diff --git a/src/Mod/CAM/App/PathSegmentWalker.cpp b/src/Mod/CAM/App/PathSegmentWalker.cpp
index e948f8b546..2ed4fb161f 100644
--- a/src/Mod/CAM/App/PathSegmentWalker.cpp
+++ b/src/Mod/CAM/App/PathSegmentWalker.cpp
@@ -129,6 +129,7 @@ void PathSegmentVisitor::g38(int id, const Base::Vector3d& last, const Base::Vec
PathSegmentWalker::PathSegmentWalker(const Toolpath& 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 == "G89")) {
// 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;
if (cmd.has("R")) {
r = cmd.getValue("R");
diff --git a/src/Mod/CAM/CAMTests/TestPathPost.py b/src/Mod/CAM/CAMTests/TestPathPost.py
index 3955e3d334..9ede3a21dd 100644
--- a/src/Mod/CAM/CAMTests/TestPathPost.py
+++ b/src/Mod/CAM/CAMTests/TestPathPost.py
@@ -30,6 +30,7 @@ import Path
import Path.Post.Command as PathCommand
import Path.Post.Processor as PathPost
import Path.Post.Utils as PostUtils
+import Path.Post.UtilsExport as PostUtilsExport
import Path.Main.Job as PathJob
import Path.Tool.Controller as PathToolController
import difflib
@@ -503,6 +504,232 @@ class TestPathPostUtils(unittest.TestCase):
# self.assertTrue(len(results.Commands) == 117)
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):
"""
@@ -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
def setUpClass(cls):
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
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
# Original had 3 operations: outsideprofile, DrillAllHoles, Comment
# The Comment operation has no tool controller
@@ -638,8 +973,8 @@ class TestBuildPostList(unittest.TestCase):
postlist = self.pp._buildPostList()
firstoutputitem = postlist[0]
firstoplist = firstoutputitem[1]
- print(f"DEBUG test030: postlist length={len(firstoplist)}, expected=14")
- print(f"DEBUG test030: firstoplist={[str(item) for item in firstoplist]}")
+ if self.debug:
+ print(self._format_postables(postlist, "test030: No splitting, order by Operation"))
self.assertEqual(len(firstoplist), 14)
def test040(self):
@@ -652,13 +987,12 @@ class TestBuildPostList(unittest.TestCase):
postlist = self.pp._buildPostList()
firstoutputitem = postlist[0]
- print(f"DEBUG test040: firstoutputitem[0]={firstoutputitem[0]}, expected='5'")
- print(f"DEBUG test040: tool numbers={[tc.ToolNumber for tc in self.job.Tools.Group]}")
+ if self.debug:
+ print(self._format_postables(postlist, "test040: Split by tool, order by Tool"))
self.assertTrue(firstoutputitem[0] == str(5))
# check length of output
firstoplist = firstoutputitem[1]
- print(f"DEBUG test040: postlist length={len(firstoplist)}, expected=5")
self.assertEqual(len(firstoplist), 5)
def test050(self):
@@ -684,3 +1018,142 @@ class TestBuildPostList(unittest.TestCase):
firstoplist = firstoutputitem[1]
self.assertEqual(len(firstoplist), 6)
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"
+ )
diff --git a/src/Mod/CAM/CAMTests/TestPostGCodes.py b/src/Mod/CAM/CAMTests/TestPostGCodes.py
index d6ad008dd1..b1ec853fc3 100644
--- a/src/Mod/CAM/CAMTests/TestPostGCodes.py
+++ b/src/Mod/CAM/CAMTests/TestPostGCodes.py
@@ -713,12 +713,13 @@ class TestPostGCodes(PathTestUtils.PathTestBase):
def test10730(self):
"""Test G73 command Generation."""
+ cmd = Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5")
+ cmd.Annotations = {"RetractMode": "G99"}
path = [
Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"),
Path.Command("G90"),
- Path.Command("G99"),
- Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5"),
+ cmd,
Path.Command("G80"),
Path.Command("G90"),
]
@@ -905,12 +906,14 @@ G90
def test10810(self):
"""Test G81 command Generation."""
+ cmd = Path.Command("G81 X1 Y2 Z0 F123 R5")
+ cmd.Annotations = {"RetractMode": "G99"}
path = [
Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"),
Path.Command("G90"),
Path.Command("G99"),
- Path.Command("G81 X1 Y2 Z0 F123 R5"),
+ cmd,
Path.Command("G80"),
Path.Command("G90"),
]
@@ -1061,12 +1064,14 @@ G90
def test10820(self):
"""Test G82 command Generation."""
+ cmd = Path.Command("G82 X1 Y2 Z0 F123 R5 P1.23456")
+ cmd.Annotations = {"RetractMode": "G99"}
path = [
Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"),
Path.Command("G90"),
Path.Command("G99"),
- Path.Command("G82 X1 Y2 Z0 F123 R5 P1.23456"),
+ cmd,
Path.Command("G80"),
Path.Command("G90"),
]
@@ -1221,12 +1226,14 @@ G90
def test10830(self):
"""Test G83 command Generation."""
+ cmd = Path.Command("G83 X1 Y2 Z0 F123 Q1.5 R5")
+ cmd.Annotations = {"RetractMode": "G99"}
path = [
Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"),
Path.Command("G90"),
Path.Command("G99"),
- Path.Command("G83 X1 Y2 Z0 F123 Q1.5 R5"),
+ cmd,
Path.Command("G80"),
Path.Command("G90"),
]
diff --git a/src/Mod/CAM/CAMTests/TestTestPost.py b/src/Mod/CAM/CAMTests/TestTestPost.py
index b6590c3110..c9480ed21c 100644
--- a/src/Mod/CAM/CAMTests/TestTestPost.py
+++ b/src/Mod/CAM/CAMTests/TestTestPost.py
@@ -202,12 +202,14 @@ G54
def test00125(self) -> None:
"""Test chipbreaking amount."""
+ cmd = Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5")
+ cmd.Annotations = {"RetractMode": "G99"}
test_path = [
Path.Command("G0 X1 Y2"),
Path.Command("G0 Z8"),
Path.Command("G90"),
Path.Command("G99"),
- Path.Command("G73 X1 Y2 Z0 F123 Q1.5 R5"),
+ cmd,
Path.Command("G80"),
Path.Command("G90"),
]
diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt
index 66e38be32c..505239afb1 100644
--- a/src/Mod/CAM/CMakeLists.txt
+++ b/src/Mod/CAM/CMakeLists.txt
@@ -295,6 +295,7 @@ SET(PathPythonToolsShapeUi_SRCS
SET(PathPythonPost_SRCS
Path/Post/__init__.py
Path/Post/Command.py
+ Path/Post/PostList.py
Path/Post/Processor.py
Path/Post/Utils.py
Path/Post/UtilsArguments.py
diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpDrillingEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpDrillingEdit.ui
index d6a0330d5f..15511c7aba 100644
--- a/src/Mod/CAM/Gui/Resources/panels/PageOpDrillingEdit.ui
+++ b/src/Mod/CAM/Gui/Resources/panels/PageOpDrillingEdit.ui
@@ -153,23 +153,6 @@
- -
-
-
- false
-
-
- Retract
-
-
-
- -
-
-
- false
-
-
-
-
diff --git a/src/Mod/CAM/Path/Base/FeedRate.py b/src/Mod/CAM/Path/Base/FeedRate.py
index 5f627969ed..042c0bcdda 100644
--- a/src/Mod/CAM/Path/Base/FeedRate.py
+++ b/src/Mod/CAM/Path/Base/FeedRate.py
@@ -24,6 +24,7 @@ import FreeCAD
import Path
import Path.Base.MachineState as PathMachineState
import Part
+from Path.Geom import CmdMoveDrill
__title__ = "Feed Rate Helper Utility"
__author__ = "sliptonic (Brad Collette)"
@@ -63,7 +64,12 @@ def setFeedRate(commandlist, ToolController):
if command.Name not in Path.Geom.CmdMoveAll:
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 = (
ToolController.VertRapid.Value
if command.Name in Path.Geom.CmdMoveRapid
diff --git a/src/Mod/CAM/Path/Base/Generator/linking.py b/src/Mod/CAM/Path/Base/Generator/linking.py
index 3a3434856f..f410de22f9 100644
--- a/src/Mod/CAM/Path/Base/Generator/linking.py
+++ b/src/Mod/CAM/Path/Base/Generator/linking.py
@@ -34,6 +34,37 @@ else:
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(
start_position: Vector,
target_position: Vector,
@@ -42,10 +73,23 @@ def get_linking_moves(
tool_shape: Part.Shape, # required placeholder
solids: Optional[List[Part.Shape]] = None,
retract_height_offset: Optional[float] = None,
+ skip_if_no_collision: bool = False,
) -> 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:
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:
raise ValueError("Local clearance must not exceed global clearance")
@@ -59,7 +103,7 @@ def get_linking_moves(
if len(solids) == 1:
collision_model = solids[0]
elif len(solids) > 1:
- collision_model = Part.makeFuse(solids)
+ collision_model = Part.makeCompound(solids)
# Determine candidate heights
if retract_height_offset is not None:
@@ -72,7 +116,7 @@ def get_linking_moves(
else:
candidate_heights = {local_clearance, global_clearance}
- heights = sorted(candidate_heights, reverse=True)
+ heights = sorted(candidate_heights)
# Try each height
for height in heights:
@@ -103,10 +147,21 @@ def get_linking_moves(
def make_linking_wire(start: Vector, target: Vector, z: float) -> Part.Wire:
p1 = Vector(start.x, start.y, z)
p2 = Vector(target.x, target.y, z)
- e1 = Part.makeLine(start, p1)
- e2 = Part.makeLine(p1, p2)
- e3 = Part.makeLine(p2, target)
- return Part.Wire([e1, e2, e3])
+ edges = []
+
+ # Only add retract edge if there's actual movement
+ 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(
diff --git a/src/Mod/CAM/Path/Op/Drilling.py b/src/Mod/CAM/Path/Op/Drilling.py
index da9ec0c60b..ad7256e41a 100644
--- a/src/Mod/CAM/Path/Op/Drilling.py
+++ b/src/Mod/CAM/Path/Op/Drilling.py
@@ -92,24 +92,40 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
return PathOp.FeatureBaseGeometry | PathOp.FeatureLocations | PathOp.FeatureCoolant
def onDocumentRestored(self, obj):
- if not hasattr(obj, "chipBreakEnabled"):
+ if hasattr(obj, "chipBreakEnabled"):
+ obj.renameProperty("chipBreakEnabled", "ChipBreakEnabled")
+ elif not hasattr(obj, "ChipBreakEnabled"):
obj.addProperty(
"App::PropertyBool",
- "chipBreakEnabled",
+ "ChipBreakEnabled",
"Drill",
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(
"App::PropertyBool",
- "feedRetractEnabled",
+ "FeedRetractEnabled",
"Drill",
QT_TRANSLATE_NOOP("App::Property", "Use G85 boring cycle with feed out"),
)
+
if hasattr(obj, "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"):
obj.addProperty(
"App::PropertyBool",
@@ -117,7 +133,7 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
"Drill",
QT_TRANSLATE_NOOP(
"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(
"App::PropertyBool",
- "chipBreakEnabled",
+ "ChipBreakEnabled",
"Drill",
QT_TRANSLATE_NOOP("App::Property", "Use chipbreaking"),
)
@@ -165,15 +181,6 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
"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(
"App::PropertyEnumeration",
"ExtraOffset",
@@ -186,23 +193,36 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
"Drill",
QT_TRANSLATE_NOOP(
"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(
"App::PropertyBool",
- "feedRetractEnabled",
+ "FeedRetractEnabled",
"Drill",
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):
"""circularHoleExecute(obj, holes) ... generate drill operation for each hole in holes."""
Path.Log.track()
machinestate = PathMachineState.MachineState()
+ # We should be at clearance height.
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
endoffset = 0.0
if obj.ExtraOffset == "Drill Tip":
@@ -220,7 +240,7 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
# build list of solids for collision detection.
# Include base objects from job
solids = []
- for base in job.BaseObjects:
+ for base in self.job.Model.Group:
solids.append(base.Shape)
# 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
command = Path.Command("G0", {"Z": obj.ClearanceHeight.Value})
+ machinestate.addCommand(command)
+
# machine.addCommand(command)
self.commandlist.append(command)
@@ -251,31 +273,49 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
command = Path.Command("G0", {"X": startPoint.x, "Y": startPoint.y})
self.commandlist.append(command)
machinestate.addCommand(command)
- command = Path.Command("G0", {"Z": obj.SafeHeight.Value})
+ command = Path.Command("G0", {"Z": safe_height})
self.commandlist.append(command)
machinestate.addCommand(command)
firstMove = False
- else: # Use get_linking_moves generator
- linking_moves = linking.get_linking_moves(
- machinestate.getPosition(),
- startPoint,
- obj.ClearanceHeight.Value,
- obj.SafeHeight.Value,
- self.tool,
- solids,
- obj.RetractHeight.Value,
+ else: # Check if we need linking moves
+ # For G99 mode, tool is at StartDepth (R-plane) after previous hole
+ # Check if direct move at retract plane would collide with model
+ current_pos = machinestate.getPosition()
+ target_at_retract_plane = FreeCAD.Vector(startPoint.x, startPoint.y, current_pos.z)
+
+ # Check collision at the retract plane (current Z height)
+ collision_detected = linking.check_collision(
+ start_position=current_pos,
+ target_position=target_at_retract_plane,
+ solids=solids,
)
- if len(linking_moves) == 1: # straight move possible. Do nothing.
- pass
- else:
+
+ if collision_detected:
+ # 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)
+ for move in linking_moves:
+ machinestate.addCommand(move)
+ # else: no collision - G99 cycle continues, tool stays at retract plane
# Perform drilling
dwelltime = obj.DwellTime if obj.DwellEnabled else 0.0
peckdepth = obj.PeckDepth.Value if obj.PeckEnabled else 0.0
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:
drillcommands = drill.generate(
@@ -283,9 +323,9 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
dwelltime,
peckdepth,
repeat,
- obj.RetractHeight.Value,
+ obj.StartDepth.Value,
chipBreak=chipBreak,
- feedRetract=obj.feedRetractEnabled,
+ feedRetract=obj.FeedRetractEnabled,
)
except ValueError as e: # any targets that fail the generator are ignored
@@ -298,24 +338,24 @@ class ObjectDrilling(PathCircularHoleBase.ObjectOp):
annotations["RetractMode"] = mode
command.Annotations = annotations
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
PathFeedRate.setFeedRate(self.commandlist, obj.ToolController)
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.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"):
obj.PeckDepth = job.SetupSheet.PeckDepth
elif self.applyExpression(obj, "PeckDepth", "OpToolDiameter*0.75"):
@@ -335,7 +375,6 @@ def SetupProperties():
setup.append("DwellEnabled")
setup.append("AddTipLength")
setup.append("ExtraOffset")
- setup.append("RetractHeight")
setup.append("KeepToolDown")
return setup
diff --git a/src/Mod/CAM/Path/Op/Gui/Drilling.py b/src/Mod/CAM/Path/Op/Gui/Drilling.py
index 8bbb2f19e8..59b37e0b76 100644
--- a/src/Mod/CAM/Path/Op/Gui/Drilling.py
+++ b/src/Mod/CAM/Path/Op/Gui/Drilling.py
@@ -50,9 +50,6 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
def initPage(self, obj):
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.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.setChipBreakControl)
- self.form.peckRetractHeight.setEnabled(True)
- self.form.retractLabel.setEnabled(True)
-
if self.form.peckEnabled.isChecked():
self.form.dwellEnabled.setEnabled(False)
self.form.feedRetractEnabled.setEnabled(False)
@@ -109,14 +103,12 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
def updateQuantitySpinBoxes(self, index=None):
self.peckDepthSpinBox.updateWidget()
- self.peckRetractSpinBox.updateWidget()
self.dwellTimeSpinBox.updateWidget()
def getFields(self, obj):
"""setFields(obj) ... update obj's properties with values from the UI"""
Path.Log.track()
self.peckDepthSpinBox.updateProperty()
- self.peckRetractSpinBox.updateProperty()
self.dwellTimeSpinBox.updateProperty()
if obj.KeepToolDown != self.form.KeepToolDownEnabled.isChecked():
@@ -125,10 +117,10 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
obj.DwellEnabled = self.form.dwellEnabled.isChecked()
if obj.PeckEnabled != self.form.peckEnabled.isChecked():
obj.PeckEnabled = self.form.peckEnabled.isChecked()
- if obj.feedRetractEnabled != self.form.feedRetractEnabled.isChecked():
- obj.feedRetractEnabled = self.form.feedRetractEnabled.isChecked()
- if obj.chipBreakEnabled != self.form.chipBreakEnabled.isChecked():
- obj.chipBreakEnabled = self.form.chipBreakEnabled.isChecked()
+ if obj.FeedRetractEnabled != self.form.feedRetractEnabled.isChecked():
+ obj.FeedRetractEnabled = self.form.feedRetractEnabled.isChecked()
+ if obj.ChipBreakEnabled != self.form.chipBreakEnabled.isChecked():
+ obj.ChipBreakEnabled = self.form.chipBreakEnabled.isChecked()
if obj.ExtraOffset != str(self.form.ExtraOffset.currentData()):
obj.ExtraOffset = str(self.form.ExtraOffset.currentData())
@@ -147,7 +139,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
"Drill",
QtCore.QT_TRANSLATE_NOOP(
"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.chipBreakEnabled.setEnabled(False)
- if obj.chipBreakEnabled:
+ if obj.ChipBreakEnabled:
self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Checked)
else:
self.form.chipBreakEnabled.setCheckState(QtCore.Qt.Unchecked)
- if obj.feedRetractEnabled:
+ if obj.FeedRetractEnabled:
self.form.feedRetractEnabled.setCheckState(QtCore.Qt.Checked)
else:
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"""
signals = []
- signals.append(self.form.peckRetractHeight.editingFinished)
signals.append(self.form.peckDepth.editingFinished)
signals.append(self.form.dwellTime.editingFinished)
if hasattr(self.form.dwellEnabled, "checkStateChanged"): # Qt version >= 6.7.0
@@ -209,7 +200,7 @@ class TaskPanelOpPage(PathCircularHoleBaseGui.TaskPanelOpPage):
return signals
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()
diff --git a/src/Mod/CAM/Path/Op/Gui/Selection.py b/src/Mod/CAM/Path/Op/Gui/Selection.py
index 0163c13e81..1454f92f48 100644
--- a/src/Mod/CAM/Path/Op/Gui/Selection.py
+++ b/src/Mod/CAM/Path/Op/Gui/Selection.py
@@ -30,8 +30,12 @@ import Path
import Path.Base.Drillable as Drillable
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):
diff --git a/src/Mod/CAM/Path/Post/PostList.py b/src/Mod/CAM/Path/Post/PostList.py
new file mode 100644
index 0000000000..f3cdd5152d
--- /dev/null
+++ b/src/Mod/CAM/Path/Post/PostList.py
@@ -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)
+ <- operations with T4
+ T5 M6 <- change to tool 5 (already prepped)
+ T7 <- prep tool 7 early (while T5 is working)
+ <- 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
diff --git a/src/Mod/CAM/Path/Post/Processor.py b/src/Mod/CAM/Path/Post/Processor.py
index 5465b9f90c..b1ee7e8d97 100644
--- a/src/Mod/CAM/Path/Post/Processor.py
+++ b/src/Mod/CAM/Path/Post/Processor.py
@@ -39,6 +39,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union
import Path.Base.Util as PathUtil
import Path.Post.UtilsArguments as PostUtilsArguments
import Path.Post.UtilsExport as PostUtilsExport
+import Path.Post.PostList as PostList
+import Path.Post.Utils as PostUtils
import FreeCAD
import Path
@@ -55,13 +57,6 @@ else:
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.
#
@@ -93,6 +88,9 @@ class PostProcessorFactory:
module_name = f"{postname}_post"
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
for path in paths:
@@ -120,12 +118,7 @@ class PostProcessorFactory:
def needsTcOp(oldTc, newTc):
- return (
- oldTc is None
- or oldTc.ToolNumber != newTc.ToolNumber
- or oldTc.SpindleSpeed != newTc.SpindleSpeed
- or oldTc.SpindleDir != newTc.SpindleDir
- )
+ return PostList.needsTcOp(oldTc, newTc)
class PostProcessor:
@@ -167,164 +160,21 @@ class PostProcessor:
"""Get the units used by the post processor."""
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
- 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
+ return PostList.buildPostList(self, early_tool_prep)
def export(self) -> Union[None, GCodeSections]:
"""Process the parser arguments, then postprocess the 'postables'."""
@@ -446,6 +296,13 @@ class PostProcessor:
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)}")
g_code_sections = []
diff --git a/src/Mod/CAM/Path/Post/Utils.py b/src/Mod/CAM/Path/Post/Utils.py
index 76632c2a9a..fc5e18f66f 100644
--- a/src/Mod/CAM/Path/Post/Utils.py
+++ b/src/Mod/CAM/Path/Post/Utils.py
@@ -31,6 +31,7 @@ These are common functions and classes for creating custom post processors.
from Path.Base.MachineState import MachineState
from Path.Main.Gui.Editor import CodeEditor
+from Path.Geom import CmdMoveDrill
from PySide import QtCore, QtGui
@@ -412,3 +413,83 @@ def splitArcs(path, deflection=None):
machine.addCommand(command)
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)