[CAM] make the adaptive model aware feature optional (#24553)

* [CAM] make the adaptive model aware feature optional

* fix CAM tests

* placate github codql

* remove model-aware-only properties from the adaptive task ui panel
This commit is contained in:
David Kaufman
2025-10-17 11:42:15 -04:00
committed by GitHub
parent 6abf3993b0
commit 0335d06311
4 changed files with 279 additions and 52 deletions

View File

@@ -411,6 +411,7 @@ class TestPathAdaptive(PathTestBase):
adaptive.StepOver = 75
adaptive.UseOutline = False
adaptive.setExpression("StepDown", None)
adaptive.ModelAwareExperiment = True
adaptive.StepDown.Value = (
5.0 # Have to set expression to None before numerical value assignment
)
@@ -542,6 +543,7 @@ class TestPathAdaptive(PathTestBase):
adaptive.StepOver = 75
adaptive.UseOutline = False
adaptive.setExpression("StepDown", None)
adaptive.ModelAwareExperiment = True
adaptive.StepDown.Value = (
5.0 # Have to set expression to None before numerical value assignment
)
@@ -623,6 +625,7 @@ class TestPathAdaptive(PathTestBase):
adaptive.StepOver = 75
adaptive.UseOutline = False
adaptive.setExpression("StepDown", None)
adaptive.ModelAwareExperiment = True
adaptive.StepDown.Value = (
5.0 # Have to set expression to None before numerical value assignment
)

View File

@@ -279,32 +279,6 @@ Larger values (further to the right) will calculate faster; smaller values (furt
</property>
</widget>
</item>
<item row="26" column="0">
<widget class="QCheckBox" name="orderCutsByRegion">
<property name="toolTip">
<string>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.</string>
</property>
<property name="text">
<string>Order cuts by region</string>
</property>
</widget>
</item>
<item row="21" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Z stock to leave</string>
</property>
</widget>
</item>
<item row="21" column="1">
<widget class="Gui::QuantitySpinBox" name="ZStockToLeave" native="true">
<property name="toolTip">
<string>How much material to leave along the Z axis (i.e. for finishing operation)</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>

View File

@@ -151,15 +151,15 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
lz = obj.StartDepth.Value
for region in adaptiveResults:
passStartDepth = region["TopDepth"]
passStartDepth = region.get("TopDepth", obj.StartDepth.Value)
depthParams = PathUtils.depth_params(
clearance_height=obj.ClearanceHeight.Value,
safe_height=obj.SafeHeight.Value,
start_depth=region["TopDepth"],
start_depth=passStartDepth,
step_down=stepDown,
z_finish_step=finishStep,
final_depth=region["BottomDepth"],
final_depth=region.get("BottomDepth", obj.FinalDepth.Value),
user_depths=None,
)
@@ -523,6 +523,168 @@ def Execute(op, obj):
# hide old toolpaths during recalculation
obj.Path = Path.Path("(Calculating...)")
oldObjVisibility = oldJobVisibility = False
if FreeCAD.GuiUp:
# store old visibility state
job = op.getJob(obj)
oldObjVisibility = obj.ViewObject.Visibility
oldJobVisibility = job.ViewObject.Visibility
obj.ViewObject.Visibility = False
job.ViewObject.Visibility = False
FreeCADGui.updateGui()
try:
helixDiameter = obj.HelixDiameterLimit.Value
topZ = op.stock.Shape.BoundBox.ZMax
obj.Stopped = False
obj.StopProcessing = False
if obj.Tolerance < 0.001:
obj.Tolerance = 0.001
# 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?",
)
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)
# 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,
"forceInsideOut": obj.ForceInsideOut,
"finishingProfile": obj.FinishingProfile,
"keepToolDownRatio": keepToolDownRatio,
"stockToLeave": float(obj.StockToLeave),
"modelAwareExperiment": obj.ModelAwareExperiment,
}
inputStateChanged = False
adaptiveResults = None
if obj.AdaptiveOutputState is not None and obj.AdaptiveOutputState != "":
adaptiveResults = obj.AdaptiveOutputState
if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject):
inputStateChanged = True
adaptiveResults = None
# progress callback fn, if return true it will stop processing
def progressFn(tpaths):
if FreeCAD.GuiUp:
for (
path
) in tpaths: # path[0] contains the MotionType, #path[1] contains list of points
if path[0] == area.AdaptiveMotionType.Cutting:
sceneDrawPath(path[1], (0, 0, 1))
else:
sceneDrawPath(path[1], (1, 0, 1))
FreeCADGui.updateGui()
return obj.StopProcessing
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
# EXECUTE
results = a2d.Execute(stockPath2d, path2d, progressFn)
# 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,
}
)
# GENERATE
GenerateGCode(op, obj, adaptiveResults, helixDiameter)
if not obj.StopProcessing:
Path.Log.info("*** Done. Elapsed time: %f sec\n\n" % (time.time() - start))
obj.AdaptiveOutputState = adaptiveResults
obj.AdaptiveInputState = inputStateObject
else:
Path.Log.info("*** Processing cancelled (after: %f sec).\n\n" % (time.time() - start))
finally:
if FreeCAD.GuiUp:
obj.ViewObject.Visibility = oldObjVisibility
job.ViewObject.Visibility = oldJobVisibility
sceneClean()
def ExecuteModelAware(op, obj):
global sceneGraph
global topZ
if FreeCAD.GuiUp:
sceneGraph = FreeCADGui.ActiveDocument.ActiveView.getSceneGraph()
Path.Log.info("*** Adaptive toolpath processing started...\n")
# hide old toolpaths during recalculation
obj.Path = Path.Path("(Calculating...)")
oldObjVisibility = oldJobVisibility = False
if FreeCAD.GuiUp:
# store old visibility state
job = op.getJob(obj)
@@ -637,6 +799,7 @@ def Execute(op, obj):
"stockToLeave": obj.StockToLeave.Value,
"zStockToLeave": obj.ZStockToLeave.Value,
"orderCutsByRegion": obj.OrderCutsByRegion,
"modelAwareExperiment": obj.ModelAwareExperiment,
}
inputStateObject = [outsideInputStateObject, insideInputStateObject]
@@ -1178,8 +1341,75 @@ def _workingEdgeHelperManual(op, obj, depths):
return insideRegions, outsideRegions
def _getWorkingEdges(op, obj):
"""_getWorkingEdges(op, obj)...
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.
"""
all_regions = list()
edge_list = list()
avoidFeatures = list()
rawEdges = list()
# Get extensions and identify faces to avoid
extensions = FeatureExtensions.getExtensions(obj)
for e in extensions:
if e.avoid:
avoidFeatures.append(e.feature)
# 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 = []
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)
# 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)])
return edge_list
def _getWorkingEdgesModelAware(op, obj):
"""_getWorkingEdgesModelAware(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,
@@ -1492,7 +1722,7 @@ class PathAdaptive(PathOp.ObjectOp):
"Adaptive",
QT_TRANSLATE_NOOP(
"App::Property",
"How much stock to leave along the Z axis (eg for finishing operation)",
"How much stock to leave along the Z axis (eg for finishing operation). This property is only used if the ModelAwareExperiment is enabled.",
),
)
obj.addProperty(
@@ -1601,7 +1831,7 @@ class PathAdaptive(PathOp.ObjectOp):
"Adaptive",
QT_TRANSLATE_NOOP(
"App::Property",
"Orders cuts by region instead of depth.",
"Orders cuts by region instead of depth. This property is only used if the ModelAwareExperiment is enabled.",
),
)
obj.addProperty(
@@ -1610,6 +1840,17 @@ class PathAdaptive(PathOp.ObjectOp):
"Path",
QT_TRANSLATE_NOOP("App::Property", ""),
)
obj.addProperty(
"App::PropertyBool",
"ModelAwareExperiment",
"Adaptive",
QT_TRANSLATE_NOOP(
"App::Property",
"Enable the experimental model awareness feature to respect 3D geometry and prevent cutting under overhangs",
),
)
obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2)
obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2)
for n in self.propertyEnumerations():
setattr(obj, n[0], n[1])
@@ -1640,6 +1881,7 @@ class PathAdaptive(PathOp.ObjectOp):
obj.UseHelixArcs = False
obj.UseOutline = False
obj.OrderCutsByRegion = False
obj.ModelAwareExperiment = False
FeatureExtensions.set_default_property_values(obj, job)
def opExecute(self, obj):
@@ -1647,15 +1889,22 @@ class PathAdaptive(PathOp.ObjectOp):
See documentation of execute() for a list of base functionality provided.
Should be overwritten by subclasses."""
# 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)
obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2)
obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2)
self.insidePathArray = inside
self.outsidePathArray = outside
self.stockPathArray = stock
if obj.ModelAwareExperiment:
# Contains both geometry to machine and the applicable depths
# NOTE: Reminder that stock is formatted differently than inside/outside!
inside, outside, stock = _getWorkingEdgesModelAware(self, obj)
Execute(self, obj)
self.insidePathArray = inside
self.outsidePathArray = outside
self.stockPathArray = stock
ExecuteModelAware(self, obj)
else:
self.pathArray = _get_working_edges(self, obj)
Execute(self, obj)
def opOnDocumentRestored(self, obj):
if not hasattr(obj, "HelixConeAngle"):
@@ -1696,6 +1945,19 @@ class PathAdaptive(PathOp.ObjectOp):
),
)
if not hasattr(obj, "ModelAwareExperiment"):
obj.addProperty(
"App::PropertyBool",
"ModelAwareExperiment",
"Adaptive",
QT_TRANSLATE_NOOP(
"App::Property",
"Enable the experimental model awareness feature to respect 3D geometry and prevent cutting under overhangs",
),
)
obj.setEditorMode("OrderCutsByRegion", 0 if obj.ModelAwareExperiment else 2)
obj.setEditorMode("ZStockToLeave", 0 if obj.ModelAwareExperiment else 2)
if not hasattr(obj, "removalshape"):
obj.addProperty("Part::PropertyPartShape", "removalshape", "Path", "")
obj.setEditorMode("removalshape", 2) # hide

View File

@@ -49,7 +49,6 @@ 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,18 +64,15 @@ 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)
if hasattr(self.form.ForceInsideOut, "checkStateChanged"): # Qt version >= 6.7.0
signals.append(self.form.ForceInsideOut.checkStateChanged)
signals.append(self.form.FinishingProfile.checkStateChanged)
signals.append(self.form.useOutline.checkStateChanged)
signals.append(self.form.orderCutsByRegion.checkStateChanged)
else: # Qt version < 6.7.0
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
@@ -105,13 +101,9 @@ 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)
@@ -142,13 +134,9 @@ 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