diff --git a/src/Mod/CAM/CAMTests/TestPathAdaptive.py b/src/Mod/CAM/CAMTests/TestPathAdaptive.py
index 9325d4b836..62c8fef05b 100644
--- a/src/Mod/CAM/CAMTests/TestPathAdaptive.py
+++ b/src/Mod/CAM/CAMTests/TestPathAdaptive.py
@@ -101,18 +101,14 @@ class TestPathAdaptive(PathTestBase):
pass
# Unit tests
- def test00(self):
- """test00() Empty test."""
- return
-
- def test01(self):
- """test01() Verify path generated on Face3."""
+ def testFaceSingleSimple(self):
+ """testFaceSingleSimple() Verify path generated on Face3."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
adaptive.Base = [(self.doc.Fusion, ["Face3"])] # (base, subs_list)
- adaptive.Label = "test01+"
- adaptive.Comment = "test01() Verify path generated on Face3."
+ adaptive.Label = "testFaceSingleSimple+"
+ adaptive.Comment = "testFaceSingleSimple() Verify path generated on Face3."
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -138,14 +134,15 @@ class TestPathAdaptive(PathTestBase):
# "expected_moves_test01: {}\noperationMoves: {}".format(expected_moves_test01, operationMoves))
self.assertTrue(len(adaptive.Path.Commands) > 100, "Command count not greater than 100.")
- def test02(self):
- """test02() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different."""
+ def testFacesMergedDifferentZ(self):
+ """testFacesMergedDifferentZ() Verify path generated on adjacent, combined
+ Face3 and Face10. The Z heights are different."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
adaptive.Base = [(self.doc.Fusion, ["Face3", "Face10"])] # (base, subs_list)
- adaptive.Label = "test02+"
- adaptive.Comment = "test02() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different."
+ adaptive.Label = "testFacesMergedDifferentZ+"
+ adaptive.Comment = "testFacesMergedDifferentZ() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different. UseOutline = False"
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -165,14 +162,15 @@ class TestPathAdaptive(PathTestBase):
self.assertTrue(len(adaptive.Path.Commands) > 100, "Command count not greater than 100.")
- def test03(self):
- """test03() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different."""
+ def testFacesMergedDifferentZUseOutline(self):
+ """testFacesMergedDifferentZUseOutline() Verify path generated on adjacent, combined Face3 and Face10.
+ The Z heights are different."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
adaptive.Base = [(self.doc.Fusion, ["Face3", "Face10"])] # (base, subs_list)
- adaptive.Label = "test03+"
- adaptive.Comment = "test03() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different."
+ adaptive.Label = "testFacesMergedDifferentZUseOutline+"
+ adaptive.Comment = "testFacesMergedDifferentZUseOutline() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different. UseOutline = True."
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -192,8 +190,8 @@ class TestPathAdaptive(PathTestBase):
self.assertTrue(len(adaptive.Path.Commands) > 100, "Command count not greater than 100.")
- def test04(self):
- """test04() Verify path generated non-closed edges with differing Z-heights that are closed with Z=1 projection: "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19"."""
+ def testOutlineDifferentZDiscontinuousEdges(self):
+ """testOutlineDifferentZDiscontinuous() Verify path generated non-closed edges with differing Z-heights that are closed with Z=1 projection: "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19"."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
@@ -212,8 +210,8 @@ class TestPathAdaptive(PathTestBase):
],
)
] # (base, subs_list)
- adaptive.Label = "test04+"
- adaptive.Comment = 'test04() Verify path generated non-closed edges with differing Z-heights that are closed with Z=1 projection: "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19".'
+ adaptive.Label = "testOutlineDifferentZDiscontinuous+"
+ adaptive.Comment = 'testOutlineDifferentZDiscontinuous() Verify path generated non-closed edges with differing Z-heights that are closed with Z=1 projection: "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19".'
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -233,7 +231,7 @@ class TestPathAdaptive(PathTestBase):
self.assertTrue(len(adaptive.Path.Commands) > 100, "Command count not greater than 100.")
- def test05(self):
+ def testOutlineDifferentZContinuousEdges(self):
"""test05() Verify path generated closed wire with differing Z-heights: "Edge13", "Edge7", "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19"."""
# Instantiate a Adaptive operation and set Base Geometry
@@ -255,8 +253,8 @@ class TestPathAdaptive(PathTestBase):
],
)
] # (base, subs_list)
- adaptive.Label = "test05+"
- adaptive.Comment = 'test05() Verify path generated closed wire with differing Z-heights: "Edge13", "Edge7", "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19".'
+ adaptive.Label = "testOutlineDifferentZContinuous+"
+ adaptive.Comment = 'testOutlineDifferentZContinuous() Verify path generated closed wire with differing Z-heights: "Edge13", "Edge7", "Edge9", "Edge2", "Edge8", "Edge15", "Edge30", "Edge31", "Edge29", "Edge19".'
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -276,8 +274,8 @@ class TestPathAdaptive(PathTestBase):
self.assertTrue(len(adaptive.Path.Commands) > 100, "Command count not greater than 100.")
- def test06(self):
- """test06() Verify path generated with outer and inner edge loops at same Z height: "Edge15", "Edge30", "Edge31", "Edge29", "Edge19", "Edge18", "Edge35", "Edge32", "Edge34", "Edge33"."""
+ def testOutlineWithCutout(self):
+ """testOutlineWithCutout() Verify path generated with outer and inner edge loops at same Z height: "Edge15", "Edge30", "Edge31", "Edge29", "Edge19", "Edge18", "Edge35", "Edge32", "Edge34", "Edge33"."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
@@ -298,8 +296,8 @@ class TestPathAdaptive(PathTestBase):
],
)
] # (base, subs_list)
- adaptive.Label = "test06+"
- adaptive.Comment = 'test06() Verify path generated with outer and inner edge loops at same Z height: "Edge15", "Edge30", "Edge31", "Edge29", "Edge19", "Edge18", "Edge35", "Edge32", "Edge34", "Edge33".'
+ adaptive.Label = "testOutlineWithCutout+"
+ adaptive.Comment = 'testOutlineWithCutout() Verify path generated with outer and inner edge loops at same Z height: "Edge15", "Edge30", "Edge31", "Edge29", "Edge19", "Edge18", "Edge35", "Edge32", "Edge34", "Edge33".'
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -335,14 +333,14 @@ class TestPathAdaptive(PathTestBase):
break
self.assertFalse(isInBox, "Paths originating within the inner hole.")
- def test07(self):
- """test07() Verify path generated on donut-shaped Face10."""
+ def testFaceWithCutout(self):
+ """testFaceWithCutout() Verify path generated on donut-shaped Face10."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
adaptive.Base = [(self.doc.Fusion, ["Face10"])] # (base, subs_list)
- adaptive.Label = "test07+"
- adaptive.Comment = "test07() Verify path generated on donut-shaped Face10."
+ adaptive.Label = "testFaceWithCutout+"
+ adaptive.Comment = "testFaceWithCutout() Verify path generated on donut-shaped Face10."
# Set additional operation properties
# setDepthsAndHeights(adaptive)
@@ -396,10 +394,340 @@ class TestPathAdaptive(PathTestBase):
break
self.assertTrue(isInBox, "No paths originating within the inner hole.")
+ def testModelStockAwareness(self):
+ """testModelStockAwareness() Tests stock awareness- avoids cutting into the model regardless
+ of bounding box selected."""
+ # Instantiate a Adaptive operation and set Base Geometry
+ adaptive = PathAdaptive.Create("Adaptive")
+ adaptive.Base = [(self.doc.Fusion, ["Face3", "Face10"])] # (base, subs_list)
+ adaptive.Label = "testModelStockAwareness+"
+ adaptive.Comment = "testModelStockAwareness() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different. Result should be the combination at Z=10 (faces from (0,0) to (40,25), minus tool radius), and only the lower face at Z=5: (15,0) to (40,25)."
+
+ # 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
+ )
+ # Don't use helix entry- ensures helix moves are counted in the path
+ # boundary calculation. This should be unnecessary, as the helices are
+ # grown out of the cut area, and thus must be inside of it.
+ adaptive.UseHelixArcs = False
+
+ _addViewProvider(adaptive)
+ self.doc.recompute()
+
+ # Check:
+ # - Bounding box at Z=10 stays within Face3 and Face10- so -X for Face3,
+ # +X and +/-Y for Face10
+ # - bounding box at Z=5 stays within Face10
+ # - No toolpaths at Z=0
+
+ 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 = getPathBoundaries(paths, [10, 5, 0])
+
+ # NOTE: Face3 is at Z=10, Face10 is at Z=5
+ bbf3 = self.doc.Fusion.Shape.getElement("Face3").BoundBox
+ bbf10 = self.doc.Fusion.Shape.getElement("Face10").BoundBox
+
+ okAt10 = (
+ zDict[10] is not None
+ and zDict[10]["min"][0] >= bbf3.XMin + moffset
+ and zDict[10]["min"][1] >= bbf10.YMin + moffset
+ and zDict[10]["max"][0] <= bbf10.XMax - moffset
+ and zDict[10]["max"][1] <= bbf10.YMax - moffset
+ )
+
+ okAt5 = (
+ zDict[5] is not None
+ and zDict[5]["min"][0] >= bbf10.XMin + moffset
+ and zDict[5]["min"][1] >= bbf10.YMin + moffset
+ and zDict[5]["max"][0] < bbf10.XMax - moffset
+ and zDict[5]["max"][1] < bbf10.YMax - moffset
+ )
+
+ okAt0 = not zDict[0]
+
+ self.assertTrue(okAt10 and okAt5 and okAt0, "Path boundaries outside of expected regions")
+
+ def testZStockToLeave(self):
+ """testZStockToLeave() 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 = "testZStockToLeave+"
+ adaptive.Comment = "testZStockToLeave() 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.")
+
+ def testFullModelAdaptiveRoughing(self):
+ """testFullModelAdaptiveRoughing() 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 = "testFullModelAdaptiveRoughing+"
+ adaptive.Comment = (
+ "testFullModelAdaptiveRoughing() 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
+ )
+ # Don't use helix entry- ensures helix moves are counted in the path
+ # boundary calculation. This should be unnecessary, as the helices are
+ # grown out of the cut area, and thus must be inside of it.
+ adaptive.UseHelixArcs = False
+
+ _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 = getPathBoundaries(paths, [10, 5, 0])
+ 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 testStockLimitsAwareness(self):
+ """testStockLimitsAwareness() 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 = "testStockLimitsAwareness+"
+ adaptive.Comment = (
+ "testStockLimitsAwareness() 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
+ )
+ # Don't use helix entry- ensures helix moves are counted in the path
+ # boundary calculation. This should be unnecessary, as the helices are
+ # grown out of the cut area, and thus must be inside of it.
+ adaptive.UseHelixArcs = False
+
+ # 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 = getPathBoundaries(paths, [10, 5])
+ 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
+def getPathBoundaries(paths, zLevels):
+ """getPathBoundaries(paths, zLevels): Takes the list of paths and list of Z
+ depths of interest, and finds the bounding box of the paths at each depth.
+ A dictionary of depth: {"min": (x,y), "max": (x,y)} entries is returned.
+
+ NOTE: You'd think that using Path.BoundBox would give us what we want,
+ but... no, for whatever reason it appears to always extend to (0,0,0)
+ """
+ last = FreeCAD.Vector(0.0, 0.0, 0.0)
+ # First make sure each element has X, Y, and Z coordinates
+ for p in paths:
+ params = p.Parameters
+ last.x = p.X if "X" in params else last.x
+ last.y = p.Y if "Y" in params else last.y
+ last.z = p.Z if "Z" in params else last.z
+
+ p.X = last.x
+ p.Y = last.y
+ p.Z = last.z
+
+ zDict = {}
+ for z in zLevels:
+ zpaths = [k for k in paths if k.Z == z]
+ if not zpaths:
+ zDict[z] = None
+ continue
+ xmin = min([k.X for k in zpaths])
+ xmax = max([k.X for k in zpaths])
+ ymin = min([k.Y for k in zpaths])
+ ymax = max([k.Y for k in zpaths])
+ zDict[z] = {"min": (xmin, ymin), "max": (xmax, ymax)}
+
+ return zDict
+
+
def setDepthsAndHeights(op, strDep=20.0, finDep=0.0):
"""setDepthsAndHeights(op, strDep=20.0, finDep=0.0)... Sets default depths and heights for `op` passed to it"""
@@ -421,43 +749,29 @@ def getGcodeMoves(cmdList, includeRapids=True, includeLines=True, includeArcs=Tr
"""getGcodeMoves(cmdList, includeRapids=True, includeLines=True, includeArcs=True)...
Accepts command dict and returns point string coordinate.
"""
+
+ # NOTE: Can NOT just check "if p.get("X")" or similar- that chokes when X is
+ # zero. That becomes especially obvious when Z=0, and moves end up on the
+ # wrong depth
gcode_list = list()
last = FreeCAD.Vector(0.0, 0.0, 0.0)
for c in cmdList:
p = c.Parameters
name = c.Name
- if includeRapids and name in ["G0", "G00"]:
+ if (includeRapids and name in ["G0", "G00"]) or (includeLines and name in ["G1", "G01"]):
gcode = name
x = last.x
y = last.y
z = last.z
- if p.get("X"):
+ if "X" in p:
x = round(p["X"], 2)
- gcode += " X" + str(x)
- if p.get("Y"):
+ gcode += " X" + str(x)
+ if "Y" in p:
y = round(p["Y"], 2)
- gcode += " Y" + str(y)
- if p.get("Z"):
+ gcode += " Y" + str(y)
+ if "Z" in p:
z = round(p["Z"], 2)
- gcode += " Z" + str(z)
- last.x = x
- last.y = y
- last.z = z
- gcode_list.append(gcode)
- elif includeLines and name in ["G1", "G01"]:
- gcode = name
- x = last.x
- y = last.y
- z = last.z
- if p.get("X"):
- x = round(p["X"], 2)
- gcode += " X" + str(x)
- if p.get("Y"):
- y = round(p["Y"], 2)
- gcode += " Y" + str(y)
- if p.get("Z"):
- z = round(p["Z"], 2)
- gcode += " Z" + str(z)
+ gcode += " Z" + str(z)
last.x = x
last.y = y
last.z = z
@@ -470,23 +784,23 @@ def getGcodeMoves(cmdList, includeRapids=True, includeLines=True, includeArcs=Tr
i = 0.0
j = 0.0
k = 0.0
- if p.get("I"):
+ if "I" in p:
i = round(p["I"], 2)
gcode += " I" + str(i)
- if p.get("J"):
+ if "J" in p:
j = round(p["J"], 2)
gcode += " J" + str(j)
- if p.get("K"):
+ if "K" in p:
k = round(p["K"], 2)
gcode += " K" + str(k)
- if p.get("X"):
+ if "X" in p:
x = round(p["X"], 2)
gcode += " X" + str(x)
- if p.get("Y"):
+ if "Y" in p:
y = round(p["Y"], 2)
gcode += " Y" + str(y)
- if p.get("Z"):
+ if "Z" in p:
z = round(p["Z"], 2)
gcode += " Z" + str(z)
@@ -501,7 +815,7 @@ def pathOriginatesInBox(cmd, minPoint, maxPoint):
p = cmd.Parameters
name = cmd.Name
if name in ["G0", "G00", "G1", "G01"]:
- if p.get("X") and p.get("Y"):
+ if "X" in p and "Y" in p:
x = p.get("X")
y = p.get("Y")
if x > minPoint.x and y > minPoint.y and x < maxPoint.x and y < maxPoint.y:
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 dc40299eda..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
@@ -72,6 +78,9 @@ sceneGraph = None
scenePathNodes = [] # for scene cleanup afterwards
topZ = 10
+# Constants to avoid magic numbers in the code
+_ADAPTIVE_MIN_STEPDOWN = 0.1
+
def sceneDrawPath(path, color=(0, 0, 1)):
coPoint = coin.SoCoordinate3()
@@ -117,7 +126,7 @@ def CalcHelixConePoint(height, cur_z, radius, angle):
def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
- if len(adaptiveResults) == 0 or len(adaptiveResults[0]["AdaptivePaths"]) == 0:
+ if not adaptiveResults or not adaptiveResults[0]["AdaptivePaths"]:
return
# minLiftDistance = op.tool.Diameter
@@ -125,74 +134,55 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
for region in adaptiveResults:
p1 = region["HelixCenterPoint"]
p2 = region["StartPoint"]
- r = math.sqrt((p1[0] - p2[0]) * (p1[0] - p2[0]) + (p1[1] - p2[1]) * (p1[1] - p2[1]))
- if r > helixRadius:
- helixRadius = r
+ helixRadius = max(math.dist(p1[:2], p2[:2]), helixRadius)
- stepDown = obj.StepDown.Value
- passStartDepth = obj.StartDepth.Value
-
- if stepDown < 0.1:
- stepDown = 0.1
+ stepDown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN)
length = 2 * math.pi * helixRadius
- if float(obj.HelixAngle) < 1:
- obj.HelixAngle = 1
- if float(obj.HelixAngle) > 89:
- obj.HelixAngle = 89
+ obj.HelixAngle = min(89, max(obj.HelixAngle.Value, 1))
+ obj.HelixConeAngle = max(obj.HelixConeAngle, 0)
- if float(obj.HelixConeAngle) < 0:
- obj.HelixConeAngle = 0
-
- helixAngleRad = math.pi * float(obj.HelixAngle) / 180.0
+ helixAngleRad = math.radians(obj.HelixAngle)
depthPerOneCircle = length * math.tan(helixAngleRad)
- # print("Helix circle depth: {}".format(depthPerOneCircle))
- stepUp = obj.LiftDistance.Value
- if stepUp < 0:
- stepUp = 0
+ stepUp = max(obj.LiftDistance.Value, 0)
- finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0
- if finish_step > stepDown:
- finish_step = stepDown
+ # TODO: finishStep is of limited utility with how regions are now broken
+ # up based on the model geometry- the "finish" step gets applied to each
+ # region separately, which results in excessive "finish" steps being taken
+ # where they really need not be. Leaving stock in Z generally makes more
+ # sense, but both technically have their uses, so leaving this here as
+ # option. Implementing flat area detection would make better use of both.
+ finishStep = min(obj.FinishDepth.Value, stepDown) if hasattr(obj, "FinishDepth") else 0.0
- depth_params = PathUtils.depth_params(
- clearance_height=obj.ClearanceHeight.Value,
- safe_height=obj.SafeHeight.Value,
- start_depth=obj.StartDepth.Value,
- step_down=stepDown,
- z_finish_step=finish_step,
- final_depth=obj.FinalDepth.Value,
- user_depths=None,
- )
+ # Track Z position to determine when changing height is necessary prior to a move
+ lz = obj.StartDepth.Value
- # ml: this is dangerous because it'll hide all unused variables hence forward
- # however, I don't know what lx and ly signify so I'll leave them for now
- # lx = adaptiveResults[0]["HelixCenterPoint"][0]
- # ly = adaptiveResults[0]["HelixCenterPoint"][1]
- lz = passStartDepth
- step = 0
+ for region in adaptiveResults:
+ passStartDepth = region["TopDepth"]
- for passEndDepth in depth_params.data:
- step = step + 1
+ depthParams = PathUtils.depth_params(
+ clearance_height=obj.ClearanceHeight.Value,
+ safe_height=obj.SafeHeight.Value,
+ start_depth=region["TopDepth"],
+ step_down=stepDown,
+ z_finish_step=finishStep,
+ final_depth=region["BottomDepth"],
+ user_depths=None,
+ )
- for region in adaptiveResults:
+ for passEndDepth in depthParams.data:
startAngle = math.atan2(
region["StartPoint"][1] - region["HelixCenterPoint"][1],
region["StartPoint"][0] - region["HelixCenterPoint"][0],
)
- # lx = region["HelixCenterPoint"][0]
- # ly = region["HelixCenterPoint"][1]
-
passDepth = passStartDepth - passEndDepth
p1 = region["HelixCenterPoint"]
p2 = region["StartPoint"]
- helixRadius = math.sqrt(
- (p1[0] - p2[0]) * (p1[0] - p2[0]) + (p1[1] - p2[1]) * (p1[1] - p2[1])
- )
+ helixRadius = math.dist(p1[:2], p2[:2])
# Helix ramp
if helixRadius > 0.01:
@@ -256,8 +246,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
op.commandlist.append(
Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.vertFeed})
)
- # lx = x
- # ly = y
fi = fi + math.pi / 16
# one more circle at target depth to make sure center is cleared
@@ -269,13 +257,11 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
op.commandlist.append(
Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.horizFeed})
)
- # lx = x
- # ly = y
fi = fi + math.pi / 16
else:
# Cone
- _HelixAngle = 360 - (float(obj.HelixAngle) * 4)
+ _HelixAngle = 360 - (obj.HelixAngle.Value * 4)
if obj.HelixConeAngle > 6:
obj.HelixConeAngle = 6
@@ -510,8 +496,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
},
)
)
- # lx = x
- # ly = y
else: # no helix entry
# rapid move to clearance height
@@ -551,8 +535,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
x = pt[0]
y = pt[1]
- # dist = math.sqrt((x-lx)*(x-lx) + (y-ly)*(y-ly))
-
if motionType == area.AdaptiveMotionType.Cutting:
z = passEndDepth
if z != lz:
@@ -576,13 +558,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
op.commandlist.append(Path.Command("G0", {"X": x, "Y": y}))
- # elif motionType == area.AdaptiveMotionType.LinkClearAtPrevPass:
- # if lx!=x or ly!=y:
- # op.commandlist.append(Path.Command("G0", { "X": lx, "Y":ly, "Z":passStartDepth+stepUp}))
- # op.commandlist.append(Path.Command("G0", { "X": x, "Y":y, "Z":passStartDepth+stepUp}))
-
- # lx = x
- # ly = y
lz = z
# return to safe height in this Z pass
@@ -592,14 +567,14 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
lz = z
- passStartDepth = passEndDepth
+ passStartDepth = passEndDepth
- # return to safe height in this Z pass
- z = obj.ClearanceHeight.Value
- if z != lz:
- op.commandlist.append(Path.Command("G0", {"Z": z}))
+ # return to safe height in this Z pass
+ z = obj.ClearanceHeight.Value
+ if z != lz:
+ op.commandlist.append(Path.Command("G0", {"Z": z}))
- lz = z
+ lz = z
z = obj.ClearanceHeight.Value
if z != lz:
@@ -634,70 +609,108 @@ def Execute(op, obj):
topZ = op.stock.Shape.BoundBox.ZMax
obj.Stopped = False
obj.StopProcessing = False
- if obj.Tolerance < 0.001:
- obj.Tolerance = 0.001
+ obj.Tolerance = max(0.001, obj.Tolerance)
- # Get list of working edges for adaptive algorithm
- pathArray = op.pathArray
- if not pathArray:
- msg = translate(
- "CAM",
- "Adaptive operation couldn't determine the boundary wire. Did you select base geometry?",
+ # NOTE: Reminder that stock is formatted differently than inside/outside!
+ stockPaths = {d: convertTo2d(op.stockPathArray[d]) for d in op.stockPathArray}
+
+ outsideOpType = area.AdaptiveOperationType.ClearingOutside
+ insideOpType = area.AdaptiveOperationType.ClearingInside
+
+ # List every REGION separately- we can then calculate a toolpath based
+ # on the region. One or more stepdowns may use that same toolpath by
+ # keeping a reference to the region without requiring we calculate the
+ # toolpath once per step down OR forcing all stepdowns of a region into
+ # a single list.
+ regionOps = list()
+ outsidePathArray2dDepthTuples = list()
+ insidePathArray2dDepthTuples = list()
+ # NOTE: Make sure the depth lists are sorted for use in order-by-depth
+ # and order-by-region algorithms below
+ # NOTE: Pretty sure sorting is already guaranteed by how these are
+ # created, but best to not assume that
+ for rdict in op.outsidePathArray:
+ regionOps.append(
+ {
+ "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],
+ }
+ )
+ outsidePathArray2dDepthTuples.append(
+ (sorted(rdict["depths"], reverse=True), regionOps[-1])
+ )
+ for rdict in op.insidePathArray:
+ regionOps.append(
+ {
+ "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],
+ }
+ )
+ insidePathArray2dDepthTuples.append(
+ (sorted(rdict["depths"], reverse=True), regionOps[-1])
)
- FreeCAD.Console.PrintUserWarning(msg)
- return
-
- path2d = convertTo2d(pathArray)
-
- # Use the 2D outline of the stock as the stock
- # FIXME: This does not account for holes in the middle of stock!
- outer_wire = TechDraw.findShapeOutline(op.stock.Shape, 1, FreeCAD.Vector(0, 0, 1))
- stockPaths = [[discretize(outer_wire)]]
-
- stockPath2d = convertTo2d(stockPaths)
-
- # opType = area.AdaptiveOperationType.ClearingInside # Commented out per LGTM suggestion
- if obj.OperationType == "Clearing":
- if obj.Side == "Outside":
- opType = area.AdaptiveOperationType.ClearingOutside
-
- else:
- opType = area.AdaptiveOperationType.ClearingInside
-
- else: # profiling
- if obj.Side == "Outside":
- opType = area.AdaptiveOperationType.ProfilingOutside
-
- else:
- opType = area.AdaptiveOperationType.ProfilingInside
keepToolDownRatio = 3.0
if hasattr(obj, "KeepToolDownRatio"):
- keepToolDownRatio = float(obj.KeepToolDownRatio)
+ keepToolDownRatio = obj.KeepToolDownRatio.Value
- # put here all properties that influence calculation of adaptive base paths,
-
- inputStateObject = {
- "tool": float(op.tool.Diameter),
- "tolerance": float(obj.Tolerance),
- "geometry": path2d,
- "stockGeometry": stockPath2d,
- "stepover": float(obj.StepOver),
- "effectiveHelixDiameter": float(helixDiameter),
- "operationType": obj.OperationType,
- "side": obj.Side,
+ # These fields are used to determine if toolpaths should be recalculated
+ outsideInputStateObject = {
+ "tool": op.tool.Diameter.Value,
+ "tolerance": obj.Tolerance,
+ "geometry": [k["path2d"] for k in regionOps if k["opType"] == outsideOpType],
+ "stockGeometry": stockPaths,
+ "stepover": obj.StepOver,
+ "effectiveHelixDiameter": helixDiameter,
+ "operationType": "Clearing",
+ "side": "Outside",
"forceInsideOut": obj.ForceInsideOut,
"finishingProfile": obj.FinishingProfile,
"keepToolDownRatio": keepToolDownRatio,
- "stockToLeave": float(obj.StockToLeave),
+ "stockToLeave": obj.StockToLeave.Value,
+ "zStockToLeave": obj.ZStockToLeave.Value,
+ "orderCutsByRegion": obj.OrderCutsByRegion,
}
+ insideInputStateObject = {
+ "tool": op.tool.Diameter.Value,
+ "tolerance": obj.Tolerance,
+ "geometry": [k["path2d"] for k in regionOps if k["opType"] == insideOpType],
+ "stockGeometry": stockPaths,
+ "stepover": obj.StepOver,
+ "effectiveHelixDiameter": helixDiameter,
+ "operationType": "Clearing",
+ "side": "Inside",
+ "forceInsideOut": obj.ForceInsideOut,
+ "finishingProfile": obj.FinishingProfile,
+ "keepToolDownRatio": keepToolDownRatio,
+ "stockToLeave": obj.StockToLeave.Value,
+ "zStockToLeave": obj.ZStockToLeave.Value,
+ "orderCutsByRegion": obj.OrderCutsByRegion,
+ }
+
+ inputStateObject = [outsideInputStateObject, insideInputStateObject]
+
inputStateChanged = False
adaptiveResults = None
- if obj.AdaptiveOutputState is not None and obj.AdaptiveOutputState != "":
+ # If we have a valid... path? Something. Generated, make that
+ # tentatively the output
+ if obj.AdaptiveOutputState:
adaptiveResults = obj.AdaptiveOutputState
+ # If ANYTHING in our input-cutting parameters, cutting regions,
+ # etc.- changes, force recalculating
if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject):
inputStateChanged = True
adaptiveResults = None
@@ -721,31 +734,85 @@ def Execute(op, obj):
start = time.time()
if inputStateChanged or adaptiveResults is None:
- a2d = area.Adaptive2d()
- a2d.stepOverFactor = 0.01 * obj.StepOver
- a2d.toolDiameter = float(op.tool.Diameter)
- a2d.helixRampDiameter = helixDiameter
- a2d.keepToolDownDistRatio = keepToolDownRatio
- a2d.stockToLeave = float(obj.StockToLeave)
- a2d.tolerance = float(obj.Tolerance)
- a2d.forceInsideOut = obj.ForceInsideOut
- a2d.finishingProfile = obj.FinishingProfile
- a2d.opType = opType
+ # NOTE: Seem to need to create a new a2d for each area when we're
+ # stepping down depths like this. If we don't, it will keep history
+ # from the last region we did.
- # EXECUTE
- results = a2d.Execute(stockPath2d, path2d, progressFn)
+ # TODO: QThread/QRunnable trigger Python's global interpretor lock
+ # (GIL). To calculate toolpaths in parallel, making a C++ shim that
+ # takes in the array of regions/stock paths and parallelizes in
+ # C++-land is probably the way to do it.
+
+ # Create a toolpath for each region to avoid re-calculating for
+ # identical stepdowns
+ for rdict in regionOps:
+ path2d = rdict["path2d"]
+ opType = rdict["opType"]
+
+ a2d = area.Adaptive2d()
+ a2d.stepOverFactor = 0.01 * obj.StepOver
+ 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
+ a2d.finishingProfile = obj.FinishingProfile
+ a2d.opType = opType
+
+ rdict["toolpaths"] = a2d.Execute(
+ stockPaths[rdict["startdepth"]], path2d, progressFn
+ )
+
+ # 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()
+ # Create sorted list of unique depths
+ # NOTE: reverse because we cut top-down!
+ depths = list()
+ # NOTE: alltuples is sorted by depth already
+ alltuples = outsidePathArray2dDepthTuples + insidePathArray2dDepthTuples
+ for t in alltuples:
+ depths += [d for d in t[0]]
+ depths = sorted(list(set(depths)), reverse=True)
+ 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
- adaptiveResults = []
- for result in results:
- adaptiveResults.append(
- {
- "HelixCenterPoint": result.HelixCenterPoint,
- "StartPoint": result.StartPoint,
- "AdaptivePaths": result.AdaptivePaths,
- "ReturnMotionType": result.ReturnMotionType,
- }
- )
+ stepdown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN)
+ adaptiveResults = list()
+ for depths, region in cutlist:
+ for result in region["toolpaths"]:
+ adaptiveResults.append(
+ {
+ "HelixCenterPoint": result.HelixCenterPoint,
+ "StartPoint": result.StartPoint,
+ "AdaptivePaths": result.AdaptivePaths,
+ "ReturnMotionType": result.ReturnMotionType,
+ "TopDepth": depths[0] + stepdown,
+ "BottomDepth": depths[-1],
+ }
+ )
# GENERATE
GenerateGCode(op, obj, adaptiveResults, helixDiameter)
@@ -765,71 +832,559 @@ def Execute(op, obj):
sceneClean()
-def _get_working_edges(op, obj):
- """_get_working_edges(op, obj)...
- Compile all working edges from the Base Geometry selection (obj.Base)
- for the current operation.
- Additional modifications to selected region(face), such as extensions,
- should be placed within this function.
+def projectFacesToXY(faces, minEdgeLength=1e-10):
+ """projectFacesToXY(faces, minEdgeLength)
+ Calculates the projection of the provided list of faces onto the XY plane.
+ The returned value is a single shape that may contain multiple faces if
+ there were disjoint projections. Each individual face will be clean, without
+ triangulated geometry, etc., and will be at Z=0 on the XY plane
+
+ minEdgeLength is provided to (eg) filter out the tips of cones that are
+ internally represented as arbitrarily-small circular faces- using those for
+ additional operations causes problems.
"""
- all_regions = list()
- edge_list = list()
- avoidFeatures = list()
- rawEdges = list()
+ projdir = FreeCAD.Vector(0, 0, 1)
+ outfaces = []
+ for f in faces:
+ # Vertical cones and spheres will still have a projection on the XY
+ # plane. Cylinders and flat faces will not.
+ if Path.Geom.isVertical(f) and type(f.Surface) not in [Part.Cone, Part.Sphere]:
+ continue
+
+ # NOTE: Wires/edges get clipped if we have an "exact fit" bounding box
+ projface = Path.Geom.makeBoundBoxFace(f.BoundBox, offset=1, zHeight=0)
+
+ # NOTE: Cylinders, cones, and spheres are messy:
+ # - Internal representation of non-truncted cones and spheres includes
+ # the "tip" with a ~0-area closed edge. This is different than the
+ # "isNull() note" at the top in magnitude
+ # - Projecting edges doesn't naively work due to the way seams are handled
+ # - There may be holes at either end that may or may not line up- any
+ # overlap is a hole in the projection
+ if type(f.Surface) in [Part.Cone, Part.Cylinder, Part.Sphere]:
+ # This gets most of the face outline, but since cylinder/cone faces
+ # are hollow, if the ends overlap in the projection there may be a
+ # hole we need to remove from the solid projection
+ oface = Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir))
+
+ # "endfacewires" is JUST the end faces of a cylinder/cone, used to
+ # determine if there's a hole we can see through the shape that
+ # should NOT be solid in the projection
+ endfacewires = DraftGeomUtils.findWires(
+ [e for e in f.Edges if not e.isSeam(f) and e.Length > minEdgeLength]
+ )
+
+ # Need to verify that there actually is a projection before taking
+ # a wire from the list, else this could nicely be one line.
+ projwires = []
+ for w in endfacewires:
+ pp = projface.makeParallelProjection(w, projdir).Wires
+ if pp:
+ projwires.append(pp[0])
+
+ if len(projwires) > 1:
+ faces = [Part.makeFace(x) for x in projwires]
+ overlap = faces[0].common(faces[1:])
+ outfaces.append(oface.cut(overlap))
+ else:
+ outfaces.append(oface)
+ # For other cases, projecting the wires to a plane should suffice
+ else:
+ facewires = list()
+ for w in f.Wires:
+ if w.isClosed():
+ projwire = projface.makeParallelProjection(w, projdir).Wires[0]
+ if projwire.isClosed():
+ facewires.append(projwire)
+ if facewires:
+ outfaces.append(Part.makeFace(facewires))
+ if outfaces:
+ fusion = outfaces[0].fuse(outfaces[1:])
+ # removeSplitter fixes occasional concatenate issues for some face orders
+ return DraftGeomUtils.concatenate(fusion.removeSplitter())
+ else:
+ return Part.Shape()
+
+
+def _getSolidProjection(shp, z):
+ """_getSolidProjection(shp, z)
+ Calculates a shape obtained by slicing shp at the height z, then projecting
+ the solids above that height onto a region of proj_face, and creating a
+ simplified face
+ """
+ bb = shp.BoundBox
+
+ # Find all faces above the machining depth. This is used to mask future
+ # interior cuts, and the outer wire is used as the external wire
+ bbCutTop = Part.makeBox(
+ bb.XLength,
+ bb.YLength,
+ max(bb.ZLength, bb.ZLength - z),
+ FreeCAD.Vector(bb.XMin, bb.YMin, z),
+ )
+ aboveSolids = shp.common(bbCutTop).Solids
+
+ faces = list()
+ for s in aboveSolids:
+ faces += s.Faces
+
+ 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
+ # "depths" - list of depths this region applies to
+ insideRegions = list()
+ outsideRegions = list()
+
+ # User selections, with extensions
+ selectedRegions = list()
+ selectedEdges = list()
# Get extensions and identify faces to avoid
extensions = FeatureExtensions.getExtensions(obj)
- for e in extensions:
- if e.avoid:
- avoidFeatures.append(e.feature)
+ avoidFeatures = [e for e in extensions if e.avoid]
- # Get faces selected by user
- for base, subs in obj.Base:
- for sub in subs:
- if sub.startswith("Face"):
- if sub not in avoidFeatures:
- if obj.UseOutline:
- face = base.Shape.getElement(sub)
- # get outline with wire_A method used in PocketShape, but it does not play nicely later
- # wire_A = TechDraw.findShapeOutline(face, 1, FreeCAD.Vector(0.0, 0.0, 1.0))
- wire_B = face.OuterWire
- shape = Part.Face(wire_B)
- else:
- shape = base.Shape.getElement(sub)
- all_regions.append(shape)
- elif sub.startswith("Edge"):
- # Save edges for later processing
- rawEdges.append(base.Shape.getElement(sub))
- # Efor
-
- # Process selected edges
- if rawEdges:
- edgeWires = DraftGeomUtils.findWires(rawEdges)
- if edgeWires:
- for w in edgeWires:
- for e in w.Edges:
- edge_list.append([discretize(e)])
-
- # Apply regular Extensions
- op.exts = []
+ # Similarly, expand selected regions with extensions
for ext in extensions:
if not ext.avoid:
- wire = ext.getWire()
- if wire:
- for f in ext.getExtensionFaces(wire):
- op.exts.append(f)
- all_regions.append(f)
+ if wire := ext.getWire():
+ selectedRegions += [f for f in ext.getExtensionFaces(wire)]
- # Second face-combining method attempted
- horizontal = Path.Geom.combineHorizontalFaces(all_regions)
- if horizontal:
- obj.removalshape = Part.makeCompound(horizontal)
- for f in horizontal:
- for w in f.Wires:
- for e in w.Edges:
- edge_list.append([discretize(e)])
+ for base, subs in obj.Base:
+ for sub in subs:
+ element = base.Shape.getElement(sub)
+ if sub.startswith("Face") and sub not in avoidFeatures:
+ shape = Part.Face(element.OuterWire) if obj.UseOutline else element
+ selectedRegions.append(shape)
+ # Omit vertical edges, since they project to nothing in the XY plane
+ # and cause processing failures later if included
+ elif sub.startswith("Edge") and not Path.Geom.isVertical(element):
+ selectedEdges.append(element)
- return edge_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:]])
+
+ # Make a face to project onto
+ # NOTE: Use 0 as the height, since that's what TechDraw.findShapeOutline
+ # uses, which we use to find the machining boundary, and the actual depth
+ # is tracked separately.
+ # NOTE: Project to the PART bounding box- with some padding- not the stock,
+ # since the stock may be smaller than the part
+ projface = Path.Geom.makeBoundBoxFace(shps.BoundBox, offset=1, zHeight=0)
+ projdir = FreeCAD.Vector(0, 0, 1)
+
+ # When looking for selected edges, project to a single plane first, THEN try
+ # to find wires. Take all the resulting wires and make faces in one shot to
+ # make bullseye-style cutouts where selected wires nest.
+ edgefaces = list()
+ if selectedEdges:
+ pp = [projface.makeParallelProjection(e, projdir).Wires[0] for e in selectedEdges]
+ ppe = list()
+ for w in pp:
+ ppe += w.Edges
+ edgeWires = DraftGeomUtils.findWires(ppe)
+ edgefaces = Part.makeFace(edgeWires).Faces
+
+ selectedRefined = projectFacesToXY(selectedRegions + edgefaces)
+
+ # If the user selected only faces that don't have an XY projection AND no
+ # 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
+
+ lastdepth = obj.StartDepth.Value
+
+ for depth in depths:
+ # If our depth is above the top of the stock, there's nothing to machine
+ if depth >= op.stock.Shape.BoundBox.ZMax:
+ lastdepth = depth
+ continue
+
+ # 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
+ # as requested by operation
+ if obj.Side == "Outside":
+ # Outside is based on the outer wire of the faces of aboveRefined
+ # 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: Take the outer wire of the above faces, added to selected
+ # edges and regions
+ # 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
+ # NOTE: This check naively seems unnecessary, but it's possible the
+ # user selected a vertical face as the only face to stay outside of,
+ # and we're above the model, causing keepOutFaces to be empty
+ if keepOutFaces := [
+ Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir))
+ for f in aboveRefined.Faces + selectedRefined.Faces
+ ]:
+ finalMerge = keepOutFaces[0].fuse(keepOutFaces[1:])
+ else:
+ finalMerge = selectedRefined
+ # Without removeSplitter(), concatenate will sometimes fail when
+ # trying to merge faces that are (eg) connected A-B and B-C,
+ # seemingly when trying to merge A-C
+ regions = DraftGeomUtils.concatenate(finalMerge.removeSplitter())
+
+ # 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: Do NOT need a check for whether outsideRegions[-1]["region"]
+ # is valid since we have a user-specified region regardless of depth
+ # NOTE: See "isNull() note" at top of file
+ if (
+ outsideRegions
+ and regions.Wires
+ and not regions.cut(outsideRegions[-1]["region"]).Wires
+ ):
+ outsideRegions[-1]["depths"].append(depth)
+ else:
+ outsideRegions.append({"region": regions, "depths": [depth]})
+
+ # Inside
+ # For every area selected by the user, project to a plane
+ # NOTE: See "isNull() note" at top of file
+ else:
+ if aboveRefined.Wires:
+ finalCut = selectedRefined.cut(aboveRefined)
+ else:
+ finalCut = selectedRefined
+
+ # 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 _getWorkingEdges(op, obj):
+ """_getWorkingEdges(op, obj)...
+ Compile all working edges from the Base Geometry selection (obj.Base)
+ for the current operation (or the entire model if no selections).
+ Additional modifications to selected region(face), such as extensions,
+ should be placed within this function.
+ This version will return two lists- one for outside (keepout) edges and one
+ for inside ("machine inside") edges. Each list will be a dict with "region"
+ and "depths" entries- the former being discretized geometry of the region,
+ the latter being a list of every depth the geometry is machined on
+ """
+
+ # Find depth steps, throwing out all depths above anywhere we might cut
+ # NOTE: Finish stepdown = 0 here- it's actually applied when gcode is
+ # generated; doing so here would cause it to be applied twice.
+ depthParams = PathUtils.depth_params(
+ clearance_height=obj.ClearanceHeight.Value,
+ safe_height=obj.SafeHeight.Value,
+ start_depth=obj.StartDepth.Value,
+ step_down=max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN),
+ z_finish_step=0.0,
+ final_depth=obj.FinalDepth.Value,
+ user_depths=None,
+ )
+
+ depths = [d for d in depthParams.data if d < op.stock.Shape.BoundBox.ZMax]
+
+ # Get the stock outline at each stepdown. Used to calculate toolpaths and
+ # for calcuating cut regions in some instances
+ # 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.
+ 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
+ # Output are lists of dicts with "region" and "depths" entries. Depths are
+ # 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.
+ 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
+ # 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()
+ for rdict in regionDicts:
+ discretizedRegions.append(
+ {
+ "edges": [[discretize(w)] for w in rdict["region"].Wires],
+ "depths": rdict["depths"],
+ "id": rdict["id"],
+ "children": rdict["children"],
+ }
+ )
+ return discretizedRegions
+
+ insideDiscretized = _createDiscretizedRegions(insideRegions)
+ outsideDiscretized = _createDiscretizedRegions(outsideRegions)
+
+ # NOTE: REMINDER: This is notably different from machining regions- just
+ # a dict with depth: region entries, single depth for easy lookup
+ stockDiscretized = {}
+ for d in stockProjectionDict:
+ discretizedEdges = list()
+ for a in stockProjectionDict[d].Faces:
+ for w in a.Wires:
+ discretizedEdges.append([discretize(w)])
+ stockDiscretized[d] = discretizedEdges
+
+ # Return found inside and outside regions/depths. Up to the caller to decide
+ # which ones it cares about.
+ # NOTE: REMINDER: Stock is notably different from machining regions- just
+ # a dict with depth: region entries, single depth for easy lookup
+ return insideDiscretized, outsideDiscretized, stockDiscretized
class PathAdaptive(PathOp.ObjectOp):
@@ -898,11 +1453,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",
@@ -912,18 +1462,13 @@ 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",
"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(
@@ -959,7 +1504,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(
@@ -987,7 +1541,6 @@ class PathAdaptive(PathOp.ObjectOp):
QT_TRANSLATE_NOOP("App::Property", "Stop processing"),
)
obj.setEditorMode("Stopped", 2) # hide this property
-
obj.addProperty(
"App::PropertyBool",
"StopProcessing",
@@ -998,7 +1551,6 @@ class PathAdaptive(PathOp.ObjectOp):
),
)
obj.setEditorMode("StopProcessing", 2) # hide this property
-
obj.addProperty(
"App::PropertyBool",
"UseHelixArcs",
@@ -1008,7 +1560,6 @@ class PathAdaptive(PathOp.ObjectOp):
"Use Arcs (G2) for helix ramp",
),
)
-
obj.addProperty(
"App::PropertyPythonObject",
"AdaptiveInputState",
@@ -1056,7 +1607,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",
@@ -1066,7 +1616,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",
@@ -1098,9 +1656,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):
@@ -1108,7 +1668,14 @@ class PathAdaptive(PathOp.ObjectOp):
See documentation of execute() for a list of base functionality provided.
Should be overwritten by subclasses."""
- self.pathArray = _get_working_edges(self, obj)
+ # Contains both geometry to machine and the applicable depths
+ # NOTE: Reminder that stock is formatted differently than inside/outside!
+ inside, outside, stock = _getWorkingEdges(self, obj)
+
+ self.insidePathArray = inside
+ self.outsidePathArray = outside
+ self.stockPathArray = stock
+
Execute(self, obj)
def opOnDocumentRestored(self, obj):
@@ -1128,6 +1695,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
@@ -1135,9 +1724,6 @@ class PathAdaptive(PathOp.ObjectOp):
FeatureExtensions.initialize_properties(obj)
-# Eclass
-
-
def SetupProperties():
setup = [
"Side",
@@ -1147,6 +1733,7 @@ def SetupProperties():
"LiftDistance",
"KeepToolDownRatio",
"StockToLeave",
+ "ZStockToLeave",
"ForceInsideOut",
"FinishingProfile",
"Stopped",
@@ -1158,6 +1745,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