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)