Download src/Mod/CAM/Path/Op/MillFacing.py from AbdulElahGwaith/FreeCAD: direct link, hf CLI and curl.
- Browser
- Download file 34.5 kB
-
https://huggingface.co/AbdulElahGwaith/FreeCAD/resolve/main/src/Mod/CAM/Path/Op/MillFacing.py
- Command line
-
hf download hf://AbdulElahGwaith/FreeCAD/src/Mod/CAM/Path/Op/MillFacing.py
-
curl -L -o MillFacing.py https://huggingface.co/AbdulElahGwaith/FreeCAD/resolve/main/src/Mod/CAM/Path/Op/MillFacing.py
34.5 kB
| # -*- coding: utf-8 -*- | |
| # SPDX-License-Identifier: LGPL-2.1-or-later | |
| # *************************************************************************** | |
| # * * | |
| # * Copyright (c) 2025 sliptonic sliptonic@freecad.org * | |
| # * * | |
| # * This file is part of FreeCAD. * | |
| # * * | |
| # * FreeCAD is free software: you can redistribute it and/or modify it * | |
| # * under the terms of the GNU Lesser General Public License as * | |
| # * published by the Free Software Foundation, either version 2.1 of the * | |
| # * License, or (at your option) any later version. * | |
| # * * | |
| # * FreeCAD is distributed in the hope that it will be useful, but * | |
| # * WITHOUT ANY WARRANTY; without even the implied warranty of * | |
| # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * | |
| # * Lesser General Public License for more details. * | |
| # * * | |
| # * You should have received a copy of the GNU Lesser General Public * | |
| # * License along with FreeCAD. If not, see * | |
| # * <https://www.gnu.org/licenses/>. * | |
| # * * | |
| # *************************************************************************** | |
| __title__ = "CAM Mill Facing Operation" | |
| __author__ = "sliptonic (Brad Collette)" | |
| __url__ = "https://www.freecad.org" | |
| __doc__ = "Class and implementation of Mill Facing operation." | |
| __contributors__ = "" | |
| import FreeCAD | |
| from PySide import QtCore | |
| import Path | |
| import Path.Op.Base as PathOp | |
| import Path.Base.Generator.spiral_facing as spiral_facing | |
| import Path.Base.Generator.zigzag_facing as zigzag_facing | |
| import Path.Base.Generator.directional_facing as directional_facing | |
| import Path.Base.Generator.bidirectional_facing as bidirectional_facing | |
| import Path.Base.Generator.linking as linking | |
| import PathScripts.PathUtils as PathUtils | |
| import Path.Base.FeedRate as FeedRate | |
| # lazily loaded modules | |
| from lazy_loader.lazy_loader import LazyLoader | |
| Part = LazyLoader("Part", globals(), "Part") | |
| Arcs = LazyLoader("draftgeoutils.arcs", globals(), "draftgeoutils.arcs") | |
| if FreeCAD.GuiUp: | |
| FreeCADGui = LazyLoader("FreeCADGui", globals(), "FreeCADGui") | |
| translate = FreeCAD.Qt.translate | |
| if False: | |
| Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) | |
| Path.Log.trackModule(Path.Log.thisModule()) | |
| else: | |
| Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) | |
| class ObjectMillFacing(PathOp.ObjectOp): | |
| """Proxy object for Mill Facing operation.""" | |
| def opFeatures(self, obj): | |
| """opFeatures(obj) ... return all standard features""" | |
| return ( | |
| PathOp.FeatureTool | |
| | PathOp.FeatureDepths | |
| | PathOp.FeatureHeights | |
| | PathOp.FeatureStepDown | |
| | PathOp.FeatureCoolant | |
| ) | |
| def initOperation(self, obj): | |
| """initOperation(obj) ... Initialize the operation by | |
| managing property creation and property editor status.""" | |
| self.propertiesReady = False | |
| self.initOpProperties(obj) # Initialize operation-specific properties | |
| def initOpProperties(self, obj, warn=False): | |
| """initOpProperties(obj) ... create operation specific properties""" | |
| Path.Log.track() | |
| self.addNewProps = list() | |
| for prtyp, nm, grp, tt in self.opPropertyDefinitions(): | |
| if not hasattr(obj, nm): | |
| obj.addProperty(prtyp, nm, grp, tt) | |
| self.addNewProps.append(nm) | |
| # Set enumeration lists for enumeration properties | |
| if len(self.addNewProps) > 0: | |
| ENUMS = self.propertyEnumerations() | |
| for n in ENUMS: | |
| if n[0] in self.addNewProps: | |
| setattr(obj, n[0], n[1]) | |
| if warn: | |
| newPropMsg = translate("CAM_MIllFacing", "New property added to") | |
| newPropMsg += ' "{}": {}'.format(obj.Label, self.addNewProps) + ". " | |
| newPropMsg += translate("CAM_MillFacing", "Check default value(s).") | |
| FreeCAD.Console.PrintWarning(newPropMsg + "\n") | |
| self.propertiesReady = True | |
| def onChanged(self, obj, prop): | |
| """onChanged(obj, prop) ... Called when a property changes""" | |
| if prop == "StepOver" and hasattr(obj, "StepOver"): | |
| # Validate StepOver is between 0 and 100 percent | |
| if obj.StepOver < 0: | |
| obj.StepOver = 0 | |
| elif obj.StepOver > 100: | |
| obj.StepOver = 100 | |
| if prop == "Active" and obj.ViewObject: | |
| obj.ViewObject.signalChangeIcon() | |
| def opPropertyDefinitions(self): | |
| """opPropertyDefinitions(obj) ... Store operation specific properties""" | |
| return [ | |
| ( | |
| "App::PropertyEnumeration", | |
| "CutMode", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Set the cut mode for the operation.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyEnumeration", | |
| "ClearingPattern", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Set the clearing pattern for the operation.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyAngle", | |
| "Angle", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Set the angle for the operation.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyPercent", | |
| "StepOver", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Set the stepover percentage of tool diameter.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyDistance", | |
| "AxialStockToLeave", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Set the stock to leave for the operation.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyDistance", | |
| "PassExtension", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Distance to extend cuts beyond polygon boundary for tool disengagement.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyDistance", | |
| "StockExtension", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Extends the boundary in both direction.", | |
| ), | |
| ), | |
| ( | |
| "App::PropertyBool", | |
| "Reverse", | |
| "Facing", | |
| QtCore.QT_TRANSLATE_NOOP( | |
| "App::Property", | |
| "Reverse the cutting direction for the selected pattern.", | |
| ), | |
| ), | |
| ] | |
| def propertyEnumerations(self, dataType="data"): | |
| """propertyEnumerations(dataType="data")... return property enumeration lists of specified dataType. | |
| Args: | |
| dataType = 'data', 'raw', 'translated' | |
| Notes: | |
| 'data' is list of internal string literals used in code | |
| 'raw' is list of (translated_text, data_string) tuples | |
| 'translated' is list of translated string literals | |
| """ | |
| Path.Log.track() | |
| enums = { | |
| "CutMode": [ | |
| (translate("CAM_MillFacing", "Climb"), "Climb"), | |
| (translate("CAM_MillFacing", "Conventional"), "Conventional"), | |
| ], | |
| "ClearingPattern": [ | |
| (translate("CAM_MillFacing", "ZigZag"), "ZigZag"), | |
| (translate("CAM_MillFacing", "Bidirectional"), "Bidirectional"), | |
| (translate("CAM_MillFacing", "Directional"), "Directional"), | |
| (translate("CAM_MillFacing", "Spiral"), "Spiral"), | |
| ], | |
| } | |
| if dataType == "raw": | |
| return enums | |
| data = list() | |
| idx = 0 if dataType == "translated" else 1 | |
| Path.Log.debug(enums) | |
| for k, v in enumerate(enums): | |
| data.append((v, [tup[idx] for tup in enums[v]])) | |
| Path.Log.debug(data) | |
| return data | |
| def opPropertyDefaults(self, obj, job): | |
| """opPropertyDefaults(obj, job) ... returns a dictionary of default values | |
| for the operation's properties.""" | |
| defaults = { | |
| "CutMode": "Climb", | |
| "ClearingPattern": "ZigZag", | |
| "Angle": 0, | |
| "StepOver": 25, | |
| "AxialStockToLeave": 0.0, | |
| } | |
| return defaults | |
| def opSetDefaultValues(self, obj, job): | |
| """opSetDefaultValues(obj, job) ... set default values for operation-specific properties""" | |
| Path.Log.track() | |
| # Set default values directly like other operations do | |
| obj.CutMode = "Climb" | |
| obj.ClearingPattern = "ZigZag" | |
| obj.Angle = 0.0 | |
| obj.StepOver = 25 # 25% as percentage | |
| obj.AxialStockToLeave = 0.0 | |
| obj.PassExtension = ( | |
| 3.0 # Default to 3mm, will be adjusted based on tool diameter in opExecute | |
| ) | |
| obj.Reverse = False | |
| def opExecute(self, obj): | |
| """opExecute(obj) ... process Mill Facing operation""" | |
| Path.Log.track() | |
| Path.Log.debug("MillFacing.opExecute() starting") | |
| # Get tool information | |
| tool = obj.ToolController.Tool | |
| Path.Log.debug(f"Tool: {tool.Label if tool else 'None'}") | |
| tool_diameter = tool.Diameter.Value | |
| Path.Log.debug(f"Tool diameter: {tool_diameter}") | |
| # Determine the step-downs | |
| finish_step = 0.0 # No finish step for facing | |
| Path.Log.debug( | |
| f"Depth parameters: clearance={obj.ClearanceHeight.Value}, safe={obj.SafeHeight.Value}, start={obj.StartDepth.Value}, step={obj.StepDown.Value}, final={obj.FinalDepth.Value + obj.AxialStockToLeave.Value}" | |
| ) | |
| depthparams = PathUtils.depth_params( | |
| clearance_height=obj.ClearanceHeight.Value, | |
| safe_height=obj.SafeHeight.Value, | |
| start_depth=obj.StartDepth.Value, | |
| step_down=obj.StepDown.Value, | |
| z_finish_step=finish_step, | |
| final_depth=obj.FinalDepth.Value + obj.AxialStockToLeave.Value, | |
| user_depths=None, | |
| ) | |
| Path.Log.debug(f"Depth params object: {depthparams}") | |
| # Always use the stock object top face for facing operations | |
| job = PathUtils.findParentJob(obj) | |
| Path.Log.debug(f"Job: {job.Label if job else 'None'}") | |
| if job and job.Stock: | |
| Path.Log.debug(f"Stock: {job.Stock.Label}") | |
| stock_faces = job.Stock.Shape.Faces | |
| Path.Log.debug(f"Number of stock faces: {len(stock_faces)}") | |
| # Find faces with normal pointing toward Z+ (upward) | |
| z_up_faces = [] | |
| for face in stock_faces: | |
| # Get face normal at center | |
| u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2 | |
| v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2 | |
| normal = face.normalAt(u_mid, v_mid) | |
| Path.Log.debug(f"Face normal: {normal}, Z component: {normal.z}") | |
| # Check if normal points upward (Z+ direction) with some tolerance | |
| if normal.z > 0.9: # Allow for slight deviation from perfect vertical | |
| z_up_faces.append(face) | |
| Path.Log.debug(f"Found upward-facing face at Z={face.BoundBox.ZMax}") | |
| if not z_up_faces: | |
| Path.Log.error("No upward-facing faces found in stock") | |
| raise ValueError("No upward-facing faces found in stock") | |
| # From the upward-facing faces, select the highest one | |
| top_face = max(z_up_faces, key=lambda f: f.BoundBox.ZMax) | |
| Path.Log.debug(f"Selected top face ZMax: {top_face.BoundBox.ZMax}") | |
| boundary_wire = top_face.OuterWire | |
| Path.Log.debug(f"Wire vertices: {len(boundary_wire.Vertexes)}") | |
| else: | |
| Path.Log.error("No stock found for facing operation") | |
| raise ValueError("No stock found for facing operation") | |
| boundary_wire = boundary_wire.makeOffset2D( | |
| obj.StockExtension.Value, 2 | |
| ) # offset with intersection joins | |
| # Determine milling direction | |
| milling_direction = "climb" if obj.CutMode == "Climb" else "conventional" | |
| # Get operation parameters | |
| stepover_percent = obj.StepOver | |
| pass_extension = ( | |
| obj.PassExtension.Value if hasattr(obj, "PassExtension") else tool_diameter * 0.5 | |
| ) | |
| retract_height = obj.SafeHeight.Value | |
| # Generate the base toolpath for one depth level based on clearing pattern | |
| try: | |
| if obj.ClearingPattern == "Spiral": | |
| # Spiral has different signature - no pass_extension or retract_height | |
| Path.Log.debug("Generating spiral toolpath") | |
| base_commands = spiral_facing.spiral( | |
| polygon=boundary_wire, | |
| tool_diameter=tool_diameter, | |
| stepover_percent=stepover_percent, | |
| milling_direction=milling_direction, | |
| reverse=bool(getattr(obj, "Reverse", False)), | |
| angle_degrees=getattr(obj.Angle, "Value", obj.Angle), | |
| ) | |
| elif obj.ClearingPattern == "ZigZag": | |
| Path.Log.debug("Generating zigzag toolpath") | |
| base_commands = zigzag_facing.zigzag( | |
| polygon=boundary_wire, | |
| tool_diameter=tool_diameter, | |
| stepover_percent=stepover_percent, | |
| pass_extension=pass_extension, | |
| retract_height=retract_height, | |
| milling_direction=milling_direction, | |
| reverse=bool(getattr(obj, "Reverse", False)), | |
| angle_degrees=getattr(obj.Angle, "Value", obj.Angle), | |
| ) | |
| elif obj.ClearingPattern == "Bidirectional": | |
| Path.Log.debug("Generating bidirectional toolpath") | |
| base_commands = bidirectional_facing.bidirectional( | |
| polygon=boundary_wire, | |
| tool_diameter=tool_diameter, | |
| stepover_percent=stepover_percent, | |
| pass_extension=pass_extension, | |
| milling_direction=milling_direction, | |
| reverse=bool(getattr(obj, "Reverse", False)), | |
| angle_degrees=getattr(obj.Angle, "Value", obj.Angle), | |
| ) | |
| elif obj.ClearingPattern == "Directional": | |
| Path.Log.debug("Generating directional toolpath") | |
| base_commands = directional_facing.directional( | |
| polygon=boundary_wire, | |
| tool_diameter=tool_diameter, | |
| stepover_percent=stepover_percent, | |
| pass_extension=pass_extension, | |
| retract_height=retract_height, | |
| milling_direction=milling_direction, | |
| reverse=bool(getattr(obj, "Reverse", False)), | |
| angle_degrees=getattr(obj.Angle, "Value", obj.Angle), | |
| ) | |
| else: | |
| Path.Log.error(f"Unknown clearing pattern: {obj.ClearingPattern}") | |
| raise ValueError(f"Unknown clearing pattern: {obj.ClearingPattern}") | |
| Path.Log.debug(f"Generated {len(base_commands)} base commands") | |
| Path.Log.debug(base_commands) | |
| except Exception as e: | |
| Path.Log.error(f"Error generating toolpath: {e}") | |
| raise | |
| # clear commandlist | |
| self.commandlist = [] | |
| # Be safe. Add first G0 to clearance height | |
| targetZ = obj.ClearanceHeight.Value | |
| self.commandlist.append(Path.Command("G0", {"Z": targetZ})) | |
| # Process each step-down using iterator protocol and add to commandlist | |
| depth_count = 0 | |
| try: | |
| while True: | |
| depth = depthparams.next() | |
| depth_count += 1 | |
| Path.Log.debug(f"Processing depth {depth_count}: {depth}") | |
| if depth_count == 1: | |
| # First stepdown preamble: | |
| # 1) Rapid to ClearanceHeight (already done at line 401) | |
| # 2) Rapid to XY start position at ClearanceHeight | |
| # 3) Rapid down to SafeHeight | |
| # 4) Rapid down to cutting depth | |
| # Find the first XY target from the base commands | |
| first_xy = None | |
| first_move_idx = None | |
| for i, bc in enumerate(base_commands): | |
| if "X" in bc.Parameters and "Y" in bc.Parameters: | |
| first_xy = (bc.Parameters["X"], bc.Parameters["Y"]) | |
| first_move_idx = i | |
| break | |
| if first_xy is not None: | |
| # 1) G0 to XY position at current height (ClearanceHeight from line 401) | |
| pre1 = {"X": first_xy[0], "Y": first_xy[1]} | |
| if not self.commandlist or any( | |
| abs(pre1[k] - self.commandlist[-1].Parameters.get(k, pre1[k] + 1)) | |
| > 1e-9 | |
| for k in ("X", "Y") | |
| ): | |
| self.commandlist.append(Path.Command("G0", pre1)) | |
| # 2) G0 down to SafeHeight | |
| pre2 = {"Z": obj.SafeHeight.Value} | |
| if ( | |
| abs(pre2["Z"] - self.commandlist[-1].Parameters.get("Z", pre2["Z"] + 1)) | |
| > 1e-9 | |
| ): | |
| self.commandlist.append(Path.Command("G0", pre2)) | |
| # 3) G0 down to cutting depth | |
| pre3 = {"Z": depth} | |
| if ( | |
| abs(pre3["Z"] - self.commandlist[-1].Parameters.get("Z", pre3["Z"] + 1)) | |
| > 1e-9 | |
| ): | |
| self.commandlist.append(Path.Command("G0", pre3)) | |
| # Now append the base commands, skipping the generator's initial positioning move | |
| for i, cmd in enumerate(base_commands): | |
| # Skip the first move if it only positions at the start point | |
| if i == first_move_idx: | |
| # If this first move has only XY(Z) to the start point, skip it because we preambled it | |
| pass | |
| else: | |
| new_params = dict(cmd.Parameters) | |
| # Handle Z coordinate based on command type | |
| if "Z" in new_params: | |
| if cmd.Name == "G0": | |
| # For rapids, distinguish between true retracts and plunges | |
| # True retracts are at/near retract_height and should be preserved | |
| # Plunges are at/near polygon ZMin and should be clamped to depth | |
| if ( | |
| abs(new_params["Z"] - retract_height) < 1.0 | |
| ): # Within 1mm of retract height | |
| # Keep as-is (true retract) | |
| pass | |
| else: | |
| # Not a retract - clamp to depth (includes plunges) | |
| new_params["Z"] = depth | |
| else: | |
| # For G1 cutting moves, always use depth | |
| new_params["Z"] = depth | |
| else: | |
| # Missing Z coordinate - set based on command type | |
| if cmd.Name == "G1": | |
| # Cutting moves always at depth | |
| new_params["Z"] = depth | |
| else: | |
| # Rapids without Z - carry forward last Z | |
| if self.commandlist: | |
| new_params["Z"] = self.commandlist[-1].Parameters.get( | |
| "Z", depth | |
| ) | |
| # Fill in missing X,Y coordinates from last position | |
| if self.commandlist: | |
| last = self.commandlist[-1].Parameters | |
| if "X" not in cmd.Parameters: | |
| new_params.setdefault("X", last.get("X")) | |
| if "Y" not in cmd.Parameters: | |
| new_params.setdefault("Y", last.get("Y")) | |
| # Skip zero-length moves (but allow Z-only moves for plunges/retracts) | |
| if self.commandlist: | |
| last_params = self.commandlist[-1].Parameters | |
| # Only skip if X and Y are identical (allow Z-only moves) | |
| if all( | |
| abs(new_params[k] - last_params.get(k, new_params[k] + 1)) | |
| <= 1e-9 | |
| for k in ("X", "Y") | |
| ): | |
| # But if Z is different, keep it (it's a plunge or retract) | |
| # Use sentinel values that won't conflict with depth == 0 | |
| z_new = new_params.get("Z", float("inf")) | |
| z_last = last_params.get("Z", float("-inf")) | |
| z_changed = abs(z_new - z_last) > 1e-9 | |
| if not z_changed: | |
| continue | |
| self.commandlist.append(Path.Command(cmd.Name, new_params)) | |
| Path.Log.debug( | |
| f"First stepdown: Added {len(base_commands)} commands for depth {depth}" | |
| ) | |
| else: | |
| # Subsequent stepdowns - handle linking | |
| # Make a copy of base_commands and update Z depths | |
| copy_commands = [] | |
| for cmd in base_commands: | |
| new_params = dict(cmd.Parameters) | |
| # Handle Z coordinate based on command type (same logic as first stepdown) | |
| if "Z" in new_params: | |
| if cmd.Name == "G0": | |
| # For rapids, distinguish between true retracts and plunges | |
| if ( | |
| abs(new_params["Z"] - retract_height) < 1.0 | |
| ): # Within 1mm of retract height | |
| # Keep as-is (true retract) | |
| pass | |
| else: | |
| # Not a retract - clamp to depth (includes plunges) | |
| new_params["Z"] = depth | |
| else: | |
| # For G1 cutting moves, always use depth | |
| new_params["Z"] = depth | |
| else: | |
| # Missing Z coordinate - set based on command type | |
| if cmd.Name == "G1": | |
| # Cutting moves always at depth | |
| new_params["Z"] = depth | |
| # For G0 without Z, we'll let it get filled in later from context | |
| copy_commands.append(Path.Command(cmd.Name, new_params)) | |
| # Get the last position from self.commandlist | |
| last_cmd = self.commandlist[-1] | |
| last_position = FreeCAD.Vector( | |
| last_cmd.Parameters.get("X", 0), | |
| last_cmd.Parameters.get("Y", 0), | |
| last_cmd.Parameters.get("Z", depth), | |
| ) | |
| # Identify the initial retract+position+plunge bundle (G0s) before the next cut | |
| bundle_start = None | |
| bundle_end = None | |
| target_xy = None | |
| for i, cmd in enumerate(copy_commands): | |
| if cmd.Name == "G0": | |
| bundle_start = i | |
| # collect consecutive G0s | |
| j = i | |
| while j < len(copy_commands) and copy_commands[j].Name == "G0": | |
| # capture XY target if present | |
| if ( | |
| "X" in copy_commands[j].Parameters | |
| and "Y" in copy_commands[j].Parameters | |
| ): | |
| target_xy = ( | |
| copy_commands[j].Parameters.get("X"), | |
| copy_commands[j].Parameters.get("Y"), | |
| ) | |
| j += 1 | |
| bundle_end = j # exclusive | |
| break | |
| if bundle_start is not None and target_xy is not None: | |
| # Build target position at cutting depth | |
| first_position = FreeCAD.Vector(target_xy[0], target_xy[1], depth) | |
| # Generate collision-aware linking moves up to safe/clearance and back down | |
| link_commands = linking.get_linking_moves( | |
| start_position=last_position, | |
| target_position=first_position, | |
| local_clearance=obj.SafeHeight.Value, | |
| global_clearance=obj.ClearanceHeight.Value, | |
| tool_shape=obj.ToolController.Tool.Shape, | |
| ) | |
| # Append linking moves, ensuring full XYZ continuity | |
| current = last_position | |
| for lc in link_commands: | |
| params = lc.Parameters | |
| X = params["X"] | |
| Y = params["Y"] | |
| Z = params["Z"] | |
| # Skip zero-length moves | |
| if not ( | |
| abs(X - current.x) <= 1e-9 | |
| and abs(Y - current.y) <= 1e-9 | |
| and abs(Z - current.z) <= 1e-9 | |
| ): | |
| self.commandlist.append( | |
| Path.Command(lc.Name, {"X": X, "Y": Y, "Z": Z}) | |
| ) | |
| current = FreeCAD.Vector(X, Y, Z) | |
| # Remove the entire initial G0 bundle (up, XY, down) from the copy | |
| del copy_commands[bundle_start:bundle_end] | |
| # Append the copy commands, filling missing coords | |
| for cc in copy_commands: | |
| cp = dict(cc.Parameters) | |
| if self.commandlist: | |
| last = self.commandlist[-1].Parameters | |
| # Only fill in coordinates that are truly missing from the original command | |
| if "X" not in cc.Parameters: | |
| cp.setdefault("X", last.get("X")) | |
| if "Y" not in cc.Parameters: | |
| cp.setdefault("Y", last.get("Y")) | |
| # Don't carry forward Z - it should already be set correctly in copy_commands | |
| if "Z" not in cc.Parameters: | |
| # Only set Z if it wasn't in the original command | |
| if cc.Name == "G1": | |
| cp["Z"] = depth # Cutting moves at depth | |
| else: | |
| cp.setdefault("Z", last.get("Z")) | |
| # Skip zero-length moves | |
| if self.commandlist: | |
| last = self.commandlist[-1].Parameters | |
| # Use sentinel values that won't conflict with depth == 0 | |
| if all( | |
| abs(cp.get(k, float("inf")) - last.get(k, float("-inf"))) <= 1e-9 | |
| for k in ("X", "Y", "Z") | |
| ): | |
| continue | |
| self.commandlist.append(Path.Command(cc.Name, cp)) | |
| Path.Log.debug( | |
| f"Stepdown {depth_count}: Added linking + {len(copy_commands)} commands for depth {depth}" | |
| ) | |
| except StopIteration: | |
| Path.Log.debug(f"All depths processed. Total depth levels: {depth_count}") | |
| # Add final G0 to clearance height | |
| targetZ = obj.ClearanceHeight.Value | |
| if self.commandlist: | |
| last = self.commandlist[-1].Parameters | |
| lastZ = last.get("Z") | |
| if lastZ is None or abs(targetZ - lastZ) > 1e-9: | |
| # Prefer Z-only to avoid non-numeric XY issues | |
| self.commandlist.append(Path.Command("G0", {"Z": targetZ})) | |
| # # Sanitize commands: ensure full XYZ continuity and remove zero-length/invalid/absurd moves | |
| # sanitized = [] | |
| # curX = curY = curZ = None | |
| # # Compute XY bounds from original wire | |
| # try: | |
| # bb = boundary_wire.BoundBox | |
| # import math | |
| # diag = math.hypot(bb.XLength, bb.YLength) | |
| # xy_limit = max(1.0, diag * 10.0) | |
| # except Exception: | |
| # xy_limit = 1e6 | |
| # for idx, cmd in enumerate(self.commandlist): | |
| # params = dict(cmd.Parameters) | |
| # # Carry forward | |
| # if curX is not None: | |
| # params.setdefault("X", curX) | |
| # params.setdefault("Y", curY) | |
| # params.setdefault("Z", curZ) | |
| # # Extract | |
| # X = params.get("X") | |
| # Y = params.get("Y") | |
| # Z = params.get("Z") | |
| # # Skip NaN/inf | |
| # try: | |
| # _ = float(X) + float(Y) + float(Z) | |
| # except Exception: | |
| # Path.Log.warning( | |
| # f"Dropping cmd {idx} non-finite coords: {cmd.Name} {cmd.Parameters}" | |
| # ) | |
| # continue | |
| # # Debug: large finite XY - log but keep for analysis (do not drop) | |
| # if abs(X) > xy_limit or abs(Y) > xy_limit: | |
| # Path.Log.warning(f"Large XY detected (limit {xy_limit}): {cmd.Name} {params}") | |
| # # Skip zero-length | |
| # if ( | |
| # curX is not None | |
| # and abs(X - curX) <= 1e-12 | |
| # and abs(Y - curY) <= 1e-12 | |
| # and abs(Z - curZ) <= 1e-12 | |
| # ): | |
| # continue | |
| # # Preserve I, J, K parameters for arc commands (G2/G3) | |
| # if cmd.Name in ["G2", "G3"]: | |
| # arc_params = {"X": X, "Y": Y, "Z": Z} | |
| # if "I" in params: | |
| # arc_params["I"] = params["I"] | |
| # if "J" in params: | |
| # arc_params["J"] = params["J"] | |
| # if "K" in params: | |
| # arc_params["K"] = params["K"] | |
| # if "R" in params: | |
| # arc_params["R"] = params["R"] | |
| # sanitized.append(Path.Command(cmd.Name, arc_params)) | |
| # else: | |
| # sanitized.append(Path.Command(cmd.Name, {"X": X, "Y": Y, "Z": Z})) | |
| # curX, curY, curZ = X, Y, Z | |
| # self.commandlist = sanitized | |
| # Apply feedrates to the entire commandlist, with debug on failure | |
| try: | |
| FeedRate.setFeedRate(self.commandlist, obj.ToolController) | |
| except Exception as e: | |
| # Dump last 12 commands for diagnostics | |
| n = len(self.commandlist) | |
| start = max(0, n - 12) | |
| Path.Log.error("FeedRate failure. Dumping last commands:") | |
| for i in range(start, n): | |
| c = self.commandlist[i] | |
| Path.Log.error(f" #{i}: {c.Name} {c.Parameters}") | |
| raise | |
| Path.Log.debug(f"Total commands in commandlist: {len(self.commandlist)}") | |
| Path.Log.debug("MillFacing.opExecute() completed successfully") | |
| Path.Log.debug(self.commandlist) | |
| # Eclass | |
| def Create(name, obj=None, parentJob=None): | |
| """Create(name) ... Creates and returns a Mill Facing operation.""" | |
| if obj is None: | |
| obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name) | |
| obj.Proxy = ObjectMillFacing(obj, name, parentJob) | |
| return obj | |
| def SetupProperties(): | |
| """SetupProperties() ... Return list of properties required for the operation.""" | |
| setup = [] | |
| setup.append("CutMode") | |
| setup.append("ClearingPattern") | |
| setup.append("Angle") | |
| setup.append("StepOver") | |
| setup.append("AxialStockToLeave") | |
| setup.append("PassExtension") | |
| setup.append("Reverse") | |
| return setup | |