CAM: Adaptive: Machine entire model if no faces/edges are selected ("adaptive roughing")

This commit is contained in:
Dan Taylor
2025-04-02 20:55:21 -05:00
parent 5c1e93759a
commit 2ff623e6ca
2 changed files with 300 additions and 11 deletions

View File

@@ -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

View File

@@ -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",