diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpWaterlineEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpWaterlineEdit.ui index 173f0d78b4..4b1036a8df 100644 --- a/src/Mod/CAM/Gui/Resources/panels/PageOpWaterlineEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/PageOpWaterlineEdit.ui @@ -70,7 +70,7 @@ - Select the algorithm to use: 'OCL Dropcutter*', or 'Experimental' (not OCL based). + Select the algorithm to use: 'OCL Dropcutter*', 'OCL Adaptive*' or 'Experimental' (not OCL based). @@ -205,7 +205,24 @@ A step over of 100% results in no overlap between two different cycles. - + + + + Min Sample interval + + + + + + + Set the minimum sampling resolution. Smaller values quickly increase processing time. + + + mm + + + + Enable optimization of linear paths (co-linear points). Removes unnecessary co-linear points from G-code output. @@ -249,6 +266,7 @@ A step over of 100% results in no overlap between two different cycles. cutPattern stepOver sampleInterval + minSampleInterval optimizeEnabled diff --git a/src/Mod/CAM/Path/Op/Gui/Waterline.py b/src/Mod/CAM/Path/Op/Gui/Waterline.py index 53633f5307..29f1ec48c7 100644 --- a/src/Mod/CAM/Path/Op/Gui/Waterline.py +++ b/src/Mod/CAM/Path/Op/Gui/Waterline.py @@ -87,6 +87,7 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): if obj.StepOver != self.form.stepOver.value(): obj.StepOver = self.form.stepOver.value() + PathGuiUtil.updateInputField(obj, "MinSampleInterval", self.form.minSampleInterval) PathGuiUtil.updateInputField(obj, "SampleInterval", self.form.sampleInterval) if obj.OptimizeLinearPaths != self.form.optimizeEnabled.isChecked(): @@ -104,6 +105,9 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): FreeCAD.Units.Quantity(obj.BoundaryAdjustment.Value, FreeCAD.Units.Length).UserString ) self.form.stepOver.setValue(obj.StepOver) + self.form.minSampleInterval.setText( + FreeCAD.Units.Quantity(obj.MinSampleInterval.Value, FreeCAD.Units.Length).UserString + ) self.form.sampleInterval.setText( FreeCAD.Units.Quantity(obj.SampleInterval.Value, FreeCAD.Units.Length).UserString ) @@ -126,7 +130,9 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): signals.append(self.form.cutPattern.currentIndexChanged) signals.append(self.form.boundaryAdjustment.editingFinished) signals.append(self.form.stepOver.editingFinished) + signals.append(self.form.minSampleInterval.editingFinished) signals.append(self.form.sampleInterval.editingFinished) + if hasattr(self.form.optimizeEnabled, "checkStateChanged"): # Qt version >= 6.7.0 signals.append(self.form.optimizeEnabled.checkStateChanged) else: # Qt version < 6.7.0 @@ -146,6 +152,19 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): self.form.boundaryAdjustment_label.hide() self.form.stepOver.hide() self.form.stepOver_label.hide() + self.form.minSampleInterval.hide() + self.form.minSampleInterval_label.hide() + self.form.sampleInterval.show() + self.form.sampleInterval_label.show() + elif Algorithm == "OCL Adaptive": + self.form.cutPattern.hide() + self.form.cutPattern_label.hide() + self.form.boundaryAdjustment.hide() + self.form.boundaryAdjustment_label.hide() + self.form.stepOver.hide() + self.form.stepOver_label.hide() + self.form.minSampleInterval.show() + self.form.minSampleInterval_label.show() self.form.sampleInterval.show() self.form.sampleInterval_label.show() elif Algorithm == "Experimental": @@ -159,6 +178,8 @@ class TaskPanelOpPage(PathOpGui.TaskPanelPage): else: self.form.stepOver.show() self.form.stepOver_label.show() + self.form.minSampleInterval.hide() + self.form.minSampleInterval_label.hide() self.form.sampleInterval.hide() self.form.sampleInterval_label.hide() diff --git a/src/Mod/CAM/Path/Op/SurfaceSupport.py b/src/Mod/CAM/Path/Op/SurfaceSupport.py index 0d31e60fff..4c8a7a6d24 100644 --- a/src/Mod/CAM/Path/Op/SurfaceSupport.py +++ b/src/Mod/CAM/Path/Op/SurfaceSupport.py @@ -37,7 +37,7 @@ import math # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader -# MeshPart = LazyLoader('MeshPart', globals(), 'MeshPart') +MeshPart = LazyLoader("MeshPart", globals(), "MeshPart") # tessellate bug Workaround Part = LazyLoader("Part", globals(), "Part") @@ -1254,6 +1254,22 @@ def _makeSTL(model, obj, ocl, model_type=None): """Convert a mesh or shape into an OCL STL, using the tessellation tolerance specified in obj.LinearDeflection. Returns an ocl.STLSurf().""" + # Determine Deflection Values + lin_def = obj.LinearDeflection.Value + ang_def = obj.AngularDeflection.Value + + # Apply Overrides for Waterline OCL Adaptive + # OCL Adaptive is a Vector-based algorithm, not a Grid-based algorithm (like Dropcutter) + # This fundamental difference makes it sensitive to Topology (how points connect) rather than just density + # Models with internal features can cause the algorithm to be confused even with very high density values. + # The following values create the cleanest possible Topology for a vector-slicing algorithm + # Setting those values here rather than hacking the Obj values in Waterline.py is preferable. + algo = getattr(obj, "Algorithm", None) + if algo == "OCL Adaptive": + # Force the "Sweet Spot" values for topology stability (Good enough for 99% or more of operations) + lin_def = 0.001 + ang_def = 0.15 + if model_type == "M": facets = model.Mesh.Facets.Points else: @@ -1261,7 +1277,15 @@ def _makeSTL(model, obj, ocl, model_type=None): shape = model.Shape else: shape = model - vertices, facet_indices = shape.tessellate(obj.LinearDeflection.Value) + # vertices, facet_indices = shape.tessellate(obj.LinearDeflection.Value) # tessellate workaround + # Workaround for tessellate bug + mesh = MeshPart.meshFromShape( + Shape=shape, + LinearDeflection=obj.lin_def, + AngularDeflection=obj.ang_def, + ) + vertices = [point.Vector for point in mesh.Points] + facet_indices = [facet.PointIndices for facet in mesh.Facets] facets = ((vertices[f[0]], vertices[f[1]], vertices[f[2]]) for f in facet_indices) stl = ocl.STLSurf() for tri in facets: diff --git a/src/Mod/CAM/Path/Op/Waterline.py b/src/Mod/CAM/Path/Op/Waterline.py index 36b93214a8..88d915d2a2 100644 --- a/src/Mod/CAM/Path/Op/Waterline.py +++ b/src/Mod/CAM/Path/Op/Waterline.py @@ -95,6 +95,7 @@ class ObjectWaterline(PathOp.ObjectOp): enums = { "Algorithm": [ (translate("path_waterline", "OCL Dropcutter"), "OCL Dropcutter"), + (translate("path_waterline", "OCL Adaptive"), "OCL Adaptive"), (translate("path_waterline", "Experimental"), "Experimental"), ], "BoundBox": [ @@ -289,7 +290,7 @@ class ObjectWaterline(PathOp.ObjectOp): "Clearing Options", QT_TRANSLATE_NOOP( "App::Property", - "Select the algorithm to use: OCL Dropcutter*, or Experimental (Not OCL based).", + "Select the algorithm to use: OCL Dropcutter*, OCL Adaptive or Experimental (Not OCL based).", ), ), ( @@ -392,6 +393,15 @@ class ObjectWaterline(PathOp.ObjectOp): "Set the sampling resolution. Smaller values quickly increase processing time.", ), ), + ( + "App::PropertyDistance", + "MinSampleInterval", + "Clearing Options", + QT_TRANSLATE_NOOP( + "App::Property", + "Set the minimum sampling resolution. Smaller values quickly increase processing time.", + ), + ), ( "App::PropertyFloat", "StepOver", @@ -479,6 +489,7 @@ class ObjectWaterline(PathOp.ObjectOp): "CutPatternAngle": 0.0, "DepthOffset": 0.0, "SampleInterval": 1.0, + "MinSampleInterval": 0.005, "BoundaryAdjustment": 0.0, "InternalFeaturesAdjustment": 0.0, "AvoidLastX_Faces": 0, @@ -505,7 +516,7 @@ class ObjectWaterline(PathOp.ObjectOp): def setEditorProperties(self, obj): # Used to hide inputs in properties list expMode = G = 0 - show = hide = A = B = C = 2 + show = hide = A = B = C = D = 2 obj.setEditorMode("BoundaryEnforcement", hide) obj.setEditorMode("InternalFeaturesAdjustment", hide) @@ -521,9 +532,12 @@ class ObjectWaterline(PathOp.ObjectOp): if obj.Algorithm == "OCL Dropcutter": pass + elif obj.Algorithm == "OCL Adaptive": + D = 0 + expMode = 2 elif obj.Algorithm == "Experimental": A = B = C = 0 - expMode = G = show = hide = 2 + expMode = G = D = show = hide = 2 cutPattern = obj.CutPattern if obj.ClearLastLayer != "Off": @@ -549,6 +563,7 @@ class ObjectWaterline(PathOp.ObjectOp): obj.setEditorMode("IgnoreOuterAbove", B) obj.setEditorMode("CutPattern", C) obj.setEditorMode("SampleInterval", G) + obj.setEditorMode("MinSampleInterval", D) obj.setEditorMode("LinearDeflection", expMode) obj.setEditorMode("AngularDeflection", expMode) @@ -649,6 +664,24 @@ class ObjectWaterline(PathOp.ObjectOp): ) ) + # Limit min sample interval + if obj.MinSampleInterval.Value < 0.0001: + obj.MinSampleInterval.Value = 0.0001 + Path.Log.error( + translate( + "PathWaterline", + "Min Sample interval limits are 0.0001 to 25.4 millimeters.", + ) + ) + if obj.MinSampleInterval.Value > 25.4: + obj.MinSampleInterval.Value = 25.4 + Path.Log.error( + translate( + "PathWaterline", + "Min Sample interval limits are 0.0001 to 25.4 millimeters.", + ) + ) + # Limit cut pattern angle if obj.CutPatternAngle < -360.0: obj.CutPatternAngle = 0.0 @@ -912,7 +945,7 @@ class ObjectWaterline(PathOp.ObjectOp): for m in range(0, len(JOB.Model.Group)): # Create OCL.stl model objects - if obj.Algorithm == "OCL Dropcutter": + if obj.Algorithm == "OCL Dropcutter" or obj.Algorithm == "OCL Adaptive": PathSurfaceSupport._prepareModelSTLs(self, JOB, obj, m, ocl) Mdl = JOB.Model.Group[m] @@ -930,7 +963,7 @@ class ObjectWaterline(PathOp.ObjectOp): ) Path.Log.info("Working on Model.Group[{}]: {}".format(m, Mdl.Label)) # make stock-model-voidShapes STL model for avoidance detection on transitions - if obj.Algorithm == "OCL Dropcutter": + if obj.Algorithm == "OCL Dropcutter" or obj.Algorithm == "OCL Adaptive": PathSurfaceSupport._makeSafeSTL(self, JOB, obj, m, FACES[m], VOIDS[m], ocl) # Process model/faces - OCL objects must be ready CMDS.extend(self._processWaterlineAreas(JOB, obj, m, FACES[m], VOIDS[m])) @@ -1027,7 +1060,7 @@ class ObjectWaterline(PathOp.ObjectOp): COMP = ADD final.append(Path.Command("G0", {"Z": obj.SafeHeight.Value, "F": self.vertRapid})) - if obj.Algorithm == "OCL Dropcutter": + if obj.Algorithm == "OCL Dropcutter" or obj.Algorithm == "OCL Adaptive": final.extend( self._oclWaterlineOp(JOB, obj, mdlIdx, COMP) ) # independent method set for Waterline @@ -1053,7 +1086,7 @@ class ObjectWaterline(PathOp.ObjectOp): COMP = ADD final.append(Path.Command("G0", {"Z": obj.SafeHeight.Value, "F": self.vertRapid})) - if obj.Algorithm == "OCL Dropcutter": + if obj.Algorithm == "OCL Dropcutter" or obj.Algorithm == "OCL Adaptive": final.extend( self._oclWaterlineOp(JOB, obj, mdlIdx, COMP) ) # independent method set for Waterline @@ -1184,7 +1217,7 @@ class ObjectWaterline(PathOp.ObjectOp): pdc.setSampling(SampleInterval) # set sampling size return pdc - # OCL Dropcutter waterline functions + # OCL Dropcutter - OCL Adaptive waterline functions def _oclWaterlineOp(self, JOB, obj, mdlIdx, subShp=None): """_oclWaterlineOp(obj, base) ... Main waterline function to perform waterline extraction from model.""" commands = [] @@ -1200,35 +1233,10 @@ class ObjectWaterline(PathOp.ObjectOp): if self.layerEndPnt is None: self.layerEndPnt = FreeCAD.Vector(0.0, 0.0, 0.0) - # Set extra offset to diameter of cutter to allow cutter to move around perimeter of model - - if subShp is None: - # Get correct boundbox - if obj.BoundBox == "Stock": - BS = JOB.Stock - bb = BS.Shape.BoundBox - elif obj.BoundBox == "BaseBoundBox": - BS = base - bb = base.Shape.BoundBox - - xmin = bb.XMin - xmax = bb.XMax - ymin = bb.YMin - ymax = bb.YMax - else: - xmin = subShp.BoundBox.XMin - xmax = subShp.BoundBox.XMax - ymin = subShp.BoundBox.YMin - ymax = subShp.BoundBox.YMax - smplInt = obj.SampleInterval.Value - minSampInt = 0.001 # value is mm - if smplInt < minSampInt: - smplInt = minSampInt - - # Determine bounding box length for the OCL scan - bbLength = math.fabs(ymax - ymin) - numScanLines = int(math.ceil(bbLength / smplInt) + 1) # Number of lines + minSmplInt = obj.MinSampleInterval.Value + if minSmplInt > smplInt: + minSmplInt = smplInt # Compute number and size of stepdowns, and final depth if obj.LayerMode == "Single-pass": @@ -1238,37 +1246,107 @@ class ObjectWaterline(PathOp.ObjectOp): lenDP = len(depthparams) # Scan the piece to depth at smplInt - oclScan = [] - oclScan = self._waterlineDropCutScan( - stl, smplInt, xmin, xmax, ymin, depthparams[lenDP - 1], numScanLines - ) - oclScan = [FreeCAD.Vector(P.x, P.y, P.z + depOfst) for P in oclScan] - lenOS = len(oclScan) - ptPrLn = int(lenOS / numScanLines) + if obj.Algorithm == "OCL Adaptive": + # Get Stock Bounding Box + BS = JOB.Stock + stock_bb = BS.Shape.BoundBox - # Convert oclScan list of points to multi-dimensional list - scanLines = [] - for L in range(0, numScanLines): - scanLines.append([]) - for P in range(0, ptPrLn): - pi = L * ptPrLn + P - scanLines[L].append(oclScan[pi]) - lenSL = len(scanLines) - pntsPerLine = len(scanLines[0]) - msg = "--OCL scan: " + str(lenSL * pntsPerLine) + " points, with " - msg += str(numScanLines) + " lines and " + str(pntsPerLine) + " pts/line" - Path.Log.debug(msg) + # Stock Limits + s_xmin = stock_bb.XMin + s_xmax = stock_bb.XMax + s_ymin = stock_bb.YMin + s_ymax = stock_bb.YMax + + # Calculate Tool Path Limits based on OCL STL + path_min_x = stl.bb.minpt.x - self.radius + path_min_y = stl.bb.minpt.y - self.radius + path_max_x = stl.bb.maxpt.x + self.radius + path_max_y = stl.bb.maxpt.y + self.radius + + # Compare with a tiny tolerance + tol = 0.001 + if ( + (path_min_x < s_xmin - tol) + or (path_min_y < s_ymin - tol) + or (path_max_x > s_xmax + tol) + or (path_max_y > s_ymax + tol) + ): + + newPropMsg = translate( + "PathWaterline", + "The toolpath has exceeded the stock bounding box limits. Consider using a Boundary Dressup.", + ) + FreeCAD.Console.PrintWarning(newPropMsg + "\n") + + # Run the Scan (Processing ALL depths at once) + scanLines = self._waterlineAdaptiveScan(stl, smplInt, minSmplInt, depthparams, depOfst) + + # Generate G-Code + layTime = time.time() + for loop in scanLines: + # We pass '0.0' as layDep because Adaptive loops have their own Z embedded + cmds = self._loopToGcode(obj, 0.0, loop) + commands.extend(cmds) + + Path.Log.debug("--Adaptive generation took " + str(time.time() - layTime) + " s") + + else: + # Setup BoundBox for Dropcutter grid + if subShp is None: + # Get correct boundbox + if obj.BoundBox == "Stock": + BS = JOB.Stock + bb = BS.Shape.BoundBox + elif obj.BoundBox == "BaseBoundBox": + BS = base + bb = BS.Shape.BoundBox + + xmin = bb.XMin + xmax = bb.XMax + ymin = bb.YMin + ymax = bb.YMax + else: + xmin = subShp.BoundBox.XMin + xmax = subShp.BoundBox.XMax + ymin = subShp.BoundBox.YMin + ymax = subShp.BoundBox.YMax + + # Determine bounding box length for the OCL scan + bbLength = math.fabs(ymax - ymin) + numScanLines = int(math.ceil(bbLength / smplInt) + 1) + + # Run Scan (Grid based) + fd = depthparams[-1] + oclScan = self._waterlineDropCutScan(stl, smplInt, xmin, xmax, ymin, fd, numScanLines) + oclScan = [FreeCAD.Vector(P.x, P.y, P.z + depOfst) for P in oclScan] + + # Convert point list to grid (scanLines) + lenOS = len(oclScan) + ptPrLn = int(lenOS / numScanLines) + scanLines = [] + for L in range(0, numScanLines): + scanLines.append([]) + for P in range(0, ptPrLn): + pi = L * ptPrLn + P + scanLines[L].append(oclScan[pi]) + + # Extract Waterline Layers Iteratively + lenSL = len(scanLines) + pntsPerLine = len(scanLines[0]) + msg = "--OCL scan: " + str(lenSL * pntsPerLine) + " points, with " + msg += str(numScanLines) + " lines and " + str(pntsPerLine) + " pts/line" + Path.Log.debug(msg) + + lyr = 0 + cmds = [] + layTime = time.time() + self.topoMap = [] + for layDep in depthparams: + cmds = self._getWaterline(obj, scanLines, layDep, lyr, lenSL, pntsPerLine) + commands.extend(cmds) + lyr += 1 + Path.Log.debug("--All layer scans combined took " + str(time.time() - layTime) + " s") - # Extract Wl layers per depthparams - lyr = 0 - cmds = [] - layTime = time.time() - self.topoMap = [] - for layDep in depthparams: - cmds = self._getWaterline(obj, scanLines, layDep, lyr, lenSL, pntsPerLine) - commands.extend(cmds) - lyr += 1 - Path.Log.debug("--All layer scans combined took " + str(time.time() - layTime) + " s") return commands def _waterlineDropCutScan(self, stl, smplInt, xmin, xmax, ymin, fd, numScanLines): @@ -1294,20 +1372,66 @@ class ObjectWaterline(PathOp.ObjectOp): # return the list of points return pdc.getCLPoints() + def _waterlineAdaptiveScan(self, stl, smplInt, minSmplInt, zheights, depOfst): + """Perform OCL Adaptive scan for waterline purpose.""" + + msg = translate( + "Waterline", ": Steps below the model's top Face will be the only ones processed." + ) + Path.Log.info("Waterline " + msg) + + # Setup OCL AdaptiveWaterline + awl = ocl.AdaptiveWaterline() + awl.setSTL(stl) + awl.setCutter(self.cutter) + awl.setSampling(smplInt) + awl.setMinSampling(minSmplInt) + + adapt_loops = [] + + # Iterate through each Z-depth + for zh in zheights: + awl.setZ(zh) + awl.run() + + # OCL returns a list of separate loops (list of lists of Points) + # Example: [[PerimeterPoints], [HolePoints]] + temp_loops = awl.getLoops() + + if not temp_loops: + # Warn if the step is outside the model bounds + newPropMsg = translate("PathWaterline", "Step Down above model. Skipping height : ") + newPropMsg += "{} mm".format(zh) + FreeCAD.Console.PrintWarning(newPropMsg + "\n") + continue + + # Process each loop separately. + # This ensures that islands (holes) remain distinct from perimeters. + for loop in temp_loops: + # Convert OCL Points to FreeCAD Vectors and apply Z offset + fc_loop = [FreeCAD.Vector(P.x, P.y, P.z + depOfst) for P in loop] + adapt_loops.append(fc_loop) + + return adapt_loops + def _getWaterline(self, obj, scanLines, layDep, lyr, lenSL, pntsPerLine): """_getWaterline(obj, scanLines, layDep, lyr, lenSL, pntsPerLine) ... Get waterline.""" commands = [] cmds = [] loopList = [] self.topoMap = [] - # Create topo map from scanLines (highs and lows) - self.topoMap = self._createTopoMap(scanLines, layDep, lenSL, pntsPerLine) - # Add buffer lines and columns to topo map - self._bufferTopoMap(lenSL, pntsPerLine) - # Identify layer waterline from OCL scan - self._highlightWaterline(4, 9) - # Extract waterline and convert to gcode - loopList = self._extractWaterlines(obj, scanLines, lyr, layDep) + if obj.Algorithm == "OCL Adaptive": + loopList = scanLines + else: + # Create topo map from scanLines (highs and lows) + self.topoMap = self._createTopoMap(scanLines, layDep, lenSL, pntsPerLine) + # Add buffer lines and columns to topo map + self._bufferTopoMap(lenSL, pntsPerLine) + # Identify layer waterline from OCL scan + self._highlightWaterline(4, 9) + # Extract waterline and convert to gcode + loopList = self._extractWaterlines(obj, scanLines, lyr, layDep) + # save commands for loop in loopList: cmds = self._loopToGcode(obj, layDep, loop) @@ -1638,14 +1762,27 @@ class ObjectWaterline(PathOp.ObjectOp): # generate the path commands output = [] - # prev = FreeCAD.Vector(2135984513.165, -58351896873.17455, 13838638431.861) + # Safety check for empty loops + if not loop: + return output + nxt = FreeCAD.Vector(0.0, 0.0, 0.0) - # Create first point - pnt = FreeCAD.Vector(loop[0].x, loop[0].y, layDep) + # Create (first and last) point + if obj.Algorithm == "OCL Adaptive": + if obj.CutMode == "Climb": + # Reverse loop for Climb Milling + loop.reverse() + pnt = pnt1 = FreeCAD.Vector(loop[0].x, loop[0].y, loop[0].z) + else: + pnt = FreeCAD.Vector(loop[0].x, loop[0].y, layDep) # Position cutter to begin loop - output.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid})) + if self.layerEndPnt.x == 0 and self.layerEndPnt.y == 0: # First to Clearance Height + output.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value, "F": self.vertRapid})) + else: + output.append(Path.Command("G0", {"Z": obj.SafeHeight.Value, "F": self.vertRapid})) + output.append(Path.Command("G0", {"X": pnt.x, "Y": pnt.y, "F": self.horizRapid})) output.append(Path.Command("G1", {"Z": pnt.z, "F": self.vertFeed})) @@ -1656,13 +1793,19 @@ class ObjectWaterline(PathOp.ObjectOp): if i < lastIdx: nxt.x = loop[i + 1].x nxt.y = loop[i + 1].y - nxt.z = layDep - + if obj.Algorithm == "OCL Adaptive": + nxt.z = loop[i + 1].z + else: + nxt.z = layDep output.append(Path.Command("G1", {"X": pnt.x, "Y": pnt.y, "F": self.horizFeed})) # Rotate point data pnt = nxt + # Connect first and last points for Adaptive + if obj.Algorithm == "OCL Adaptive": + output.append(Path.Command("G1", {"X": pnt1.x, "Y": pnt1.y, "F": self.horizFeed})) + # Save layer end point for use in transitioning to next layer self.layerEndPnt = pnt