CAM: Drilling - Fix regression after #26584 (#26827)

* CAM: Drilling - Fix regression after #26584

* CAM: Linking generator - Fix machinestate handling

Fix linking generator for drilling

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add remaining logic to terminate canned cycles appropriately (#2)

* sorted heights

* Refactor buildPostList

Introduces early tool prep to the buildpostables.
Allows emitting a T command early to give tool changer
time to index the tool before the M6 tool change command is emitted.

updates unit testing for this use case

Not implemented in postprocessors yet.

fix

* Helper and tests for terminating canned cycles

fix cycle termination

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* CAM: setFeedRate now aware of drilling semantics (#3)

Remove retract plane property
machinestate properly handling z position

* next bugs (#4)

* setFeedRate now aware of drilling semantics
Remove retract plane property
machinestate properly handling z position

* unnecessary import.
Improved linking

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix rendering of canned cycles in G99 mode (#5)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix for collision  (#6)

* collision bug fix

* fix local variable

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: sliptonic <shopinthewoods@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
tarman3
2026-01-15 07:13:58 +02:00
committed by GitHub
parent f5bad2e6ef
commit 0059428f38
14 changed files with 1036 additions and 269 deletions

View File

@@ -129,6 +129,7 @@ void PathSegmentVisitor::g38(int id, const Base::Vector3d& last, const Base::Vec
PathSegmentWalker::PathSegmentWalker(const Toolpath& tp_) PathSegmentWalker::PathSegmentWalker(const Toolpath& tp_)
: tp(tp_) : tp(tp_)
, retract_mode(98) // Default to G98 (retract to initial Z)
{} {}
@@ -331,6 +332,18 @@ void PathSegmentWalker::walk(PathSegmentVisitor& cb, const Base::Vector3d& start
|| (name == "G83") || (name == "G84") || (name == "G85") || (name == "G86") || (name == "G83") || (name == "G84") || (name == "G85") || (name == "G86")
|| (name == "G89")) { || (name == "G89")) {
// drill,tap,bore // drill,tap,bore
// Check for RetractMode annotation (G98 or G99)
if (cmd.hasAnnotation("RetractMode")) {
std::string mode = cmd.getAnnotationString("RetractMode");
if (mode == "G99") {
retract_mode = 99;
}
else if (mode == "G98") {
retract_mode = 98;
}
}
double r = 0; double r = 0;
if (cmd.has("R")) { if (cmd.has("R")) {
r = cmd.getValue("R"); r = cmd.getValue("R");

View File

@@ -30,6 +30,7 @@ import Path
import Path.Post.Command as PathCommand import Path.Post.Command as PathCommand
import Path.Post.Processor as PathPost import Path.Post.Processor as PathPost
import Path.Post.Utils as PostUtils import Path.Post.Utils as PostUtils
import Path.Post.UtilsExport as PostUtilsExport
import Path.Main.Job as PathJob import Path.Main.Job as PathJob
import Path.Tool.Controller as PathToolController import Path.Tool.Controller as PathToolController
import difflib import difflib
@@ -503,6 +504,232 @@ class TestPathPostUtils(unittest.TestCase):
# self.assertTrue(len(results.Commands) == 117) # self.assertTrue(len(results.Commands) == 117)
self.assertTrue(len([c for c in results.Commands if c.Name in ["G2", "G3"]]) == 0) self.assertTrue(len([c for c in results.Commands if c.Name in ["G2", "G3"]]) == 0)
def test020(self):
"""Test Termination of Canned Cycles"""
# Test basic cycle termination when parameters change
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -1.0, "R": 0.2, "F": 10.0})
cmd2.Annotations = {"RetractMode": "G98"}
test_path = Path.Path(
[
Path.Command("G0", {"Z": 1.0}),
cmd1,
cmd2, # Different Z depth
Path.Command("G1", {"X": 3.0, "Y": 3.0}),
]
)
expected_path = Path.Path(
[
Path.Command("G0", {"Z": 1.0}),
Path.Command("G98"), # Retract mode for first cycle
Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0}),
Path.Command("G80"), # Terminate due to parameter change
Path.Command("G98"), # Retract mode for second cycle
Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -1.0, "R": 0.2, "F": 10.0}),
Path.Command("G80"), # Final termination
Path.Command("G1", {"X": 3.0, "Y": 3.0}),
]
)
result = PostUtils.cannedCycleTerminator(test_path)
self.assertEqual(len(result.Commands), len(expected_path.Commands))
for i, (res, exp) in enumerate(zip(result.Commands, expected_path.Commands)):
self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch")
self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch")
def test030_canned_cycle_termination_with_non_cycle_commands(self):
"""Test cycle termination when non-cycle commands are encountered"""
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G82", {"X": 3.0, "Y": 3.0, "Z": -1.0, "R": 0.2, "P": 1.0, "F": 10.0})
cmd2.Annotations = {"RetractMode": "G98"}
test_path = Path.Path(
[
cmd1,
Path.Command("G0", {"X": 2.0, "Y": 2.0}), # Non-cycle command
cmd2,
]
)
expected_path = Path.Path(
[
Path.Command("G98"), # Retract mode for first cycle
Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0}),
Path.Command("G80"), # Terminate before non-cycle command
Path.Command("G0", {"X": 2.0, "Y": 2.0}),
Path.Command("G98"), # Retract mode for second cycle
Path.Command("G82", {"X": 3.0, "Y": 3.0, "Z": -1.0, "R": 0.2, "P": 1.0, "F": 10.0}),
Path.Command("G80"), # Final termination
]
)
result = PostUtils.cannedCycleTerminator(test_path)
self.assertEqual(len(result.Commands), len(expected_path.Commands))
for i, (res, exp) in enumerate(zip(result.Commands, expected_path.Commands)):
self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch")
self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch")
def test040_canned_cycle_modal_same_parameters(self):
"""Test modal cycles with same parameters don't get terminated"""
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd2.Annotations = {"RetractMode": "G98"}
cmd3 = Path.Command("G81", {"X": 3.0, "Y": 3.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd3.Annotations = {"RetractMode": "G98"}
test_path = Path.Path(
[
cmd1,
cmd2, # Modal - same parameters
cmd3, # Modal - same parameters
]
)
expected_path = Path.Path(
[
Path.Command("G98"), # Retract mode at start of cycle
Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0}),
Path.Command(
"G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "F": 10.0}
), # No termination - same params
Path.Command(
"G81", {"X": 3.0, "Y": 3.0, "Z": -0.5, "R": 0.1, "F": 10.0}
), # No termination - same params
Path.Command("G80"), # Final termination
]
)
result = PostUtils.cannedCycleTerminator(test_path)
self.assertEqual(len(result.Commands), len(expected_path.Commands))
for i, (res, exp) in enumerate(zip(result.Commands, expected_path.Commands)):
self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch")
self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch")
def test050_canned_cycle_feed_rate_change(self):
"""Test cycle termination when feed rate changes"""
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "F": 20.0})
cmd2.Annotations = {"RetractMode": "G98"}
test_path = Path.Path(
[
cmd1,
cmd2, # Different feed rate
]
)
expected_path = Path.Path(
[
Path.Command("G98"), # Retract mode for first cycle
Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0}),
Path.Command("G80"), # Terminate due to feed rate change
Path.Command("G98"), # Retract mode for second cycle
Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "F": 20.0}),
Path.Command("G80"), # Final termination
]
)
result = PostUtils.cannedCycleTerminator(test_path)
self.assertEqual(len(result.Commands), len(expected_path.Commands))
for i, (res, exp) in enumerate(zip(result.Commands, expected_path.Commands)):
self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch")
self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch")
def test060_canned_cycle_retract_plane_change(self):
"""Test cycle termination when retract plane changes"""
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.2, "F": 10.0})
cmd2.Annotations = {"RetractMode": "G98"}
test_path = Path.Path(
[
cmd1,
cmd2, # Different R plane
]
)
expected_path = Path.Path(
[
Path.Command("G98"), # Retract mode for first cycle
Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0}),
Path.Command("G80"), # Terminate due to R plane change
Path.Command("G98"), # Retract mode for second cycle
Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.2, "F": 10.0}),
Path.Command("G80"), # Final termination
]
)
result = PostUtils.cannedCycleTerminator(test_path)
self.assertEqual(len(result.Commands), len(expected_path.Commands))
for i, (res, exp) in enumerate(zip(result.Commands, expected_path.Commands)):
self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch")
self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch")
def test070_canned_cycle_mixed_cycle_types(self):
"""Test termination between different cycle types"""
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G82", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "P": 1.0, "F": 10.0})
cmd2.Annotations = {"RetractMode": "G98"}
test_path = Path.Path(
[
cmd1,
cmd2, # Different cycle type
]
)
expected_path = Path.Path(
[
Path.Command("G98"), # Retract mode for first cycle
Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0}),
Path.Command("G80"), # Terminate due to different cycle type (different parameters)
Path.Command("G98"), # Retract mode for second cycle
Path.Command("G82", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "P": 1.0, "F": 10.0}),
Path.Command("G80"), # Final termination
]
)
result = PostUtils.cannedCycleTerminator(test_path)
self.assertEqual(len(result.Commands), len(expected_path.Commands))
for i, (res, exp) in enumerate(zip(result.Commands, expected_path.Commands)):
self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch")
self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch")
def test080_canned_cycle_retract_mode_change(self):
"""Test cycle termination and retract mode insertion when RetractMode annotation changes"""
# Create commands with RetractMode annotations
cmd1 = Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd1.Annotations = {"RetractMode": "G98"}
cmd2 = Path.Command("G81", {"X": 2.0, "Y": 2.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd2.Annotations = {"RetractMode": "G98"}
cmd3 = Path.Command("G81", {"X": 3.0, "Y": 3.0, "Z": -0.5, "R": 0.1, "F": 10.0})
cmd3.Annotations = {"RetractMode": "G99"} # Mode change
test_path = Path.Path([cmd1, cmd2, cmd3])
result = PostUtils.cannedCycleTerminator(test_path)
# Expected: G98, G81, G81 (modal), G80 (terminate), G99, G81, G80 (final)
self.assertEqual(result.Commands[0].Name, "G98")
self.assertEqual(result.Commands[1].Name, "G81")
self.assertEqual(result.Commands[2].Name, "G81")
self.assertEqual(result.Commands[3].Name, "G80") # Terminate due to mode change
self.assertEqual(result.Commands[4].Name, "G99") # New retract mode
self.assertEqual(result.Commands[5].Name, "G81")
self.assertEqual(result.Commands[6].Name, "G80") # Final termination
self.assertEqual(len(result.Commands), 7)
class TestBuildPostList(unittest.TestCase): class TestBuildPostList(unittest.TestCase):
""" """
@@ -523,6 +750,110 @@ class TestBuildPostList(unittest.TestCase):
""" """
# Set to True to enable verbose debug output for test validation
debug = False
@classmethod
def _format_postables(cls, postables, title="Postables"):
"""Format postables for readable debug output, following dumper_post.py pattern."""
output = []
output.append("=" * 80)
output.append(title)
output.append("=" * 80)
output.append("")
for idx, postable in enumerate(postables, 1):
group_key = postable[0]
objects = postable[1]
# Format the group key display
if group_key == "":
display_key = "(empty string)"
elif group_key == "allitems":
display_key = '"allitems" (combined output)'
else:
display_key = f'"{group_key}"'
output.append(f"[{idx}] Group: {display_key}")
output.append(f" Objects: {len(objects)}")
output.append("")
for obj_idx, obj in enumerate(objects, 1):
obj_label = getattr(obj, "Label", str(type(obj).__name__))
output.append(f" [{obj_idx}] {obj_label}")
# Determine object type/role
obj_type = type(obj).__name__
if obj_type == "_FixtureSetupObject":
output.append(f" Type: Fixture Setup")
if hasattr(obj, "Path") and obj.Path and len(obj.Path.Commands) > 0:
fixture_cmd = obj.Path.Commands[0]
output.append(f" Fixture: {fixture_cmd.Name}")
elif obj_type == "_CommandObject":
output.append(f" Type: Command Object")
if hasattr(obj, "Path") and obj.Path and len(obj.Path.Commands) > 0:
cmd = obj.Path.Commands[0]
params = " ".join(
f"{k}:{v}"
for k, v in zip(
cmd.Parameters.keys() if hasattr(cmd.Parameters, "keys") else [],
(
cmd.Parameters.values()
if hasattr(cmd.Parameters, "values")
else cmd.Parameters
),
)
)
output.append(f" Command: {cmd.Name} {params}")
elif hasattr(obj, "TypeId"):
# Check if it's a tool controller
if hasattr(obj, "Proxy") and hasattr(obj.Proxy, "__class__"):
proxy_name = obj.Proxy.__class__.__name__
if "ToolController" in proxy_name:
output.append(f" Type: Tool Controller")
if hasattr(obj, "ToolNumber"):
output.append(f" Tool Number: {obj.ToolNumber}")
if hasattr(obj, "Path") and obj.Path and obj.Path.Commands:
for cmd in obj.Path.Commands:
if cmd.Name == "M6":
params = " ".join(
f"{k}:{v}"
for k, v in zip(
(
cmd.Parameters.keys()
if hasattr(cmd.Parameters, "keys")
else []
),
(
cmd.Parameters.values()
if hasattr(cmd.Parameters, "values")
else cmd.Parameters
),
)
)
output.append(f" M6 Command: {cmd.Name} {params}")
else:
output.append(f" Type: Operation")
if hasattr(obj, "ToolController") and obj.ToolController:
tc = obj.ToolController
output.append(
f" ToolController: {tc.Label} (T{tc.ToolNumber})"
)
else:
output.append(f" Type: {obj.TypeId}")
else:
output.append(f" Type: {obj_type}")
output.append("")
output.append("=" * 80)
output.append(f"Total Groups: {len(postables)}")
total_objects = sum(len(p[1]) for p in postables)
output.append(f"Total Objects: {total_objects}")
output.append("=" * 80)
return "\n".join(output)
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True")
@@ -560,6 +891,10 @@ class TestBuildPostList(unittest.TestCase):
tc2.Label = 'TC: 7/16" two flute' # Same label as first tool controller tc2.Label = 'TC: 7/16" two flute' # Same label as first tool controller
cls.job.Proxy.addToolController(tc2) cls.job.Proxy.addToolController(tc2)
# Recompute tool controllers to populate their Path.Commands with M6 commands
cls.job.Tools.Group[0].recompute()
cls.job.Tools.Group[1].recompute()
# Create mock operations to match original file structure # Create mock operations to match original file structure
# Original had 3 operations: outsideprofile, DrillAllHoles, Comment # Original had 3 operations: outsideprofile, DrillAllHoles, Comment
# The Comment operation has no tool controller # The Comment operation has no tool controller
@@ -638,8 +973,8 @@ class TestBuildPostList(unittest.TestCase):
postlist = self.pp._buildPostList() postlist = self.pp._buildPostList()
firstoutputitem = postlist[0] firstoutputitem = postlist[0]
firstoplist = firstoutputitem[1] firstoplist = firstoutputitem[1]
print(f"DEBUG test030: postlist length={len(firstoplist)}, expected=14") if self.debug:
print(f"DEBUG test030: firstoplist={[str(item) for item in firstoplist]}") print(self._format_postables(postlist, "test030: No splitting, order by Operation"))
self.assertEqual(len(firstoplist), 14) self.assertEqual(len(firstoplist), 14)
def test040(self): def test040(self):
@@ -652,13 +987,12 @@ class TestBuildPostList(unittest.TestCase):
postlist = self.pp._buildPostList() postlist = self.pp._buildPostList()
firstoutputitem = postlist[0] firstoutputitem = postlist[0]
print(f"DEBUG test040: firstoutputitem[0]={firstoutputitem[0]}, expected='5'") if self.debug:
print(f"DEBUG test040: tool numbers={[tc.ToolNumber for tc in self.job.Tools.Group]}") print(self._format_postables(postlist, "test040: Split by tool, order by Tool"))
self.assertTrue(firstoutputitem[0] == str(5)) self.assertTrue(firstoutputitem[0] == str(5))
# check length of output # check length of output
firstoplist = firstoutputitem[1] firstoplist = firstoutputitem[1]
print(f"DEBUG test040: postlist length={len(firstoplist)}, expected=5")
self.assertEqual(len(firstoplist), 5) self.assertEqual(len(firstoplist), 5)
def test050(self): def test050(self):
@@ -684,3 +1018,142 @@ class TestBuildPostList(unittest.TestCase):
firstoplist = firstoutputitem[1] firstoplist = firstoutputitem[1]
self.assertEqual(len(firstoplist), 6) self.assertEqual(len(firstoplist), 6)
self.assertTrue(firstoutputitem[0] == "G54") self.assertTrue(firstoutputitem[0] == "G54")
def test070(self):
self.job.SplitOutput = True
self.job.PostProcessorOutputFile = "%T.nc"
self.job.OrderOutputBy = "Tool"
postables = self.pp._buildPostList(early_tool_prep=True)
_, sublist = postables[0]
if self.debug:
print(self._format_postables(postables, "test070: Early tool prep, split by tool"))
# Extract all commands from the postables
commands = []
if self.debug:
print("\n=== Extracting commands from postables ===")
for item in sublist:
if self.debug:
item_type = type(item).__name__
has_path = hasattr(item, "Path")
path_exists = item.Path if has_path else None
has_commands = path_exists and item.Path.Commands if path_exists else False
print(
f"Item: {getattr(item, 'Label', item_type)}, Type: {item_type}, HasPath: {has_path}, PathExists: {path_exists is not None}, HasCommands: {bool(has_commands)}"
)
if has_commands:
print(f" Commands: {[cmd.Name for cmd in item.Path.Commands]}")
if hasattr(item, "Path") and item.Path and item.Path.Commands:
commands.extend(item.Path.Commands)
if self.debug:
print(f"\nTotal commands extracted: {len(commands)}")
print("=" * 40)
# Should have M6 command with tool parameter
m6_commands = [cmd for cmd in commands if cmd.Name == "M6"]
self.assertTrue(len(m6_commands) > 0, "Should have M6 command")
# First M6 should have T parameter for tool 5
first_m6 = m6_commands[0]
self.assertTrue("T" in first_m6.Parameters, "First M6 should have T parameter")
self.assertEqual(first_m6.Parameters["T"], 5.0, "First M6 should be for tool 5")
# Should have T2 prep command (early prep for next tool)
t2_commands = [cmd for cmd in commands if cmd.Name == "T2"]
self.assertTrue(len(t2_commands) > 0, "Should have T2 early prep command")
# T2 prep should come after first M6
first_m6_index = next((i for i, cmd in enumerate(commands) if cmd.Name == "M6"), None)
t2_index = next((i for i, cmd in enumerate(commands) if cmd.Name == "T2"), None)
self.assertIsNotNone(first_m6_index, "M6 should exist")
self.assertIsNotNone(t2_index, "T2 should exist")
self.assertLess(first_m6_index, t2_index, "M6 should come before T2 prep")
def test080(self):
self.job.SplitOutput = False
self.job.OrderOutputBy = "Tool"
postables = self.pp._buildPostList(early_tool_prep=True)
_, sublist = postables[0]
if self.debug:
print(self._format_postables(postables, "test080: Early tool prep, combined output"))
# Extract all commands from the postables
commands = []
if self.debug:
print("\n=== Extracting commands from postables ===")
for item in sublist:
if self.debug:
item_type = type(item).__name__
has_path = hasattr(item, "Path")
path_exists = item.Path if has_path else None
has_commands = path_exists and item.Path.Commands if path_exists else False
print(
f"Item: {getattr(item, 'Label', item_type)}, Type: {item_type}, HasPath: {has_path}, PathExists: {path_exists is not None}, HasCommands: {bool(has_commands)}"
)
if has_commands:
print(f" Commands: {[cmd.Name for cmd in item.Path.Commands]}")
if hasattr(item, "Path") and item.Path and item.Path.Commands:
commands.extend(item.Path.Commands)
if self.debug:
print(f"\nTotal commands extracted: {len(commands)}")
# Expected command sequence with early_tool_prep=True:
# M6 T5 <- change to tool 5 (standard format)
# T2 <- prep next tool immediately (early prep)
# (ops with T5...)
# M6 T2 <- change to tool 2 (was prepped early)
# (ops with T2...)
if self.debug:
print("\n=== Command Sequence ===")
for i, cmd in enumerate(commands):
params = " ".join(
f"{k}:{v}"
for k, v in zip(
cmd.Parameters.keys() if hasattr(cmd.Parameters, "keys") else [],
(
cmd.Parameters.values()
if hasattr(cmd.Parameters, "values")
else cmd.Parameters
),
)
)
print(f"{i:3d}: {cmd.Name} {params}")
print("=" * 40)
# Find M6 and T2 commands
m6_commands = [(i, cmd) for i, cmd in enumerate(commands) if cmd.Name == "M6"]
t2_commands = [(i, cmd) for i, cmd in enumerate(commands) if cmd.Name == "T2"]
self.assertTrue(len(m6_commands) >= 2, "Should have at least 2 M6 commands")
self.assertTrue(len(t2_commands) >= 1, "Should have at least 1 T2 early prep command")
first_m6_idx, first_m6_cmd = m6_commands[0]
second_m6_idx, second_m6_cmd = m6_commands[1] if len(m6_commands) >= 2 else (None, None)
first_t2_idx = t2_commands[0][0]
# First M6 should have T parameter for tool 5
self.assertTrue("T" in first_m6_cmd.Parameters, "First M6 should have T parameter")
self.assertEqual(first_m6_cmd.Parameters["T"], 5.0, "First M6 should be for tool 5")
# Second M6 should have T parameter for tool 2
if second_m6_cmd is not None:
self.assertTrue("T" in second_m6_cmd.Parameters, "Second M6 should have T parameter")
self.assertEqual(second_m6_cmd.Parameters["T"], 2.0, "Second M6 should be for tool 2")
# T2 (early prep) should come shortly after first M6 (within a few commands)
self.assertLess(first_m6_idx, first_t2_idx, "T2 prep should come after first M6")
self.assertLess(
first_t2_idx - first_m6_idx, 5, "T2 prep should be within a few commands of first M6"
)
# T2 early prep should come before second M6
if second_m6_idx is not None:
self.assertLess(
first_t2_idx, second_m6_idx, "T2 early prep should come before second M6"
)

View File

@@ -713,12 +713,13 @@ class TestPostGCodes(PathTestUtils.PathTestBase):
def test10730(self): def test10730(self):
"""Test G73 command Generation.""" """Test G73 command Generation."""
cmd = Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5")
cmd.Annotations = {"RetractMode": "G99"}
path = [ path = [
Path.Command("G0 X1 Y2"), Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"), Path.Command("G0 Z8"),
Path.Command("G90"), Path.Command("G90"),
Path.Command("G99"), cmd,
Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5"),
Path.Command("G80"), Path.Command("G80"),
Path.Command("G90"), Path.Command("G90"),
] ]
@@ -905,12 +906,14 @@ G90
def test10810(self): def test10810(self):
"""Test G81 command Generation.""" """Test G81 command Generation."""
cmd = Path.Command("G81 X1 Y2 Z0 F123 R5")
cmd.Annotations = {"RetractMode": "G99"}
path = [ path = [
Path.Command("G0 X1 Y2"), Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"), Path.Command("G0 Z8"),
Path.Command("G90"), Path.Command("G90"),
Path.Command("G99"), Path.Command("G99"),
Path.Command("G81 X1 Y2 Z0 F123 R5"), cmd,
Path.Command("G80"), Path.Command("G80"),
Path.Command("G90"), Path.Command("G90"),
] ]
@@ -1061,12 +1064,14 @@ G90
def test10820(self): def test10820(self):
"""Test G82 command Generation.""" """Test G82 command Generation."""
cmd = Path.Command("G82 X1 Y2 Z0 F123 R5 P1.23456")
cmd.Annotations = {"RetractMode": "G99"}
path = [ path = [
Path.Command("G0 X1 Y2"), Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"), Path.Command("G0 Z8"),
Path.Command("G90"), Path.Command("G90"),
Path.Command("G99"), Path.Command("G99"),
Path.Command("G82 X1 Y2 Z0 F123 R5 P1.23456"), cmd,
Path.Command("G80"), Path.Command("G80"),
Path.Command("G90"), Path.Command("G90"),
] ]
@@ -1221,12 +1226,14 @@ G90
def test10830(self): def test10830(self):
"""Test G83 command Generation.""" """Test G83 command Generation."""
cmd = Path.Command("G83 X1 Y2 Z0 F123 Q1.5 R5")
cmd.Annotations = {"RetractMode": "G99"}
path = [ path = [
Path.Command("G0 X1 Y2"), Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"), Path.Command("G0 Z8"),
Path.Command("G90"), Path.Command("G90"),
Path.Command("G99"), Path.Command("G99"),
Path.Command("G83 X1 Y2 Z0 F123 Q1.5 R5"), cmd,
Path.Command("G80"), Path.Command("G80"),
Path.Command("G90"), Path.Command("G90"),
] ]

View File

@@ -202,12 +202,14 @@ G54
def test00125(self) -> None: def test00125(self) -> None:
"""Test chipbreaking amount.""" """Test chipbreaking amount."""
cmd = Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5")
cmd.Annotations = {"RetractMode": "G99"}
test_path = [ test_path = [
Path.Command("G0 X1 Y2"), Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"), Path.Command("G0 Z8"),
Path.Command("G90"), Path.Command("G90"),
Path.Command("G99"), Path.Command("G99"),
Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5"), cmd,
Path.Command("G80"), Path.Command("G80"),
Path.Command("G90"), Path.Command("G90"),
] ]

View File

@@ -295,6 +295,7 @@ SET(PathPythonToolsShapeUi_SRCS
SET(PathPythonPost_SRCS SET(PathPythonPost_SRCS
Path/Post/__init__.py Path/Post/__init__.py
Path/Post/Command.py Path/Post/Command.py
Path/Post/PostList.py
Path/Post/Processor.py Path/Post/Processor.py
Path/Post/Utils.py Path/Post/Utils.py
Path/Post/UtilsArguments.py Path/Post/UtilsArguments.py

View File

@@ -153,23 +153,6 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="4" column="4">
<widget class="QLabel" name="retractLabel">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Retract</string>
</property>
</widget>
</item>
<item row="4" column="6">
<widget class="Gui::QuantitySpinBox" name="peckRetractHeight">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="1"> <item row="2" column="1">
<widget class="QCheckBox" name="chipBreakEnabled"> <widget class="QCheckBox" name="chipBreakEnabled">
<property name="text"> <property name="text">

View File

@@ -24,6 +24,7 @@ import FreeCAD
import Path import Path
import Path.Base.MachineState as PathMachineState import Path.Base.MachineState as PathMachineState
import Part import Part
from Path.Geom import CmdMoveDrill
__title__ = "Feed Rate Helper Utility" __title__ = "Feed Rate Helper Utility"
__author__ = "sliptonic (Brad Collette)" __author__ = "sliptonic (Brad Collette)"
@@ -63,7 +64,12 @@ def setFeedRate(commandlist, ToolController):
if command.Name not in Path.Geom.CmdMoveAll: if command.Name not in Path.Geom.CmdMoveAll:
continue continue
if _isVertical(machine.getPosition(), command): # Canned drill cycles (G73, G81, G82, G83, G85) are vertical cutting operations
# The F word in a drill cycle specifies the feed rate for the vertical cutting component
# The positioning move to XY is done at rapid speed (not controlled by F word)
if command.Name in Path.Geom.CmdMoveDrill:
rate = ToolController.VertFeed.Value
elif _isVertical(machine.getPosition(), command):
rate = ( rate = (
ToolController.VertRapid.Value ToolController.VertRapid.Value
if command.Name in Path.Geom.CmdMoveRapid if command.Name in Path.Geom.CmdMoveRapid

View File

@@ -34,6 +34,37 @@ else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def check_collision(
start_position: Vector,
target_position: Vector,
solids: Optional[List[Part.Shape]] = None,
tolerance: float = 0.001,
) -> bool:
"""
Check if a direct move from start to target would collide with solids.
Returns True if collision detected, False if path is clear.
"""
if start_position == target_position:
return False
# Build collision model
collision_model = None
if solids:
solids = [s for s in solids if s]
if len(solids) == 1:
collision_model = solids[0]
elif len(solids) > 1:
collision_model = Part.makeCompound(solids)
if not collision_model:
return False
# Create direct path wire
wire = Part.Wire([Part.makeLine(start_position, target_position)])
distance = wire.distToShape(collision_model)[0]
return distance < tolerance
def get_linking_moves( def get_linking_moves(
start_position: Vector, start_position: Vector,
target_position: Vector, target_position: Vector,
@@ -42,10 +73,23 @@ def get_linking_moves(
tool_shape: Part.Shape, # required placeholder tool_shape: Part.Shape, # required placeholder
solids: Optional[List[Part.Shape]] = None, solids: Optional[List[Part.Shape]] = None,
retract_height_offset: Optional[float] = None, retract_height_offset: Optional[float] = None,
skip_if_no_collision: bool = False,
) -> list: ) -> list:
"""
Generate linking moves from start to target position.
If skip_if_no_collision is True and the direct path at the current height
is collision-free, returns empty list (useful for canned drill cycles that
handle their own retraction).
"""
if start_position == target_position: if start_position == target_position:
return [] return []
# For canned cycles: if we're already at a safe height and can move directly, skip linking
if skip_if_no_collision:
if not check_collision(start_position, target_position, solids):
return []
if local_clearance > global_clearance: if local_clearance > global_clearance:
raise ValueError("Local clearance must not exceed global clearance") raise ValueError("Local clearance must not exceed global clearance")
@@ -59,7 +103,7 @@ def get_linking_moves(
if len(solids) == 1: if len(solids) == 1:
collision_model = solids[0] collision_model = solids[0]
elif len(solids) > 1: elif len(solids) > 1:
collision_model = Part.makeFuse(solids) collision_model = Part.makeCompound(solids)
# Determine candidate heights # Determine candidate heights
if retract_height_offset is not None: if retract_height_offset is not None:
@@ -72,7 +116,7 @@ def get_linking_moves(
else: else:
candidate_heights = {local_clearance, global_clearance} candidate_heights = {local_clearance, global_clearance}
heights = sorted(candidate_heights, reverse=True) heights = sorted(candidate_heights)
# Try each height # Try each height
for height in heights: for height in heights:
@@ -103,10 +147,21 @@ def get_linking_moves(
def make_linking_wire(start: Vector, target: Vector, z: float) -> Part.Wire: def make_linking_wire(start: Vector, target: Vector, z: float) -> Part.Wire:
p1 = Vector(start.x, start.y, z) p1 = Vector(start.x, start.y, z)
p2 = Vector(target.x, target.y, z) p2 = Vector(target.x, target.y, z)
e1 = Part.makeLine(start, p1) edges = []
e2 = Part.makeLine(p1, p2)
e3 = Part.makeLine(p2, target) # Only add retract edge if there's actual movement
return Part.Wire([e1, e2, e3]) if not start.isEqual(p1, 1e-6):
edges.append(Part.makeLine(start, p1))
# Only add traverse edge if there's actual movement
if not p1.isEqual(p2, 1e-6):
edges.append(Part.makeLine(p1, p2))
# Only add plunge edge if there's actual movement
if not p2.isEqual(target, 1e-6):
edges.append(Part.makeLine(p2, target))
return Part.Wire(edges) if edges else Part.Wire([Part.makeLine(start, target)])
def is_wire_collision_free( def is_wire_collision_free(

View File

@@ -92,24 +92,40 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
return PathOp.FeatureBaseGeometry | PathOp.FeatureLocations | PathOp.FeatureCoolant return PathOp.FeatureBaseGeometry | PathOp.FeatureLocations | PathOp.FeatureCoolant
def onDocumentRestored(self, obj): def onDocumentRestored(self, obj):
if not hasattr(obj, "chipBreakEnabled"): if hasattr(obj, "chipBreakEnabled"):
obj.renameProperty("chipBreakEnabled", "ChipBreakEnabled")
elif not hasattr(obj, "ChipBreakEnabled"):
obj.addProperty( obj.addProperty(
"App::PropertyBool", "App::PropertyBool",
"chipBreakEnabled", "ChipBreakEnabled",
"Drill", "Drill",
QT_TRANSLATE_NOOP("App::Property", "Use chipbreaking"), QT_TRANSLATE_NOOP("App::Property", "Use chipbreaking"),
) )
if not hasattr(obj, "feedRetractEnabled"): if hasattr(obj, "feedRetractEnabled"):
obj.renameProperty("feedRetractEnabled", "FeedRetractEnabled")
elif not hasattr(obj, "FeedRetractEnabled"):
obj.addProperty( obj.addProperty(
"App::PropertyBool", "App::PropertyBool",
"feedRetractEnabled", "FeedRetractEnabled",
"Drill", "Drill",
QT_TRANSLATE_NOOP("App::Property", "Use G85 boring cycle with feed out"), QT_TRANSLATE_NOOP("App::Property", "Use G85 boring cycle with feed out"),
) )
if hasattr(obj, "RetractMode"): if hasattr(obj, "RetractMode"):
obj.removeProperty("RetractMode") obj.removeProperty("RetractMode")
# Migration: Remove RetractHeight property and adjust StartDepth if needed
if hasattr(obj, "RetractHeight"):
# If RetractHeight was higher than StartDepth, migrate to StartDepth
if obj.RetractHeight.Value > obj.StartDepth.Value:
Path.Log.warning(
f"Migrating RetractHeight ({obj.RetractHeight.Value}) to StartDepth. "
f"Old StartDepth was {obj.StartDepth.Value}"
)
obj.StartDepth = obj.RetractHeight.Value
obj.removeProperty("RetractHeight")
if not hasattr(obj, "KeepToolDown"): if not hasattr(obj, "KeepToolDown"):
obj.addProperty( obj.addProperty(
"App::PropertyBool", "App::PropertyBool",
@@ -117,7 +133,7 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
"Drill", "Drill",
QT_TRANSLATE_NOOP( QT_TRANSLATE_NOOP(
"App::Property", "App::Property",
"Apply G99 retraction: only retract to RetractHeight between holes in this operation", "Apply G99 retraction: only retract to StartDepth between holes in this operation",
), ),
) )
@@ -140,7 +156,7 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
) )
obj.addProperty( obj.addProperty(
"App::PropertyBool", "App::PropertyBool",
"chipBreakEnabled", "ChipBreakEnabled",
"Drill", "Drill",
QT_TRANSLATE_NOOP("App::Property", "Use chipbreaking"), QT_TRANSLATE_NOOP("App::Property", "Use chipbreaking"),
) )
@@ -165,15 +181,6 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
"Calculate the tip length and subtract from final depth", "Calculate the tip length and subtract from final depth",
), ),
) )
obj.addProperty(
"App::PropertyDistance",
"RetractHeight",
"Drill",
QT_TRANSLATE_NOOP(
"App::Property",
"The height where cutting feed rate starts and retract height for peck operation",
),
)
obj.addProperty( obj.addProperty(
"App::PropertyEnumeration", "App::PropertyEnumeration",
"ExtraOffset", "ExtraOffset",
@@ -186,23 +193,36 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
"Drill", "Drill",
QT_TRANSLATE_NOOP( QT_TRANSLATE_NOOP(
"App::Property", "App::Property",
"Apply G99 retraction: only retract to RetractHeight between holes in this operation", "Apply G99 retraction: only retract to StartDepth between holes in this operation",
), ),
) )
obj.addProperty( obj.addProperty(
"App::PropertyBool", "App::PropertyBool",
"feedRetractEnabled", "FeedRetractEnabled",
"Drill", "Drill",
QT_TRANSLATE_NOOP("App::Property", "Use G85 boring cycle with feed out"), QT_TRANSLATE_NOOP("App::Property", "Use G85 boring cycle with feed out"),
) )
for n in self.propertyEnumerations():
setattr(obj, n[0], n[1])
def circularHoleExecute(self, obj, holes): def circularHoleExecute(self, obj, holes):
"""circularHoleExecute(obj, holes) ... generate drill operation for each hole in holes.""" """circularHoleExecute(obj, holes) ... generate drill operation for each hole in holes."""
Path.Log.track() Path.Log.track()
machinestate = PathMachineState.MachineState() machinestate = PathMachineState.MachineState()
# We should be at clearance height.
mode = "G99" if obj.KeepToolDown else "G98" mode = "G99" if obj.KeepToolDown else "G98"
# Validate that SafeHeight doesn't exceed ClearanceHeight
safe_height = obj.SafeHeight.Value
if safe_height > obj.ClearanceHeight.Value:
Path.Log.warning(
f"SafeHeight ({safe_height}) is above ClearanceHeight ({obj.ClearanceHeight.Value}). "
f"Using ClearanceHeight instead."
)
safe_height = obj.ClearanceHeight.Value
# Calculate offsets to add to target edge # Calculate offsets to add to target edge
endoffset = 0.0 endoffset = 0.0
if obj.ExtraOffset == "Drill Tip": if obj.ExtraOffset == "Drill Tip":
@@ -220,7 +240,7 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
# build list of solids for collision detection. # build list of solids for collision detection.
# Include base objects from job # Include base objects from job
solids = [] solids = []
for base in job.BaseObjects: for base in self.job.Model.Group:
solids.append(base.Shape) solids.append(base.Shape)
# http://linuxcnc.org/docs/html/gcode/g-code.html#gcode:g98-g99 # http://linuxcnc.org/docs/html/gcode/g-code.html#gcode:g98-g99
@@ -235,6 +255,8 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
# Make sure tool is at a clearance height # Make sure tool is at a clearance height
command = Path.Command("G0", {"Z": obj.ClearanceHeight.Value}) command = Path.Command("G0", {"Z": obj.ClearanceHeight.Value})
machinestate.addCommand(command)
# machine.addCommand(command) # machine.addCommand(command)
self.commandlist.append(command) self.commandlist.append(command)
@@ -251,31 +273,49 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
command = Path.Command("G0", {"X": startPoint.x, "Y": startPoint.y}) command = Path.Command("G0", {"X": startPoint.x, "Y": startPoint.y})
self.commandlist.append(command) self.commandlist.append(command)
machinestate.addCommand(command) machinestate.addCommand(command)
command = Path.Command("G0", {"Z": obj.SafeHeight.Value}) command = Path.Command("G0", {"Z": safe_height})
self.commandlist.append(command) self.commandlist.append(command)
machinestate.addCommand(command) machinestate.addCommand(command)
firstMove = False firstMove = False
else: # Use get_linking_moves generator else: # Check if we need linking moves
linking_moves = linking.get_linking_moves( # For G99 mode, tool is at StartDepth (R-plane) after previous hole
machinestate.getPosition(), # Check if direct move at retract plane would collide with model
startPoint, current_pos = machinestate.getPosition()
obj.ClearanceHeight.Value, target_at_retract_plane = FreeCAD.Vector(startPoint.x, startPoint.y, current_pos.z)
obj.SafeHeight.Value,
self.tool, # Check collision at the retract plane (current Z height)
solids, collision_detected = linking.check_collision(
obj.RetractHeight.Value, start_position=current_pos,
target_position=target_at_retract_plane,
solids=solids,
) )
if len(linking_moves) == 1: # straight move possible. Do nothing.
pass if collision_detected:
else: # Cannot traverse at retract plane - need to break cycle group
# Retract to safe height, traverse, then plunge to safe height for new cycle
target_at_safe_height = FreeCAD.Vector(startPoint.x, startPoint.y, safe_height)
linking_moves = linking.get_linking_moves(
start_position=current_pos,
target_position=target_at_safe_height,
local_clearance=safe_height,
global_clearance=obj.ClearanceHeight.Value,
tool_shape=self.tool.Shape,
solids=solids,
)
self.commandlist.extend(linking_moves) self.commandlist.extend(linking_moves)
for move in linking_moves:
machinestate.addCommand(move)
# else: no collision - G99 cycle continues, tool stays at retract plane
# Perform drilling # Perform drilling
dwelltime = obj.DwellTime if obj.DwellEnabled else 0.0 dwelltime = obj.DwellTime if obj.DwellEnabled else 0.0
peckdepth = obj.PeckDepth.Value if obj.PeckEnabled else 0.0 peckdepth = obj.PeckDepth.Value if obj.PeckEnabled else 0.0
repeat = 1 # technical debt: Add a repeat property for user control repeat = 1 # technical debt: Add a repeat property for user control
chipBreak = obj.chipBreakEnabled and obj.PeckEnabled chipBreak = obj.ChipBreakEnabled and obj.PeckEnabled
# Save Z position before canned cycle for G98 retract
z_before_cycle = machinestate.Z
try: try:
drillcommands = drill.generate( drillcommands = drill.generate(
@@ -283,9 +323,9 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
dwelltime, dwelltime,
peckdepth, peckdepth,
repeat, repeat,
obj.RetractHeight.Value, obj.StartDepth.Value,
chipBreak=chipBreak, chipBreak=chipBreak,
feedRetract=obj.feedRetractEnabled, feedRetract=obj.FeedRetractEnabled,
) )
except ValueError as e: # any targets that fail the generator are ignored except ValueError as e: # any targets that fail the generator are ignored
@@ -298,24 +338,24 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
annotations["RetractMode"] = mode annotations["RetractMode"] = mode
command.Annotations = annotations command.Annotations = annotations
self.commandlist.append(command) self.commandlist.append(command)
# machine.addCommand(command) machinestate.addCommand(command)
# Update Z position based on RetractMode
# G98: retract to initial Z (Z before cycle started)
# G99: retract to R parameter (StartDepth)
if mode == "G98":
machinestate.Z = z_before_cycle
else: # G99
machinestate.Z = obj.StartDepth.Value
# Apply feedrates to commands # Apply feedrates to commands
PathFeedRate.setFeedRate(self.commandlist, obj.ToolController) PathFeedRate.setFeedRate(self.commandlist, obj.ToolController)
def opSetDefaultValues(self, obj, job): def opSetDefaultValues(self, obj, job):
"""opSetDefaultValues(obj, job) ... set default value for RetractHeight""" """opSetDefaultValues(obj, job) ... set default values for drilling operation"""
obj.ExtraOffset = "None" obj.ExtraOffset = "None"
obj.KeepToolDown = False # default to safest option: G98 obj.KeepToolDown = False # default to safest option: G98
if hasattr(job.SetupSheet, "RetractHeight"):
obj.RetractHeight = job.SetupSheet.RetractHeight
elif self.applyExpression(obj, "RetractHeight", "StartDepth+SetupSheet.SafeHeightOffset"):
if not job:
obj.RetractHeight = 10
else:
obj.RetractHeight.Value = obj.StartDepth.Value + 1.0
if hasattr(job.SetupSheet, "PeckDepth"): if hasattr(job.SetupSheet, "PeckDepth"):
obj.PeckDepth = job.SetupSheet.PeckDepth obj.PeckDepth = job.SetupSheet.PeckDepth
elif self.applyExpression(obj, "PeckDepth", "OpToolDiameter*0.75"): elif self.applyExpression(obj, "PeckDepth", "OpToolDiameter*0.75"):
@@ -335,7 +375,6 @@ def SetupProperties():
setup.append("DwellEnabled") setup.append("DwellEnabled")
setup.append("AddTipLength") setup.append("AddTipLength")
setup.append("ExtraOffset") setup.append("ExtraOffset")
setup.append("RetractHeight")
setup.append("KeepToolDown") setup.append("KeepToolDown")
return setup return setup

View File

@@ -50,9 +50,6 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
def initPage(self, obj): def initPage(self, obj):
self.peckDepthSpinBox = PathGuiUtil.QuantitySpinBox(self.form.peckDepth, obj, "PeckDepth") self.peckDepthSpinBox = PathGuiUtil.QuantitySpinBox(self.form.peckDepth, obj, "PeckDepth")
self.peckRetractSpinBox = PathGuiUtil.QuantitySpinBox(
self.form.peckRetractHeight, obj, "RetractHeight"
)
self.dwellTimeSpinBox = PathGuiUtil.QuantitySpinBox(self.form.dwellTime, obj, "DwellTime") self.dwellTimeSpinBox = PathGuiUtil.QuantitySpinBox(self.form.dwellTime, obj, "DwellTime")
self.form.chipBreakEnabled.setEnabled(False) self.form.chipBreakEnabled.setEnabled(False)
@@ -75,9 +72,6 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
self.form.dwellEnabled.toggled.connect(self.form.feedRetractEnabled.setDisabled) self.form.dwellEnabled.toggled.connect(self.form.feedRetractEnabled.setDisabled)
self.form.dwellEnabled.toggled.connect(self.setChipBreakControl) self.form.dwellEnabled.toggled.connect(self.setChipBreakControl)
self.form.peckRetractHeight.setEnabled(True)
self.form.retractLabel.setEnabled(True)
if self.form.peckEnabled.isChecked(): if self.form.peckEnabled.isChecked():
self.form.dwellEnabled.setEnabled(False) self.form.dwellEnabled.setEnabled(False)
self.form.feedRetractEnabled.setEnabled(False) self.form.feedRetractEnabled.setEnabled(False)
@@ -109,14 +103,12 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
def updateQuantitySpinBoxes(self, index=None): def updateQuantitySpinBoxes(self, index=None):
self.peckDepthSpinBox.updateWidget() self.peckDepthSpinBox.updateWidget()
self.peckRetractSpinBox.updateWidget()
self.dwellTimeSpinBox.updateWidget() self.dwellTimeSpinBox.updateWidget()
def getFields(self, obj): def getFields(self, obj):
"""setFields(obj) ... update obj's properties with values from the UI""" """setFields(obj) ... update obj's properties with values from the UI"""
Path.Log.track() Path.Log.track()
self.peckDepthSpinBox.updateProperty() self.peckDepthSpinBox.updateProperty()
self.peckRetractSpinBox.updateProperty()
self.dwellTimeSpinBox.updateProperty() self.dwellTimeSpinBox.updateProperty()
if obj.KeepToolDown != self.form.KeepToolDownEnabled.isChecked(): if obj.KeepToolDown != self.form.KeepToolDownEnabled.isChecked():
@@ -125,10 +117,10 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
obj.DwellEnabled = self.form.dwellEnabled.isChecked() obj.DwellEnabled = self.form.dwellEnabled.isChecked()
if obj.PeckEnabled != self.form.peckEnabled.isChecked(): if obj.PeckEnabled != self.form.peckEnabled.isChecked():
obj.PeckEnabled = self.form.peckEnabled.isChecked() obj.PeckEnabled = self.form.peckEnabled.isChecked()
if obj.feedRetractEnabled != self.form.feedRetractEnabled.isChecked(): if obj.FeedRetractEnabled != self.form.feedRetractEnabled.isChecked():
obj.feedRetractEnabled = self.form.feedRetractEnabled.isChecked() obj.FeedRetractEnabled = self.form.feedRetractEnabled.isChecked()
if obj.chipBreakEnabled != self.form.chipBreakEnabled.isChecked(): if obj.ChipBreakEnabled != self.form.chipBreakEnabled.isChecked():
obj.chipBreakEnabled = self.form.chipBreakEnabled.isChecked() obj.ChipBreakEnabled = self.form.chipBreakEnabled.isChecked()
if obj.ExtraOffset != str(self.form.ExtraOffset.currentData()): if obj.ExtraOffset != str(self.form.ExtraOffset.currentData()):
obj.ExtraOffset = str(self.form.ExtraOffset.currentData()) obj.ExtraOffset = str(self.form.ExtraOffset.currentData())
@@ -147,7 +139,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
"Drill", "Drill",
QtCore.QT_TRANSLATE_NOOP( QtCore.QT_TRANSLATE_NOOP(
"App::Property", "App::Property",
"Apply G99 retraction: only retract to RetractHeight between holes in this operation", "Apply G99 retraction: only retract to StartDepth between holes in this operation",
), ),
) )
@@ -167,12 +159,12 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
self.form.peckEnabled.setCheckState(QtCore.Qt.Unchecked) self.form.peckEnabled.setCheckState(QtCore.Qt.Unchecked)
self.form.chipBreakEnabled.setEnabled(False) self.form.chipBreakEnabled.setEnabled(False)
if obj.chipBreakEnabled: if obj.ChipBreakEnabled:
self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Checked) self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Checked)
else: else:
self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Unchecked) self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Unchecked)
if obj.feedRetractEnabled: if obj.FeedRetractEnabled:
self.form.feedRetractEnabled.setCheckState(QtCore.Qt.Checked) self.form.feedRetractEnabled.setCheckState(QtCore.Qt.Checked)
else: else:
self.form.feedRetractEnabled.setCheckState(QtCore.Qt.Unchecked) self.form.feedRetractEnabled.setCheckState(QtCore.Qt.Unchecked)
@@ -186,7 +178,6 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
"""getSignalsForUpdate(obj) ... return list of signals which cause the receiver to update the model""" """getSignalsForUpdate(obj) ... return list of signals which cause the receiver to update the model"""
signals = [] signals = []
signals.append(self.form.peckRetractHeight.editingFinished)
signals.append(self.form.peckDepth.editingFinished) signals.append(self.form.peckDepth.editingFinished)
signals.append(self.form.dwellTime.editingFinished) signals.append(self.form.dwellTime.editingFinished)
if hasattr(self.form.dwellEnabled, "checkStateChanged"): # Qt version >= 6.7.0 if hasattr(self.form.dwellEnabled, "checkStateChanged"): # Qt version >= 6.7.0
@@ -209,7 +200,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
return signals return signals
def updateData(self, obj, prop): def updateData(self, obj, prop):
if prop in ["PeckDepth", "RetractHeight"] and not prop in ["Base", "Disabled"]: if prop in ["PeckDepth"] and not prop in ["Base", "Disabled"]:
self.updateQuantitySpinBoxes() self.updateQuantitySpinBoxes()

View File

@@ -30,8 +30,12 @@ import Path
import Path.Base.Drillable as Drillable import Path.Base.Drillable as Drillable
import math import math
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule()) 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 PathBaseGate(object): class PathBaseGate(object):

View File

@@ -0,0 +1,255 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
import re
from typing import Any, List, Tuple
import Path
import Path.Base.Util as PathUtil
import Path.Tool.Controller as PathToolController
class _FixtureSetupObject:
Path = None
Name = "Fixture"
InList = []
Label = "Fixture"
class _CommandObject:
def __init__(self, command):
self.Path = Path.Path([command])
self.Name = "Command"
self.InList = []
self.Label = "Command"
def needsTcOp(oldTc: Any, newTc: Any) -> bool:
return (
oldTc is None
or oldTc.ToolNumber != newTc.ToolNumber
or oldTc.SpindleSpeed != newTc.SpindleSpeed
or oldTc.SpindleDir != newTc.SpindleDir
)
def create_fixture_setup(processor: Any, order: int, fixture: str) -> _FixtureSetupObject:
fobj = _FixtureSetupObject()
c1 = Path.Command(fixture)
fobj.Path = Path.Path([c1])
if order != 0:
clearance_z = (
processor._job.Stock.Shape.BoundBox.ZMax
+ processor._job.SetupSheet.ClearanceHeightOffset.Value
)
c2 = Path.Command(f"G0 Z{clearance_z}")
fobj.Path.addCommands(c2)
fobj.InList.append(processor._job)
return fobj
def build_postlist_by_fixture(processor: Any, early_tool_prep: bool = False) -> list:
Path.Log.debug("Ordering by Fixture")
postlist = []
wcslist = processor._job.Fixtures
currTc = None
for index, f in enumerate(wcslist):
sublist = [create_fixture_setup(processor, index, f)]
for obj in processor._operations:
tc = PathUtil.toolControllerForOp(obj)
if tc is not None and PathUtil.activeForOp(obj):
if needsTcOp(currTc, tc):
sublist.append(tc)
Path.Log.debug(f"Appending TC: {tc.Name}")
currTc = tc
sublist.append(obj)
postlist.append((f, sublist))
return postlist
def build_postlist_by_tool(processor: Any, early_tool_prep: bool = False) -> list:
Path.Log.debug("Ordering by Tool")
postlist = []
wcslist = processor._job.Fixtures
toolstring = "None"
currTc = None
fixturelist = []
for index, f in enumerate(wcslist):
fixturelist.append(create_fixture_setup(processor, index, f))
curlist = []
sublist = []
def commitToPostlist():
if len(curlist) > 0:
for fixture in fixturelist:
sublist.append(fixture)
sublist.extend(curlist)
postlist.append((toolstring, sublist))
Path.Log.track(processor._job.PostProcessorOutputFile)
for _, obj in enumerate(processor._operations):
Path.Log.track(obj.Label)
if not PathUtil.activeForOp(obj):
Path.Log.track()
continue
tc = PathUtil.toolControllerForOp(obj)
if tc is None or not needsTcOp(currTc, tc):
curlist.append(obj)
else:
commitToPostlist()
sublist = [tc]
curlist = [obj]
currTc = tc
if "%T" in processor._job.PostProcessorOutputFile:
toolstring = f"{tc.ToolNumber}"
else:
toolstring = re.sub(r"[^\w\d-]", "_", tc.Label)
commitToPostlist()
return postlist
def build_postlist_by_operation(processor: Any, early_tool_prep: bool = False) -> list:
Path.Log.debug("Ordering by Operation")
postlist = []
wcslist = processor._job.Fixtures
currTc = None
for obj in processor._operations:
if not PathUtil.activeForOp(obj):
continue
sublist = []
Path.Log.debug(f"obj: {obj.Name}")
for index, f in enumerate(wcslist):
sublist.append(create_fixture_setup(processor, index, f))
tc = PathUtil.toolControllerForOp(obj)
if tc is not None:
if processor._job.SplitOutput or needsTcOp(currTc, tc):
sublist.append(tc)
currTc = tc
sublist.append(obj)
postlist.append((obj.Label, sublist))
return postlist
def buildPostList(processor: Any, early_tool_prep: bool = False) -> List[Tuple[str, List]]:
orderby = processor._job.OrderOutputBy
Path.Log.debug(f"Ordering by {orderby}")
if orderby == "Fixture":
postlist = build_postlist_by_fixture(processor, early_tool_prep)
elif orderby == "Tool":
postlist = build_postlist_by_tool(processor, early_tool_prep)
elif orderby == "Operation":
postlist = build_postlist_by_operation(processor, early_tool_prep)
else:
raise ValueError(f"Unknown order: {orderby}")
Path.Log.debug(f"Postlist: {postlist}")
if processor._job.SplitOutput:
final_postlist = postlist
else:
final_postlist = [("allitems", [item for sublist in postlist for item in sublist[1]])]
if early_tool_prep:
return apply_early_tool_prep(final_postlist)
return final_postlist
def apply_early_tool_prep(postlist: List[Tuple[str, List]]) -> List[Tuple[str, List]]:
"""
Apply early tool preparation optimization to the postlist.
This function modifies tool change commands to enable early tool preparation:
- Always outputs tool changes as "Tn M6" (tool number followed by change command)
- Additionally emits standalone "Tn" prep commands immediately after the previous M6
to allow the machine to prepare the next tool while the current tool is working
Example output:
T4 M6 <- change to tool 4
T5 <- prep tool 5 early (while T4 is working)
<gcode> <- operations with T4
T5 M6 <- change to tool 5 (already prepped)
T7 <- prep tool 7 early (while T5 is working)
<gcode> <- operations with T5
T7 M6 <- change to tool 7 (already prepped)
"""
# First, collect all tool controllers across all groups to find next tool
all_tool_controllers = []
for group_idx, (name, sublist) in enumerate(postlist):
for item_idx, item in enumerate(sublist):
if hasattr(item, "Proxy") and isinstance(item.Proxy, PathToolController.ToolController):
all_tool_controllers.append((group_idx, item_idx, item))
new_postlist = []
for group_idx, (name, sublist) in enumerate(postlist):
new_sublist = []
i = 0
while i < len(sublist):
item = sublist[i]
# Check if item is a tool controller
if hasattr(item, "Proxy") and isinstance(item.Proxy, PathToolController.ToolController):
# Tool controller has Path.Commands like: [Command (comment), Command M6 [T:n]]
# Find the M6 command
m6_cmd = None
for cmd in item.Path.Commands:
if cmd.Name == "M6":
m6_cmd = cmd
break
if m6_cmd and len(m6_cmd.Parameters) > 0:
# M6 command has parameters like {'T': 5}, access via key
tool_number = m6_cmd.Parameters.get("T", item.ToolNumber)
# Find this TC in the global list and check if there's a next one
tc_position = next(
(
idx
for idx, (g_idx, i_idx, tc) in enumerate(all_tool_controllers)
if g_idx == group_idx and i_idx == i
),
None,
)
next_tc = (
all_tool_controllers[tc_position + 1][2]
if tc_position is not None and tc_position + 1 < len(all_tool_controllers)
else None
)
# Keep the original M6 command with tool parameter (M6 T5 format)
# This is the valid FreeCAD format, postprocessor will reformat if needed
new_sublist.append(item)
# If there's a next tool controller, add Tn prep for it immediately after M6
if next_tc is not None:
next_tool_number = next_tc.ToolNumber
prep_next = Path.Command(f"T{next_tool_number}")
prep_next_object = _CommandObject(prep_next)
new_sublist.append(prep_next_object)
else:
# No M6 command found or no tool parameter, keep as-is
new_sublist.append(item)
else:
new_sublist.append(item)
i += 1
new_postlist.append((name, new_sublist))
return new_postlist

View File

@@ -39,6 +39,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import Path.Base.Util as PathUtil import Path.Base.Util as PathUtil
import Path.Post.UtilsArguments as PostUtilsArguments import Path.Post.UtilsArguments as PostUtilsArguments
import Path.Post.UtilsExport as PostUtilsExport import Path.Post.UtilsExport as PostUtilsExport
import Path.Post.PostList as PostList
import Path.Post.Utils as PostUtils
import FreeCAD import FreeCAD
import Path import Path
@@ -55,13 +57,6 @@ else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class _TempObject:
Path = None
Name = "Fixture"
InList = []
Label = "Fixture"
# #
# Define some types that are used throughout this file. # Define some types that are used throughout this file.
# #
@@ -93,6 +88,9 @@ class PostProcessorFactory:
module_name = f"{postname}_post" module_name = f"{postname}_post"
class_name = postname.title() class_name = postname.title()
Path.Log.debug(f"PostProcessorFactory.get_post_processor() - postname: {postname}")
Path.Log.debug(f"PostProcessorFactory.get_post_processor() - module_name: {module_name}")
Path.Log.debug(f"PostProcessorFactory.get_post_processor() - class_name: {class_name}")
# Iterate all the paths to find the module # Iterate all the paths to find the module
for path in paths: for path in paths:
@@ -120,12 +118,7 @@ class PostProcessorFactory:
def needsTcOp(oldTc, newTc): def needsTcOp(oldTc, newTc):
return ( return PostList.needsTcOp(oldTc, newTc)
oldTc is None
or oldTc.ToolNumber != newTc.ToolNumber
or oldTc.SpindleSpeed != newTc.SpindleSpeed
or oldTc.SpindleDir != newTc.SpindleDir
)
class PostProcessor: class PostProcessor:
@@ -167,164 +160,21 @@ class PostProcessor:
"""Get the units used by the post processor.""" """Get the units used by the post processor."""
return self._units return self._units
def _buildPostList(self): def _buildPostList(self, early_tool_prep=False):
"""Determine the specific objects and order to postprocess.
Returns a list of objects which can be passed to exportObjectsWith()
for final posting. The ordering strategy is determined by the job's
OrderOutputBy setting.
Args:
early_tool_prep: If True, split tool changes into separate prep (Tn)
and change (M6) commands for better machine efficiency
Returns:
List of (name, operations) tuples
""" """
determines the specific objects and order to postprocess return PostList.buildPostList(self, early_tool_prep)
Returns a list of objects which can be passed to
exportObjectsWith() for final posting."""
def __fixtureSetup(order, fixture, job):
"""Convert a Fixture setting to _TempObject instance with a G0 move to a
safe height every time the fixture coordinate system change. Skip
the move for first fixture, to avoid moving before tool and tool
height compensation is enabled."""
fobj = _TempObject()
c1 = Path.Command(fixture)
fobj.Path = Path.Path([c1])
# Avoid any tool move after G49 in preamble and before tool change
# and G43 in case tool height compensation is in use, to avoid
# dangerous move without tool compensation.
if order != 0:
c2 = Path.Command(
"G0 Z"
+ str(
job.Stock.Shape.BoundBox.ZMax + job.SetupSheet.ClearanceHeightOffset.Value
)
)
fobj.Path.addCommands(c2)
fobj.InList.append(job)
return fobj
wcslist = self._job.Fixtures
orderby = self._job.OrderOutputBy
Path.Log.debug(f"Ordering by {orderby}")
postlist = []
if orderby == "Fixture":
Path.Log.debug("Ordering by Fixture")
# Order by fixture means all operations and tool changes will be
# completed in one fixture before moving to the next.
currTc = None
for index, f in enumerate(wcslist):
# create an object to serve as the fixture path
sublist = [__fixtureSetup(index, f, self._job)]
# Now generate the gcode
for obj in self._operations:
tc = PathUtil.toolControllerForOp(obj)
if tc is not None and PathUtil.activeForOp(obj):
if needsTcOp(currTc, tc):
sublist.append(tc)
Path.Log.debug(f"Appending TC: {tc.Name}")
currTc = tc
sublist.append(obj)
postlist.append((f, sublist))
elif orderby == "Tool":
Path.Log.debug("Ordering by Tool")
# Order by tool means tool changes are minimized.
# all operations with the current tool are processed in the current
# fixture before moving to the next fixture.
toolstring = "None"
currTc = None
# Build the fixture list
fixturelist = []
for index, f in enumerate(wcslist):
# create an object to serve as the fixture path
fixturelist.append(__fixtureSetup(index, f, self._job))
# Now generate the gcode
curlist = [] # list of ops for tool, will repeat for each fixture
sublist = [] # list of ops for output splitting
def commitToPostlist():
if len(curlist) > 0:
for fixture in fixturelist:
sublist.append(fixture)
sublist.extend(curlist)
postlist.append((toolstring, sublist))
Path.Log.track(self._job.PostProcessorOutputFile)
for idx, obj in enumerate(self._operations):
Path.Log.track(obj.Label)
# check if the operation is active
if not PathUtil.activeForOp(obj):
Path.Log.track()
continue
tc = PathUtil.toolControllerForOp(obj)
# The operation has no ToolController or uses the same
# ToolController as the previous operations
if tc is None or not needsTcOp(currTc, tc):
# Queue current operation
curlist.append(obj)
# The operation is the first operation or uses a different
# ToolController as the previous operations
else:
# Commit previous operations
commitToPostlist()
# Queue current ToolController and operation
sublist = [tc]
curlist = [obj]
currTc = tc
# Determine the proper string for the operation's
# ToolController
if "%T" in self._job.PostProcessorOutputFile:
toolstring = f"{tc.ToolNumber}"
else:
toolstring = re.sub(r"[^\w\d-]", "_", tc.Label)
# Commit remaining operations
commitToPostlist()
elif orderby == "Operation":
Path.Log.debug("Ordering by Operation")
# Order by operation means ops are done in each fixture in
# sequence.
currTc = None
# Now generate the gcode
for obj in self._operations:
# check if the operation is active
if not PathUtil.activeForOp(obj):
continue
sublist = []
Path.Log.debug(f"obj: {obj.Name}")
for index, f in enumerate(wcslist):
sublist.append(__fixtureSetup(index, f, self._job))
tc = PathUtil.toolControllerForOp(obj)
if tc is not None:
if self._job.SplitOutput or needsTcOp(currTc, tc):
sublist.append(tc)
currTc = tc
sublist.append(obj)
postlist.append((obj.Label, sublist))
Path.Log.debug(f"Postlist: {postlist}")
if self._job.SplitOutput:
Path.Log.track()
return postlist
Path.Log.track()
finalpostlist = [("allitems", [item for slist in postlist for item in slist[1]])]
Path.Log.debug(f"Postlist: {postlist}")
return finalpostlist
def export(self) -> Union[None, GCodeSections]: def export(self) -> Union[None, GCodeSections]:
"""Process the parser arguments, then postprocess the 'postables'.""" """Process the parser arguments, then postprocess the 'postables'."""
@@ -446,6 +296,13 @@ class PostProcessor:
postables = self._buildPostList() postables = self._buildPostList()
# Process canned cycles for drilling operations
for _, section in enumerate(postables):
_, sublist = section
for obj in sublist:
if hasattr(obj, "Path"):
obj.Path = PostUtils.cannedCycleTerminator(obj.Path)
Path.Log.debug(f"postables count: {len(postables)}") Path.Log.debug(f"postables count: {len(postables)}")
g_code_sections = [] g_code_sections = []

View File

@@ -31,6 +31,7 @@ These are common functions and classes for creating custom post processors.
from Path.Base.MachineState import MachineState from Path.Base.MachineState import MachineState
from Path.Main.Gui.Editor import CodeEditor from Path.Main.Gui.Editor import CodeEditor
from Path.Geom import CmdMoveDrill
from PySide import QtCore, QtGui from PySide import QtCore, QtGui
@@ -412,3 +413,83 @@ def splitArcs(path, deflection=None):
machine.addCommand(command) machine.addCommand(command)
return Path.Path(results) return Path.Path(results)
def cannedCycleTerminator(path):
"""iterate through a Path object and insert G80 commands to terminate canned cycles at the correct time"""
# Canned cycles terminate if any parameter change other than XY coordinates.
# - if Z depth changes
# - if feed rate changes
# - if retract plane changes
# - if retract mode (G98/G99) changes
result = []
cycle_active = False
last_cycle_params = {}
last_retract_mode = None
explicit_retract_mode_set = False
for command in path.Commands:
if (
command.Name == "G80"
): # This shouldn't happen because cycle generators shouldn't be inserting it. Be safe anyway.
# G80 is already a cycle terminator, don't terminate before it
# Just mark cycle as inactive and pass it through
cycle_active = False
last_retract_mode = None
explicit_retract_mode_set = False
result.append(command)
elif command.Name in ["G98", "G99"]:
# Explicit retract mode in the path - track it
if cycle_active and last_retract_mode and command.Name != last_retract_mode:
# Mode changed while cycle active - terminate
result.append(Path.Command("G80"))
cycle_active = False
last_retract_mode = command.Name
explicit_retract_mode_set = True
result.append(command)
elif command.Name in CmdMoveDrill:
# Check if this cycle has different parameters than the last one
current_params = {k: v for k, v in command.Parameters.items() if k not in ["X", "Y"]}
# Get retract mode from annotations
current_retract_mode = command.Annotations.get("RetractMode", "G98")
# Check if we need to terminate the previous cycle
if cycle_active and (
current_params != last_cycle_params or current_retract_mode != last_retract_mode
):
# Parameters or retract mode changed, terminate previous cycle
result.append(Path.Command("G80"))
cycle_active = False
explicit_retract_mode_set = False
# Insert retract mode command if starting a new cycle or mode changed
# But only if it wasn't already explicitly set in the path
if (
not cycle_active or current_retract_mode != last_retract_mode
) and not explicit_retract_mode_set:
result.append(Path.Command(current_retract_mode))
# Add the cycle command
result.append(command)
cycle_active = True
last_cycle_params = current_params
last_retract_mode = current_retract_mode
explicit_retract_mode_set = False # Reset for next cycle
else:
# Non-cycle command (not G80 or drill cycle)
if cycle_active:
# Terminate active cycle
result.append(Path.Command("G80"))
cycle_active = False
last_retract_mode = None
explicit_retract_mode_set = False
result.append(command)
# If cycle is still active at the end, terminate it
if cycle_active:
result.append(Path.Command("G80"))
return Path.Path(result)