From 2ff623e6caa67caac8e7c208bc8e7cf6b8a98a8b Mon Sep 17 00:00:00 2001 From: Dan Taylor Date: Wed, 2 Apr 2025 20:55:21 -0500 Subject: [PATCH] CAM: Adaptive: Machine entire model if no faces/edges are selected ("adaptive roughing") --- src/Mod/CAM/CAMTests/TestPathAdaptive.py | 157 +++++++++++++++++++++++ src/Mod/CAM/Path/Op/Adaptive.py | 154 ++++++++++++++++++++-- 2 files changed, 300 insertions(+), 11 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestPathAdaptive.py b/src/Mod/CAM/CAMTests/TestPathAdaptive.py index a544de3553..c328296980 100644 --- a/src/Mod/CAM/CAMTests/TestPathAdaptive.py +++ b/src/Mod/CAM/CAMTests/TestPathAdaptive.py @@ -524,6 +524,163 @@ class TestPathAdaptive(PathTestBase): self.assertTrue(noPathTouchesFace3, "No feed moves within the top face.") + def test10(self): + """test10() Tests full roughing- should machine entire model with no inputs""" + # Instantiate a Adaptive operation and set Base Geometry + adaptive = PathAdaptive.Create("Adaptive") + adaptive.Base = [(self.doc.Fusion, [])] # (base, subs_list) + adaptive.Label = "test10+" + adaptive.Comment = "test10() Verify path generated with no subs roughs entire model" + + # Set additional operation properties + setDepthsAndHeights(adaptive, 15, 0) + 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 + ) + + _addViewProvider(adaptive) + self.doc.recompute() + + # Check: + # - Bounding box at Z=0 goes outside the model box + tool diameter + # (has to profile the model) + # - Bounding box at Z=5 should go past the model in -X, but only up to the + # stock edges in +X and Y + # - Bounding box at Z=10 goes to at least stock bounding box edges, + # minus tool diameter (has to machine the entire top of the stock off) + # - [Should maybe check] At least one move Z = [10,5] is within the model + # - [Should maybe check] No moves at Z = 0 are within the model + + paths = [c for c in adaptive.Path.Commands if c.Name in ["G0", "G00", "G1", "G01"]] + 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 + + zDict = {10: None, 5: None, 0: None} + + getPathBoundaries(paths, zDict) + mbb = self.doc.Fusion.Shape.BoundBox + sbb = adaptive.Document.Stock.Shape.BoundBox + + okAt10 = ( + zDict[10] is not None + and zDict[10]["min"][0] <= sbb.XMin + moffset + and zDict[10]["min"][1] <= sbb.YMin + moffset + and zDict[10]["max"][0] >= sbb.XMax - moffset + and zDict[10]["max"][1] >= sbb.YMax - moffset + ) + + okAt5 = ( + zDict[5] is not None + and zDict[5]["min"][0] <= mbb.XMin - moffset + and zDict[5]["min"][1] <= sbb.YMin + moffset + and zDict[5]["max"][0] >= sbb.XMax - moffset + and zDict[5]["max"][1] >= sbb.YMax - moffset + ) + + okAt0 = ( + zDict[0] is not None + and zDict[0]["min"][0] <= mbb.XMin - moffset + and zDict[0]["min"][1] <= mbb.YMin - moffset + and zDict[0]["max"][0] >= mbb.XMax + moffset + and zDict[0]["max"][1] >= mbb.YMax + moffset + ) + + self.assertTrue( + okAt10 and okAt5 and okAt0, "Path boundaries don't include expected regions" + ) + + def test11(self): + """test11() Tests stock handling- should rough full model, but not cut + air excessively where there's not stock""" + # Instantiate a Adaptive operation and set Base Geometry + adaptive = PathAdaptive.Create("Adaptive") + adaptive.Base = [(self.doc.Fusion, [])] # (base, subs_list) + adaptive.Label = "test11+" + adaptive.Comment = "test11() Verify machining region is limited to the stock" + + # Set additional operation properties + setDepthsAndHeights(adaptive, 15, 5) + 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 + ) + + # Create and assign new stock that will create different bounds at + # different stepdowns + btall = Part.makeBox(17, 27, 11, FreeCAD.Vector(-1, -1, 0)) + bshort = Part.makeBox(42, 27, 6, FreeCAD.Vector(-1, -1, 0)) + adaptive.Document.Job.Stock.Shape = btall.fuse(bshort) + + _addViewProvider(adaptive) + # NOTE: Do NOT recompute entire doc, which will undo our stock change! + adaptive.recompute() + + # Check: + # - Bounding box at Z=10 stays basically above "btall" + # - Bounding box at Z=5 and Z=0 are outside of stock + + paths = [c for c in adaptive.Path.Commands if c.Name in ["G1", "G01"]] + 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 + # NOTE: ADD tol here, since we're effectively flipping our normal + # comparison and want tolerance to make our check looser + moffset = toolr + tol + + zDict = {10: None, 5: None} + + getPathBoundaries(paths, zDict) + sbb = adaptive.Document.Stock.Shape.BoundBox + sbb10 = btall.BoundBox + + # These should be no more than a tool radius outside of the "btall" + # XY section of the stock + okAt10 = ( + zDict[10] is not None + and zDict[10]["min"][0] >= sbb10.XMin - moffset + and zDict[10]["min"][1] >= sbb10.YMin - moffset + and zDict[10]["max"][0] <= sbb10.XMax + moffset + and zDict[10]["max"][1] <= sbb10.YMax + moffset + ) + + # These should be no more than a tool radius outside of the overall + # stock bounding box + okAt5 = ( + zDict[5] is not None + and zDict[5]["min"][0] >= sbb.XMin - moffset + and zDict[5]["min"][1] >= sbb.YMin - moffset + and zDict[5]["max"][0] <= sbb.XMax + moffset + and zDict[5]["max"][1] <= sbb.YMax + moffset + ) + + self.assertTrue(okAt10 and okAt5, "Path feeds extend excessively in +X") + + # POSSIBLY MISSING TESTS: + # - Something for region ordering + # - Known-edge cases: cones/spheres/cylinders (especially partials on edges + # of model + strange angles- especially for cylinders) + # - Multiple models/stock + # - XY stock to leave + # Eclass diff --git a/src/Mod/CAM/Path/Op/Adaptive.py b/src/Mod/CAM/Path/Op/Adaptive.py index 51c7d8b656..6aa2118d31 100644 --- a/src/Mod/CAM/Path/Op/Adaptive.py +++ b/src/Mod/CAM/Path/Op/Adaptive.py @@ -22,6 +22,12 @@ # * * # *************************************************************************** +# NOTE: "isNull() note" +# After performing cut operations, checking the resulting shape.isNull() will +# sometimes return False even when the resulting shape is infinitesimal and +# further operations with it will raise exceptions. Instead checking if the +# shape.Wires list is non-empty bypasses this issue. + import Path import Path.Op.Base as PathOp import PathScripts.PathUtils as PathUtils @@ -759,7 +765,8 @@ def Execute(op, obj): stockPaths[rdict["startdepth"]], path2d, progressFn ) - # Create list of regions to cut, in an order they can be cut in + # Sort regions to cut by either depth or area. + # TODO: Bonus points for ordering to minimize rapids cutlist = list() # Region IDs that have been cut already cutids = list() @@ -924,6 +931,131 @@ def _getSolidProjection(shp, z): return projectFacesToXY(faces) +def _workingEdgeHelperRoughing(op, obj, depths): + # Final calculated regions- list of dicts with entries: + # "region" - actual shape + # "depths" - list of depths this region applies to + insideRegions = list() + outsideRegions = list() + + # Multiple input solids can be selected- make a single part out of them, + # will process each solid separately as appropriate + shps = op.model[0].Shape.fuse([k.Shape for k in op.model[1:]]) + + projdir = FreeCAD.Vector(0, 0, 1) + + # Take outline of entire model as our baseline machining region. No need to + # do this repeatedly inside the loop. + modelOutlineFaces = [ + Part.makeFace(TechDraw.findShapeOutline(s, 1, projdir)) for s in shps.Solids + ] + + lastdepth = obj.StartDepth.Value + + for depth in depths: + # If we have no stock to machine, just skip all the rest of the math + if depth >= op.stock.Shape.BoundBox.ZMax: + lastdepth = depth + continue + + # NOTE: To "leave" stock along Z without actually checking any face + # depths, we simply slice the model "lower" than our actual cut depth by + # the Z stock to leave, which ensures we stay at least that far from the + # actual faces + stockface = _getSolidProjection(op.stock.Shape, depth - obj.ZStockToLeave.Value) + aboveRefined = _getSolidProjection(shps, depth - obj.ZStockToLeave.Value) + + # Outside is based on the outer wire of the above_faces + # Insides are based on the remaining "below" regions, masked by the + # "above"- if something is above an area, we can't machine it in 2.5D + + # OUTSIDE REGIONS + # Outside: Take the outer wire of the above faces + # NOTE: Exactly one entry per depth (not necessarily one depth entry per + # stepdown, however), which is a LIST of the wires we're staying outside + # NOTE: Do this FIRST- if any inside regions share enough of an edge + # with an outside region for a tool to get through, we want to skip them + # for the current stepdown + if aboveModelFaces := [ + Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir)) for f in aboveRefined.Faces + ]: + aboveModelFaces = aboveModelFaces[0].fuse(aboveModelFaces[1:]) + else: + aboveModelFaces = Part.Shape() + # If this region exists in our list, it has to be the last entry, due to + # proceeding in order and having only one per depth. If it's already + # there, replace with the new, deeper depth, else add new + # NOTE: Check for NULL regions to not barf on regions between the top of + # the model and the top of the stock, which are "outside" of nothing + # NOTE: See "isNull() note" at top of file + if ( + outsideRegions + and outsideRegions[-1]["region"].Wires + and aboveModelFaces.Wires + and not aboveModelFaces.cut(outsideRegions[-1]["region"]).Wires + ): + outsideRegions[-1]["depths"].append(depth) + else: + outsideRegions.append({"region": aboveModelFaces, "depths": [depth]}) + + # NOTE: If you don't care about controlling depth vs region ordering, + # you can actually just do everything with "outside" processing, if you + # don't remove internal holes from the regions above + + # INSIDE REGIONS + # NOTE: Nothing inside if there's no model above us + # NOTE: See "isNull() note" at top of file + if aboveModelFaces.Wires: + # Remove any overlapping areas already machined from the outside. + outsideface = stockface.cut(outsideRegions[-1]["region"].Faces) + # NOTE: See "isNull() note" at top of file + if outsideface.Wires: + belowFaces = [f.cut(outsideface) for f in modelOutlineFaces] + else: + # NOTE: Doesn't matter here, but ensure we're making a new list so + # we don't clobber modelOutlineFaces + belowFaces = [f for f in modelOutlineFaces] + + # This isn't really necessary unless the user inputs bad data- eg, a + # min depth above the top of the model. In which case we still want to + # clear the stock + if belowFaces: + # Remove the overhangs from the desired region to cut + belowCut = belowFaces[0].fuse(belowFaces[1:]).cut(aboveRefined) + # NOTE: See "isNull() note" at top of file + if belowCut.Wires: + # removeSplitter fixes occasional concatenate issues for + # some face orders + finalCut = DraftGeomUtils.concatenate(belowCut.removeSplitter()) + else: + finalCut = Part.Shape() + else: + # Make a dummy shape if we don't have anything actually below + finalCut = Part.Shape() + + # Split up into individual faces if any are disjoint, then update + # insideRegions- either by adding a new entry OR by updating the depth + # of an existing entry + for f in finalCut.Faces: + addNew = True + # Brute-force search all existing regions to see if any are the same + newtop = lastdepth + for rdict in insideRegions: + # FIXME: Smarter way to do this than a full cut operation? + if not rdict["region"].cut(f).Wires: + rdict["depths"].append(depth) + addNew = False + break + if addNew: + insideRegions.append({"region": f, "depths": [depth]}) + + # Update the last depth step + lastdepth = depth + # end for depth + + return insideRegions, outsideRegions + + def _workingEdgeHelperManual(op, obj, depths): # Final calculated regions- list of dicts with entries: # "region" - actual shape @@ -984,7 +1116,7 @@ def _workingEdgeHelperManual(op, obj, depths): selectedRefined = projectFacesToXY(selectedRegions + edgefaces) # If the user selected only faces that don't have an XY projection AND no - # edges, give a useful error + # edges, give a useful message if not selectedRefined.Wires: Path.Log.warning("Selected faces/wires have no projection on the XY plane") return insideRegions, outsideRegions @@ -997,8 +1129,8 @@ def _workingEdgeHelperManual(op, obj, depths): lastdepth = depth continue - # NOTE: Slice stock lower than cut depth to effectively leave (at least) - # obj.ZStockToLeave + # NOTE: See note in _workingEdgeHelperRoughing- tl;dr 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 @@ -1106,8 +1238,8 @@ 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: See note in _workingEdgeHelperRoughing- tl;dr 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. @@ -1121,7 +1253,10 @@ def _getWorkingEdges(op, obj): # a list of Z depths that the region applies to # Inside regions are a single face; outside regions consist of ALL geometry # to be avoided at those depths. - insideRegions, outsideRegions = _workingEdgeHelperManual(op, obj, depths) + if obj.Base: + insideRegions, outsideRegions = _workingEdgeHelperManual(op, obj, depths) + else: + insideRegions, outsideRegions = _workingEdgeHelperRoughing(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 @@ -1333,7 +1468,7 @@ class PathAdaptive(PathOp.ObjectOp): "Adaptive", QT_TRANSLATE_NOOP( "App::Property", - "Influences accuracy and performance", + "Influences calculation performance vs stability and accuracy.\n\nLarger values (further to the right) will calculate faster; smaller values (further to the left) will result in more accurate toolpaths.", ), ) obj.addProperty( @@ -1589,9 +1724,6 @@ class PathAdaptive(PathOp.ObjectOp): FeatureExtensions.initialize_properties(obj) -# Eclass - - def SetupProperties(): setup = [ "Side",