758 lines
34 KiB
Python
758 lines
34 KiB
Python
# -*- 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.",
|
|
),
|
|
),
|
|
]
|
|
|
|
@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()
|
|
|
|
# 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
|