diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpVcarveEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpVcarveEdit.ui index a4df0aed4c..be95da54d9 100644 --- a/src/Mod/CAM/Gui/Resources/panels/PageOpVcarveEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/PageOpVcarveEdit.ui @@ -6,8 +6,8 @@ 0 0 - 400 - 197 + 739 + 379 @@ -55,65 +55,109 @@ - - - - - - - - - Discretization Deflection - - - - - - - This value is used in discretizing arcs into segments. Smaller values will result in larger gcode. Larger values may cause unwanted segments in the medial line path. - - - 3 - - - 0.001000000000000 - - - 1.000000000000000 - - - 0.010000000000000 - - - 0.010000000000000 - - - - - - - - - - Filter Colinear lines - - - - - - - Sets how aggressively colinear segments are filtered from the Voronoi diagram. Valid values are 0 - 90 degrees (larger numbers filter more). Default = 10 - - - 90 - - - 10 - - - - - + + + + + + + + Discretization Deflection + + + + + + + This value is used in discretizing arcs into segments. Smaller values will result in larger gcode. Larger values may cause unwanted segments in the medial line path. + + + 3 + + + 0.001000000000000 + + + 1.000000000000000 + + + 0.010000000000000 + + + 0.010000000000000 + + + + + + + + + + Filter Colinear lines + + + + + + + Sets how aggressively colinear segments are filtered from the Voronoi diagram. Valid values are 0 - 90 degrees (larger numbers filter more). Default = 10 + + + 90 + + + 10 + + + + + + + Finishing pass Z offset + + + + + + + Endmill offset for the finishing pass run. Use small value like -0.2 mm to help clean "fuzzy skin" or other artefacts. + + + + + + 0.100000000000000 + + + + + + + true + + + After carving travel again the path to remove artifacts and imperfections + + + + + + Finishing pass + + + + + + + Optimize path to avoid raising endmill when moving to adjacent edges. May result in sub-millimeter inaccuracies. + + + Optimize movements + + + + @@ -130,6 +174,13 @@ + + + Gui::QuantitySpinBox + QWidget +
Gui/QuantitySpinBox.h
+
+
diff --git a/src/Mod/CAM/Path/Log.py b/src/Mod/CAM/Path/Log.py index 0f2b436c1c..e33cbc1282 100644 --- a/src/Mod/CAM/Path/Log.py +++ b/src/Mod/CAM/Path/Log.py @@ -102,6 +102,7 @@ def _caller(): def _log(level, module_line_func, msg): """internal function to do the logging""" module, line, func = module_line_func + if getLevel(module) >= level: message = "%s.%s: %s" % (module, Level.toString(level), msg) if _useConsole: @@ -122,9 +123,10 @@ def _log(level, module_line_func, msg): def debug(msg): """(message)""" - module, line, func = _caller() + caller_info = _caller() + _, line, _ = caller_info msg = "({}) - {}".format(line, msg) - return _log(Level.DEBUG, _caller(), msg) + return _log(Level.DEBUG, caller_info, msg) def info(msg): diff --git a/src/Mod/CAM/Path/Op/Base.py b/src/Mod/CAM/Path/Op/Base.py index 25726db074..2600d4edda 100644 --- a/src/Mod/CAM/Path/Op/Base.py +++ b/src/Mod/CAM/Path/Op/Base.py @@ -463,6 +463,15 @@ class ObjectOp(object): QT_TRANSLATE_NOOP("App::Property", "Operations Cycle Time Estimation"), ) + if FeatureStepDown & features and not hasattr(obj, "StepDown"): + obj.addProperty( + "App::PropertyDistance", + "StepDown", + "Depth", + QT_TRANSLATE_NOOP("App::Property", "Incremental Step Down of Tool"), + ) + obj.StepDown = 0 + self.setEditorModes(obj, features) self.opOnDocumentRestored(obj) diff --git a/src/Mod/CAM/Path/Op/Gui/Vcarve.py b/src/Mod/CAM/Path/Op/Gui/Vcarve.py index 1c275e533a..ef6e8818d1 100644 --- a/src/Mod/CAM/Path/Op/Gui/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Gui/Vcarve.py @@ -25,6 +25,8 @@ import FreeCADGui import Path import Path.Op.Gui.Base as PathOpGui import Path.Op.Vcarve as PathVcarve +import Path.Base.Gui.Util as PathGuiUtil + import PathGui import PathScripts.PathUtils as PathUtils from PySide import QtCore, QtGui @@ -35,6 +37,7 @@ __author__ = "sliptonic (Brad Collette)" __url__ = "https://www.freecad.org" __doc__ = "Vcarve operation page controller and command implementation." +# There is a bug in logging library. To enable debugging - set True also in Op/Vcarve.py if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -124,9 +127,37 @@ class TaskPanelBaseGeometryPage(PathOpGui.TaskPanelBaseGeometryPage): class TaskPanelOpPage(PathOpGui.TaskPanelPage): """Page controller class for the Vcarve operation.""" + def initPage(self, obj): + self.finishingPassZOffsetSpinBox = PathGuiUtil.QuantitySpinBox( + self.form.finishingPassZOffset, obj, "FinishingPassZOffset" + ) + def getForm(self): """getForm() ... returns UI""" - return FreeCADGui.PySideUic.loadUi(":/panels/PageOpVcarveEdit.ui") + form = FreeCADGui.PySideUic.loadUi(":/panels/PageOpVcarveEdit.ui") + self.updateFormConditionalState(form) + return form + + def updateFormConditionalState(self, form): + """ + Update conditional form controls - i.e settings that should be + visible only under certain conditions (other settings enabled, etc). + """ + + if form.finishingPassEnabled.isChecked(): + form.finishingPassZOffset.setVisible(True) + form.finishingPassZOffsetLabel.setVisible(True) + else: + form.finishingPassZOffset.setVisible(False) + form.finishingPassZOffsetLabel.setVisible(False) + + def updateFormCallback(self): + return self.updateFormConditionalState(self.form) + + def registerSignalHandlers(self, obj): + """Register signal handlers to update conditiona UI states""" + + self.form.finishingPassEnabled.stateChanged.connect(self.updateFormCallback) def getFields(self, obj): """getFields(obj) ... transfers values from UI to obj's properties""" @@ -134,6 +165,15 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): obj.Discretize = self.form.discretize.value() if obj.Colinear != self.form.colinearFilter.value(): obj.Colinear = self.form.colinearFilter.value() + + if obj.FinishingPass != self.form.finishingPassEnabled.isChecked(): + obj.FinishingPass = self.form.finishingPassEnabled.isChecked() + + if obj.OptimizeMovements != self.form.optimizeMovementsEnabled.isChecked(): + obj.OptimizeMovements = self.form.optimizeMovementsEnabled.isChecked() + + self.finishingPassZOffsetSpinBox.updateProperty() + self.updateToolController(obj, self.form.toolController) self.updateCoolant(obj, self.form.coolantController) @@ -141,14 +181,27 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): """setFields(obj) ... transfers obj's property values to UI""" self.form.discretize.setValue(obj.Discretize) self.form.colinearFilter.setValue(obj.Colinear) + self.form.finishingPassEnabled.setChecked(obj.FinishingPass) + self.form.optimizeMovementsEnabled.setChecked(obj.OptimizeMovements) + + self.finishingPassZOffsetSpinBox.updateSpinBox() + self.setupToolController(obj, self.form.toolController) self.setupCoolant(obj, self.form.coolantController) + self.updateFormConditionalState(self.form) + def getSignalsForUpdate(self, obj): """getSignalsForUpdate(obj) ... return list of signals for updating obj""" signals = [] signals.append(self.form.discretize.editingFinished) signals.append(self.form.colinearFilter.editingFinished) + signals.append(self.form.finishingPassEnabled.stateChanged) + signals.append(self.form.finishingPassZOffset.editingFinished) + + signals.append(self.form.optimizeMovementsEnabled.stateChanged) + + signals.append(self.form.toolController.currentIndexChanged) signals.append(self.form.coolantController.currentIndexChanged) return signals diff --git a/src/Mod/CAM/Path/Op/Vcarve.py b/src/Mod/CAM/Path/Op/Vcarve.py index 5e81e20d18..f1b1230002 100644 --- a/src/Mod/CAM/Path/Op/Vcarve.py +++ b/src/Mod/CAM/Path/Op/Vcarve.py @@ -29,7 +29,6 @@ import PathScripts.PathUtils as PathUtils import math from PySide.QtCore import QT_TRANSLATE_NOOP - from PySide import QtCore __doc__ = "Class and implementation of CAM Vcarve operation" @@ -42,6 +41,8 @@ COLINEAR = 4 TWIN = 5 BORDERLINE = 6 +# There is a bug in logging library. To enable debugging - set True also in Gui/Vcarve.py + if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) @@ -50,7 +51,6 @@ else: translate = FreeCAD.Qt.translate -_sorting = "global" def _collectVoronoiWires(vd): @@ -150,13 +150,50 @@ def _sortVoronoiWires(wires, start=FreeCAD.Vector(0, 0, 0)): class _Geometry(object): """POD class so the limits only have to be calculated once.""" - def __init__(self, zStart, zStop, zScale): + def __init__(self, zStart, zStop, zScale, zStepDown): self.start = zStart self.stop = zStop self.scale = zScale + self.stepDown = zStepDown + self.stepDownPass = 1 + + # offset is used in finishing passes to override + # any calculated vcarving depths. Usually going deeper 0.1-0.2 mm on finishing pass can help + # remove "fuzzy skin" or other imperfections. + self.offset = 0 + + def incrementStepDownDepth(self, maximumUsableDepth): + """ + Increase stepDown depth before staring new carving pass. + :returns: True if successful, False if maximum depth achieved + """ + + # do not allow to increase depth if we are already at stop depth + if self.maximumDepth == self.stop: + return False + + # do not allow to increase depth if we are already at + # maximum usable depth + + if self.maximumDepth <= maximumUsableDepth: + return False + + self.stepDownPass += 1 + return True + + @property + def maximumDepth(self): + """ + Return maximum vcarving depth computed from step down setting and pass number + """ + + if self.stepDown == 0: + return self.stop + + return max(self.stop, self.start - (self.stepDownPass * self.stepDown)) @classmethod - def FromTool(cls, tool, zStart, zFinal): + def FromTool(cls, tool, zStart, zFinal, zStepDown=0): rMax = float(tool.Diameter) / 2.0 rMin = float(tool.TipDiameter) / 2.0 toolangle = math.tan(math.radians(tool.CuttingEdgeAngle.Value / 2.0)) @@ -164,32 +201,65 @@ class _Geometry(object): zStop = zStart - rMax * zScale zOff = rMin * zScale - return _Geometry(zStart + zOff, max(zStop + zOff, zFinal), zScale) + return _Geometry(zStart + zOff, max(zStop + zOff, zFinal), zScale, zStepDown) @classmethod def FromObj(cls, obj, model): zStart = model.Shape.BoundBox.ZMax finalDepth = obj.FinalDepth.Value + stepDown = abs(obj.StepDown.Value) - return cls.FromTool(obj.ToolController.Tool, zStart, finalDepth) + return cls.FromTool(obj.ToolController.Tool, zStart, finalDepth, stepDown) def _calculate_depth(MIC, geom): # given a maximum inscribed circle (MIC) and tool angle, # return depth of cut relative to zStart. depth = geom.start - round(MIC * geom.scale, 4) - Path.Log.debug("zStart value: {} depth: {}".format(geom.start, depth)) - return max(depth, geom.stop) + return max(depth, geom.maximumDepth) + geom.offset -def _getPartEdge(edge, depths): +def _get_maximumUsableDepth(wires, geom): + """ + Calculate maximum engraving depth for a list of wires + belonging to one face. + """ + + def _get_depth(MIC, geom): + """Similar logic to _calculate_depth but without stepdown and offset calculations""" + depth = geom.start - round(MIC * geom.scale, 4) + return max(depth, geom.stop) + + min_depth = None + + for wire in wires: + for edge in wire: + dist = edge.getDistances() + depth = min(_get_depth(dist[0], geom), _get_depth(dist[1], geom)) + + if min_depth is None: + min_depth = depth + else: + min_depth = min(min_depth, depth) + + return min_depth + + +def _getPartEdge(edge, geom): dist = edge.getDistances() - zBegin = _calculate_depth(dist[0], depths) - zEnd = _calculate_depth(dist[1], depths) + zBegin = _calculate_depth(dist[0], geom) + zEnd = _calculate_depth(dist[1], geom) return edge.toShape(zBegin, zEnd) +def _getPartEdges(obj, vWire, geom): + edges = [] + for e in vWire: + edges.append(_getPartEdge(e, geom)) + return edges + + class ObjectVcarve(PathEngraveBase.ObjectOp): """Proxy class for Vcarve operation.""" @@ -199,6 +269,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): PathOp.FeatureTool | PathOp.FeatureHeights | PathOp.FeatureDepths + | PathOp.FeatureStepDown | PathOp.FeatureBaseFaces | PathOp.FeatureCoolant ) @@ -215,6 +286,30 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): ) obj.setEditorMode("BaseShapes", 2) # hide + obj.addProperty( + "App::PropertyBool", + "OptimizeMovements", + "Path", + QT_TRANSLATE_NOOP("App::Property", "Optimize movements"), + ) + + obj.addProperty( + "App::PropertyBool", + "FinishingPass", + "Path", + QT_TRANSLATE_NOOP("App::Property", "Add finishing pass"), + ) + + obj.addProperty( + "App::PropertyDistance", + "FinishingPassZOffset", + "Path", + QT_TRANSLATE_NOOP("App::Property", "Finishing pass Z offset"), + ) + + obj.FinishingPass = False + obj.FinishingPassZOffset = "0.00" + def initOperation(self, obj): """initOperation(obj) ... create vcarve specific properties.""" obj.addProperty( @@ -241,6 +336,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): "Path", QT_TRANSLATE_NOOP("App::Property", "Vcarve Tolerance"), ) + obj.Colinear = 10.0 obj.Discretize = 0.01 obj.Tolerance = Path.Preferences.defaultGeometryTolerance() @@ -250,14 +346,13 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): # upgrade ... self.setupAdditionalProperties(obj) - def _getPartEdges(self, obj, vWire, geom): - edges = [] - for e in vWire: - edges.append(_getPartEdge(e, geom)) - return edges + def buildMedialWires(self, obj, faces): + """ + constructs a medial axis path using openvoronoi + :returns: dictionary - each face object is a key containing list of wires""" - def buildPathMedial(self, obj, faces): - """constructs a medial axis path using openvoronoi""" + wires_by_face = dict() + self.voronoiDebugCache = dict() def insert_many_wires(vd, wires): for wire in wires: @@ -276,34 +371,18 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): dist = ptv[-1].distanceToPoint(ptv[0]) if dist < FreeCAD.Base.Precision.confusion(): Path.Log.debug( - "Removing bad carve point: {} from polygon origin" - .format(dist)) + "Removing bad carve point: {} from polygon origin".format( + dist + ) + ) del ptv[-1] ptv.append(ptv[0]) - for i in range(len(ptv)-1): + for i in range(len(ptv) - 1): vd.addSegment(ptv[i], ptv[i + 1]) - def cutWire(edges): - path = [] - path.append(Path.Command("G0 Z{}".format(obj.SafeHeight.Value))) - e = edges[0] - p = e.valueAt(e.FirstParameter) - path.append( - Path.Command("G0 X{} Y{} Z{}".format(p.x, p.y, obj.SafeHeight.Value)) - ) - hSpeed = obj.ToolController.HorizFeed.Value - vSpeed = obj.ToolController.VertFeed.Value - path.append( - Path.Command("G1 X{} Y{} Z{} F{}".format(p.x, p.y, p.z, vSpeed)) - ) - for e in edges: - path.extend(Path.Geom.cmdsForEdge(e, hSpeed=hSpeed, vSpeed=vSpeed)) - - return path - - voronoiWires = [] for f in faces: + voronoiWires = [] vd = Path.Voronoi.Diagram() insert_many_wires(vd, f.Wires) @@ -328,28 +407,136 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): vd.colorTwins(TWIN) wires = _collectVoronoiWires(vd) - if _sorting != "global": - wires = _sortVoronoiWires(wires) + wires = _sortVoronoiWires(wires) voronoiWires.extend(wires) - if _sorting == "global": - voronoiWires = _sortVoronoiWires(voronoiWires) + wires_by_face[f] = voronoiWires + self.voronoiDebugCache = wires_by_face - geom = _Geometry.FromObj(obj, self.model[0]) + return wires_by_face + + def buildCommandList(self, obj, faces): + """ + Build command list to cut wires - based on voronoi + wire list from buildMedialWires + """ + + def getCurrentPosition(wire): + """ + Calculate CNC head position assuming it reached the end of the wire + """ + + if not wire: + return None + + lastEdge = wire[-1] + return lastEdge.valueAt(lastEdge.LastParameter) + + def cutWires(wires, pathlist, optimizeMovements=False): + currentPosition = None + for w in wires: + pWire = _getPartEdges(obj, w, geom) + if pWire: + pathlist.extend(_cutWire(pWire, currentPosition)) + + # movement optimization only works if we provide current head position + if optimizeMovements: + currentPosition = getCurrentPosition(pWire) + + def canSkipRepositioning(currentPosition, newPosition): + """ + Calculate if it makes sense to raise head to safe height and reposition before + starting to cut another edge + """ + + if not currentPosition: + return False + + # get vertex position on X/Y plane only + v0 = FreeCAD.Base.Vector(currentPosition.x, currentPosition.y) + v1 = FreeCAD.Base.Vector(newPosition.x, newPosition.y) + + return v0.distanceToPoint(v1) <= 0.5 + + def _cutWire(wire, currentPosition=None): + path = [] + + e = wire[0] + newPosition = e.valueAt(e.FirstParameter) + + # raise and reposition the head only if new wire starts further than 0.5 mm + # from current head position + if not canSkipRepositioning(currentPosition, newPosition): + path.append(Path.Command("G0 Z{}".format(obj.SafeHeight.Value))) + path.append( + Path.Command( + "G0 X{} Y{} Z{}".format( + newPosition.x, newPosition.y, obj.SafeHeight.Value + ) + ) + ) + + hSpeed = obj.ToolController.HorizFeed.Value + vSpeed = obj.ToolController.VertFeed.Value + path.append( + Path.Command( + "G1 X{} Y{} Z{} F{}".format( + newPosition.x, newPosition.y, newPosition.z, vSpeed + ) + ) + ) + for e in wire: + path.extend(Path.Geom.cmdsForEdge(e, hSpeed=hSpeed, vSpeed=vSpeed)) + + return path pathlist = [] pathlist.append(Path.Command("(starting)")) - for w in voronoiWires: - pWire = self._getPartEdges(obj, w, geom) - if pWire: - wires.append(pWire) - pathlist.extend(cutWire(pWire)) + + # iterate over each face separatedly + for face, wires in self.buildMedialWires(obj, faces).items(): + + geom = _Geometry.FromObj(obj, self.model[0]) + + # If using depth step-down, calculate maximum usable depth for current face. + # This is done to avoid adding additional step-down engraving passes when it + # would make no sense as depth is limited by Maximum Inscribed Circle anyway. + + maximumUsableDepth = geom.stop + + if geom.stepDown > 0: + _maximumUsableDepth = _get_maximumUsableDepth(wires, geom) + if _maximumUsableDepth is not None: + maximumUsableDepth = _maximumUsableDepth + Path.Log.debug( + f"Maximum usable depth for current face: {maximumUsableDepth}" + ) + + # first pass + cutWires(wires, pathlist, obj.OptimizeMovements) + + # subsequent stepDown depth passes (if any) + while geom.incrementStepDownDepth(maximumUsableDepth): + cutWires(wires, pathlist, obj.OptimizeMovements) + + # add finishing pass if enabled + + # if obj.FinishingPass: + # geom.offset = obj.FinishingPassZOffset.Value + + # for w in wires: + # pWire = self._getPartEdges(obj, w, geom) + # if pWire: + # pathlist.extend(cutWire(pWire)) + self.commandlist = pathlist def opExecute(self, obj): """opExecute(obj) ... process engraving operation""" Path.Log.track() + self.voronoiDebugCache = None + if not hasattr(obj.ToolController.Tool, "CuttingEdgeAngle"): Path.Log.error( translate( @@ -386,7 +573,7 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): faces.extend(model.Shape.Faces) if faces: - self.buildPathMedial(obj, faces) + self.buildCommandList(obj, faces) else: Path.Log.error( translate( @@ -399,6 +586,9 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): Path.Log.error( "Error processing Base object. Engraving operation will produce no output." ) + import traceback + + Path.Log.error(f"Engraving operation exception: {traceback.format_exc()}") def opUpdateDepths(self, obj, ignoreErrors=False): """updateDepths(obj) ... engraving is always done at the top most z-value""" @@ -423,6 +613,38 @@ class ObjectVcarve(PathEngraveBase.ObjectOp): and hasattr(tool, "TipDiameter") ) + def debugVoronoi(self, obj): + """Debug function to display calculated voronoi edges""" + + if not getattr(self, "voronoiDebugCache", None): + Path.Log.error( + "debugVoronoi: empty debug cache. Recompute VCarve operation first" + ) + return + + vPart = FreeCAD.activeDocument().addObject( + "App::Part", f"{obj.Name}-VoronoiDebug" + ) + + wiresToShow = [] + + for face, wires in self.voronoiDebugCache.items(): + for wire in wires: + lastEdge = None + currentPartWire = Part.Wire() + currentPartWire.fixTolerance(0.01) + for edge in wire: + currentEdge = edge.toShape() + + for v in currentEdge.Vertexes: + v.fixTolerance(0.1) + + currentPartWire.add(currentEdge) + wiresToShow.append(currentPartWire) + + for w in wiresToShow: + vPart.addObject(Part.show(w)) + def SetupProperties(): return ["Discretize"] diff --git a/src/Mod/CAM/Tests/TestPathVcarve.py b/src/Mod/CAM/Tests/TestPathVcarve.py index c91e5ecb95..e66a461162 100644 --- a/src/Mod/CAM/Tests/TestPathVcarve.py +++ b/src/Mod/CAM/Tests/TestPathVcarve.py @@ -111,3 +111,37 @@ class TestPathVcarve(PathTestBase): self.assertRoughly(geom.start, Scale45) self.assertRoughly(geom.stop, -3) self.assertRoughly(geom.scale, Scale45) + + def test14(self): + """Verify if max dept is calculated properly when step-down is disabled""" + + tool = VbitTool(10, 45, 2) + geom = PathVcarve._Geometry.FromTool(tool, zStart=0, zFinal=-3, zStepDown=0) + + self.assertEqual(geom.maximumDepth, -3) + self.assertEqual(geom.maximumDepth, geom.stop) + + def test15(self): + """Verify if step-down sections match final max depth""" + + tool = VbitTool(10, 45, 2) + geom = PathVcarve._Geometry.FromTool(tool, zStart=0, zFinal=-3, zStepDown=0.13) + + while geom.incrementStepDownDepth(maximumUsableDepth=-3): + pass + + self.assertEqual(geom.maximumDepth, -3) + + def test16(self): + """Verify 90 deg with tip dia depth calculation with step-down enabled""" + tool = VbitTool(10, 90, 2) + geom = PathVcarve._Geometry.FromTool(tool, zStart=0, zFinal=-10, zStepDown=0.13) + + while geom.incrementStepDownDepth(maximumUsableDepth=-10): + pass + + # in order for the width to be correct the height needs to be shifted + self.assertRoughly(geom.start, 1) + self.assertRoughly(geom.stop, -4) + self.assertRoughly(geom.scale, 1) + self.assertRoughly(geom.maximumDepth, -4)