| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| __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 |
|
|
| |
| 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) |
|
|
| 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) |
|
|
| |
| 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"): |
| |
| 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.", |
| ), |
| ), |
| ] |
|
|
| @classmethod |
| 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() |
|
|
| |
| obj.CutMode = "Climb" |
| obj.ClearingPattern = "ZigZag" |
| obj.Angle = 0.0 |
| obj.StepOver = 25 |
| obj.AxialStockToLeave = 0.0 |
| obj.PassExtension = ( |
| 3.0 |
| ) |
| obj.Reverse = False |
|
|
| def opExecute(self, obj): |
| """opExecute(obj) ... process Mill Facing operation""" |
| Path.Log.track() |
| Path.Log.debug("MillFacing.opExecute() starting") |
|
|
| |
| 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}") |
|
|
| |
| finish_step = 0.0 |
| 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}") |
|
|
| |
| 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)}") |
|
|
| |
| z_up_faces = [] |
| for face in stock_faces: |
| |
| 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}") |
|
|
| |
| if normal.z > 0.9: |
| 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") |
|
|
| |
| 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 |
| ) |
|
|
| |
| milling_direction = "climb" if obj.CutMode == "Climb" else "conventional" |
|
|
| |
| stepover_percent = obj.StepOver |
| pass_extension = ( |
| obj.PassExtension.Value if hasattr(obj, "PassExtension") else tool_diameter * 0.5 |
| ) |
| retract_height = obj.SafeHeight.Value |
|
|
| |
| try: |
| if obj.ClearingPattern == "Spiral": |
| |
| 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 |
|
|
| |
| self.commandlist = [] |
|
|
| |
| targetZ = obj.ClearanceHeight.Value |
| self.commandlist.append(Path.Command("G0", {"Z": targetZ})) |
|
|
| |
| 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_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: |
| |
| 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)) |
|
|
| |
| 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)) |
|
|
| |
| 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)) |
|
|
| |
| for i, cmd in enumerate(base_commands): |
| |
| if i == first_move_idx: |
| |
| pass |
| else: |
| new_params = dict(cmd.Parameters) |
| |
| if "Z" in new_params: |
| if cmd.Name == "G0": |
| |
| |
| |
| if ( |
| abs(new_params["Z"] - retract_height) < 1.0 |
| ): |
| |
| pass |
| else: |
| |
| new_params["Z"] = depth |
| else: |
| |
| new_params["Z"] = depth |
| else: |
| |
| if cmd.Name == "G1": |
| |
| new_params["Z"] = depth |
| else: |
| |
| if self.commandlist: |
| new_params["Z"] = self.commandlist[-1].Parameters.get( |
| "Z", depth |
| ) |
|
|
| |
| 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")) |
| |
| if self.commandlist: |
| last_params = self.commandlist[-1].Parameters |
| |
| if all( |
| abs(new_params[k] - last_params.get(k, new_params[k] + 1)) |
| <= 1e-9 |
| for k in ("X", "Y") |
| ): |
| |
| |
| 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: |
| |
| |
| copy_commands = [] |
| for cmd in base_commands: |
| new_params = dict(cmd.Parameters) |
| |
| if "Z" in new_params: |
| if cmd.Name == "G0": |
| |
| if ( |
| abs(new_params["Z"] - retract_height) < 1.0 |
| ): |
| |
| pass |
| else: |
| |
| new_params["Z"] = depth |
| else: |
| |
| new_params["Z"] = depth |
| else: |
| |
| if cmd.Name == "G1": |
| |
| new_params["Z"] = depth |
| |
| copy_commands.append(Path.Command(cmd.Name, new_params)) |
|
|
| |
| 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), |
| ) |
|
|
| |
| bundle_start = None |
| bundle_end = None |
| target_xy = None |
| for i, cmd in enumerate(copy_commands): |
| if cmd.Name == "G0": |
| bundle_start = i |
| |
| j = i |
| while j < len(copy_commands) and copy_commands[j].Name == "G0": |
| |
| 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 |
| break |
|
|
| if bundle_start is not None and target_xy is not None: |
| |
| first_position = FreeCAD.Vector(target_xy[0], target_xy[1], depth) |
|
|
| |
| 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, |
| ) |
| |
| current = last_position |
| for lc in link_commands: |
| params = lc.Parameters |
| X = params["X"] |
| Y = params["Y"] |
| Z = params["Z"] |
| |
| 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) |
|
|
| |
| del copy_commands[bundle_start:bundle_end] |
|
|
| |
| for cc in copy_commands: |
| cp = dict(cc.Parameters) |
| if self.commandlist: |
| last = self.commandlist[-1].Parameters |
| |
| if "X" not in cc.Parameters: |
| cp.setdefault("X", last.get("X")) |
| if "Y" not in cc.Parameters: |
| cp.setdefault("Y", last.get("Y")) |
| |
| if "Z" not in cc.Parameters: |
| |
| if cc.Name == "G1": |
| cp["Z"] = depth |
| else: |
| cp.setdefault("Z", last.get("Z")) |
| |
| if self.commandlist: |
| last = self.commandlist[-1].Parameters |
| |
| 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}") |
|
|
| |
| 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: |
| |
| self.commandlist.append(Path.Command("G0", {"Z": targetZ})) |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| try: |
| FeedRate.setFeedRate(self.commandlist, obj.ToolController) |
| except Exception as e: |
| |
| 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) |
|
|
|
|
| |
|
|
|
|
| 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 |
|
|