From 3c48b4a950e5f1736ec154dd080a033c83b955a8 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Fri, 13 Mar 2026 12:18:45 -0500 Subject: [PATCH] CAM: Machine-based postprocessing (#27507) CAM: Add template dropdown below machine name in editor Adds a template selection dropdown to the Machine Editor dialog, positioned below the machine name field. Dropdown lists user and built-in templates for new machines only. Adds MachineFactory template discovery and ensures templates are copied to build directory. src/Mod/CAM/CMakeLists.txt: - Copy machine templates to build directory for runtime access src/Mod/CAM/Machine/models/machine.py: - Add MachineFactory.list_builtin_templates() for template discovery - Use FreeCAD install path for built-in templates src/Mod/CAM/Machine/ui/editor/machine_editor.py: - Add template dropdown below name field for new machines - Populate dropdown with user and built-in templates - Update UI fields when template is selected Major improvements to the GenericPlasma postprocessor with new dialog-based mode selection and robust plasma cutting features. New Features: - Add pre_processing_dialog() for interactive mode selection - Replace persistent mark_entry_only property with runtime dialog - User-friendly choice between Normal Cutting and Mark Entry Points Only - Enhanced torch control with pierce height movement - Complete mark_entry_only implementation with proper marking sequence - Improved error handling and state management Technical Improvements: - Add _reset_plasma_state() for clean per-operation tracking - Fix Z direction detection with proper null checking - Enhanced height extraction from StartDepth/FinalDepth/ClearanceHeight - Better error handling with debug logging - Graceful fallback when GUI not available Bug Fixes: - Fix torch control to move to pierce height before ignition - Fix force_rapid_feeds to remove F parameters (revert from override) - Fix state tracking initialization and null value handling - Add proper state reset for each operationUses a dialog to set mark_only. Co-authored-by: tarman3 --- src/Mod/CAM/CAMTests/PostTestMocks.py | 36 + .../CAM/CAMTests/TestDrillCycleExpander.py | 472 ++++ src/Mod/CAM/CAMTests/TestFanucPost.py | 2 +- .../CAM/CAMTests/TestGcodeProcessingUtils.py | 534 +++++ src/Mod/CAM/CAMTests/TestGenericPlasma.py | 520 +++++ src/Mod/CAM/CAMTests/TestLinuxCNCPost.py | 481 +++- .../CAM/CAMTests/TestPathDressupDogboneII.py | 7 +- src/Mod/CAM/CAMTests/TestPathPreferences.py | 8 +- src/Mod/CAM/CAMTests/TestPathTapGenerator.py | 10 +- .../{TestPathPost.py => TestPostCore.py} | 751 +++--- src/Mod/CAM/CAMTests/TestPostOutput.py | 1850 +++++++++++++++ src/Mod/CAM/CAMTests/TestPostProcessor.py | 448 ++++ .../CAM/CAMTests/TestPostToolProcessing.py | 558 +++++ src/Mod/CAM/CAMTests/TestSnapmakerPost.py | 5 +- src/Mod/CAM/CAMTests/TestTestPost.py | 9 +- src/Mod/CAM/CMakeLists.txt | 67 +- src/Mod/CAM/Gui/DlgSettingsPathColor.cpp | 2 + src/Mod/CAM/Gui/DlgSettingsPathColor.ui | 30 +- src/Mod/CAM/Machine/models/machine.py | 26 + .../CAM/Machine/ui/editor/machine_editor.py | 130 +- src/Mod/CAM/Path/Base/Generator/tapping.py | 8 +- .../CAM/Path/Base/SetupSheetOpPrototype.py | 2 + src/Mod/CAM/Path/Main/Gui/Job.py | 2 +- src/Mod/CAM/Path/Main/Gui/PreferencesJob.py | 2 +- src/Mod/CAM/Path/Main/Job.py | 109 +- src/Mod/CAM/Path/Main/Sanity/Sanity.py | 102 +- src/Mod/CAM/Path/Op/Base.py | 67 + src/Mod/CAM/Path/Op/Gui/Base.py | 196 ++ src/Mod/CAM/Path/Post/Command.py | 94 +- src/Mod/CAM/Path/Post/DrillCycleExpander.py | 367 +++ src/Mod/CAM/Path/Post/GcodeProcessingUtils.py | 447 ++++ src/Mod/CAM/Path/Post/PostList.py | 122 +- src/Mod/CAM/Path/Post/Processor.py | 2069 ++++++++++++++++- src/Mod/CAM/Path/Post/Utils.py | 13 +- src/Mod/CAM/Path/Post/UtilsParse.py | 7 +- ...t.py => KineticNCBeamicon2_legacy_post.py} | 5 +- .../CAM/Path/Post/scripts/centroid_post.py | 38 + ...0_post.py => dynapath_4060_legacy_post.py} | 0 ...napath_post.py => dynapath_legacy_post.py} | 0 ...estlcam_post.py => estlcam_legacy_post.py} | 4 + .../{fablin_post.py => fablin_legacy_post.py} | 4 + ...ngling_post.py => fangling_legacy_post.py} | 0 .../{fanuc_post.py => fanuc_legacy_post.py} | 0 .../Path/Post/scripts/generic_plasma_post.py | 603 +++++ src/Mod/CAM/Path/Post/scripts/generic_post.py | 28 +- .../CAM/Path/Post/scripts/grbl_legacy_post.py | 4 + src/Mod/CAM/Path/Post/scripts/grbl_post.py | 6 + ...hain_post.py => heidenhain_legacy_post.py} | 0 .../{jtech_post.py => jtech_legacy_post.py} | 0 .../CAM/Path/Post/scripts/linuxcnc_post.py | 300 ++- .../CAM/Path/Post/scripts/mach3_mach4_post.py | 2 + .../{marlin_post.py => marlin_legacy_post.py} | 0 .../CAM/Path/Post/scripts/masso_g3_post.py | 6 + .../{nccad_post.py => nccad_legacy_post.py} | 0 .../Path/Post/scripts/opensbp_legacy_post.py | 376 +++ src/Mod/CAM/Path/Post/scripts/opensbp_post.py | 688 +++--- ...philips_post.py => philips_legacy_post.py} | 0 .../{rml_post.py => rml_legacy_post.py} | 0 .../{rrf_post.py => rrf_legacy_post.py} | 0 .../CAM/Path/Post/scripts/smoothie_post.py | 21 +- ...maker_post.py => snapmaker_legacy_post.py} | 6 + src/Mod/CAM/Path/Post/scripts/svg_post.py | 2 +- .../{uccnc_post.py => uccnc_legacy_post.py} | 4 + .../{wedm_post.py => wedm_legacy_post.py} | 0 src/Mod/CAM/Path/Preferences.py | 97 + src/Mod/CAM/TestCAMApp.py | 48 +- 66 files changed, 10693 insertions(+), 1102 deletions(-) create mode 100644 src/Mod/CAM/CAMTests/TestDrillCycleExpander.py create mode 100644 src/Mod/CAM/CAMTests/TestGcodeProcessingUtils.py create mode 100644 src/Mod/CAM/CAMTests/TestGenericPlasma.py rename src/Mod/CAM/CAMTests/{TestPathPost.py => TestPostCore.py} (67%) create mode 100644 src/Mod/CAM/CAMTests/TestPostOutput.py create mode 100644 src/Mod/CAM/CAMTests/TestPostProcessor.py create mode 100644 src/Mod/CAM/CAMTests/TestPostToolProcessing.py create mode 100644 src/Mod/CAM/Path/Post/DrillCycleExpander.py create mode 100644 src/Mod/CAM/Path/Post/GcodeProcessingUtils.py rename src/Mod/CAM/Path/Post/scripts/{KineticNCBeamicon2_post.py => KineticNCBeamicon2_legacy_post.py} (98%) rename src/Mod/CAM/Path/Post/scripts/{dynapath_4060_post.py => dynapath_4060_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{dynapath_post.py => dynapath_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{estlcam_post.py => estlcam_legacy_post.py} (99%) rename src/Mod/CAM/Path/Post/scripts/{fablin_post.py => fablin_legacy_post.py} (98%) rename src/Mod/CAM/Path/Post/scripts/{fangling_post.py => fangling_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{fanuc_post.py => fanuc_legacy_post.py} (100%) create mode 100644 src/Mod/CAM/Path/Post/scripts/generic_plasma_post.py rename src/Mod/CAM/Path/Post/scripts/{heidenhain_post.py => heidenhain_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{jtech_post.py => jtech_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{marlin_post.py => marlin_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{nccad_post.py => nccad_legacy_post.py} (100%) create mode 100644 src/Mod/CAM/Path/Post/scripts/opensbp_legacy_post.py rename src/Mod/CAM/Path/Post/scripts/{philips_post.py => philips_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{rml_post.py => rml_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{rrf_post.py => rrf_legacy_post.py} (100%) rename src/Mod/CAM/Path/Post/scripts/{snapmaker_post.py => snapmaker_legacy_post.py} (99%) rename src/Mod/CAM/Path/Post/scripts/{uccnc_post.py => uccnc_legacy_post.py} (99%) rename src/Mod/CAM/Path/Post/scripts/{wedm_post.py => wedm_legacy_post.py} (100%) diff --git a/src/Mod/CAM/CAMTests/PostTestMocks.py b/src/Mod/CAM/CAMTests/PostTestMocks.py index a112b77cfb..ca6600c86e 100644 --- a/src/Mod/CAM/CAMTests/PostTestMocks.py +++ b/src/Mod/CAM/CAMTests/PostTestMocks.py @@ -111,6 +111,42 @@ class MockSetupSheet: self.SafeHeightOffset = type("obj", (object,), {"Value": safe_height})() +class MockToolhead: + """Mock Toolhead/Spindle object.""" + + def __init__(self, index=0): + self.index = index + self.spindle_wait = 0 # Default to 0 to avoid spindle wait expansion + self.coolant_delay = 0 # Default to 0 to avoid coolant delay expansion + + +class MockMachine: + """Mock Machine object with postprocessor properties.""" + + def __init__(self): + self.postprocessor_properties = {} + self.toolheads = [MockToolhead(0)] # Default toolhead at index 0 + # Disable tool change and other processing to avoid extra commands in tests + processing_config = { + "early_tool_prep": False, + "filter_inefficient_moves": False, + "split_arcs": False, + "tool_change": False, # Disable tool change to avoid extra M3 commands + "translate_rapid_moves": False, + "xy_before_z_after_tool_change": False, + "spindle": False, # Disable spindle commands to avoid M3 S1000 + "coolant": False, # Disable coolant commands + } + # Make processing properties accessible as attributes + self.processing = type("Processing", (), processing_config)() + + def get_spindle_by_index(self, index): + """Get toolhead by index (legacy compatibility method).""" + if 0 <= index < len(self.toolheads): + return self.toolheads[index] + return None + + class MockJob: """Mock Job object for testing postprocessors.""" diff --git a/src/Mod/CAM/CAMTests/TestDrillCycleExpander.py b/src/Mod/CAM/CAMTests/TestDrillCycleExpander.py new file mode 100644 index 0000000000..cd934c7565 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestDrillCycleExpander.py @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2026 sliptonic +# SPDX-FileNotice: Part of the FreeCAD project. + +################################################################################ +# # +# FreeCAD is free software: you can redistribute it and/or modify # +# it under the terms of the GNU Lesser General Public License as # +# published by the Free Software Foundation, either version 2.1 # +# of the License, or (at your option) any later version. # +# # +# FreeCAD is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty # +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # +# See the GNU Lesser General Public License for more details. # +# # +# You should have received a copy of the GNU Lesser General Public # +# License along with FreeCAD. If not, see https://www.gnu.org/licenses # +# # +################################################################################ + +""" +Test suite for DrillCycleExpander class. +""" + +import unittest +import Path +from Path.Post.DrillCycleExpander import DrillCycleExpander + + +class TestDrillCycleExpander(unittest.TestCase): + """Test the DrillCycleExpander class with Path.Command objects.""" + + def test_00_error_r_less_than_z(self): + """Test error condition when R < Z.""" + + initial_position = {"X": 0.0, "Y": 0.0, "Z": 10.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + # Invalid: retract height below drill depth + cmd = Path.Command("G81", {"X": 5.0, "Y": 5.0, "Z": -3.0, "R": -5.0, "F": 100.0}) + expanded = expander.expand_command(cmd) + + # Should return empty list for error condition + self.assertEqual(len(expanded), 0) + + def test_01_modal_retract_mode(self): + """Test that G98/G99 modal commands are processed and filtered out""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 10.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + # Test G99 processing + cmd = Path.Command("G99", {}) + result = expander.expand_command(cmd) + + # Command should be filtered out (empty result) + self.assertEqual(len(result), 0) + + # Expander should track the mode + self.assertEqual(expander.retract_mode, "G99") + + # Test G98 processing + cmd = Path.Command("G98", {}) + result = expander.expand_command(cmd) + + # Command should be filtered out (empty result) + self.assertEqual(len(result), 0) + + # Expander should track the mode + self.assertEqual(expander.retract_mode, "G98") + + def test_02_position_tracking(self): + """Test that position is tracked correctly""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 10.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + commands = [ + Path.Command("G0", {"X": 5.0, "Y": 10.0, "Z": 15.0}), + Path.Command("G81", {"Z": -5.0, "R": 2.0, "F": 100.0}), # No X/Y, should use current + ] + + # Expand commands to update position tracking + expander.expand_commands(commands) + + # Position should be updated from first move + self.assertEqual(expander.current_position["X"], 5.0) + self.assertEqual(expander.current_position["Y"], 10.0) + + def test_03_expand_path_object(self): + """Test expanding a complete Path object""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 10.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + commands = [ + Path.Command("G0", {"X": 10.0, "Y": 10.0, "Z": 30.0}), + Path.Command("G1", {"X": 10.0, "Y": 10.0, "Z": 10.0}), + Path.Command("G81", {"X": 10.0, "Y": 10.0, "Z": -5.0, "R": 2.0, "F": 100.0}), + Path.Command("G0", {"X": 10.0, "Y": 10.0, "Z": 30.0}), + ] + + path = Path.Path(commands) + expanded_path = expander.expand_path(path) + + # Should have more commands than original (drill expanded) + self.assertGreater(len(expanded_path.Commands), len(path.Commands)) + + # Should not contain G81 anymore + cmd_names = [c.Name for c in expanded_path.Commands] + self.assertNotIn("G81", cmd_names) + + # Should contain basic movements + self.assertIn("G0", cmd_names) + self.assertIn("G1", cmd_names) + + def test_04_g81_with_g98(self): + """Test 1: Basic G81 (simple drill) with G98 retract""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 30.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 10, "F": 10.0}), + ] + + expected_cmds = [ + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 30.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 10.0}), # Z to R position + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 30.0}), + ] + + result = expander.expand_commands(input_cmds) + + print("\n") + print("#### Input ####") + print(f"starting position: {initial_position}") + print(f"retract mode: {retract_mode}") + print(Path.Path(input_cmds).toGCode()) + print("#### Result ####") + print(Path.Path(result).toGCode()) + print("##########") + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch") + + def test_05_g81_with_g99(self): + """Test 2: G81 with G99 retract (retract to R instead of initial Z)""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 30.0} + retract_mode = "G99" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 10, "F": 10.0}), + ] + + expected_cmds = [ + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 30.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 10.0}), + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 10.0}), + ] + + result = expander.expand_commands(input_cmds) + + print("\n") + print("#### Input ####") + print(f"starting position: {initial_position}") + print(f"retract mode: {retract_mode}") + print(Path.Path(input_cmds).toGCode()) + print("#### Result ####") + print(Path.Path(result).toGCode()) + print("##########") + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch") + + def test_06_g82(self): + """Test 3: G82 (drill with dwell)""" + # Initialize expander with G98 retract mode + initial_position = {"X": 0.0, "Y": 0.0, "Z": 0.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G82", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 0.1, "P": 1.5, "F": 10.0}), + Path.Command("G80", {}), # This should be filtered out + ] + + expected_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # XY move at current Z + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), # Z to R position + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), + Path.Command("G4", {"P": 1.5}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # Retract to initial Z + # G80 is filtered out + ] + + result = expander.expand_commands(input_cmds) + + print("\n") + print("#### Input ####") + print(f"starting position: {initial_position}") + print(f"retract mode: {retract_mode}") + print(Path.Path(input_cmds).toGCode()) + print("#### Result ####") + print(Path.Path(result).toGCode()) + print("##########") + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch") + + def test_07_g83(self): + """Test 4: G83 (peck drill) with 3 pecks""" + # Initialize expander with G98 retract mode + initial_position = {"X": 0.0, "Y": 0.0, "Z": 0.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G83", {"X": 1.0, "Y": 1.0, "Z": -0.6, "R": 0.1, "Q": 0.2, "F": 10.0}), + Path.Command("G80", {}), + ] + + expected_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # XY move at current Z + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), # Z to R position + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.1, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": -0.09}), + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.3, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": -0.29}), + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": -0.49}), + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.6, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # Retract to initial Z + ] + + result = expander.expand_commands(input_cmds) + + print("\n") + print("#### Input ####") + print(f"starting position: {initial_position}") + print(f"retract mode: {retract_mode}") + print(Path.Path(input_cmds).toGCode()) + print("#### Result ####") + print(Path.Path(result).toGCode()) + print("##########") + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + # Allow small floating point differences + for param in exp.Parameters: + self.assertAlmostEqual( + res.Parameters.get(param, 0), + exp.Parameters[param], + places=5, + msg=f"Command {i}: parameter {param} mismatch", + ) + + def test_08_preliminary_moves(self): + """Test preliminary motion according to LinuxCNC specification""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 30.0} + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 10, "F": 10.0}), + ] + + # According to LinuxCNC spec: + # 1. Since Z=30 > R=10, no preliminary Z move + # 2. Move XY to position at current Z (30) + # 3. Move Z to R position (10) since it's not already there + # 4. Drill + # 5. Retract to initial Z (30) for G98 + expected_cmds = [ + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 30.0}), # XY move at current Z + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 10.0}), # Z to R position + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), # Drill + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 30.0}), # Retract to initial Z (G98) + ] + + result = expander.expand_commands(input_cmds) + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch") + + def test_09_preliminary_moves_z_below_r(self): + """Test preliminary motion when Z starts below R""" + initial_position = {"X": 0.0, "Y": 0.0, "Z": 5.0} # Below R=10 + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G81", {"X": 1.0, "Y": 1.0, "Z": -0.5, "R": 10, "F": 10.0}), + ] + + # According to LinuxCNC spec: + # 1. Since Z=5 < R=10, preliminary Z move to R (once) + # 2. Move XY to position at current Z (now 10) + # 3. Z is already at R, no additional Z move + # 4. Drill + # 5. Retract to initial Z (5) for G98, but initial Z < R, so retract to R + expected_cmds = [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 10.0}), # Preliminary Z to R + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 10.0}), # XY move at R + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), # Drill + Path.Command( + "G0", {"X": 1.0, "Y": 1.0, "Z": 10.0} + ), # Retract to R (max of initial Z=5 and R=10) + ] + + result = expander.expand_commands(input_cmds) + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch") + + def test_10_g73(self): + """Test 6: G73 (chip breaking drill) with small retracts""" + # Initialize expander with G98 retract mode + initial_position = {"X": 0.0, "Y": 0.0, "Z": 0.0} # Below R=10 + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + + input_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G73", {"X": 1.0, "Y": 1.0, "Z": -0.6, "R": 0.1, "Q": 0.2, "F": 10.0}), + Path.Command("G80", {}), # This should be filtered out + ] + + expected_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # XY move at current Z + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), # Z to R position + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.1, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": -0.09}), # Small retract (chip break) + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.3, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": -0.29}), # Small retract (chip break) + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": -0.49}), # Small retract (chip break) + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.6, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), # Final retract to R + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # Retract to initial Z + # G80 is filtered out + ] + + result = expander.expand_commands(input_cmds) + print("\n") + print("#### Input ####") + print(f"starting position: {initial_position}") + print(f"retract mode: {retract_mode}") + print(Path.Path(input_cmds).toGCode()) + print("#### Result ####") + print(Path.Path(result).toGCode()) + print("##########") + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + # Allow small floating point differences + for param in exp.Parameters: + self.assertAlmostEqual( + res.Parameters.get(param, 0), + exp.Parameters[param], + places=5, + msg=f"Command {i}: parameter {param} mismatch", + ) + + def test_11_cycle_multiple_positions(self): + """Test 5: Modal cycle with multiple positions (G81)""" + # Initialize expander with G98 retract mode + initial_position = {"X": 0.0, "Y": 0.0, "Z": 0.0} # Below R=10 + retract_mode = "G98" + expander = DrillCycleExpander( + retract_mode=retract_mode, initial_position=initial_position.copy() + ) + input_cmds = [ + Path.Command("G0", {"Z": 1.0}), + 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}), # Modal - reuses Z, R, F + Path.Command("G81", {"X": 3.0, "Y": 3.0}), # Modal - reuses Z, R, F + Path.Command("G80", {}), + ] + + # Note: The expander needs to track modal parameters (Z, R, F) from the first G81 + # For now, we'll test with explicit parameters since modal parameter tracking + # is a more complex feature that may need to be added + input_cmds_explicit = [ + Path.Command("G0", {"Z": 1.0}), + 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}), + Path.Command("G81", {"X": 3.0, "Y": 3.0, "Z": -0.5, "R": 0.1, "F": 10.0}), + Path.Command("G80", {}), + ] + + expected_cmds = [ + Path.Command("G0", {"Z": 1.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # XY move at current Z + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 0.1}), # Z to R position + Path.Command("G1", {"X": 1.0, "Y": 1.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 1.0, "Y": 1.0, "Z": 1.0}), # Retract to initial Z + Path.Command("G0", {"X": 2.0, "Y": 2.0, "Z": 1.0}), # XY move at current Z + Path.Command("G0", {"X": 2.0, "Y": 2.0, "Z": 0.1}), # Z to R position + Path.Command("G1", {"X": 2.0, "Y": 2.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 2.0, "Y": 2.0, "Z": 1.0}), # Retract to initial Z + Path.Command("G0", {"X": 3.0, "Y": 3.0, "Z": 1.0}), # XY move at current Z + Path.Command("G0", {"X": 3.0, "Y": 3.0, "Z": 0.1}), # Z to R position + Path.Command("G1", {"X": 3.0, "Y": 3.0, "Z": -0.5, "F": 10.0}), + Path.Command("G0", {"X": 3.0, "Y": 3.0, "Z": 1.0}), # Retract to initial Z + # G80 is filtered out + ] + + result = expander.expand_commands(input_cmds_explicit) + + print("\n") + print("#### Input ####") + print(f"starting position: {initial_position}") + print(f"retract mode: {retract_mode}") + print(Path.Path(input_cmds).toGCode()) + print("#### Result ####") + print(Path.Path(result).toGCode()) + print("##########") + + self.assertEqual(len(result), len(expected_cmds)) + for i, (res, exp) in enumerate(zip(result, expected_cmds)): + self.assertEqual(res.Name, exp.Name, f"Command {i}: name mismatch") + self.assertEqual(res.Parameters, exp.Parameters, f"Command {i}: parameters mismatch") diff --git a/src/Mod/CAM/CAMTests/TestFanucPost.py b/src/Mod/CAM/CAMTests/TestFanucPost.py index 12724e7227..f7cb0c6337 100644 --- a/src/Mod/CAM/CAMTests/TestFanucPost.py +++ b/src/Mod/CAM/CAMTests/TestFanucPost.py @@ -66,7 +66,7 @@ class TestFanucPost(PathTestUtils.PathTestBase): ) # Create postprocessor using the mock job - self.post = PostProcessorFactory.get_post_processor(self.job, "fanuc") + self.post = PostProcessorFactory.get_post_processor(self.job, "fanuc_legacy") # allow a full length "diff" if an error occurs self.maxDiff = None diff --git a/src/Mod/CAM/CAMTests/TestGcodeProcessingUtils.py b/src/Mod/CAM/CAMTests/TestGcodeProcessingUtils.py new file mode 100644 index 0000000000..d0a46c5f65 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestGcodeProcessingUtils.py @@ -0,0 +1,534 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2026 sliptonic * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import unittest + +from Path.Post.GcodeProcessingUtils import ( + insert_line_numbers, + suppress_redundant_axes_words, + filter_inefficient_moves, + deduplicate_repeated_commands, + NumberGenerator, +) + + +class TestInsertLineNumbers(unittest.TestCase): + """Test the insert_line_numbers function.""" + + def test_empty_list(self): + """Test with empty list.""" + result = insert_line_numbers([]) + self.assertEqual(result, []) + + def test_single_line(self): + """Test with single G-code line.""" + gcode = ["G0 X10 Y20"] + result = insert_line_numbers(gcode) + expected = ["N10 G0 X10 Y20"] + self.assertEqual(result, expected) + + def test_multiple_lines(self): + """Test with multiple G-code lines.""" + gcode = ["G0 X0 Y0 Z0", "G1 X10 Y20 Z5", "G0 Z10"] + result = insert_line_numbers(gcode) + expected = ["N10 G0 X0 Y0 Z0", "N20 G1 X10 Y20 Z5", "N30 G0 Z10"] + self.assertEqual(result, expected) + + def test_skip_comments(self): + """Test that comments are not numbered.""" + gcode = ["(Header comment)", "G0 X0 Y0", "(Inline comment)", "G1 X10 Y10"] + result = insert_line_numbers(gcode) + expected = ["(Header comment)", "N10 G0 X0 Y0", "(Inline comment)", "N20 G1 X10 Y10"] + self.assertEqual(result, expected) + + def test_skip_empty_lines(self): + """Test that empty lines are not numbered.""" + gcode = ["", "G0 X0 Y0", " ", "G1 X10 Y10"] + result = insert_line_numbers(gcode) + expected = ["", "N10 G0 X0 Y0", " ", "N20 G1 X10 Y10"] + self.assertEqual(result, expected) + + +class TestSuppressRedundantAxesWords(unittest.TestCase): + """Test the suppress_redundant_axes_words function.""" + + def test_empty_list(self): + """Test with empty list.""" + result = suppress_redundant_axes_words([]) + self.assertEqual(result, []) + + def test_no_duplicates(self): + """Test with no redundant axes (same as input).""" + gcode = ["G1 X10 Y20 Z5"] + result = suppress_redundant_axes_words(gcode) + expected = ["G1 X10 Y20 Z5"] + self.assertEqual(result, expected) + + def test_suppress_redundant_axes(self): + """Test suppressing redundant axis values based on current position.""" + gcode = [ + "G0 X0 Y0 Z0", # Set initial position + "G1 X0 Y10 Z0", # X is redundant, Y changes + "G1 X0 Y10 Z5", # X and Y redundant, Z changes + "G1 X10 Y10 Z5", # Only X changes + ] + result = suppress_redundant_axes_words(gcode) + expected = [ + "G0 X0 Y0 Z0", # All axes are new + "G1 Y10", # X redundant, Y changes + "G1 Z5", # X and Y redundant, Z changes + "G1 X10", # Only X changes + ] + self.assertEqual(result, expected) + + def test_suppress_redundant_feed_rates(self): + """Test suppressing redundant feed rate values.""" + gcode = [ + "G0 X0 Y0 Z0 F1000", # Set initial feed rate + "G1 X10 Y0 Z0 F1000", # Feed rate redundant + "G1 X20 Y0 Z0 F2000", # Feed rate changes + "G1 X30 Y0 Z0 F2000", # Feed rate redundant again + ] + result = suppress_redundant_axes_words(gcode) + expected = [ + "G0 X0 Y0 Z0 F1000", # Feed rate is new + "G1 X10", # Feed rate redundant + "G1 X20 F2000", # Feed rate changes + "G1 X30", # Feed rate redundant + ] + self.assertEqual(result, expected) + + def test_mixed_axes_and_feed_suppression(self): + """Test suppressing both redundant axes and feed rates.""" + gcode = [ + "G0 X0 Y0 Z0 F1000", # Set initial state + "G1 X0 Y10 Z0 F1000", # X and F redundant, Y changes + "G1 X0 Y10 Z5 F1000", # X, Y, F redundant, Z changes + "G1 X10 Y10 Z5 F2000", # X, Y, Z redundant, F changes + ] + result = suppress_redundant_axes_words(gcode) + expected = [ + "G0 X0 Y0 Z0 F1000", # All new + "G1 Y10", # X and F redundant + "G1 Z5", # X, Y, F redundant + "G1 X10 F2000", # X, Y, Z redundant + ] + self.assertEqual(result, expected) + + def test_different_axes(self): + """Test with different axes (should keep all).""" + gcode = ["G0 X0 Y0 Z0", "G1 X10 Y20 Z5 A30 B40"] + result = suppress_redundant_axes_words(gcode) + expected = ["G0 X0 Y0 Z0", "G1 X10 Y20 Z5 A30 B40"] + self.assertEqual(result, expected) + + def test_skip_comments(self): + """Test that comments are unchanged.""" + gcode = ["(Header comment)", "G0 X0 Y0 Z0", "G1 X0 Y10 Z0", "(Inline comment)"] + result = suppress_redundant_axes_words(gcode) + expected = ["(Header comment)", "G0 X0 Y0 Z0", "G1 Y10", "(Inline comment)"] + self.assertEqual(result, expected) + + def test_blockdelete_slash_preservation(self): + """Test that leading slashes (blockdelete mode) are preserved.""" + gcode = [ + "G0 X0 Y0 Z0", # Normal line + "/G1 X0 Y10 Z0", # Blockdelete line + "/G1 X0 Y10 Z5", # Blockdelete with redundant axes + "G1 X10 Y10 Z5", # Normal line + ] + result = suppress_redundant_axes_words(gcode) + expected = [ + "G0 X0 Y0 Z0", # Normal + "/G1 Y10", # Blockdelete preserved, X redundant + "/G1 Z5", # Blockdelete preserved, X,Y redundant + "G1 X10", # Normal, X changes + ] + self.assertEqual(result, expected) + + +class TestFilterInefficientMoves(unittest.TestCase): + """Test the filter_inefficient_moves function.""" + + def test_empty_list(self): + """Test with empty list.""" + result = filter_inefficient_moves([]) + self.assertEqual(result, []) + + def test_keep_different_moves(self): + """Test keeping moves to different positions.""" + gcode = ["G0 X0 Y0 Z0", "G1 X10 Y20 Z5", "G0 X20 Y30 Z10"] + result = filter_inefficient_moves(gcode) + expected = ["G0 X0 Y0 Z0", "G1 X10 Y20 Z5", "G0 X20 Y30 Z10"] + self.assertEqual(result, expected) + + def test_filter_same_position_moves(self): + """Test that same position moves are not filtered (only rapid chains are optimized).""" + gcode = [ + "G0 X10 Y20 Z5", + "G1 X10 Y20 Z5", # G1 to same position - kept (not a rapid move) + "G0 X10 Y20 Z5", # G0 to same position - would be redundant but not in a chain + ] + result = filter_inefficient_moves(gcode) + expected = [ + "G0 X10 Y20 Z5", + "G1 X10 Y20 Z5", # G1 moves are preserved + "G0 X10 Y20 Z5", # Single G0 is preserved + ] + self.assertEqual(result, expected) + + def test_keep_non_move_commands(self): + """Test keeping non-move commands.""" + gcode = ["M3 S1000", "G0 X10 Y20 Z5", "M5", "G1 X10 Y20 Z5"] # G1 to same position - kept + result = filter_inefficient_moves(gcode) + expected = ["M3 S1000", "G0 X10 Y20 Z5", "M5", "G1 X10 Y20 Z5"] # G1 moves are preserved + self.assertEqual(result, expected) + + def test_partial_position_changes(self): + """Test moves that change only some axes.""" + gcode = [ + "G0 X0 Y0 Z0", + "G1 X10 Y0 Z0", # Changes X + "G1 X10 Y20 Z0", # Changes Y + "G1 X10 Y20 Z0", # No change - kept (not rapid) + ] + result = filter_inefficient_moves(gcode) + expected = [ + "G0 X0 Y0 Z0", + "G1 X10 Y0 Z0", + "G1 X10 Y20 Z0", + "G1 X10 Y20 Z0", # G1 to same position is kept + ] + self.assertEqual(result, expected) + + def test_skip_comments(self): + """Test that comments are preserved.""" + gcode = [ + "(Start)", + "G0 X0 Y0 Z0", + "(Comment)", + "G1 X0 Y0 Z0", # G1 to same position - kept + "(End)", + ] + result = filter_inefficient_moves(gcode) + expected = [ + "(Start)", + "G0 X0 Y0 Z0", + "(Comment)", + "G1 X0 Y0 Z0", # G1 moves are preserved + "(End)", + ] + self.assertEqual(result, expected) + + def test_skip_empty_lines(self): + """Test that empty lines are preserved.""" + gcode = ["", "G0 X10 Y20 Z5", " ", "G1 X10 Y20 Z5"] # G1 to same position - kept + result = filter_inefficient_moves(gcode) + expected = ["", "G0 X10 Y20 Z5", " ", "G1 X10 Y20 Z5"] # G1 moves are preserved + self.assertEqual(result, expected) + + def test_optimize_single_axis_collapse(self): + """Test collapsing rapid chain with single-axis changes.""" + gcode = ["G0 X10.0", "G0 X20.0", "G0 X30.0"] + result = filter_inefficient_moves(gcode) + expected = ["G0 X30.0"] # Only last position kept + self.assertEqual(result, expected) + + def test_optimize_multi_axis_no_collapse(self): + """Test that multi-axis rapid chains within linear group DO collapse.""" + gcode = ["G0 X10.0 Y10.0", "G0 X20.0 Y20.0"] + result = filter_inefficient_moves(gcode) + expected = ["G0 X20.0 Y20.0"] # Collapsed to final position (both X,Y in linear group) + self.assertEqual(result, expected) + + def test_optimize_with_side_effects(self): + """Test no collapsing when side effects are present.""" + gcode = [ + "G0 Z10.0", + "M6 T1", # Tool change, has side effect + "G0 Z5.0", + ] + result = filter_inefficient_moves(gcode) + expected = [ + "G0 Z10.0", + "M6 T1", # Side effect should flush chain + "G0 Z5.0", + ] + self.assertEqual(result, expected) + + def test_optimize_with_fixture_side_effects(self): + """Test no collapsing when fixture side effects are present.""" + gcode = [ + "G0 X10.0", + "G56", # Fixture change, has side effect + "G0 X20.0", + ] + result = filter_inefficient_moves(gcode) + expected = [ + "G0 X10.0", + "G56", # Side effect should flush chain + "G0 X20.0", + ] + self.assertEqual(result, expected) + + def test_optimize_empty_list(self): + """Test optimization with empty command list.""" + result = filter_inefficient_moves([]) + self.assertEqual(result, []) + + def test_optimize_single_command(self): + """Test optimization with a single command.""" + gcode = ["G0 X10.0"] + result = filter_inefficient_moves(gcode) + expected = ["G0 X10.0"] + self.assertEqual(result, expected) + + def test_optimize_mixed_sequence(self): + """Test mixed sequence with rapid and side effect commands.""" + gcode = [ + "G0 X10.0", + "G0 X20.0", + "M3 S1000", # Spindle on, side effect + "G0 X30.0", + ] + result = filter_inefficient_moves(gcode) + expected = [ + "G0 X20.0", # First chain collapses to last position + "M3 S1000", # Side effect + "G0 X30.0", # New move after side effect + ] + self.assertEqual(result, expected) + + def test_optimize_linear_group_collapse(self): + """Test collapsing rapid moves within linear axis group (X,Y,Z).""" + gcode = [ + "G0 X10.0 Y10.0 Z10.0", + "G0 X20.0 Y20.0 Z20.0", # All linear axes change + ] + result = filter_inefficient_moves(gcode) + expected = ["G0 X20.0 Y20.0 Z20.0"] # Collapsed to final position + self.assertEqual(result, expected) + + def test_optimize_rotary_group_collapse(self): + """Test collapsing rapid moves within rotary axis group (A,B,C).""" + gcode = [ + "G0 A10.0 B10.0 C10.0", + "G0 A20.0 B20.0 C20.0", # All rotary axes change + ] + result = filter_inefficient_moves(gcode) + expected = ["G0 A20.0 B20.0 C20.0"] # Collapsed to final position + self.assertEqual(result, expected) + + def test_optimize_mixed_axes_no_collapse(self): + """Test that mixed linear/rotary changes don't collapse.""" + gcode = [ + "G0 X10.0 A10.0", + "G0 X20.0 A20.0", + "G0 Y10.0 B10.0", # Different axes + ] + result = filter_inefficient_moves(gcode) + expected = [ + "G0 X10.0 A10.0", + "G0 X20.0 A20.0", + "G0 Y10.0 B10.0", + ] # All kept since mixed axes across groups + self.assertEqual(result, expected) + + +class TestNumberGenerator(unittest.TestCase): + """Test the NumberGenerator class.""" + + def test010_default_initialization(self): + """Test NumberGenerator initializes with default parameters.""" + gen = NumberGenerator() + + self.assertEqual(gen._template, "{}") + self.assertEqual(gen._start, 1) + self.assertEqual(gen._increment, 1) + self.assertEqual(gen._current, 1) + + def test020_custom_initialization(self): + """Test NumberGenerator with custom parameters.""" + gen = NumberGenerator(template="N{:04d}", start=100, increment=10) + + self.assertEqual(gen._template, "N{:04d}") + self.assertEqual(gen._start, 100) + self.assertEqual(gen._increment, 10) + self.assertEqual(gen._current, 100) + + def test030_get_sequence_default(self): + """Test get() method with default parameters.""" + gen = NumberGenerator() + + # First call + self.assertEqual(gen.get(), "1") + self.assertEqual(gen._current, 2) + + # Second call + self.assertEqual(gen.get(), "2") + self.assertEqual(gen._current, 3) + + # Third call + self.assertEqual(gen.get(), "3") + self.assertEqual(gen._current, 4) + + def test040_get_sequence_custom_template(self): + """Test get() method with custom template.""" + gen = NumberGenerator(template="N{:03d}") + + self.assertEqual(gen.get(), "N001") + self.assertEqual(gen.get(), "N002") + self.assertEqual(gen.get(), "N003") + + def test050_get_sequence_custom_start_increment(self): + """Test get() method with custom start and increment.""" + gen = NumberGenerator(start=100, increment=5) + + self.assertEqual(gen.get(), "100") + self.assertEqual(gen.get(), "105") + self.assertEqual(gen.get(), "110") + + def test060_reset_functionality(self): + """Test reset() method.""" + gen = NumberGenerator(start=10, increment=2) + + # Generate some numbers + self.assertEqual(gen.get(), "10") + self.assertEqual(gen.get(), "12") + self.assertEqual(gen.get(), "14") + + # Reset + gen.reset() + self.assertEqual(gen._current, 10) + + # Generate again from start + self.assertEqual(gen.get(), "10") + self.assertEqual(gen.get(), "12") + + def test070_gcode_line_numbers(self): + """Test typical G-code line number generation.""" + gen = NumberGenerator(template="N{:04d}", start=100, increment=10) + + self.assertEqual(gen.get(), "N0100") + self.assertEqual(gen.get(), "N0110") + self.assertEqual(gen.get(), "N0120") + self.assertEqual(gen.get(), "N0130") + + def test080_zero_start(self): + """Test with zero start value.""" + gen = NumberGenerator(start=0) + + self.assertEqual(gen.get(), "0") + self.assertEqual(gen.get(), "1") + self.assertEqual(gen.get(), "2") + + def test090_negative_values(self): + """Test with negative start and increment.""" + gen = NumberGenerator(start=-10, increment=-1) + + self.assertEqual(gen.get(), "-10") + self.assertEqual(gen.get(), "-11") + self.assertEqual(gen.get(), "-12") + + def test100_large_numbers(self): + """Test with large numbers.""" + gen = NumberGenerator(start=10000, increment=1000) + + self.assertEqual(gen.get(), "10000") + self.assertEqual(gen.get(), "11000") + self.assertEqual(gen.get(), "12000") + + +class TestDeduplicateRepeatedCommands(unittest.TestCase): + """Test the deduplicate_repeated_commands function for modal G-code output.""" + + def test_modal_consecutive_same_commands(self): + """Test that consecutive same commands have command word removed (modal behavior).""" + gcode = ["G1 X10.0 Y20.0", "G1 X30.0 Y40.0", "G1 X50.0 Y60.0"] + result = deduplicate_repeated_commands(gcode) + expected = [ + "G1 X10.0 Y20.0", # First G1 - full command + "X30.0 Y40.0", # G1 removed (modal) + "X50.0 Y60.0", # G1 removed (modal) + ] + self.assertEqual(result, expected) + + def test_modal_different_commands(self): + """Test that different commands are output with full command word.""" + gcode = ["G1 X10.0", "G1 X20.0", "G0 Z5.0", "G0 Z10.0"] + result = deduplicate_repeated_commands(gcode) + expected = [ + "G1 X10.0", # First G1 + "X20.0", # G1 removed + "G0 Z5.0", # Different command - full + "Z10.0", # G0 removed + ] + self.assertEqual(result, expected) + + def test_modal_with_comments(self): + """Test that comments are preserved and don't affect modal state.""" + gcode = ["G1 X10.0", "(Comment)", "G1 X20.0", "G1 X30.0"] + result = deduplicate_repeated_commands(gcode) + expected = [ + "G1 X10.0", + "(Comment)", + "X20.0", # G1 removed (modal continues) + "X30.0", # G1 removed + ] + self.assertEqual(result, expected) + + def test_modal_with_empty_lines(self): + """Test that empty lines are preserved.""" + gcode = ["G1 X10.0", "", "G1 X20.0"] + result = deduplicate_repeated_commands(gcode) + expected = ["G1 X10.0", "", "X20.0"] # G1 removed + self.assertEqual(result, expected) + + def test_modal_command_without_parameters(self): + """Test commands without parameters.""" + gcode = ["G80", "G80"] + result = deduplicate_repeated_commands(gcode) + expected = ["G80"] # First one kept, second removed (no params to output) + self.assertEqual(result, expected) + + def test_modal_mixed_commands(self): + """Test realistic G-code with mixed commands.""" + gcode = ["G0 X0.0 Y0.0", "G0 Z5.0", "G1 X10.0 F100.0", "G1 Y10.0", "G1 X0.0", "G0 Z20.0"] + result = deduplicate_repeated_commands(gcode) + expected = [ + "G0 X0.0 Y0.0", + "Z5.0", # G0 removed + "G1 X10.0 F100.0", + "Y10.0", # G1 removed + "X0.0", # G1 removed + "G0 Z20.0", + ] + self.assertEqual(result, expected) + + def test_modal_blockdelete(self): + """Test that blockdelete prefix is handled correctly.""" + gcode = ["/G1 X10.0", "/G1 X20.0"] + result = deduplicate_repeated_commands(gcode) + # Blockdelete commands should still follow modal rules + expected = ["/G1 X10.0", "/G1 X20.0"] # Full line kept (blockdelete handling) + self.assertEqual(result, expected) diff --git a/src/Mod/CAM/CAMTests/TestGenericPlasma.py b/src/Mod/CAM/CAMTests/TestGenericPlasma.py new file mode 100644 index 0000000000..8f4cf4cd92 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestGenericPlasma.py @@ -0,0 +1,520 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2026 sliptonic * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + + +import Path +import CAMTests.PathTestUtils as PathTestUtils +import CAMTests.PostTestMocks as PostTestMocks +from Path.Post.Processor import PostProcessorFactory + + +Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) + + +class TestGenericPlasma(PathTestUtils.PathTestBase): + """Test the GenericPlasma postprocessor unique functionality.""" + + @classmethod + def setUpClass(cls): + """setUpClass()... + + This method is called upon instantiation of this test class. Add code + and objects here that are needed for the duration of the test() methods + in this class. In other words, set up the 'global' test environment + here; use the `setUp()` method to set up a 'local' test environment. + This method does not have access to the class `self` reference, but it + is able to call static methods within this same class. + """ + + # Create mock job with default operation and tool controller + cls.job, cls.profile_op, cls.tool_controller = ( + PostTestMocks.create_default_job_with_operation() + ) + + # Create GenericPlasma postprocessor using the mock job + cls.post = PostProcessorFactory.get_post_processor(cls.job, "generic_plasma") + + @classmethod + def tearDownClass(cls): + """tearDownClass()... + + This method is called prior to destruction of this test class. Add + code and objects here that cleanup the test environment after the + test() methods in this class have been executed. This method does not + have access to the class `self` reference. This method + is able to call static methods within this same class. + """ + # No cleanup needed for mock objects + pass + + # Setup and tear down methods called before and after each unit test + + def setUp(self): + """setUp()... + + This method is called prior to each `test()` method. Add code and + objects here that are needed for multiple `test()` methods. + """ + # allow a full length "diff" if an error occurs + self.maxDiff = None + # reinitialize the postprocessor data structures between tests + self.post.reinitialize() + + # Create mock machine with postprocessor properties + from CAMTests.PostTestMocks import MockMachine + + self.post._machine = MockMachine() + + def tearDown(self): + """tearDown()... + + This method is called after each test() method. Add cleanup instructions here. + Such cleanup instructions will likely undo those in the setUp() method. + """ + pass + + def test00_property_schema(self): + """ + Test that GenericPlasma has the correct property schema with plasma-specific properties. + + INPUT: + - Function: get_property_schema() + - Parameters: None + - Input data: GenericPlasma postprocessor instance + + EXPECTED OUTPUT: + - Returns schema with pierce_delay, cooling_delay, marking_delay, torch_zaxis_control, force_rapid_feeds + - Properties should have correct types, defaults, and help text + - This ensures the machine configuration editor can properly configure plasma features + """ + schema = self.post.get_property_schema() + + # Check that we have the expected number of properties + self.assertEqual(len(schema), 5) + + # Check pierce_delay property + pierce_delay = next(prop for prop in schema if prop["name"] == "pierce_delay") + self.assertEqual(pierce_delay["type"], "integer") + self.assertEqual(pierce_delay["default"], 1000) + self.assertEqual(pierce_delay["min"], 0) + self.assertEqual(pierce_delay["max"], 10000) + + # Check cooling_delay property + cooling_delay = next(prop for prop in schema if prop["name"] == "cooling_delay") + self.assertEqual(cooling_delay["type"], "integer") + self.assertEqual(cooling_delay["default"], 500) + self.assertEqual(cooling_delay["min"], 0) + self.assertEqual(cooling_delay["max"], 10000) + + # Check marking_delay property + marking_delay = next(prop for prop in schema if prop["name"] == "marking_delay") + self.assertEqual(marking_delay["type"], "integer") + self.assertEqual(marking_delay["default"], 100) + self.assertEqual(marking_delay["min"], 0) + self.assertEqual(marking_delay["max"], 10000) + + # Check torch_zaxis_control property + torch_control = next(prop for prop in schema if prop["name"] == "torch_zaxis_control") + self.assertEqual(torch_control["type"], "bool") + self.assertEqual(torch_control["default"], True) + + # Check force_rapid_feeds property + rapid_feeds = next(prop for prop in schema if prop["name"] == "force_rapid_feeds") + self.assertEqual(rapid_feeds["type"], "bool") + self.assertEqual(rapid_feeds["default"], False) + + def test01_pierce_delay_injection(self): + """ + Test that pierce delay is correctly injected after M3/M4 commands. + + INPUT: + - Function: _inject_pierce_delay() + - Parameters: postables with M3 command + - Input data: Path containing M3 torch ignition command + + EXPECTED OUTPUT: + - G4 dwell command inserted after M3 with correct P parameter + - Delay duration matches pierce_delay property value in seconds + - This ensures proper torch ignition delay for plasma cutting + """ + # Create a simple path with M3 command + commands = [ + Path.Command("G0", {"Z": 5.0}), + Path.Command("M3"), # Torch ignition + Path.Command("G1", {"X": 10.0, "Y": 10.0, "F": 1000}), + ] + self.profile_op.Path = Path.Path(commands) + + # Set pierce delay to 2000ms (should become 2.0 seconds in G4) + self.post._machine.postprocessor_properties = {"pierce_delay": 2000} + + # Build postables and call injection method directly + postables = [("section", [self.profile_op])] + self.post._inject_pierce_delay(postables) + + # Verify the modified path + result_cmds = self.profile_op.Path.Commands + cmd_names = [cmd.Name for cmd in result_cmds] + + # Should have G4 inserted after M3 + m3_idx = cmd_names.index("M3") + self.assertEqual(cmd_names[m3_idx + 1], "G4", "G4 should follow M3") + self.assertAlmostEqual( + result_cmds[m3_idx + 1].Parameters["P"], 2.0, msg="G4 should have 2.0 second delay" + ) + + def test02_cooling_delay_injection(self): + """ + Test that cooling delay is correctly injected after M5 commands. + + INPUT: + - Function: _inject_cooling_delay() + - Parameters: postables with M5 command + - Input data: Path containing M5 torch extinguish command + + EXPECTED OUTPUT: + - G4 dwell command inserted after M5 with correct P parameter + - Delay duration matches cooling_delay property value in seconds + - This ensures proper torch cooling delay before next movement + """ + # Create a simple path with M5 command + commands = [ + Path.Command("G1", {"X": 10.0, "Y": 10.0, "F": 1000}), + Path.Command("M5"), # Torch extinguish + Path.Command("G0", {"Z": 10.0}), + ] + self.profile_op.Path = Path.Path(commands) + + # Set cooling delay to 500ms (should become 0.5 seconds in G4) + self.post._machine.postprocessor_properties = {"cooling_delay": 500} + + # Build postables and call injection method directly + postables = [("section", [self.profile_op])] + self.post._inject_cooling_delay(postables) + + # Verify the modified path + result_cmds = self.profile_op.Path.Commands + cmd_names = [cmd.Name for cmd in result_cmds] + + # Should have G4 inserted after M5 + m5_idx = cmd_names.index("M5") + self.assertEqual(cmd_names[m5_idx + 1], "G4", "G4 should follow M5") + self.assertAlmostEqual( + result_cmds[m5_idx + 1].Parameters["P"], 0.5, msg="G4 should have 0.5 second delay" + ) + + def test03_torch_z_axis_control_enabled(self): + """ + Test torch Z-axis control when enabled - M3/M5 inserted based on Z movement. + + INPUT: + - Function: _inject_torch_control() + - Parameters: postables with Z movements + - Input data: Path with Z- movement to cut height, then Z+ retraction + + EXPECTED OUTPUT: + - M3 inserted before Z- movement when torch_zaxis_control=True + - M5 inserted after Z+ movement when torch is active + - This demonstrates automatic torch control based on Z-axis movement + """ + # Set up operation heights + self.profile_op.StartDepth = 2.0 # Pierce height + self.profile_op.FinalDepth = 0.0 # Cut height + + # Create path with Z movements + commands = [ + Path.Command("G0", {"Z": 10.0}), # Start at clearance + Path.Command("G0", {"Z": 2.0}), # Move to pierce height + Path.Command("G1", {"Z": 0.0, "F": 500}), # Move to cut height (should trigger M3) + Path.Command("G1", {"X": 10.0, "Y": 10.0, "F": 1000}), # Cut + Path.Command("G0", {"Z": 10.0}), # Retract (should trigger M5) + ] + self.profile_op.Path = Path.Path(commands) + + # Enable torch Z-axis control + self.post._machine.postprocessor_properties = {"torch_zaxis_control": True} + + # Build postables and call injection method directly + postables = [("section", [self.profile_op])] + self.post._inject_torch_control(postables) + + # Verify the modified path + result_cmds = self.profile_op.Path.Commands + cmd_names = [cmd.Name for cmd in result_cmds] + + # Should have M3 inserted for torch ignition + self.assertIn("M3", cmd_names, "M3 should be inserted for torch ignition") + # Should have M5 inserted for torch extinguish + self.assertIn("M5", cmd_names, "M5 should be inserted for torch extinguish") + + # M3 should appear before the Z- cut move + m3_idx = cmd_names.index("M3") + # Find the G1 Z0.0 command (cut height move) + cut_idx = None + for i, cmd in enumerate(result_cmds): + if cmd.Name == "G1" and "Z" in cmd.Parameters and cmd.Parameters["Z"] == 0.0: + cut_idx = i + break + self.assertIsNotNone(cut_idx, "G1 Z0.0 cut move should be present") + self.assertLess(m3_idx, cut_idx, "M3 should appear before Z- cut move") + + def test04_torch_z_axis_control_disabled(self): + """ + Test that torch Z-axis control is disabled when property is False. + + INPUT: + - Function: _inject_torch_control() + - Parameters: postables with Z movements + - Input data: Path with Z movements, torch_zaxis_control=False + + EXPECTED OUTPUT: + - No M3/M5 commands automatically inserted based on Z movement + - Original path commands pass through unchanged + - This allows manual torch control when automatic control is disabled + """ + # Set up operation heights + self.profile_op.StartDepth = 2.0 + self.profile_op.FinalDepth = 0.0 + + # Create path with Z movements (no manual M3/M5) + commands = [ + Path.Command("G0", {"Z": 10.0}), + Path.Command("G0", {"Z": 2.0}), + Path.Command("G1", {"Z": 0.0, "F": 500}), + Path.Command("G1", {"X": 10.0, "Y": 10.0, "F": 1000}), + Path.Command("G0", {"Z": 10.0}), + ] + self.profile_op.Path = Path.Path(commands) + original_cmd_count = len(commands) + + # Disable torch Z-axis control + self.post._machine.postprocessor_properties = {"torch_zaxis_control": False} + + # Build postables and call injection method directly + postables = [("section", [self.profile_op])] + self.post._inject_torch_control(postables) + + # Verify the path is unchanged + result_cmds = self.profile_op.Path.Commands + cmd_names = [cmd.Name for cmd in result_cmds] + + self.assertEqual( + len(result_cmds), + original_cmd_count, + "Path should be unchanged when torch control is disabled", + ) + self.assertNotIn("M3", cmd_names, "No M3 should be injected when torch control is disabled") + self.assertNotIn("M5", cmd_names, "No M5 should be injected when torch control is disabled") + + def test05_mark_entry_only_mode(self): + """ + Test mark entry only mode - only first entry point is marked. + + INPUT: + - Function: _inject_mark_entry_only() + - Parameters: postables with multiple Z- movements + - Input data: Path with multiple cutting passes, mark_entry_only=True + + EXPECTED OUTPUT: + - Only first Z- movement to cut height is processed with torch mark + - Subsequent Z- movements are skipped + - Z+ movements (retractions) are allowed through + - This enables marking entry points for drilling preparation + """ + # Set up operation heights + self.profile_op.StartDepth = 2.0 + self.profile_op.FinalDepth = 0.0 + self.profile_op.ClearanceHeight = 10.0 + + # Create path with multiple cutting passes + commands = [ + Path.Command("G0", {"Z": 10.0}), # Start at clearance + Path.Command("G1", {"Z": 0.0, "F": 500}), # First entry (should be marked) + Path.Command("G1", {"X": 10.0, "Y": 10.0, "F": 1000}), # Cut + Path.Command("G0", {"Z": 10.0}), # Retract + Path.Command("G0", {"X": 20.0, "Y": 20.0}), # Move to next position + Path.Command("G1", {"Z": 0.0, "F": 500}), # Second entry (should be skipped) + Path.Command("G1", {"X": 30.0, "Y": 30.0, "F": 1000}), # Cut + Path.Command("G0", {"Z": 10.0}), # Final retract + ] + self.profile_op.Path = Path.Path(commands) + + # Enable mark entry only mode + self.post._machine.postprocessor_properties = {"mark_entry_only": True} + + # Build postables and call injection method directly + postables = [("section", [self.profile_op])] + self.post._inject_mark_entry_only(postables) + + # Verify the modified path + result_cmds = self.profile_op.Path.Commands + + # Should have torch mark sequence for first entry only + # The mark entry sequence includes: G1 Z(cut), G4, M5 + g1_cut_moves = [ + cmd + for cmd in result_cmds + if cmd.Name == "G1" and "Z" in cmd.Parameters and cmd.Parameters["Z"] <= 0.0 + ] + + # In mark mode, we should have exactly 1 G1 Z0 move (the marked entry) + self.assertEqual(len(g1_cut_moves), 1, "Should have exactly 1 cutting move in mark mode") + + def test06_force_rapid_feeds(self): + """ + Test force rapid feeds functionality - removes F parameters from movement commands. + + INPUT: + - Function: _force_rapid_feeds() + - Parameters: postables with movement commands containing F parameters + - Input data: Path with G0/G1/G2/G3 commands having feed rates + + EXPECTED OUTPUT: + - All F parameters removed from movement commands + - Non-movement commands unchanged + - This enables dry run mode for path verification without cutting + """ + # Create path with various movement commands and feed rates + commands = [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 10.0, "F": 3000}), # Rapid with feed + Path.Command("G1", {"X": 10.0, "Y": 10.0, "Z": 0.0, "F": 1000}), # Linear move + Path.Command("G2", {"X": 20.0, "Y": 10.0, "I": 5.0, "F": 800}), # Arc move + Path.Command("G3", {"X": 30.0, "Y": 20.0, "J": 5.0, "F": 600}), # Arc move + Path.Command("M3", {"S": 1000}), # Non-movement command + ] + self.profile_op.Path = Path.Path(commands) + + # Enable force rapid feeds + self.post._machine.postprocessor_properties = {"force_rapid_feeds": True} + + # Build postables and call injection method directly + postables = [("section", [self.profile_op])] + self.post._force_rapid_feeds(postables) + + # Verify the modified path + result_cmds = self.profile_op.Path.Commands + + # Check that no movement commands have F parameters + for cmd in result_cmds: + if cmd.Name in ["G0", "G1", "G2", "G3"]: + self.assertNotIn( + "F", + cmd.Parameters, + f"{cmd.Name} should not have F parameter after force rapid feeds", + ) + + # Check that non-movement commands are unchanged + m3_cmd = next(cmd for cmd in result_cmds if cmd.Name == "M3") + self.assertIn("S", m3_cmd.Parameters, "M3 should retain S parameter") + self.assertAlmostEqual( + m3_cmd.Parameters["S"], 1000.0, msg="M3 S parameter should be unchanged" + ) + + def test07_common_property_overrides(self): + """ + Test that GenericPlasma correctly overrides common postprocessor properties. + + INPUT: + - Function: get_common_property_schema() + - Parameters: None + - Input data: GenericPlasma postprocessor instance + + EXPECTED OUTPUT: + - file_extension defaults to "nc" + - supports_tool_radius_compensation defaults to True + - preamble and postamble have plasma-specific defaults + - This ensures proper defaults for plasma cutting controllers + """ + common_props = self.post.get_common_property_schema() + + # Check file extension override + file_ext = next(prop for prop in common_props if prop["name"] == "file_extension") + self.assertEqual(file_ext["default"], "nc") + + # Check tool radius compensation override + trc = next( + prop for prop in common_props if prop["name"] == "supports_tool_radius_compensation" + ) + self.assertEqual(trc["default"], True) + + # Check preamble override + preamble = next(prop for prop in common_props if prop["name"] == "preamble") + self.assertEqual(preamble["default"], "G17 G54 G40 G49 G80 G90") + + # Check postamble override + postamble = next(prop for prop in common_props if prop["name"] == "postamble") + self.assertEqual(postamble["default"], "M05\nG17 G54 G90 G80 G40\nM2") + + def test08_zero_delay_values(self): + """ + Test that zero or negative delay values don't inject G4 commands. + + INPUT: + - Function: _inject_pierce_delay() and _inject_cooling_delay() + - Parameters: postables with M3/M5 commands + - Input data: pierce_delay=0, cooling_delay=-100 + + EXPECTED OUTPUT: + - No G4 commands injected when delay values are <= 0 + - M3/M5 commands pass through unchanged + - This prevents unnecessary dwell commands when delays are disabled + """ + # Create path with M3 and M5 commands + commands = [ + Path.Command("G0", {"Z": 5.0}), + Path.Command("M3"), # Torch ignition + Path.Command("G1", {"X": 10.0, "Y": 10.0, "F": 1000}), + Path.Command("M5"), # Torch extinguish + Path.Command("G0", {"Z": 10.0}), + ] + self.profile_op.Path = Path.Path(commands) + original_cmd_count = len(commands) + + # Set zero/negative delays + self.post._machine.postprocessor_properties = {"pierce_delay": 0, "cooling_delay": -100} + + # Build postables and call both injection methods directly + postables = [("section", [self.profile_op])] + self.post._inject_pierce_delay(postables) + self.post._inject_cooling_delay(postables) + + # Verify the path is unchanged (no G4 commands added) + result_cmds = self.profile_op.Path.Commands + cmd_names = [cmd.Name for cmd in result_cmds] + + # Should have no G4 commands + self.assertNotIn( + "G4", cmd_names, "No G4 commands should be injected for zero/negative delays" + ) + + # Path should be unchanged + self.assertEqual( + len(result_cmds), + original_cmd_count, + "Path length should be unchanged when delays are zero/negative", + ) + + # Should still have M3 and M5 + self.assertIn("M3", cmd_names, "M3 should be present") + self.assertIn("M5", cmd_names, "M5 should be present") diff --git a/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py b/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py index 68988f32d9..253a70a2f9 100644 --- a/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py +++ b/src/Mod/CAM/CAMTests/TestLinuxCNCPost.py @@ -22,12 +22,12 @@ # * * # *************************************************************************** -import FreeCAD import Path import CAMTests.PathTestUtils as PathTestUtils import CAMTests.PostTestMocks as PostTestMocks from Path.Post.Processor import PostProcessorFactory +from Machine.models.machine import Machine, Toolhead, ToolheadType Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -35,7 +35,7 @@ Path.Log.trackModule(Path.Log.thisModule()) class TestLinuxCNCPost(PathTestUtils.PathTestBase): - """Test LinuxCNC-specific features of the inuxcnc_post.py postprocessor. + """Test LinuxCNC-specific features of the linuxcnc_post.py postprocessor. This test suite focuses on LinuxCNC-specific functionality such as path blending modes. Generic postprocessor functionality is tested in TestGenericPost. @@ -86,6 +86,18 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): self.maxDiff = None # reinitialize the postprocessor data structures between tests self.post.reinitialize() + # Create a machine configuration for each test + self.post._machine = Machine.create_3axis_config() + self.post._machine.name = "Test LinuxCNC Machine" + # Add a default toolhead (required by export2) + toolhead = Toolhead( + name="Default Toolhead", + toolhead_type=ToolheadType.ROTARY, + min_rpm=0, + max_rpm=24000, + max_power_kw=1.0, + ) + self.post._machine.toolheads = [toolhead] def tearDown(self): """tearDown()... @@ -98,10 +110,11 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_mode_exact_path(self): """Test EXACT_PATH blend mode outputs G61.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - "--no-header --no-comments --blend-mode EXACT_PATH --no-show-editor" - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "EXACT_PATH" + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] # G61 should be in the preamble self.assertIn("G61", gcode) @@ -113,10 +126,11 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_mode_exact_stop(self): """Test EXACT_STOP blend mode outputs G61.1.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - "--no-header --no-comments --blend-mode EXACT_STOP --no-show-editor" - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "EXACT_STOP" + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] # G61.1 should be in the preamble self.assertIn("G61.1", gcode) @@ -126,8 +140,12 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_mode_blend_default(self): """Test BLEND mode with default tolerance (0) outputs G64.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = "--no-header --no-comments --blend-mode BLEND --no-show-editor" - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "BLEND" + self.post._machine.postprocessor_properties["blend_tolerance"] = 0.0 + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] # G64 should be in the preamble (without P parameter) lines = gcode.splitlines() @@ -137,10 +155,12 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_mode_blend_with_tolerance(self): """Test BLEND mode with tolerance outputs G64 P.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0.05 --no-show-editor" - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "BLEND" + self.post._machine.postprocessor_properties["blend_tolerance"] = 0.05 + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] # G64 P0.05 should be in the preamble self.assertIn("G64 P0.0500", gcode) @@ -148,10 +168,12 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_mode_blend_with_custom_tolerance(self): """Test BLEND mode with custom tolerance value.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0.02 --no-show-editor" - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "BLEND" + self.post._machine.postprocessor_properties["blend_tolerance"] = 0.02 + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] # G64 P0.02 should be in the preamble self.assertIn("G64 P0.0200", gcode) @@ -159,10 +181,12 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_mode_in_preamble_position(self): """Test that blend mode command appears in correct position in preamble.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0.1 --no-show-editor" - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "BLEND" + self.post._machine.postprocessor_properties["blend_tolerance"] = 0.1 + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] lines = gcode.splitlines() # Find G64 P line @@ -179,10 +203,12 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): def test_blend_tolerance_zero_equals_no_tolerance(self): """Test that blend tolerance of 0 outputs G64 without P parameter.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - "--no-header --no-comments --blend-mode BLEND --blend-tolerance 0 --no-show-editor" - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "BLEND" + self.post._machine.postprocessor_properties["blend_tolerance"] = 0 + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] # Should have G64 without P lines = gcode.splitlines() @@ -190,12 +216,397 @@ class TestLinuxCNCPost(PathTestUtils.PathTestBase): self.assertTrue(has_g64_without_p, "Expected G64 without P parameter when tolerance is 0") def test_blend_interaction_with_preamble_argument(self): - """Test interaction with a --preamble command line argument.""" + """Test blend mode appears after units command in preamble.""" self.profile_op.Path = Path.Path([]) - self.job.PostProcessorArgs = ( - '--no-header --no-comments --blend-mode BLEND --preamble="G80 G90" --no-show-editor' - ) - gcode = self.post.export()[0][1] + # Set blend mode via machine configuration + self.post._machine.postprocessor_properties["blend_mode"] = "BLEND" + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + gcode = self.post.export2()[0][1] lines = gcode.splitlines() - self.assertEqual(lines[0], "G80 G90") - self.assertEqual(lines[1], "G64") + # G64 should appear early in the output + self.assertIn("G64", gcode) + # Find G64 line + g64_idx = None + for i, line in enumerate(lines): + if "G64" in line: + g64_idx = i + break + self.assertIsNotNone(g64_idx) + self.assertLess(g64_idx, 5, "G64 should be in preamble") + + def test_rigid_tapping_g84_basic(self): + """ + Test G84 rigid tapping conversion to G33.1 sequence. + + Expected behavior: + BEFORE: G84 Z-10 F1.5 (rigid=True) + + AFTER: G33.1 K1.5000 Z-10.0000 + M4 + G33.1 K1.5000 Z0.0000 + M3 + """ + # Setup - create G84 command with rigid annotation + command = Path.Command("G84", {"Z": -10.0, "F": 1.5}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify + self.assertIn("G33.1", result) + self.assertIn("K1.5000", result) + self.assertIn("Z-10.0000", result) + self.assertIn("M4", result) + self.assertIn("M3", result) + + def test_rigid_tapping_g74_basic(self): + """ + Test G74 rigid tapping conversion to G33.1 sequence. + + Expected behavior: + BEFORE: G74 Z-10 F1.5 (rigid=True) + + AFTER: G33.1 K1.5000 Z-10.0000 + M3 + G33.1 K1.5000 Z0.0000 + M4 + """ + # Setup - create G74 command with rigid annotation + command = Path.Command("G74", {"Z": -10.0, "F": 1.5}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify + self.assertIn("G33.1", result) + self.assertIn("K1.5000", result) + self.assertIn("Z-10.0000", result) + self.assertIn("M3", result) + self.assertIn("M4", result) + + def test_rigid_tapping_pitch_conversion(self): + """ + Test pitch (F) parameter conversion to K parameter. + + Expected behavior: + BEFORE: G84 Z-10 F1.25 (rigid=True) + + AFTER: G33.1 K1.2500 Z-10.0000 + """ + # Setup + command = Path.Command("G84", {"Z": -10.0, "F": 1.25}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify + self.assertIn("K1.2500", result) + + def test_rigid_tapping_with_retract_height(self): + """ + Test rigid tapping with retract height (R parameter). + + Expected behavior: + BEFORE: G84 Z-15 R5 F1.25 (rigid=True) + + AFTER: G33.1 K1.2500 Z-15.0000 + M4 + G33.1 K1.2500 Z5.0000 + M3 + """ + # Setup + command = Path.Command("G84", {"Z": -15.0, "R": 5.0, "F": 1.25}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify + self.assertIn("Z-15.0000", result) # Tap depth + self.assertIn("Z5.0000", result) # Retract height + + def test_rigid_tapping_with_coordinates(self): + """ + Test rigid tapping preserves X and Y coordinates. + + Expected behavior: + BEFORE: G84 X10 Y20 Z-10 F1.5 (rigid=True) + + AFTER: G33.1 K1.5000 X10.0000 Y20.0000 Z-10.0000 + """ + # Setup + command = Path.Command("G84", {"X": 10.0, "Y": 20.0, "Z": -10.0, "F": 1.5}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify + self.assertIn("X10.0000", result) + self.assertIn("Y20.0000", result) + + def test_rigid_tapping_with_dwell(self): + """ + Test rigid tapping with dwell (P parameter). + + Expected behavior: + BEFORE: G84 Z-10 F1.5 P0.5 (rigid=True) + + AFTER: G33.1 K1.5000 Z-10.0000 + M5 + G04 P0.50 + M4 + G33.1 K1.5000 Z0.0000 + M3 + """ + # Setup + command = Path.Command("G84", {"Z": -10.0, "F": 1.5, "P": 0.5}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify + self.assertIn("M5", result) + self.assertIn("G04 P0.50", result) + + def test_rigid_tapping_imperial_units(self): + """ + Test rigid tapping unit conversion to imperial. + + Expected behavior: + BEFORE: G84 Z-10 F1.5 (rigid=True) in imperial units + + AFTER: G33.1 K0.0591 Z-0.3937 + """ + # Setup - set imperial units + from Machine.models.machine import OutputUnits + + self.post._machine.output.units = OutputUnits.IMPERIAL + + command = Path.Command("G84", {"Z": -10.0, "F": 1.5}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify - converted values (mm to inches) + self.assertIn("Z-0.3937", result) # -10mm / 25.4 + self.assertIn("K0.0591", result) # 1.5mm / 25.4 + + def test_rigid_tapping_block_delete(self): + """ + Test rigid tapping with block delete annotation. + + Expected behavior: + BEFORE: G84 Z-10 F1.5 (rigid=True, blockdelete=True) + + AFTER: /G33.1 K1.5000 Z-10.0000 + /M4 + /G33.1 K1.5000 Z0.0000 + /M3 + """ + # Setup + command = Path.Command("G84", {"Z": -10.0, "F": 1.5}) + command.Annotations = {"rigid": "True", "operation": "tapping", "blockdelete": True} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify - all commands should have '/' prefix + lines = result.split("\n") + for line in lines: + if line.strip(): + self.assertTrue(line.startswith("/"), f"Line missing block delete: {line}") + + def test_rigid_tapping_missing_pitch_fallback(self): + """ + Test rigid tapping falls back to parent when pitch missing. + + Expected behavior: + BEFORE: G84 Z-10 (rigid=True, no F parameter) + + AFTER: [standard G84 conversion, not G33.1] + """ + # Setup - command without F (pitch) parameter + command = Path.Command("G84", {"Z": -10.0}) + command.Annotations = {"rigid": "True", "operation": "tapping"} + + # Execute + result = self.post._convert_drill_cycle(command) + + # Verify - should not contain G33.1 (fallback to parent) + self.assertNotIn("G33.1", result) + + def test_rigid_tapping_suppresses_g80(self): + """ + Test G80 is suppressed for rigid tapping operations. + + Expected behavior: + BEFORE: G80 (operation=tapping, rigid=True) + + AFTER: None (command suppressed) + """ + # Setup + command = Path.Command("G80", {}) + command.Annotations = {"operation": "tapping", "rigid": "True"} + + # Execute + result = self.post._convert_modal_command(command) + + # Verify - should return None (suppressed) + self.assertIsNone(result) + + def test_rigid_tapping_suppresses_g98(self): + """ + Test G98 is suppressed for rigid tapping operations. + + Expected behavior: + BEFORE: G98 (operation=tapping, rigid=True) + + AFTER: None (command suppressed) + """ + # Setup + command = Path.Command("G98", {}) + command.Annotations = {"operation": "tapping", "rigid": "True"} + + # Execute + result = self.post._convert_modal_command(command) + + # Verify - should return None (suppressed) + self.assertIsNone(result) + + def test_rigid_tapping_suppresses_g99(self): + """ + Test G99 is suppressed for rigid tapping operations. + + Expected behavior: + BEFORE: G99 (operation=tapping, rigid=True) + + AFTER: None (command suppressed) + """ + # Setup + command = Path.Command("G99", {}) + command.Annotations = {"operation": "tapping", "rigid": "True"} + + # Execute + result = self.post._convert_modal_command(command) + + # Verify - should return None (suppressed) + self.assertIsNone(result) + + def test_non_rigid_tapping_not_suppressed(self): + """ + Test G80/G98/G99 are not suppressed for non-rigid tapping. + + Expected behavior: + BEFORE: G80 (operation=tapping, rigid=False) + + AFTER: G80 (command not suppressed) + """ + # Setup + command = Path.Command("G80", {}) + command.Annotations = {"operation": "tapping", "rigid": "False"} + + # Execute + result = self.post._convert_modal_command(command) + + # Verify - should not be None (not suppressed) + self.assertIsNotNone(result) + + def test_schema_defaults_applied_for_sparse_config(self): + """ + Test that LinuxCNC schema defaults are applied when postprocessor_properties + is sparse (simulating a real .fcm file that only stores user-changed values). + + LinuxCNC overrides get_common_property_schema() to set: + preamble = "G17 G54 G40 G49 G80 G90" + postamble = "M05\\nG17 G54 G90 G80 G40\\nM2" + safetyblock = "G40 G49 G80" + + INPUT: + - LinuxCNC postprocessor with a machine that has only + file_extension and blend_mode in postprocessor_properties + - preamble, postamble, safetyblock keys are absent + + EXPECTED OUTPUT: + - After export2, postprocessor_properties contains all schema keys + - preamble, postamble, safetyblock have LinuxCNC-specific defaults + """ + # Start with a sparse config (only blend_mode set, no blocks) + self.post._machine.postprocessor_properties = { + "file_extension": "ngc", + "blend_mode": "BLEND", + } + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + + # Verify keys are absent before export + self.assertNotIn("preamble", self.post._machine.postprocessor_properties) + self.assertNotIn("postamble", self.post._machine.postprocessor_properties) + self.assertNotIn("safetyblock", self.post._machine.postprocessor_properties) + + self.profile_op.Path = Path.Path([Path.Command("G0", {"X": 10.0, "Y": 10.0, "Z": 5.0})]) + results = self.post.export2() + + # After export2, schema defaults should have been applied + props = self.post._machine.postprocessor_properties + self.assertIn("preamble", props, "preamble key should exist after export2") + self.assertIn("postamble", props, "postamble key should exist after export2") + self.assertIn("safetyblock", props, "safetyblock key should exist after export2") + + # Existing value should be preserved + self.assertEqual(props["file_extension"], "ngc") + + def test_schema_defaults_blocks_appear_in_output(self): + """ + Test that LinuxCNC schema default blocks actually appear in the G-code + output when the .fcm file omits them. + + This simulates the real-world bug: user's .fcm has only file_extension + and rotary move properties, but the machine editor shows preamble, + postamble, and safetyblock with their schema defaults. After + postprocessing, those blocks must appear in the output. + + INPUT: + - LinuxCNC postprocessor with sparse postprocessor_properties + - No preamble, postamble, or safetyblock keys in config + + EXPECTED OUTPUT: + - Preamble default "G17 G54 G40 G49 G80 G90" appears in output + - Postamble defaults "M05", "G17 G54 G90 G80 G40", "M2" appear + - Safetyblock default "G40 G49 G80" appears in output + """ + self.post._machine.postprocessor_properties = { + "file_extension": "ngc", + "blend_mode": "BLEND", + "blend_tolerance": 0.0, + } + self.post._machine.output.comments.enabled = False + self.post._machine.output.output_header = False + + self.profile_op.Path = Path.Path( + [ + Path.Command("G0", {"X": 10.0, "Y": 10.0, "Z": 5.0}), + Path.Command("G1", {"X": 20.0, "Y": 10.0, "Z": 5.0, "F": 1000.0}), + ] + ) + results = self.post.export2() + gcode = "\n".join(g for _, g in results) + + # LinuxCNC preamble defaults + self.assertIn("G17", gcode, "Preamble G17 should appear from schema default") + self.assertIn("G54", gcode, "Preamble G54 should appear from schema default") + self.assertIn("G80", gcode, "Preamble/safety G80 should appear from schema default") + + # LinuxCNC postamble defaults + self.assertIn("M05", gcode, "Postamble M05 should appear from schema default") + self.assertIn("M2", gcode, "Postamble M2 should appear from schema default") + + # LinuxCNC safetyblock defaults + self.assertIn("G40", gcode, "Safetyblock G40 should appear from schema default") + self.assertIn("G49", gcode, "Safetyblock G49 should appear from schema default") diff --git a/src/Mod/CAM/CAMTests/TestPathDressupDogboneII.py b/src/Mod/CAM/CAMTests/TestPathDressupDogboneII.py index 4e09af4251..755fa8af2b 100644 --- a/src/Mod/CAM/CAMTests/TestPathDressupDogboneII.py +++ b/src/Mod/CAM/CAMTests/TestPathDressupDogboneII.py @@ -71,7 +71,12 @@ class MockFeaturePython(object): def __setattr__(self, name, val): if name == "prop": return super().__setattr__(name, val) - self.prop[name] = (self.prop[name][0], val) + if name in self.prop: + self.prop[name] = (self.prop[name][0], val) + else: + # Handle assignment to properties that don't exist yet + # Default to App::PropertyString for unknown properties + self.prop[name] = ("App::PropertyString", val) def __getattr__(self, name): if name == "prop": diff --git a/src/Mod/CAM/CAMTests/TestPathPreferences.py b/src/Mod/CAM/CAMTests/TestPathPreferences.py index c92ac3688c..06c2eeceeb 100644 --- a/src/Mod/CAM/CAMTests/TestPathPreferences.py +++ b/src/Mod/CAM/CAMTests/TestPathPreferences.py @@ -43,11 +43,11 @@ class TestPathPreferences(PathTestUtils.PathTestBase): self.assertEqual(len([p for p in paths if p.endswith("/Path/Post/scripts/")]), 1) def test03(self): - """Available post processors include linuxcnc, grbl and opensbp.""" + """Available post processors include linuxcnc, generic and opensbp.""" posts = Path.Preferences.allAvailablePostProcessors() - self.assertTrue("linuxcnc" in posts) - self.assertTrue("grbl" in posts) - self.assertTrue("opensbp" in posts) + self.assertIn("linuxcnc", posts) + self.assertIn("generic", posts) + self.assertIn("opensbp", posts) def test10(self): """Default paths for tools are resolved correctly""" diff --git a/src/Mod/CAM/CAMTests/TestPathTapGenerator.py b/src/Mod/CAM/CAMTests/TestPathTapGenerator.py index abfdd24340..5e2dc0e430 100644 --- a/src/Mod/CAM/CAMTests/TestPathTapGenerator.py +++ b/src/Mod/CAM/CAMTests/TestPathTapGenerator.py @@ -47,11 +47,11 @@ class TestPathTapGenerator(PathTestUtils.PathTestBase): command = result[0] self.assertTrue(command.Name == "G84") - self.assertTrue(command.Parameters["R"] == 10) - self.assertTrue(command.Parameters["X"] == 0) - self.assertTrue(command.Parameters["Y"] == 0) - self.assertTrue(command.Parameters["Z"] == 0) - self.assertTrue(command.Annotations["rigid"] == "False") + self.assertEqual(command.Parameters["R"], 10) + self.assertEqual(command.Parameters["X"], 0) + self.assertEqual(command.Parameters["Y"], 0) + self.assertEqual(command.Parameters["Z"], 0) + self.assertEqual(command.Annotations["rigid"], "False") # repeat must be > 0 args = {"edge": e, "repeat": 0} diff --git a/src/Mod/CAM/CAMTests/TestPathPost.py b/src/Mod/CAM/CAMTests/TestPostCore.py similarity index 67% rename from src/Mod/CAM/CAMTests/TestPathPost.py rename to src/Mod/CAM/CAMTests/TestPostCore.py index 9ede3a21dd..6fe00dac26 100644 --- a/src/Mod/CAM/CAMTests/TestPathPost.py +++ b/src/Mod/CAM/CAMTests/TestPostCore.py @@ -22,465 +22,20 @@ # * * # *************************************************************************** -from Path.Post.Command import DlgSelectPostProcessor -from Path.Post.Processor import PostProcessor, PostProcessorFactory -from unittest.mock import patch, MagicMock + import FreeCAD 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 -import os import unittest -from .FilePathTestUtils import assertFilePathsEqual - PathCommand.LOG_MODULE = Path.Log.thisModule() Path.Log.setLevel(Path.Log.Level.INFO, PathCommand.LOG_MODULE) -class TestFileNameGenerator(unittest.TestCase): - r""" - String substitution allows the following: - %D ... directory of the active document - %d ... name of the active document (with extension) - %M ... user macro directory - %j ... name of the active Job object - - - The Following can be used if output is being split. If Output is not split - these will be ignored. - - %S ... Sequence Number (default) - - Either: - %T ... Tool Number - %t ... Tool Controller label - - %W ... Work Coordinate System - %O ... Operation Label - - |split on| use | Ignore | - |-----------|-------|--------| - |fixture | %W | %O %T %t | - |Operation| %O | %T %t %W | - |Tool| **Either %T or %t** | %O %W | - - The confusing bit is that for split on tool, it will use EITHER the tool number or the tool label. - If you include both, the second one overrides the first. - And for split on operation, where including the tool should be possible, it ignores it altogether. - - self.job.Fixtures = ["G54"] - self.job.SplitOutput = False - self.job.OrderOutputBy = "Fixture" - - Assume: - active document: self.assertTrue(filename, f"{home}/testdoc.fcstd - user macro: ~/.local/share/FreeCAD/Macro - Job: MainJob - Operations: - OutsideProfile - DrillAllHoles - TC: 7/16" two flute (5) - TC: Drill (2) - Fixtures: (G54, G55) - - Strings should be sanitized like this to ensure valid filenames - # import re - # filename="TC: 7/16" two flute" - # >>> re.sub(r"[^\w\d-]","_",filename) - # "TC__7_16__two_flute" - - """ - - @classmethod - def setUpClass(cls): - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") - - # Create a new document instead of opening external file - cls.doc = FreeCAD.newDocument("TestFileNaming") - cls.testfilename = cls.doc.Name - cls.testfilepath = os.getcwd() - cls.macro = FreeCAD.getUserMacroDir() - - # Create a simple geometry object for the job - import Part - - box = cls.doc.addObject("Part::Box", "TestBox") - box.Length = 100 - box.Width = 100 - box.Height = 20 - - # Create CAM job programmatically - cls.job = PathJob.Create("MainJob", [box], None) - cls.job.PostProcessor = "linuxcnc" - cls.job.PostProcessorOutputFile = "" - cls.job.SplitOutput = False - cls.job.OrderOutputBy = "Operation" - cls.job.Fixtures = ["G54", "G55"] - - # Create a tool controller for testing tool-related substitutions - from Path.Tool.toolbit import ToolBit - - tool_attrs = { - "name": "TestTool", - "shape": "endmill.fcstd", - "parameter": {"Diameter": 6.0}, - "attribute": {}, - } - toolbit = ToolBit.from_dict(tool_attrs) - tool = toolbit.attach_to_doc(doc=cls.doc) - tool.Label = "6mm_Endmill" - - tc = PathToolController.Create("TC_Test_Tool", tool, 5) - tc.Label = "TC: 6mm Endmill" - cls.job.addObject(tc) - - # Create a simple mock operation for testing operation-related substitutions - profile_op = cls.doc.addObject("Path::FeaturePython", "TestProfile") - profile_op.Label = "OutsideProfile" - # Path::FeaturePython objects already have a Path property - profile_op.Path = Path.Path() - cls.job.Operations.addObject(profile_op) - - cls.doc.recompute() - - @classmethod - def tearDownClass(cls): - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") - - def test000(self): - # Test basic name generation with empty string - FreeCAD.setActiveDocument(self.doc.Label) - teststring = "" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - Path.Log.debug(filename) - assertFilePathsEqual( - self, filename, os.path.join(self.testfilepath, f"{self.testfilename}.nc") - ) - - def test010(self): - # Substitute current file path - teststring = "%D/testfile.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - print(os.path.normpath(filename)) - assertFilePathsEqual(self, filename, f"{self.testfilepath}/testfile.nc") - - def test015(self): - # Test basic string substitution without splitting - teststring = "~/Desktop/%j.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, "~/Desktop/MainJob.nc") - - def test020(self): - teststring = "%d.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - expected = os.path.join(self.testfilepath, f"{self.testfilename}.nc") - - assertFilePathsEqual(self, filename, expected) - - def test030(self): - teststring = "%M/outfile.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, f"{self.macro}outfile.nc") - - def test040(self): - # unused substitution strings should be ignored - teststring = "%d%T%t%W%O/testdoc.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, f"{self.testfilename}/testdoc.nc") - - def test045(self): - """Testing the sequence number substitution""" - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - expected_filenames = [f"TestFileNaming{os.sep}testdoc.nc"] + [ - f"TestFileNaming{os.sep}testdoc-{i}.nc" for i in range(1, 5) - ] - for expected_filename in expected_filenames: - filename = next(filename_generator) - assertFilePathsEqual(self, filename, expected_filename) - - def test046(self): - """Testing the sequence number substitution""" - teststring = "%S-%d.nc" - self.job.PostProcessorOutputFile = teststring - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - expected_filenames = [ - os.path.join(self.testfilepath, f"{i}-TestFileNaming.nc") for i in range(5) - ] - for expected_filename in expected_filenames: - filename = next(filename_generator) - assertFilePathsEqual(self, filename, expected_filename) - - def test050(self): - # explicitly using the sequence number should include it where indicated. - teststring = "%S-%d.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "0-TestFileNaming.nc")) - - def test060(self): - """Test subpart naming""" - teststring = "%M/outfile.nc" - self.job.PostProcessorOutputFile = teststring - Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") - - generator = PostUtils.FilenameGenerator(job=self.job) - generator.set_subpartname("Tool") - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, f"{self.macro}outfile-Tool.nc") - - def test070(self): - """Test %T substitution (tool number) with actual tool controller""" - teststring = "%T.nc" - self.job.PostProcessorOutputFile = teststring - - generator = PostUtils.FilenameGenerator(job=self.job) - generator.set_subpartname("5") # Tool number from our test tool controller - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "5.nc")) - - def test071(self): - """Test %t substitution (tool description) with actual tool controller""" - teststring = "%t.nc" - self.job.PostProcessorOutputFile = teststring - - generator = PostUtils.FilenameGenerator(job=self.job) - generator.set_subpartname("TC__6mm_Endmill") # Sanitized tool label - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "TC__6mm_Endmill.nc")) - - def test072(self): - """Test %W substitution (work coordinate system/fixture)""" - teststring = "%W.nc" - self.job.PostProcessorOutputFile = teststring - - generator = PostUtils.FilenameGenerator(job=self.job) - generator.set_subpartname("G54") # First fixture from our job setup - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "G54.nc")) - - def test073(self): - """Test %O substitution (operation label)""" - teststring = "%O.nc" - self.job.PostProcessorOutputFile = teststring - - generator = PostUtils.FilenameGenerator(job=self.job) - generator.set_subpartname("OutsideProfile") # Operation label from our test setup - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "OutsideProfile.nc")) - - def test075(self): - """Test path and filename substitutions together""" - teststring = "%D/%j_%S.nc" - self.job.PostProcessorOutputFile = teststring - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - # %D should resolve to document directory (empty since doc has no filename) - # %j should resolve to job name "MainJob" - # %S should resolve to sequence number "0" - assertFilePathsEqual(self, filename, os.path.join(".", "MainJob_0.nc")) - - def test076(self): - """Test invalid substitution characters are ignored""" - teststring = "%X%Y%Z/invalid_%Q.nc" - self.job.PostProcessorOutputFile = teststring - - generator = PostUtils.FilenameGenerator(job=self.job) - filename_generator = generator.generate_filenames() - filename = next(filename_generator) - - # Invalid substitutions should be removed, leaving "invalid_.nc" - assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "invalid_.nc")) - - -class TestResolvingPostProcessorName(unittest.TestCase): - @classmethod - def setUpClass(cls): - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") - # Create a new document instead of opening external file - cls.doc = FreeCAD.newDocument("boxtest") - - # Create a simple geometry object for the job - import Part - - box = cls.doc.addObject("Part::Box", "TestBox") - box.Length = 100 - box.Width = 100 - box.Height = 20 - - # Create CAM job programmatically - cls.job = PathJob.Create("MainJob", [box], None) - cls.job.PostProcessorOutputFile = "" - cls.job.SplitOutput = False - cls.job.OrderOutputBy = "Operation" - cls.job.Fixtures = ["G54", "G55"] - - @classmethod - def tearDownClass(cls): - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") - - def setUp(self): - pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") - pref.SetString("PostProcessorDefault", "") - - def tearDown(self): - pass - - def test010(self): - # Test if post is defined in job - self.job.PostProcessor = "linuxcnc" - with patch("Path.Post.Processor.PostProcessor.exists", return_value=True): - postname = PathCommand._resolve_post_processor_name(self.job) - self.assertEqual(postname, "linuxcnc") - - def test020(self): - # Test if post is invalid - with patch("Path.Post.Processor.PostProcessor.exists", return_value=False): - with self.assertRaises(ValueError): - PathCommand._resolve_post_processor_name(self.job) - - def test030(self): - # Test if post is defined in prefs - self.job.PostProcessor = "" - pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") - pref.SetString("PostProcessorDefault", "grbl") - - with patch("Path.Post.Processor.PostProcessor.exists", return_value=True): - postname = PathCommand._resolve_post_processor_name(self.job) - self.assertEqual(postname, "grbl") - - def test040(self): - # Test if user interaction is correctly handled - if FreeCAD.GuiUp: - with patch("Path.Post.Command.DlgSelectPostProcessor") as mock_dlg, patch( - "Path.Post.Processor.PostProcessor.exists", return_value=True - ): - mock_dlg.return_value.exec_.return_value = "generic" - postname = PathCommand._resolve_post_processor_name(self.job) - self.assertEqual(postname, "generic") - else: - with patch.object(self.job, "PostProcessor", ""): - with self.assertRaises(ValueError): - PathCommand._resolve_post_processor_name(self.job) - - -class TestPostProcessorFactory(unittest.TestCase): - """Test creation of postprocessor objects.""" - - @classmethod - def setUpClass(cls): - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") - # Create a new document instead of opening external file - cls.doc = FreeCAD.newDocument("boxtest") - - # Create a simple geometry object for the job - import Part - - box = cls.doc.addObject("Part::Box", "TestBox") - box.Length = 100 - box.Width = 100 - box.Height = 20 - - # Create CAM job programmatically - cls.job = PathJob.Create("MainJob", [box], None) - cls.job.PostProcessor = "linuxcnc" - cls.job.PostProcessorOutputFile = "" - cls.job.SplitOutput = False - cls.job.OrderOutputBy = "Operation" - cls.job.Fixtures = ["G54", "G55"] - - @classmethod - def tearDownClass(cls): - FreeCAD.closeDocument(cls.doc.Name) - FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") - - def setUp(self): - pass - - def tearDown(self): - pass - - def test020(self): - # test creation of postprocessor object - post = PostProcessorFactory.get_post_processor(self.job, "generic") - self.assertTrue(post is not None) - self.assertTrue(hasattr(post, "export")) - self.assertTrue(hasattr(post, "_buildPostList")) - - def test030(self): - # test wrapping of old school postprocessor scripts - post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc_legacy") - self.assertTrue(post is not None) - self.assertTrue(hasattr(post, "_buildPostList")) - - def test040(self): - """Test that the __name__ of the postprocessor is correct.""" - post = PostProcessorFactory.get_post_processor(self.job, "linuxcnc_legacy") - self.assertEqual(post.script_module.__name__, "linuxcnc_legacy_post") - - class TestPathPostUtils(unittest.TestCase): def test010(self): """Test the utility functions in the PostUtils.py file.""" @@ -785,12 +340,12 @@ class TestBuildPostList(unittest.TestCase): # Determine object type/role obj_type = type(obj).__name__ if obj_type == "_FixtureSetupObject": - output.append(f" Type: Fixture Setup") + output.append(" 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") + output.append(" Type: Command Object") if hasattr(obj, "Path") and obj.Path and len(obj.Path.Commands) > 0: cmd = obj.Path.Commands[0] params = " ".join( @@ -810,7 +365,7 @@ class TestBuildPostList(unittest.TestCase): 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") + output.append(" 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: @@ -833,7 +388,7 @@ class TestBuildPostList(unittest.TestCase): ) output.append(f" M6 Command: {cmd.Name} {params}") else: - output.append(f" Type: Operation") + output.append(" Type: Operation") if hasattr(obj, "ToolController") and obj.ToolController: tc = obj.ToolController output.append( @@ -870,7 +425,7 @@ class TestBuildPostList(unittest.TestCase): # Create CAM job programmatically cls.job = PathJob.Create("MainJob", [box], None) - cls.job.PostProcessor = "generic" + cls.job.PostProcessor = "linuxcnc_legacy" cls.job.PostProcessorOutputFile = "" cls.job.SplitOutput = False cls.job.OrderOutputBy = "Operation" @@ -1148,12 +703,300 @@ class TestBuildPostList(unittest.TestCase): # 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" ) + + +class TestJobPropertyOverrides(unittest.TestCase): + """Test job-level postprocessor property overrides.""" + + @classmethod + def setUpClass(cls): + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") + cls.doc = FreeCAD.newDocument("job_override_test") + + # Create test geometry + import Part + + box = cls.doc.addObject("Part::Box", "TestBox") + box.Length = 100 + box.Width = 100 + box.Height = 20 + + # Create job + cls.job = PathJob.Create("OverrideTestJob", [box], None) + cls.job.PostProcessor = "linuxcnc_legacy" + cls.job.PostProcessorOutputFile = "" + cls.job.SplitOutput = False + cls.job.OrderOutputBy = "Operation" + cls.job.Fixtures = ["G54"] + cls.job.Machine = "TestMachine" + + # Create tool + from Path.Tool.toolbit import ToolBit + + tool_attrs = { + "name": "TestTool", + "shape": "endmill.fcstd", + "parameter": {"Diameter": 6.0}, + "attribute": {}, + } + toolbit = ToolBit.from_dict(tool_attrs) + tool = toolbit.attach_to_doc(doc=cls.doc) + tool.Label = "6mm_Endmill" + + tc = PathToolController.Create("TC_Test_Tool", tool, 1) + tc.Label = "TC: 6mm Endmill" + cls.job.addObject(tc) + + # Create operation + profile_op = cls.doc.addObject("Path::FeaturePython", "TestProfile") + profile_op.Label = "TestProfile" + profile_op.Path = Path.Path( + [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + Path.Command("G1", {"X": 100.0, "Y": 0.0, "Z": -5.0, "F": 100.0}), + Path.Command("G1", {"X": 100.0, "Y": 100.0, "Z": -5.0}), + Path.Command("G1", {"X": 0.0, "Y": 100.0, "Z": -5.0}), + Path.Command("G1", {"X": 0.0, "Y": 0.0, "Z": -5.0}), + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + ] + ) + cls.job.Operations.addObject(profile_op) + + cls.doc.recompute() + + @classmethod + def tearDownClass(cls): + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") + + def _create_test_machine(self, **properties): + """Create a test machine with specified postprocessor properties.""" + from Machine.models.machine import Machine, Toolhead, ToolheadType + + machine = Machine.create_3axis_config() + machine.name = "TestMachine" + machine.postprocessor_file_name = "generic" + machine.postprocessor_properties = { + "pierce_delay": 1000, + "cooling_delay": 500, + "force_rapid_feeds": False, + **properties, + } + + # Add toolhead + toolhead = Toolhead( + name="Default Toolhead", + toolhead_type=ToolheadType.ROTARY, + id="toolhead1", + max_power_kw=2.2, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + machine.toolheads = [toolhead] + return machine + + def test_job_property_overrides_basic(self): + """ + Test that job-level postprocessor property overrides work correctly. + + Expected: + - Job overrides take precedence over machine defaults + - Only specified keys are overridden + - Invalid JSON is handled gracefully + """ + from Path.Post.Processor import PostProcessor + from Machine.models.machine import MachineFactory + + # Reset job overrides to clean state + self.job.PostProcessorPropertyOverrides = "{}" + + # Create test machine + machine = self._create_test_machine() + + # Mock MachineFactory to return our test machine + original_get_machine = MachineFactory.get_machine + MachineFactory.get_machine = lambda name: machine + + try: + # Test 1: Basic override functionality + self.job.PostProcessorPropertyOverrides = '{"pierce_delay": 1800, "cooling_delay": 700}' + + processor = PostProcessor(self.job, "", "", "mm") + # Call export2 to trigger the override mechanism + processor.export2() + + # Verify overrides were applied + self.assertEqual(processor._machine.postprocessor_properties["pierce_delay"], 1800) + self.assertEqual(processor._machine.postprocessor_properties["cooling_delay"], 700) + # Verify non-overridden property stays at machine default + self.assertEqual( + processor._machine.postprocessor_properties["force_rapid_feeds"], False + ) + + # Test 2: Empty overrides do nothing + machine2 = self._create_test_machine() # Fresh machine instance + MachineFactory.get_machine = lambda name: machine2 + self.job.PostProcessorPropertyOverrides = "{}" + processor = PostProcessor(self.job, "", "", "mm") + processor.export2() + self.assertEqual(processor._machine.postprocessor_properties["pierce_delay"], 1000) + self.assertEqual(processor._machine.postprocessor_properties["cooling_delay"], 500) + + # Test 3: Invalid JSON is handled gracefully + machine3 = self._create_test_machine() # Fresh machine instance + MachineFactory.get_machine = lambda name: machine3 + self.job.PostProcessorPropertyOverrides = ( + '{"pierce_delay": 1800,' # Missing closing brace + ) + processor = PostProcessor(self.job, "", "", "mm") + processor.export2() + # Should fall back to machine defaults + self.assertEqual(processor._machine.postprocessor_properties["pierce_delay"], 1000) + + # Test 4: Unknown keys are ignored + machine4 = self._create_test_machine() # Fresh machine instance + MachineFactory.get_machine = lambda name: machine4 + self.job.PostProcessorPropertyOverrides = ( + '{"unknown_property": 1234, "pierce_delay": 1500}' + ) + processor = PostProcessor(self.job, "", "", "mm") + processor.export2() + # Known property should be overridden + self.assertEqual(processor._machine.postprocessor_properties["pierce_delay"], 1500) + # Unknown property should not be added + self.assertNotIn("unknown_property", processor._machine.postprocessor_properties) + + finally: + # Restore original MachineFactory + MachineFactory.get_machine = original_get_machine + + def test_job_property_overrides_with_plasma(self): + """ + Test that job-level overrides affect G-code output with plasma postprocessor. + + Expected: + - Override values are reflected in the final G-code output + """ + from Path.Post.scripts.generic_plasma_post import GenericPlasma + from Machine.models.machine import MachineFactory + + # Reset job overrides to clean state + self.job.PostProcessorPropertyOverrides = "{}" + + # Create machine with plasma postprocessor + machine = self._create_test_machine(pierce_delay=1000) + machine.postprocessor_file_name = "generic_plasma" + + # Add M3/M4 commands to trigger plasma behavior + plasma_commands = [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + Path.Command("M3", {}), # Torch on - should trigger pierce delay + Path.Command("G1", {"X": 100.0, "Y": 0.0, "Z": -5.0, "F": 100.0}), + Path.Command("M5", {}), # Torch off + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + ] + + # Update operation path + profile_op = self.doc.getObject("TestProfile") + original_path = profile_op.Path + profile_op.Path = Path.Path(plasma_commands) + + try: + # Mock MachineFactory + original_get_machine = MachineFactory.get_machine + MachineFactory.get_machine = lambda name: machine + + # Test with no overrides (machine defaults) + self.job.PostProcessorPropertyOverrides = "{}" + processor = GenericPlasma(self.job, "", "", "mm") + results = processor.export2() + gcode_no_override = "" + for section_name, gcode in results: + gcode_no_override += gcode + + # Test with pierce_delay override + self.job.PostProcessorPropertyOverrides = '{"pierce_delay": 2500}' # 2.5 seconds + processor = GenericPlasma(self.job, "", "", "mm") + results = processor.export2() + gcode_with_override = "" + for section_name, gcode in results: + gcode_with_override += gcode + + # The override should result in different G-code + self.assertNotEqual(gcode_no_override, gcode_with_override) + + # Verify the specific G4 dwell command reflects the override + # With 2500ms override, we should see G4 P2.5 + self.assertIn("G4 P2.5", gcode_with_override) + # With 1000ms default, we should see G4 P1.0 + self.assertIn("G4 P1.0", gcode_no_override) + + finally: + # Restore original path and MachineFactory + profile_op.Path = original_path + MachineFactory.get_machine = original_get_machine + + def test_job_property_overrides_template_round_trip(self): + """ + Test that job property overrides survive template save/restore cycle. + + Expected: + - Overrides are saved to template + - Overrides are restored from template + - Empty overrides are not saved to template + """ + import json + import tempfile + import os + + # Set some overrides and machine + self.job.PostProcessorPropertyOverrides = '{"pierce_delay": 1800, "cooling_delay": 700}' + self.job.Machine = "TestMachine" + + # Save to template + template_attrs = self.job.Proxy.templateAttrs(self.job) + + # Verify overrides are in template + self.assertIn("PostPropertyOverrides", template_attrs) + self.assertEqual( + template_attrs["PostPropertyOverrides"], {"pierce_delay": 1800, "cooling_delay": 700} + ) + + # Verify machine is in template + self.assertIn("Machine", template_attrs) + self.assertEqual(template_attrs["Machine"], "TestMachine") + + # Test empty overrides are not saved + self.job.PostProcessorPropertyOverrides = "{}" + template_attrs = self.job.Proxy.templateAttrs(self.job) + self.assertNotIn("PostPropertyOverrides", template_attrs) + + # Test round-trip: save to file and restore + self.job.PostProcessorPropertyOverrides = '{"pierce_delay": 1500}' + self.job.Machine = "" # Use empty machine (no machine) for test + template_attrs = self.job.Proxy.templateAttrs(self.job) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(template_attrs, f) + template_path = f.name + + try: + # Create a new job and restore from template + new_job = PathJob.Create("TemplateTestJob", [self.job.Stock], None) + new_job.Proxy.setFromTemplateFile(new_job, template_path) + + # Verify overrides were restored + self.assertEqual(new_job.PostProcessorPropertyOverrides, '{"pierce_delay": 1500}') + + # Verify machine was restored + self.assertEqual(new_job.Machine, "") + + finally: + os.unlink(template_path) diff --git a/src/Mod/CAM/CAMTests/TestPostOutput.py b/src/Mod/CAM/CAMTests/TestPostOutput.py new file mode 100644 index 0000000000..d3d2d98e8e --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPostOutput.py @@ -0,0 +1,1850 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * Copyright (c) 2016 sliptonic * +# * Copyright (c) 2022 Larry Woestman * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + + +from Path.Post.Processor import PostProcessorFactory +from Machine.models.machine import Machine +import FreeCAD +import Path +import Path.Post.Command as PathCommand +import Path.Post.Utils as PostUtils +import Path.Main.Job as PathJob +import Path.Tool.Controller as PathToolController +import os +import unittest + +from .FilePathTestUtils import assertFilePathsEqual + +PathCommand.LOG_MODULE = Path.Log.thisModule() +Path.Log.setLevel(Path.Log.Level.INFO, PathCommand.LOG_MODULE) + + +class TestFileNameGenerator(unittest.TestCase): + r""" + String substitution allows the following: + %D ... directory of the active document + %d ... name of the active document (with extension) + %M ... user macro directory + %j ... name of the active Job object + + + The Following can be used if output is being split. If Output is not split + these will be ignored. + + %S ... Sequence Number (default) + + Either: + %T ... Tool Number + %t ... Tool Controller label + + %W ... Work Coordinate System + %O ... Operation Label + + |split on| use | Ignore | + |-----------|-------|--------| + |fixture | %W | %O %T %t | + |Operation| %O | %T %t %W | + |Tool| **Either %T or %t** | %O %W | + + The confusing bit is that for split on tool, it will use EITHER the tool number or the tool label. + If you include both, the second one overrides the first. + And for split on operation, where including the tool should be possible, it ignores it altogether. + + self.job.Fixtures = ["G54"] + self.job.SplitOutput = False + self.job.OrderOutputBy = "Fixture" + + Assume: + active document: self.assertTrue(filename, f"{home}/testdoc.fcstd + user macro: ~/.local/share/FreeCAD/Macro + Job: MainJob + Operations: + OutsideProfile + DrillAllHoles + TC: 7/16" two flute (5) + TC: Drill (2) + Fixtures: (G54, G55) + + Strings should be sanitized like this to ensure valid filenames + # import re + # filename="TC: 7/16" two flute" + # >>> re.sub(r"[^\w\d-]","_",filename) + # "TC__7_16__two_flute" + + """ + + @classmethod + def setUpClass(cls): + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") + + # Create a new document instead of opening external file + cls.doc = FreeCAD.newDocument("TestFileNaming") + cls.testfilename = cls.doc.Name + cls.testfilepath = os.getcwd() + cls.macro = FreeCAD.getUserMacroDir() + + # Create a simple geometry object for the job + import Part + + box = cls.doc.addObject("Part::Box", "TestBox") + box.Length = 100 + box.Width = 100 + box.Height = 20 + + # Create CAM job programmatically + cls.job = PathJob.Create("MainJob", [box], None) + cls.job.PostProcessor = "linuxcnc_legacy" + cls.job.PostProcessorOutputFile = "" + cls.job.SplitOutput = False + cls.job.OrderOutputBy = "Operation" + cls.job.Fixtures = ["G54", "G55"] + + # Create a tool controller for testing tool-related substitutions + from Path.Tool.toolbit import ToolBit + + tool_attrs = { + "name": "TestTool", + "shape": "endmill.fcstd", + "parameter": {"Diameter": 6.0}, + "attribute": {}, + } + toolbit = ToolBit.from_dict(tool_attrs) + tool = toolbit.attach_to_doc(doc=cls.doc) + tool.Label = "6mm_Endmill" + + tc = PathToolController.Create("TC_Test_Tool", tool, 5) + tc.Label = "TC: 6mm Endmill" + cls.job.addObject(tc) + + # Create a simple mock operation for testing operation-related substitutions + profile_op = cls.doc.addObject("Path::FeaturePython", "TestProfile") + profile_op.Label = "OutsideProfile" + # Path::FeaturePython objects already have a Path property + profile_op.Path = Path.Path() + cls.job.Operations.addObject(profile_op) + + cls.doc.recompute() + + @classmethod + def tearDownClass(cls): + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") + + def test000(self): + # Test basic name generation with empty string + FreeCAD.setActiveDocument(self.doc.Label) + teststring = "" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + Path.Log.debug(filename) + assertFilePathsEqual( + self, filename, os.path.join(self.testfilepath, f"{self.testfilename}.nc") + ) + + def test010(self): + # Substitute current file path + teststring = "%D/testfile.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + print(os.path.normpath(filename)) + assertFilePathsEqual(self, filename, f"{self.testfilepath}/testfile.nc") + + def test015(self): + # Test basic string substitution without splitting + teststring = "~/Desktop/%j.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, "~/Desktop/MainJob.nc") + + def test020(self): + teststring = "%d.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + expected = os.path.join(self.testfilepath, f"{self.testfilename}.nc") + + assertFilePathsEqual(self, filename, expected) + + def test030(self): + teststring = "%M/outfile.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, f"{self.macro}outfile.nc") + + def test040(self): + # unused substitution strings should be ignored + teststring = "%d%T%t%W%O/testdoc.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, f"{self.testfilename}/testdoc.nc") + + def test045(self): + """Testing the sequence number substitution""" + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + expected_filenames = [f"TestFileNaming{os.sep}testdoc.nc"] + [ + f"TestFileNaming{os.sep}testdoc-{i}.nc" for i in range(1, 5) + ] + for expected_filename in expected_filenames: + filename = next(filename_generator) + assertFilePathsEqual(self, filename, expected_filename) + + def test046(self): + """Testing the sequence number substitution""" + teststring = "%S-%d.nc" + self.job.PostProcessorOutputFile = teststring + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + expected_filenames = [ + os.path.join(self.testfilepath, f"{i}-TestFileNaming.nc") for i in range(5) + ] + for expected_filename in expected_filenames: + filename = next(filename_generator) + assertFilePathsEqual(self, filename, expected_filename) + + def test050(self): + # explicitly using the sequence number should include it where indicated. + teststring = "%S-%d.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "0-TestFileNaming.nc")) + + def test060(self): + """Test subpart naming""" + teststring = "%M/outfile.nc" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + generator.set_subpartname("Tool") + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, f"{self.macro}outfile-Tool.nc") + + def test070(self): + """Test %T substitution (tool number) with actual tool controller""" + teststring = "%T.nc" + self.job.PostProcessorOutputFile = teststring + + generator = PostUtils.FilenameGenerator(job=self.job) + generator.set_subpartname("5") # Tool number from our test tool controller + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "5.nc")) + + def test071(self): + """Test %t substitution (tool description) with actual tool controller""" + teststring = "%t.nc" + self.job.PostProcessorOutputFile = teststring + + generator = PostUtils.FilenameGenerator(job=self.job) + generator.set_subpartname("TC__6mm_Endmill") # Sanitized tool label + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "TC__6mm_Endmill.nc")) + + def test072(self): + """Test %W substitution (work coordinate system/fixture)""" + teststring = "%W.nc" + self.job.PostProcessorOutputFile = teststring + + generator = PostUtils.FilenameGenerator(job=self.job) + generator.set_subpartname("G54") # First fixture from our job setup + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "G54.nc")) + + def test073(self): + """Test %O substitution (operation label)""" + teststring = "%O.nc" + self.job.PostProcessorOutputFile = teststring + + generator = PostUtils.FilenameGenerator(job=self.job) + generator.set_subpartname("OutsideProfile") # Operation label from our test setup + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "OutsideProfile.nc")) + + def test075(self): + """Test path and filename substitutions together""" + teststring = "%D/%j_%S.nc" + self.job.PostProcessorOutputFile = teststring + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + # %D should resolve to document directory (empty since doc has no filename) + # %j should resolve to job name "MainJob" + # %S should resolve to sequence number "0" + assertFilePathsEqual(self, filename, os.path.join(".", "MainJob_0.nc")) + + def test076(self): + """Test invalid substitution characters are ignored""" + teststring = "%X%Y%Z/invalid_%Q.nc" + self.job.PostProcessorOutputFile = teststring + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + # Invalid substitutions should be removed, leaving "invalid_.nc" + assertFilePathsEqual(self, filename, os.path.join(self.testfilepath, "invalid_.nc")) + + def test080_file_extension_override(self): + """ + Test that a file_extension override replaces the default .nc extension. + + Expected: + file_extension = "gcode" + PostProcessorOutputFile = "" + -> output file ends with .gcode instead of .nc + """ + FreeCAD.setActiveDocument(self.doc.Label) + self.job.PostProcessorOutputFile = "" + Path.Preferences.setOutputFileDefaults("", "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job, file_extension="gcode") + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + self.assertTrue(filename.endswith(".gcode"), f"Expected .gcode extension, got: {filename}") + + def test081_file_extension_overrides_job_extension(self): + """ + Test that file_extension override always wins over the extension + from PostProcessorOutputFile, since the postprocessor is the + authoritative source for the output format. + + Expected: + file_extension = "gcode" + PostProcessorOutputFile = "output.tap" + -> output file ends with .gcode (postprocessor wins) + """ + FreeCAD.setActiveDocument(self.doc.Label) + teststring = "output.tap" + self.job.PostProcessorOutputFile = teststring + Path.Preferences.setOutputFileDefaults(teststring, "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job, file_extension="gcode") + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + self.assertTrue( + filename.endswith(".gcode"), + f"Expected .gcode extension (postprocessor override), got: {filename}", + ) + + def test082_no_machine_falls_back_to_nc(self): + """ + Test that .nc is used when no machine is provided and no extension + is specified by the user. + + Expected: + No machine, PostProcessorOutputFile = "" + -> output file ends with .nc + """ + FreeCAD.setActiveDocument(self.doc.Label) + self.job.PostProcessorOutputFile = "" + Path.Preferences.setOutputFileDefaults("", "Append Unique ID on conflict") + + generator = PostUtils.FilenameGenerator(job=self.job) + filename_generator = generator.generate_filenames() + filename = next(filename_generator) + + self.assertTrue( + filename.endswith(".nc"), f"Expected .nc fallback extension, got: {filename}" + ) + + +class TestExport2Integration(unittest.TestCase): + """Integration tests for the export2() function.""" + + @classmethod + def setUpClass(cls): + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True") + cls.doc = FreeCAD.newDocument("export2_test") + + import Part + + box = cls.doc.addObject("Part::Box", "TestBox") + box.Length = 100 + box.Width = 100 + box.Height = 20 + + cls.job = PathJob.Create("Export2TestJob", [box], None) + cls.job.PostProcessor = "linuxcnc_legacy" + cls.job.PostProcessorOutputFile = "" + cls.job.SplitOutput = False + cls.job.OrderOutputBy = "Operation" + cls.job.Fixtures = ["G54"] + + cls.job.Machine = "Millstone" + + from Path.Tool.toolbit import ToolBit + + tool_attrs = { + "name": "TestTool", + "shape": "endmill.fcstd", + "parameter": {"Diameter": 6.0}, + "attribute": {}, + } + toolbit = ToolBit.from_dict(tool_attrs) + tool = toolbit.attach_to_doc(doc=cls.doc) + tool.Label = "6mm_Endmill" + + tc = PathToolController.Create("TC_Test_Tool", tool, 1) + tc.Label = "TC: 6mm Endmill" + cls.job.addObject(tc) + + profile_op = cls.doc.addObject("Path::FeaturePython", "TestProfile") + profile_op.Label = "TestProfile" + cls._default_path = Path.Path( + [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + Path.Command("G1", {"X": 100.0, "Y": 0.0, "Z": -5.0, "F": 100.0}), + Path.Command("G1", {"X": 100.0, "Y": 100.0, "Z": -5.0}), + Path.Command("G1", {"X": 0.0, "Y": 100.0, "Z": -5.0}), + Path.Command("G1", {"X": 0.0, "Y": 0.0, "Z": -5.0}), + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + ] + ) + profile_op.Path = cls._default_path + cls.job.Operations.addObject(profile_op) + + cls.doc.recompute() + + @classmethod + def tearDownClass(cls): + FreeCAD.closeDocument(cls.doc.Name) + FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "") + + def _create_machine(self, **output_options): + """Helper to create a machine with specified output options.""" + from Machine.models.machine import Machine, OutputUnits, Toolhead, ToolheadType + + machine = Machine.create_3axis_config() + machine.name = "TestMachine" + + # Add a default toolhead since post processor expects toolhead index 0 + default_toolhead = Toolhead( + name="Default Toolhead", + toolhead_type=ToolheadType.ROTARY, + id="toolhead1", + max_power_kw=2.2, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + machine.toolheads = [default_toolhead] + + for key, value in output_options.items(): + # Handle nested output structure + if key in ["header", "comments", "formatting", "precision", "duplicates"]: + # Set nested attribute + setattr(getattr(machine.output, key), key, value) + elif key in [ + "include_date", + "include_description", + "include_document_name", + "include_machine_name", + "include_project_file", + "include_units", + "include_tool_list", + "include_fixture_list", + ]: + # Set header nested attribute + setattr(machine.output.header, key, value) + elif key in [ + "enabled", + "symbol", + "include_operation_labels", + "include_blank_lines", + "output_bcnc_comments", + ]: + # Set comments nested attribute + setattr(machine.output.comments, key, value) + elif key == "comment_symbol": + # Handle legacy test parameter name + setattr(machine.output.comments, "symbol", value) + elif key == "comments_enabled": + # Handle legacy test parameter name + setattr(machine.output.comments, "enabled", value) + elif key in [ + "line_numbers", + "line_number_start", + "line_number_prefix", + "line_increment", + "command_space", + "end_of_line_chars", + ]: + # Set formatting nested attribute + setattr(machine.output.formatting, key, value) + elif key in ["axis", "feed", "spindle"]: + # Set precision nested attribute + setattr(machine.output.precision, key, value) + elif key == "axis_precision": + # Handle legacy test parameter name + setattr(machine.output.precision, "axis", value) + elif key == "feed_precision": + # Handle legacy test parameter name + setattr(machine.output.precision, "feed", value) + elif key == "spindle_precision": + # Handle legacy test parameter name + setattr(machine.output.precision, "spindle", value) + elif key in ["commands", "parameters"]: + # Set duplicates nested attribute + setattr(machine.output.duplicates, key, value) + elif key == "output_duplicate_commands": + # Handle legacy test parameter name + setattr(machine.output.duplicates, "commands", value) + elif key == "output_duplicate_parameters": + # Handle legacy test parameter name + setattr(machine.output.duplicates, "parameters", value) + elif key == "output_units": + # Convert string output_units to enum + if value == "metric": + machine.output.units = OutputUnits.METRIC + elif value == "imperial": + machine.output.units = OutputUnits.IMPERIAL + elif key == "output_header": + # Set top-level output_header attribute + setattr(machine.output, key, value) + else: + # Set top-level attribute + setattr(machine.output, key, value) + return machine + + def _create_postprocessor(self, machine=None, job=None): + """Helper to create a PostProcessor with optional machine config.""" + from Path.Post.Processor import PostProcessor + + if job is None: + job = self.job + post = PostProcessor(job, "", "", "mm") + if machine: + post._machine = machine + return post + + def _run_export2(self, machine=None, job=None): + """Helper to run export2 and return results.""" + post = self._create_postprocessor(machine, job) + return post.export2() + + def _get_first_section_gcode(self, results): + """Helper to extract first section's G-code from results.""" + if results and len(results) > 0: + return results[0][1] + return "" + + def _get_all_gcode(self, results): + """Helper to combine all sections into single G-code string.""" + all_output = "" + for section_name, gcode in results: + all_output += f"\n--- {section_name} ---\n{gcode}" + return all_output + + def _modify_operation_path(self, commands): + """Context manager to temporarily modify operation path.""" + + class PathModifier: + def __init__(modifier_self, test_self, commands): + modifier_self.test_self = test_self + modifier_self.commands = commands + modifier_self.profile_op = None + modifier_self.original_path = None + + def __enter__(modifier_self): + modifier_self.profile_op = modifier_self.test_self.doc.getObject("TestProfile") + modifier_self.original_path = modifier_self.profile_op.Path + modifier_self.profile_op.Path = Path.Path(modifier_self.commands) + return modifier_self + + def __exit__(modifier_self, exc_type, exc_val, exc_tb): + modifier_self.profile_op.Path = modifier_self.original_path + + return PathModifier(self, commands) + + @staticmethod + def _get_full_machine_config(): + """Helper to get the complete machine config used in multiple tests.""" + return { + "freecad_version": "1.2.0", + "machine": { + "axes": { + "A": { + "joint": [[0, 0, 0], [0.0, 0.0, 1.0]], + "max": 180, + "max_velocity": 36000, + "min": -180, + "prefer_positive": True, + "sequence": 0, + "type": "angular", + }, + "C": { + "joint": [[0, 0, 0], [0.0, 0.0, 1.0]], + "max": 180, + "max_velocity": 36000, + "min": -180, + "prefer_positive": True, + "sequence": 0, + "type": "angular", + }, + "X": { + "joint": [[1.0, 0.0, 0.0], [0, 0, 0]], + "max": 500.0, + "max_velocity": 10000, + "min": 50.0, + "sequence": 0, + "type": "linear", + }, + "Y": { + "joint": [[0.0, 1.0, 0.0], [0, 0, 0]], + "max": 1000, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear", + }, + "Z": { + "joint": [[0.0, 0.0, 1.0], [0, 0, 0]], + "max": 1000, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear", + }, + }, + "description": "My linuxcnc mill", + "manufacturer": "Supermax", + "name": "MillStone", + "spindles": [ + { + "id": "spindle1", + "max_power_kw": 3.0, + "max_rpm": 24000, + "min_rpm": 6000, + "name": "Spindle 1", + "tool_change": "manual", + "coolant_flood": False, + "coolant_mist": False, + "coolant_delay": 0.0, + "spindle_wait": 0.0, + } + ], + "units": "metric", + }, + "output": { + "axis_precision": 3, + "blank_lines": True, + "command_space": " ", + "comment_symbol": ";", + "comments": False, + "end_of_line_chars": "\n", + "feed_precision": 3, + "header": False, + "line_increment": 10, + "line_number_start": 100, + "line_numbers": False, + "list_tools_in_preamble": False, + "machine_name": False, + "output_bcnc_comments": True, + "output_duplicate_parameters": True, + "output_units": "metric", + "path_labels": False, + "show_operation_labels": True, + "spindle_decimals": 0, + }, + "postprocessor": { + "file_name": "", + "properties": { + "supports_tool_radius_compensation": False, + "supported_commands": "", + "drill_cycles_to_translate": "", + "preamble": "(preamble)", + "postamble": "(postamble)", + "safetyblock": "(safety)", + "pre_operation": "(preoperation)", + "post_operation": "(postoperation)", + "pre_tool_change": "(pretoolchange)", + "post_tool_change": "(posttoolchange)", + "pre_job": "(prejob)", + "post_job": "(postjob)", + "pre_fixture_change": "(prefixture)", + "post_fixture_change": "(postfixture)", + "pre_rotary_move": "(prerotary)", + "post_rotary_move": "(Postrotary)", + "tool_return": "(toolreturn)", + }, + }, + "processing": { + "early_tool_prep": False, + "filter_inefficient_moves": False, + "split_arcs": False, + "tool_change": True, + "translate_rapid_moves": False, + }, + "version": 1, + } + + # ===== 010-019: Basic smoke tests ===== + + def test010_export2_returns_gcode_sections(self): + """Test that export2() returns a non-empty list of (name, gcode) tuples.""" + from Path.Post.Processor import PostProcessor + + post = PostProcessor(self.job, "", "", "mm") + results = post.export2() + + self.assertIsNotNone(results, "export2 should return results") + self.assertIsInstance(results, list) + self.assertGreater(len(results), 0, "Should have at least one section") + + section_name, gcode = results[0] + self.assertIsInstance(section_name, str) + self.assertGreater(len(gcode), 0, "First section should contain G-code") + + # ===== 020-039: _build_header tests ===== + + def test020_header_enabled_shows_machine_name(self): + """ + Test that _build_header includes machine name when header and include_machine_name are True. + + Expected: + Output contains a comment line with the machine name. + """ + machine = self._create_machine( + output_header=True, include_machine_name=True, line_numbers=False + ) + + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + + header_comments = [ + line for line in gcode.split("\n") if line.startswith("(") and "Machine" in line + ] + self.assertGreater(len(header_comments), 0, "Header should contain machine name comment") + + def test021_header_disabled_suppresses_all_header(self): + """ + Test that _build_header produces no header when output_header is False. + + Expected: + No machine name, no tool list, no date in output header area. + """ + machine = self._create_machine( + output_header=False, include_machine_name=True, line_numbers=False + ) + + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + + header_comments = [ + line for line in gcode.split("\n") if line.startswith("(") and "Machine" in line + ] + self.assertEqual( + len(header_comments), 0, "Header comments should be suppressed when header:false" + ) + + def test022_header_true_comments_false_suppresses_inline(self): + """ + Test that header:true + comments:false shows header but suppresses inline comments. + + Expected: + Header comments present, but inline (Test comment) is suppressed. + """ + machine = self._create_machine( + output_header=True, comments_enabled=False, line_numbers=False + ) + + with self._modify_operation_path( + [ + Path.Command("(Test inline comment)"), + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + self.assertNotIn( + "Test inline comment", + gcode, + "Inline comments should be suppressed when comments:false", + ) + + def test023_header_false_comments_true_shows_inline(self): + """ + Test that header:false + comments:true suppresses header but shows inline comments. + + Expected: + No header comments, but inline (Test inline comment) is present. + """ + machine = self._create_machine( + output_header=False, comments_enabled=True, line_numbers=False + ) + + with self._modify_operation_path( + [ + Path.Command("(Test inline comment)"), + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + + header_comments = [ + line for line in gcode.split("\n") if line.startswith("(") and "Machine" in line + ] + self.assertEqual( + len(header_comments), 0, "Header should be suppressed when header:false" + ) + self.assertIn( + "Test inline comment", gcode, "Inline comments should be present when comments:true" + ) + + # ===== 040-049: _expand_canned_cycles tests ===== + + def test040_canned_cycle_termination(self): + """ + Test that _expand_canned_cycles adds G80 termination after drill cycle sequences. + + Expected: + BEFORE: G81 X10 Y20 Z-10 R1 F100 + G81 X10 Y30 Z-10 R1 F100 + + AFTER: G81 X10 Y20 Z-10 R1 F100 + G81 X10 Y30 Z-10 R1 F100 + G80 + """ + config = self._get_full_machine_config() + machine = Machine.from_dict(config) + + cmd1 = Path.Command("G81", {"X": 10.0, "Y": 20.0, "Z": -10.0, "R": 1.0, "F": 100.0}) + cmd1.Annotations = {"RetractMode": "G98"} + cmd2 = Path.Command("G81", {"X": 10.0, "Y": 30.0, "Z": -10.0, "R": 1.0, "F": 100.0}) + cmd2.Annotations = {"RetractMode": "G98"} + cmd3 = Path.Command("G81", {"X": 20.0, "Y": 30.0, "Z": -10.0, "R": 1.0, "F": 100.0}) + cmd3.Annotations = {"RetractMode": "G98"} + + with self._modify_operation_path( + [ + Path.Command("G0", {"Z": 20.0}), + Path.Command("G0", {"X": 10.0, "Y": 20.0}), + Path.Command("G0", {"Z": 1.0}), + cmd1, + cmd2, + cmd3, + Path.Command("G0", {"Z": 20.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + lines = [line.strip() for line in gcode.split("\n") if line.strip()] + + g81_lines = [line for line in lines if line.startswith("G81")] + self.assertEqual(len(g81_lines), 3, "Should have 3 G81 drill commands") + + for g81_line in g81_lines: + self.assertIn("Z", g81_line, f"G81 should have Z parameter: {g81_line}") + self.assertIn("R", g81_line, f"G81 should have R parameter: {g81_line}") + self.assertIn("F", g81_line, f"G81 should have F parameter: {g81_line}") + + g80_count = sum(1 for line in lines if line.startswith("G80")) + self.assertEqual(g80_count, 1, "Should have exactly one G80 termination") + + g81_indices = [i for i, line in enumerate(lines) if line.startswith("G81")] + g80_indices = [i for i, line in enumerate(lines) if line.startswith("G80")] + self.assertGreater( + min(g80_indices), max(g81_indices), "G80 should come after all G81 commands" + ) + + # ===== 050-059: _expand_split_arcs tests ===== + + def test050_split_arcs(self): + """ + Test that _expand_split_arcs splits arc moves into linear segments. + + Expected when split_arcs=True: + BEFORE: G2 X10 Y0 I5 J0 F100 + + AFTER: G1 X... Y... F100 (multiple linear segments) + """ + config = self._get_full_machine_config() + config["processing"]["split_arcs"] = True + machine = Machine.from_dict(config) + + with self._modify_operation_path( + [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 1.0}), + Path.Command("G1", {"Z": -5.0, "F": 100.0}), + Path.Command("G2", {"X": 10.0, "Y": 0.0, "I": 5.0, "J": 0.0, "F": 100.0}), + Path.Command("G0", {"Z": 20.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + lines = [line.strip() for line in gcode.split("\n") if line.strip()] + + g1_lines = [line for line in lines if line.startswith("G1")] + self.assertGreater( + len(g1_lines), 1, "Should have multiple G1 commands from arc splitting" + ) + + # ===== 060-069: _expand_spindle_wait tests ===== + + def test060_spindle_wait_injects_dwell(self): + """ + Test that _expand_spindle_wait injects G4 pause after M3/M4 spindle start. + + Expected when spindle_wait=2.5: + BEFORE: M3 S1000 + G0 X10 Y20 + + AFTER: M3 S1000 + G4 P2.5 + G0 X10 Y20 + """ + config = self._get_full_machine_config() + config["machine"]["spindles"][0]["spindle_wait"] = 2.5 + machine = Machine.from_dict(config) + + with self._modify_operation_path( + [ + Path.Command("M3", {"S": 1000.0}), + Path.Command("G0", {"X": 10.0, "Y": 20.0}), + Path.Command("G1", {"Z": -5.0, "F": 100.0}), + Path.Command("M4", {"S": 1500.0}), + Path.Command("G1", {"X": 20.0, "Y": 30.0, "F": 100.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + lines = [line.strip() for line in gcode.split("\n") if line.strip()] + + g4_lines = [line for line in lines if line.startswith("G4")] + self.assertGreaterEqual( + len(g4_lines), 2, "Should have at least 2 G4 commands (after M3 and M4)" + ) + + for g4_line in g4_lines: + self.assertIn("P2.5", g4_line, f"G4 command should have P2.5 parameter: {g4_line}") + + # Verify G4 appears immediately after M3 and M4 + m3_idx = next((i for i, l in enumerate(lines) if l.startswith("M3")), None) + m4_idx = next((i for i, l in enumerate(lines) if l.startswith("M4")), None) + g4_indices = [i for i, l in enumerate(lines) if l.startswith("G4")] + + if m3_idx is not None: + self.assertIn(m3_idx + 1, g4_indices, "G4 should appear immediately after M3") + if m4_idx is not None: + self.assertIn(m4_idx + 1, g4_indices, "G4 should appear immediately after M4") + + def test061_spindle_wait_zero_no_dwell(self): + """ + Test that no G4 is injected when spindle_wait is 0. + + Expected: + M3 S1000 + G0 X10 Y20 (no G4 between) + """ + config = self._get_full_machine_config() + config["machine"]["spindles"][0]["spindle_wait"] = 0.0 + machine = Machine.from_dict(config) + + with self._modify_operation_path( + [ + Path.Command("M3", {"S": 1000.0}), + Path.Command("G0", {"X": 10.0, "Y": 20.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + lines = [line.strip() for line in gcode.split("\n") if line.strip()] + + g4_lines = [line for line in lines if line.startswith("G4")] + self.assertEqual(len(g4_lines), 0, "Should have no G4 commands when spindle_wait is 0") + + # ===== 070-079: _expand_coolant_delay tests ===== + + def test070_coolant_delay_injects_dwell(self): + """ + Test that _expand_coolant_delay injects G4 pause after coolant on commands. + + Expected when coolant_delay=1.5: + BEFORE: M8 + G1 X10 F100 + + AFTER: M8 + G4 P1.5 + G1 X10 F100 + """ + config = self._get_full_machine_config() + config["machine"]["spindles"][0]["coolant_delay"] = 1.5 + machine = Machine.from_dict(config) + + with self._modify_operation_path( + [ + Path.Command("M8"), + Path.Command("G1", {"X": 10.0, "F": 100.0}), + Path.Command("M7"), + Path.Command("G1", {"X": 20.0, "F": 100.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_all_gcode(results) + lines = [line.strip() for line in gcode.split("\n") if line.strip()] + + g4_lines = [line for line in lines if line.startswith("G4")] + self.assertGreater(len(g4_lines), 0, "Should have G4 pause after coolant on command") + + for g4_line in g4_lines: + self.assertIn("P1.5", g4_line, f"G4 should have P1.5 parameter: {g4_line}") + + # ===== 080-089: _expand_translate_rapids tests ===== + + def test080_translate_rapid_moves(self): + """ + Test that _expand_translate_rapids converts G0 to G1 when enabled. + + Expected when translate_rapid_moves=True: + BEFORE: G0 X10 Y10 + G1 X20 Y10 F100 + G0 Z5 + + AFTER: G1 X10 Y10 + G1 X20 Y10 F100 + G1 Z5 + """ + config = self._get_full_machine_config() + config["processing"]["translate_rapid_moves"] = True + machine = Machine.from_dict(config) + + with self._modify_operation_path( + [ + Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}), + Path.Command("G0", {"X": 10.0, "Y": 10.0}), + Path.Command("G1", {"X": 20.0, "Y": 10.0, "F": 100.0}), + Path.Command("G0", {"Z": 10.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_all_gcode(results) + lines = [ + line.strip() + for line in gcode.split("\n") + if line.strip() and not line.strip().startswith("(") + ] + + g0_count = sum(1 for line in lines if line.startswith("G0")) + g1_count = sum(1 for line in lines if line.startswith("G1")) + + self.assertEqual( + g0_count, 0, "Should have no G0 commands when rapid move translation is enabled" + ) + self.assertGreaterEqual( + g1_count, 4, f"Should have at least 4 G1 commands, found {g1_count}" + ) + + # ===== 090-099: _expand_xy_before_z tests ===== + + def test090_xy_before_z_after_tool_change(self): + """ + Test that _expand_xy_before_z decomposes first move after tool change. + + When xy_before_z_after_tool_change=True, a combined XYZ move after an + M6 tool change is split into XY first, then Z, to prevent plunging + before positioning. + + Expected: + BEFORE: M6 T1 + G0 X10 Y20 Z-5 + + AFTER: M6 T1 + G0 X10 Y20 + G0 Z-5 + """ + config = self._get_full_machine_config() + config["processing"]["xy_before_z_after_tool_change"] = True + machine = Machine.from_dict(config) + + with self._modify_operation_path( + [ + Path.Command("M6", {"T": 1}), + Path.Command("G0", {"X": 10.0, "Y": 20.0, "Z": -5.0}), + Path.Command("G1", {"X": 20.0, "Y": 30.0, "Z": -5.0, "F": 100.0}), + ] + ): + results = self._run_export2(machine) + gcode = self._get_all_gcode(results) + lines = [ + line.strip() + for line in gcode.split("\n") + if line.strip() and not line.strip().startswith("(") + ] + + # Find G0 lines that are part of the decomposed move + g0_lines = [line for line in lines if line.startswith("G0")] + + # Find a G0 with X or Y but no Z (the XY-only move) + xy_only = [l for l in g0_lines if ("X" in l or "Y" in l) and "Z" not in l] + z_only = [l for l in g0_lines if "Z" in l and "X" not in l and "Y" not in l] + + self.assertGreater(len(xy_only), 0, "Should have an XY-only G0 move") + self.assertGreater(len(z_only), 0, "Should have a Z-only G0 move") + + # ===== 100-109: _expand_bcnc_commands tests ===== + + def test100_bcnc_comments_enabled(self): + """ + Test that _expand_bcnc_commands injects bCNC block annotations when enabled. + + Expected when output_bcnc_comments=True: + (Block-name: TestProfile) + (Block-expand: 0) + (Block-enable: 1) + G0 X0 Y0 Z5 + ... + (Block-name: post_amble) + (Block-expand: 0) + (Block-enable: 1) + """ + machine = self._create_machine(output_bcnc_comments=True, output_header=True) + + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + + self.assertIn("(Block-name:", gcode, "Should contain bCNC block name comments") + self.assertIn("(Block-expand: 0)", gcode, "Should contain bCNC block expand comments") + self.assertIn("(Block-enable: 1)", gcode, "Should contain bCNC block enable comments") + self.assertIn("(Block-name: post_amble)", gcode, "Should contain bCNC postamble block") + + def test101_bcnc_comments_disabled(self): + """ + Test that _expand_bcnc_commands removes bCNC annotations when disabled. + + Expected when output_bcnc_comments=False: + No (Block-name:, (Block-expand:, or (Block-enable: lines. + """ + machine = self._create_machine(output_bcnc_comments=False, output_header=True) + + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + + self.assertNotIn("(Block-name:", gcode, "Should not contain bCNC block name comments") + self.assertNotIn("(Block-expand: 0)", gcode, "Should not contain bCNC expand comments") + self.assertNotIn("(Block-enable: 1)", gcode, "Should not contain bCNC enable comments") + + def test102_bcnc_with_regular_comments_disabled(self): + """ + Test that bCNC comments appear even when regular comments are disabled. + + bCNC block annotations are structural, not user comments, so they should + be output regardless of the comments.enabled setting. + """ + machine = self._create_machine( + output_bcnc_comments=True, comments_enabled=False, output_header=False + ) + + results = self._run_export2(machine) + gcode = self._get_first_section_gcode(results) + + self.assertIn( + "(Block-name:", gcode, "bCNC blocks should appear even when comments disabled" + ) + self.assertIn("(Block-name: post_amble)", gcode, "bCNC postamble should appear") + + def test103_bcnc_block_structure(self): + """ + Test that each bCNC block has the correct 3-line structure: + (Block-name: