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 <[email protected]>
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2026 sliptonic <[email protected]> *
|
||||
# * *
|
||||
# * 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)
|
||||
@@ -0,0 +1,520 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2026 sliptonic <[email protected]> *
|
||||
# * *
|
||||
# * 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")
|
||||
@@ -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<tolerance>."""
|
||||
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")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2016 sliptonic <[email protected]> *
|
||||
# * Copyright (c) 2022 Larry Woestman <[email protected]> *
|
||||
# * *
|
||||
# * 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 unittest.mock import patch
|
||||
import FreeCAD
|
||||
import Path
|
||||
import Path.Post.Command as PathCommand
|
||||
import Path.Main.Job as PathJob
|
||||
import unittest
|
||||
from Path.Post.Processor import _HeaderBuilder
|
||||
|
||||
PathCommand.LOG_MODULE = Path.Log.thisModule()
|
||||
Path.Log.setLevel(Path.Log.Level.INFO, PathCommand.LOG_MODULE)
|
||||
|
||||
|
||||
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_legacy"
|
||||
with patch("Path.Post.Processor.PostProcessor.exists", return_value=True):
|
||||
postname = PathCommand._resolve_post_processor_name(self.job)
|
||||
self.assertEqual(postname, "linuxcnc_legacy")
|
||||
|
||||
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_legacy")
|
||||
|
||||
with patch("Path.Post.Processor.PostProcessor.exists", return_value=True):
|
||||
postname = PathCommand._resolve_post_processor_name(self.job)
|
||||
self.assertEqual(postname, "grbl_legacy")
|
||||
|
||||
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_legacy"
|
||||
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, "linuxcnc_legacy")
|
||||
self.assertIsNotNone(post)
|
||||
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.assertIsNotNone(post)
|
||||
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")
|
||||
# Refactored post processors don't have script_module, they are the module
|
||||
if hasattr(post, "script_module"):
|
||||
self.assertEqual(post.script_module.__name__, "linuxcnc_legacy_post")
|
||||
else:
|
||||
# For refactored posts, check the class module name
|
||||
self.assertEqual(post.__class__.__module__, "linuxcnc_legacy_post")
|
||||
|
||||
|
||||
class TestHeaderBuilder(unittest.TestCase):
|
||||
"""Test the HeaderBuilder class."""
|
||||
|
||||
def test010_initialization(self):
|
||||
"""Test that HeaderBuilder initializes with empty data structures."""
|
||||
|
||||
builder = _HeaderBuilder()
|
||||
|
||||
# Check initial state
|
||||
self.assertIsNone(builder._exporter)
|
||||
self.assertIsNone(builder._post_processor)
|
||||
self.assertIsNone(builder._cam_file)
|
||||
self.assertIsNone(builder._project_file)
|
||||
self.assertIsNone(builder._output_units)
|
||||
self.assertIsNone(builder._document_name)
|
||||
self.assertIsNone(builder._description)
|
||||
self.assertIsNone(builder._author)
|
||||
self.assertIsNone(builder._output_time)
|
||||
self.assertEqual(builder._tools, [])
|
||||
self.assertEqual(builder._fixtures, [])
|
||||
self.assertEqual(builder._notes, [])
|
||||
|
||||
def test020_add_methods(self):
|
||||
"""Test adding header elements."""
|
||||
|
||||
builder = _HeaderBuilder()
|
||||
|
||||
# Add various elements
|
||||
builder.add_exporter_info("TestExporter")
|
||||
builder.add_machine_info("TestMachine")
|
||||
builder.add_post_processor("test_post")
|
||||
builder.add_cam_file("test.fcstd")
|
||||
builder.add_project_file("/path/to/project.FCStd")
|
||||
builder.add_output_units("Metric - mm")
|
||||
builder.add_document_name("TestDocument")
|
||||
builder.add_description("Test job description")
|
||||
builder.add_author("Test Author")
|
||||
builder.add_output_time("2024-12-24 10:00:00")
|
||||
builder.add_tool(1, "End Mill")
|
||||
builder.add_tool(2, "Drill Bit")
|
||||
builder.add_fixture("G54")
|
||||
builder.add_fixture("G55")
|
||||
builder.add_note("This is a test note")
|
||||
|
||||
# Verify elements were added
|
||||
self.assertEqual(builder._exporter, "TestExporter")
|
||||
self.assertEqual(builder._machine, "TestMachine")
|
||||
self.assertEqual(builder._post_processor, "test_post")
|
||||
self.assertEqual(builder._cam_file, "test.fcstd")
|
||||
self.assertEqual(builder._project_file, "/path/to/project.FCStd")
|
||||
self.assertEqual(builder._output_units, "Metric - mm")
|
||||
self.assertEqual(builder._document_name, "TestDocument")
|
||||
self.assertEqual(builder._description, "Test job description")
|
||||
self.assertEqual(builder._author, "Test Author")
|
||||
self.assertEqual(builder._output_time, "2024-12-24 10:00:00")
|
||||
self.assertEqual(builder._tools, [(1, "End Mill"), (2, "Drill Bit")])
|
||||
self.assertEqual(builder._fixtures, ["G54", "G55"])
|
||||
self.assertEqual(builder._notes, ["This is a test note"])
|
||||
|
||||
def test030_path_property_empty(self):
|
||||
"""Test Path property with no data returns empty Path."""
|
||||
|
||||
builder = _HeaderBuilder()
|
||||
path = builder.Path
|
||||
|
||||
self.assertIsInstance(path, Path.Path)
|
||||
self.assertEqual(len(path.Commands), 0)
|
||||
|
||||
def test040_path_property_complete(self):
|
||||
"""Test Path property generates correct comment commands."""
|
||||
|
||||
builder = _HeaderBuilder()
|
||||
|
||||
# Add complete header data
|
||||
builder.add_exporter_info("FreeCAD")
|
||||
builder.add_machine_info("CNC Router")
|
||||
builder.add_post_processor("linuxcnc")
|
||||
builder.add_cam_file("project.fcstd")
|
||||
builder.add_project_file("/home/user/myproject.FCStd")
|
||||
builder.add_output_units("Metric - mm")
|
||||
builder.add_document_name("MyProject")
|
||||
builder.add_description("CNC milling project")
|
||||
builder.add_author("John Doe")
|
||||
builder.add_output_time("2024-12-24 10:00:00")
|
||||
builder.add_tool(1, '1/4" End Mill')
|
||||
builder.add_fixture("G54")
|
||||
builder.add_note("Test operation")
|
||||
|
||||
path = builder.Path
|
||||
|
||||
# Verify it's a Path object
|
||||
self.assertIsInstance(path, Path.Path)
|
||||
|
||||
# Check expected number of commands
|
||||
expected_commands = [
|
||||
"(Exported by FreeCAD)",
|
||||
"(Machine: CNC Router)",
|
||||
"(Post Processor: linuxcnc)",
|
||||
"(Cam File: project.fcstd)",
|
||||
"(Project File: /home/user/myproject.FCStd)",
|
||||
"(Output Units: Metric - mm)",
|
||||
"(Document: MyProject)",
|
||||
"(Description: CNC milling project)",
|
||||
"(Author: John Doe)",
|
||||
"(Output Time: 2024-12-24 10:00:00)",
|
||||
'(T1=1/4" End Mill)',
|
||||
"(Fixture: G54)",
|
||||
"(Note: Test operation)",
|
||||
]
|
||||
|
||||
self.assertEqual(len(path.Commands), len(expected_commands))
|
||||
|
||||
# Verify each command
|
||||
for i, expected_comment in enumerate(expected_commands):
|
||||
self.assertIsInstance(path.Commands[i], Path.Command)
|
||||
self.assertEqual(path.Commands[i].Name, expected_comment)
|
||||
|
||||
def test050_path_property_partial(self):
|
||||
"""Test Path property with partial data."""
|
||||
|
||||
builder = _HeaderBuilder()
|
||||
|
||||
# Add only some elements
|
||||
builder.add_exporter_info()
|
||||
builder.add_tool(5, "Drill")
|
||||
builder.add_note("Partial test")
|
||||
|
||||
path = builder.Path
|
||||
|
||||
expected_commands = ["(Exported by FreeCAD)", "(T5=Drill)", "(Note: Partial test)"]
|
||||
|
||||
self.assertEqual(len(path.Commands), len(expected_commands))
|
||||
for i, expected_comment in enumerate(expected_commands):
|
||||
self.assertEqual(path.Commands[i].Name, expected_comment)
|
||||
|
||||
# converted
|
||||
expected_gcode = "(Exported by FreeCAD)\n(T5=Drill)\n(Note: Partial test)\n"
|
||||
gcode = path.toGCode()
|
||||
self.assertEqual(gcode, expected_gcode)
|
||||
|
||||
def test060_multiple_tools_fixtures_notes(self):
|
||||
"""Test adding multiple tools, fixtures, and notes."""
|
||||
|
||||
builder = _HeaderBuilder()
|
||||
|
||||
# Add multiple items
|
||||
builder.add_tool(1, "Tool A")
|
||||
builder.add_tool(2, "Tool B")
|
||||
builder.add_tool(3, "Tool C")
|
||||
|
||||
builder.add_fixture("G54")
|
||||
builder.add_fixture("G55")
|
||||
builder.add_fixture("G56")
|
||||
|
||||
builder.add_note("Note 1")
|
||||
builder.add_note("Note 2")
|
||||
|
||||
path = builder.Path
|
||||
|
||||
# Should have 8 commands (3 tools + 3 fixtures + 2 notes)
|
||||
self.assertEqual(len(path.Commands), 8)
|
||||
|
||||
# Check tool commands
|
||||
self.assertEqual(path.Commands[0].Name, "(T1=Tool A)")
|
||||
self.assertEqual(path.Commands[1].Name, "(T2=Tool B)")
|
||||
self.assertEqual(path.Commands[2].Name, "(T3=Tool C)")
|
||||
|
||||
# Check fixture commands
|
||||
self.assertEqual(path.Commands[3].Name, "(Fixture: G54)")
|
||||
self.assertEqual(path.Commands[4].Name, "(Fixture: G55)")
|
||||
self.assertEqual(path.Commands[5].Name, "(Fixture: G56)")
|
||||
|
||||
# Check note commands
|
||||
self.assertEqual(path.Commands[6].Name, "(Note: Note 1)")
|
||||
self.assertEqual(path.Commands[7].Name, "(Note: Note 2)")
|
||||
|
||||
|
||||
class TestPostProcessorClassification(unittest.TestCase):
|
||||
"""Test the POST_TYPE-based postprocessor classification system."""
|
||||
|
||||
def setUp(self):
|
||||
# Clear the classification cache before each test
|
||||
import Path.Preferences
|
||||
|
||||
Path.Preferences._post_type_cache = {}
|
||||
Path.Preferences._post_type_cache_keys = None
|
||||
|
||||
def test010_classify_machine_post(self):
|
||||
"""New-style posts with POST_TYPE = 'machine' are classified as 'machine'."""
|
||||
import Path.Preferences
|
||||
|
||||
machine_posts = [
|
||||
"generic",
|
||||
"linuxcnc",
|
||||
"grbl",
|
||||
"centroid",
|
||||
"mach3_mach4",
|
||||
"opensbp",
|
||||
"generic_plasma",
|
||||
"smoothie",
|
||||
"masso_g3",
|
||||
]
|
||||
for post in machine_posts:
|
||||
result = Path.Preferences.classifyPostProcessor(post)
|
||||
self.assertEqual(result, "machine", f"Expected 'machine' for {post}, got '{result}'")
|
||||
|
||||
def test020_classify_legacy_post(self):
|
||||
"""Legacy posts without POST_TYPE are classified as 'legacy'."""
|
||||
import Path.Preferences
|
||||
|
||||
legacy_posts = ["linuxcnc_legacy", "grbl_legacy", "test"]
|
||||
available = Path.Preferences.allAvailablePostProcessors()
|
||||
for post in legacy_posts:
|
||||
if post in available:
|
||||
result = Path.Preferences.classifyPostProcessor(post)
|
||||
self.assertEqual(result, "legacy", f"Expected 'legacy' for {post}, got '{result}'")
|
||||
|
||||
def test030_classify_nonexistent_post(self):
|
||||
"""A nonexistent postprocessor is classified as 'unknown'."""
|
||||
import Path.Preferences
|
||||
|
||||
result = Path.Preferences.classifyPostProcessor("nonexistent_xyz_post_that_does_not_exist")
|
||||
self.assertEqual(result, "unknown")
|
||||
|
||||
def test040_legacy_list_excludes_machine(self):
|
||||
"""allAvailableLegacyPostProcessors excludes machine-type posts."""
|
||||
import Path.Preferences
|
||||
|
||||
legacy = Path.Preferences.allAvailableLegacyPostProcessors()
|
||||
machine = Path.Preferences.allAvailableMachinePostProcessors()
|
||||
|
||||
# No overlap
|
||||
overlap = set(legacy) & set(machine)
|
||||
self.assertEqual(overlap, set(), f"Unexpected overlap: {overlap}")
|
||||
|
||||
# Machine posts should not appear in legacy list
|
||||
for post in ["generic", "linuxcnc", "grbl"]:
|
||||
self.assertNotIn(post, legacy, f"Machine post '{post}' found in legacy list")
|
||||
|
||||
def test050_machine_list_excludes_legacy(self):
|
||||
"""allAvailableMachinePostProcessors excludes legacy-type posts."""
|
||||
import Path.Preferences
|
||||
|
||||
machine = Path.Preferences.allAvailableMachinePostProcessors()
|
||||
|
||||
# Legacy posts should not appear in machine list
|
||||
available = Path.Preferences.allAvailablePostProcessors()
|
||||
for post in available:
|
||||
if "_legacy" in post:
|
||||
self.assertNotIn(post, machine, f"Legacy post '{post}' found in machine list")
|
||||
|
||||
def test060_all_posts_accounted_for(self):
|
||||
"""Every available post is classified as either 'machine', 'legacy', or 'unknown'."""
|
||||
import Path.Preferences
|
||||
|
||||
all_posts = Path.Preferences.allAvailablePostProcessors()
|
||||
for post in all_posts:
|
||||
result = Path.Preferences.classifyPostProcessor(post)
|
||||
self.assertIn(
|
||||
result,
|
||||
["machine", "legacy", "unknown"],
|
||||
f"Unexpected classification '{result}' for {post}",
|
||||
)
|
||||
|
||||
def test070_cache_invalidation(self):
|
||||
"""Cache invalidates when available post list changes."""
|
||||
import Path.Preferences
|
||||
|
||||
# Prime the cache
|
||||
Path.Preferences.classifyPostProcessor("generic")
|
||||
self.assertIn("generic", Path.Preferences._post_type_cache)
|
||||
|
||||
# Simulate a change in available posts by modifying the cache key
|
||||
Path.Preferences._post_type_cache_keys = ("fake_post",)
|
||||
|
||||
# Next call should rebuild the cache
|
||||
result = Path.Preferences.classifyPostProcessor("generic")
|
||||
self.assertEqual(result, "machine")
|
||||
@@ -0,0 +1,558 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2024 FreeCAD Developers *
|
||||
# * *
|
||||
# * This file is part of FreeCAD. *
|
||||
# * *
|
||||
# * FreeCAD is free software: you can redistribute it and/or modify it *
|
||||
# * under the terms of the GNU Lesser General Public License as *
|
||||
# * published by the Free Software Foundation, either version 2.1 of the *
|
||||
# * License, or (at your option) any later version. *
|
||||
# * *
|
||||
# * FreeCAD is distributed in the hope that it will be useful, but *
|
||||
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
# * Lesser General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Lesser General Public *
|
||||
# * License along with FreeCAD. If not, see *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *************************************************************************
|
||||
|
||||
import FreeCAD
|
||||
import Path
|
||||
import unittest
|
||||
from Path.Post.Processor import PostProcessor
|
||||
from Machine.models.machine import Machine
|
||||
import Path.Tool.Controller as PathToolController
|
||||
from Path.Tool.toolbit import ToolBit
|
||||
import Path.Main.Job as PathJob
|
||||
|
||||
|
||||
class TestToolLengthOffset(unittest.TestCase):
|
||||
"""Test tool length offset (G43) suppression functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
self.doc = FreeCAD.newDocument("TestToolLengthOffset")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment."""
|
||||
FreeCAD.closeDocument("TestToolLengthOffset")
|
||||
|
||||
def test_g43_suppression_disabled(self):
|
||||
"""Test that G43 commands are suppressed when output_tool_length_offset is False."""
|
||||
# Create machine config with G43 disabled
|
||||
machine = Machine("Test Machine")
|
||||
machine.output.output_tool_length_offset = False
|
||||
|
||||
# Create a simple path with G43 command
|
||||
path = Path.Path()
|
||||
g43_cmd = Path.Command("G43", {"H": 1})
|
||||
path.addCommands(g43_cmd)
|
||||
|
||||
# Create post processor
|
||||
processor = PostProcessor(None, tooltip=None, tooltipargs=None, units=None)
|
||||
processor._machine = machine
|
||||
processor.values["OUTPUT_TOOL_LENGTH_OFFSET"] = False
|
||||
|
||||
# Convert G43 command
|
||||
result = processor.convert_command_to_gcode(g43_cmd)
|
||||
|
||||
# Should return None (suppressed)
|
||||
self.assertIsNone(
|
||||
result, "G43 command should be suppressed when output_tool_length_offset is False"
|
||||
)
|
||||
|
||||
def test_g43_output_enabled(self):
|
||||
"""Test that G43 commands are output when output_tool_length_offset is True."""
|
||||
# Create machine config with G43 enabled
|
||||
machine = Machine("Test Machine")
|
||||
machine.output.output_tool_length_offset = True
|
||||
|
||||
# Create a simple path with G43 command
|
||||
path = Path.Path()
|
||||
g43_cmd = Path.Command("G43", {"H": 1})
|
||||
path.addCommands(g43_cmd)
|
||||
|
||||
# Create post processor
|
||||
processor = PostProcessor(None, tooltip=None, tooltipargs=None, units=None)
|
||||
processor._machine = machine
|
||||
processor.values["OUTPUT_TOOL_LENGTH_OFFSET"] = True
|
||||
|
||||
# Convert G43 command
|
||||
result = processor.convert_command_to_gcode(g43_cmd)
|
||||
|
||||
# Should return the G43 command
|
||||
self.assertIsNotNone(
|
||||
result, "G43 command should be output when output_tool_length_offset is True"
|
||||
)
|
||||
self.assertIn("G43", result, "Result should contain G43 command")
|
||||
self.assertIn("H1", result, "Result should contain H parameter")
|
||||
|
||||
def test_machine_config_mapping(self):
|
||||
"""Test that machine config field is properly mapped to processor values."""
|
||||
# Create machine config with G43 disabled
|
||||
machine = Machine("Test Machine")
|
||||
machine.output.output_tool_length_offset = False
|
||||
|
||||
# Create post processor
|
||||
processor = PostProcessor(None, tooltip=None, tooltipargs=None, units=None)
|
||||
processor._machine = machine
|
||||
|
||||
# Simulate the mapping that happens in export2
|
||||
if hasattr(machine.output, "output_tool_length_offset"):
|
||||
processor.values["OUTPUT_TOOL_LENGTH_OFFSET"] = machine.output.output_tool_length_offset
|
||||
|
||||
# Check that the value was mapped correctly
|
||||
self.assertFalse(
|
||||
processor.values["OUTPUT_TOOL_LENGTH_OFFSET"],
|
||||
"Machine config field should be mapped to processor values",
|
||||
)
|
||||
|
||||
|
||||
class TestToolProcessing(unittest.TestCase):
|
||||
"""Test tool processing functionality including early tool prep."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
self.doc = FreeCAD.newDocument("TestToolProcessing")
|
||||
|
||||
# Create a basic job and tool controller for testing
|
||||
# Create base geometry for the job
|
||||
import Part
|
||||
|
||||
box = Part.makeBox(100, 100, 20)
|
||||
base_obj = self.doc.addObject("Part::Feature", "BaseBox")
|
||||
base_obj.Shape = box
|
||||
|
||||
# Create a test tool
|
||||
tool_attrs = {
|
||||
"name": "TestTool1",
|
||||
"shape": "endmill.fcstd",
|
||||
"parameter": {"Diameter": 6.0},
|
||||
"attribute": {},
|
||||
}
|
||||
toolbit1 = ToolBit.from_dict(tool_attrs)
|
||||
tool1 = toolbit1.attach_to_doc(doc=self.doc)
|
||||
tool1.Label = "6mm_Endmill"
|
||||
|
||||
# Create tool controller
|
||||
self.tc1 = PathToolController.Create("TC_Test_Tool1", tool1, 1)
|
||||
self.tc1.Label = "TC: 6mm Endmill"
|
||||
|
||||
# Create job
|
||||
self.job = PathJob.Create("TestJob", [base_obj], None)
|
||||
self.job.Label = "TestJob"
|
||||
|
||||
# Add tool controller to job
|
||||
self.job.Tools.Group = [self.tc1]
|
||||
|
||||
# Create a basic operation
|
||||
profile_op = self.doc.addObject("Path::FeaturePython", "TestProfile")
|
||||
profile_op.Label = "TestProfile"
|
||||
profile_op.addProperty("App::PropertyLink", "ToolController", "Base", "Tool controller")
|
||||
profile_op.ToolController = self.tc1
|
||||
profile_op.Path = Path.Path(
|
||||
[
|
||||
Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}),
|
||||
Path.Command("G1", {"X": 10.0, "Y": 0.0, "Z": -5.0, "F": 100.0}),
|
||||
Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}),
|
||||
]
|
||||
)
|
||||
self.job.Operations.addObject(profile_op)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment."""
|
||||
FreeCAD.closeDocument("TestToolProcessing")
|
||||
|
||||
def _get_full_machine_config(self):
|
||||
"""Get a full machine configuration for testing."""
|
||||
return {
|
||||
"freecad_version": "1.0.0",
|
||||
"machine": {
|
||||
"name": "Test Machine",
|
||||
"description": "Test machine for unit tests",
|
||||
"manufacturer": "Test",
|
||||
"units": "metric",
|
||||
"axes": {
|
||||
"X": {"type": "linear", "max": 100, "min": 0, "max_velocity": 1000},
|
||||
"Y": {"type": "linear", "max": 100, "min": 0, "max_velocity": 1000},
|
||||
"Z": {"type": "linear", "max": 100, "min": 0, "max_velocity": 1000},
|
||||
},
|
||||
"spindles": [
|
||||
{
|
||||
"id": "spindle1",
|
||||
"name": "Spindle 1",
|
||||
"max_power_kw": 2.0,
|
||||
"max_rpm": 24000,
|
||||
"min_rpm": 6000,
|
||||
"tool_change": "manual",
|
||||
}
|
||||
],
|
||||
},
|
||||
"output": {
|
||||
"units": "metric",
|
||||
"output_tool_length_offset": True,
|
||||
"output_header": True,
|
||||
"header": {
|
||||
"include_date": True,
|
||||
"include_description": True,
|
||||
"include_document_name": True,
|
||||
"include_machine_name": True,
|
||||
"include_project_file": True,
|
||||
"include_units": True,
|
||||
"include_tool_list": True,
|
||||
"include_fixture_list": True,
|
||||
},
|
||||
"comments": {
|
||||
"enabled": True,
|
||||
"symbol": "(",
|
||||
"include_operation_labels": True,
|
||||
"include_blank_lines": True,
|
||||
"output_bcnc_comments": False,
|
||||
},
|
||||
"formatting": {
|
||||
"line_numbers": False,
|
||||
"line_number_start": 100,
|
||||
"line_number_prefix": "N",
|
||||
"line_increment": 10,
|
||||
"command_space": " ",
|
||||
"end_of_line_chars": "\n",
|
||||
},
|
||||
"precision": {"axis": 3, "feed": 3, "spindle": 0},
|
||||
"duplicates": {"commands": True, "parameters": True},
|
||||
},
|
||||
"postprocessor": {
|
||||
"file_name": "generic",
|
||||
"properties": {
|
||||
"preamble": "G17 G54 G40 G49 G80 G90",
|
||||
"postamble": "M05\nG17 G54 G90 G80 G40\nM2",
|
||||
"supported_commands": "G0\nG00\nG1\nG01\nG2\nG02\nG3\nG03\nG73\nG74\nG81\nG82\nG83\nG84\nG38.2\nG54\nG55\nG56\nG57\nG58\nG59\nG59.1\nG59.2\nG59.3\nG59.4\nG59.5\nG59.6\nG59.7\nG59.8\nG59.9\nM0\nM00\nM1\nM01\nM3\nM03\nM4\nM04\nM6\nM06",
|
||||
},
|
||||
},
|
||||
"processing": {"tool_change": True, "early_tool_prep": False},
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
def _run_export2(self, machine):
|
||||
"""Run export2 with the given machine configuration."""
|
||||
processor = PostProcessor(self.job, tooltip=None, tooltipargs=None, units=None)
|
||||
processor._machine = machine
|
||||
return processor.export2()
|
||||
|
||||
def _get_all_gcode(self, results):
|
||||
"""Extract all G-code from export results."""
|
||||
if not results:
|
||||
return ""
|
||||
|
||||
all_output = ""
|
||||
for section_name, gcode in results:
|
||||
all_output += f"\n{gcode}"
|
||||
|
||||
return all_output
|
||||
|
||||
def test_XY_before_Z_after_tool_change(self):
|
||||
"""
|
||||
Test that xy_before_z_after_tool_change decomposes first move after tool change.
|
||||
|
||||
Expected behavior when enabled:
|
||||
BEFORE: M6 T2
|
||||
G0 X50.0 Y60.0 Z10.0
|
||||
|
||||
AFTER: M6 T2
|
||||
G0 X50.0 Y60.0
|
||||
G0 Z10.0
|
||||
"""
|
||||
# Create a second tool controller for testing tool changes
|
||||
tool_attrs = {
|
||||
"name": "TestTool2",
|
||||
"shape": "endmill.fcstd",
|
||||
"parameter": {"Diameter": 3.0},
|
||||
"attribute": {},
|
||||
}
|
||||
toolbit2 = ToolBit.from_dict(tool_attrs)
|
||||
tool2 = toolbit2.attach_to_doc(doc=self.doc)
|
||||
tool2.Label = "3mm_Endmill"
|
||||
|
||||
tc2 = PathToolController.Create("TC_Test_Tool2", tool2, 2)
|
||||
tc2.Label = "TC: 3mm Endmill"
|
||||
self.job.addObject(tc2)
|
||||
|
||||
# Create a second operation using the second tool
|
||||
# First move after tool change has X, Y, and Z components
|
||||
profile_op2 = self.doc.addObject("Path::FeaturePython", "TestProfile2")
|
||||
profile_op2.Label = "TestProfile2"
|
||||
profile_op2.addProperty("App::PropertyLink", "ToolController", "Base", "Tool controller")
|
||||
profile_op2.ToolController = tc2
|
||||
profile_op2.Path = Path.Path(
|
||||
[
|
||||
Path.Command("G0", {"X": 50.0, "Y": 60.0, "Z": 10.0}), # First move with X, Y, Z
|
||||
Path.Command("G1", {"X": 55.0, "Y": 65.0, "Z": -5.0, "F": 100.0}),
|
||||
]
|
||||
)
|
||||
self.job.Operations.addObject(profile_op2)
|
||||
|
||||
# Test with feature ENABLED
|
||||
config = self._get_full_machine_config()
|
||||
config["processing"]["xy_before_z_after_tool_change"] = True
|
||||
machine = Machine.from_dict(config)
|
||||
|
||||
try:
|
||||
results = self._run_export2(machine)
|
||||
gcode = self._get_all_gcode(results)
|
||||
lines = [line.strip() for line in gcode.split("\n") if line.strip()]
|
||||
|
||||
# Find the tool change to T2
|
||||
m6_index = None
|
||||
for i, line in enumerate(lines):
|
||||
if "M6" in line and "T2" in line:
|
||||
m6_index = i
|
||||
break
|
||||
|
||||
self.assertIsNotNone(m6_index, "Should have M6 T2 tool change")
|
||||
|
||||
# Get the next two move commands after M6
|
||||
moves = []
|
||||
for i in range(m6_index + 1, min(m6_index + 10, len(lines))):
|
||||
if lines[i].startswith("G0") or lines[i].startswith("G1"):
|
||||
moves.append(lines[i])
|
||||
if len(moves) == 2:
|
||||
break
|
||||
|
||||
self.assertEqual(len(moves), 2, "Should have 2 moves after tool change (XY then Z)")
|
||||
|
||||
# First move should have X and Y but NOT Z
|
||||
self.assertIn("X", moves[0], "First move should have X")
|
||||
self.assertIn("Y", moves[0], "First move should have Y")
|
||||
self.assertNotIn("Z", moves[0], "First move should NOT have Z")
|
||||
|
||||
# Second move should have Z but NOT X or Y
|
||||
self.assertIn("Z", moves[1], "Second move should have Z")
|
||||
self.assertNotIn("X", moves[1], "Second move should NOT have X")
|
||||
self.assertNotIn("Y", moves[1], "Second move should NOT have Y")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
self.job.Operations.removeObject(profile_op2)
|
||||
self.job.removeObject(tc2)
|
||||
self.doc.removeObject(profile_op2.Name)
|
||||
self.doc.removeObject(tc2.Name)
|
||||
self.doc.removeObject(tool2.Name)
|
||||
|
||||
def test_early_tool_prep(self):
|
||||
"""Test that early_tool_prep inserts tool prep commands after M6."""
|
||||
# Create a second tool controller for testing tool changes
|
||||
tool_attrs = {
|
||||
"name": "TestTool2",
|
||||
"shape": "endmill.fcstd",
|
||||
"parameter": {"Diameter": 3.0},
|
||||
"attribute": {},
|
||||
}
|
||||
toolbit2 = ToolBit.from_dict(tool_attrs)
|
||||
tool2 = toolbit2.attach_to_doc(doc=self.doc)
|
||||
tool2.Label = "3mm_Endmill"
|
||||
|
||||
tc2 = PathToolController.Create("TC_Test_Tool2", tool2, 2)
|
||||
tc2.Label = "TC: 3mm Endmill"
|
||||
self.job.addObject(tc2)
|
||||
|
||||
# Create a second operation using the second tool
|
||||
profile_op2 = self.doc.addObject("Path::FeaturePython", "TestProfile2")
|
||||
profile_op2.Label = "TestProfile2"
|
||||
profile_op2.Path = Path.Path(
|
||||
[
|
||||
Path.Command("G0", {"X": 50.0, "Y": 50.0, "Z": 5.0}),
|
||||
Path.Command("G1", {"X": 60.0, "Y": 50.0, "Z": -5.0, "F": 100.0}),
|
||||
Path.Command("G0", {"X": 50.0, "Y": 50.0, "Z": 5.0}),
|
||||
]
|
||||
)
|
||||
self.job.Operations.addObject(profile_op2)
|
||||
|
||||
# Create machine with early_tool_prep enabled
|
||||
config_with_prep = self._get_full_machine_config()
|
||||
config_with_prep["processing"]["early_tool_prep"] = True
|
||||
machine_with_prep = Machine.from_dict(config_with_prep)
|
||||
|
||||
# Create machine without early_tool_prep
|
||||
config_no_prep = self._get_full_machine_config()
|
||||
config_no_prep["processing"]["early_tool_prep"] = False
|
||||
machine_no_prep = Machine.from_dict(config_no_prep)
|
||||
|
||||
try:
|
||||
# Test with early_tool_prep enabled
|
||||
results_with = self._run_export2(machine_with_prep)
|
||||
gcode_with = self._get_all_gcode(results_with)
|
||||
|
||||
# Test without early_tool_prep
|
||||
results_without = self._run_export2(machine_no_prep)
|
||||
|
||||
lines_with = [line.strip() for line in gcode_with.split("\n") if line.strip()]
|
||||
|
||||
# Find M6 commands in output with early_tool_prep enabled
|
||||
m6_lines_with = [i for i, line in enumerate(lines_with) if "M6" in line]
|
||||
|
||||
# With early_tool_prep, should have standalone T commands (tool prep)
|
||||
# Look for lines that start with T followed by a digit
|
||||
import re
|
||||
|
||||
standalone_t_with = [line for line in lines_with if re.match(r"^T\d+$", line)]
|
||||
|
||||
# Should have standalone T commands when early_tool_prep is enabled
|
||||
# Note: early_tool_prep only works when there are multiple tools
|
||||
if len(m6_lines_with) >= 2:
|
||||
self.assertGreater(
|
||||
len(standalone_t_with),
|
||||
0,
|
||||
"Should have standalone T prep commands when early_tool_prep is enabled with multiple tools",
|
||||
)
|
||||
|
||||
# Verify the early prep command appears after first M6
|
||||
first_m6_idx = m6_lines_with[0]
|
||||
# Look for standalone T command shortly after first M6
|
||||
found_early_prep = False
|
||||
for i in range(first_m6_idx + 1, min(first_m6_idx + 20, len(lines_with))):
|
||||
line = lines_with[i]
|
||||
if re.match(r"^T\d+$", line):
|
||||
found_early_prep = True
|
||||
break
|
||||
|
||||
self.assertTrue(
|
||||
found_early_prep, "Should have early tool prep command shortly after first M6"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up the second tool controller and operation
|
||||
self.job.Operations.removeObject(profile_op2)
|
||||
self.job.removeObject(tc2)
|
||||
self.doc.removeObject(profile_op2.Name)
|
||||
self.doc.removeObject(tc2.Name)
|
||||
self.doc.removeObject(tool2.Name)
|
||||
|
||||
def test_tool_change_blocks_insertion(self):
|
||||
"""Test that pre/post tool change blocks are inserted around tool changes."""
|
||||
machine_config = self._get_full_machine_config()
|
||||
# Add pre/post tool change blocks to postprocessor properties
|
||||
machine_config["postprocessor"]["properties"]["pre_tool_change"] = "(pretoolchange)"
|
||||
machine_config["postprocessor"]["properties"]["post_tool_change"] = "(posttoolchange)"
|
||||
machine = Machine.from_dict(machine_config)
|
||||
|
||||
# Add a second tool controller to trigger tool changes
|
||||
tool_attrs = {
|
||||
"name": "SecondTool",
|
||||
"shape": "endmill.fcstd",
|
||||
"parameter": {"Diameter": 3.0},
|
||||
"attribute": {},
|
||||
}
|
||||
toolbit = ToolBit.from_dict(tool_attrs)
|
||||
tool = toolbit.attach_to_doc(doc=self.doc)
|
||||
tool.Label = "3mm_Endmill"
|
||||
|
||||
tc2 = PathToolController.Create("TC_Second_Tool", tool, 2)
|
||||
tc2.Label = "TC: 3mm Endmill"
|
||||
self.job.addObject(tc2)
|
||||
|
||||
# Create a second operation using the second tool
|
||||
profile_op2 = self.doc.addObject("Path::FeaturePython", "TestProfile2")
|
||||
profile_op2.Label = "TestProfile2"
|
||||
profile_op2.addProperty("App::PropertyLink", "ToolController", "Base", "Tool controller")
|
||||
profile_op2.ToolController = tc2
|
||||
profile_op2.Path = Path.Path(
|
||||
[
|
||||
Path.Command("G0", {"X": 50.0, "Y": 50.0, "Z": 5.0}),
|
||||
Path.Command("G1", {"X": 60.0, "Y": 50.0, "Z": -5.0, "F": 100.0}),
|
||||
]
|
||||
)
|
||||
self.job.Operations.addObject(profile_op2)
|
||||
|
||||
try:
|
||||
self.doc.recompute()
|
||||
results = self._run_export2(machine)
|
||||
all_output = self._get_all_gcode(results)
|
||||
|
||||
# Should have some output
|
||||
self.assertIsNotNone(all_output, "Should generate some output")
|
||||
self.assertGreater(len(all_output), 0, "Should have non-empty output")
|
||||
|
||||
self.assertIn(
|
||||
"(pretoolchange)", all_output, "Pre-tool-change block should appear in output"
|
||||
)
|
||||
self.assertIn(
|
||||
"(posttoolchange)", all_output, "Post-tool-change block should appear in output"
|
||||
)
|
||||
|
||||
# Count occurrences - should have blocks for tool changes
|
||||
pretool_count = all_output.count("(pretoolchange)")
|
||||
posttool_count = all_output.count("(posttoolchange)")
|
||||
|
||||
# Should have at least 2 tool changes (initial tool + change to second tool)
|
||||
self.assertGreaterEqual(
|
||||
pretool_count, 1, "Should have at least 1 pre-tool-change block"
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
posttool_count, 1, "Should have at least 1 post-tool-change block"
|
||||
)
|
||||
|
||||
# Verify ordering: pre-tool-change should come before post-tool-change
|
||||
lines = all_output.split("\n")
|
||||
pretool_indices = [i for i, line in enumerate(lines) if "(pretoolchange)" in line]
|
||||
posttool_indices = [i for i, line in enumerate(lines) if "(posttoolchange)" in line]
|
||||
|
||||
for pre_idx, post_idx in zip(pretool_indices, posttool_indices):
|
||||
self.assertLess(
|
||||
pre_idx, post_idx, "Pre-tool-change should come before post-tool-change"
|
||||
)
|
||||
|
||||
# Verify tool change commands (M6) are present
|
||||
self.assertIn("M6", all_output, "Tool change command M6 should be present")
|
||||
|
||||
finally:
|
||||
# Clean up - remove the second tool controller and operation
|
||||
self.job.Operations.removeObject(profile_op2)
|
||||
self.job.removeObject(tc2)
|
||||
self.doc.removeObject(profile_op2.Name)
|
||||
self.doc.removeObject(tc2.Name)
|
||||
self.doc.recompute()
|
||||
|
||||
def test_list_tools_in_header_option(self):
|
||||
"""Test that list_tools_in_header option includes tool list in header."""
|
||||
# Test with tool list enabled
|
||||
machine_with_tools = self._get_full_machine_config()
|
||||
machine_with_tools["output"]["list_tools_in_header"] = True
|
||||
machine_with_tools = Machine.from_dict(machine_with_tools)
|
||||
|
||||
# Test with tool list disabled
|
||||
machine_no_tools = self._get_full_machine_config()
|
||||
machine_no_tools["output"]["list_tools_in_header"] = False
|
||||
machine_no_tools = Machine.from_dict(machine_no_tools)
|
||||
|
||||
results_with = self._run_export2(machine_with_tools)
|
||||
gcode_with = self._get_all_gcode(results_with)
|
||||
|
||||
results_without = self._run_export2(machine_no_tools)
|
||||
gcode_without = self._get_all_gcode(results_without)
|
||||
|
||||
# With tool list enabled, header should contain tool information in comments
|
||||
# Look for specific tool listing format: (T<number>=toolname)
|
||||
import re
|
||||
|
||||
tool_pattern = re.compile(r"\(T\d+=.*?\)")
|
||||
|
||||
lines_with = gcode_with.split("\n")
|
||||
tool_comments_with = [line for line in lines_with if tool_pattern.search(line)]
|
||||
|
||||
lines_without = gcode_without.split("\n")
|
||||
tool_comments_without = [line for line in lines_without if tool_pattern.search(line)]
|
||||
|
||||
# Should have more tool-related comments when enabled
|
||||
self.assertGreaterEqual(
|
||||
len(tool_comments_with),
|
||||
len(tool_comments_without),
|
||||
"Should have more tool comments when list_tools_in_header=True",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,6 +18,7 @@
|
||||
# * implied. See the Licence for the specific language governing *
|
||||
# * permissions and limitations under the Licence. *
|
||||
# ***************************************************************************
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from typing import List
|
||||
@@ -42,7 +43,7 @@ class TestSnapmakerPost(PathTestUtils.PathTestBase):
|
||||
FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True")
|
||||
cls.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/CAMTests/boxtest.fcstd")
|
||||
cls.job = cls.doc.getObject("Job")
|
||||
cls.post = PostProcessorFactory.get_post_processor(cls.job, "snapmaker")
|
||||
cls.post = PostProcessorFactory.get_post_processor(cls.job, "snapmaker_legacy")
|
||||
# locate the operation named "Profile"
|
||||
for op in cls.job.Operations.Group:
|
||||
if op.Label == "Profile":
|
||||
@@ -80,7 +81,7 @@ class TestSnapmakerPost(PathTestUtils.PathTestBase):
|
||||
;Header Start
|
||||
;header_type: cnc
|
||||
;machine: Snapmaker 2 A350 50W CNC module
|
||||
;Post Processor: snapmaker_post
|
||||
;Post Processor: snapmaker_legacy_post
|
||||
;CAM File: boxtest.fcstd
|
||||
;Output Time: \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{0,6}
|
||||
;thumbnail: deactivated."""
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
from os import linesep, path, remove
|
||||
from os import linesep, path
|
||||
import re
|
||||
import tempfile
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
@@ -1302,15 +1303,13 @@ G0 Z8.000
|
||||
|
||||
self.job.PostProcessorArgs = "--output_all_arguments"
|
||||
gcode = self.post.export()[0][1]
|
||||
# Strip ANSI color codes from output
|
||||
gcode = re.sub(r"\x1b\[[0-9;]*m", "", gcode)
|
||||
# print(f"--------{nl}{gcode}--------{nl}")
|
||||
# The argparse help routine turns out to be sensitive to the
|
||||
# number of columns in the terminal window that the tests
|
||||
# are run from. This affects the indenting in the output.
|
||||
# The next couple of lines remove all of the white space.
|
||||
# Also strip ANSI color codes that may be present
|
||||
import re
|
||||
|
||||
gcode = re.sub(r"\x1b\[[0-9;]*m", "", gcode) # Remove ANSI color codes
|
||||
gcode = "".join(gcode.split())
|
||||
expected = "".join(expected.split())
|
||||
self.assertEqual(gcode, expected)
|
||||
|
||||
+38
-29
@@ -303,8 +303,10 @@ SET(PathPythonToolsShapeUi_SRCS
|
||||
SET(PathPythonPost_SRCS
|
||||
Path/Post/__init__.py
|
||||
Path/Post/Command.py
|
||||
Path/Post/GcodeProcessingUtils.py
|
||||
Path/Post/PostList.py
|
||||
Path/Post/Processor.py
|
||||
Path/Post/DrillCycleExpander.py
|
||||
Path/Post/Utils.py
|
||||
Path/Post/UtilsArguments.py
|
||||
Path/Post/UtilsExport.py
|
||||
@@ -313,42 +315,35 @@ SET(PathPythonPost_SRCS
|
||||
|
||||
SET(PathPythonPostScripts_SRCS
|
||||
Path/Post/scripts/__init__.py
|
||||
Path/Post/scripts/centroid_post.py
|
||||
Path/Post/scripts/centroid_legacy_post.py
|
||||
Path/Post/scripts/dxf_post.py
|
||||
Path/Post/scripts/dynapath_post.py
|
||||
Path/Post/scripts/dynapath_4060_post.py
|
||||
Path/Post/scripts/estlcam_post.py
|
||||
Path/Post/scripts/fablin_post.py
|
||||
Path/Post/scripts/fanuc_post.py
|
||||
Path/Post/scripts/fangling_post.py
|
||||
Path/Post/scripts/dynapath_legacy_post.py
|
||||
Path/Post/scripts/dynapath_4060_legacy_post.py
|
||||
Path/Post/scripts/estlcam_legacy_post.py
|
||||
Path/Post/scripts/fanuc_legacy_post.py
|
||||
Path/Post/scripts/fangling_legacy_post.py
|
||||
Path/Post/scripts/gcode_pre.py
|
||||
Path/Post/scripts/generic_post.py
|
||||
Path/Post/scripts/grbl_legacy_post.py
|
||||
Path/Post/scripts/heidenhain_post.py
|
||||
Path/Post/scripts/jtech_post.py
|
||||
Path/Post/scripts/KineticNCBeamicon2_post.py
|
||||
Path/Post/scripts/generic_plasma_post.py
|
||||
Path/Post/scripts/heidenhain_legacy_post.py
|
||||
Path/Post/scripts/linuxcnc_post.py
|
||||
Path/Post/scripts/linuxcnc_legacy_post.py
|
||||
Path/Post/scripts/mach3_mach4_post.py
|
||||
Path/Post/scripts/jtech_legacy_post.py
|
||||
Path/Post/scripts/mach3_mach4_legacy_post.py
|
||||
Path/Post/scripts/masso_g3_post.py
|
||||
Path/Post/scripts/marlin_post.py
|
||||
Path/Post/scripts/nccad_post.py
|
||||
Path/Post/scripts/marlin_legacy_post.py
|
||||
Path/Post/scripts/nccad_legacy_post.py
|
||||
Path/Post/scripts/opensbp_legacy_post.py
|
||||
Path/Post/scripts/opensbp_post.py
|
||||
Path/Post/scripts/opensbp_pre.py
|
||||
Path/Post/scripts/philips_post.py
|
||||
Path/Post/scripts/grbl_post.py
|
||||
Path/Post/scripts/grbl_legacy_post.py
|
||||
Path/Post/scripts/philips_legacy_post.py
|
||||
Path/Post/scripts/test_post.py
|
||||
Path/Post/scripts/rml_post.py
|
||||
Path/Post/scripts/rrf_post.py
|
||||
Path/Post/scripts/rml_legacy_post.py
|
||||
Path/Post/scripts/rrf_legacy_post.py
|
||||
Path/Post/scripts/slic3r_pre.py
|
||||
Path/Post/scripts/smoothie_post.py
|
||||
Path/Post/scripts/snapmaker_post.py
|
||||
Path/Post/scripts/snapmaker_legacy_post.py
|
||||
Path/Post/scripts/svg_post.py
|
||||
Path/Post/scripts/uccnc_post.py
|
||||
Path/Post/scripts/wedm_post.py
|
||||
Path/Post/scripts/wedm_legacy_post.py
|
||||
)
|
||||
|
||||
SET(PathPythonOp_SRCS
|
||||
@@ -514,16 +509,13 @@ SET(Tests_SRCS
|
||||
CAMTests/test_geomop.fcstd
|
||||
CAMTests/test_holes00.fcstd
|
||||
CAMTests/TestCAMSanity.py
|
||||
CAMTests/TestCentroidPost.py
|
||||
CAMTests/TestCentroidLegacyPost.py
|
||||
CAMTests/TestFanucPost.py
|
||||
CAMTests/TestGenericPost.py
|
||||
CAMTests/TestGrblPost.py
|
||||
CAMTests/TestGrblLegacyPost.py
|
||||
CAMTests/TestGenericPlasma.py
|
||||
CAMTests/TestLinuxCNCPost.py
|
||||
CAMTests/TestLinkingGenerator.py
|
||||
CAMTests/TestMachine.py
|
||||
CAMTests/TestMach3Mach4Post.py
|
||||
CAMTests/TestMach3Mach4LegacyPost.py
|
||||
CAMTests/TestMassoG3Post.py
|
||||
CAMTests/TestPathAdaptive.py
|
||||
@@ -534,6 +526,7 @@ SET(Tests_SRCS
|
||||
CAMTests/TestPathDressupDogboneII.py
|
||||
CAMTests/TestPathDressupHoldingTags.py
|
||||
CAMTests/TestPathDrillGenerator.py
|
||||
CAMTests/TestDrillCycleExpander.py
|
||||
CAMTests/TestPathDrillable.py
|
||||
CAMTests/TestPathFacingGenerator.py
|
||||
CAMTests/TestPathGeneratorDogboneII.py
|
||||
@@ -545,7 +538,9 @@ SET(Tests_SRCS
|
||||
CAMTests/TestPathLog.py
|
||||
CAMTests/TestPathOpDeburr.py
|
||||
CAMTests/TestPathOpUtil.py
|
||||
CAMTests/TestPathPost.py
|
||||
CAMTests/TestPostCore.py
|
||||
CAMTests/TestPostProcessor.py
|
||||
CAMTests/TestPostOutput.py
|
||||
CAMTests/TestPathPreferences.py
|
||||
CAMTests/TestPathProfile.py
|
||||
CAMTests/TestPathPropertyBag.py
|
||||
@@ -578,13 +573,14 @@ SET(Tests_SRCS
|
||||
CAMTests/TestPathVcarve.py
|
||||
CAMTests/TestPathVoronoi.py
|
||||
CAMTests/TestGrblLegacyPost.py
|
||||
CAMTests/TestLinuxCNCLegacyPost.py
|
||||
CAMTests/TestDressupPost.py
|
||||
CAMTests/TestTestPost.py
|
||||
CAMTests/TestPostGCodes.py
|
||||
CAMTests/TestPostMCodes.py
|
||||
CAMTests/TestSnapmakerPost.py
|
||||
CAMTests/TestPostToolProcessing.py
|
||||
CAMTests/TestTSPSolver.py
|
||||
CAMTests/TestGcodeProcessingUtils.py
|
||||
CAMTests/Tools/Bit/test-path-tool-bit-bit-00.fctb
|
||||
CAMTests/Tools/Library/test-path-tool-bit-library-00.fctl
|
||||
CAMTests/Tools/Shape/test-path-tool-bit-shape-00.fcstd
|
||||
@@ -682,6 +678,11 @@ ADD_CUSTOM_TARGET(Tests ALL
|
||||
fc_copy_sources(PathScripts "${CMAKE_BINARY_DIR}/Mod/CAM" ${all_files})
|
||||
fc_copy_sources(Tests "${CMAKE_BINARY_DIR}/Mod/CAM" ${test_files})
|
||||
|
||||
# Copy machine templates to build directory
|
||||
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/Machine/machines/
|
||||
DESTINATION ${CMAKE_BINARY_DIR}/Mod/CAM/Machine/machines
|
||||
FILES_MATCHING PATTERN "*.fcm")
|
||||
|
||||
INSTALL(
|
||||
FILES
|
||||
${PathScripts_SRCS}
|
||||
@@ -1015,3 +1016,11 @@ INSTALL(
|
||||
DESTINATION
|
||||
Mod/CAM/Data/Threads
|
||||
)
|
||||
|
||||
INSTALL(
|
||||
DIRECTORY
|
||||
Machine/machines/
|
||||
DESTINATION
|
||||
Mod/CAM/Machine/machines
|
||||
FILES_MATCHING PATTERN "*.fcm"
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ void DlgSettingsPathColor::saveSettings()
|
||||
ui->DefaultSelectionStyle->onSave();
|
||||
ui->DefaultTaskPanelLayout->onSave();
|
||||
ui->HideFirstRapid->onSave();
|
||||
ui->PostProcessorShowEditor->onSave();
|
||||
}
|
||||
|
||||
void DlgSettingsPathColor::loadSettings()
|
||||
@@ -80,6 +81,7 @@ void DlgSettingsPathColor::loadSettings()
|
||||
ui->DefaultSelectionStyle->onRestore();
|
||||
ui->DefaultTaskPanelLayout->onRestore();
|
||||
ui->HideFirstRapid->onRestore();
|
||||
ui->PostProcessorShowEditor->onRestore();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -398,23 +398,13 @@
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_13">
|
||||
<property name="text">
|
||||
<string>Hide first rapid move</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<item row="2" column="0" colspan="2">
|
||||
<widget class="Gui::PrefCheckBox" name="HideFirstRapid">
|
||||
<property name="toolTip">
|
||||
<string>Hide the initial rapid move in path visualization by setting the start index to the first feed move</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>false</bool>
|
||||
<string>Hide first rapid move</string>
|
||||
</property>
|
||||
<property name="prefEntry" stdset="0">
|
||||
<cstring>HideFirstRapid</cstring>
|
||||
@@ -424,6 +414,22 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="2">
|
||||
<widget class="Gui::PrefCheckBox" name="PostProcessorShowEditor">
|
||||
<property name="toolTip">
|
||||
<string>Pop up the G-code editor for review and editing before writing the output file</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Show editor before writing G-code</string>
|
||||
</property>
|
||||
<property name="prefEntry" stdset="0">
|
||||
<cstring>PostProcessorShowEditor</cstring>
|
||||
</property>
|
||||
<property name="prefPath" stdset="0">
|
||||
<cstring>Mod/CAM</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -1575,12 +1575,35 @@ class MachineFactory:
|
||||
# Default configuration directory
|
||||
_config_dir = None
|
||||
|
||||
# Callback registry for configuration changes
|
||||
_callbacks = []
|
||||
|
||||
@classmethod
|
||||
def set_config_directory(cls, directory):
|
||||
"""Set the directory for storing machine configuration files"""
|
||||
cls._config_dir = pathlib.Path(directory)
|
||||
cls._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def register_callback(cls, callback):
|
||||
"""Register a callback to be called when machine configurations change"""
|
||||
cls._callbacks.append(callback)
|
||||
|
||||
@classmethod
|
||||
def unregister_callback(cls, callback):
|
||||
"""Unregister a callback"""
|
||||
if callback in cls._callbacks:
|
||||
cls._callbacks.remove(callback)
|
||||
|
||||
@classmethod
|
||||
def _notify_callbacks(cls, event_type, machine_name=None):
|
||||
"""Notify all registered callbacks of a configuration change"""
|
||||
for callback in cls._callbacks:
|
||||
try:
|
||||
callback(event_type, machine_name)
|
||||
except Exception as e:
|
||||
Path.Log.error(f"Error in machine factory callback: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_config_directory(cls):
|
||||
"""Get the configuration directory, creating default if not set"""
|
||||
@@ -1825,6 +1848,9 @@ class MachineFactory:
|
||||
FileNotFoundError: If no machine with that name is found
|
||||
ValueError: If the loaded data is not a valid machine configuration
|
||||
"""
|
||||
if not machine_name:
|
||||
return None
|
||||
|
||||
# Get list of available machine files
|
||||
machine_files = cls.list_configuration_files()
|
||||
|
||||
|
||||
@@ -1973,138 +1973,18 @@ class MachineEditorDialog(QtGui.QDialog):
|
||||
)
|
||||
self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip)
|
||||
|
||||
# Populate postprocessor list - only show new-style post processors with property schema support
|
||||
Path.Log.info("Machine Editor: Starting postprocessor filtering...")
|
||||
|
||||
# Clear any existing items first
|
||||
# Populate postprocessor list - only show new-style machine post processors
|
||||
self.post_processor_combo.clear()
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: Cleared combo box (now has {self.post_processor_combo.count()} items)"
|
||||
)
|
||||
|
||||
postProcessors = Path.Preferences.allEnabledPostProcessors([""])
|
||||
found_generic = False
|
||||
total_postprocessors = len(postProcessors)
|
||||
new_style_count = 0
|
||||
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: Filtering {total_postprocessors} postprocessors for new-style architecture"
|
||||
)
|
||||
Path.Log.debug(f"Machine Editor: Postprocessors found: {postProcessors}")
|
||||
|
||||
postProcessors = Path.Preferences.allEnabledMachinePostProcessors([""])
|
||||
for post in postProcessors:
|
||||
Path.Log.debug(f"Machine Editor: Processing postprocessor: {post}")
|
||||
if post == "generic_plasma":
|
||||
Path.Log.debug("Machine Editor: *** Processing generic_plasma specifically ***")
|
||||
# Check if this is a new-style post processor by testing for property schema support
|
||||
try:
|
||||
processor = PostProcessorFactory.get_post_processor(None, post)
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: Loaded processor for {post}: {processor.__class__.__name__ if processor else 'None'}"
|
||||
)
|
||||
|
||||
if not processor:
|
||||
Path.Log.debug(f"Machine Editor: Skipped {post} - could not load")
|
||||
continue
|
||||
|
||||
# Skip WrapperPost instances - these are legacy script-based post processors
|
||||
if processor.__class__.__name__ == "WrapperPost":
|
||||
Path.Log.debug(f"Machine Editor: Skipped WrapperPost (legacy script): {post}")
|
||||
continue
|
||||
|
||||
# Check if it's a PostProcessor subclass (works for both instances and uninitialized objects)
|
||||
from Path.Post.Processor import PostProcessor
|
||||
|
||||
processor_class = processor.__class__
|
||||
|
||||
if not issubclass(processor_class, PostProcessor):
|
||||
Path.Log.debug(f"Machine Editor: Skipped non-PostProcessor subclass: {post}")
|
||||
continue
|
||||
|
||||
# Check for schema methods (these are class methods, so check on the class)
|
||||
has_get_property_schema = hasattr(
|
||||
processor_class, "get_property_schema"
|
||||
) and callable(getattr(processor_class, "get_property_schema"))
|
||||
has_get_common_property_schema = hasattr(
|
||||
processor_class, "get_common_property_schema"
|
||||
) and callable(getattr(processor_class, "get_common_property_schema"))
|
||||
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: {post} - has_get_property_schema: {has_get_property_schema}, has_get_common_property_schema: {has_get_common_property_schema}"
|
||||
)
|
||||
|
||||
if has_get_property_schema and has_get_common_property_schema:
|
||||
|
||||
# Test that the methods actually return meaningful schema data
|
||||
try:
|
||||
common_schema = processor_class.get_common_property_schema()
|
||||
specific_schema = processor_class.get_property_schema()
|
||||
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: {post} - common_schema type: {type(common_schema)}, len: {len(common_schema) if hasattr(common_schema, '__len__') else 'N/A'}"
|
||||
)
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: {post} - specific_schema type: {type(specific_schema)}, len: {len(specific_schema) if hasattr(specific_schema, '__len__') else 'N/A'}"
|
||||
)
|
||||
|
||||
# New-style post processors should return non-empty lists with proper structure
|
||||
if (
|
||||
isinstance(common_schema, list)
|
||||
and len(common_schema) > 0
|
||||
and isinstance(specific_schema, list)
|
||||
and all(
|
||||
isinstance(prop, dict) and "name" in prop for prop in common_schema
|
||||
)
|
||||
):
|
||||
# This is a proper new-style post processor
|
||||
self.post_processor_combo.addItem(post)
|
||||
new_style_count += 1
|
||||
Path.Log.debug(f"Machine Editor: Added new-style postprocessor: {post}")
|
||||
if post == "generic":
|
||||
found_generic = True
|
||||
else:
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: Skipped postprocessor with invalid schema: {post}"
|
||||
)
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: {post} - common_schema valid: {isinstance(common_schema, list) and len(common_schema) > 0}"
|
||||
)
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: {post} - specific_schema valid: {isinstance(specific_schema, list)}"
|
||||
)
|
||||
if isinstance(common_schema, list) and len(common_schema) > 0:
|
||||
prop_names = [
|
||||
prop.get("name", "NO_NAME") for prop in common_schema[:3]
|
||||
] # First 3 props
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: {post} - sample prop names: {prop_names}"
|
||||
)
|
||||
except Exception as schema_error:
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: Schema test failed for {post}: {schema_error}"
|
||||
)
|
||||
import traceback
|
||||
|
||||
Path.Log.debug(
|
||||
f"Machine Editor: Schema test traceback for {post}: {traceback.format_exc()}"
|
||||
)
|
||||
else:
|
||||
Path.Log.debug(f"Machine Editor: Skipped legacy postprocessor: {post}")
|
||||
except Exception as e:
|
||||
# Skip post processors that can't be instantiated or don't support new architecture
|
||||
import traceback
|
||||
|
||||
Path.Log.debug(f"Machine Editor: Error loading postprocessor {post}: {e}")
|
||||
Path.Log.debug(f"Machine Editor: Traceback: {traceback.format_exc()}")
|
||||
continue
|
||||
self.post_processor_combo.addItem(post)
|
||||
|
||||
# Ensure generic post processor is always available as fallback
|
||||
if not found_generic:
|
||||
if self.post_processor_combo.findText("generic") < 0:
|
||||
self.post_processor_combo.addItem("generic")
|
||||
Path.Log.debug("Machine Editor: Added generic postprocessor as fallback")
|
||||
|
||||
Path.Log.info(
|
||||
f"Machine Editor: Showing {new_style_count} new-style postprocessors (filtered from {total_postprocessors} total)"
|
||||
f"Machine Editor: Showing {self.post_processor_combo.count()} machine postprocessors"
|
||||
)
|
||||
|
||||
# Connect signals
|
||||
|
||||
@@ -46,6 +46,7 @@ def generate(
|
||||
righthand=True,
|
||||
pitch=None,
|
||||
spindle_speed=None,
|
||||
rigid=False,
|
||||
):
|
||||
"""
|
||||
Generates Gcode for tapping a single hole.
|
||||
@@ -119,6 +120,7 @@ def generate(
|
||||
else:
|
||||
cmd = "G84"
|
||||
|
||||
command = Path.Command(cmd, cmdParams)
|
||||
command.addAnnotations({"rigid": "False"})
|
||||
return [command]
|
||||
finalcmd = Path.Command(cmd, cmdParams)
|
||||
finalcmd.addAnnotations({"rigid": str(rigid)})
|
||||
|
||||
return [finalcmd]
|
||||
|
||||
@@ -196,8 +196,10 @@ class OpPrototype(object):
|
||||
"App::PropertyLinkSubListGlobal": Property,
|
||||
"App::PropertyMap": PropertyMap,
|
||||
"App::PropertyPercent": PropertyPercent,
|
||||
"App::PropertyPlacement": Property,
|
||||
"App::PropertyString": PropertyString,
|
||||
"App::PropertyStringList": Property,
|
||||
"App::PropertyVector": Property,
|
||||
"App::PropertyVectorDistance": Property,
|
||||
"App::PropertyVectorList": Property,
|
||||
"Part::PropertyPartShape": Property,
|
||||
|
||||
@@ -780,7 +780,7 @@ class TaskPanel:
|
||||
self.form.toolControllerList.resizeColumnsToContents()
|
||||
|
||||
currentPostProcessor = self.obj.PostProcessor
|
||||
postProcessors = Path.Preferences.allEnabledPostProcessors(["", currentPostProcessor])
|
||||
postProcessors = Path.Preferences.allEnabledLegacyPostProcessors(["", currentPostProcessor])
|
||||
for post in postProcessors:
|
||||
self.form.postProcessor.addItem(post)
|
||||
# update the enumeration values, just to make sure all selections are valid
|
||||
|
||||
@@ -149,7 +149,7 @@ class JobPreferencesPage:
|
||||
self.form.leDefaultJobTemplate.setText(Path.Preferences.defaultJobTemplate())
|
||||
|
||||
blacklist = Path.Preferences.postProcessorBlacklist()
|
||||
for processor in Path.Preferences.allAvailablePostProcessors():
|
||||
for processor in Path.Preferences.allAvailableLegacyPostProcessors():
|
||||
item = QtGui.QListWidgetItem(processor)
|
||||
if processor in blacklist:
|
||||
item.setCheckState(QtCore.Qt.CheckState.Unchecked)
|
||||
|
||||
@@ -65,6 +65,8 @@ class JobTemplate:
|
||||
Stock = "Stock"
|
||||
# TCs are grouped under Tools in a job, the template refers to them directly though
|
||||
ToolController = "ToolController"
|
||||
PostProcessorPropertyOverrides = "PostPropertyOverrides"
|
||||
Machine = "Machine"
|
||||
Version = "Version"
|
||||
|
||||
|
||||
@@ -210,6 +212,22 @@ class ObjectJob:
|
||||
"WCS",
|
||||
QT_TRANSLATE_NOOP("App::Property", "The Work Coordinate Systems for the Job"),
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"Machine",
|
||||
"Output",
|
||||
QT_TRANSLATE_NOOP("App::Property", "The Machine for the Job"),
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"PostProcessorPropertyOverrides",
|
||||
"Output",
|
||||
QT_TRANSLATE_NOOP(
|
||||
"App::Property",
|
||||
"JSON dict of postprocessor properties that override machine defaults for this job",
|
||||
),
|
||||
)
|
||||
obj.PostProcessorPropertyOverrides = "{}"
|
||||
|
||||
obj.Fixtures = ["G54"]
|
||||
|
||||
@@ -217,7 +235,7 @@ class ObjectJob:
|
||||
setattr(obj, n[0], n[1])
|
||||
|
||||
obj.PostProcessorOutputFile = Path.Preferences.defaultOutputFile()
|
||||
postProcessors = Path.Preferences.allEnabledPostProcessors()
|
||||
postProcessors = Path.Preferences.allEnabledLegacyPostProcessors()
|
||||
# Add empty string as a valid enumeration option
|
||||
if "" not in postProcessors:
|
||||
postProcessors = [""] + postProcessors
|
||||
@@ -229,6 +247,7 @@ class ObjectJob:
|
||||
else:
|
||||
obj.PostProcessor = ""
|
||||
obj.PostProcessorArgs = Path.Preferences.defaultPostProcessorArgs()
|
||||
|
||||
obj.GeometryTolerance = Path.Preferences.defaultGeometryTolerance()
|
||||
|
||||
self.setupOperations(obj)
|
||||
@@ -466,10 +485,42 @@ class ObjectJob:
|
||||
else:
|
||||
ops.Label = label
|
||||
|
||||
def ensureMachineProperty(self, obj):
|
||||
"""Ensure the Machine property exists as a String.
|
||||
Migrates from Enumeration to String if needed (legacy documents)."""
|
||||
if not hasattr(obj, "Machine"):
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"Machine",
|
||||
"Output",
|
||||
QT_TRANSLATE_NOOP("App::Property", "The Machine for the Job"),
|
||||
)
|
||||
elif obj.getTypeIdOfProperty("Machine") == "App::PropertyEnumeration":
|
||||
current_value = getattr(obj, "Machine", "") or ""
|
||||
obj.removeProperty("Machine")
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"Machine",
|
||||
"Output",
|
||||
QT_TRANSLATE_NOOP("App::Property", "The Machine for the Job"),
|
||||
)
|
||||
obj.Machine = current_value
|
||||
|
||||
def onDocumentRestored(self, obj):
|
||||
self.setupBaseModel(obj)
|
||||
self.fixupOperations(obj)
|
||||
self.setupSetupSheet(obj)
|
||||
|
||||
# Update PostProcessor enumeration to legacy-only posts
|
||||
postProcessors = Path.Preferences.allEnabledLegacyPostProcessors()
|
||||
if "" not in postProcessors:
|
||||
postProcessors = [""] + postProcessors
|
||||
obj.PostProcessor = postProcessors
|
||||
|
||||
# Ensure Machine property exists as a String.
|
||||
# Old documents may have it as an Enumeration or not at all.
|
||||
self.ensureMachineProperty(obj)
|
||||
|
||||
self.setupToolTable(obj)
|
||||
self.integrityCheck(obj)
|
||||
|
||||
@@ -524,6 +575,25 @@ class ObjectJob:
|
||||
)
|
||||
obj.setEditorMode("JobType", 2) # Hide
|
||||
|
||||
if not hasattr(obj, "Machine"):
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"Machine",
|
||||
"Output",
|
||||
QT_TRANSLATE_NOOP("App::Property", "The Machine for the Job"),
|
||||
)
|
||||
if not hasattr(obj, "PostProcessorPropertyOverrides"):
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"PostProcessorPropertyOverrides",
|
||||
"Output",
|
||||
QT_TRANSLATE_NOOP(
|
||||
"App::Property",
|
||||
"JSON dict of postprocessor properties that override machine defaults for this job",
|
||||
),
|
||||
)
|
||||
obj.PostProcessorPropertyOverrides = "{}"
|
||||
|
||||
for n in self.propertyEnumerations():
|
||||
setattr(obj, n[0], n[1])
|
||||
|
||||
@@ -574,8 +644,14 @@ class ObjectJob:
|
||||
obj.PostProcessorArgs = attrs.get(JobTemplate.PostProcessorArgs)
|
||||
else:
|
||||
obj.PostProcessorArgs = ""
|
||||
if attrs.get(JobTemplate.PostProcessorPropertyOverrides):
|
||||
obj.PostProcessorPropertyOverrides = json.dumps(
|
||||
attrs[JobTemplate.PostProcessorPropertyOverrides]
|
||||
)
|
||||
if attrs.get(JobTemplate.PostProcessorOutputFile):
|
||||
obj.PostProcessorOutputFile = attrs.get(JobTemplate.PostProcessorOutputFile)
|
||||
if attrs.get(JobTemplate.Machine):
|
||||
obj.Machine = attrs.get(JobTemplate.Machine)
|
||||
if attrs.get(JobTemplate.Description):
|
||||
obj.Description = attrs.get(JobTemplate.Description)
|
||||
|
||||
@@ -619,8 +695,18 @@ class ObjectJob:
|
||||
attrs[JobTemplate.Fixtures] = [{f: True} for f in obj.Fixtures]
|
||||
attrs[JobTemplate.OrderOutputBy] = obj.OrderOutputBy
|
||||
attrs[JobTemplate.SplitOutput] = obj.SplitOutput
|
||||
if (
|
||||
hasattr(obj, "PostProcessorPropertyOverrides")
|
||||
and obj.PostProcessorPropertyOverrides
|
||||
and obj.PostProcessorPropertyOverrides != "{}"
|
||||
):
|
||||
attrs[JobTemplate.PostProcessorPropertyOverrides] = json.loads(
|
||||
obj.PostProcessorPropertyOverrides
|
||||
)
|
||||
if obj.PostProcessorOutputFile:
|
||||
attrs[JobTemplate.PostProcessorOutputFile] = obj.PostProcessorOutputFile
|
||||
if hasattr(obj, "Machine") and obj.Machine:
|
||||
attrs[JobTemplate.Machine] = obj.Machine
|
||||
attrs[JobTemplate.GeometryTolerance] = str(obj.GeometryTolerance.Value)
|
||||
if obj.Description:
|
||||
attrs[JobTemplate.Description] = obj.Description
|
||||
@@ -690,6 +776,27 @@ class ObjectJob:
|
||||
self.obj.Operations.Group = group
|
||||
# op.Path.Center = self.obj.Operations.Path.Center
|
||||
|
||||
def getMachine(self):
|
||||
"""getMachine() ... returns an instantiated Machine object for this job.
|
||||
Returns None if no machine is configured or if the machine cannot be loaded.
|
||||
"""
|
||||
# TODO: Once Machine property is added to Job, use it here
|
||||
# For now, return None since Machine property doesn't exist yet
|
||||
if not hasattr(self.obj, "Machine"):
|
||||
return None
|
||||
|
||||
machine_name = self.obj.Machine
|
||||
if not machine_name:
|
||||
return None
|
||||
|
||||
try:
|
||||
from Machine.models.machine import MachineFactory
|
||||
|
||||
return MachineFactory.get_machine(machine_name)
|
||||
except Exception as e:
|
||||
Path.Log.error(f"Failed to load machine '{machine_name}': {e}")
|
||||
return None
|
||||
|
||||
def nextToolNumber(self):
|
||||
# returns the next available toolnumber in the job
|
||||
group = self.obj.Tools.Group
|
||||
|
||||
@@ -505,6 +505,102 @@ class CAMSanity:
|
||||
Path.Log.debug("get_output_url")
|
||||
|
||||
generator = ReportGenerator.ReportGenerator(self.data, embed_images=True)
|
||||
html = generator.generate_html()
|
||||
generator = None
|
||||
return html
|
||||
return generator.get_output_report()
|
||||
|
||||
def validate_for_postprocessing(self):
|
||||
"""
|
||||
Lightweight validation for post-processing without full report generation.
|
||||
|
||||
Returns:
|
||||
tuple: (has_critical_issues, all_squawks, critical_squawks)
|
||||
"""
|
||||
all_squawks = []
|
||||
|
||||
# Collect squawks from key validation methods
|
||||
all_squawks.extend(self._toolData().get("squawkData", []))
|
||||
|
||||
# Add basic job structure validation
|
||||
job_squawks = self._validate_job_structure()
|
||||
all_squawks.extend(job_squawks)
|
||||
|
||||
# Identify critical squawks that should block post-processing
|
||||
critical_squawks = []
|
||||
for squawk in all_squawks:
|
||||
if squawk["squawkType"] in ("WARNING", "CAUTION"):
|
||||
note = squawk["Note"].lower()
|
||||
# Critical issues for post-processing
|
||||
if any(
|
||||
keyword in note
|
||||
for keyword in [
|
||||
"no feedrate",
|
||||
"no spindlespeed",
|
||||
"no tool controllers",
|
||||
"no operations",
|
||||
"no model",
|
||||
"no base",
|
||||
]
|
||||
):
|
||||
critical_squawks.append(squawk)
|
||||
|
||||
has_critical = len(critical_squawks) > 0
|
||||
return has_critical, all_squawks, critical_squawks
|
||||
|
||||
def _validate_job_structure(self):
|
||||
"""
|
||||
Validate basic job structure for post-processing.
|
||||
|
||||
Returns:
|
||||
list: List of squawk dictionaries for job structure issues
|
||||
"""
|
||||
job_squawks = []
|
||||
|
||||
# Check if job has operations
|
||||
if not hasattr(self.job, "Operations") or not self.job.Operations:
|
||||
job_squawks.append(
|
||||
self.squawk(
|
||||
"CAMSanity",
|
||||
translate("CAM_Sanity", "No operations found in job"),
|
||||
squawkType="WARNING",
|
||||
)
|
||||
)
|
||||
|
||||
# Check if job has model
|
||||
if not hasattr(self.job, "Model") or not self.job.Model:
|
||||
job_squawks.append(
|
||||
self.squawk(
|
||||
"CAMSanity",
|
||||
translate("CAM_Sanity", "No model/base geometry found in job"),
|
||||
squawkType="WARNING",
|
||||
)
|
||||
)
|
||||
|
||||
return job_squawks
|
||||
|
||||
@staticmethod
|
||||
def validate_job_for_postprocessing(job):
|
||||
"""
|
||||
Static convenience method to validate a job for post-processing.
|
||||
|
||||
Args:
|
||||
job: FreeCAD CAM job object
|
||||
|
||||
Returns:
|
||||
tuple: (has_critical_issues, all_squawks, critical_squawks)
|
||||
"""
|
||||
# Create a minimal CAMSanity instance for validation
|
||||
# Use a dummy output file since we won't generate reports
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp_file:
|
||||
dummy_output = tmp_file.name
|
||||
|
||||
try:
|
||||
sanity = CAMSanity(job, dummy_output)
|
||||
return sanity.validate_for_postprocessing()
|
||||
finally:
|
||||
# Clean up the temporary file
|
||||
try:
|
||||
os.unlink(dummy_output)
|
||||
except:
|
||||
pass
|
||||
|
||||
@@ -26,6 +26,7 @@ from PathScripts.PathUtils import waiting_effects
|
||||
from PySide.QtCore import QT_TRANSLATE_NOOP
|
||||
import Path
|
||||
import Path.Base.Util as PathUtil
|
||||
import Path.Geom
|
||||
import PathScripts.PathUtils as PathUtils
|
||||
import math
|
||||
import time
|
||||
@@ -168,6 +169,14 @@ class ObjectOp(object):
|
||||
"App::Property", "Make False, to prevent operation from generating code"
|
||||
),
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyBool",
|
||||
"BlockDelete",
|
||||
"Path",
|
||||
QT_TRANSLATE_NOOP(
|
||||
"App::Property", "Enable post processor to add block delete commands"
|
||||
),
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"Comment",
|
||||
@@ -188,6 +197,12 @@ class ObjectOp(object):
|
||||
)
|
||||
obj.setEditorMode("CycleTime", 1) # read-only
|
||||
|
||||
# Add attachment extension to enable attaching operations to geometry
|
||||
# This allows operations to automatically position/orient based on attached faces
|
||||
# Only add to real objects, not OpPrototypes
|
||||
if hasattr(obj, "hasExtension") and not obj.hasExtension("Part::AttachExtension"):
|
||||
obj.addExtension("Part::AttachExtensionPython")
|
||||
|
||||
features = self.opFeatures(obj)
|
||||
|
||||
if FeatureBaseGeometry & features:
|
||||
@@ -443,6 +458,15 @@ class ObjectOp(object):
|
||||
"Path",
|
||||
QT_TRANSLATE_NOOP("App::Property", "Operations Cycle Time Estimation"),
|
||||
)
|
||||
if not hasattr(obj, "BlockDelete"):
|
||||
obj.addProperty(
|
||||
"App::PropertyBool",
|
||||
"BlockDelete",
|
||||
"Path",
|
||||
QT_TRANSLATE_NOOP(
|
||||
"App::Property", "Enable post processor to add block delete commands"
|
||||
),
|
||||
)
|
||||
|
||||
if FeatureStepDown & features and not hasattr(obj, "StepDown"):
|
||||
obj.addProperty(
|
||||
@@ -803,7 +827,50 @@ class ObjectOp(object):
|
||||
# Let's finish by rapid to clearance...just for safety
|
||||
self.commandlist.append(Path.Command("G0", {"Z": obj.ClearanceHeight.Value}))
|
||||
|
||||
# Add block delete annotations if enabled
|
||||
if obj.BlockDelete:
|
||||
for command in self.commandlist:
|
||||
annotations = command.Annotations
|
||||
annotations["BlockDelete"] = True
|
||||
command.Annotations = annotations
|
||||
|
||||
# Add handling of coolant commands.
|
||||
# if the coolant mode is not None, add the command to turn it on right before the first non-rapid
|
||||
# move in the command list.
|
||||
# Add the command to turn it off right after the last non-rapid move in the command list.
|
||||
if hasattr(obj, "CoolantMode") and obj.CoolantMode != "None":
|
||||
# Find the first and last cutting moves (includes G1, G2, G3, and canned drill cycles)
|
||||
# Use Path.Geom.CmdMove which includes: G1, G2, G3, G73, G81, G82, G83, G85
|
||||
first_feed_index = None
|
||||
last_feed_index = None
|
||||
|
||||
for i, cmd in enumerate(self.commandlist):
|
||||
if cmd.Name in Path.Geom.CmdMove:
|
||||
if first_feed_index is None:
|
||||
first_feed_index = i
|
||||
last_feed_index = i
|
||||
|
||||
# Insert coolant commands if we found cutting moves
|
||||
if first_feed_index is not None:
|
||||
# Insert coolant on command before first cutting move
|
||||
if obj.CoolantMode == "Flood":
|
||||
coolant_on = Path.Command("M8", {})
|
||||
elif obj.CoolantMode == "Mist":
|
||||
coolant_on = Path.Command("M7", {})
|
||||
else:
|
||||
coolant_on = None
|
||||
|
||||
if coolant_on:
|
||||
self.commandlist.insert(first_feed_index, coolant_on)
|
||||
# Adjust last_feed_index since we inserted a command
|
||||
last_feed_index += 1
|
||||
|
||||
# Insert coolant off command after last cutting move
|
||||
coolant_off = Path.Command("M9", {})
|
||||
self.commandlist.insert(last_feed_index + 1, coolant_off)
|
||||
|
||||
path = Path.Path(self.commandlist)
|
||||
|
||||
obj.Path = path
|
||||
obj.CycleTime = getCycleTimeEstimate(obj)
|
||||
self.job.Proxy.getCycleTime()
|
||||
|
||||
@@ -73,14 +73,122 @@ class ViewProvider(object):
|
||||
self.vobj = vobj
|
||||
self.Object = None
|
||||
self.panel = None
|
||||
self._updating_workplane = False # Guard against recursion
|
||||
self._selected = False # Track selection state
|
||||
|
||||
def attach(self, vobj):
|
||||
Path.Log.track()
|
||||
self.vobj = vobj
|
||||
self.Object = vobj.Object
|
||||
self.panel = None
|
||||
|
||||
# Create workplane visualization (coordinate system)
|
||||
from pivy import coin
|
||||
|
||||
self.workplane_switch = coin.SoSwitch()
|
||||
self.workplane_switch.whichChild = coin.SO_SWITCH_NONE # Hidden by default
|
||||
|
||||
# Create coordinate system visualization
|
||||
self.workplane_sep = coin.SoSeparator()
|
||||
self.workplane_transform = coin.SoTransform()
|
||||
|
||||
# Create three axes (X=red, Y=green, Z=blue)
|
||||
axis_length = 50.0 # mm
|
||||
|
||||
# X axis (red)
|
||||
x_sep = coin.SoSeparator()
|
||||
x_mat = coin.SoMaterial()
|
||||
x_mat.diffuseColor = (1, 0, 0)
|
||||
x_coords = coin.SoCoordinate3()
|
||||
x_coords.point.setValues(0, 2, [(0, 0, 0), (axis_length, 0, 0)])
|
||||
x_line = coin.SoLineSet()
|
||||
x_line.numVertices.setValue(2)
|
||||
x_sep.addChild(x_mat)
|
||||
x_sep.addChild(x_coords)
|
||||
x_sep.addChild(x_line)
|
||||
|
||||
# Y axis (green)
|
||||
y_sep = coin.SoSeparator()
|
||||
y_mat = coin.SoMaterial()
|
||||
y_mat.diffuseColor = (0, 1, 0)
|
||||
y_coords = coin.SoCoordinate3()
|
||||
y_coords.point.setValues(0, 2, [(0, 0, 0), (0, axis_length, 0)])
|
||||
y_line = coin.SoLineSet()
|
||||
y_line.numVertices.setValue(2)
|
||||
y_sep.addChild(y_mat)
|
||||
y_sep.addChild(y_coords)
|
||||
y_sep.addChild(y_line)
|
||||
|
||||
# Z axis (blue)
|
||||
z_sep = coin.SoSeparator()
|
||||
z_mat = coin.SoMaterial()
|
||||
z_mat.diffuseColor = (0, 0, 1)
|
||||
z_coords = coin.SoCoordinate3()
|
||||
z_coords.point.setValues(0, 2, [(0, 0, 0), (0, 0, axis_length)])
|
||||
z_line = coin.SoLineSet()
|
||||
z_line.numVertices.setValue(2)
|
||||
z_sep.addChild(z_mat)
|
||||
z_sep.addChild(z_coords)
|
||||
z_sep.addChild(z_line)
|
||||
|
||||
# Assemble the coordinate system
|
||||
self.workplane_sep.addChild(self.workplane_transform)
|
||||
self.workplane_sep.addChild(x_sep)
|
||||
self.workplane_sep.addChild(y_sep)
|
||||
self.workplane_sep.addChild(z_sep)
|
||||
|
||||
self.workplane_switch.addChild(self.workplane_sep)
|
||||
|
||||
# Add to the scene graph via RootNode (not addDisplayMode)
|
||||
vobj.RootNode.addChild(self.workplane_switch)
|
||||
|
||||
# Update the visualization
|
||||
self.updateWorkplaneVisualization()
|
||||
|
||||
return
|
||||
|
||||
def isSelected(self):
|
||||
"""Check if this operation is currently selected."""
|
||||
return getattr(self, "_selected", False)
|
||||
|
||||
def updateWorkplaneVisualization(self):
|
||||
"""Update the workplane coordinate system visualization based on the operation Placement."""
|
||||
# Guard against recursion
|
||||
if getattr(self, "_updating_workplane", False):
|
||||
return
|
||||
|
||||
if not hasattr(self, "workplane_transform"):
|
||||
return
|
||||
|
||||
if not hasattr(self.Object, "Placement"):
|
||||
return
|
||||
|
||||
try:
|
||||
self._updating_workplane = True
|
||||
|
||||
placement = self.Object.Placement
|
||||
if not placement:
|
||||
return
|
||||
|
||||
from pivy import coin
|
||||
|
||||
# The visualization should BE the placement, not be relative to it
|
||||
# Since this is attached to vobj.RootNode, the operation's Placement
|
||||
# already positions the entire scene graph. We just need to show the
|
||||
# coordinate system at the origin with identity rotation.
|
||||
self.workplane_transform.translation.setValue(0, 0, 0)
|
||||
self.workplane_transform.rotation.setValue(0, 0, 0, 1) # Identity quaternion
|
||||
|
||||
# Show/hide based on selection state and rotation
|
||||
# Check if rotation angle is non-zero
|
||||
rot = placement.Rotation
|
||||
if abs(rot.Angle) > 1e-6 and self.isSelected():
|
||||
self.workplane_switch.whichChild = coin.SO_SWITCH_ALL
|
||||
else:
|
||||
self.workplane_switch.whichChild = coin.SO_SWITCH_NONE
|
||||
finally:
|
||||
self._updating_workplane = False
|
||||
|
||||
def deleteObjectsOnReject(self):
|
||||
"""
|
||||
deleteObjectsOnReject() ... return true if all objects should
|
||||
@@ -102,6 +210,10 @@ class ViewProvider(object):
|
||||
if 0 == mode:
|
||||
if vobj is None:
|
||||
vobj = self.vobj
|
||||
# Mark as selected and update workplane visualization
|
||||
self._selected = True
|
||||
self.updateWorkplaneVisualization()
|
||||
|
||||
page = self.getTaskPanelOpPage(vobj.Object)
|
||||
page.setTitle(self.OpName)
|
||||
page.setIcon(self.OpIcon)
|
||||
@@ -134,6 +246,10 @@ class ViewProvider(object):
|
||||
job.ViewObject.Proxy.resetEditVisibility(job)
|
||||
|
||||
def unsetEdit(self, arg1, arg2):
|
||||
# Mark as not selected and hide workplane visualization
|
||||
self._selected = False
|
||||
self.updateWorkplaneVisualization()
|
||||
|
||||
if self.panel:
|
||||
self.panel.reject(False)
|
||||
|
||||
@@ -173,6 +289,10 @@ class ViewProvider(object):
|
||||
"""getSelectionFactory() ... return a factory function that can be used to create the selection observer."""
|
||||
return PathSelection.select(self.OpName)
|
||||
|
||||
def onChanged(self, vobj, prop):
|
||||
"""onChanged(vobj, prop) ... callback when a view property changes."""
|
||||
pass
|
||||
|
||||
def updateData(self, obj, prop):
|
||||
"""updateData(obj, prop) ... callback whenever a property of the receiver's model is assigned.
|
||||
The callback is forwarded to the task panel - in case an editing session is ongoing."""
|
||||
@@ -180,6 +300,10 @@ class ViewProvider(object):
|
||||
if self.panel:
|
||||
self.panel.updateData(obj, prop)
|
||||
|
||||
# Update workplane visualization when Placement property changes
|
||||
if prop == "Placement":
|
||||
self.updateWorkplaneVisualization()
|
||||
|
||||
def onDelete(self, vobj, arg2=None):
|
||||
PathUtil.clearExpressionEngine(vobj.Object)
|
||||
return True
|
||||
@@ -192,9 +316,81 @@ class ViewProvider(object):
|
||||
action.triggered.connect(self._editInContextMenuTriggered)
|
||||
menu.addAction(action)
|
||||
|
||||
# Add "Set Workplane from Face" action
|
||||
action = QtGui.QAction(translate("PathOp", "Set Workplane from Face"), menu)
|
||||
action.triggered.connect(self._setWorkplaneFromFaceTriggered)
|
||||
menu.addAction(action)
|
||||
|
||||
def _editInContextMenuTriggered(self, checked):
|
||||
self.setEdit()
|
||||
|
||||
def _setWorkplaneFromFaceTriggered(self, checked):
|
||||
"""Activate face selection mode to set workplane."""
|
||||
# Store reference to the operation
|
||||
self._workplaneOperation = self.Object
|
||||
|
||||
# Create selection observer
|
||||
class FaceSelectionObserver:
|
||||
def __init__(self, operation, viewprovider):
|
||||
self.operation = operation
|
||||
self.viewprovider = viewprovider
|
||||
self.active = True
|
||||
|
||||
def addSelection(self, doc, obj, sub, pnt):
|
||||
"""Called when user selects something."""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
# Check if it's a face
|
||||
if sub and sub.startswith("Face"):
|
||||
try:
|
||||
# Get the face object
|
||||
selected_obj = FreeCAD.ActiveDocument.getObject(obj)
|
||||
if selected_obj and hasattr(selected_obj, "Shape"):
|
||||
# Get the face
|
||||
face = selected_obj.Shape.getElement(sub)
|
||||
|
||||
# Extract the normal vector
|
||||
# For planar faces, use the surface axis
|
||||
if hasattr(face.Surface, "Axis"):
|
||||
normal = face.Surface.Axis
|
||||
else:
|
||||
# For non-planar faces, use center of mass normal
|
||||
u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2.0
|
||||
v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2.0
|
||||
normal = face.normalAt(u_mid, v_mid)
|
||||
|
||||
# Normalize the vector
|
||||
normal.normalize()
|
||||
|
||||
# Use attachment engine to set operation placement
|
||||
# AttachmentSupport: tuple of (object, subname)
|
||||
# MapMode: "FlatFace" aligns Z-axis with face normal
|
||||
self.operation.AttachmentSupport = (obj, (sub,))
|
||||
self.operation.MapMode = "FlatFace"
|
||||
FreeCAD.ActiveDocument.recompute()
|
||||
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Attached {self.operation.Label} to {obj.Label}.{sub}\n"
|
||||
)
|
||||
|
||||
# Deactivate and remove observer
|
||||
self.active = False
|
||||
FreeCADGui.Selection.removeObserver(self)
|
||||
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"Error setting workplane: {e}\n")
|
||||
self.active = False
|
||||
FreeCADGui.Selection.removeObserver(self)
|
||||
|
||||
# Create and add the observer
|
||||
observer = FaceSelectionObserver(self._workplaneOperation, self)
|
||||
FreeCADGui.Selection.addObserver(observer)
|
||||
|
||||
# Clear current selection and provide user feedback
|
||||
FreeCADGui.Selection.clearSelection()
|
||||
FreeCAD.Console.PrintMessage(f"Click on a face to set workplane for {self.Object.Label}\n")
|
||||
|
||||
|
||||
class TaskPanelPage(object):
|
||||
"""Base class for all task panel pages."""
|
||||
|
||||
@@ -29,15 +29,16 @@ import FreeCAD
|
||||
import FreeCADGui
|
||||
import Path
|
||||
from PathScripts import PathUtils
|
||||
from Path.Post.Utils import FilenameGenerator
|
||||
from Path.Post.Utils import FilenameGenerator, GCodeEditorDialog
|
||||
import os
|
||||
from Path.Post.Processor import PostProcessor, PostProcessorFactory
|
||||
from Machine.models.machine import MachineFactory
|
||||
from PySide import QtCore, QtGui
|
||||
from PySide.QtCore import QT_TRANSLATE_NOOP
|
||||
|
||||
LOG_MODULE = Path.Log.thisModule()
|
||||
|
||||
DEBUG = False
|
||||
DEBUG = True
|
||||
if DEBUG:
|
||||
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
|
||||
Path.Log.trackModule(Path.Log.thisModule())
|
||||
@@ -71,7 +72,7 @@ class DlgSelectPostProcessor:
|
||||
def __init__(self):
|
||||
self.dialog = FreeCADGui.PySideUic.loadUi(":/panels/DlgSelectPostProcessor.ui")
|
||||
firstItem = None
|
||||
for post in Path.Preferences.allEnabledPostProcessors():
|
||||
for post in Path.Preferences.allEnabledLegacyPostProcessors():
|
||||
item = QtGui.QListWidgetItem(post)
|
||||
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
|
||||
self.dialog.lwPostProcessor.addItem(item)
|
||||
@@ -202,20 +203,53 @@ class CommandPathPost:
|
||||
Path.Log.debug(self.candidate.Name)
|
||||
FreeCAD.ActiveDocument.openTransaction("Post Process the Selected Job")
|
||||
|
||||
postprocessor_name = _resolve_post_processor_name(self.candidate)
|
||||
# Determine if we use new flow (machine-based) or old flow (legacy)
|
||||
# New flow: Job has Machine property -> get postprocessor from machine config -> use export2()
|
||||
# Old flow: Job lacks Machine -> get postprocessor from job property -> use export()
|
||||
use_new_flow = hasattr(self.candidate, "Machine") and self.candidate.Machine
|
||||
|
||||
if use_new_flow:
|
||||
Path.Log.debug("Using new flow (machine-based)")
|
||||
# New flow: Get postprocessor from machine configuration
|
||||
try:
|
||||
machine = MachineFactory.get_machine(self.candidate.Machine)
|
||||
postprocessor_name = machine.postprocessor_file_name
|
||||
|
||||
if not postprocessor_name:
|
||||
FreeCAD.Console.PrintError(
|
||||
f"Machine '{machine.name}' does not specify a postprocessor\n"
|
||||
)
|
||||
FreeCAD.ActiveDocument.abortTransaction()
|
||||
return
|
||||
|
||||
except FileNotFoundError as e:
|
||||
FreeCAD.Console.PrintError(f"Machine configuration error: {e}\n")
|
||||
FreeCAD.ActiveDocument.abortTransaction()
|
||||
return
|
||||
else:
|
||||
Path.Log.debug("Using old flow (legacy)")
|
||||
# Old flow: Get postprocessor from job property
|
||||
postprocessor_name = _resolve_post_processor_name(self.candidate)
|
||||
|
||||
Path.Log.debug(f"Post Processor: {postprocessor_name}")
|
||||
|
||||
if not postprocessor_name:
|
||||
FreeCAD.ActiveDocument.abortTransaction()
|
||||
return
|
||||
|
||||
# get a postprocessor
|
||||
# Get postprocessor (same factory for both flows)
|
||||
postprocessor = PostProcessorFactory.get_post_processor(
|
||||
self.candidate,
|
||||
postprocessor_name,
|
||||
)
|
||||
|
||||
post_data = postprocessor.export()
|
||||
# Call appropriate export method
|
||||
if use_new_flow:
|
||||
# export2() returns [(section_name, gcode), ...]
|
||||
post_data = postprocessor.export2()
|
||||
else:
|
||||
# export() returns [(subpart, gcode), ...]
|
||||
post_data = postprocessor.export()
|
||||
# None is returned if there was an error during argument processing
|
||||
# otherwise the "usual" post_data data structure is returned.
|
||||
if not post_data:
|
||||
@@ -223,7 +257,11 @@ class CommandPathPost:
|
||||
return
|
||||
|
||||
policy = Path.Preferences.defaultOutputPolicy()
|
||||
generator = FilenameGenerator(job=self.candidate)
|
||||
file_ext = postprocessor.get_file_extension() if use_new_flow else None
|
||||
generator = FilenameGenerator(
|
||||
job=self.candidate,
|
||||
file_extension=file_ext,
|
||||
)
|
||||
generated_filename = generator.generate_filenames()
|
||||
|
||||
for item in post_data:
|
||||
@@ -253,8 +291,27 @@ class CommandPathPost:
|
||||
# a file. There may be other uses found for this capability over time.
|
||||
#
|
||||
if gcode is not None:
|
||||
# Show editor if user preference is enabled and GUI is available
|
||||
final_gcode = gcode
|
||||
if FreeCAD.GuiUp and Path.Preferences.showEditorOnPostProcess():
|
||||
if len(gcode) > 100000:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
"Skipping editor since output is greater than 100kb\n"
|
||||
)
|
||||
else:
|
||||
dia = GCodeEditorDialog(gcode, refactored=True)
|
||||
# Enable OK button so user can accept without editing
|
||||
dia.buttons.button(QtGui.QDialogButtonBox.Ok).setDisabled(False)
|
||||
editor_result = dia.exec_()
|
||||
if editor_result == 1: # User clicked OK
|
||||
final_gcode = dia.editor.toPlainText()
|
||||
else:
|
||||
# User cancelled - skip writing this file
|
||||
FreeCAD.Console.PrintMessage(f"Post-processing cancelled for {fname}\n")
|
||||
continue
|
||||
|
||||
# write the results to the file
|
||||
self._write_file(fname, gcode, policy)
|
||||
self._write_file(fname, final_gcode, policy)
|
||||
|
||||
FreeCAD.ActiveDocument.commitTransaction()
|
||||
FreeCAD.ActiveDocument.recompute()
|
||||
@@ -349,8 +406,27 @@ class CommandPathPostSelected(CommandPathPost):
|
||||
fname = next(generated_filename)
|
||||
|
||||
if gcode is not None:
|
||||
# Show editor if user preference is enabled and GUI is available
|
||||
final_gcode = gcode
|
||||
if FreeCAD.GuiUp and Path.Preferences.showEditorOnPostProcess():
|
||||
if len(gcode) > 100000:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
"Skipping editor since output is greater than 100kb\n"
|
||||
)
|
||||
else:
|
||||
dia = GCodeEditorDialog(gcode, refactored=True)
|
||||
# Enable OK button so user can accept without editing
|
||||
dia.buttons.button(QtGui.QDialogButtonBox.Ok).setDisabled(False)
|
||||
editor_result = dia.exec_()
|
||||
if editor_result == 1: # User clicked OK
|
||||
final_gcode = dia.editor.toPlainText()
|
||||
else:
|
||||
# User cancelled - skip writing this file
|
||||
FreeCAD.Console.PrintMessage(f"Post-processing cancelled for {fname}\n")
|
||||
continue
|
||||
|
||||
# write the results to the file
|
||||
self._write_file(fname, gcode, policy)
|
||||
self._write_file(fname, final_gcode, policy)
|
||||
|
||||
FreeCAD.ActiveDocument.commitTransaction()
|
||||
FreeCAD.ActiveDocument.recompute()
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
# SPDX-FileCopyrightText: 2026 sliptonic <[email protected]>
|
||||
# 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 #
|
||||
# #
|
||||
################################################################################
|
||||
"""
|
||||
Standalone drill cycle expander for FreeCAD Path.Command objects.
|
||||
|
||||
This module provides a clean API for expanding canned drill cycles without
|
||||
coupling to the postprocessing infrastructure.
|
||||
"""
|
||||
|
||||
import Path
|
||||
from typing import List, Optional
|
||||
|
||||
EXPANDABLE_DRILL_CYCLES = {"G81", "G82", "G83", "G73"}
|
||||
|
||||
|
||||
class DrillCycleExpander:
|
||||
"""Expands canned drill cycles (Path.Command) into basic G-code movements."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
retract_mode: str = "G98",
|
||||
motion_mode: str = "G90",
|
||||
initial_position: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the expander.
|
||||
|
||||
Args:
|
||||
retract_mode: "G98" (return to initial Z) or "G99" (return to R plane)
|
||||
motion_mode: "G90" (absolute) or "G91" (incremental)
|
||||
initial_position: Initial position dict with X, Y, Z keys
|
||||
"""
|
||||
self.retract_mode = retract_mode
|
||||
self.motion_mode = motion_mode
|
||||
self.current_position = (
|
||||
initial_position if initial_position else {"X": 0.0, "Y": 0.0, "Z": 0.0}
|
||||
)
|
||||
|
||||
def expand_command(self, command: Path.Command) -> List[Path.Command]:
|
||||
"""
|
||||
Expand a single drill cycle command into basic movements.
|
||||
|
||||
Args:
|
||||
command: Path.Command object (e.g., Path.Command("G81", {"X": 10.0, "Y": 10.0, "Z": -5.0, "R": 2.0, "F": 100.0}))
|
||||
|
||||
Returns:
|
||||
List of expanded Path.Command objects
|
||||
"""
|
||||
cmd_name = command.Name.upper()
|
||||
params = command.Parameters
|
||||
|
||||
# Handle modal commands - filter them out after processing
|
||||
if cmd_name == "G98":
|
||||
self.retract_mode = "G98"
|
||||
return [] # Filter out after processing
|
||||
elif cmd_name == "G99":
|
||||
self.retract_mode = "G99"
|
||||
return [] # Filter out after processing
|
||||
elif cmd_name == "G90":
|
||||
self.motion_mode = "G90"
|
||||
return [] # Filter out after processing
|
||||
elif cmd_name == "G91":
|
||||
self.motion_mode = "G91"
|
||||
return [] # Filter out after processing
|
||||
elif cmd_name == "G80":
|
||||
# Cancel drill cycle - filter out since cycles are already expanded
|
||||
return []
|
||||
|
||||
# Handle drill cycles
|
||||
if cmd_name in ("G81", "G82", "G73", "G83"):
|
||||
return self._expand_drill_cycle(command)
|
||||
|
||||
# Update position for non-drill commands
|
||||
if cmd_name in ("G0", "G00", "G1", "G01"):
|
||||
for axis in ("X", "Y", "Z"):
|
||||
if axis in params:
|
||||
if self.motion_mode == "G90":
|
||||
self.current_position[axis] = params[axis]
|
||||
else: # G91
|
||||
self.current_position[axis] += params[axis]
|
||||
|
||||
# Pass through other commands unchanged
|
||||
return [command]
|
||||
|
||||
def _expand_drill_cycle(self, command: Path.Command) -> List[Path.Command]:
|
||||
"""Expand a drill cycle into basic movements."""
|
||||
cmd_name = command.Name.upper()
|
||||
params = command.Parameters
|
||||
|
||||
# Extract parameters
|
||||
drill_x = params.get("X", self.current_position["X"])
|
||||
drill_y = params.get("Y", self.current_position["Y"])
|
||||
drill_z = params["Z"]
|
||||
retract_z = params["R"]
|
||||
feedrate = params.get("F")
|
||||
|
||||
# Store initial Z for G98 mode
|
||||
initial_z = self.current_position["Z"]
|
||||
|
||||
# Determine final retract height
|
||||
if self.retract_mode == "G98":
|
||||
final_retract = max(initial_z, retract_z)
|
||||
else: # G99
|
||||
final_retract = retract_z
|
||||
|
||||
# Error check
|
||||
if retract_z < drill_z:
|
||||
# Return empty list or could raise exception
|
||||
return []
|
||||
|
||||
# Preliminary moves should match the linuxcnc documentation
|
||||
# https://linuxcnc.org/docs/html/gcode/g-code.html#gcode:preliminary-motion
|
||||
|
||||
expanded = []
|
||||
|
||||
# Preliminary motion: If Z < R, move Z to R once (LinuxCNC spec)
|
||||
if self.current_position["Z"] < retract_z:
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": retract_z,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.current_position["Z"] = retract_z
|
||||
|
||||
# Move to XY position at current Z height (which should be R)
|
||||
if drill_x != self.current_position["X"] or drill_y != self.current_position["Y"]:
|
||||
expanded.append(
|
||||
Path.Command("G0", {"X": drill_x, "Y": drill_y, "Z": self.current_position["Z"]})
|
||||
)
|
||||
self.current_position["X"] = drill_x
|
||||
self.current_position["Y"] = drill_y
|
||||
|
||||
# Ensure Z is at R position (should already be there from preliminary motion)
|
||||
if self.current_position["Z"] != retract_z:
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": retract_z,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.current_position["Z"] = retract_z
|
||||
|
||||
# Perform the drilling operation
|
||||
if cmd_name in ("G81", "G82"):
|
||||
expanded.extend(
|
||||
self._expand_g81_g82(cmd_name, params, drill_z, final_retract, feedrate)
|
||||
)
|
||||
elif cmd_name in ("G73", "G83"):
|
||||
expanded.extend(
|
||||
self._expand_g73_g83(cmd_name, params, drill_z, retract_z, final_retract, feedrate)
|
||||
)
|
||||
|
||||
return expanded
|
||||
|
||||
def _expand_g81_g82(
|
||||
self,
|
||||
cmd_name: str,
|
||||
params: dict,
|
||||
drill_z: float,
|
||||
final_retract: float,
|
||||
feedrate: Optional[float],
|
||||
) -> List[Path.Command]:
|
||||
"""Expand G81 (simple drill) or G82 (drill with dwell)."""
|
||||
expanded = []
|
||||
|
||||
# Feed to depth
|
||||
move_params = {
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": drill_z,
|
||||
}
|
||||
if feedrate:
|
||||
move_params["F"] = feedrate
|
||||
expanded.append(Path.Command("G1", move_params))
|
||||
self.current_position["Z"] = drill_z
|
||||
|
||||
# Dwell for G82
|
||||
if cmd_name == "G82" and "P" in params:
|
||||
expanded.append(Path.Command("G4", {"P": params["P"]}))
|
||||
|
||||
# Retract
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": final_retract,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.current_position["Z"] = final_retract
|
||||
|
||||
return expanded
|
||||
|
||||
def _expand_g73_g83(
|
||||
self,
|
||||
cmd_name: str,
|
||||
params: dict,
|
||||
drill_z: float,
|
||||
retract_z: float,
|
||||
final_retract: float,
|
||||
feedrate: Optional[float],
|
||||
) -> List[Path.Command]:
|
||||
"""Expand G73 (chip breaking) or G83 (peck drilling)."""
|
||||
expanded = []
|
||||
|
||||
peck_depth = params.get("Q", abs(drill_z - retract_z))
|
||||
current_depth = retract_z
|
||||
clearance = peck_depth * 0.05 # Small clearance amount
|
||||
|
||||
while current_depth > drill_z:
|
||||
# Calculate next peck depth
|
||||
next_depth = max(current_depth - peck_depth, drill_z)
|
||||
|
||||
# If not first peck, rapid to clearance above previous depth
|
||||
if current_depth != retract_z and cmd_name == "G83":
|
||||
clearance_depth = current_depth + clearance
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": clearance_depth,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Feed to next depth
|
||||
move_params = {
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": next_depth,
|
||||
}
|
||||
if feedrate:
|
||||
move_params["F"] = feedrate
|
||||
expanded.append(Path.Command("G1", move_params))
|
||||
self.current_position["Z"] = next_depth
|
||||
|
||||
# Retract based on cycle type
|
||||
if cmd_name == "G73":
|
||||
if next_depth == drill_z:
|
||||
# Final peck - retract to R
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": retract_z,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Chip breaking - small retract
|
||||
chip_break_height = next_depth + clearance
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": chip_break_height,
|
||||
},
|
||||
)
|
||||
)
|
||||
elif cmd_name == "G83":
|
||||
# Full retract to R plane
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": retract_z,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
current_depth = next_depth
|
||||
|
||||
# Final retract
|
||||
if self.current_position["Z"] != final_retract:
|
||||
expanded.append(
|
||||
Path.Command(
|
||||
"G0",
|
||||
{
|
||||
"X": self.current_position["X"],
|
||||
"Y": self.current_position["Y"],
|
||||
"Z": final_retract,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.current_position["Z"] = final_retract
|
||||
|
||||
return expanded
|
||||
|
||||
def _update_position(self, cmd: Path.Command) -> None:
|
||||
"""
|
||||
Update the current position based on a movement command.
|
||||
|
||||
Args:
|
||||
cmd: The command to update position from
|
||||
"""
|
||||
if "X" in cmd.Parameters:
|
||||
self.current_position["X"] = cmd.Parameters["X"]
|
||||
if "Y" in cmd.Parameters:
|
||||
self.current_position["Y"] = cmd.Parameters["Y"]
|
||||
if "Z" in cmd.Parameters:
|
||||
self.current_position["Z"] = cmd.Parameters["Z"]
|
||||
|
||||
def expand_commands(self, commands: List[Path.Command]) -> List[Path.Command]:
|
||||
"""
|
||||
Expand a list of Path.Command objects.
|
||||
|
||||
Args:
|
||||
commands: List of Path.Command objects
|
||||
|
||||
Returns:
|
||||
List of expanded Path.Command objects
|
||||
"""
|
||||
expanded = []
|
||||
for cmd in commands:
|
||||
expanded.extend(self.expand_command(cmd))
|
||||
return expanded
|
||||
|
||||
def expand_path(self, path: Path.Path) -> Path.Path:
|
||||
"""
|
||||
Expand drill cycles in a Path object.
|
||||
|
||||
Args:
|
||||
path: Path.Path object containing commands
|
||||
|
||||
Returns:
|
||||
New Path.Path object with expanded commands
|
||||
"""
|
||||
expanded_commands = self.expand_commands(path.Commands)
|
||||
return Path.Path(expanded_commands)
|
||||
@@ -0,0 +1,447 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2026 sliptonic <[email protected]> *
|
||||
# * *
|
||||
# * This file is part of the FreeCAD CAx development system. *
|
||||
# * *
|
||||
# * 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 *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
"""
|
||||
Various utilities for handling G-code.
|
||||
These utilities do NOT operate on Path.Command objects. They
|
||||
operate on strings of pre-processed G-code.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
|
||||
class NumberGenerator:
|
||||
"""
|
||||
Generate a sequence of line numbers with configurable formatting.
|
||||
|
||||
Args:
|
||||
template: Format string for the line number (e.g., 'N{:04d}')
|
||||
start: Starting number for the sequence (default: 1)
|
||||
increment: Step size for the sequence (default: 1)
|
||||
"""
|
||||
|
||||
def __init__(self, template: str = "{}", start: int = 1, increment: int = 1):
|
||||
"""Initialize the number generator with template, start, and increment values."""
|
||||
self._template = template
|
||||
self._start = start
|
||||
self._increment = increment
|
||||
self.reset()
|
||||
|
||||
def get(self) -> str:
|
||||
"""Get the next number in the sequence and format it according to the template."""
|
||||
current = self._current
|
||||
self._current += self._increment
|
||||
return self._template.format(current)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the sequence to the starting number."""
|
||||
self._current = self._start
|
||||
|
||||
|
||||
# Insert Line Numbers
|
||||
|
||||
|
||||
def insert_line_numbers(gcode: List[str], start: int = 10, increment: int = 10) -> List[str]:
|
||||
"""Insert line numbers (N-codes) into G-code lines.
|
||||
|
||||
Args:
|
||||
gcode: List of G-code strings
|
||||
start: Starting line number (default: 10)
|
||||
increment: Line number increment (default: 10)
|
||||
|
||||
Returns:
|
||||
List of G-code strings with line numbers inserted
|
||||
"""
|
||||
result = []
|
||||
line_generator = NumberGenerator(template="N{}", start=start, increment=increment)
|
||||
|
||||
for line in gcode:
|
||||
# Skip empty lines and comments
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("("):
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Insert line number at the beginning
|
||||
line_number = line_generator.get()
|
||||
result.append(f"{line_number} {line}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Suppress redundant axes words
|
||||
|
||||
|
||||
def suppress_redundant_axes_words(gcode: List[str]) -> List[str]:
|
||||
"""Suppress redundant axis and feed rate words by tracking current machine state.
|
||||
|
||||
Removes axis words where the value matches the current machine position,
|
||||
and F words where the feed rate matches the current feed rate.
|
||||
|
||||
Args:
|
||||
gcode: List of G-code strings
|
||||
|
||||
Returns:
|
||||
List of G-code strings with redundant words suppressed
|
||||
"""
|
||||
result = []
|
||||
current_pos = {
|
||||
"X": None,
|
||||
"Y": None,
|
||||
"Z": None,
|
||||
"U": None,
|
||||
"V": None,
|
||||
"W": None,
|
||||
"A": None,
|
||||
"B": None,
|
||||
"C": None,
|
||||
}
|
||||
current_feed = None # Track current feed rate
|
||||
|
||||
for line in gcode:
|
||||
stripped = line.strip()
|
||||
|
||||
# Keep comments and empty lines unchanged
|
||||
if not stripped or stripped.startswith("("):
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Check for drill cycle commands - these need ALL parameters, don't suppress
|
||||
# G80, G98, G99 have no parameters but should pass through
|
||||
is_parametric_drill_cycle = any(
|
||||
stripped.startswith(cmd)
|
||||
for cmd in ["G73", "G74", "G81", "G82", "G83", "G84", "G85", "G86", "G87", "G88", "G89"]
|
||||
)
|
||||
is_drill_mode_command = any(stripped.startswith(cmd) for cmd in ["G80", "G98", "G99"])
|
||||
|
||||
if is_parametric_drill_cycle:
|
||||
# Parametric drill cycles need all parameters preserved
|
||||
result.append(line)
|
||||
continue
|
||||
elif is_drill_mode_command:
|
||||
# G80 (cancel), G98 (retract to initial), G99 (retract to R) have no parameters
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Check for blockdelete slash
|
||||
has_blockdelete = line.lstrip().startswith("/")
|
||||
blockdelete_prefix = "/" if has_blockdelete else ""
|
||||
|
||||
# Parse the line for axis and feed movements
|
||||
words = stripped.split()
|
||||
if has_blockdelete and words and words[0].startswith("/"):
|
||||
# Remove the slash from the first word if it's a blockdelete command
|
||||
words[0] = words[0][1:]
|
||||
new_pos = current_pos.copy()
|
||||
new_feed = current_feed
|
||||
filtered_words = []
|
||||
|
||||
# First pass: collect all movements in this command
|
||||
for word in words:
|
||||
axis = word[0] if word else ""
|
||||
if axis in current_pos:
|
||||
try:
|
||||
value = float(word[1:])
|
||||
new_pos[axis] = value
|
||||
except (ValueError, IndexError):
|
||||
# If we can't parse the value, skip updating position
|
||||
pass
|
||||
elif axis == "F":
|
||||
try:
|
||||
value = float(word[1:])
|
||||
new_feed = value
|
||||
except (ValueError, IndexError):
|
||||
# If we can't parse the value, skip updating feed rate
|
||||
pass
|
||||
|
||||
# Second pass: filter out redundant words
|
||||
for word in words:
|
||||
axis = word[0] if word else ""
|
||||
if axis in current_pos:
|
||||
try:
|
||||
value = float(word[1:])
|
||||
# Only include the axis if it differs from current position
|
||||
if current_pos[axis] != value:
|
||||
filtered_words.append(word)
|
||||
except (ValueError, IndexError):
|
||||
# If we can't parse the value, keep the word
|
||||
filtered_words.append(word)
|
||||
elif axis == "F":
|
||||
try:
|
||||
value = float(word[1:])
|
||||
# Only include F if it differs from current feed rate
|
||||
if current_feed != value:
|
||||
filtered_words.append(word)
|
||||
except (ValueError, IndexError):
|
||||
# If we can't parse the value, keep the word
|
||||
filtered_words.append(word)
|
||||
else:
|
||||
# Non-axis, non-feed words are always included
|
||||
filtered_words.append(word)
|
||||
|
||||
# Update current state for next command
|
||||
current_pos = new_pos
|
||||
current_feed = new_feed
|
||||
|
||||
# Join the filtered words back into a line with preserved blockdelete
|
||||
if filtered_words:
|
||||
result.append(f"{blockdelete_prefix}{' '.join(filtered_words)}")
|
||||
else:
|
||||
# If no words left, keep the original line (shouldn't happen for valid G-code)
|
||||
result.append(line)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Filter inefficient moves
|
||||
|
||||
|
||||
def filter_inefficient_moves(gcode: List[str]) -> List[str]:
|
||||
"""Filter out inefficient or redundant moves from G-code.
|
||||
|
||||
Removes unnecessary rapid (G0) moves by collapsing chains that only move
|
||||
along single axes or within linear/rotary groups.
|
||||
|
||||
Args:
|
||||
gcode: List of G-code strings
|
||||
|
||||
Returns:
|
||||
List of G-code strings with inefficient moves filtered out
|
||||
"""
|
||||
AXES = ("X", "Y", "Z", "A", "B", "C")
|
||||
|
||||
SIDE_EFFECT_KEYS = {
|
||||
"tool",
|
||||
"tool_change",
|
||||
"spindle",
|
||||
"spindle_on",
|
||||
"spindle_off",
|
||||
"coolant",
|
||||
"dwell",
|
||||
"feed",
|
||||
"F",
|
||||
"M",
|
||||
}
|
||||
|
||||
def parse_gcode_line(line: str) -> dict:
|
||||
"""Parse a G-code line into command name and parameters."""
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("("):
|
||||
return {"name": "COMMENT", "params": {}, "original": line}
|
||||
|
||||
# Check for blockdelete
|
||||
has_blockdelete = stripped.startswith("/")
|
||||
if has_blockdelete:
|
||||
stripped = stripped[1:]
|
||||
|
||||
words = stripped.split()
|
||||
if not words:
|
||||
return {"name": "EMPTY", "params": {}, "original": line}
|
||||
|
||||
cmd_name = words[0]
|
||||
params = {}
|
||||
|
||||
for word in words[1:]:
|
||||
if len(word) > 1:
|
||||
key = word[0]
|
||||
try:
|
||||
value = float(word[1:])
|
||||
params[key] = value
|
||||
except (ValueError, IndexError):
|
||||
params[word] = None # Non-numeric parameter
|
||||
|
||||
return {
|
||||
"name": cmd_name,
|
||||
"params": params,
|
||||
"original": line,
|
||||
"blockdelete": has_blockdelete,
|
||||
}
|
||||
|
||||
def is_rapid(parsed_cmd: dict) -> bool:
|
||||
"""Check if command is a rapid move (G0)."""
|
||||
return parsed_cmd["name"] in ("G0", "G00")
|
||||
|
||||
def has_side_effects(parsed_cmd: dict) -> bool:
|
||||
"""Check if command has side effects that prevent optimization."""
|
||||
# Check for side effect parameter keys
|
||||
if any(k in parsed_cmd["params"] for k in SIDE_EFFECT_KEYS):
|
||||
return True
|
||||
|
||||
# Check for M-codes and other side effect commands
|
||||
cmd = parsed_cmd["name"]
|
||||
if cmd.startswith("M") or cmd in (
|
||||
"G28",
|
||||
"G30",
|
||||
"G53",
|
||||
"G54",
|
||||
"G55",
|
||||
"G56",
|
||||
"G57",
|
||||
"G58",
|
||||
"G59",
|
||||
"G92",
|
||||
"G10",
|
||||
"T", # Tool change
|
||||
"G73",
|
||||
"G74",
|
||||
"G80",
|
||||
"G81",
|
||||
"G82",
|
||||
"G83",
|
||||
"G84",
|
||||
"G85",
|
||||
"G86",
|
||||
"G87",
|
||||
"G88",
|
||||
"G89", # Drill cycles
|
||||
"G98",
|
||||
"G99",
|
||||
): # Retract modes
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def full_position(parsed_cmd: dict, last_pos: dict) -> dict:
|
||||
"""Compute full position from command and last position."""
|
||||
pos = {}
|
||||
for ax in AXES:
|
||||
if ax in parsed_cmd["params"] and parsed_cmd["params"][ax] is not None:
|
||||
pos[ax] = parsed_cmd["params"][ax]
|
||||
else:
|
||||
pos[ax] = last_pos.get(ax)
|
||||
return pos
|
||||
|
||||
def collapse_rapid_chain(chain):
|
||||
"""
|
||||
Collapse a chain of rapid moves.
|
||||
chain = list of dicts with 'parsed', 'pos', and 'original' keys.
|
||||
"""
|
||||
if not chain:
|
||||
return []
|
||||
|
||||
# Check which axes change across the chain
|
||||
first = chain[0]["pos"]
|
||||
axes_changed = {ax for ax in AXES if any(c["pos"][ax] != first[ax] for c in chain)}
|
||||
|
||||
# If only one axis changes → keep only the last command
|
||||
if len(axes_changed) == 1:
|
||||
return [chain[-1]["original"]]
|
||||
|
||||
# If changes are only within linear or rotary groups → keep only last
|
||||
lin = {"X", "Y", "Z"}
|
||||
rot = {"A", "B", "C"}
|
||||
|
||||
if axes_changed <= lin or axes_changed <= rot:
|
||||
return [chain[-1]["original"]]
|
||||
|
||||
# Mixed changes → can't collapse, keep all
|
||||
return [c["original"] for c in chain]
|
||||
|
||||
# Main optimization logic
|
||||
result = []
|
||||
rapid_chain = []
|
||||
last_full_pos = {ax: None for ax in AXES}
|
||||
|
||||
def flush_chain():
|
||||
nonlocal rapid_chain
|
||||
if rapid_chain:
|
||||
result.extend(collapse_rapid_chain(rapid_chain))
|
||||
rapid_chain = []
|
||||
|
||||
for line in gcode:
|
||||
parsed = parse_gcode_line(line)
|
||||
|
||||
# Skip comments and empty lines
|
||||
if parsed["name"] in ("COMMENT", "EMPTY"):
|
||||
flush_chain() # Flush any pending rapid chain
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Get full position for this command
|
||||
pos = full_position(parsed, last_full_pos)
|
||||
last_full_pos = pos
|
||||
|
||||
# Check if this is a rapid move without side effects
|
||||
if is_rapid(parsed) and not has_side_effects(parsed):
|
||||
rapid_chain.append({"parsed": parsed, "pos": pos, "original": line})
|
||||
else:
|
||||
flush_chain() # Flush any pending rapid chain before adding this command
|
||||
result.append(line)
|
||||
|
||||
# Flush any remaining rapid chain
|
||||
flush_chain()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def deduplicate_repeated_commands(gcode: List[str]) -> List[str]:
|
||||
"""Deduplicate consecutive repeated commands from G-code.
|
||||
|
||||
Removes the command word from consecutive commands of the same type,
|
||||
keeping only the parameters. This is modal G-code behavior.
|
||||
|
||||
Example:
|
||||
G1 X10 Y20
|
||||
G1 X30 Y40 -> X30 Y40 (G1 removed)
|
||||
G1 X50 Y60 -> X50 Y60 (G1 removed)
|
||||
G0 Z5 -> G0 Z5 (different command, kept)
|
||||
|
||||
Args:
|
||||
gcode: List of G-code strings
|
||||
|
||||
Returns:
|
||||
List of G-code strings with modal command words removed
|
||||
"""
|
||||
result = []
|
||||
last_cmd = None
|
||||
|
||||
for line in gcode:
|
||||
stripped = line.strip()
|
||||
|
||||
# Keep comments and empty lines unchanged
|
||||
if not stripped or stripped.startswith("("):
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Extract the primary command (first word)
|
||||
words = stripped.split()
|
||||
if words:
|
||||
cmd = words[0]
|
||||
# Check for blockdelete
|
||||
if cmd.startswith("/"):
|
||||
cmd = cmd[1:]
|
||||
|
||||
if cmd == last_cmd:
|
||||
# Same command - output only parameters (remove command word)
|
||||
params = " ".join(words[1:])
|
||||
if params: # Only if there are parameters
|
||||
result.append(params)
|
||||
else:
|
||||
# Different command - output full line
|
||||
result.append(line)
|
||||
last_cmd = cmd
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
return result
|
||||
@@ -3,6 +3,7 @@
|
||||
import re
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import FreeCAD
|
||||
import Path
|
||||
import Path.Base.Util as PathUtil
|
||||
import Path.Tool.Controller as PathToolController
|
||||
@@ -23,6 +24,16 @@ class _CommandObject:
|
||||
self.Label = "Command"
|
||||
|
||||
|
||||
class _RotationSetupObject:
|
||||
"""Postable for rotation commands to align workpiece/table for 3+2 axis machining."""
|
||||
|
||||
Path = None
|
||||
Name = "Rotation"
|
||||
InList = []
|
||||
Label = "Rotation"
|
||||
Placement = None
|
||||
|
||||
|
||||
def needsTcOp(oldTc: Any, newTc: Any) -> bool:
|
||||
return (
|
||||
oldTc is None
|
||||
@@ -49,18 +60,94 @@ def create_fixture_setup(processor: Any, order: int, fixture: str) -> _FixtureSe
|
||||
return fobj
|
||||
|
||||
|
||||
def create_rotation_setup(processor: Any, placement: Any) -> _RotationSetupObject:
|
||||
"""Create a rotation postable to align machine axes with the operation placement.
|
||||
|
||||
Args:
|
||||
processor: The postprocessor object
|
||||
placement: The target placement (FreeCAD.Placement)
|
||||
|
||||
Returns:
|
||||
_RotationSetupObject with rotation commands, or None if rotation not possible
|
||||
"""
|
||||
robj = _RotationSetupObject()
|
||||
robj.Placement = placement
|
||||
robj.Label = f"Rotate to {placement}"
|
||||
robj.InList.append(processor._job)
|
||||
|
||||
# Check if machine has rotary axes
|
||||
machine = processor._job.Proxy.getMachine() if processor._job else None
|
||||
if not machine or not machine.has_rotary_axes:
|
||||
Path.Log.warning("Rotation required but machine does not have rotary axes")
|
||||
return None
|
||||
|
||||
try:
|
||||
import Path.Base.Generator.rotation as rotation
|
||||
|
||||
# Get rotation limits from machine (default to unlimited if not specified)
|
||||
aMin, aMax = -360, 360
|
||||
cMin, cMax = -360, 360
|
||||
|
||||
if "A" in machine.rotary_axes:
|
||||
aMin = machine.rotary_axes["A"].min_limit
|
||||
aMax = machine.rotary_axes["A"].max_limit
|
||||
if "C" in machine.rotary_axes:
|
||||
cMin = machine.rotary_axes["C"].min_limit
|
||||
cMax = machine.rotary_axes["C"].max_limit
|
||||
|
||||
# Generate rotation commands to align placement with Z
|
||||
# Extract the Z-axis (normal vector) from the operation placement
|
||||
placement_z_axis = placement.Rotation.multVec(FreeCAD.Vector(0, 0, 1))
|
||||
|
||||
# Use compound moves if machine supports it
|
||||
rotation_commands = rotation.generate(
|
||||
placement_z_axis,
|
||||
aMin=aMin,
|
||||
aMax=aMax,
|
||||
cMin=cMin,
|
||||
cMax=cMax,
|
||||
compound=machine.compound_moves,
|
||||
)
|
||||
|
||||
robj.Path = Path.Path(rotation_commands)
|
||||
Path.Log.debug(f"Created rotation setup: {rotation_commands}")
|
||||
return robj
|
||||
|
||||
except ValueError as e:
|
||||
# No valid rotation solution found within machine limits
|
||||
Path.Log.error(f"Cannot find valid rotation for placement within machine limits: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
Path.Log.error(f"Error calculating placement rotation: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def build_postlist_by_fixture(processor: Any, early_tool_prep: bool = False) -> list:
|
||||
Path.Log.debug("Ordering by Fixture")
|
||||
postlist = []
|
||||
wcslist = processor._job.Fixtures
|
||||
currTc = None
|
||||
current_placement = FreeCAD.Placement() # Track current placement (identity)
|
||||
|
||||
for index, f in enumerate(wcslist):
|
||||
sublist = [create_fixture_setup(processor, index, f)]
|
||||
|
||||
for obj in processor._operations:
|
||||
if not PathUtil.activeForOp(obj):
|
||||
continue
|
||||
|
||||
# Check if operation placement requires rotation
|
||||
if hasattr(obj, "Placement") and obj.Placement:
|
||||
if not obj.Placement.Rotation.isSame(current_placement.Rotation, 1e-6):
|
||||
# Placement changed - insert rotation postable
|
||||
rotation_obj = create_rotation_setup(processor, obj.Placement)
|
||||
if rotation_obj:
|
||||
sublist.append(rotation_obj)
|
||||
current_placement = obj.Placement
|
||||
Path.Log.debug(f"Inserted rotation for {obj.Label}")
|
||||
|
||||
tc = PathUtil.toolControllerForOp(obj)
|
||||
if tc is not None and PathUtil.activeForOp(obj):
|
||||
if tc is not None:
|
||||
if needsTcOp(currTc, tc):
|
||||
sublist.append(tc)
|
||||
Path.Log.debug(f"Appending TC: {tc.Name}")
|
||||
@@ -78,6 +165,7 @@ def build_postlist_by_tool(processor: Any, early_tool_prep: bool = False) -> lis
|
||||
wcslist = processor._job.Fixtures
|
||||
toolstring = "None"
|
||||
currTc = None
|
||||
current_placement = FreeCAD.Placement() # Track current placement (identity)
|
||||
|
||||
fixturelist = []
|
||||
for index, f in enumerate(wcslist):
|
||||
@@ -104,11 +192,31 @@ def build_postlist_by_tool(processor: Any, early_tool_prep: bool = False) -> lis
|
||||
tc = PathUtil.toolControllerForOp(obj)
|
||||
|
||||
if tc is None or not needsTcOp(currTc, tc):
|
||||
# Check if operation placement requires rotation before adding to curlist
|
||||
if hasattr(obj, "Placement") and obj.Placement:
|
||||
if not obj.Placement.Rotation.isSame(current_placement.Rotation, 1e-6):
|
||||
# Placement changed - insert rotation postable
|
||||
rotation_obj = create_rotation_setup(processor, obj.Placement)
|
||||
if rotation_obj:
|
||||
curlist.append(rotation_obj)
|
||||
current_placement = obj.Placement
|
||||
Path.Log.debug(f"Inserted rotation for {obj.Label}")
|
||||
curlist.append(obj)
|
||||
else:
|
||||
commitToPostlist()
|
||||
|
||||
sublist = [tc]
|
||||
|
||||
# Check if operation placement requires rotation
|
||||
if hasattr(obj, "Placement") and obj.Placement:
|
||||
if not obj.Placement.Rotation.isSame(current_placement.Rotation, 1e-6):
|
||||
# Placement changed - insert rotation postable
|
||||
rotation_obj = create_rotation_setup(processor, obj.Placement)
|
||||
if rotation_obj:
|
||||
sublist.append(rotation_obj)
|
||||
current_placement = obj.Placement
|
||||
Path.Log.debug(f"Inserted rotation for {obj.Label}")
|
||||
|
||||
curlist = [obj]
|
||||
currTc = tc
|
||||
|
||||
@@ -127,6 +235,7 @@ def build_postlist_by_operation(processor: Any, early_tool_prep: bool = False) -
|
||||
postlist = []
|
||||
wcslist = processor._job.Fixtures
|
||||
currTc = None
|
||||
current_placement = FreeCAD.Placement() # Track current placement (identity)
|
||||
|
||||
for obj in processor._operations:
|
||||
if not PathUtil.activeForOp(obj):
|
||||
@@ -137,6 +246,17 @@ def build_postlist_by_operation(processor: Any, early_tool_prep: bool = False) -
|
||||
|
||||
for index, f in enumerate(wcslist):
|
||||
sublist.append(create_fixture_setup(processor, index, f))
|
||||
|
||||
# Check if operation placement requires rotation
|
||||
if hasattr(obj, "Placement") and obj.Placement:
|
||||
if not obj.Placement.Rotation.isSame(current_placement.Rotation, 1e-6):
|
||||
# Placement changed - insert rotation postable
|
||||
rotation_obj = create_rotation_setup(processor, obj.Placement)
|
||||
if rotation_obj:
|
||||
sublist.append(rotation_obj)
|
||||
current_placement = obj.Placement
|
||||
Path.Log.debug(f"Inserted rotation for {obj.Label}")
|
||||
|
||||
tc = PathUtil.toolControllerForOp(obj)
|
||||
if tc is not None:
|
||||
if processor._job.SplitOutput or needsTcOp(currTc, tc):
|
||||
|
||||
+2059
-10
File diff suppressed because it is too large
Load Diff
@@ -57,8 +57,9 @@ if FreeCAD.GuiUp:
|
||||
|
||||
|
||||
class FilenameGenerator:
|
||||
def __init__(self, job):
|
||||
def __init__(self, job, file_extension=None):
|
||||
self.job = job
|
||||
self._file_extension_override = file_extension
|
||||
self.subpartname = ""
|
||||
self.sequencenumber = 0
|
||||
path, filename, ext = self.get_path_and_filename_default()
|
||||
@@ -70,7 +71,7 @@ class FilenameGenerator:
|
||||
def get_path_and_filename_default(self):
|
||||
outputpath = ""
|
||||
filename = ""
|
||||
ext = ".nc"
|
||||
ext = ""
|
||||
|
||||
validPathSubstitutions = ["D", "d", "M", "j"]
|
||||
validFilenameSubstitutions = ["j", "d", "T", "t", "W", "O", "S"]
|
||||
@@ -99,7 +100,13 @@ class FilenameGenerator:
|
||||
os.getcwd()
|
||||
) ## TODO: This should be avoided as it gives the Freecad executable's path in some systems (e.g. Windows)
|
||||
|
||||
if not ext:
|
||||
if self._file_extension_override:
|
||||
ext = (
|
||||
f".{self._file_extension_override}"
|
||||
if not self._file_extension_override.startswith(".")
|
||||
else self._file_extension_override
|
||||
)
|
||||
elif not ext:
|
||||
ext = ".nc"
|
||||
|
||||
# Check for invalid matches
|
||||
|
||||
@@ -64,7 +64,7 @@ def check_for_an_adaptive_op(
|
||||
if values["OUTPUT_ADAPTIVE"] and adaptiveOp and command in values["RAPID_MOVES"]:
|
||||
if opHorizRapid and opVertRapid:
|
||||
return "G1"
|
||||
command_line.append(f"(Tool Controller Rapid Values are unset)")
|
||||
command_line.append("(Tool Controller Rapid Values are unset)")
|
||||
return ""
|
||||
|
||||
|
||||
@@ -181,7 +181,10 @@ def check_for_tool_change(
|
||||
def create_comment(values: Values, comment_string: str) -> str:
|
||||
"""Create a comment from a string using the correct comment symbol."""
|
||||
if values["COMMENT_SYMBOL"] == "(":
|
||||
return f"({comment_string})"
|
||||
# Sanitize nested parentheses to prevent breaking G-code comment format
|
||||
# Replace ( with [ and ) with ] to preserve readability
|
||||
sanitized = comment_string.replace("(", "[").replace(")", "]")
|
||||
return f"({sanitized})"
|
||||
return values["COMMENT_SYMBOL"] + comment_string
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -22,6 +22,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************/
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Generic_KineticNC.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
# ****************************************************************************
|
||||
# * Modifications by Samuel Mayer ([email protected]) *
|
||||
@@ -42,7 +46,6 @@ import argparse
|
||||
import datetime
|
||||
import shlex
|
||||
from PathScripts import PathUtils
|
||||
import PathScripts.PathUtils as PathUtils
|
||||
from builtins import open as pyopen
|
||||
|
||||
TOOLTIP = """
|
||||
@@ -47,10 +47,40 @@ else:
|
||||
Values = Dict[str, Any]
|
||||
Visible = Dict[str, bool]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Centroid(PostProcessor):
|
||||
"""The Centroid post processor class."""
|
||||
|
||||
@classmethod
|
||||
def get_common_property_schema(cls):
|
||||
"""Override common properties with Centroid-specific defaults."""
|
||||
common_props = super().get_common_property_schema()
|
||||
|
||||
# Override defaults for Centroid
|
||||
for prop in common_props:
|
||||
if prop["name"] == "supports_tool_radius_compensation":
|
||||
prop["default"] = False # Centroid doesn't support G41/G42
|
||||
elif prop["name"] == "preamble":
|
||||
prop["default"] = "G53 G00 G17"
|
||||
elif prop["name"] == "postamble":
|
||||
prop["default"] = "M99"
|
||||
elif prop["name"] == "safetyblock":
|
||||
prop["default"] = "G90 G80 G40 G49"
|
||||
elif prop["name"] == "tool_return":
|
||||
prop["default"] = "M5\nM25\nG49 H0"
|
||||
|
||||
return common_props
|
||||
|
||||
@classmethod
|
||||
def get_property_schema(cls):
|
||||
"""Return schema for Centroid-specific configurable properties."""
|
||||
return [
|
||||
# Centroid doesn't have specific properties beyond common ones
|
||||
# All Centroid-specific configuration is in common properties
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job,
|
||||
@@ -74,6 +104,14 @@ class Centroid(PostProcessor):
|
||||
# Set any values here that need to override the default values set
|
||||
# in the parent routine.
|
||||
#
|
||||
# TODO: Migrate to postprocessor properties system
|
||||
# This postprocessor now supports schema-based configuration via:
|
||||
# - get_common_property_schema() - defines common properties with Centroid defaults
|
||||
# - get_property_schema() - defines Centroid-specific properties (currently none)
|
||||
#
|
||||
# The machine editor can now configure this postprocessor using the new property system.
|
||||
# Future updates should migrate hardcoded values below to use postprocessor_properties.
|
||||
#
|
||||
# Use 4 digits for axis precision by default.
|
||||
#
|
||||
values["AXIS_PRECISION"] = 4
|
||||
|
||||
+4
@@ -25,6 +25,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Generic_Estlcam.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
import FreeCAD
|
||||
from FreeCAD import Units
|
||||
+4
@@ -25,6 +25,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Generic_Fablin.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
import datetime
|
||||
import Path.Post.Utils as PostUtils
|
||||
@@ -0,0 +1,603 @@
|
||||
# -*- 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 #
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
Generic Postprocessor for plasma, laser, and waterjet cutters that require a pierce delay
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
import copy
|
||||
|
||||
from Path.Post.Processor import PostProcessor
|
||||
|
||||
import Constants
|
||||
import Path
|
||||
import FreeCAD
|
||||
|
||||
translate = FreeCAD.Qt.translate
|
||||
|
||||
DEBUG = False
|
||||
if DEBUG:
|
||||
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
|
||||
Path.Log.trackModule(Path.Log.thisModule())
|
||||
else:
|
||||
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
|
||||
|
||||
Path.Log.debug("generic_plasma_post.py module loaded")
|
||||
|
||||
# Define some types that are used throughout this file.
|
||||
Values = Dict[str, Any]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
# Round value to within precision (for the purposes of float comparisons)
|
||||
def CompValue(val):
|
||||
# 5 significant digits should be precise enough (for plasma)
|
||||
return round(val, 5)
|
||||
|
||||
|
||||
class GenericPlasma(PostProcessor):
|
||||
"""
|
||||
The GenericPlasma post processor class.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_common_property_schema(cls):
|
||||
Path.Log.debug("GenericPlasma.get_common_property_schema() called")
|
||||
common_props = copy.deepcopy(super().get_common_property_schema())
|
||||
|
||||
# Override defaults for GenericPlasma
|
||||
for prop in common_props:
|
||||
if prop["name"] == "file_extension":
|
||||
prop["default"] = "nc"
|
||||
elif prop["name"] == "supports_tool_radius_compensation":
|
||||
prop["default"] = True
|
||||
elif prop["name"] == "preamble":
|
||||
prop["default"] = "G17 G54 G40 G49 G80 G90"
|
||||
elif prop["name"] == "postamble":
|
||||
prop["default"] = "M05\nG17 G54 G90 G80 G40\nM2"
|
||||
elif prop["name"] == "safetyblock":
|
||||
prop["default"] = "G40 G49 G80"
|
||||
|
||||
return common_props
|
||||
|
||||
@classmethod
|
||||
def get_property_schema(cls):
|
||||
"""Return schema for plasma-specific configurable properties."""
|
||||
return [
|
||||
{
|
||||
"name": "pierce_delay",
|
||||
"type": "integer",
|
||||
"label": translate("CAM", "Pierce Delay"),
|
||||
"default": 1000,
|
||||
"min": 0,
|
||||
"max": 10000,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Pierce delay in milliseconds to wait after torch ignites (M3) before starting movement",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "cooling_delay",
|
||||
"type": "integer",
|
||||
"label": translate("CAM", "Cooling Delay"),
|
||||
"default": 500,
|
||||
"min": 0,
|
||||
"max": 10000,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Cooling delay in milliseconds to wait after torch extinguishes (M5) before movement",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "marking_delay",
|
||||
"type": "integer",
|
||||
"label": translate("CAM", "Marking Delay"),
|
||||
"default": 100,
|
||||
"min": 0,
|
||||
"max": 10000,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Marking delay in milliseconds to wait after torch ignites (M3) when making a mark",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "torch_zaxis_control",
|
||||
"type": "bool",
|
||||
"label": translate("CAM", "Torch Z-Axis Control"),
|
||||
"default": True,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Torch ignites (M3) on Z- movement and extinguishes (M5) on Z+ movement. "
|
||||
"When disabled, any M3/M5 commands are output as-is.",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "force_rapid_feeds",
|
||||
"type": "bool",
|
||||
"label": translate("CAM", "Force Rapid Feeds"),
|
||||
"default": False,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Force rapid-feed speeds for all feed specified commands. "
|
||||
"Useful for dry runs to verify paths without cutting.",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job,
|
||||
tooltip=translate("CAM", "Generic Plasma post processor"),
|
||||
tooltipargs=[],
|
||||
units="Metric",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
job=job,
|
||||
tooltip=tooltip,
|
||||
tooltipargs=tooltipargs,
|
||||
units=units,
|
||||
)
|
||||
Path.Log.debug("Generic Plasma post processor initialized.")
|
||||
|
||||
# Torch commands
|
||||
self.TorchIgniteCommand = Path.Command("M3")
|
||||
self.TorchExtinguishCommand = Path.Command("M5")
|
||||
|
||||
# State tracking for plasma-specific features
|
||||
self._torch_active = False
|
||||
self._last_z = None # Track last Z position for direction detection
|
||||
|
||||
def _reset_plasma_state(self, item):
|
||||
"""Reset plasma-specific state tracking for each operation."""
|
||||
reset_commands = []
|
||||
clearance_height = self._get_operation_height(item, "ClearanceHeight", 0)
|
||||
|
||||
if self._torch_active is not False:
|
||||
Path.Log.debug("Resetting torch to inactive")
|
||||
self._torch_active = False
|
||||
reset_commands.append(self.TorchExtinguishCommand)
|
||||
|
||||
if (
|
||||
self._last_z is not None
|
||||
and CompValue(clearance_height) != 0
|
||||
and CompValue(self._last_z) != CompValue(clearance_height)
|
||||
):
|
||||
Path.Log.debug("Resetting torch to clearence height")
|
||||
self._last_z = clearance_height
|
||||
move_cmd = Path.Command("G0", {"Z": clearance_height})
|
||||
reset_commands.append(move_cmd)
|
||||
|
||||
return reset_commands
|
||||
|
||||
def _get_property_value(self, name, default):
|
||||
"""Get a property value from machine configuration with fallback to default."""
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
return self._machine.postprocessor_properties.get(name, default)
|
||||
return default
|
||||
|
||||
def init_values(self, values: Values) -> None:
|
||||
"""Initialize values that are used throughout the postprocessor."""
|
||||
#
|
||||
super().init_values(values)
|
||||
#
|
||||
# Set any values here that need to override the default values set
|
||||
# in the parent routine.
|
||||
#
|
||||
values["ENABLE_COOLANT"] = True
|
||||
#
|
||||
# The order of parameters.
|
||||
#
|
||||
# linuxcnc doesn't want K properties on XY plane; Arcs need work.
|
||||
#
|
||||
values["PARAMETER_ORDER"] = [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"I",
|
||||
"J",
|
||||
"F",
|
||||
"S",
|
||||
"T",
|
||||
"Q",
|
||||
"R",
|
||||
"L",
|
||||
"H",
|
||||
"D",
|
||||
"P",
|
||||
]
|
||||
|
||||
values["MACHINE_NAME"] = "GenericPlasma"
|
||||
values["POSTPROCESSOR_FILE_NAME"] = __name__
|
||||
#
|
||||
# Load preamble from machine configuration if available
|
||||
#
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
values["PREAMBLE"] = props.get("preamble", "")
|
||||
else:
|
||||
values["PREAMBLE"] = ""
|
||||
|
||||
def _inject_pierce_delay(self, postables):
|
||||
"""Inject pierce delay after torch ignition command."""
|
||||
pierce_delay_ms = int(self._get_property_value("pierce_delay", 1000))
|
||||
if pierce_delay_ms <= 0:
|
||||
return
|
||||
|
||||
# Marking doesn't pierce through stock (i.e. no delay needed)
|
||||
if self._get_property_value("mark_entry_only", False):
|
||||
return
|
||||
|
||||
# Convert milliseconds to seconds for G4 command
|
||||
pierce_delay_sec = pierce_delay_ms / 1000.0
|
||||
|
||||
for section_name, sublist in postables:
|
||||
for item in sublist:
|
||||
if hasattr(item, "Path") and item.Path:
|
||||
# Reset state for each operation
|
||||
new_commands = self._reset_plasma_state(item)
|
||||
|
||||
for cmd in item.Path.Commands:
|
||||
new_commands.append(cmd)
|
||||
# After torch on commands, inject G4 pause
|
||||
if cmd.Name == self.TorchIgniteCommand.Name:
|
||||
# Create G4 dwell command with P parameter (seconds)
|
||||
pause_cmd = Path.Command("G4", {"P": pierce_delay_sec})
|
||||
new_commands.append(pause_cmd)
|
||||
# Replace Path with modified command list
|
||||
item.Path = Path.Path(new_commands)
|
||||
|
||||
def _inject_cooling_delay(self, postables):
|
||||
"""Inject cooling delay after torch extinguish command."""
|
||||
cooling_delay_ms = int(self._get_property_value("cooling_delay", 500))
|
||||
if cooling_delay_ms <= 0:
|
||||
return
|
||||
|
||||
# Convert milliseconds to seconds for G4 command
|
||||
cooling_delay_sec = cooling_delay_ms / 1000.0
|
||||
|
||||
for section_name, sublist in postables:
|
||||
for item in sublist:
|
||||
if hasattr(item, "Path") and item.Path:
|
||||
# Reset state for each operation
|
||||
new_commands = self._reset_plasma_state(item)
|
||||
|
||||
for cmd in item.Path.Commands:
|
||||
new_commands.append(cmd)
|
||||
# After torch off command, inject G4 pause
|
||||
if cmd.Name == self.TorchExtinguishCommand.Name:
|
||||
# Create G4 dwell command with P parameter (seconds)
|
||||
pause_cmd = Path.Command("G4", {"P": cooling_delay_sec})
|
||||
new_commands.append(pause_cmd)
|
||||
# Replace Path with modified command list
|
||||
item.Path = Path.Path(new_commands)
|
||||
|
||||
def _inject_torch_control(self, postables):
|
||||
"""Handle torch ignition/extinguishment based on Z-axis movement."""
|
||||
if not self._get_property_value("torch_zaxis_control", True):
|
||||
return
|
||||
|
||||
for section_name, sublist in postables:
|
||||
for item in sublist:
|
||||
if hasattr(item, "Path") and item.Path:
|
||||
# Get operation heights from the path object
|
||||
pierce_height = self._get_operation_height(item, "StartDepth", 0)
|
||||
cut_height = self._get_operation_height(item, "FinalDepth", 0)
|
||||
|
||||
# Reset state for each operation
|
||||
new_commands = self._reset_plasma_state(item)
|
||||
|
||||
for cmd in item.Path.Commands:
|
||||
# Only track Z movements for this injection
|
||||
if "Z" not in cmd.Parameters:
|
||||
new_commands.append(cmd)
|
||||
continue
|
||||
|
||||
# Handle torch control based on Z movement
|
||||
# Torch ignites AT pierce_height but is TRIGGERED by a move to cut_height
|
||||
if not self._torch_active and CompValue(cmd.Parameters["Z"]) <= CompValue(
|
||||
cut_height
|
||||
):
|
||||
if self._get_property_value("mark_entry_only", False):
|
||||
new_commands.append(cmd)
|
||||
new_commands.append(self.TorchIgniteCommand)
|
||||
self._torch_active = True
|
||||
continue
|
||||
else:
|
||||
# Move to pierce height first if not already there
|
||||
if self._last_z is None or CompValue(self._last_z) > CompValue(
|
||||
pierce_height
|
||||
):
|
||||
move_cmd = Path.Command("G0", {"Z": pierce_height})
|
||||
new_commands.append(move_cmd)
|
||||
|
||||
# Insert torch ignition command before Z- move
|
||||
new_commands.append(self.TorchIgniteCommand)
|
||||
self._torch_active = True
|
||||
elif self._torch_active and CompValue(cmd.Parameters["Z"]) > CompValue(
|
||||
cut_height
|
||||
):
|
||||
# Insert torch extinguish command before Z+ move
|
||||
new_commands.append(self.TorchExtinguishCommand)
|
||||
self._torch_active = False
|
||||
|
||||
# Update last Z position
|
||||
self._last_z = cmd.Parameters["Z"]
|
||||
|
||||
new_commands.append(cmd)
|
||||
# Replace Path with modified command list
|
||||
item.Path = Path.Path(new_commands)
|
||||
|
||||
def _get_operation_height(self, item, height_type, default):
|
||||
"""Get operation height (StartDepth/FinalDepth) from path object."""
|
||||
try:
|
||||
# Try to get the height from the path object's properties
|
||||
if hasattr(item, height_type):
|
||||
value = getattr(item, height_type)
|
||||
if value is not None:
|
||||
return float(value)
|
||||
elif hasattr(item, "Base") and hasattr(item.Base, height_type):
|
||||
value = getattr(item.Base, height_type)
|
||||
if value is not None:
|
||||
return float(value)
|
||||
else:
|
||||
# Try to get from proxy object
|
||||
proxy = getattr(item, "Proxy", None)
|
||||
if proxy and hasattr(proxy, height_type):
|
||||
value = getattr(proxy, height_type)
|
||||
if value is not None:
|
||||
return float(value)
|
||||
except (AttributeError, TypeError, ValueError) as e:
|
||||
Path.Log.debug(f"GenericPlasma: Could not get {height_type}: {e}")
|
||||
return default
|
||||
|
||||
def _inject_mark_entry_only(self, postables):
|
||||
"""Mark first entry points only (Z- to cut height, torch on, short delay, torch off, Z+ to clearance)."""
|
||||
if not self._get_property_value("mark_entry_only", False):
|
||||
return
|
||||
|
||||
marking_delay_ms = int(self._get_property_value("marking_delay", 100))
|
||||
|
||||
# Convert milliseconds to seconds for G4 command
|
||||
marking_delay_sec = marking_delay_ms / 1000.0
|
||||
|
||||
for section_name, sublist in postables:
|
||||
for item in sublist:
|
||||
if hasattr(item, "Path") and item.Path:
|
||||
# Get operation heights from the path object
|
||||
cut_height = self._get_operation_height(item, "FinalDepth", 0)
|
||||
|
||||
# Reset state for each operation
|
||||
new_commands = self._reset_plasma_state(item)
|
||||
marked = False # True once the first entry has been marked
|
||||
in_cut = False # True while descending/at cut height
|
||||
|
||||
for cmd in item.Path.Commands:
|
||||
# Check if this is a Z move to cut height (first entry)
|
||||
if (
|
||||
not marked
|
||||
and not in_cut
|
||||
and "Z" in cmd.Parameters
|
||||
and self._last_z is not None
|
||||
and CompValue(cmd.Parameters["Z"]) < CompValue(self._last_z)
|
||||
and CompValue(cmd.Parameters["Z"]) <= CompValue(cut_height)
|
||||
):
|
||||
|
||||
# Mark the entry point
|
||||
# 1. Keep move decending to cut height (torch on)
|
||||
new_commands.append(cmd)
|
||||
|
||||
# 2. Very short delay (torch mark)
|
||||
if marking_delay_sec:
|
||||
new_commands.append(Path.Command("G4", {"P": marking_delay_sec}))
|
||||
|
||||
marked = True
|
||||
in_cut = True
|
||||
|
||||
# Skip remaining movement commands [while at cut height] until Z+ (retraction)
|
||||
elif (
|
||||
cmd.Name in Constants.GCODE_MOVE_LINE + Constants.GCODE_MOVE_ARC
|
||||
and "Z" in cmd.Parameters
|
||||
):
|
||||
# Only keep movements that are ascending (retraction)
|
||||
if self._last_z is not None and CompValue(
|
||||
cmd.Parameters["Z"]
|
||||
) > CompValue(self._last_z):
|
||||
# 3. Keep movement ascending from cut height (torch off)
|
||||
new_commands.append(cmd)
|
||||
in_cut = False
|
||||
elif not marked:
|
||||
# Before first mark, allow initial positioning moves
|
||||
new_commands.append(cmd)
|
||||
elif cmd.Name in [
|
||||
self.TorchIgniteCommand.Name,
|
||||
self.TorchExtinguishCommand.Name,
|
||||
]:
|
||||
# Skip torch commands in mark entry mode
|
||||
continue
|
||||
else:
|
||||
# Keep non-movement commands
|
||||
new_commands.append(cmd)
|
||||
|
||||
# Update last Z position
|
||||
if "Z" in cmd.Parameters:
|
||||
self._last_z = cmd.Parameters["Z"]
|
||||
|
||||
# Replace Path with modified command list
|
||||
item.Path = Path.Path(new_commands)
|
||||
|
||||
def _force_rapid_feeds(self, postables):
|
||||
"""Replace all feed rates with rapid speeds for dry runs."""
|
||||
if not self._get_property_value("force_rapid_feeds", False):
|
||||
return
|
||||
|
||||
for section_name, sublist in postables:
|
||||
for item in sublist:
|
||||
if hasattr(item, "Path") and item.Path:
|
||||
new_commands = []
|
||||
for cmd in item.Path.Commands:
|
||||
new_cmd = cmd
|
||||
# Remove F parameter from all movement commands
|
||||
if (
|
||||
cmd.Name in Constants.GCODE_MOVE_LINE + Constants.GCODE_MOVE_ARC
|
||||
and "F" in cmd.Parameters
|
||||
):
|
||||
# Create new command without F parameter
|
||||
new_params = dict(cmd.Parameters)
|
||||
del new_params["F"]
|
||||
new_cmd = Path.Command(cmd.Name, new_params)
|
||||
new_commands.append(new_cmd)
|
||||
# Replace Path with modified command list
|
||||
item.Path = Path.Path(new_commands)
|
||||
|
||||
def pre_processing_dialog(self):
|
||||
"""
|
||||
Show plasma cutting mode dialog to ask user about mark-only operation.
|
||||
|
||||
Returns:
|
||||
bool: True to continue with post-processing, False to cancel
|
||||
"""
|
||||
try:
|
||||
from PySide import QtWidgets
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is None:
|
||||
return True
|
||||
|
||||
# Get current mark_entry_only setting from machine config
|
||||
mark_only = False
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
mark_only = props.get("mark_entry_only", False)
|
||||
|
||||
# Create dialog
|
||||
dialog = QtWidgets.QDialog()
|
||||
dialog.setWindowTitle("Plasma Cutting Mode")
|
||||
dialog.resize(400, 200)
|
||||
layout = QtWidgets.QVBoxLayout(dialog)
|
||||
|
||||
# Add description label
|
||||
label = QtWidgets.QLabel(
|
||||
"Select plasma cutting mode:\n\n"
|
||||
"• Normal: Full cutting with torch control\n"
|
||||
"• Mark Only: Only mark first entry points (for drilling prep)"
|
||||
)
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Add radio buttons for mode selection
|
||||
button_group = QtWidgets.QButtonGroup(dialog)
|
||||
|
||||
normal_radio = QtWidgets.QRadioButton("Normal Cutting")
|
||||
normal_radio.setChecked(not mark_only)
|
||||
button_group.addButton(normal_radio, 0)
|
||||
layout.addWidget(normal_radio)
|
||||
|
||||
mark_radio = QtWidgets.QRadioButton("Mark Entry Points Only")
|
||||
mark_radio.setChecked(mark_only)
|
||||
button_group.addButton(mark_radio, 1)
|
||||
layout.addWidget(mark_radio)
|
||||
|
||||
# Add info text about mark mode
|
||||
info_label = QtWidgets.QLabel(
|
||||
"Mark mode will:\n"
|
||||
"• Mark first entry point with torch\n"
|
||||
"• Skip all cutting movements\n"
|
||||
"• Useful for preparing holes for drilling"
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("color: #666; font-size: 11px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Add OK/Cancel buttons
|
||||
button_box = QtWidgets.QDialogButtonBox(
|
||||
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
|
||||
)
|
||||
button_box.accepted.connect(dialog.accept)
|
||||
button_box.rejected.connect(dialog.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Show dialog and get result
|
||||
result = dialog.exec_()
|
||||
|
||||
if result == QtWidgets.QDialog.Accepted:
|
||||
# Update machine config with user's choice
|
||||
mark_only_selected = mark_radio.isChecked()
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
props["mark_entry_only"] = mark_only_selected
|
||||
mode_text = "Mark Only" if mark_only_selected else "Normal Cutting"
|
||||
Path.Log.info(f"Plasma cutting mode set to: {mode_text}")
|
||||
return True
|
||||
else:
|
||||
Path.Log.info("Plasma cutting mode dialog cancelled")
|
||||
return False
|
||||
|
||||
except ImportError:
|
||||
Path.Log.debug("GUI not available - using machine config for mark_entry_only")
|
||||
return True
|
||||
except Exception as e:
|
||||
Path.Log.error(f"Error showing plasma cutting dialog: {str(e)}")
|
||||
return True
|
||||
|
||||
def _expand_postprocessor_commands(self, postables):
|
||||
"""Apply plasma-specific transformations to postables.
|
||||
|
||||
This hook is called by the parent's export2() between Stage 1 (ordering)
|
||||
and Stage 2 (command expansion), ensuring transformations are applied to
|
||||
the actual postables that get converted to G-code.
|
||||
"""
|
||||
Path.Log.debug("GenericPlasma: Applying plasma-specific transformations")
|
||||
self._inject_mark_entry_only(postables)
|
||||
self._inject_torch_control(postables)
|
||||
self._inject_pierce_delay(postables)
|
||||
self._inject_cooling_delay(postables)
|
||||
self._force_rapid_feeds(postables)
|
||||
|
||||
@property
|
||||
def tooltip(self):
|
||||
tooltip: str = """
|
||||
This is a postprocessor file for the CAM workbench.
|
||||
It is used to take a pseudo-gcode fragment from a CAM object
|
||||
and output 'real' GCode suitable for a plasma cutter.
|
||||
|
||||
Features:
|
||||
- Torch Z-axis control (M3/M5 based on Z movement)
|
||||
- Pierce delay after torch ignition
|
||||
- Cooling delay after torch extinguishment
|
||||
- Mark entry points only mode (via dialog)
|
||||
- Force rapid feeds for dry runs
|
||||
|
||||
The postprocessor will show a dialog to select between
|
||||
normal cutting and mark-only modes.
|
||||
"""
|
||||
return tooltip
|
||||
|
||||
|
||||
# Class aliases for PostProcessorFactory
|
||||
# The factory looks for a class with title-cased postname (e.g., "Generic_Plasma")
|
||||
Generic_Plasma = GenericPlasma
|
||||
Genericplasma = GenericPlasma # Fallback for different title() behavior
|
||||
@@ -39,11 +39,28 @@ else:
|
||||
|
||||
Values = Dict[str, Any]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Generic(PostProcessor):
|
||||
|
||||
@classmethod
|
||||
def get_common_property_schema(cls):
|
||||
"""Return common properties with Generic defaults (uses base defaults)."""
|
||||
# Generic postprocessor uses base defaults without overrides
|
||||
return super().get_common_property_schema()
|
||||
|
||||
@classmethod
|
||||
def get_property_schema(cls):
|
||||
"""Return schema for Generic-specific configurable properties."""
|
||||
return [
|
||||
# Generic doesn't have specific properties beyond common ones
|
||||
# All configuration is handled through common properties with base defaults
|
||||
]
|
||||
|
||||
def __init__(self, job):
|
||||
super().__init__(
|
||||
job,
|
||||
job=job,
|
||||
tooltip=translate("CAM", "Generic post processor"),
|
||||
tooltipargs=[],
|
||||
units="Metric",
|
||||
@@ -54,6 +71,15 @@ class Generic(PostProcessor):
|
||||
"""Initialize values that are used throughout the postprocessor."""
|
||||
#
|
||||
super().init_values(values)
|
||||
#
|
||||
# TODO: Migrate to postprocessor properties system
|
||||
# This postprocessor now supports schema-based configuration via:
|
||||
# - get_common_property_schema() - defines common properties with base defaults
|
||||
# - get_property_schema() - defines Generic-specific properties (currently none)
|
||||
#
|
||||
# The machine editor can now configure this postprocessor using the new property system.
|
||||
# Future updates should migrate hardcoded values below to use postprocessor_properties.
|
||||
#
|
||||
values["POSTPROCESSOR_FILE_NAME"] = __name__
|
||||
values["MACHINE_NAME"] = "Generic"
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Generic_Grbl.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
import FreeCAD
|
||||
from FreeCAD import Units
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Generic_Grbl.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
import argparse
|
||||
|
||||
@@ -50,6 +54,8 @@ Defaults = Dict[str, bool]
|
||||
Values = Dict[str, Any]
|
||||
Visible = Dict[str, bool]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Grbl(PostProcessor):
|
||||
"""The Grbl post processor class."""
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from Path.Post.Processor import PostProcessor
|
||||
@@ -42,11 +43,11 @@ if DEBUG:
|
||||
else:
|
||||
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
|
||||
|
||||
#
|
||||
# Define some types that are used throughout this file.
|
||||
#
|
||||
Values = Dict[str, Any]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Linuxcnc(PostProcessor):
|
||||
"""
|
||||
@@ -56,17 +57,67 @@ class Linuxcnc(PostProcessor):
|
||||
|
||||
This post processor implements the following trajectory control methods:
|
||||
- Exact Path (G61)
|
||||
- Exact Stop (G64)
|
||||
- Blend (G61.1)
|
||||
|
||||
|
||||
- Exact Stop (G61.1)
|
||||
- Blend (G64)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_common_property_schema(cls):
|
||||
"""Override common properties with LinuxCNC-specific defaults."""
|
||||
common_props = super().get_common_property_schema()
|
||||
|
||||
# Override defaults for LinuxCNC
|
||||
for prop in common_props:
|
||||
if prop["name"] == "file_extension":
|
||||
prop["default"] = "ngc"
|
||||
elif prop["name"] == "supports_tool_radius_compensation":
|
||||
prop["default"] = True
|
||||
elif prop["name"] == "preamble":
|
||||
prop["default"] = "G17 G54 G40 G49 G80 G90"
|
||||
elif prop["name"] == "postamble":
|
||||
prop["default"] = "M05\nG17 G54 G90 G80 G40\nM2"
|
||||
elif prop["name"] == "safetyblock":
|
||||
prop["default"] = "G40 G49 G80"
|
||||
|
||||
return common_props
|
||||
|
||||
@classmethod
|
||||
def get_property_schema(cls):
|
||||
"""Return schema for LinuxCNC-specific configurable properties."""
|
||||
return [
|
||||
{
|
||||
"name": "blend_mode",
|
||||
"type": "choice",
|
||||
"label": translate("CAM", "Path Blending Mode"),
|
||||
"default": "BLEND",
|
||||
"choices": ["EXACT_PATH", "EXACT_STOP", "BLEND"],
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Path blending mode: EXACT_PATH (G61) stops at each point, "
|
||||
"EXACT_STOP (G61.1) stops at path ends, BLEND (G64) allows smooth motion",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "blend_tolerance",
|
||||
"type": "float",
|
||||
"label": translate("CAM", "Blend Tolerance"),
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 10.0,
|
||||
"decimals": 4,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Tolerance for BLEND mode (P value): 0 = no tolerance (G64), "
|
||||
">0 = tolerance (G64 P-), in current units",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job,
|
||||
tooltip=translate("CAM", "LinuxCNC post processor"),
|
||||
tooltipargs=["blend-mode", "blend-tolerance"],
|
||||
tooltipargs=[],
|
||||
units="Metric",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -110,89 +161,205 @@ class Linuxcnc(PostProcessor):
|
||||
"D",
|
||||
"P",
|
||||
]
|
||||
#
|
||||
# Used in the argparser code as the "name" of the postprocessor program.
|
||||
#
|
||||
|
||||
values["MACHINE_NAME"] = "LinuxCNC"
|
||||
#
|
||||
# Any commands in this value will be output as the last commands
|
||||
# in the G-code file.
|
||||
#
|
||||
values[
|
||||
"POSTAMBLE"
|
||||
] = """M05
|
||||
G17 G54 G90 G80 G40
|
||||
M2"""
|
||||
values["POSTPROCESSOR_FILE_NAME"] = __name__
|
||||
#
|
||||
# Load preamble from machine configuration if available
|
||||
#
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
values["PREAMBLE"] = props.get("preamble", "")
|
||||
else:
|
||||
values["PREAMBLE"] = ""
|
||||
|
||||
# Path blending mode configuration (LinuxCNC-specific)
|
||||
# Load from machine configuration if available, otherwise use defaults
|
||||
#
|
||||
values["BLEND_MODE"] = "BLEND" # Options: EXACT_PATH, EXACT_STOP, BLEND
|
||||
values["BLEND_TOLERANCE"] = 0.0 # P value for BLEND mode (0 = G64, >0 = G64 P-)
|
||||
#
|
||||
# Any commands in this value will be output after the header and
|
||||
# safety block at the beginning of the G-code file.
|
||||
#
|
||||
values["PREAMBLE"] = """G17 G54 G40 G49 G80 G90 """
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
values["BLEND_MODE"] = props.get("blend_mode", "BLEND")
|
||||
values["BLEND_TOLERANCE"] = props.get("blend_tolerance", 0.0)
|
||||
else:
|
||||
# Fallback to defaults if no machine configuration
|
||||
values["BLEND_MODE"] = "BLEND"
|
||||
values["BLEND_TOLERANCE"] = 0.0
|
||||
|
||||
def init_arguments(self, values, argument_defaults, arguments_visible):
|
||||
"""Initialize command-line arguments, including LinuxCNC-specific options."""
|
||||
parser = super().init_arguments(values, argument_defaults, arguments_visible)
|
||||
# Add blend command to PREAMBLE
|
||||
blend_cmd = self._get_blend_command()
|
||||
if values["PREAMBLE"]:
|
||||
values["PREAMBLE"] += f"\n{blend_cmd}"
|
||||
else:
|
||||
values["PREAMBLE"] = blend_cmd
|
||||
|
||||
# Add LinuxCNC-specific argument group
|
||||
linuxcnc_group = parser.add_argument_group("LinuxCNC-specific arguments")
|
||||
def export2(self):
|
||||
"""Override export2 to inject blend command before parent processing.
|
||||
|
||||
linuxcnc_group.add_argument(
|
||||
"--blend-mode",
|
||||
choices=["EXACT_PATH", "EXACT_STOP", "BLEND"],
|
||||
default="BLEND",
|
||||
help="Path blending mode: EXACT_PATH (G61), EXACT_STOP (G61.1), "
|
||||
"BLEND (G64/G64 P-) (default: BLEND)",
|
||||
)
|
||||
This ensures the blend command is added to the preamble before
|
||||
the parent's export2() reads it from postprocessor_properties.
|
||||
"""
|
||||
# Apply job property overrides FIRST so blend command uses overridden values
|
||||
self._apply_job_property_overrides()
|
||||
|
||||
linuxcnc_group.add_argument(
|
||||
"--blend-tolerance",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="Tolerance for BLEND mode (P value): 0 = no tolerance (G64), "
|
||||
">0 = tolerance (G64 P-), in current units (default: 0.0)",
|
||||
)
|
||||
return parser
|
||||
# Update values dict with overridden blend tolerance
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
self.values["BLEND_TOLERANCE"] = props.get("blend_tolerance", 0.0)
|
||||
self.values["BLEND_MODE"] = props.get("blend_mode", "BLEND")
|
||||
|
||||
def process_arguments(self):
|
||||
"""Process arguments and update values, including blend mode handling."""
|
||||
flag, args = super().process_arguments()
|
||||
|
||||
if flag and args:
|
||||
# Update blend mode values from parsed arguments
|
||||
if hasattr(args, "blend_mode"):
|
||||
self.values["BLEND_MODE"] = args.blend_mode
|
||||
if hasattr(args, "blend_tolerance"):
|
||||
self.values["BLEND_TOLERANCE"] = args.blend_tolerance
|
||||
|
||||
# Update PREAMBLE with blend command
|
||||
# Inject blend command into preamble before parent export2 processes it
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
blend_cmd = self._get_blend_command()
|
||||
self.values["PREAMBLE"] += f"\n{blend_cmd}"
|
||||
props = self._machine.postprocessor_properties
|
||||
current_preamble = props.get("preamble", "")
|
||||
if current_preamble:
|
||||
props["preamble"] = f"{current_preamble}\n{blend_cmd}"
|
||||
else:
|
||||
props["preamble"] = blend_cmd
|
||||
|
||||
return flag, args
|
||||
# Call parent export2 which will now include the blend command in preamble
|
||||
return super().export2()
|
||||
|
||||
def _get_blend_command(self) -> str:
|
||||
"""Generate the path blending G-code command based on current settings."""
|
||||
mode = self.values.get("BLEND_MODE", "BLEND")
|
||||
"""Generate the path blending G-code command based on current settings.
|
||||
|
||||
Reads from postprocessor_properties if available, otherwise falls back to values dict.
|
||||
"""
|
||||
# Try to read from postprocessor_properties first (for export2)
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
mode = props.get("blend_mode", "BLEND")
|
||||
tolerance = props.get("blend_tolerance", 0.0)
|
||||
else:
|
||||
# Fallback to values dict (for legacy export)
|
||||
mode = self.values.get("BLEND_MODE", "BLEND")
|
||||
tolerance = self.values.get("BLEND_TOLERANCE", 0.0)
|
||||
|
||||
if mode == "EXACT_PATH":
|
||||
return "G61"
|
||||
elif mode == "EXACT_STOP":
|
||||
return "G61.1"
|
||||
else: # BLEND
|
||||
tolerance = self.values.get("BLEND_TOLERANCE", 0.0)
|
||||
if tolerance > 0:
|
||||
return f"G64 P{tolerance:.4f}"
|
||||
else:
|
||||
return "G64"
|
||||
|
||||
# tooltipArgs is inherited from base class and automatically includes
|
||||
# all arguments from init_arguments() via parser.format_help()
|
||||
# Property tooltip is inherited from base class
|
||||
|
||||
def _convert_drill_cycle(self, command):
|
||||
"""
|
||||
Convert drill cycle commands to G-code.
|
||||
|
||||
For G84/G74 tapping cycles, check for 'rigid' annotation and convert
|
||||
to G33.1 rigid tapping if present. Otherwise use standard conversion.
|
||||
"""
|
||||
from Path.Post.UtilsParse import format_command_line
|
||||
|
||||
# Check if this is a tapping cycle with rigid annotation
|
||||
if command.Name in ["G84", "G74"]:
|
||||
annotations = command.Annotations
|
||||
is_rigid = annotations.get("rigid", "False") == "True"
|
||||
|
||||
if is_rigid:
|
||||
# Rigid tapping - convert to G33.1
|
||||
params = command.Parameters.copy()
|
||||
|
||||
# Extract pitch from F parameter
|
||||
if "F" not in params:
|
||||
Path.Log.warning(f"Rigid tapping {command.Name} missing F (pitch) parameter")
|
||||
return super()._convert_drill_cycle(command)
|
||||
|
||||
pitch = params["F"]
|
||||
|
||||
# Get unit conversion function
|
||||
def get_value(val):
|
||||
if self._machine and hasattr(self._machine, "output"):
|
||||
from Machine.models.machine import OutputUnits
|
||||
|
||||
if self._machine.output.units == OutputUnits.IMPERIAL:
|
||||
return val / 25.4
|
||||
return val
|
||||
|
||||
pitch = get_value(pitch)
|
||||
|
||||
# Build output commands
|
||||
output = []
|
||||
block_delete = "/" if annotations.get("blockdelete") else ""
|
||||
|
||||
# Initial G33.1 command (in)
|
||||
cmd_line = ["G33.1"]
|
||||
cmd_line.append(f"K{pitch:.4f}")
|
||||
|
||||
if "Z" in params:
|
||||
z_val = get_value(params["Z"])
|
||||
cmd_line.append(f"Z{z_val:.4f}")
|
||||
|
||||
if "X" in params:
|
||||
x_val = get_value(params["X"])
|
||||
cmd_line.append(f"X{x_val:.4f}")
|
||||
|
||||
if "Y" in params:
|
||||
y_val = get_value(params["Y"])
|
||||
cmd_line.append(f"Y{y_val:.4f}")
|
||||
|
||||
output.append(f"{block_delete}{' '.join(cmd_line)}")
|
||||
|
||||
# Handle dwell if P parameter present
|
||||
if "P" in params:
|
||||
output.append(f"{block_delete}M5")
|
||||
output.append(f"{block_delete}G04 P{params['P']:.2f}")
|
||||
|
||||
# Reverse out
|
||||
if command.Name == "G84":
|
||||
# Right-hand tap: reverse spindle (M4), retract, restore (M3)
|
||||
output.append(f"{block_delete}M4")
|
||||
|
||||
# Retract to R height
|
||||
retract_line = ["G33.1", f"K{pitch:.4f}"]
|
||||
if "R" in params:
|
||||
r_val = get_value(params["R"])
|
||||
retract_line.append(f"Z{r_val:.4f}")
|
||||
output.append(f"{block_delete}{' '.join(retract_line)}")
|
||||
|
||||
output.append(f"{block_delete}M3")
|
||||
|
||||
elif command.Name == "G74":
|
||||
# Left-hand tap: forward spindle (M3), retract, restore (M4)
|
||||
output.append(f"{block_delete}M3")
|
||||
|
||||
# Retract to R height
|
||||
retract_line = ["G33.1", f"K{pitch:.4f}"]
|
||||
if "R" in params:
|
||||
r_val = get_value(params["R"])
|
||||
retract_line.append(f"Z{r_val:.4f}")
|
||||
output.append(f"{block_delete}{' '.join(retract_line)}")
|
||||
|
||||
output.append(f"{block_delete}M4")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
# Not rigid tapping or not a tapping cycle - use parent implementation
|
||||
return super()._convert_drill_cycle(command)
|
||||
|
||||
def _convert_modal_command(self, command):
|
||||
"""
|
||||
Convert modal commands to G-code.
|
||||
|
||||
Suppress G80, G98, G99 if they're part of a rigid tapping operation.
|
||||
"""
|
||||
# Check if this is G80/G98/G99 with tapping annotation
|
||||
if command.Name in ["G80", "G98", "G99"]:
|
||||
annotations = command.Annotations
|
||||
# Check if this is part of a tapping operation with rigid annotation
|
||||
if annotations.get("operation") == "tapping":
|
||||
is_rigid = annotations.get("rigid", "False") == "True"
|
||||
if is_rigid:
|
||||
# Suppress these commands for rigid tapping
|
||||
return None
|
||||
|
||||
# Use parent implementation for other modal commands
|
||||
return super()._convert_modal_command(command)
|
||||
|
||||
@property
|
||||
def tooltip(self):
|
||||
@@ -200,5 +367,8 @@ M2"""
|
||||
This is a postprocessor file for the CAM workbench.
|
||||
It is used to take a pseudo-gcode fragment from a CAM object
|
||||
and output 'real' GCode suitable for a linuxcnc 3 axis mill.
|
||||
|
||||
Supports rigid tapping via G33.1 when the 'rigid' annotation is present
|
||||
on G84/G74 tapping cycles.
|
||||
"""
|
||||
return tooltip
|
||||
|
||||
@@ -47,6 +47,8 @@ else:
|
||||
Values = Dict[str, Any]
|
||||
Visible = Dict[str, bool]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Mach3_Mach4(PostProcessor):
|
||||
"""The Mach3_Mach4 post processor class."""
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Masso_G3.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -47,6 +51,8 @@ else:
|
||||
#
|
||||
Values = Dict[str, Any]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Masso_G3(PostProcessor):
|
||||
"""The Masso G3 post processor class."""
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2014 sliptonic <[email protected]> *
|
||||
# * *
|
||||
# * This file is part of the FreeCAD CAx development system. *
|
||||
# * *
|
||||
# * 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. *
|
||||
# * *
|
||||
# * 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 Library General Public *
|
||||
# * License along with FreeCAD; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
import datetime
|
||||
import Path.Post.Utils as PostUtils
|
||||
import PathScripts.PathUtils as PathUtils
|
||||
from builtins import open as pyopen
|
||||
|
||||
|
||||
TOOLTIP = """
|
||||
This is an postprocessor file for the Path workbench. It will output path data
|
||||
in a format suitable for OpenSBP controllers like shopbot. This postprocessor,
|
||||
once placed in the appropriate PathScripts folder, can be used directly from
|
||||
inside FreeCAD, via the GUI importer or via python scripts with:
|
||||
|
||||
import Path
|
||||
Path.write(object,"/path/to/file.ncc","post_opensbp")
|
||||
"""
|
||||
|
||||
"""
|
||||
DONE:
|
||||
uses native commands
|
||||
handles feed and jog moves
|
||||
handles XY, Z, and XYZ feed speeds
|
||||
handles arcs
|
||||
support for inch output
|
||||
ToDo
|
||||
comments may not format correctly
|
||||
drilling. Haven't looked at it.
|
||||
many other things
|
||||
|
||||
"""
|
||||
|
||||
TOOLTIP_ARGS = """
|
||||
Arguments for opensbp:
|
||||
--comments ... insert comments - mostly for debugging
|
||||
--inches ... convert output to inches
|
||||
--no-header ... suppress header output
|
||||
--no-show-editor ... don't show editor, just save result
|
||||
"""
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
OUTPUT_COMMENTS = False
|
||||
OUTPUT_HEADER = True
|
||||
SHOW_EDITOR = True
|
||||
COMMAND_SPACE = ","
|
||||
|
||||
# Preamble text will appear at the beginning of the GCODE output file.
|
||||
PREAMBLE = """"""
|
||||
# Postamble text will appear following the last operation.
|
||||
POSTAMBLE = """"""
|
||||
|
||||
# Pre operation text will be inserted before every operation
|
||||
PRE_OPERATION = """"""
|
||||
|
||||
# Post operation text will be inserted after every operation
|
||||
POST_OPERATION = """"""
|
||||
|
||||
# Tool Change commands will be inserted before a tool change
|
||||
TOOL_CHANGE = """"""
|
||||
|
||||
|
||||
CurrentState = {}
|
||||
|
||||
|
||||
def getMetricValue(val):
|
||||
return val
|
||||
|
||||
|
||||
def getImperialValue(val):
|
||||
return val / 25.4
|
||||
|
||||
|
||||
GetValue = getMetricValue
|
||||
|
||||
|
||||
def export(objectslist, filename, argstring):
|
||||
global OUTPUT_COMMENTS
|
||||
global OUTPUT_HEADER
|
||||
global SHOW_EDITOR
|
||||
global CurrentState
|
||||
global GetValue
|
||||
|
||||
for arg in argstring.split():
|
||||
if arg == "--comments":
|
||||
OUTPUT_COMMENTS = True
|
||||
if arg == "--inches":
|
||||
GetValue = getImperialValue
|
||||
if arg == "--no-header":
|
||||
OUTPUT_HEADER = False
|
||||
if arg == "--no-show-editor":
|
||||
SHOW_EDITOR = False
|
||||
|
||||
for obj in objectslist:
|
||||
if not hasattr(obj, "Path"):
|
||||
s = "the object " + obj.Name
|
||||
s += " is not a path. Please select only path and Compounds."
|
||||
print(s)
|
||||
return
|
||||
|
||||
CurrentState = {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0,
|
||||
"F": 0,
|
||||
"S": 0,
|
||||
"JSXY": 0,
|
||||
"JSZ": 0,
|
||||
"MSXY": 0,
|
||||
"MSZ": 0,
|
||||
}
|
||||
print("postprocessing...")
|
||||
gcode = ""
|
||||
|
||||
# write header
|
||||
if OUTPUT_HEADER:
|
||||
gcode += linenumber() + "'Exported by FreeCAD\n"
|
||||
gcode += linenumber() + "'Post Processor: " + __name__ + "\n"
|
||||
gcode += linenumber() + "'Output Time:" + str(now) + "\n"
|
||||
|
||||
# Write the preamble
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += linenumber() + "'(begin preamble)\n"
|
||||
for line in PREAMBLE.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
for obj in objectslist:
|
||||
|
||||
# do the pre_op
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += linenumber() + "'(begin operation: " + obj.Label + ")\n"
|
||||
for line in PRE_OPERATION.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
gcode += parse(obj)
|
||||
|
||||
# do the post_op
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += linenumber() + "'(finish operation: " + obj.Label + ")\n"
|
||||
for line in POST_OPERATION.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
# do the post_amble
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += "'(begin postamble)\n"
|
||||
for line in POSTAMBLE.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
if SHOW_EDITOR:
|
||||
dia = PostUtils.GCodeEditorDialog()
|
||||
dia.editor.setPlainText(gcode)
|
||||
result = dia.exec_()
|
||||
if result:
|
||||
final = dia.editor.toPlainText()
|
||||
else:
|
||||
final = gcode
|
||||
else:
|
||||
final = gcode
|
||||
|
||||
print("done postprocessing.")
|
||||
|
||||
# Write the output
|
||||
if not filename == "-":
|
||||
gfile = pyopen(filename, "w")
|
||||
gfile.write(final)
|
||||
gfile.close()
|
||||
|
||||
return final
|
||||
|
||||
|
||||
def move(command):
|
||||
txt = ""
|
||||
|
||||
# if 'F' in command.Parameters:
|
||||
# txt += feedrate(command)
|
||||
|
||||
axis = ""
|
||||
for p in ["X", "Y", "Z"]:
|
||||
if p in command.Parameters:
|
||||
if command.Parameters[p] != CurrentState[p]:
|
||||
axis += p
|
||||
|
||||
if "F" in command.Parameters:
|
||||
speed = command.Parameters["F"]
|
||||
if command.Name in ["G1", "G01"]: # move
|
||||
movetype = "MS"
|
||||
else: # jog
|
||||
movetype = "JS"
|
||||
zspeed = ""
|
||||
xyspeed = ""
|
||||
if "Z" in axis:
|
||||
speedKey = "{}Z".format(movetype)
|
||||
speedVal = GetValue(speed)
|
||||
if CurrentState[speedKey] != speedVal:
|
||||
CurrentState[speedKey] = speedVal
|
||||
zspeed = "{:f}".format(speedVal)
|
||||
if ("X" in axis) or ("Y" in axis):
|
||||
speedKey = "{}XY".format(movetype)
|
||||
speedVal = GetValue(speed)
|
||||
if CurrentState[speedKey] != speedVal:
|
||||
CurrentState[speedKey] = speedVal
|
||||
xyspeed = "{:f}".format(speedVal)
|
||||
if zspeed or xyspeed:
|
||||
txt += "{},{},{}\n".format(movetype, xyspeed, zspeed)
|
||||
|
||||
if command.Name in ["G0", "G00"]:
|
||||
pref = "J"
|
||||
else:
|
||||
pref = "M"
|
||||
|
||||
if axis == "X":
|
||||
txt += pref + "X"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "Y":
|
||||
txt += pref + "Y"
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "Z":
|
||||
txt += pref + "Z"
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "XY":
|
||||
txt += pref + "2"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "XZ":
|
||||
txt += pref + "3"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += ","
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "XYZ":
|
||||
txt += pref + "3"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "YZ":
|
||||
txt += pref + "3"
|
||||
txt += ","
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "":
|
||||
print("warning: skipping duplicate move.")
|
||||
else:
|
||||
print(CurrentState)
|
||||
print(command)
|
||||
print("I don't know how to handle '{}' for a move.".format(axis))
|
||||
|
||||
return txt
|
||||
|
||||
|
||||
def arc(command):
|
||||
if command.Name == "G2": # CW
|
||||
dirstring = "1"
|
||||
else: # G3 means CCW
|
||||
dirstring = "-1"
|
||||
txt = "CG,,"
|
||||
txt += format(GetValue(command.Parameters["X"]), ".4f") + ","
|
||||
txt += format(GetValue(command.Parameters["Y"]), ".4f") + ","
|
||||
txt += format(GetValue(command.Parameters["I"]), ".4f") + ","
|
||||
txt += format(GetValue(command.Parameters["J"]), ".4f") + ","
|
||||
txt += "T" + ","
|
||||
txt += dirstring
|
||||
txt += "\n"
|
||||
return txt
|
||||
|
||||
|
||||
def tool_change(command):
|
||||
txt = ""
|
||||
if OUTPUT_COMMENTS:
|
||||
txt += "'a tool change happens now\n"
|
||||
for line in TOOL_CHANGE.splitlines(True):
|
||||
txt += line
|
||||
txt += "&ToolName=" + str(int(command.Parameters["T"]))
|
||||
txt += "\n"
|
||||
txt += "&Tool=" + str(int(command.Parameters["T"]))
|
||||
txt += "\n"
|
||||
return txt
|
||||
|
||||
|
||||
def comment(command):
|
||||
print("a comment", command)
|
||||
return
|
||||
|
||||
|
||||
def spindle(command):
|
||||
txt = ""
|
||||
if command.Name == "M3": # CW
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
txt += "TR," + str(command.Parameters["S"]) + "\n"
|
||||
txt += "C6\n"
|
||||
txt += "PAUSE 2\n"
|
||||
return txt
|
||||
|
||||
|
||||
# Supported Commands
|
||||
scommands = {
|
||||
"G0": move,
|
||||
"G1": move,
|
||||
"G2": arc,
|
||||
"G3": arc,
|
||||
"M6": tool_change,
|
||||
"M3": spindle,
|
||||
"G00": move,
|
||||
"G01": move,
|
||||
"G02": arc,
|
||||
"G03": arc,
|
||||
"M06": tool_change,
|
||||
"M03": spindle,
|
||||
"message": comment,
|
||||
}
|
||||
|
||||
|
||||
def parse(pathobj):
|
||||
output = ""
|
||||
# Above list controls the order of parameters
|
||||
|
||||
if hasattr(pathobj, "Group"): # We have a compound or project.
|
||||
if OUTPUT_COMMENTS:
|
||||
output += linenumber() + "'(compound: " + pathobj.Label + ")\n"
|
||||
for p in pathobj.Group:
|
||||
output += parse(p)
|
||||
else: # parsing simple path
|
||||
# groups might contain non-path things like stock.
|
||||
if not hasattr(pathobj, "Path"):
|
||||
return output
|
||||
if OUTPUT_COMMENTS:
|
||||
output += linenumber() + "'(Path: " + pathobj.Label + ")\n"
|
||||
for c in PathUtils.getPathWithPlacement(pathobj).Commands:
|
||||
command = c.Name
|
||||
if command in scommands:
|
||||
output += scommands[command](c)
|
||||
if c.Parameters:
|
||||
CurrentState.update(c.Parameters)
|
||||
elif command.startswith("("):
|
||||
output += "' " + command + "\n"
|
||||
else:
|
||||
print("I don't know what the hell the command: ", end="")
|
||||
print(command + " means. Maybe I should support it.")
|
||||
return output
|
||||
|
||||
|
||||
def linenumber():
|
||||
return ""
|
||||
|
||||
|
||||
# print(__name__ + " gcode postprocessor loaded.")
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2014 sliptonic <[email protected]> *
|
||||
# * Copyright (c) 2025 sliptonic <[email protected]> *
|
||||
# * *
|
||||
# * This file is part of the FreeCAD CAx development system. *
|
||||
# * *
|
||||
@@ -23,354 +23,444 @@
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
import datetime
|
||||
import Path.Post.Utils as PostUtils
|
||||
import PathScripts.PathUtils as PathUtils
|
||||
from builtins import open as pyopen
|
||||
"""
|
||||
OpenSBP Post Processor for ShopBot Controllers
|
||||
|
||||
This is a new-style postprocessor that uses the hook methods pattern to override
|
||||
specific command handling for the OpenSBP dialect used by ShopBot controllers.
|
||||
|
||||
TOOLTIP = """
|
||||
This is an postprocessor file for the Path workbench. It will output path data
|
||||
in a format suitable for OpenSBP controllers like shopbot. This postprocessor,
|
||||
once placed in the appropriate PathScripts folder, can be used directly from
|
||||
inside FreeCAD, via the GUI importer or via python scripts with:
|
||||
OpenSBP uses commands like:
|
||||
- MX, MY, MZ - Move (feed) single axis
|
||||
- M2, M3 - Move (feed) multiple axes
|
||||
- JX, JY, JZ - Jog (rapid) single axis
|
||||
- J2, J3 - Jog (rapid) multiple axes
|
||||
- CG - Circular interpolation (arcs)
|
||||
- TR - Set spindle RPM
|
||||
- MS, JS - Set move/jog speeds
|
||||
|
||||
This postprocessor demonstrates how to override only the necessary hook methods
|
||||
without reimplementing the entire convert_command_to_gcode function.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from Path.Post.Processor import PostProcessor
|
||||
|
||||
import Path
|
||||
Path.write(object,"/path/to/file.ncc","post_opensbp")
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
"""
|
||||
DONE:
|
||||
uses native commands
|
||||
handles feed and jog moves
|
||||
handles XY, Z, and XYZ feed speeds
|
||||
handles arcs
|
||||
support for inch output
|
||||
ToDo
|
||||
comments may not format correctly
|
||||
drilling. Haven't looked at it.
|
||||
many other things
|
||||
translate = FreeCAD.Qt.translate
|
||||
|
||||
"""
|
||||
|
||||
TOOLTIP_ARGS = """
|
||||
Arguments for opensbp:
|
||||
--comments ... insert comments - mostly for debugging
|
||||
--inches ... convert output to inches
|
||||
--no-header ... suppress header output
|
||||
--no-show-editor ... don't show editor, just save result
|
||||
"""
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
OUTPUT_COMMENTS = False
|
||||
OUTPUT_HEADER = True
|
||||
SHOW_EDITOR = True
|
||||
COMMAND_SPACE = ","
|
||||
|
||||
# Preamble text will appear at the beginning of the GCODE output file.
|
||||
PREAMBLE = """"""
|
||||
# Postamble text will appear following the last operation.
|
||||
POSTAMBLE = """"""
|
||||
|
||||
# Pre operation text will be inserted before every operation
|
||||
PRE_OPERATION = """"""
|
||||
|
||||
# Post operation text will be inserted after every operation
|
||||
POST_OPERATION = """"""
|
||||
|
||||
# Tool Change commands will be inserted before a tool change
|
||||
TOOL_CHANGE = """"""
|
||||
DEBUG = False
|
||||
|
||||
|
||||
CurrentState = {}
|
||||
# Set logging level based on DEBUG flag
|
||||
def _setup_logging():
|
||||
if DEBUG:
|
||||
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
|
||||
Path.Log.trackModule(Path.Log.thisModule())
|
||||
else:
|
||||
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
|
||||
|
||||
|
||||
def getMetricValue(val):
|
||||
return val
|
||||
_setup_logging()
|
||||
|
||||
# Define types
|
||||
Values = Dict[str, Any]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
def getImperialValue(val):
|
||||
return val / 25.4
|
||||
class OpenSBPPost(PostProcessor):
|
||||
"""
|
||||
OpenSBP postprocessor for ShopBot controllers.
|
||||
|
||||
This class demonstrates the new hook methods pattern by overriding only
|
||||
the specific command conversion methods needed for OpenSBP dialect.
|
||||
|
||||
GetValue = getMetricValue
|
||||
OpenSBP uses native commands prefixed with '>' for non-G-code operations.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_common_property_schema(cls):
|
||||
"""Override common properties with OpenSBP-specific defaults."""
|
||||
common_props = super().get_common_property_schema()
|
||||
|
||||
def export(objectslist, filename, argstring):
|
||||
global OUTPUT_COMMENTS
|
||||
global OUTPUT_HEADER
|
||||
global SHOW_EDITOR
|
||||
global CurrentState
|
||||
global GetValue
|
||||
# Override defaults for OpenSBP
|
||||
for prop in common_props:
|
||||
if prop["name"] == "file_extension":
|
||||
prop["default"] = "sbp"
|
||||
elif prop["name"] == "preamble":
|
||||
prop["default"] = (
|
||||
"'OpenSBP output from FreeCAD\n"
|
||||
"'NOTE: In OpenSBP, M3 is a 3-axis MOVE command, NOT spindle control\n"
|
||||
"'Spindle control is via TR (speed) and C6/C7 (on/off) commands"
|
||||
)
|
||||
elif prop["name"] == "postamble":
|
||||
prop["default"] = ">C7\n'End of program"
|
||||
|
||||
for arg in argstring.split():
|
||||
if arg == "--comments":
|
||||
OUTPUT_COMMENTS = True
|
||||
if arg == "--inches":
|
||||
GetValue = getImperialValue
|
||||
if arg == "--no-header":
|
||||
OUTPUT_HEADER = False
|
||||
if arg == "--no-show-editor":
|
||||
SHOW_EDITOR = False
|
||||
return common_props
|
||||
|
||||
for obj in objectslist:
|
||||
if not hasattr(obj, "Path"):
|
||||
s = "the object " + obj.Name
|
||||
s += " is not a path. Please select only path and Compounds."
|
||||
print(s)
|
||||
return
|
||||
@classmethod
|
||||
def get_property_schema(cls):
|
||||
"""Return schema for OpenSBP-specific configurable properties."""
|
||||
return [
|
||||
{
|
||||
"name": "automatic_tool_changer",
|
||||
"type": "bool",
|
||||
"label": translate("CAM", "Automatic Tool Changer"),
|
||||
"default": False,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Enable if machine has automatic tool changer. "
|
||||
"If disabled, tool changes will pause for manual intervention.",
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "automatic_spindle",
|
||||
"type": "bool",
|
||||
"label": translate("CAM", "Automatic Spindle Control"),
|
||||
"default": False,
|
||||
"help": translate(
|
||||
"CAM",
|
||||
"Enable if machine has automatic spindle speed control. "
|
||||
"If disabled, spindle commands will prompt for manual adjustment.",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
CurrentState = {
|
||||
"X": 0,
|
||||
"Y": 0,
|
||||
"Z": 0,
|
||||
"F": 0,
|
||||
"S": 0,
|
||||
"JSXY": 0,
|
||||
"JSZ": 0,
|
||||
"MSXY": 0,
|
||||
"MSZ": 0,
|
||||
}
|
||||
print("postprocessing...")
|
||||
gcode = ""
|
||||
def __init__(
|
||||
self,
|
||||
job,
|
||||
tooltip=translate("CAM", "OpenSBP post processor for ShopBot controllers"),
|
||||
tooltipargs=[],
|
||||
units="Metric",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
job=job,
|
||||
tooltip=tooltip,
|
||||
tooltipargs=tooltipargs,
|
||||
units=units,
|
||||
)
|
||||
Path.Log.debug("OpenSBP post processor initialized.")
|
||||
|
||||
# write header
|
||||
if OUTPUT_HEADER:
|
||||
gcode += linenumber() + "'Exported by FreeCAD\n"
|
||||
gcode += linenumber() + "'Post Processor: " + __name__ + "\n"
|
||||
gcode += linenumber() + "'Output Time:" + str(now) + "\n"
|
||||
# Track current speeds for OpenSBP (separate XY and Z speeds)
|
||||
self._current_move_speed_xy = None
|
||||
self._current_move_speed_z = None
|
||||
self._current_jog_speed_xy = None
|
||||
self._current_jog_speed_z = None
|
||||
|
||||
# Write the preamble
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += linenumber() + "'(begin preamble)\n"
|
||||
for line in PREAMBLE.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
def init_values(self, values: Values) -> None:
|
||||
"""Initialize values that are used throughout the postprocessor."""
|
||||
super().init_values(values)
|
||||
|
||||
for obj in objectslist:
|
||||
# OpenSBP-specific settings
|
||||
values["MACHINE_NAME"] = "ShopBot"
|
||||
values["POSTPROCESSOR_FILE_NAME"] = __name__
|
||||
|
||||
# do the pre_op
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += linenumber() + "'(begin operation: " + obj.Label + ")\n"
|
||||
for line in PRE_OPERATION.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
gcode += parse(obj)
|
||||
|
||||
# do the post_op
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += linenumber() + "'(finish operation: " + obj.Label + ")\n"
|
||||
for line in POST_OPERATION.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
# do the post_amble
|
||||
if OUTPUT_COMMENTS:
|
||||
gcode += "'(begin postamble)\n"
|
||||
for line in POSTAMBLE.splitlines(True):
|
||||
gcode += linenumber() + line
|
||||
|
||||
if SHOW_EDITOR:
|
||||
dia = PostUtils.GCodeEditorDialog()
|
||||
dia.editor.setPlainText(gcode)
|
||||
result = dia.exec_()
|
||||
if result:
|
||||
final = dia.editor.toPlainText()
|
||||
# Load configuration from machine properties if available
|
||||
if self._machine and hasattr(self._machine, "postprocessor_properties"):
|
||||
props = self._machine.postprocessor_properties
|
||||
values["AUTOMATIC_TOOL_CHANGER"] = props.get("automatic_tool_changer", False)
|
||||
values["AUTOMATIC_SPINDLE"] = props.get("automatic_spindle", False)
|
||||
else:
|
||||
final = gcode
|
||||
else:
|
||||
final = gcode
|
||||
values["AUTOMATIC_TOOL_CHANGER"] = False
|
||||
values["AUTOMATIC_SPINDLE"] = False
|
||||
|
||||
print("done postprocessing.")
|
||||
def _convert_comment(self, command):
|
||||
"""
|
||||
Convert comments to OpenSBP format (single quote prefix).
|
||||
"""
|
||||
# Extract comment text
|
||||
comment_text = (
|
||||
command.Name[1:-1]
|
||||
if command.Name.startswith("(") and command.Name.endswith(")")
|
||||
else command.Name[1:]
|
||||
)
|
||||
|
||||
# Write the output
|
||||
if not filename == "-":
|
||||
gfile = pyopen(filename, "w")
|
||||
gfile.write(final)
|
||||
gfile.close()
|
||||
# OpenSBP uses single quote for comments
|
||||
return f"'{comment_text}"
|
||||
|
||||
return final
|
||||
def _convert_rapid_move(self, command):
|
||||
"""
|
||||
Convert rapid moves (G0) to OpenSBP jog commands (JX, JY, JZ, J2, J3).
|
||||
"""
|
||||
return self._convert_move_command(command, is_rapid=True)
|
||||
|
||||
def _convert_linear_move(self, command):
|
||||
"""
|
||||
Convert linear moves (G1) to OpenSBP move commands (MX, MY, MZ, M2, M3).
|
||||
"""
|
||||
return self._convert_move_command(command, is_rapid=False)
|
||||
|
||||
def move(command):
|
||||
txt = ""
|
||||
def _convert_move_command(self, command, is_rapid):
|
||||
"""
|
||||
Convert move commands to OpenSBP format.
|
||||
|
||||
# if 'F' in command.Parameters:
|
||||
# txt += feedrate(command)
|
||||
OpenSBP uses different commands based on:
|
||||
- Move type: M (feed) or J (jog/rapid)
|
||||
- Axes involved: X, Y, Z, 2 (XY), 3 (XYZ)
|
||||
|
||||
axis = ""
|
||||
for p in ["X", "Y", "Z"]:
|
||||
if p in command.Parameters:
|
||||
if command.Parameters[p] != CurrentState[p]:
|
||||
axis += p
|
||||
Native OpenSBP commands are prefixed with '>'
|
||||
"""
|
||||
params = command.Parameters
|
||||
output = []
|
||||
|
||||
if "F" in command.Parameters:
|
||||
speed = command.Parameters["F"]
|
||||
if command.Name in ["G1", "G01"]: # move
|
||||
movetype = "MS"
|
||||
else: # jog
|
||||
movetype = "JS"
|
||||
zspeed = ""
|
||||
xyspeed = ""
|
||||
if "Z" in axis:
|
||||
speedKey = "{}Z".format(movetype)
|
||||
speedVal = GetValue(speed)
|
||||
if CurrentState[speedKey] != speedVal:
|
||||
CurrentState[speedKey] = speedVal
|
||||
zspeed = "{:f}".format(speedVal)
|
||||
if ("X" in axis) or ("Y" in axis):
|
||||
speedKey = "{}XY".format(movetype)
|
||||
speedVal = GetValue(speed)
|
||||
if CurrentState[speedKey] != speedVal:
|
||||
CurrentState[speedKey] = speedVal
|
||||
xyspeed = "{:f}".format(speedVal)
|
||||
if zspeed or xyspeed:
|
||||
txt += "{},{},{}\n".format(movetype, xyspeed, zspeed)
|
||||
# Determine which axes are moving
|
||||
has_x = "X" in params
|
||||
has_y = "Y" in params
|
||||
has_z = "Z" in params
|
||||
|
||||
if command.Name in ["G0", "G00"]:
|
||||
pref = "J"
|
||||
else:
|
||||
pref = "M"
|
||||
# Get unit conversion function
|
||||
def get_value(val):
|
||||
"""Convert value based on machine units."""
|
||||
if self._machine and hasattr(self._machine, "output"):
|
||||
from Machine.models.machine import OutputUnits
|
||||
|
||||
if axis == "X":
|
||||
txt += pref + "X"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "Y":
|
||||
txt += pref + "Y"
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "Z":
|
||||
txt += pref + "Z"
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "XY":
|
||||
txt += pref + "2"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "XZ":
|
||||
txt += pref + "3"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += ","
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "XYZ":
|
||||
txt += pref + "3"
|
||||
txt += "," + format(GetValue(command.Parameters["X"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "YZ":
|
||||
txt += pref + "3"
|
||||
txt += ","
|
||||
txt += "," + format(GetValue(command.Parameters["Y"]), ".4f")
|
||||
txt += "," + format(GetValue(command.Parameters["Z"]), ".4f")
|
||||
txt += "\n"
|
||||
elif axis == "":
|
||||
print("warning: skipping duplicate move.")
|
||||
else:
|
||||
print(CurrentState)
|
||||
print(command)
|
||||
print("I don't know how to handle '{}' for a move.".format(axis))
|
||||
if self._machine.output.units == OutputUnits.IMPERIAL:
|
||||
return val / 25.4
|
||||
return val
|
||||
|
||||
return txt
|
||||
# Handle speed settings (MS/JS commands)
|
||||
if "F" in params:
|
||||
speed = params["F"] * 60.0 # Convert mm/sec to mm/min
|
||||
speed = get_value(speed)
|
||||
|
||||
prefix = "JS" if is_rapid else "MS"
|
||||
|
||||
def arc(command):
|
||||
if command.Name == "G2": # CW
|
||||
dirstring = "1"
|
||||
else: # G3 means CCW
|
||||
dirstring = "-1"
|
||||
txt = "CG,,"
|
||||
txt += format(GetValue(command.Parameters["X"]), ".4f") + ","
|
||||
txt += format(GetValue(command.Parameters["Y"]), ".4f") + ","
|
||||
txt += format(GetValue(command.Parameters["I"]), ".4f") + ","
|
||||
txt += format(GetValue(command.Parameters["J"]), ".4f") + ","
|
||||
txt += "T" + ","
|
||||
txt += dirstring
|
||||
txt += "\n"
|
||||
return txt
|
||||
# OpenSBP has separate speeds for XY and Z
|
||||
xy_speed = ""
|
||||
z_speed = ""
|
||||
|
||||
if has_z:
|
||||
speed_attr = "_current_jog_speed_z" if is_rapid else "_current_move_speed_z"
|
||||
if getattr(self, speed_attr) != speed:
|
||||
setattr(self, speed_attr, speed)
|
||||
z_speed = f"{speed:.4f}"
|
||||
|
||||
def tool_change(command):
|
||||
txt = ""
|
||||
if OUTPUT_COMMENTS:
|
||||
txt += "'a tool change happens now\n"
|
||||
for line in TOOL_CHANGE.splitlines(True):
|
||||
txt += line
|
||||
txt += "&ToolName=" + str(int(command.Parameters["T"]))
|
||||
txt += "\n"
|
||||
txt += "&Tool=" + str(int(command.Parameters["T"]))
|
||||
txt += "\n"
|
||||
return txt
|
||||
if has_x or has_y:
|
||||
speed_attr = "_current_jog_speed_xy" if is_rapid else "_current_move_speed_xy"
|
||||
if getattr(self, speed_attr) != speed:
|
||||
setattr(self, speed_attr, speed)
|
||||
xy_speed = f"{speed:.4f}"
|
||||
|
||||
# Only output speed command if it changed
|
||||
if xy_speed or z_speed:
|
||||
output.append(f">{prefix},{xy_speed},{z_speed}")
|
||||
|
||||
def comment(command):
|
||||
print("a comment", command)
|
||||
return
|
||||
# Generate move command based on axes
|
||||
prefix = "J" if is_rapid else "M"
|
||||
|
||||
if has_x and has_y and has_z:
|
||||
# XYZ move - use M3/J3
|
||||
x_val = get_value(params["X"])
|
||||
y_val = get_value(params["Y"])
|
||||
z_val = get_value(params["Z"])
|
||||
output.append(f">{prefix}3,{x_val:.4f},{y_val:.4f},{z_val:.4f}")
|
||||
|
||||
def spindle(command):
|
||||
txt = ""
|
||||
if command.Name == "M3": # CW
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
txt += "TR," + str(command.Parameters["S"]) + "\n"
|
||||
txt += "C6\n"
|
||||
txt += "PAUSE 2\n"
|
||||
return txt
|
||||
elif has_x and has_y:
|
||||
# XY move - use M2/J2
|
||||
x_val = get_value(params["X"])
|
||||
y_val = get_value(params["Y"])
|
||||
output.append(f">{prefix}2,{x_val:.4f},{y_val:.4f}")
|
||||
|
||||
elif has_x and has_z:
|
||||
# XZ move - use M3/J3 with empty Y
|
||||
x_val = get_value(params["X"])
|
||||
z_val = get_value(params["Z"])
|
||||
output.append(f">{prefix}3,{x_val:.4f},,{z_val:.4f}")
|
||||
|
||||
# Supported Commands
|
||||
scommands = {
|
||||
"G0": move,
|
||||
"G1": move,
|
||||
"G2": arc,
|
||||
"G3": arc,
|
||||
"M6": tool_change,
|
||||
"M3": spindle,
|
||||
"G00": move,
|
||||
"G01": move,
|
||||
"G02": arc,
|
||||
"G03": arc,
|
||||
"M06": tool_change,
|
||||
"M03": spindle,
|
||||
"message": comment,
|
||||
}
|
||||
elif has_y and has_z:
|
||||
# YZ move - use M3/J3 with empty X
|
||||
y_val = get_value(params["Y"])
|
||||
z_val = get_value(params["Z"])
|
||||
output.append(f">{prefix}3,,{y_val:.4f},{z_val:.4f}")
|
||||
|
||||
elif has_x:
|
||||
# X only - use MX/JX
|
||||
x_val = get_value(params["X"])
|
||||
output.append(f">{prefix}X,{x_val:.4f}")
|
||||
|
||||
def parse(pathobj):
|
||||
output = ""
|
||||
# Above list controls the order of parameters
|
||||
elif has_y:
|
||||
# Y only - use MY/JY
|
||||
y_val = get_value(params["Y"])
|
||||
output.append(f">{prefix}Y,{y_val:.4f}")
|
||||
|
||||
if hasattr(pathobj, "Group"): # We have a compound or project.
|
||||
if OUTPUT_COMMENTS:
|
||||
output += linenumber() + "'(compound: " + pathobj.Label + ")\n"
|
||||
for p in pathobj.Group:
|
||||
output += parse(p)
|
||||
else: # parsing simple path
|
||||
# groups might contain non-path things like stock.
|
||||
if not hasattr(pathobj, "Path"):
|
||||
return output
|
||||
if OUTPUT_COMMENTS:
|
||||
output += linenumber() + "'(Path: " + pathobj.Label + ")\n"
|
||||
for c in PathUtils.getPathWithPlacement(pathobj).Commands:
|
||||
command = c.Name
|
||||
if command in scommands:
|
||||
output += scommands[command](c)
|
||||
if c.Parameters:
|
||||
CurrentState.update(c.Parameters)
|
||||
elif command.startswith("("):
|
||||
output += "' " + command + "\n"
|
||||
elif has_z:
|
||||
# Z only - use MZ/JZ
|
||||
z_val = get_value(params["Z"])
|
||||
output.append(f">{prefix}Z,{z_val:.4f}")
|
||||
|
||||
return "\n".join(output) if output else None
|
||||
|
||||
def _convert_arc_move(self, command):
|
||||
"""
|
||||
Convert arc moves (G2/G3) to OpenSBP CG command.
|
||||
|
||||
OpenSBP CG format: >CG,,X,Y,I,J,T,direction[,plunge]
|
||||
where:
|
||||
- direction is 1 for CW (G2) or -1 for CCW (G3)
|
||||
- plunge is optional Z movement (relative, sign inverted)
|
||||
|
||||
Note: ShopBot only supports arcs in XY plane with I,J offsets.
|
||||
If Z is present, it's converted to a helical arc with plunge parameter.
|
||||
"""
|
||||
params = command.Parameters
|
||||
|
||||
# Get unit conversion function
|
||||
def get_value(val):
|
||||
if self._machine and hasattr(self._machine, "output"):
|
||||
from Machine.models.machine import OutputUnits
|
||||
|
||||
if self._machine.output.units == OutputUnits.IMPERIAL:
|
||||
return val / 25.4
|
||||
return val
|
||||
|
||||
# Determine direction
|
||||
direction = "1" if command.Name in ["G2", "G02"] else "-1"
|
||||
|
||||
# Extract arc parameters
|
||||
x_val = get_value(params.get("X", 0))
|
||||
y_val = get_value(params.get("Y", 0))
|
||||
i_val = get_value(params.get("I", 0))
|
||||
j_val = get_value(params.get("J", 0))
|
||||
|
||||
# Check for helical arc (Z parameter present)
|
||||
output = []
|
||||
if "Z" in params:
|
||||
# Helical arc - need to calculate plunge
|
||||
# Get current Z from modal state (default to 0 if not set)
|
||||
current_z = self._modal_state.get("Z", 0.0)
|
||||
if current_z is None:
|
||||
current_z = 0.0
|
||||
target_z = params["Z"]
|
||||
plunge = get_value(current_z - target_z) # Relative, inverted sign
|
||||
|
||||
# Set move speed if feed rate is specified
|
||||
if "F" in params:
|
||||
speed = params["F"] * 60.0 # Convert mm/sec to mm/min
|
||||
speed = get_value(speed)
|
||||
# Only output if speed changed
|
||||
if self._current_move_speed_xy != speed or self._current_move_speed_z != speed:
|
||||
output.append(f">MS,{speed:.4f},{speed:.4f}")
|
||||
self._current_move_speed_xy = speed
|
||||
self._current_move_speed_z = speed
|
||||
|
||||
# Use L (linear) instead of T (tool comp) for helical arcs
|
||||
output.append(
|
||||
f">CG,,{x_val:.4f},{y_val:.4f},{i_val:.4f},{j_val:.4f},L,{direction},{plunge:.4f}"
|
||||
)
|
||||
else:
|
||||
# Simple arc in XY plane (no tool compensation - use L instead of T)
|
||||
output.append(f">CG,,{x_val:.4f},{y_val:.4f},{i_val:.4f},{j_val:.4f},L,{direction}")
|
||||
|
||||
return "\n".join(output) if output else None
|
||||
|
||||
def _convert_tool_change(self, command):
|
||||
"""
|
||||
Convert tool change (M6) to OpenSBP tool commands.
|
||||
|
||||
Supports both automatic and manual tool changers based on configuration.
|
||||
"""
|
||||
params = command.Parameters
|
||||
tool_num = int(params.get("T", 0))
|
||||
|
||||
output = []
|
||||
|
||||
# Check if automatic tool changer is enabled
|
||||
has_atc = self.values.get("AUTOMATIC_TOOL_CHANGER", False)
|
||||
|
||||
if has_atc:
|
||||
# Automatic tool changer
|
||||
output.append(f">&ToolName={tool_num}")
|
||||
output.append(f">&Tool={tool_num}")
|
||||
else:
|
||||
# Manual tool change - pause and prompt
|
||||
output.append(f"'Manual tool change to T{tool_num}")
|
||||
output.append(f">&ToolName={tool_num}")
|
||||
output.append(f">&Tool={tool_num}")
|
||||
output.append(">PAUSE")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _convert_spindle_command(self, command):
|
||||
"""
|
||||
Convert spindle commands (M3/M4/M5) to OpenSBP TR command.
|
||||
|
||||
Supports both automatic and manual spindle control based on configuration.
|
||||
"""
|
||||
params = command.Parameters
|
||||
has_auto_spindle = self.values.get("AUTOMATIC_SPINDLE", False)
|
||||
|
||||
if command.Name in ["M5", "M05"]:
|
||||
# Spindle off
|
||||
if has_auto_spindle:
|
||||
return ">TR,0\n>C7"
|
||||
else:
|
||||
print("I don't know what the hell the command: ", end="")
|
||||
print(command + " means. Maybe I should support it.")
|
||||
return output
|
||||
return "'Turn spindle OFF manually\n>PAUSE"
|
||||
|
||||
# Spindle on (M3/M4)
|
||||
rpm = int(params.get("S", 0))
|
||||
|
||||
output = []
|
||||
|
||||
if has_auto_spindle:
|
||||
# Automatic spindle control
|
||||
output.append(f">TR,{rpm}")
|
||||
output.append(">C6") # Start spindle
|
||||
output.append(">PAUSE 2") # Wait for spindle to reach speed
|
||||
else:
|
||||
# Manual spindle control - prompt user
|
||||
output.append(f"'Set spindle to {rpm} RPM and start manually")
|
||||
output.append(">PAUSE")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _convert_dwell(self, command):
|
||||
"""
|
||||
Convert dwell (G4) to OpenSBP PAUSE command.
|
||||
"""
|
||||
params = command.Parameters
|
||||
seconds = params.get("P", 0)
|
||||
|
||||
return f">PAUSE {seconds:.2f}"
|
||||
|
||||
def _convert_fixture(self, command):
|
||||
"""
|
||||
Suppress fixture commands (G54-G59) as OpenSBP doesn't use them.
|
||||
"""
|
||||
# OpenSBP doesn't have work coordinate systems
|
||||
return None
|
||||
|
||||
def _convert_modal_command(self, command):
|
||||
"""
|
||||
Suppress most modal commands that don't apply to OpenSBP.
|
||||
"""
|
||||
# OpenSBP doesn't use most G-code modal commands
|
||||
# Suppress G21/G20 (units), G43, G80, G90, G91, etc.
|
||||
return None
|
||||
|
||||
@property
|
||||
def tooltip(self):
|
||||
tooltip: str = """
|
||||
This is a postprocessor file for the CAM workbench.
|
||||
It is used to take a pseudo-gcode fragment from a CAM object
|
||||
and output OpenSBP code suitable for ShopBot CNC controllers.
|
||||
|
||||
OpenSBP uses native commands like MX, MY, M2, M3 for moves,
|
||||
CG for arcs, TR for spindle speed, etc.
|
||||
"""
|
||||
return tooltip
|
||||
|
||||
|
||||
def linenumber():
|
||||
return ""
|
||||
# Class alias for PostProcessorFactory
|
||||
# The factory looks for a class with title-cased postname (e.g., "Opensbp")
|
||||
Opensbp = OpenSBPPost
|
||||
|
||||
|
||||
# print(__name__ + " gcode postprocessor loaded.")
|
||||
# Factory function for creating the postprocessor
|
||||
def create(job, **kwargs):
|
||||
"""
|
||||
Factory function to create an OpenSBP postprocessor instance.
|
||||
"""
|
||||
return OpenSBPPost(job, **kwargs)
|
||||
|
||||
@@ -47,6 +47,8 @@ else:
|
||||
#
|
||||
Values = Dict[str, Any]
|
||||
|
||||
POST_TYPE = "machine"
|
||||
|
||||
|
||||
class Smoothie(PostProcessor):
|
||||
"""
|
||||
@@ -157,14 +159,13 @@ M2"""
|
||||
|
||||
def export(self):
|
||||
"""Override export to handle network upload to SmoothieBoard."""
|
||||
# First, do the standard export processing
|
||||
gcode_sections = super().export()
|
||||
# Use the base export method - remote posting is now handled in remote_post()
|
||||
return super().export()
|
||||
|
||||
if gcode_sections is None:
|
||||
return None
|
||||
|
||||
# If IP address is specified, send to SmoothieBoard instead of writing to file
|
||||
if self.ip_addr:
|
||||
def remote_post(self, gcode_sections):
|
||||
"""Override remote_post to handle SmoothieBoard network upload."""
|
||||
# Check if remote posting is enabled and IP address is specified
|
||||
if self.values.get("REMOTE_POST", False) and self.ip_addr:
|
||||
# Combine all G-code sections
|
||||
gcode = ""
|
||||
for section_name, section_gcode in gcode_sections:
|
||||
@@ -178,12 +179,6 @@ M2"""
|
||||
|
||||
self._send_to_smoothie(self.ip_addr, gcode, filename)
|
||||
|
||||
# Return the gcode for display/editor
|
||||
return gcode_sections
|
||||
|
||||
# Normal file-based export
|
||||
return gcode_sections
|
||||
|
||||
def _send_to_smoothie(self, ip: str, gcode: str, fname: str) -> None:
|
||||
"""
|
||||
Send G-code directly to SmoothieBoard via network.
|
||||
|
||||
+6
@@ -853,3 +853,9 @@ class Snapmaker(Path.Post.Processor.PostProcessor):
|
||||
|
||||
if __name__ == "__main__":
|
||||
Snapmaker(None).visible_parser.format_help()
|
||||
|
||||
|
||||
# Class aliases for PostProcessorFactory
|
||||
# The factory looks for a class with title-cased postname (e.g., "Snapmaker_Legacy")
|
||||
snapmaker_legacy = Snapmaker # What factory expects
|
||||
Snapmaker_Legacy = Snapmaker # Fallback for different title() behavior
|
||||
@@ -45,7 +45,7 @@ class Svg(PostProcessor):
|
||||
def __init__(self, job):
|
||||
|
||||
super().__init__(
|
||||
job,
|
||||
job=job,
|
||||
tooltip=translate("CAM", "SVG post processor"),
|
||||
tooltipargs=[],
|
||||
units="mm",
|
||||
|
||||
+4
@@ -24,6 +24,10 @@
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
#
|
||||
# DEPRECATED: This post processor is deprecated and replaced by the generic
|
||||
# post processor with Generic_UCCNC.fcm machine configuration file.
|
||||
# Use the generic post processor instead.
|
||||
|
||||
# See: https://wiki.freecad.org/Path_Post
|
||||
# https://wiki.freecad.org/Path_Postprocessor_Customization
|
||||
@@ -24,6 +24,7 @@
|
||||
import FreeCAD
|
||||
import Path
|
||||
import glob
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
from collections import defaultdict
|
||||
@@ -51,6 +52,7 @@ PostProcessorDefaultArgs = "PostProcessorDefaultArgs"
|
||||
PostProcessorBlacklist = "PostProcessorBlacklist"
|
||||
PostProcessorOutputFile = "PostProcessorOutputFile"
|
||||
PostProcessorOutputPolicy = "PostProcessorOutputPolicy"
|
||||
PostProcessorShowEditor = "PostProcessorShowEditor"
|
||||
|
||||
ToolGroup = PreferencesGroup + "/Tools"
|
||||
ToolPath = "ToolPath"
|
||||
@@ -220,6 +222,81 @@ def allEnabledPostProcessors(include=None):
|
||||
return enabled
|
||||
|
||||
|
||||
_post_type_cache = {}
|
||||
_post_type_cache_keys = None
|
||||
|
||||
|
||||
def classifyPostProcessor(name):
|
||||
"""Classify a postprocessor as 'machine', 'legacy', or 'unknown'.
|
||||
|
||||
Checks for a POST_TYPE module-level constant in the post's .py file.
|
||||
Returns 'machine' for new-style posts, 'legacy' for old-style,
|
||||
'unknown' if the file cannot be found or loaded.
|
||||
"""
|
||||
global _post_type_cache, _post_type_cache_keys
|
||||
|
||||
# Invalidate cache if the available post list has changed
|
||||
current_keys = tuple(allAvailablePostProcessors())
|
||||
if current_keys != _post_type_cache_keys:
|
||||
_post_type_cache = {}
|
||||
_post_type_cache_keys = current_keys
|
||||
|
||||
if name in _post_type_cache:
|
||||
return _post_type_cache[name]
|
||||
|
||||
module_name = f"{name}_post"
|
||||
for search_path in searchPathsPost():
|
||||
module_path = os.path.join(search_path, f"{module_name}.py")
|
||||
if not os.path.isfile(module_path):
|
||||
continue
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
post_type = getattr(module, "POST_TYPE", "legacy")
|
||||
_post_type_cache[name] = post_type
|
||||
return post_type
|
||||
except Exception:
|
||||
continue
|
||||
_post_type_cache[name] = "unknown"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def allAvailableLegacyPostProcessors():
|
||||
"""Return only legacy postprocessors."""
|
||||
return [p for p in allAvailablePostProcessors() if classifyPostProcessor(p) == "legacy"]
|
||||
|
||||
|
||||
def allAvailableMachinePostProcessors():
|
||||
"""Return only new-style machine postprocessors."""
|
||||
return [p for p in allAvailablePostProcessors() if classifyPostProcessor(p) == "machine"]
|
||||
|
||||
|
||||
def allEnabledLegacyPostProcessors(include=None):
|
||||
"""Return enabled legacy postprocessors (for Job context)."""
|
||||
blacklist = postProcessorBlacklist()
|
||||
enabled = [p for p in allAvailableLegacyPostProcessors() if p not in blacklist]
|
||||
if include:
|
||||
legacy_include = [p for p in include if p == "" or classifyPostProcessor(p) == "legacy"]
|
||||
postlist = list(set(legacy_include + enabled))
|
||||
postlist.sort()
|
||||
return postlist
|
||||
return enabled
|
||||
|
||||
|
||||
def allEnabledMachinePostProcessors(include=None):
|
||||
"""Return enabled machine postprocessors (for Machine editor context)."""
|
||||
blacklist = postProcessorBlacklist()
|
||||
enabled = [p for p in allAvailableMachinePostProcessors() if p not in blacklist]
|
||||
if include:
|
||||
machine_include = [p for p in include if p == "" or classifyPostProcessor(p) == "machine"]
|
||||
postlist = list(set(machine_include + enabled))
|
||||
postlist.sort()
|
||||
return postlist
|
||||
return enabled
|
||||
|
||||
|
||||
def defaultPostProcessor():
|
||||
pref = preferences()
|
||||
return pref.GetString(PostProcessorDefault, "")
|
||||
@@ -323,6 +400,26 @@ def defaultOutputPolicy():
|
||||
return pref.GetString(PostProcessorOutputPolicy, "")
|
||||
|
||||
|
||||
def showEditorOnPostProcess():
|
||||
"""Get user preference for showing editor before writing G-code.
|
||||
|
||||
Returns:
|
||||
bool: True to show editor, False to skip it (default: True)
|
||||
"""
|
||||
pref = preferences()
|
||||
return pref.GetBool(PostProcessorShowEditor, True)
|
||||
|
||||
|
||||
def setShowEditorOnPostProcess(show: bool):
|
||||
"""Set user preference for showing editor before writing G-code.
|
||||
|
||||
Args:
|
||||
show: True to show editor, False to skip it
|
||||
"""
|
||||
pref = preferences()
|
||||
pref.SetBool(PostProcessorShowEditor, show)
|
||||
|
||||
|
||||
def defaultStockTemplate():
|
||||
return preferences().GetString(DefaultStockTemplate, "")
|
||||
|
||||
|
||||
+33
-15
@@ -24,8 +24,13 @@
|
||||
import TestApp
|
||||
|
||||
from CAMTests.TestCAMSanity import TestCAMSanity
|
||||
|
||||
from CAMTests.TestLinkingGenerator import TestGetLinkingMoves
|
||||
from CAMTests.TestMachine import TestMachineDataclass, TestMachineFactory, TestToolhead
|
||||
from CAMTests.TestMachine import (
|
||||
TestMachineDataclass,
|
||||
TestMachineFactory,
|
||||
TestToolhead,
|
||||
)
|
||||
from CAMTests.TestPathProfile import TestPathProfile
|
||||
|
||||
from CAMTests.TestPathAdaptive import TestPathAdaptive
|
||||
@@ -36,6 +41,7 @@ from CAMTests.TestPathDressupDogboneII import TestDressupDogboneII
|
||||
from CAMTests.TestPathDressupHoldingTags import TestHoldingTags
|
||||
from CAMTests.TestPathDrillable import TestPathDrillable
|
||||
from CAMTests.TestPathDrillGenerator import TestPathDrillGenerator
|
||||
from CAMTests.TestDrillCycleExpander import TestDrillCycleExpander
|
||||
from CAMTests.TestPathFacingGenerator import TestPathFacingGenerator
|
||||
from CAMTests.TestPathGeneratorDogboneII import TestGeneratorDogboneII
|
||||
from CAMTests.TestPathGeom import TestPathGeom
|
||||
@@ -46,15 +52,19 @@ from CAMTests.TestPathHelix import TestPathHelix
|
||||
from CAMTests.TestPathHelixGenerator import TestPathHelixGenerator
|
||||
from CAMTests.TestPathLog import TestPathLog
|
||||
from CAMTests.TestPathOpUtil import TestPathOpUtil
|
||||
from CAMTests.TestPostToolProcessing import TestToolLengthOffset, TestToolProcessing
|
||||
|
||||
# from CAMTests.TestPathPost import TestPathPost
|
||||
from CAMTests.TestPathPost import TestPathPostUtils
|
||||
from CAMTests.TestPathPost import TestBuildPostList
|
||||
|
||||
# from CAMTests.TestPathPost import TestOutputNameSubstitution
|
||||
from CAMTests.TestPathPost import TestPostProcessorFactory
|
||||
from CAMTests.TestPathPost import TestResolvingPostProcessorName
|
||||
from CAMTests.TestPathPost import TestFileNameGenerator
|
||||
# Post-processing tests split into 3 files for better organization
|
||||
from CAMTests.TestPostCore import TestPathPostUtils, TestBuildPostList, TestJobPropertyOverrides
|
||||
from CAMTests.TestPostProcessor import (
|
||||
TestPostProcessorFactory,
|
||||
TestResolvingPostProcessorName,
|
||||
TestHeaderBuilder,
|
||||
)
|
||||
from CAMTests.TestPostOutput import (
|
||||
TestFileNameGenerator,
|
||||
TestExport2Integration,
|
||||
)
|
||||
|
||||
from CAMTests.TestPathPreferences import TestPathPreferences
|
||||
from CAMTests.TestPathProfile import TestPathProfile
|
||||
@@ -99,21 +109,29 @@ from CAMTests.TestPathVcarve import TestPathVcarve
|
||||
from CAMTests.TestPathVoronoi import TestPathVoronoi
|
||||
|
||||
from CAMTests.TestGenericPost import TestGenericPost
|
||||
from CAMTests.TestGenericPlasma import TestGenericPlasma
|
||||
from CAMTests.TestLinuxCNCPost import TestLinuxCNCPost
|
||||
from CAMTests.TestFanucPost import TestFanucPost
|
||||
from CAMTests.TestGrblPost import TestGrblPost
|
||||
from CAMTests.TestMassoG3Post import TestMassoG3Post
|
||||
from CAMTests.TestCentroidPost import TestCentroidPost
|
||||
from CAMTests.TestMach3Mach4Post import TestMach3Mach4Post
|
||||
|
||||
# from CAMTests.TestGrblPost import TestGrblPost
|
||||
# from CAMTests.TestMassoG3Post import TestMassoG3Post
|
||||
# from CAMTests.TestCentroidPost import TestCentroidPost
|
||||
# from CAMTests.TestMach3Mach4Post import TestMach3Mach4Post
|
||||
from CAMTests.TestTestPost import TestTestPost
|
||||
from CAMTests.TestPostGCodes import TestPostGCodes
|
||||
from CAMTests.TestPostMCodes import TestPostMCodes
|
||||
from CAMTests.TestDressupPost import TestDressupPost
|
||||
|
||||
from CAMTests.TestLinuxCNCLegacyPost import TestLinuxCNCLegacyPost
|
||||
from CAMTests.TestGrblLegacyPost import TestGrblLegacyPost
|
||||
# from CAMTests.TestLinuxCNCLegacyPost import TestLinuxCNCLegacyPost
|
||||
# from CAMTests.TestGrblLegacyPost import TestGrblLegacyPost
|
||||
from CAMTests.TestCentroidLegacyPost import TestCentroidLegacyPost
|
||||
from CAMTests.TestMach3Mach4LegacyPost import TestMach3Mach4LegacyPost
|
||||
|
||||
from CAMTests.TestSnapmakerPost import TestSnapmakerPost
|
||||
from CAMTests.TestTSPSolver import TestTSPSolver
|
||||
from CAMTests.TestGcodeProcessingUtils import (
|
||||
TestInsertLineNumbers,
|
||||
TestSuppressRedundantAxesWords,
|
||||
TestFilterInefficientMoves,
|
||||
TestNumberGenerator,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user