From 31ca3e742f2d32c0ca31b2a15e3f0482ebea3dca Mon Sep 17 00:00:00 2001 From: Dan Taylor Date: Wed, 2 Apr 2025 20:47:44 -0500 Subject: [PATCH] CAM: Adaptive: Add Z stock to leave (separate from XY stock to leave) and order-by-region/order-by-depth cut ordering options --- src/Mod/CAM/CAMTests/TestPathAdaptive.py | 61 ++++ .../Resources/panels/PageOpAdaptiveEdit.ui | 294 ++++++++++-------- src/Mod/CAM/Path/Op/Adaptive.py | 199 ++++++++++-- src/Mod/CAM/Path/Op/Gui/Adaptive.py | 11 + 4 files changed, 411 insertions(+), 154 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestPathAdaptive.py b/src/Mod/CAM/CAMTests/TestPathAdaptive.py index 70d72ac086..a544de3553 100644 --- a/src/Mod/CAM/CAMTests/TestPathAdaptive.py +++ b/src/Mod/CAM/CAMTests/TestPathAdaptive.py @@ -463,6 +463,67 @@ class TestPathAdaptive(PathTestBase): self.assertTrue(okAt10 and okAt5 and okAt0, "Path boundaries outside of expected regions") + def test09(self): + """test09() Tests Z stock to leave- with 1mm Z stock to leave, machining + at the top of the model should not touch the top model face""" + # Instantiate a Adaptive operation and set Base Geometry + adaptive = PathAdaptive.Create("Adaptive") + adaptive.Base = [(self.doc.Fusion, ["Face3", "Face10"])] # (base, subs_list) + adaptive.Label = "test09+" + adaptive.Comment = "test09() Verify Z stock is left as requested" + + # Set additional operation properties + setDepthsAndHeights(adaptive, 15, 10) + adaptive.FinishingProfile = False + adaptive.HelixAngle = 75.0 + adaptive.HelixDiameterLimit.Value = 1.0 + adaptive.LiftDistance.Value = 1.0 + adaptive.StepOver = 75 + adaptive.UseOutline = False + adaptive.setExpression("StepDown", None) + adaptive.StepDown.Value = ( + 5.0 # Have to set expression to None before numerical value assignment + ) + # Add some Z stock to leave so we avoid Face3 in this stepdown at Z=10 + adaptive.setExpression("ZStockToLeave", None) + adaptive.ZStockToLeave.Value = 1 + + _addViewProvider(adaptive) + self.doc.recompute() + + # Check: + # - No feed path at depth Z=10 touchs Face3 + toolr = adaptive.OpToolDiameter.Value / 2 + tol = adaptive.Tolerance + + # Make clean up math below- combine tool radius and tolerance into a + # single field that can be added/subtracted to/from bounding boxes + moffset = toolr - tol + + # Offset the face we don't expect to touch, verify no move is within + # that boundary + # NOTE: This isn't a perfect test (won't catch moves that start and end + # outside of our face, but cut through/across it), but combined with + # other tests should be sufficient. + noPathTouchesFace3 = True + foffset = self.doc.Fusion.Shape.getElement("Face3").makeOffset2D(moffset) + # NOTE: Face3 is at Z=10, and the only feed moves will be at Z=10 + lastpt = FreeCAD.Vector(0, 0, 10) + for p in [c.Parameters for c in adaptive.Path.Commands if c.Name in ["G1", "G01"]]: + pt = FreeCAD.Vector(lastpt) + if "X" in p: + pt.x = p.get("X") + if "Y" in p: + pt.x = p.get("Y") + + if foffset.isInside(pt, 0.001, True): + noPathTouchesFace3 = False + break + + lastpt = pt + + self.assertTrue(noPathTouchesFace3, "No feed moves within the top face.") + # Eclass diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui index 08db3a609f..91ae04896c 100644 --- a/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui @@ -43,103 +43,19 @@ - QFrame::StyledPanel + QFrame::Shape::StyledPanel - QFrame::Raised + QFrame::Shadow::Raised - - - - Use Outline - - - - - - - Keep Tool Down Ratio - - - - - - - Helix Max Diameter - - - - - - - Type of adaptive operation - - - - - - - Cut Region - - - - - - - Step Over Percent - - - - - - - Force Clearing Inside-out - - - - - - - Lift Distance - - - - - - - Stock to Leave - - - - - - - Operation Type - - - - - - - Helix Ramp Angle - - - - - - - Finishing Profile - - - - QFrame::StyledPanel + QFrame::Shape::StyledPanel - QFrame::Raised + QFrame::Shadow::Raised @@ -152,7 +68,9 @@ - Influences calculation performance vs stability and accuracy + Influences calculation performance vs stability and accuracy. + +Larger values (further to the right) will calculate faster; smaller values (further to the left) will result in more accurate toolpaths. 5 @@ -167,7 +85,7 @@ 10 - Qt::Horizontal + Qt::Orientation::Horizontal 1 @@ -177,6 +95,13 @@ + + + + Force Clearing Inside-out + + + @@ -184,53 +109,37 @@ - - + + + + Type of adaptive operation + + + + + - Helix Cone Angle - - - - - - - If greater than zero it limits the helix ramp diameter, otherwise 75 percent of tool diameter is used - - - - - - - - - - How much to lift the tool up during the rapid linking moves over cleared regions. If linking path is not clear tool is raised to clearance height. - - - - - - - - - - Max length of keep-tool-down linking path compared to direct distance between points. If exceeded link will be done by raising the tool to clearance height. - - - + Finishing Profile - + - How much material to leave (i.e. for finishing operation) + How much material to leave in the XY plane (i.e. for finishing operation) + + + + XY Stock to Leave + + + @@ -250,13 +159,10 @@ - - - - Angle of the helix ramp entry - - - + + + + Step Over Percent @@ -270,6 +176,128 @@ + + + + Helix Ramp Angle + + + + + + + Use Outline + + + + + + + Operation Type + + + + + + + How much to lift the tool up during the rapid linking moves over cleared regions. If linking path is not clear tool is raised to clearance height. + + + + + + + + + + Keep Tool Down Ratio + + + + + + + If greater than zero it limits the helix ramp diameter, otherwise 75 percent of tool diameter is used + + + + + + + + + + Helix Cone Angle + + + + + + + Angle of the helix ramp entry + + + + + + + + + + Lift Distance + + + + + + + Cut Region + + + + + + + Max length of keep-tool-down linking path compared to direct distance between points. If exceeded link will be done by raising the tool to clearance height. + + + + + + + + + + Helix Max Diameter + + + + + + + After calculating toolpaths, the default cut order is by depth- all regions at a given stepdown are cleared before moving to the next stepdown. + +This option changes that behavior to cut each discrete area to its full depth before moving on to the next. + + + Order cuts by region + + + + + + + Z Stock to Leave + + + + + + + How much material to leave along the Z axis (i.e. for finishing operation) + + + @@ -283,7 +311,7 @@ - Qt::Vertical + Qt::Orientation::Vertical diff --git a/src/Mod/CAM/Path/Op/Adaptive.py b/src/Mod/CAM/Path/Op/Adaptive.py index 75d3b62aa5..51c7d8b656 100644 --- a/src/Mod/CAM/Path/Op/Adaptive.py +++ b/src/Mod/CAM/Path/Op/Adaptive.py @@ -628,6 +628,8 @@ def Execute(op, obj): { "opType": outsideOpType, "path2d": convertTo2d(rdict["edges"]), + "id": rdict["id"], + "children": rdict["children"], # FIXME: Kinda gross- just use this to match up with the # appropriate stockpaths entry... "startdepth": rdict["depths"][0], @@ -641,6 +643,8 @@ def Execute(op, obj): { "opType": insideOpType, "path2d": convertTo2d(rdict["edges"]), + "id": rdict["id"], + "children": rdict["children"], # FIXME: Kinda gross- just use this to match up with the # appropriate stockpaths entry... "startdepth": rdict["depths"][0], @@ -668,6 +672,8 @@ def Execute(op, obj): "finishingProfile": obj.FinishingProfile, "keepToolDownRatio": keepToolDownRatio, "stockToLeave": obj.StockToLeave.Value, + "zStockToLeave": obj.ZStockToLeave.Value, + "orderCutsByRegion": obj.OrderCutsByRegion, } insideInputStateObject = { @@ -683,6 +689,8 @@ def Execute(op, obj): "finishingProfile": obj.FinishingProfile, "keepToolDownRatio": keepToolDownRatio, "stockToLeave": obj.StockToLeave.Value, + "zStockToLeave": obj.ZStockToLeave.Value, + "orderCutsByRegion": obj.OrderCutsByRegion, } inputStateObject = [outsideInputStateObject, insideInputStateObject] @@ -740,6 +748,7 @@ def Execute(op, obj): a2d.toolDiameter = op.tool.Diameter.Value a2d.helixRampDiameter = helixDiameter a2d.keepToolDownDistRatio = keepToolDownRatio + # NOTE: Z stock is handled in our stepdowns a2d.stockToLeave = obj.StockToLeave.Value a2d.tolerance = obj.Tolerance a2d.forceInsideOut = obj.ForceInsideOut @@ -762,9 +771,25 @@ def Execute(op, obj): for t in alltuples: depths += [d for d in t[0]] depths = sorted(list(set(depths)), reverse=True) - for d in depths: - cutlist += [([d], o[1]) for o in outsidePathArray2dDepthTuples if d in o[0]] - cutlist += [([d], i[1]) for i in insidePathArray2dDepthTuples if d in i[0]] + if obj.OrderCutsByRegion: + # Translate child ID numbers to an actual reference to the + # associated tuple + for rdict in regionOps: + rdict["childTuples"] = [t for t in alltuples if t[1]["id"] in rdict["children"]] + + # Helper function to recurse down children + def addToCutList(tuples): + for k in tuples: + if k in cutlist: + continue + cutlist.append(k) + addToCutList(k[1]["childTuples"]) + + addToCutList(alltuples) + else: + for d in depths: + cutlist += [([d], o[1]) for o in outsidePathArray2dDepthTuples if d in o[0]] + cutlist += [([d], i[1]) for i in insidePathArray2dDepthTuples if d in i[0]] # need to convert results to python object to be JSON serializable stepdown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN) @@ -972,7 +997,9 @@ def _workingEdgeHelperManual(op, obj, depths): lastdepth = depth continue - aboveRefined = _getSolidProjection(shps, depth) + # NOTE: Slice stock lower than cut depth to effectively leave (at least) + # obj.ZStockToLeave + aboveRefined = _getSolidProjection(shps, depth - obj.ZStockToLeave.Value) # Create appropriate tuples and add to list, processing inside/outside # as requested by operation @@ -1079,10 +1106,14 @@ def _getWorkingEdges(op, obj): # Get the stock outline at each stepdown. Used to calculate toolpaths and # for calcuating cut regions in some instances + # NOTE: Slice stock lower than cut depth to effectively leave (at least) + # obj.ZStockToLeave # NOTE: Stock is handled DIFFERENTLY than inside and outside regions! # Combining different depths just adds code to look up the correct outline # when computing inside/outside regions, for no real benefit. - stockProjectionDict = {d: _getSolidProjection(op.stock.Shape, d) for d in depths} + stockProjectionDict = { + d: _getSolidProjection(op.stock.Shape, d - obj.ZStockToLeave.Value) for d in depths + } # If user specified edges, calculate the machining regions based on that # input. Otherwise, process entire model @@ -1092,6 +1123,101 @@ def _getWorkingEdges(op, obj): # to be avoided at those depths. insideRegions, outsideRegions = _workingEdgeHelperManual(op, obj, depths) + # Find all children of each region. A child of region X is any region Y such + # that Y is a subset of X AND Y starts within one stepdown of X (ie, direct + # children only). + # NOTE: Inside and outside regions are inverses of each other, so above + # refers to the area to be machined! + + # Assign an ID number to track each region + idnumber = 0 + for r in insideRegions + outsideRegions: + r["id"] = idnumber + r["children"] = list() + idnumber += 1 + + # NOTE: Inside and outside regions are inverses of each other + # NOTE: Outside regions can't have parents + for rx in insideRegions: + for ry in [k for k in insideRegions if k != rx]: + dist = min(rx["depths"]) - max(ry["depths"]) + # Ignore regions at our level or above, or more than one step down + if dist <= 0 or dist > depthParams.step_down: + continue + if not ry["region"].cut(rx["region"]).Wires: + rx["children"].append(ry["id"]) + # See which outside region this is a child of- basically inverse of above + for ry in [k for k in outsideRegions]: + dist = min(ry["depths"]) - max(rx["depths"]) + # Ignore regions at our level or above, or more than one step down + if dist <= 0 or dist > depthParams.step_down: + continue + # child if there is NO overlap between the stay-outside and stay- + # inside regions + # Also a child if the outer region is NULL (includes everything) + # NOTE: See "isNull() note" at top of file + if not ry["region"].Wires or not rx["region"].common(ry["region"]).Wires: + ry["children"].append(rx["id"]) + + # Further split regions as necessary for when the stock changes- a region as + # reported here is where a toolpath will be generated, and can be projected + # along all of the depths associated with it. By doing this, we can minimize + # the number of toolpaths that need to be generated AND avoid more complex + # logic in depth-first vs region-first sorting of regions. + # NOTE: For internal regions, stock is "the same" if the region cut with + # the stock results in the same region. + # NOTE: For external regions, stock is "the same" if the stock cut by the + # region results in the same region + def _regionChildSplitterHelper(regions, areInsideRegions): + nonlocal stockProjectionDict + nonlocal idnumber + for r in regions: + depths = sorted(r["depths"], reverse=True) + if areInsideRegions: + rcut = r["region"].cut(stockProjectionDict[depths[0]]) + else: + # NOTE: We may end up with empty "outside" regions in the space + # between the top of the stock and the top of the model- want + # to machine the entire stock in that case + # NOTE: See "isNull() note" at top of file + if not r["region"].Wires: + rcut = stockProjectionDict[depths[0]] + else: + rcut = stockProjectionDict[depths[0]].cut(r["region"]) + parentdepths = depths[0:1] + # If the region cut with the stock at a new depth is different than + # the original cut, we need to split this region + # The new region gets all of the children, and becomes a child of + # the existing region. + for d in depths[1:]: + if ( + areInsideRegions and r["region"].cut(stockProjectionDict[d]).cut(rcut).Wires + ) or stockProjectionDict[d].cut(r["region"]).cut(rcut).Wires: + newregion = { + "id": idnumber, + "depths": [k for k in depths if k not in parentdepths], + "region": r["region"], + "children": r["children"], + } + # Update parent with the new region as a child, along with all + # the depths it was unchanged on + r["children"] = [idnumber] + r["depths"] = parentdepths + + # Add the new region to the end of the list and stop processing + # this region + # When the new region is processed at the end, we'll effectively + # recurse and handle splitting that new region if required + regions.append(newregion) + idnumber += 1 + continue + # If we didn't split at this depth, the parent will keep "control" + # of this depth + parentdepths.append(d) + + _regionChildSplitterHelper(insideRegions, True) + _regionChildSplitterHelper(outsideRegions, False) + # Create discretized regions def _createDiscretizedRegions(regionDicts): discretizedRegions = list() @@ -1100,6 +1226,8 @@ def _getWorkingEdges(op, obj): { "edges": [[discretize(w)] for w in rdict["region"].Wires], "depths": rdict["depths"], + "id": rdict["id"], + "children": rdict["children"], } ) return discretizedRegions @@ -1190,11 +1318,6 @@ class PathAdaptive(PathOp.ObjectOp): "Side of selected faces that tool should cut", ), ) - # obj.Side = [ - # "Outside", - # "Inside", - # ] # side of profile that cutter is on in relation to direction of profile - obj.addProperty( "App::PropertyEnumeration", "OperationType", @@ -1204,11 +1327,6 @@ class PathAdaptive(PathOp.ObjectOp): "Type of adaptive operation", ), ) - # obj.OperationType = [ - # "Clearing", - # "Profiling", - # ] # side of profile that cutter is on in relation to direction of profile - obj.addProperty( "App::PropertyFloat", "Tolerance", @@ -1251,7 +1369,16 @@ class PathAdaptive(PathOp.ObjectOp): "Adaptive", QT_TRANSLATE_NOOP( "App::Property", - "How much stock to leave (i.e. for finishing operation)", + "How much stock to leave in the XY plane (eg for finishing operation)", + ), + ) + obj.addProperty( + "App::PropertyDistance", + "ZStockToLeave", + "Adaptive", + QT_TRANSLATE_NOOP( + "App::Property", + "How much stock to leave along the Z axis (eg for finishing operation)", ), ) obj.addProperty( @@ -1279,7 +1406,6 @@ class PathAdaptive(PathOp.ObjectOp): QT_TRANSLATE_NOOP("App::Property", "Stop processing"), ) obj.setEditorMode("Stopped", 2) # hide this property - obj.addProperty( "App::PropertyBool", "StopProcessing", @@ -1290,7 +1416,6 @@ class PathAdaptive(PathOp.ObjectOp): ), ) obj.setEditorMode("StopProcessing", 2) # hide this property - obj.addProperty( "App::PropertyBool", "UseHelixArcs", @@ -1300,7 +1425,6 @@ class PathAdaptive(PathOp.ObjectOp): "Use Arcs (G2) for helix ramp", ), ) - obj.addProperty( "App::PropertyPythonObject", "AdaptiveInputState", @@ -1348,7 +1472,6 @@ class PathAdaptive(PathOp.ObjectOp): "Limit helix entry diameter, if limit larger than tool diameter or 0, tool diameter is used", ), ) - obj.addProperty( "App::PropertyBool", "UseOutline", @@ -1358,7 +1481,15 @@ class PathAdaptive(PathOp.ObjectOp): "Uses the outline of the base geometry.", ), ) - + obj.addProperty( + "App::PropertyBool", + "OrderCutsByRegion", + "Adaptive", + QT_TRANSLATE_NOOP( + "App::Property", + "Orders cuts by region instead of depth.", + ), + ) obj.addProperty( "Part::PropertyPartShape", "removalshape", @@ -1390,9 +1521,11 @@ class PathAdaptive(PathOp.ObjectOp): obj.AdaptiveInputState = "" obj.AdaptiveOutputState = "" obj.StockToLeave = 0 + obj.ZStockToLeave = 0 obj.KeepToolDownRatio = 3.0 obj.UseHelixArcs = False obj.UseOutline = False + obj.OrderCutsByRegion = False FeatureExtensions.set_default_property_values(obj, job) def opExecute(self, obj): @@ -1427,6 +1560,28 @@ class PathAdaptive(PathOp.ObjectOp): "Uses the outline of the base geometry.", ) + if not hasattr(obj, "OrderCutsByRegion"): + obj.addProperty( + "App::PropertyBool", + "OrderCutsByRegion", + "Adaptive", + QT_TRANSLATE_NOOP( + "App::Property", + "Orders cuts by region instead of depth.", + ), + ) + + if not hasattr(obj, "ZStockToLeave"): + obj.addProperty( + "App::PropertyDistance", + "ZStockToLeave", + "Adaptive", + QT_TRANSLATE_NOOP( + "App::Property", + "How much stock to leave along the Z axis (eg for finishing operation)", + ), + ) + if not hasattr(obj, "removalshape"): obj.addProperty("Part::PropertyPartShape", "removalshape", "Path", "") obj.setEditorMode("removalshape", 2) # hide @@ -1446,6 +1601,7 @@ def SetupProperties(): "LiftDistance", "KeepToolDownRatio", "StockToLeave", + "ZStockToLeave", "ForceInsideOut", "FinishingProfile", "Stopped", @@ -1457,6 +1613,7 @@ def SetupProperties(): "HelixConeAngle", "HelixDiameterLimit", "UseOutline", + "OrderCutsByRegion", ] return setup diff --git a/src/Mod/CAM/Path/Op/Gui/Adaptive.py b/src/Mod/CAM/Path/Op/Gui/Adaptive.py index a9ae651faa..c2438dd21c 100644 --- a/src/Mod/CAM/Path/Op/Gui/Adaptive.py +++ b/src/Mod/CAM/Path/Op/Gui/Adaptive.py @@ -50,6 +50,7 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): ) self.form.KeepToolDownRatio.setProperty("unit", obj.KeepToolDownRatio.getUserPreferred()[2]) self.form.StockToLeave.setProperty("unit", obj.StockToLeave.getUserPreferred()[2]) + self.form.ZStockToLeave.setProperty("unit", obj.ZStockToLeave.getUserPreferred()[2]) def getSignalsForUpdate(self, obj): """getSignalsForUpdate(obj) ... return list of signals for updating obj""" @@ -65,10 +66,12 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): signals.append(self.form.LiftDistance.valueChanged) signals.append(self.form.KeepToolDownRatio.valueChanged) signals.append(self.form.StockToLeave.valueChanged) + signals.append(self.form.ZStockToLeave.valueChanged) signals.append(self.form.coolantController.currentIndexChanged) signals.append(self.form.ForceInsideOut.stateChanged) signals.append(self.form.FinishingProfile.stateChanged) signals.append(self.form.useOutline.stateChanged) + signals.append(self.form.orderCutsByRegion.stateChanged) signals.append(self.form.StopButton.toggled) return signals @@ -97,9 +100,13 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): if hasattr(obj, "StockToLeave"): self.form.StockToLeave.setProperty("rawValue", obj.StockToLeave.Value) + if hasattr(obj, "ZStockToLeave"): + self.form.ZStockToLeave.setProperty("rawValue", obj.ZStockToLeave.Value) + self.form.ForceInsideOut.setChecked(obj.ForceInsideOut) self.form.FinishingProfile.setChecked(obj.FinishingProfile) self.form.useOutline.setChecked(obj.UseOutline) + self.form.orderCutsByRegion.setChecked(obj.OrderCutsByRegion) self.setupToolController(obj, self.form.ToolController) self.setupCoolant(obj, self.form.coolantController) self.form.StopButton.setChecked(obj.Stopped) @@ -130,9 +137,13 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): if hasattr(obj, "StockToLeave"): PathGuiUtil.updateInputField(obj, "StockToLeave", self.form.StockToLeave) + if hasattr(obj, "ZStockToLeave"): + PathGuiUtil.updateInputField(obj, "ZStockToLeave", self.form.ZStockToLeave) + obj.ForceInsideOut = self.form.ForceInsideOut.isChecked() obj.FinishingProfile = self.form.FinishingProfile.isChecked() obj.UseOutline = self.form.useOutline.isChecked() + obj.OrderCutsByRegion = self.form.orderCutsByRegion.isChecked() obj.Stopped = self.form.StopButton.isChecked() if obj.Stopped: self.form.StopButton.setChecked(False) # reset the button