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:
sliptonic
2026-03-13 13:18:45 -04:00
committed by GitHub
co-authored by tarman3
parent a78c31be69
commit 3c48b4a950
66 changed files with 10693 additions and 1102 deletions
+36
View File
@@ -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")
+1 -1
View File
@@ -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)
+520
View File
@@ -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")
+446 -35
View File
@@ -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":
+4 -4
View File
@@ -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"""
+5 -5
View File
@@ -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
+448
View File
@@ -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()
+3 -2
View File
@@ -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."""
+4 -5
View File
@@ -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
View File
@@ -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"
)
+2
View File
@@ -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();
}
/**
+18 -12
View File
@@ -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>
+26
View File
@@ -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()
+5 -125
View File
@@ -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
+5 -3
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+108 -1
View File
@@ -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
+99 -3
View File
@@ -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
+67
View File
@@ -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()
+196
View File
@@ -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."""
+85 -9
View File
@@ -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()
+367
View File
@@ -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
+121 -1
View File
@@ -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):
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -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
+5 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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
+27 -1
View File
@@ -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."""
+235 -65
View File
@@ -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.")
+389 -299
View File
@@ -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)
+8 -13
View File
@@ -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.
@@ -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
+1 -1
View File
@@ -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",
@@ -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
+97
View File
@@ -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
View File
@@ -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,
)