From cdd9d685fea43a7f47793f843eef916ccf954e0e Mon Sep 17 00:00:00 2001 From: sliptonic Date: Mon, 9 Mar 2026 13:02:31 -0500 Subject: [PATCH] CAM: Improvements to machine (#28011) * CAM: Improvements to machine * CAM: SPDX * lint and translation string problems * fixing headers and missing toolhead tests --------- Co-authored-by: tarman3 --- src/Mod/CAM/CAMTests/TestMachine.py | 299 +- src/Mod/CAM/CMakeLists.txt | 16 +- src/Mod/CAM/Machine/machines/Generic_Grbl.fcm | 156 + .../CAM/Machine/machines/Generic_LinuxCNC.fcm | 135 + .../CAM/Machine/machines/Generic_Smoothie.fcm | 150 + src/Mod/CAM/Machine/models/__init__.py | 52 + src/Mod/CAM/Machine/models/machine.py | 1872 ++++++++++++ src/Mod/CAM/{Path => }/Machine/ui/__init__.py | 4 +- src/Mod/CAM/Machine/ui/editor/__init__.py | 25 + .../CAM/Machine/ui/editor/machine_editor.py | 2643 +++++++++++++++++ .../ui/editor/postprocessor_properties.py | 155 + src/Mod/CAM/Path/Machine/models/__init__.py | 48 - src/Mod/CAM/Path/Machine/models/machine.py | 1398 --------- .../CAM/Path/Machine/ui/editor/__init__.py | 26 - .../Path/Machine/ui/editor/machine_editor.py | 1570 ---------- .../CAM/Path/Tool/assets/ui/preferences.py | 42 +- src/Mod/CAM/TestCAMApp.py | 45 +- 17 files changed, 5434 insertions(+), 3202 deletions(-) create mode 100644 src/Mod/CAM/Machine/machines/Generic_Grbl.fcm create mode 100644 src/Mod/CAM/Machine/machines/Generic_LinuxCNC.fcm create mode 100644 src/Mod/CAM/Machine/machines/Generic_Smoothie.fcm create mode 100644 src/Mod/CAM/Machine/models/__init__.py create mode 100644 src/Mod/CAM/Machine/models/machine.py rename src/Mod/CAM/{Path => }/Machine/ui/__init__.py (89%) create mode 100644 src/Mod/CAM/Machine/ui/editor/__init__.py create mode 100644 src/Mod/CAM/Machine/ui/editor/machine_editor.py create mode 100644 src/Mod/CAM/Machine/ui/editor/postprocessor_properties.py delete mode 100644 src/Mod/CAM/Path/Machine/models/__init__.py delete mode 100644 src/Mod/CAM/Path/Machine/models/machine.py delete mode 100644 src/Mod/CAM/Path/Machine/ui/editor/__init__.py delete mode 100644 src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py index 2c63b55e92..726e422b0e 100644 --- a/src/Mod/CAM/CAMTests/TestMachine.py +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -1,24 +1,32 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2025 Brad Collette * -# * * -# * 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. * -# * * -# *************************************************************************** +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Brad Collette +# 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 # +# # +################################################################################ import FreeCAD import tempfile import pathlib import CAMTests.PathTestUtils as PathTestUtils -from Path.Machine.models.machine import ( +from Machine.models.machine import ( Machine, - Spindle, + Toolhead, OutputOptions, - GCodeBlocks, ProcessingOptions, MachineFactory, ) @@ -58,12 +66,6 @@ class TestMachineDataclass(PathTestUtils.PathTestBase): machine.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), -360, 360) self.assertEqual(machine.machine_type, "xyzac") - # Coordinate system defaults - self.assertEqual(machine.reference_system["X"], FreeCAD.Vector(1, 0, 0)) - self.assertEqual(machine.reference_system["Y"], FreeCAD.Vector(0, 1, 0)) - self.assertEqual(machine.reference_system["Z"], FreeCAD.Vector(0, 0, 1)) - self.assertEqual(machine.tool_axis, FreeCAD.Vector(0, 0, -1)) - # Units and versioning self.assertEqual(machine.configuration_units, "metric") self.assertEqual(machine.version, 1) @@ -71,8 +73,9 @@ class TestMachineDataclass(PathTestUtils.PathTestBase): # Post-processor defaults self.assertIsInstance(machine.output, OutputOptions) - self.assertIsInstance(machine.blocks, GCodeBlocks) self.assertIsInstance(machine.processing, ProcessingOptions) + self.assertEqual(machine.postprocessor_file_name, "") + self.assertIsInstance(machine.postprocessor_properties, dict) def test_custom_initialization(self): """Test Machine initialization with custom values and verify machine_type is derived""" @@ -113,44 +116,99 @@ class TestOutputOptions(PathTestUtils.PathTestBase): """Test OutputOptions initialization with defaults""" opts = OutputOptions() - # Default values - self.assertTrue(opts.comments) - self.assertTrue(opts.blank_lines) - self.assertTrue(opts.header) + # Default values - using current field names + from Machine.models.machine import OutputUnits + + self.assertEqual(opts.output_units, OutputUnits.METRIC) + self.assertEqual(opts.command_space, " ") + self.assertEqual(opts.comment_symbol, "(") + self.assertEqual(opts.end_of_line_chars, "\n") + self.assertEqual(opts.line_increment, 10) + self.assertEqual(opts.line_number_start, 100) self.assertFalse(opts.line_numbers) - self.assertFalse(opts.bcnc_blocks) - self.assertFalse(opts.path_labels) - self.assertFalse(opts.machine_name) - self.assertTrue(opts.tool_change) - self.assertTrue(opts.doubles) - self.assertFalse(opts.adaptive) + self.assertEqual(opts.line_number_prefix, "N") + self.assertTrue(opts.output_comments) + self.assertTrue(opts.output_blank_lines) + self.assertTrue(opts.output_bcnc_comments) + self.assertTrue(opts.output_header) + self.assertFalse(opts.output_labels) + self.assertTrue(opts.output_operation_labels) + self.assertFalse(opts.list_tools_in_header) + self.assertTrue(opts.list_fixtures_in_header) + self.assertFalse(opts.machine_name_in_header) + self.assertTrue(opts.description_in_header) + self.assertTrue(opts.project_file_in_header) + self.assertTrue(opts.output_units_in_header) + self.assertTrue(opts.date_in_header) + self.assertTrue(opts.document_name_in_header) + self.assertTrue(opts.output_duplicate_parameters) + self.assertTrue(opts.output_duplicate_commands) + self.assertEqual(opts.axis_precision, 3) + self.assertEqual(opts.feed_precision, 3) + self.assertEqual(opts.spindle_precision, 0) def test_custom_initialization(self): """Test OutputOptions initialization with custom values""" + from Machine.models.machine import OutputUnits + opts = OutputOptions( - comments=False, - blank_lines=False, - header=False, + output_units=OutputUnits.IMPERIAL, + command_space="", + comment_symbol=";", + end_of_line_chars="\r\n", + line_increment=5, + line_number_start=10, line_numbers=True, - bcnc_blocks=True, - path_labels=True, - machine_name=True, - tool_change=False, - doubles=False, - adaptive=True, + line_number_prefix="L", + output_comments=False, + output_blank_lines=False, + output_bcnc_comments=False, + output_header=False, + output_labels=True, + output_operation_labels=False, + list_tools_in_header=True, + list_fixtures_in_header=False, + machine_name_in_header=True, + description_in_header=False, + project_file_in_header=False, + output_units_in_header=False, + date_in_header=False, + document_name_in_header=False, + output_duplicate_parameters=False, + output_duplicate_commands=False, + axis_precision=4, + feed_precision=2, + spindle_precision=1, ) # Verify custom values - self.assertFalse(opts.comments) - self.assertFalse(opts.blank_lines) - self.assertFalse(opts.header) + self.assertEqual(opts.output_units, OutputUnits.IMPERIAL) + self.assertEqual(opts.command_space, "") + self.assertEqual(opts.comment_symbol, ";") + self.assertEqual(opts.end_of_line_chars, "\r\n") + self.assertEqual(opts.line_increment, 5) + self.assertEqual(opts.line_number_start, 10) self.assertTrue(opts.line_numbers) - self.assertTrue(opts.bcnc_blocks) - self.assertTrue(opts.path_labels) - self.assertTrue(opts.machine_name) - self.assertFalse(opts.tool_change) - self.assertFalse(opts.doubles) - self.assertTrue(opts.adaptive) + self.assertEqual(opts.line_number_prefix, "L") + self.assertFalse(opts.output_comments) + self.assertFalse(opts.output_blank_lines) + self.assertFalse(opts.output_bcnc_comments) + self.assertFalse(opts.output_header) + self.assertTrue(opts.output_labels) + self.assertFalse(opts.output_operation_labels) + self.assertTrue(opts.list_tools_in_header) + self.assertFalse(opts.list_fixtures_in_header) + self.assertTrue(opts.machine_name_in_header) + self.assertFalse(opts.description_in_header) + self.assertFalse(opts.project_file_in_header) + self.assertFalse(opts.output_units_in_header) + self.assertFalse(opts.date_in_header) + self.assertFalse(opts.document_name_in_header) + self.assertFalse(opts.output_duplicate_parameters) + self.assertFalse(opts.output_duplicate_commands) + self.assertEqual(opts.axis_precision, 4) + self.assertEqual(opts.feed_precision, 2) + self.assertEqual(opts.spindle_precision, 1) def test_equality(self): """Test OutputOptions equality comparison""" @@ -158,63 +216,98 @@ class TestOutputOptions(PathTestUtils.PathTestBase): opts2 = OutputOptions() self.assertEqual(opts1, opts2) - opts2.comments = False + opts2.output_comments = False self.assertNotEqual(opts1, opts2) -class TestSpindle(PathTestUtils.PathTestBase): - """Test Spindle dataclass""" +class TestProcessingOptions(PathTestUtils.PathTestBase): + """Test ProcessingOptions dataclass""" - def test_spindle_initialization(self): - """Test Spindle initialization with defaults""" - spindle = Spindle( - name="Main Spindle", + def test_default_initialization(self): + """Test ProcessingOptions initialization with defaults""" + opts = ProcessingOptions() + + # Default values + self.assertFalse(opts.early_tool_prep) + self.assertFalse(opts.filter_inefficient_moves) + self.assertFalse(opts.split_arcs) + self.assertTrue(opts.tool_change) + self.assertFalse(opts.translate_rapid_moves) + self.assertIsNone(opts.return_to) + + def test_custom_initialization(self): + """Test ProcessingOptions initialization with custom values""" + opts = ProcessingOptions( + early_tool_prep=True, + filter_inefficient_moves=True, + split_arcs=True, + tool_change=False, + translate_rapid_moves=True, + return_to=(10.0, 20.0, 30.0), + ) + + # Verify custom values + self.assertTrue(opts.early_tool_prep) + self.assertTrue(opts.filter_inefficient_moves) + self.assertTrue(opts.split_arcs) + self.assertFalse(opts.tool_change) + self.assertTrue(opts.translate_rapid_moves) + self.assertEqual(opts.return_to, (10.0, 20.0, 30.0)) + + def test_equality(self): + """Test ProcessingOptions equality comparison""" + opts1 = ProcessingOptions() + opts2 = ProcessingOptions() + self.assertEqual(opts1, opts2) + + opts2.filter_inefficient_moves = True + self.assertNotEqual(opts1, opts2) + + +class TestToolhead(PathTestUtils.PathTestBase): + """Test Toolhead dataclass""" + + def test_toolhead_initialization(self): + """Test Toolhead initialization with defaults""" + toolhead = Toolhead( + name="Main Toolhead", max_power_kw=5.5, max_rpm=24000, min_rpm=1000, tool_change="automatic", ) - self.assertEqual(spindle.name, "Main Spindle") - self.assertEqual(spindle.max_power_kw, 5.5) - self.assertEqual(spindle.max_rpm, 24000) - self.assertEqual(spindle.min_rpm, 1000) - self.assertEqual(spindle.tool_change, "automatic") - # Default tool axis should be set - self.assertEqual(spindle.tool_axis, FreeCAD.Vector(0, 0, -1)) + self.assertEqual(toolhead.name, "Main Toolhead") + self.assertEqual(toolhead.max_power_kw, 5.5) + self.assertEqual(toolhead.max_rpm, 24000) + self.assertEqual(toolhead.min_rpm, 1000) + self.assertEqual(toolhead.tool_change, "automatic") + # Default toolhead_wait should be 0.0 + self.assertEqual(toolhead.toolhead_wait, 0.0) - def test_spindle_custom_tool_axis(self): - """Test Spindle with custom tool axis""" - spindle = Spindle( - name="Side Spindle", - tool_axis=FreeCAD.Vector(1, 0, 0), - ) - - self.assertEqual(spindle.tool_axis, FreeCAD.Vector(1, 0, 0)) - - def test_spindle_serialization(self): + def test_toolhead_serialization(self): """Test to_dict and from_dict""" - spindle = Spindle( - name="Test Spindle", - id="spindle-001", + toolhead = Toolhead( + name="Test Toolhead", + id="toolhead-001", max_power_kw=3.0, max_rpm=18000, min_rpm=500, tool_change="manual", - tool_axis=FreeCAD.Vector(0, 1, 0), + toolhead_wait=1.5, ) - data = spindle.to_dict() - self.assertEqual(data["name"], "Test Spindle") - self.assertEqual(data["id"], "spindle-001") + data = toolhead.to_dict() + self.assertEqual(data["name"], "Test Toolhead") + self.assertEqual(data["id"], "toolhead-001") self.assertEqual(data["max_power_kw"], 3.0) - self.assertEqual(data["tool_axis"], [0, 1, 0]) + self.assertEqual(data["toolhead_wait"], 1.5) - restored = Spindle.from_dict(data) - self.assertEqual(restored.name, spindle.name) - self.assertEqual(restored.id, spindle.id) - self.assertEqual(restored.max_power_kw, spindle.max_power_kw) - self.assertEqual(restored.tool_axis, spindle.tool_axis) + restored = Toolhead.from_dict(data) + self.assertEqual(restored.name, toolhead.name) + self.assertEqual(restored.id, toolhead.id) + self.assertEqual(restored.max_power_kw, toolhead.max_power_kw) + self.assertEqual(restored.toolhead_wait, toolhead.toolhead_wait) class TestMachineFactory(PathTestUtils.PathTestBase): @@ -257,14 +350,14 @@ class TestMachineFactory(PathTestUtils.PathTestBase): machine.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) machine.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - # Add a spindle - spindle = Spindle( - name="Main Spindle", + # Add a toolhead + toolhead = Toolhead( + name="Main Toolhead", max_power_kw=5.5, max_rpm=24000, min_rpm=1000, ) - machine.spindles.append(spindle) + machine.toolheads.append(toolhead) # Save configuration filepath = MachineFactory.save_configuration(machine, "test_machine.fcm") @@ -279,8 +372,8 @@ class TestMachineFactory(PathTestUtils.PathTestBase): self.assertEqual(loaded_machine.description, "Test description") self.assertEqual(loaded_machine.machine_type, "xyz") self.assertEqual(loaded_machine.configuration_units, "metric") - self.assertEqual(len(loaded_machine.spindles), 1) - self.assertEqual(loaded_machine.spindles[0].name, "Main Spindle") + self.assertEqual(len(loaded_machine.toolheads), 1) + self.assertEqual(loaded_machine.toolheads[0].name, "Main Toolhead") def test_save_configuration_auto_filename(self): """Test saving with automatic filename generation""" @@ -305,7 +398,7 @@ class TestMachineFactory(PathTestUtils.PathTestBase): # The data structure has nested "machine" key self.assertIn("machine", data) self.assertEqual(data["machine"]["name"], "New Machine") - self.assertIn("spindles", data["machine"]) + self.assertIn("toolheads", data["machine"]) def test_list_configuration_files(self): """Test listing available configuration files""" @@ -381,9 +474,9 @@ class TestMachineFactory(PathTestUtils.PathTestBase): configuration_units="metric", ) - # Add spindle - machine.spindles.append( - Spindle( + # Add toolhead + machine.toolheads.append( + Toolhead( name="Main", max_power_kw=7.5, max_rpm=30000, @@ -391,9 +484,9 @@ class TestMachineFactory(PathTestUtils.PathTestBase): ) # Configure post-processor settings - machine.output.output_comments = False - machine.output.axis_precision = 4 - machine.output.line_increment = 5 + machine.output.comments.enabled = False + machine.output.precision.axis = 4 + machine.output.formatting.line_increment = 5 # line_increment is set to default 10 in OutputOptions @@ -404,7 +497,7 @@ class TestMachineFactory(PathTestUtils.PathTestBase): # Verify all components self.assertEqual(loaded.name, machine.name) self.assertEqual(loaded.manufacturer, machine.manufacturer) - self.assertEqual(len(loaded.spindles), 1) - self.assertFalse(loaded.output.output_comments) - self.assertEqual(loaded.output.axis_precision, 4) - self.assertEqual(loaded.output.line_increment, 5) + self.assertEqual(len(loaded.toolheads), 1) + self.assertFalse(loaded.output.comments.enabled) + self.assertEqual(loaded.output.precision.axis, 4) + self.assertEqual(loaded.output.formatting.line_increment, 5) diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 56043e3621..664c221873 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -171,17 +171,17 @@ SET(PathPythonToolsGui_SRCS ) SET(PathPythonMachineUi_SRCS - Path/Machine/ui/__init__.py + Machine/ui/__init__.py ) SET(PathPythonMachineUiEditor_SRCS - Path/Machine/ui/editor/machine_editor.py - Path/Machine/ui/editor/__init__.py + Machine/ui/editor/machine_editor.py + Machine/ui/editor/__init__.py ) SET(PathPythonMachineModels_SRCS - Path/Machine/models/__init__.py - Path/Machine/models/machine.py + Machine/models/__init__.py + Machine/models/machine.py ) SET(PathPythonToolsToolBit_SRCS @@ -927,21 +927,21 @@ INSTALL( FILES ${PathPythonMachineModels_SRCS} DESTINATION - Mod/CAM/Path/Machine/models + Mod/CAM/Machine/models ) INSTALL( FILES ${PathPythonMachineUi_SRCS} DESTINATION - Mod/CAM/Path/Machine/ui + Mod/CAM/Machine/ui ) INSTALL( FILES ${PathPythonMachineUiEditor_SRCS} DESTINATION - Mod/CAM/Path/Machine/ui/editor + Mod/CAM/Machine/ui/editor ) INSTALL( diff --git a/src/Mod/CAM/Machine/machines/Generic_Grbl.fcm b/src/Mod/CAM/Machine/machines/Generic_Grbl.fcm new file mode 100644 index 0000000000..5419e2a086 --- /dev/null +++ b/src/Mod/CAM/Machine/machines/Generic_Grbl.fcm @@ -0,0 +1,156 @@ +{ + "freecad_version": "1.2.0", + "machine": { + "axes": { + "X": { + "joint": [ + [ + 1.0, + 0.0, + 0.0 + ], + [ + 0, + 0, + 0 + ] + ], + "max": 1000, + "max_velocity": 10000, + "min": 0.0, + "sequence": 0, + "type": "linear" + }, + "Y": { + "joint": [ + [ + 0.0, + 1.0, + 0.0 + ], + [ + 0, + 0, + 0 + ] + ], + "max": 1000, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear" + }, + "Z": { + "joint": [ + [ + 0.0, + 0.0, + 1.0 + ], + [ + 0, + 0, + 0 + ] + ], + "max": 1000, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear" + } + }, + "description": "3 axis mill using Grbl controller", + "manufacturer": "", + "name": "Generic Grbl", + "spindles": [ + { + "id": "spindle1", + "max_power_kw": 1.5, + "max_rpm": 24000, + "min_rpm": 0, + "name": "Spindle 1", + "tool_axis": [ + 0.0, + 0.0, + -1.0 + ], + "tool_change": "manual", + "coolant_flood": false, + "coolant_mist": false, + "coolant_delay": 0.0, + "spindle_wait": 0.0 + } + ], + "units": "metric" + }, + "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": false, + "include_project_file": true, + "include_units": true, + "include_tool_list": false, + "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": { + "supports_tool_radius_compensation": false, + "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", + "drill_cycles_to_translate": "G73\nG81\nG82\nG83", + "preamble": "G17 G90", + "postamble": "M5\nG17 G90\nM2", + "safetyblock": "", + "pre_operation": "", + "post_operation": "", + "pre_tool_change": "", + "post_tool_change": "", + "pre_job": "", + "post_job": "", + "pre_fixture_change": "", + "post_fixture_change": "", + "pre_rotary_move": "", + "post_rotary_move": "", + "tool_return": "" + } + }, + "processing": { + "early_tool_prep": false, + "filter_inefficient_moves": false, + "split_arcs": false, + "tool_change": false, + "translate_rapid_moves": true + }, + "version": 1 +} diff --git a/src/Mod/CAM/Machine/machines/Generic_LinuxCNC.fcm b/src/Mod/CAM/Machine/machines/Generic_LinuxCNC.fcm new file mode 100644 index 0000000000..8be020c84a --- /dev/null +++ b/src/Mod/CAM/Machine/machines/Generic_LinuxCNC.fcm @@ -0,0 +1,135 @@ +{ + "freecad_version": "1.2.0", + "machine": { + "axes": { + "X": { + "type": "linear", + "role": "table_linear", + "parent": null, + "sequence": 0, + "joint": { + "origin": [0, 0, 0], + "axis": [1, 0, 0] + }, + "limits": { + "min": 0, + "max": 1000 + }, + "max_velocity": 10000 + }, + "Y": { + "type": "linear", + "role": "table_linear", + "parent": null, + "sequence": 0, + "joint": { + "origin": [0, 0, 0], + "axis": [0, 1, 0] + }, + "limits": { + "min": 0, + "max": 1000 + }, + "max_velocity": 10000 + }, + "Z": { + "type": "linear", + "role": "head_linear", + "parent": null, + "sequence": 0, + "joint": { + "origin": [0, 0, 0], + "axis": [0, 0, 1] + }, + "limits": { + "min": 0, + "max": 1000 + }, + "max_velocity": 10000 + } + }, + "description": "Basic LinuxCNC configuration", + "manufacturer": "", + "name": "Generic LinuxCNC", + "toolheads": [ + { + "id": "toolhead1", + "name": "Toolhead 1", + "toolhead_type": "rotary", + "tool_change": "manual", + "max_power_kw": 3.0, + "min_rpm": 6000, + "max_rpm": 24000, + "coolant_flood": false, + "coolant_mist": false, + "coolant_delay": 0.0, + "toolhead_wait": 0.0 + } + ], + "units": "metric" + }, + "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": false, + "include_project_file": true, + "include_units": true, + "include_tool_list": false, + "include_fixture_list": true + }, + "comments": { + "enabled": true, + "symbol": "(", + "include_operation_labels": true, + "include_blank_lines": true, + "output_bcnc_comments": true + }, + "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": "linuxcnc", + "properties": { + "supports_tool_radius_compensation": true, + "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", + "drill_cycles_to_translate": "", + "preamble": "G17 G54 G40 G49 G80 G90", + "postamble": "M05\nG17 G54 G90 G80 G40\nM2", + "safetyblock": "G40 G49 G80", + "pre_operation": "", + "post_operation": "", + "pre_tool_change": "", + "post_tool_change": "", + "blend_mode": "BLEND", + "blend_tolerance": 0.0 + } + }, + "processing": { + "early_tool_prep": false, + "filter_inefficient_moves": false, + "split_arcs": false, + "tool_change": true, + "translate_rapid_moves": false + }, + "version": 1 +} diff --git a/src/Mod/CAM/Machine/machines/Generic_Smoothie.fcm b/src/Mod/CAM/Machine/machines/Generic_Smoothie.fcm new file mode 100644 index 0000000000..0d97030baf --- /dev/null +++ b/src/Mod/CAM/Machine/machines/Generic_Smoothie.fcm @@ -0,0 +1,150 @@ +{ + "blocks": { + "postamble": "M05\nG17 G90\nM2", + "preamble": "G17 G90" + }, + "freecad_version": "1.2.0", + "machine": { + "axes": { + "X": { + "joint": [ + [ + 1.0, + 0.0, + 0.0 + ], + [ + 0, + 0, + 0 + ] + ], + "max": 500, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear" + }, + "Y": { + "joint": [ + [ + 0.0, + 1.0, + 0.0 + ], + [ + 0, + 0, + 0 + ] + ], + "max": 300, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear" + }, + "Z": { + "joint": [ + [ + 0.0, + 0.0, + 1.0 + ], + [ + 0, + 0, + 0 + ] + ], + "max": 300, + "max_velocity": 10000, + "min": 0, + "sequence": 0, + "type": "linear" + } + }, + "description": "3 axis CNC using SmoothieBoard controller", + "manufacturer": "Smoothieware", + "name": "Generic SmoothieBoard", + "spindles": [ + { + "id": "spindle1", + "max_power_kw": 2.2, + "max_rpm": 24000, + "min_rpm": 0, + "name": "Spindle 1", + "tool_axis": [ + 0.0, + 0.0, + -1.0 + ], + "tool_change": "manual", + "coolant_flood": false, + "coolant_mist": false, + "coolant_delay": 0.0 + } + ], + "units": "metric" + }, + "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": false, + "include_project_file": true, + "include_units": true, + "include_tool_list": false, + "include_fixture_list": true + }, + "comments": { + "enabled": true, + "symbol": "(", + "include_operation_labels": true, + "include_blank_lines": true, + "output_bcnc_comments": true + }, + "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": { + "args": "", + "file_name": "smoothie" + }, + "processing": { + "drill_cycles_to_translate": [ + "G73", + "G81", + "G82", + "G83" + ], + "early_tool_prep": false, + "filter_inefficient_moves": false, + "spindle_wait": 0.0, + "split_arcs": false, + "suppress_commands": [], + "tool_change": true, + "translate_drill_cycles": false, + "translate_rapid_moves": false + }, + "version": 1 +} diff --git a/src/Mod/CAM/Machine/models/__init__.py b/src/Mod/CAM/Machine/models/__init__.py new file mode 100644 index 0000000000..7b851ba4e4 --- /dev/null +++ b/src/Mod/CAM/Machine/models/__init__.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Billy Huddleston +# 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 # +# # +################################################################################ + +from .machine import ( + Machine, + MachineFactory, + LinearAxis, + RotaryAxis, + Toolhead, + ToolheadType, + MachineUnits, + OutputUnits, + OutputOptions, + ProcessingOptions, +) + +# Legacy compatibility aliases +Spindle = Toolhead +SpindleType = ToolheadType + +__all__ = [ + "Machine", + "MachineFactory", + "LinearAxis", + "RotaryAxis", + "Toolhead", + "ToolheadType", + "Spindle", # Legacy alias + "SpindleType", # Legacy alias + "MachineUnits", + "OutputUnits", + "OutputOptions", + "ProcessingOptions", +] diff --git a/src/Mod/CAM/Machine/models/machine.py b/src/Mod/CAM/Machine/models/machine.py new file mode 100644 index 0000000000..2c76ea8ca2 --- /dev/null +++ b/src/Mod/CAM/Machine/models/machine.py @@ -0,0 +1,1872 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Billy Huddleston +# 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 # +# # +################################################################################ + +import json +import Path +import FreeCAD +import pathlib +from dataclasses import dataclass, field +from typing import Dict, Any, List, Optional, Tuple, Callable +from collections import namedtuple +from enum import Enum + + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +# Reference axis vectors +RefAxes = namedtuple("RefAxes", ["x", "y", "z"]) +refAxis = RefAxes( + FreeCAD.Vector(1, 0, 0), # x: linear direction + FreeCAD.Vector(0, 1, 0), # y: linear direction + FreeCAD.Vector(0, 0, 1), # z: linear direction +) + +RefRotAxes = namedtuple("RefRotAxes", ["a", "b", "c"]) +refRotAxis = RefRotAxes( + FreeCAD.Vector(1, 0, 0), # a: rotational direction + FreeCAD.Vector(0, 1, 0), # b: rotational direction + FreeCAD.Vector(0, 0, 1), # c: rotational direction +) + + +# ============================================================================ +# Enums for Machine Configuration +# ============================================================================ + + +class MachineUnits(Enum): + """Machine unit system.""" + + METRIC = "G21" + IMPERIAL = "G20" + + +class OutputUnits(Enum): + """Output unit system for G-code generation.""" + + METRIC = "metric" + IMPERIAL = "imperial" + + +class AxisRole(Enum): + """Axis role in kinematic chain.""" + + TABLE_LINEAR = "table_linear" + TABLE_ROTARY = "table_rotary" + HEAD_LINEAR = "head_linear" + HEAD_ROTARY = "head_rotary" + + +# ============================================================================ +# Post-Processor Configuration Dataclasses +# ============================================================================ + + +@dataclass +class HeaderOptions: + """Controls what gets included in the G-code header.""" + + include_date: bool = True + include_description: bool = True + include_document_name: bool = True + include_machine_name: bool = True + include_project_file: bool = True + include_units: bool = True + include_tool_list: bool = True + include_fixture_list: bool = True + + +@dataclass +class CommentOptions: + """Controls comment formatting and inclusion.""" + + enabled: bool = True + symbol: str = "(" + include_operation_labels: bool = False + include_blank_lines: bool = True + output_bcnc_comments: bool = False + + +@dataclass +class FormattingOptions: + """Controls line numbering and spacing.""" + + line_numbers: bool = False + line_number_start: int = 100 + line_number_prefix: str = "N" + line_increment: int = 10 + command_space: str = " " + end_of_line_chars: str = "\n" + + +@dataclass +class PrecisionOptions: + """Controls numeric precision settings.""" + + axis: int = 3 + feed: int = 3 + spindle: int = 0 + + +@dataclass +class DuplicateOptions: + """Controls duplicate output (positive framing: True = output duplicates, False = suppress).""" + + commands: bool = True # When False, suppress repeated G/M codes (modal) + parameters: bool = True # When False, suppress repeated parameter values (modal) + + +@dataclass +class OutputOptions: + """Controls what gets included in the G-code output and its formatting.""" + + # Main output options + units: OutputUnits = OutputUnits.METRIC # G-code output units + output_tool_length_offset: bool = True # Output G43 H{tool} after M6 tool changes + remote_post: bool = False # Enable remote posting to network endpoint + output_header: bool = True # Control entire header output independently of comments + + # Nested configuration sections + header: HeaderOptions = field(default_factory=HeaderOptions) + comments: CommentOptions = field(default_factory=CommentOptions) + formatting: FormattingOptions = field(default_factory=FormattingOptions) + precision: PrecisionOptions = field(default_factory=PrecisionOptions) + duplicates: DuplicateOptions = field(default_factory=DuplicateOptions) + + +@dataclass +class ProcessingOptions: + """Processing and transformation options.""" + + # Conversion and expansion of Path Objects. Does not affect final gcode generation + + early_tool_prep: bool = False # Prepare tool before operation (affects postlist ordering) + filter_inefficient_moves: bool = False # Collapse redundant G0 rapid move chains + split_arcs: bool = False + tool_change: bool = True # Enable tool change commands + translate_rapid_moves: bool = False + xy_before_z_after_tool_change: bool = ( + False # Decompose first move after tool change: XY first, then Z + ) + + return_to: Optional[Tuple[float, float, float]] = None # (x, y, z) or None + + +# ============================================================================ +# Machine Component Dataclasses +# ============================================================================ + + +@dataclass +class BaseFrame: + """Base coordinate frame definition.""" + + origin: List[float] = field(default_factory=lambda: [0, 0, 0]) + orientation_quaternion: List[float] = field(default_factory=lambda: [0, 0, 0, 1]) + + +@dataclass +class Kinematics: + """Machine kinematics configuration.""" + + base_frame: BaseFrame = field(default_factory=BaseFrame) + tcp_supported: bool = False + dwo_supported: bool = False + notes: str = "" + + +@dataclass +class LinearAxis: + """Represents a single linear axis in a machine configuration""" + + name: str + direction_vector: FreeCAD.Vector + min_limit: float = 0 + max_limit: float = 1000 + max_velocity: float = 10000 + sequence: int = 0 + role: AxisRole = AxisRole.TABLE_LINEAR + parent: Optional[str] = None + joint_origin: List[float] = field(default_factory=lambda: [0, 0, 0]) + + def __post_init__(self): + """Normalize direction vector and validate parameters after initialization""" + if self.direction_vector is None or self.direction_vector.Length < 1e-6: + # Default to X-axis if vector is null or zero-length + self.direction_vector = FreeCAD.Vector(1, 0, 0) + else: + self.direction_vector = self.direction_vector.normalize() + + # Validate limits + if self.min_limit >= self.max_limit: + Path.Log.warning( + f"LinearAxis {self.name}: min_limit ({self.min_limit}) >= max_limit ({self.max_limit})" + ) + + # Validate velocity + if self.max_velocity <= 0: + Path.Log.warning( + f"LinearAxis {self.name}: max_velocity must be positive, got {self.max_velocity}" + ) + + def is_valid_position(self, position): + """Check if a position is within this axis's limits""" + return self.min_limit <= position <= self.max_limit + + def to_dict(self): + """Serialize to dictionary for JSON persistence""" + return { + "name": self.name, + "direction_vector": [ + self.direction_vector.x, + self.direction_vector.y, + self.direction_vector.z, + ], + "min_limit": self.min_limit, + "max_limit": self.max_limit, + "max_velocity": self.max_velocity, + "sequence": self.sequence, + "role": self.role.value, + "parent": self.parent, + "joint_origin": self.joint_origin, + } + + @classmethod + def from_dict(cls, data): + """Deserialize from dictionary""" + vec = FreeCAD.Vector( + data["direction_vector"][0], data["direction_vector"][1], data["direction_vector"][2] + ) + return cls( + data["name"], + vec, + data.get("min_limit", 0), + data.get("max_limit", 1000), + data.get("max_velocity", 10000), + data.get("sequence", 0), + AxisRole(data.get("role", "table_linear")), + data.get("parent"), + data.get("joint_origin", [0, 0, 0]), + ) + + +@dataclass +class RotaryAxis: + """Represents a single rotary axis in a machine configuration""" + + name: str + rotation_vector: FreeCAD.Vector + min_limit: float = -360 + max_limit: float = 360 + max_velocity: float = 36000 + sequence: int = 0 + prefer_positive: bool = True + role: AxisRole = AxisRole.TABLE_ROTARY + parent: Optional[str] = None + joint_origin: List[float] = field(default_factory=lambda: [0, 0, 0]) + solution_preference: str = "shortest" # "shortest", "positive", "negative" + allow_flip: bool = True + + def __post_init__(self): + """Normalize rotation vector and validate parameters after initialization""" + if self.rotation_vector is None or self.rotation_vector.Length < 1e-6: + # Default to Z-axis rotation if vector is null or zero-length + self.rotation_vector = FreeCAD.Vector(0, 0, 1) + else: + self.rotation_vector = self.rotation_vector.normalize() + + # Validate limits + if self.min_limit >= self.max_limit: + Path.Log.warning( + f"RotaryAxis {self.name}: min_limit ({self.min_limit}) >= max_limit ({self.max_limit})" + ) + + # Validate velocity + if self.max_velocity <= 0: + Path.Log.warning( + f"RotaryAxis {self.name}: max_velocity must be positive, got {self.max_velocity}" + ) + + def is_valid_angle(self, angle): + """Check if an angle is within this axis's limits""" + return self.min_limit <= angle <= self.max_limit + + def to_dict(self): + """Serialize to dictionary for JSON persistence""" + return { + "name": self.name, + "rotation_vector": [ + self.rotation_vector.x, + self.rotation_vector.y, + self.rotation_vector.z, + ], + "min_limit": self.min_limit, + "max_limit": self.max_limit, + "max_velocity": self.max_velocity, + "sequence": self.sequence, + "prefer_positive": self.prefer_positive, + "role": self.role.value, + "parent": self.parent, + "joint_origin": self.joint_origin, + "solution_preference": self.solution_preference, + "allow_flip": self.allow_flip, + } + + @classmethod + def from_dict(cls, data): + """Deserialize from dictionary""" + vec = FreeCAD.Vector( + data["rotation_vector"][0], data["rotation_vector"][1], data["rotation_vector"][2] + ) + return cls( + data["name"], + vec, + data["min_limit"], + data["max_limit"], + data.get("max_velocity", 36000), + data.get("sequence", 0), + data.get("prefer_positive", True), + AxisRole(data.get("role", "table_rotary")), + data.get("parent"), + data.get("joint_origin", [0, 0, 0]), + data.get("solution_preference", "shortest"), + data.get("allow_flip", True), + ) + + +class ToolheadType(Enum): + """Types of toolheads/tools supported by the machine.""" + + ROTARY = "rotary" # Traditional rotary spindle (router, drill, etc.) + LASER = "laser" # Laser cutting/engraving + WATERJET = "waterjet" # Waterjet cutting + PLASMA = "plasma" # Plasma cutting + + +@dataclass +class ToolheadCapabilities: + """Defines the capabilities of a toolhead based on its type.""" + + # Motion capabilities + can_rotate: bool = True + can_move_z: bool = True + can_move_xy: bool = False + + # Power control + has_power_control: bool = True + has_speed_control: bool = True + has_pulse_control: bool = False # For lasers + + # Coolant/support systems + uses_coolant: bool = False + uses_assist_gas: bool = False # For plasma/laser + uses_water: bool = False # For waterjet + + # Special capabilities + can_turn_on_off: bool = True + has_probing: bool = False + has_auto_focus: bool = False # For lasers + + @classmethod + def for_type(cls, toolhead_type: "ToolheadType") -> "ToolheadCapabilities": + """Get default capabilities for a given toolhead type.""" + capabilities = { + ToolheadType.ROTARY: cls( + can_rotate=True, + can_move_z=True, + can_move_xy=False, + has_power_control=True, + has_speed_control=True, + has_pulse_control=False, + uses_coolant=True, + uses_assist_gas=False, + uses_water=False, + can_turn_on_off=True, + has_probing=False, + has_auto_focus=False, + ), + ToolheadType.LASER: cls( + can_rotate=False, + can_move_z=True, + can_move_xy=False, + has_power_control=True, + has_speed_control=False, + has_pulse_control=True, + uses_coolant=False, + uses_assist_gas=True, + uses_water=False, + can_turn_on_off=True, + has_probing=False, + has_auto_focus=True, + ), + ToolheadType.WATERJET: cls( + can_rotate=False, + can_move_z=True, + can_move_xy=False, + has_power_control=True, + has_speed_control=False, + has_pulse_control=False, + uses_coolant=False, + uses_assist_gas=False, + uses_water=True, + can_turn_on_off=True, + has_probing=False, + has_auto_focus=False, + ), + ToolheadType.PLASMA: cls( + can_rotate=False, + can_move_z=True, + can_move_xy=False, + has_power_control=True, + has_speed_control=False, + has_pulse_control=False, + uses_coolant=False, + uses_assist_gas=True, + uses_water=False, + can_turn_on_off=True, + has_probing=False, + has_auto_focus=False, + ), + } + return capabilities.get(toolhead_type, capabilities[ToolheadType.ROTARY]) + + +@dataclass +class Toolhead: + """Represents a single toolhead in a machine configuration""" + + name: str + toolhead_type: ToolheadType = ToolheadType.ROTARY + id: Optional[str] = None + + # Power and performance specifications + max_power_kw: float = 0 + max_rpm: float = 0 # Only relevant for rotary toolheads + min_rpm: float = 0 # Only relevant for rotary toolheads + + # Tool change and handling + tool_change: str = "manual" + + # Coolant and support systems + coolant_flood: bool = False + coolant_mist: bool = False + coolant_delay: float = 0.0 + + # Timing and control + toolhead_wait: float = 0.0 # seconds to wait after toolhead start + + # Type-specific parameters + laser_wavelength: Optional[float] = None # nm, for lasers + laser_focus_range: Optional[Tuple[float, float]] = None # min/max focus distance + waterjet_pressure: Optional[float] = None # bar, for waterjets + plasma_amperage: Optional[float] = None # amps, for plasma + + # Capabilities (auto-generated based on type) + capabilities: Optional[ToolheadCapabilities] = None + + def __post_init__(self): + """Set default values and capabilities""" + if self.capabilities is None: + self.capabilities = ToolheadCapabilities.for_type(self.toolhead_type) + + # Set type-specific defaults + if self.toolhead_type == ToolheadType.LASER: + if self.laser_wavelength is None: + self.laser_wavelength = 1064.0 # Default fiber laser wavelength + elif self.toolhead_type == ToolheadType.WATERJET: + if self.waterjet_pressure is None: + self.waterjet_pressure = 4000.0 # Default 4000 bar + elif self.toolhead_type == ToolheadType.PLASMA: + if self.plasma_amperage is None: + self.plasma_amperage = 45.0 # Default 45 amps + + def is_rotary(self) -> bool: + """Check if this is a rotary toolhead.""" + return self.toolhead_type == ToolheadType.ROTARY + + def is_laser(self) -> bool: + """Check if this is a laser toolhead.""" + return self.toolhead_type == ToolheadType.LASER + + def is_waterjet(self) -> bool: + """Check if this is a waterjet toolhead.""" + return self.toolhead_type == ToolheadType.WATERJET + + def is_plasma(self) -> bool: + """Check if this is a plasma toolhead.""" + return self.toolhead_type == ToolheadType.PLASMA + + def can_use_coolant(self) -> bool: + """Check if this toolhead can use coolant.""" + return self.capabilities.uses_coolant if self.capabilities else False + + def can_control_speed(self) -> bool: + """Check if this toolhead can control speed.""" + return self.capabilities.has_speed_control if self.capabilities else False + + def can_control_power(self) -> bool: + """Check if this toolhead can control power.""" + return self.capabilities.has_power_control if self.capabilities else False + + # Legacy compatibility properties + @property + def spindle_wait(self) -> float: + """Legacy compatibility property for toolhead_wait.""" + return self.toolhead_wait + + @spindle_wait.setter + def spindle_wait(self, value: float): + """Legacy compatibility setter for toolhead_wait.""" + self.toolhead_wait = value + + @property + def spindle_type(self) -> ToolheadType: + """Legacy compatibility property for toolhead_type.""" + return self.toolhead_type + + @spindle_type.setter + def spindle_type(self, value: ToolheadType): + """Legacy compatibility setter for toolhead_type.""" + self.toolhead_type = value + + def to_dict(self): + """Serialize to dictionary for JSON persistence""" + data = { + "name": self.name, + "toolhead_type": self.toolhead_type.value, + "max_power_kw": self.max_power_kw, + "max_rpm": self.max_rpm, + "min_rpm": self.min_rpm, + "tool_change": self.tool_change, + "coolant_flood": self.coolant_flood, + "coolant_mist": self.coolant_mist, + "coolant_delay": self.coolant_delay, + "toolhead_wait": self.toolhead_wait, + } + + # Add type-specific parameters + if self.laser_wavelength is not None: + data["laser_wavelength"] = self.laser_wavelength + if self.laser_focus_range is not None: + data["laser_focus_range"] = list(self.laser_focus_range) + if self.waterjet_pressure is not None: + data["waterjet_pressure"] = self.waterjet_pressure + if self.plasma_amperage is not None: + data["plasma_amperage"] = self.plasma_amperage + + if self.id is not None: + data["id"] = self.id + + return data + + @classmethod + def from_dict(cls, data): + """Deserialize from dictionary""" + # Parse toolhead type + toolhead_type_str = data.get("toolhead_type", data.get("spindle_type", "rotary")) + toolhead_type = ToolheadType(toolhead_type_str) + + # Parse laser focus range + laser_focus_range = None + if "laser_focus_range" in data: + focus_data = data["laser_focus_range"] + laser_focus_range = (focus_data[0], focus_data[1]) + + return cls( + data["name"], + toolhead_type, + data.get("id"), + data.get("max_power_kw", 0), + data.get("max_rpm", 0), + data.get("min_rpm", 0), + data.get("tool_change", "manual"), + data.get("coolant_flood", False), + data.get("coolant_mist", False), + data.get("coolant_delay", 0.0), + data.get("toolhead_wait", data.get("spindle_wait", 0.0)), + data.get("laser_wavelength"), + laser_focus_range, + data.get("waterjet_pressure"), + data.get("plasma_amperage"), + ) + + +@dataclass +class Machine: + """Represents a CNC machine configuration with axes, spindles, and output settings. + + This class encapsulates all machine parameters including linear and rotary axes, + spindles, post-processor settings, and G-code generation options. It provides + methods for serialization to/from JSON and various factory methods for common + machine configurations (3-axis, 4-axis, 5-axis). + """ + + """ + Unified machine configuration combining physical machine definition + with post-processor settings. + + This is the single source of truth for all machine-related configuration, + including physical capabilities (axes, spindles) and G-code generation + preferences (output options, formatting, processing). + """ + + # ======================================================================== + # PHYSICAL MACHINE DEFINITION + # ======================================================================== + + # Basic identification + name: str = "Default Machine" + manufacturer: str = "" + description: str = "" + + # Machine components + linear_axes: Dict[str, LinearAxis] = field(default_factory=dict) + rotary_axes: Dict[str, RotaryAxis] = field(default_factory=dict) + toolheads: List[Toolhead] = field(default_factory=list) + + # Kinematics configuration + kinematics: Kinematics = field(default_factory=Kinematics) + + # Rotary axis configuration (legacy - will be deprecated) + primary_rotary_axis: Optional[str] = None + secondary_rotary_axis: Optional[str] = None + compound_moves: bool = True + prefer_positive_rotation: bool = True + + # Units and versioning + configuration_units: str = "metric" # Internal storage for configuration_units + version: int = 1 + freecad_version: str = field(init=False) + + # ======================================================================== + # POST-PROCESSOR CONFIGURATION + # ======================================================================== + + # Output options + output: OutputOptions = field(default_factory=OutputOptions) + processing: ProcessingOptions = field(default_factory=ProcessingOptions) + + # Post-processor selection and configuration + postprocessor_file_name: str = "" + postprocessor_properties: Dict[str, Any] = field(default_factory=dict) + + # Dynamic state (for runtime) + parameter_functions: Dict[str, Callable] = field(default_factory=dict) + + def __post_init__(self): + """Initialize computed fields and handle backward compatibility""" + # Initialize computed fields + self.freecad_version = ".".join(FreeCAD.Version()[0:3]) + + # Validate configuration_units + if self.configuration_units not in ["metric", "imperial"]: + raise ValueError( + f"configuration_units must be 'metric' or 'imperial', got '{self.configuration_units}'" + ) + + # ======================================================================== + # PROPERTIES - Bridge between physical machine and post-processor + # ======================================================================== + + @property + def machine_units(self) -> MachineUnits: + """Get machine configuration units as enum""" + return ( + MachineUnits.METRIC if self.configuration_units == "metric" else MachineUnits.IMPERIAL + ) + + @property + def output_machine_units(self) -> MachineUnits: + """Get output units as enum for G-code generation""" + return ( + MachineUnits.METRIC + if self.output.output_units == OutputUnits.METRIC + else MachineUnits.IMPERIAL + ) + + @property + def gcode_units(self) -> MachineUnits: + """Get G-code output units as enum for post-processor""" + return ( + MachineUnits.METRIC + if self.output.output_units == OutputUnits.METRIC + else MachineUnits.IMPERIAL + ) + + @property + def unit_format(self) -> str: + """Get machine configuration unit format string (mm or in)""" + return "mm" if self.configuration_units == "metric" else "in" + + @property + def output_unit_format(self) -> str: + """Get G-code output unit format string (mm or in)""" + return "mm" if self.output.output_units == OutputUnits.METRIC else "in" + + @property + def unit_speed_format(self) -> str: + """Get machine configuration unit speed format string (mm/min or in/min)""" + return "mm/min" if self.configuration_units == "metric" else "in/min" + + @property + def output_unit_speed_format(self) -> str: + """Get G-code output unit speed format string (mm/min or in/min)""" + return "mm/min" if self.output.output_units == OutputUnits.METRIC else "in/min" + + @property + def machine_type(self) -> str: + """ + Determine machine type based on available axes. + Returns one of: 'xyz', 'xyza', 'xyzb', 'xyzac', 'xyzbc', or 'custom' + """ + if not all(axis in self.linear_axes for axis in ["X", "Y", "Z"]): + return "custom" + + rot_axes = set(self.rotary_axes.keys()) + + # Check for 5-axis configurations + if {"A", "C"}.issubset(rot_axes): + return "xyzac" + if {"B", "C"}.issubset(rot_axes): + return "xyzbc" + + # Check for 4-axis configurations + if "A" in rot_axes: + return "xyza" + if "B" in rot_axes: + return "xyzb" + + # 3-axis configuration + return "xyz" + + @property + def has_rotary_axes(self) -> bool: + """Check if machine has any rotary axes""" + return len(self.rotary_axes) > 0 + + @property + def is_5axis(self) -> bool: + """Check if machine is 5-axis (2 rotary axes)""" + return len(self.rotary_axes) >= 2 + + @property + def is_4axis(self) -> bool: + """Check if machine is 4-axis (1 rotary axis)""" + return len(self.rotary_axes) == 1 + + @property + def motion_commands(self) -> List[str]: + """Get list of motion commands that change position""" + import Path.Geom as PathGeom + + return PathGeom.CmdMoveAll + + @property + def rapid_moves(self) -> List[str]: + """Get list of rapid move commands""" + import Path.Geom as PathGeom + + return PathGeom.CmdMoveRapid + + # ======================================================================== + # BUILDER METHODS - Fluent interface for machine construction + # ======================================================================== + + def add_linear_axis( + self, name, direction_vector, min_limit=0, max_limit=1000, max_velocity=10000 + ): + """Add a linear axis to the configuration""" + self.linear_axes[name] = LinearAxis( + name, direction_vector, min_limit, max_limit, max_velocity + ) + return self + + def add_rotary_axis( + self, name, rotation_vector, min_limit=-360, max_limit=360, max_velocity=36000 + ): + """Add a rotary axis to the configuration""" + self.rotary_axes[name] = RotaryAxis( + name, rotation_vector, min_limit, max_limit, max_velocity + ) + return self + + def add_spindle( + self, + name, + id=None, + max_power_kw=0, + max_rpm=0, + min_rpm=0, + tool_change="manual", + ): + """Add a toolhead to the configuration""" + self.toolheads.append(Toolhead(name, id, max_power_kw, max_rpm, min_rpm, tool_change)) + return self + + def save(self, filepath): + """Save this configuration to a file + + Args: + filepath: Path to save the configuration file + + Returns: + Path object of saved file + """ + filepath = pathlib.Path(filepath) + data = self.to_dict() + + try: + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + Path.Log.debug(f"Saved machine configuration to {filepath}") + return filepath + except Exception as e: + Path.Log.error(f"Failed to save configuration: {e}") + raise Exception(f"Failed to save machine file {filepath}: {e}") + + def set_alignment_axes(self, primary, secondary=None): + """Set the primary and secondary rotary axes for alignment strategy + + For 4-axis machines, secondary can be None (single rotary axis) + For 5-axis machines, both primary and secondary are required + """ + if primary not in self.rotary_axes: + raise ValueError(f"Primary axis {primary} not found in configuration") + + if secondary is not None and secondary not in self.rotary_axes: + raise ValueError(f"Secondary axis {secondary} not found in configuration") + + self.primary_rotary_axis = primary + self.secondary_rotary_axis = secondary + return self + + def get_axis_by_name(self, name): + """Get a rotary axis by name""" + return self.rotary_axes.get(name) + + def get_spindle_by_index(self, index: int) -> Optional[Toolhead]: + """Get toolhead by index (legacy compatibility method). + + Args: + index: Index of the toolhead to retrieve + + Returns: + Toolhead object if found, None otherwise + """ + if 0 <= index < len(self.toolheads): + return self.toolheads[index] + return None + + @property + def spindles(self) -> List[Toolhead]: + """Legacy compatibility property for toolheads.""" + return self.toolheads + + @spindles.setter + def spindles(self, value: List[Toolhead]): + """Legacy compatibility setter for toolheads.""" + self.toolheads = value + + def get_spindle_by_name(self, name): + """Get a toolhead by name (case-insensitive)""" + name_lower = name.lower() + for toolhead in self.toolheads: + if toolhead.name.lower() == name_lower: + return toolhead + raise ValueError(f"Toolhead with name '{name}' not found") + + def get_spindle_by_id(self, id): + """Get a toolhead by ID""" + for toolhead in self.toolheads: + if toolhead.id == id: + return toolhead + raise ValueError(f"Toolhead with ID '{id}' not found") + + @classmethod + def create_AC_table_config(cls, a_limits=(-120, 120), c_limits=(-360, 360)): + """Create standard A/C table configuration""" + config = cls("AC Table Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) + config.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), c_limits[0], c_limits[1]) + config.set_alignment_axes("C", "A") + return config + + @classmethod + def create_BC_head_config(cls, b_limits=(-120, 120), c_limits=(-360, 360)): + """Create standard B/C head configuration""" + config = cls("BC Head Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) + config.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), c_limits[0], c_limits[1]) + config.set_alignment_axes("C", "B") + config.compound_moves = True # Ensure compound moves are enabled for test compatibility + return config + + @classmethod + def create_AB_table_config(cls, a_limits=(-120, 120), b_limits=(-120, 120)): + """Create standard A/B table configuration""" + config = cls("AB Table Configuration") + # AB configuration will be detected as 'custom' by the machine_type property + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) + config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) + config.set_alignment_axes("A", "B") + return config + + @classmethod + def create_4axis_A_config(cls, a_limits=(-120, 120)): + """Create standard 4-axis XYZA configuration (rotary table around X)""" + config = cls("4-Axis XYZA Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) + config.set_alignment_axes("A", None) + config.description = "4-axis machine with A-axis rotary table (rotation around X-axis)" + return config + + @classmethod + def create_4axis_B_config(cls, b_limits=(-120, 120)): + """Create standard 4-axis XYZB configuration (rotary table around Y)""" + config = cls("4-Axis XYZB Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) + config.set_alignment_axes("B", None) + config.description = "4-axis machine with B-axis rotary table (rotation around Y-axis)" + return config + + def to_dict(self): + """Serialize configuration to dictionary for JSON persistence""" + # Build flattened axes structure with enhanced fields + axes = {} + + # Add linear axes from LinearAxis objects + for axis_name, axis_obj in self.linear_axes.items(): + dir_vec = axis_obj.direction_vector + joint = [axis_obj.joint_origin, [dir_vec.x, dir_vec.y, dir_vec.z]] + + axes[axis_name] = { + "type": "linear", + "role": axis_obj.role.value, + "parent": axis_obj.parent, + "sequence": axis_obj.sequence, + "joint": joint, + "limits": {"min": axis_obj.min_limit, "max": axis_obj.max_limit}, + "max_velocity": axis_obj.max_velocity, + } + + # Add rotary axes + for axis_name, axis_obj in self.rotary_axes.items(): + rot_vec = axis_obj.rotation_vector + joint = [axis_obj.joint_origin, [rot_vec.x, rot_vec.y, rot_vec.z]] + + axes[axis_name] = { + "type": "rotary", + "role": axis_obj.role.value, + "parent": axis_obj.parent, + "sequence": axis_obj.sequence, + "joint": joint, + "limits": {"min": axis_obj.min_limit, "max": axis_obj.max_limit}, + "max_velocity": axis_obj.max_velocity, + "solution_preference": axis_obj.solution_preference, + "allow_flip": axis_obj.allow_flip, + } + + data = { + "freecad_version": self.freecad_version, + "machine": { + "name": self.name, + "manufacturer": self.manufacturer, + "description": self.description, + "units": self.configuration_units, + "kinematics": { + "base_frame": { + "origin": self.kinematics.base_frame.origin, + "orientation_quaternion": self.kinematics.base_frame.orientation_quaternion, + }, + "tcp_supported": self.kinematics.tcp_supported, + "dwo_supported": self.kinematics.dwo_supported, + "notes": self.kinematics.notes, + }, + "axes": axes, + "toolheads": [toolhead.to_dict() for toolhead in self.toolheads], + }, + "version": self.version, + } + + # Add post-processor configuration + data["postprocessor"] = { + "file_name": self.postprocessor_file_name, + "properties": self.postprocessor_properties, + } + + # Output options + data["output"] = { + "units": self.output.units.value, + "output_tool_length_offset": self.output.output_tool_length_offset, + "remote_post": self.output.remote_post, + "header": { + "output_header": self.output.output_header, + "include_date": self.output.header.include_date, + "include_description": self.output.header.include_description, + "include_document_name": self.output.header.include_document_name, + "include_machine_name": self.output.header.include_machine_name, + "include_project_file": self.output.header.include_project_file, + "include_units": self.output.header.include_units, + "include_tool_list": self.output.header.include_tool_list, + "include_fixture_list": self.output.header.include_fixture_list, + }, + "comments": { + "enabled": self.output.comments.enabled, + "symbol": self.output.comments.symbol, + "include_operation_labels": self.output.comments.include_operation_labels, + "include_blank_lines": self.output.comments.include_blank_lines, + "output_bcnc_comments": self.output.comments.output_bcnc_comments, + }, + "formatting": { + "line_numbers": self.output.formatting.line_numbers, + "line_number_start": self.output.formatting.line_number_start, + "line_number_prefix": self.output.formatting.line_number_prefix, + "line_increment": self.output.formatting.line_increment, + "command_space": self.output.formatting.command_space, + "end_of_line_chars": self.output.formatting.end_of_line_chars, + }, + "precision": { + "axis": self.output.precision.axis, + "feed": self.output.precision.feed, + "spindle": self.output.precision.spindle, + }, + "duplicates": { + "commands": self.output.duplicates.commands, + "parameters": self.output.duplicates.parameters, + }, + } + + # Processing options + data["processing"] = { + "early_tool_prep": self.processing.early_tool_prep, + "filter_inefficient_moves": self.processing.filter_inefficient_moves, + "split_arcs": self.processing.split_arcs, + "tool_change": self.processing.tool_change, + "translate_rapid_moves": self.processing.translate_rapid_moves, + "xy_before_z_after_tool_change": self.processing.xy_before_z_after_tool_change, + } + if self.processing.return_to: + data["processing"]["return_to"] = list(self.processing.return_to) + + return data + + def _initialize_3axis_config(self) -> None: + """Initialize as a standard 3-axis XYZ configuration (no rotary axes)""" + self.linear_axes = { + "X": LinearAxis("X", FreeCAD.Vector(1, 0, 0)), + "Y": LinearAxis("Y", FreeCAD.Vector(0, 1, 0)), + "Z": LinearAxis("Z", FreeCAD.Vector(0, 0, 1)), + } + self.rotary_axes = {} + self.primary_rotary_axis = None + self.secondary_rotary_axis = None + self.compound_moves = True + + def validate_kinematic_chain(self) -> List[str]: + """Validate the kinematic chain configuration. + + Returns: + List of validation error messages (empty if valid) + """ + errors = [] + + # Check for circular dependencies in parent relationships + all_axes = {**self.linear_axes, **self.rotary_axes} + + for axis_name, axis_obj in all_axes.items(): + if axis_obj.parent is not None: + if axis_obj.parent not in all_axes: + errors.append( + f"Axis {axis_name} references non-existent parent {axis_obj.parent}" + ) + else: + # Check for circular dependencies + visited = set() + current = axis_obj.parent + while current is not None and current not in visited: + visited.add(current) + parent_axis = all_axes.get(current) + if parent_axis and parent_axis.parent == axis_name: + errors.append( + f"Circular dependency detected: {axis_name} -> {current} -> {axis_name}" + ) + break + current = parent_axis.parent if parent_axis else None + + # Validate sequence numbers are unique within each chain + chains = self._get_kinematic_chains() + for chain_name, chain_axes in chains.items(): + sequences = [axis.sequence for axis in chain_axes] + if len(sequences) != len(set(sequences)): + errors.append(f"Duplicate sequence numbers in chain {chain_name}") + + return errors + + def _get_kinematic_chains(self) -> Dict[str, List]: + """Group axes by their kinematic chains. + + Returns: + Dictionary mapping chain names to lists of axes + """ + chains = {} + all_axes = {**self.linear_axes, **self.rotary_axes} + + # Find root axes (those with no parent) + root_axes = [name for name, axis in all_axes.items() if axis.parent is None] + + for root_name in root_axes: + chain = [] + visited = set() + + def collect_chain(axis_name): + if axis_name in visited or axis_name not in all_axes: + return + visited.add(axis_name) + chain.append(all_axes[axis_name]) + + # Find children + for child_name, child_axis in all_axes.items(): + if child_axis.parent == axis_name: + collect_chain(child_name) + + collect_chain(root_name) + chains[root_name] = chain + + return chains + + def get_axis_chain(self, axis_name: str) -> List[str]: + """Get the kinematic chain for a specific axis. + + Args: + axis_name: Name of the axis + + Returns: + List of axis names from root to the specified axis + """ + all_axes = {**self.linear_axes, **self.rotary_axes} + chain = [] + + def build_chain(name): + if name in all_axes: + parent = all_axes[name].parent + if parent: + build_chain(parent) + chain.append(name) + + build_chain(axis_name) + return chain + + @classmethod + def create_3axis_config(cls) -> "Machine": + """Create standard 3-axis XYZ configuration (no rotary axes)""" + config = cls("3-Axis XYZ Configuration") + config._initialize_3axis_config() + return config + + def _initialize_4axis_A_config(self, a_limits=(-120, 120)) -> None: + """Initialize as a 4-axis XYZA configuration (rotary table around X)""" + self._initialize_3axis_config() + self.rotary_axes["A"] = RotaryAxis( + "A", FreeCAD.Vector(1, 0, 0), min_limit=a_limits[0], max_limit=a_limits[1] + ) + self.primary_rotary_axis = "A" + + def _initialize_4axis_B_config(self, b_limits=(-120, 120)) -> None: + """Initialize as a 4-axis XYZB configuration (rotary table around Y)""" + self._initialize_3axis_config() + self.rotary_axes["B"] = RotaryAxis( + "B", FreeCAD.Vector(0, 1, 0), min_limit=b_limits[0], max_limit=b_limits[1] + ) + self.primary_rotary_axis = "B" + + def _initialize_AC_table_config(self, a_limits=(-120, 120), c_limits=(-360, 360)) -> None: + """Initialize as a 5-axis AC table configuration""" + self._initialize_4axis_A_config(a_limits) + self.rotary_axes["C"] = RotaryAxis( + "C", FreeCAD.Vector(0, 0, 1), min_limit=c_limits[0], max_limit=c_limits[1] + ) + self.secondary_rotary_axis = "C" + + def _initialize_BC_head_config(self, b_limits=(-120, 120), c_limits=(-360, 360)) -> None: + """Initialize as a 5-axis BC head configuration""" + self._initialize_4axis_B_config(b_limits) + self.rotary_axes["C"] = RotaryAxis( + "C", FreeCAD.Vector(0, 0, 1), min_limit=c_limits[0], max_limit=c_limits[1] + ) + self.secondary_rotary_axis = "C" + + def _initialize_from_machine_type(self, machine_type: str) -> None: + """Initialize machine configuration based on machine type""" + if machine_type == "xyz": + self._initialize_3axis_config() + elif machine_type == "xyza": + self._initialize_4axis_A_config() + elif machine_type == "xyzb": + self._initialize_4axis_B_config() + elif machine_type == "xyzac": + self._initialize_AC_table_config() + elif machine_type == "xyzbc": + self._initialize_BC_head_config() + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Machine": + """Deserialize configuration from dictionary (supports both old and new formats)""" + machine_data = data.get("machine", data) # Support both old and new formats + + # Extract basic configuration + config = cls( + name=machine_data.get("name", "Loaded Machine"), + configuration_units=machine_data.get("units", "metric"), + manufacturer=machine_data.get("manufacturer", ""), + description=machine_data.get("description", ""), + ) + + # Parse axes from new flattened structure + axes = machine_data.get("axes", {}) + config.linear_axes = {} + config.rotary_axes = {} + + # Parse linear and rotary axes with enhanced fields + for axis_name, axis_data in axes.items(): + axis_type = axis_data.get("type", "linear") + limits = axis_data.get("limits", {}) + joint = axis_data.get("joint", [[0, 0, 0], [0, 0, 0]]) + + # Handle both old array format [[origin], [axis]] and new object format {"origin": [], "axis": []} + if isinstance(joint, dict) and "origin" in joint and "axis" in joint: + # New object format + joint_origin = joint.get("origin", [0, 0, 0]) + axis_vector = joint.get("axis", [0, 0, 0]) + elif isinstance(joint, list) and len(joint) >= 2: + # Old array format - need to detect which element is which + vec0 = ( + FreeCAD.Vector(joint[0][0], joint[0][1], joint[0][2]) + if len(joint[0]) >= 3 + else FreeCAD.Vector(0, 0, 0) + ) + vec1 = ( + FreeCAD.Vector(joint[1][0], joint[1][1], joint[1][2]) + if len(joint[1]) >= 3 + else FreeCAD.Vector(0, 0, 0) + ) + + if axis_type == "linear": + # For linear axes, check which vector has non-zero magnitude + if vec0.Length > 1e-6: # joint[0] is direction vector (old structure) + axis_vector = [vec0.x, vec0.y, vec0.z] + joint_origin = joint[1] if len(joint[1]) >= 3 else [0, 0, 0] + elif vec1.Length > 1e-6: # joint[1] is direction vector (new structure) + axis_vector = [vec1.x, vec1.y, vec1.z] + joint_origin = joint[0] if len(joint[0]) >= 3 else [0, 0, 0] + else: + # Both vectors are zero, use defaults + axis_vector = [1, 0, 0] # Default X-axis + joint_origin = [0, 0, 0] + Path.Log.warning( + f"Invalid joint data for linear axis {axis_name}, using defaults" + ) + else: + # For rotary axes, joint[1] is usually the rotation vector + if vec1.Length > 1e-6: + axis_vector = [vec1.x, vec1.y, vec1.z] + joint_origin = joint[0] if len(joint[0]) >= 3 else [0, 0, 0] + else: + # Fallback - assume joint[0] is rotation vector + axis_vector = [vec0.x, vec0.y, vec0.z] + joint_origin = [0, 0, 0] + Path.Log.warning( + f"Invalid joint data for rotary axis {axis_name}, using defaults" + ) + else: + # Malformed joint data - use defaults + joint_origin = [0, 0, 0] + axis_vector = [1, 0, 0] if axis_type == "linear" else [0, 0, 1] + Path.Log.warning(f"Malformed joint data for axis {axis_name}, using defaults") + + if axis_type == "linear": + # Create linear axis with parsed joint data + direction_vec = FreeCAD.Vector(axis_vector[0], axis_vector[1], axis_vector[2]) + + min_limit = limits.get("min", 0) + max_limit = limits.get("max", 1000) + max_velocity = axis_data.get("max_velocity", 10000) + role = AxisRole(axis_data.get("role", "table_linear")) + parent = axis_data.get("parent") + sequence = axis_data.get("sequence", 0) + + config.linear_axes[axis_name] = LinearAxis( + name=axis_name, + direction_vector=direction_vec, + min_limit=min_limit, + max_limit=max_limit, + max_velocity=max_velocity, + sequence=sequence, + role=role, + parent=parent, + joint_origin=joint_origin, + ) + elif axis_type in ["angular", "rotary"]: # Support both old and new type names + # Create rotary axis with parsed joint data + rotation_vec = FreeCAD.Vector(axis_vector[0], axis_vector[1], axis_vector[2]) + + min_limit = limits.get("min", -360) + max_limit = limits.get("max", 360) + max_velocity = axis_data.get("max_velocity", 36000) + role = AxisRole(axis_data.get("role", "table_rotary")) + parent = axis_data.get("parent") + sequence = axis_data.get("sequence", 0) + solution_preference = axis_data.get("solution_preference", "shortest") + allow_flip = axis_data.get("allow_flip", True) + + # Legacy support for prefer_positive + prefer_positive = axis_data.get("prefer_positive", True) + + config.rotary_axes[axis_name] = RotaryAxis( + name=axis_name, + rotation_vector=rotation_vec, + min_limit=min_limit, + max_limit=max_limit, + max_velocity=max_velocity, + sequence=sequence, + prefer_positive=prefer_positive, + role=role, + parent=parent, + joint_origin=joint_origin, + solution_preference=solution_preference, + allow_flip=allow_flip, + ) + + # Parse kinematics if present + kinematics_data = machine_data.get("kinematics", {}) + if kinematics_data: + base_frame_data = kinematics_data.get("base_frame", {}) + config.kinematics.base_frame.origin = base_frame_data.get("origin", [0, 0, 0]) + config.kinematics.base_frame.orientation_quaternion = base_frame_data.get( + "orientation_quaternion", [0, 0, 0, 1] + ) + config.kinematics.tcp_supported = kinematics_data.get("tcp_supported", False) + config.kinematics.dwo_supported = kinematics_data.get("dwo_supported", False) + config.kinematics.notes = kinematics_data.get("notes", "") + + # Determine primary/secondary rotary axes for legacy compatibility + rotary_axis_names = list(config.rotary_axes.keys()) + rotary_axis_names.sort() # Sort to get consistent ordering + + if len(rotary_axis_names) > 0: + config.primary_rotary_axis = rotary_axis_names[0] + if len(rotary_axis_names) > 1: + config.secondary_rotary_axis = rotary_axis_names[1] + + # Parse toolheads if present + toolheads = machine_data.get("toolheads", machine_data.get("spindles", [])) + config.toolheads = [Toolhead.from_dict(s) for s in toolheads] + + # Parse post-processor settings if present + post_data = data.get("postprocessor", {}) + if post_data: + config.postprocessor_file_name = post_data.get("file_name", "") + config.postprocessor_args = post_data.get("args", "") + config.postprocessor_properties = post_data.get("properties", {}) + + # Load output options + output_data = data.get("output", {}) + if output_data: + # Main output options + output_units_str = output_data.get("units", "metric") + config.output.units = ( + OutputUnits.METRIC if output_units_str == "metric" else OutputUnits.IMPERIAL + ) + config.output.output_tool_length_offset = output_data.get( + "output_tool_length_offset", True + ) + config.output.remote_post = output_data.get("remote_post", False) + + # Header options + header_data = output_data.get("header", {}) + # Handle backward compatibility - if header is a bool, use it for include_date + if isinstance(header_data, bool): + # Old structure: header was a boolean controlling all header output + config.output.output_header = header_data + config.output.header.include_date = header_data + config.output.header.include_description = header_data + config.output.header.include_document_name = header_data + config.output.header.include_machine_name = header_data + config.output.header.include_project_file = header_data + config.output.header.include_units = header_data + config.output.header.include_tool_list = header_data + config.output.header.include_fixture_list = header_data + else: + # New nested structure - output_header can be in header subsection or main output + config.output.output_header = header_data.get( + "output_header", output_data.get("output_header", True) + ) + config.output.header.include_date = header_data.get("include_date", True) + config.output.header.include_description = header_data.get( + "include_description", True + ) + config.output.header.include_document_name = header_data.get( + "include_document_name", True + ) + config.output.header.include_machine_name = header_data.get( + "include_machine_name", True + ) + config.output.header.include_project_file = header_data.get( + "include_project_file", True + ) + config.output.header.include_units = header_data.get("include_units", True) + config.output.header.include_tool_list = header_data.get("include_tool_list", True) + config.output.header.include_fixture_list = header_data.get( + "include_fixture_list", True + ) + + # Comment options + comments_data = output_data.get("comments", {}) + # Handle backward compatibility - if comments is a bool, use it for enabled + if isinstance(comments_data, bool): + # Old structure: comments was a boolean controlling all comment output + config.output.comments.enabled = comments_data + config.output.comments.symbol = "(" # Default symbol + config.output.comments.include_operation_labels = False + config.output.comments.include_blank_lines = True + config.output.comments.output_bcnc_comments = False + else: + # New nested structure + config.output.comments.enabled = comments_data.get("enabled", True) + config.output.comments.symbol = comments_data.get("symbol", "(") + config.output.comments.include_operation_labels = comments_data.get( + "include_operation_labels", False + ) + config.output.comments.include_blank_lines = comments_data.get( + "include_blank_lines", True + ) + config.output.comments.output_bcnc_comments = comments_data.get( + "output_bcnc_comments", False + ) + + # Formatting options + formatting_data = output_data.get("formatting", {}) + # Handle backward compatibility for old flat structure + if "line_numbers" in output_data and not formatting_data: + # Old structure: line_numbers was at top level + config.output.formatting.line_numbers = output_data.get("line_numbers", False) + config.output.formatting.line_number_start = output_data.get( + "line_number_start", 100 + ) + config.output.formatting.line_number_prefix = output_data.get( + "line_number_prefix", "N" + ) + config.output.formatting.line_increment = output_data.get("line_increment", 10) + config.output.formatting.command_space = output_data.get("command_space", " ") + config.output.formatting.end_of_line_chars = output_data.get( + "end_of_line_chars", "\n" + ) + else: + # New nested structure + config.output.formatting.line_numbers = formatting_data.get("line_numbers", False) + config.output.formatting.line_number_start = formatting_data.get( + "line_number_start", 100 + ) + config.output.formatting.line_number_prefix = formatting_data.get( + "line_number_prefix", "N" + ) + config.output.formatting.line_increment = formatting_data.get("line_increment", 10) + config.output.formatting.command_space = formatting_data.get("command_space", " ") + config.output.formatting.end_of_line_chars = formatting_data.get( + "end_of_line_chars", "\n" + ) + + # Precision options + precision_data = output_data.get("precision", {}) + # Handle backward compatibility for old flat structure + if "axis_precision" in output_data and not precision_data: + # Old structure: axis_precision was at top level + config.output.precision.axis = output_data.get("axis_precision", 3) + config.output.precision.feed = output_data.get("feed_precision", 3) + config.output.precision.spindle = output_data.get("spindle_precision", 0) + else: + # New nested structure + config.output.precision.axis = precision_data.get("axis", 3) + config.output.precision.feed = precision_data.get("feed", 3) + config.output.precision.spindle = precision_data.get("spindle", 0) + + # Duplicate options + duplicates_data = output_data.get("duplicates", {}) + # Handle backward compatibility for old flat structure + if "output_duplicate_parameters" in output_data and not duplicates_data: + # Old structure: output_duplicate_parameters was at top level + config.output.duplicates.commands = output_data.get( + "output_duplicate_commands", True + ) + config.output.duplicates.parameters = output_data.get( + "output_duplicate_parameters", True + ) + else: + # New nested structure + config.output.duplicates.commands = duplicates_data.get("commands", True) + config.output.duplicates.parameters = duplicates_data.get("parameters", True) + + # Handle legacy output_comments field (for backward compatibility) + if "output_comments" in output_data and "comments" not in output_data: + # Old structure: output_comments was a boolean + config.output.comments.enabled = output_data.get("output_comments", True) + + # Load processing options + processing_data = data.get("processing", {}) + if processing_data: + config.processing.early_tool_prep = processing_data.get("early_tool_prep", False) + config.processing.filter_inefficient_moves = processing_data.get( + "filter_inefficient_moves", False + ) + config.processing.split_arcs = processing_data.get("split_arcs", False) + config.processing.tool_change = processing_data.get("tool_change", True) + config.processing.translate_rapid_moves = processing_data.get( + "translate_rapid_moves", False + ) + config.processing.xy_before_z_after_tool_change = processing_data.get( + "xy_before_z_after_tool_change", False + ) + return_to = processing_data.get("return_to", None) + config.processing.return_to = tuple(return_to) if return_to is not None else None + + return config + + +class MachineFactory: + """Factory class for creating, loading, and saving machine configurations""" + + # Default configuration directory + _config_dir = None + + @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 get_config_directory(cls): + """Get the configuration directory, creating default if not set""" + if cls._config_dir is None: + # Use FreeCAD user data directory + CAM/Machines + try: + cls._config_dir = Path.Preferences.getAssetPath() / "Machines" + cls._config_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + Path.Log.warning(f"Could not create default config directory: {e}") + cls._config_dir = pathlib.Path.cwd() / "Machines" + cls._config_dir.mkdir(parents=True, exist_ok=True) + return cls._config_dir + + @classmethod + def save_configuration(cls, config, filename=None): + """ + Save a machine configuration to a JSON file + + Args: + config: Machine object to save + filename: Optional filename (without path). If None, uses sanitized config name + + Returns: + Path to the saved file + """ + if filename is None: + # Sanitize the config name for use as filename + filename = config.name.replace(" ", "_").replace("/", "_") + ".fcm" + + config_dir = cls.get_config_directory() + filepath = config_dir / filename + + try: + data = config.to_dict() + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, sort_keys=True, indent=4) + Path.Log.debug(f"Saved machine file: {filepath}") + return filepath + except Exception as e: + Path.Log.error(f"Failed to save configuration: {e}") + raise Exception(f"Failed to save machine file {filepath}: {e}") + + @classmethod + def load_configuration(cls, filename): + """ + Load a machine configuration from a JSON file + + Args: + filename: Filename (with or without path). If no path, searches config directory + + Returns: + Dictionary containing machine configuration data (new format) or + Machine object if loading old format + + Raises: + FileNotFoundError: If the file does not exist + json.JSONDecodeError: If the file is not valid JSON + Exception: For other I/O errors + """ + filepath = pathlib.Path(filename) + + # If no directory specified, look in config directory + if not filepath.parent or filepath.parent == pathlib.Path("."): + filepath = cls.get_config_directory() / filename + + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + Path.Log.debug(f"Loaded machine file: {filepath}") + machine = Machine.from_dict(data) + Path.Log.debug(f"Loaded machine configuration from {filepath}") + return machine + + except FileNotFoundError: + raise FileNotFoundError(f"Machine file not found: {filepath}") + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in machine file {filepath}: {e}") + except Exception as e: + raise Exception(f"Failed to load machine file {filepath}: {e}") + + @classmethod + def create_default_machine_data(cls): + """ + Create a default machine configuration dictionary for the editor. + + Returns: + Dictionary with default machine configuration structure + """ + machine = Machine(name="New Machine") + return machine.to_dict() + + @classmethod + def list_builtin_templates(cls) -> list[tuple[str, str]]: + """Get list of built-in machine templates. + + Scans the Machine/machines subdirectory for .fcm template files + and returns tuples of (display_name, full_file_path). + + Returns: + list: List of (name, path) tuples for built-in template files + """ + templates = [] + try: + # Get the built-in machines directory from FreeCAD installation + machines_dir = ( + pathlib.Path(FreeCAD.getHomePath()) / "Mod" / "CAM" / "Machine" / "machines" + ) + + if machines_dir.exists(): + for machine_file in sorted(machines_dir.glob("*.fcm")): + display_name = machine_file.stem.replace("_", " ") + templates.append((display_name, str(machine_file))) + except Exception as e: + Path.Log.warning(f"Could not load built-in machine templates: {e}") + + return templates + + @classmethod + def list_configuration_files(cls) -> list[tuple[str, pathlib.Path]]: + """Get list of available machine files from the asset directory. + + Scans the Machine subdirectory of the asset path for .fcm files + and returns tuples of (display_name, file_path). + + Returns: + list: List of (name, path) tuples for discovered machine files + """ + machines = [("", None)] + try: + asset_base = cls.get_config_directory() + if asset_base.exists(): + for p in sorted(asset_base.glob("*.fcm")): + name = cls.get_machine_display_name(p.name) + machines.append((name, p.name)) + except Exception: + # Failed to access machine directory or read files, return default list only + pass + return machines + + @classmethod + def list_configurations(cls) -> list[str]: + """Get list of available machines from the asset directory. + + Scans the Machine subdirectory of the asset path for .fcm files + and extracts machine names. Returns [""] plus discovered machine names. + + Returns: + list: List of machine names starting with "" + """ + machines = cls.list_configuration_files() + return [name for name, path in machines] + + @classmethod + def delete_configuration(cls, filename): + """ + Delete a machine configuration file + + Args: + filename: Name of the configuration file to delete + + Returns: + True if deleted successfully, False otherwise + """ + filepath = cls.get_config_directory() / filename + try: + if filepath.exists(): + filepath.unlink() + Path.Log.debug(f"Deleted machine: {filepath}") + return True + else: + Path.Log.warning(f"Machine file not found: {filepath}") + return False + except Exception as e: + Path.Log.error(f"Failed to delete machine: {e}") + return False + + @classmethod + def create_standard_configs(cls): + """ + Create and save all standard machine configurations + + Returns: + Dictionary mapping config names to file paths + """ + configs = { + "XYZ": Machine.create_3axis_config(), + "XYZAC": Machine.create_AC_table_config(), + "XYZBC": Machine.create_BC_head_config(), + "XYZA": Machine.create_4axis_A_config(), + "XYZB": Machine.create_4axis_B_config(), + } + + saved_paths = {} + for name, config in configs.items(): + try: + filepath = cls.save_configuration(config, f"{name}.fcm") + saved_paths[name] = filepath + except Exception as e: + Path.Log.error(f"Failed to save {name}: {e}") + + return saved_paths + + @classmethod + def get_builtin_config(cls, config_type): + """ + Get a built-in machine configuration without loading from disk + + Args: + config_type: One of "XYZ", "XYZAC", "XYZBC", "XYZA", "XYZB" + + Returns: + Machine object + """ + config_map = { + "XYZ": Machine.create_3axis_config, + "XYZAC": Machine.create_AC_table_config, + "XYZBC": Machine.create_BC_head_config, + "XYZA": Machine.create_4axis_A_config, + "XYZB": Machine.create_4axis_B_config, + } + + if config_type not in config_map: + raise ValueError( + f"Unknown config type: {config_type}. Available: {list(config_map.keys())}" + ) + + return config_map[config_type]() + + @classmethod + def get_machine(cls, machine_name): + """ + Get a machine configuration by name from the assets folder + + Args: + machine_name: Name of the machine to load (without .fcm extension) + + Returns: + Machine object + + Raises: + FileNotFoundError: If no machine with that name is found + ValueError: If the loaded data is not a valid machine configuration + """ + # Get list of available machine files + machine_files = cls.list_configuration_files() + + # Find the file matching the machine name (case-insensitive) + target_path = None + machine_name_lower = machine_name.lower() + for name, path in machine_files: + if name.lower() == machine_name_lower and path is not None: + target_path = path + break + + if target_path is None: + available = [name for name, path in machine_files if path is not None] + raise FileNotFoundError( + f"Machine '{machine_name}' not found. Available machines: {available}" + ) + + # Load the configuration using the path from list_configuration_files() + data = cls.load_configuration(target_path) + + # If load_configuration returned a dict (new format), convert to Machine + if isinstance(data, dict): + return Machine.from_dict(data) + else: + # Already a Machine object (old format) + return data + + @classmethod + def get_machine_display_name(cls, filename): + """ + Get the display name for a machine from its filename in the config directory. + + Args: + filename: Name of the machine file (without path) + + Returns: + str: Display name (machine name from JSON or filename stem) + """ + filepath = cls.get_config_directory() / filename + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("machine", {}).get("name", filepath.stem) + except Exception: + return filepath.stem diff --git a/src/Mod/CAM/Path/Machine/ui/__init__.py b/src/Mod/CAM/Machine/ui/__init__.py similarity index 89% rename from src/Mod/CAM/Path/Machine/ui/__init__.py rename to src/Mod/CAM/Machine/ui/__init__.py index 349cb65394..f7537e83f5 100644 --- a/src/Mod/CAM/Path/Machine/ui/__init__.py +++ b/src/Mod/CAM/Machine/ui/__init__.py @@ -1,10 +1,10 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Billy Huddleston # SPDX-FileNotice: Part of the FreeCAD project. + ################################################################################ # # -# © 2026 Billy Huddleston # -# # # 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 # diff --git a/src/Mod/CAM/Machine/ui/editor/__init__.py b/src/Mod/CAM/Machine/ui/editor/__init__.py new file mode 100644 index 0000000000..93152ac03c --- /dev/null +++ b/src/Mod/CAM/Machine/ui/editor/__init__.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Billy Huddleston +# 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 # +# # +################################################################################ + +"""Machine editor package.""" +from .machine_editor import MachineEditorDialog + +__all__ = ["MachineEditorDialog"] diff --git a/src/Mod/CAM/Machine/ui/editor/machine_editor.py b/src/Mod/CAM/Machine/ui/editor/machine_editor.py new file mode 100644 index 0000000000..c6b096288e --- /dev/null +++ b/src/Mod/CAM/Machine/ui/editor/machine_editor.py @@ -0,0 +1,2643 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Billy Huddleston +# 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 # +# # +################################################################################ + +from PySide import QtGui, QtCore +import FreeCAD +import Path +import json +from typing import Optional, Dict, Any, get_origin, get_args +from dataclasses import fields +from enum import Enum +from Machine.models.machine import ( + Machine, + MachineFactory, + LinearAxis, + RotaryAxis, + Toolhead, + ToolheadType, + AxisRole, +) +from Path.Main.Gui.Editor import CodeEditor +from Path.Post.Processor import PostProcessorFactory +from Machine.ui.editor.postprocessor_properties import PostProcessorPropertyManager +import re + +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()) + + +class DataclassGUIGenerator: + """Generates Qt widgets dynamically from dataclass definitions. + + This class introspects dataclass fields and creates appropriate GUI widgets + based on field types. It supports nested dataclasses, creating grouped layouts + for better organization. + """ + + # Fields that should use multi-line text editors instead of single-line + MULTILINE_FIELDS = { + "preamble", + "postamble", + "pre_job", + "post_job", + "pre_operation", + "post_operation", + "pre_tool_change", + "post_tool_change", + "tool_return", + "pre_fixture_change", + "post_fixture_change", + "pre_rotary_move", + "post_rotary_move", + "pre_toolhead_change", + "post_toolhead_change", + "safetyblock", + "drill_cycles_to_translate", + "suppress_commands", + } + + # Field display name overrides + FIELD_LABELS = { + # Legacy labels (for backward compatibility) - non-conflicting only + "blank_lines": translate("CAM_MachineEditor", "Include Blank Lines"), + "path_labels": translate("CAM_MachineEditor", "Path Labels"), + "machine_name": translate("CAM_MachineEditor", "Include Machine Name"), + "output_double_parameters": translate("CAM_MachineEditor", "Output Duplicate Axis Values"), + "doubles": translate("CAM_MachineEditor", "Output Duplicate Axis Values"), + "adaptive": translate("CAM_MachineEditor", "Adaptive Output"), + "axis_precision": translate("CAM_MachineEditor", "Axis Precision"), + "feed_precision": translate("CAM_MachineEditor", "Feed Precision"), + "toolhead_decimals": translate("CAM_MachineEditor", "Toolhead Decimals"), + "comment_symbol": translate("CAM_MachineEditor", "Comment Symbol"), + "modal": translate("CAM_MachineEditor", "Modal Output (Suppress Repeats)"), + "translate_drill_cycles": translate("CAM_MachineEditor", "Translate Drill Cycles"), + "translate_rapid_moves": translate("CAM_MachineEditor", "Translate Rapid Moves"), + "split_arcs": translate("CAM_MachineEditor", "Split Arcs"), + "xy_before_z_after_tool_change": translate( + "CAM_MachineEditor", "XY Before Z After Tool Change" + ), + "show_editor": translate("CAM_MachineEditor", "Show Editor After Generation"), + "list_tools_in_preamble": translate("CAM_MachineEditor", "List Tools in Preamble"), + "show_machine_units": translate("CAM_MachineEditor", "Show Machine Units"), + "show_operation_labels": translate("CAM_MachineEditor", "Show Operation Labels"), + "tool_before_change": translate("CAM_MachineEditor", "Output T Before M6"), + "chipbreaking_amount": translate("CAM_MachineEditor", "Chipbreaking Amount (mm)"), + "toolhead_wait": translate("CAM_MachineEditor", "Toolhead Wait Time (seconds)"), + "postprocessor_file_name": translate("CAM_MachineEditor", "Post Processor"), + "postprocessor_args": translate("CAM_MachineEditor", "Post Processor Arguments"), + "use_tlo": translate("CAM_MachineEditor", "Use Tool Length Offset"), + "output_tool_length_offset": translate( + "CAM_MachineEditor", "Output Tool Length Offset (G43)" + ), + "remote_post": translate("CAM_MachineEditor", "Enable Remote Posting"), + "stop_toolhead_for_tool_change": translate( + "CAM_MachineEditor", "Stop Toolhead for Tool Change" + ), + "enable_coolant": translate("CAM_MachineEditor", "Enable Coolant"), + "enable_machine_specific_commands": translate( + "CAM_MachineEditor", "Enable Machine-Specific Commands" + ), + # New nested structure labels + "units": translate("CAM_MachineEditor", "Output Units"), + "header": translate("CAM_MachineEditor", "Header Options"), + "comments": translate("CAM_MachineEditor", "Comment Options"), + "formatting": translate("CAM_MachineEditor", "Formatting Options"), + "precision": translate("CAM_MachineEditor", "Precision Options"), + "duplicates": translate("CAM_MachineEditor", "Duplicate Output Options"), + "output_header": translate("CAM_MachineEditor", "Output Header"), + # Header field labels + "include_date": translate("CAM_MachineEditor", "Include Date"), + "include_description": translate("CAM_MachineEditor", "Include Description"), + "include_document_name": translate("CAM_MachineEditor", "Include Document Name"), + "include_machine_name": translate("CAM_MachineEditor", "Include Machine Name"), + "include_project_file": translate("CAM_MachineEditor", "Include Project File"), + "include_units": translate("CAM_MachineEditor", "Include Units"), + "include_tool_list": translate("CAM_MachineEditor", "Include Tool List"), + "include_fixture_list": translate("CAM_MachineEditor", "Include Fixture List"), + # Comment field labels + "enabled": translate("CAM_MachineEditor", "Enable Comments"), + "symbol": translate("CAM_MachineEditor", "Comment Symbol"), + "include_operation_labels": translate("CAM_MachineEditor", "Include Operation Labels"), + "include_blank_lines": translate("CAM_MachineEditor", "Include Blank Lines"), + "output_bcnc_comments": translate("CAM_MachineEditor", "Output bCNC Comments"), + # Formatting field labels + "line_numbers": translate("CAM_MachineEditor", "Line Numbers"), + "line_number_start": translate("CAM_MachineEditor", "Line Number Start"), + "line_number_prefix": translate("CAM_MachineEditor", "Line Number Prefix"), + "line_increment": translate("CAM_MachineEditor", "Line Increment"), + "command_space": translate("CAM_MachineEditor", "Command Space"), + "end_of_line_chars": translate("CAM_MachineEditor", "End of Line Chars"), + # Precision field labels + "axis": translate("CAM_MachineEditor", "Axis Precision"), + "feed": translate("CAM_MachineEditor", "Feed Precision"), + "toolhead": translate("CAM_MachineEditor", "Toolhead Precision"), + # Duplicate field labels + "commands": translate("CAM_MachineEditor", "Duplicate Commands"), + "parameters": translate("CAM_MachineEditor", "Duplicate Parameters"), + # Kinematics field labels + "kinematics": translate("CAM_MachineEditor", "Kinematics"), + "base_frame": translate("CAM_MachineEditor", "Base Frame"), + "origin": translate("CAM_MachineEditor", "Origin"), + "orientation_quaternion": translate("CAM_MachineEditor", "Orientation Quaternion"), + "tcp_supported": translate("CAM_MachineEditor", "TCP Supported"), + "dwo_supported": translate("CAM_MachineEditor", "DWO Supported"), + "notes": translate("CAM_MachineEditor", "Kinematics Notes"), + # Axis field labels + "role": translate("CAM_MachineEditor", "Role"), + "parent": translate("CAM_MachineEditor", "Parent Axis"), + "sequence": translate("CAM_MachineEditor", "Sequence"), + "joint_origin": translate("CAM_MachineEditor", "Joint Origin"), + "joint_axis": translate("CAM_MachineEditor", "Joint Axis"), + "solution_preference": translate("CAM_MachineEditor", "Solution Preference"), + "allow_flip": translate("CAM_MachineEditor", "Allow Flip"), + } + + @staticmethod + def get_field_label(field_name: str) -> str: + """Get a human-readable label for a field name.""" + if field_name in DataclassGUIGenerator.FIELD_LABELS: + return DataclassGUIGenerator.FIELD_LABELS[field_name] + # Convert snake_case to Title Case + return " ".join(word.capitalize() for word in field_name.split("_")) + + @staticmethod + def create_widget_for_field( + field_name: str, field_type: type, current_value: Any + ) -> QtGui.QWidget: + """Create an appropriate widget for a dataclass field. + + Args: + field_name: Name of the field + field_type: Type annotation of the field + current_value: Current value of the field + + Returns: + Tuple of (widget, getter_function) where getter returns the current value + """ + origin = get_origin(field_type) + + # Handle Optional types + if origin is type(None) or (origin and str(origin).startswith("typing.Union")): + args = get_args(field_type) + if args: + # Get the non-None type + field_type = next((arg for arg in args if arg is not type(None)), args[0]) + origin = get_origin(field_type) + + # Boolean -> Checkbox + if field_type is bool: + widget = QtGui.QCheckBox() + widget.setChecked(current_value if current_value is not None else False) + widget.value_getter = lambda: widget.isChecked() + return widget + + # Enum -> ComboBox + if isinstance(field_type, type) and issubclass(field_type, Enum): + widget = QtGui.QComboBox() + for member in field_type: + widget.addItem(member.value if hasattr(member, "value") else str(member), member) + if current_value: + index = widget.findData(current_value) + if index >= 0: + widget.setCurrentIndex(index) + widget.value_getter = lambda: widget.itemData(widget.currentIndex()) + return widget + + # List[str] -> Multi-line text area + if origin is list: + args = get_args(field_type) + if args and args[0] is str: + widget = QtGui.QPlainTextEdit() + widget.setMaximumHeight(100) + if current_value: + widget.setPlainText("\n".join(current_value)) + else: + widget.setPlainText("") + widget.value_getter = lambda: [ + line.strip() for line in widget.toPlainText().split("\n") if line.strip() + ] + return widget + + # Int -> SpinBox + if field_type is int: + widget = QtGui.QSpinBox() + widget.setRange(-999999, 999999) + widget.setValue(current_value if current_value is not None else 0) + widget.value_getter = lambda: widget.value() + return widget + + # Float -> DoubleSpinBox + if field_type is float: + widget = QtGui.QDoubleSpinBox() + widget.setRange(-999999.0, 999999.0) + widget.setDecimals(4) + widget.setValue(current_value if current_value is not None else 0.0) + widget.value_getter = lambda: widget.value() + return widget + + # String -> LineEdit or PlainTextEdit + if field_type is str: + if field_name in DataclassGUIGenerator.MULTILINE_FIELDS: + widget = QtGui.QPlainTextEdit() + widget.setMaximumHeight(100) + widget.setPlainText(current_value if current_value else "") + widget.value_getter = lambda: widget.toPlainText() + else: + widget = QtGui.QLineEdit() + widget.setText(current_value if current_value else "") + widget.value_getter = lambda: widget.text() + return widget + + # Fallback to string representation + widget = QtGui.QLineEdit() + widget.setText(str(current_value) if current_value is not None else "") + widget.value_getter = lambda: widget.text() + return widget + + @staticmethod + def create_group_for_dataclass( + dataclass_instance: Any, group_title: str + ) -> tuple[QtGui.QGroupBox, Dict[str, QtGui.QWidget]]: + """Create a QGroupBox with widgets for all fields in a dataclass. + + Args: + dataclass_instance: Instance of a dataclass + group_title: Title for the group box + + Returns: + Tuple of (QGroupBox, dict mapping field_name to widget) + """ + group = QtGui.QGroupBox(group_title) + layout = QtGui.QFormLayout(group) + widgets = {} + + for field in fields(dataclass_instance): + # Skip private fields and complex types we don't handle + if field.name.startswith("_"): + continue + + current_value = getattr(dataclass_instance, field.name) + field_type = field.type + + # Skip parameter_functions and other callable/complex types + if field.name in ["parameter_functions", "parameter_order"]: + continue + + widget = DataclassGUIGenerator.create_widget_for_field( + field.name, field_type, current_value + ) + + label = DataclassGUIGenerator.get_field_label(field.name) + layout.addRow(label, widget) + widgets[field.name] = widget + + return group, widgets + + +class MachineEditorDialog(QtGui.QDialog): + """A dialog to edit machine JSON assets with proper form fields.""" + + # Todo - Make this a json schema driven form in the future + MACHINE_TYPES = { + "custom": { + "name": translate("CAM_MachineEditor", "Custom Machine"), + "linear": [], + "rotary": [], + }, + "xz": { + "name": translate("CAM_MachineEditor", "2-Axis Lathe (X, Z)"), + "linear": ["X", "Z"], + "rotary": [], + }, + "xyz": { + "name": translate("CAM_MachineEditor", "3-Axis Mill (XYZ)"), + "linear": ["X", "Y", "Z"], + "rotary": [], + }, + "xyza": { + "name": translate("CAM_MachineEditor", "4-Axis Mill (XYZ + A)"), + "linear": ["X", "Y", "Z"], + "rotary": ["A"], + }, + "xyzb": { + "name": translate("CAM_MachineEditor", "4-Axis Mill (XYZ + B)"), + "linear": ["X", "Y", "Z"], + "rotary": ["B"], + }, + "xyzac": { + "name": translate("CAM_MachineEditor", "5-Axis Mill (XYZ + A, C)"), + "linear": ["X", "Y", "Z"], + "rotary": ["A", "C"], + }, + "xyzbc": { + "name": translate("CAM_MachineEditor", "5-Axis Mill (XYZ + B, C)"), + "linear": ["X", "Y", "Z"], + "rotary": ["B", "C"], + }, + } + + ROTATIONAL_AXIS_OPTIONS = [ + ("+X", [1, 0, 0]), + ("-X", [-1, 0, 0]), + ("+Y", [0, 1, 0]), + ("-Y", [0, -1, 0]), + ("+Z", [0, 0, 1]), + ("-Z", [0, 0, -1]), + ] + + def __init__(self, machine_filename: Optional[str] = None, parent=None): + super().__init__(parent) + self.setMinimumSize(700, 900) + self.resize(700, 900) + + self.current_units = "metric" + self.is_new_machine = machine_filename is None # Track if creating new machine + + # Initialize machine object first (needed by setup methods) + self.filename = machine_filename + self.machine = None # Store the Machine object + + if machine_filename: + self.machine = MachineFactory.load_configuration(machine_filename) + else: + self.machine = Machine(name="New Machine") + + # Set window title with machine name + title = translate("CAM_MachineEditor", "Machine Editor") + if self.machine and self.machine.name: + title += f" - {self.machine.name}" + self.setWindowTitle(title) + + # Initialize widget and processor caches + self.post_widgets = {} + self.processor = {} + + self.layout = QtGui.QVBoxLayout(self) + + # Tab widget for sections + self.tabs = QtGui.QTabWidget() + self.layout.addWidget(self.tabs) + + # Machine tab + self.machine_tab = QtGui.QWidget() + self.tabs.addTab(self.machine_tab, translate("CAM_MachineEditor", "Machine")) + self.setup_machine_tab() + + # Postprocessor tab + self.postprocessor_tab = QtGui.QWidget() + self.tabs.addTab(self.postprocessor_tab, translate("CAM_MachineEditor", "Postprocessor")) + self.setup_postprocessor_tab() + + # Output Options tab + self.output_tab = QtGui.QWidget() + self.tabs.addTab(self.output_tab, translate("CAM_MachineEditor", "Output Options")) + self.setup_output_tab() + + # Processing Options tab + self.processing_tab = QtGui.QWidget() + self.tabs.addTab(self.processing_tab, translate("CAM_MachineEditor", "Processing Options")) + self.setup_processing_tab() + + # Check experimental flag for machine post processor + param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + self.enable_machine_postprocessor = param.GetBool("EnableMachinePostprocessor", True) + self.tabs.setTabVisible( + self.tabs.indexOf(self.postprocessor_tab), self.enable_machine_postprocessor + ) + self.tabs.setTabVisible( + self.tabs.indexOf(self.output_tab), self.enable_machine_postprocessor + ) + self.tabs.setTabVisible( + self.tabs.indexOf(self.processing_tab), self.enable_machine_postprocessor + ) + # Text editor (initially hidden) + self.text_editor = CodeEditor() + + p = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Editor") + font = QtGui.QFont() + font.setFamily(p.GetString("Font", "Courier")) + font.setFixedPitch(True) + font.setPointSize(p.GetInt("FontSize", 10)) + + self.text_editor.setFont(font) + self.layout.addWidget(self.text_editor) + self.text_editor.hide() + + button_layout = QtGui.QHBoxLayout() + + self.toggle_button = QtGui.QPushButton(translate("CAM_MachineEditor", "Edit as Text")) + self.toggle_button.clicked.connect(self.toggle_editor_mode) + button_layout.addWidget(self.toggle_button) + + button_layout.addStretch() + + buttons = QtGui.QDialogButtonBox( + QtGui.QDialogButtonBox.Save | QtGui.QDialogButtonBox.Close, + QtCore.Qt.Horizontal, + ) + buttons.button(QtGui.QDialogButtonBox.Save).setText(translate("CAM_MachineEditor", "Save")) + buttons.button(QtGui.QDialogButtonBox.Close).setText( + translate("CAM_MachineEditor", "Close") + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + button_layout.addWidget(buttons) + + self.layout.addLayout(button_layout) + self.text_mode = False + + # Populate GUI from machine object + self.populate_from_machine(self.machine) + + # Set focus and select the name field for new machines + if not machine_filename: + self.name_edit.setFocus() + self.name_edit.selectAll() + + def normalize_text(self, edit, suffix, axis=None, field=None): + """Normalize and validate text input for numeric fields with units. + + Parses user input, converts between metric/imperial units, and ensures + proper formatting. Updates stored data for axes configuration. + + Args: + edit: QLineEdit widget to update + suffix: Unit suffix (e.g., "mm", "in", "deg") + axis: Axis identifier for data storage + field: Field name for data storage + """ + text = edit.text().strip() + if not text: + edit.setText("0 " + suffix) + return + + # Parse input manually based on machine units + machine_units = self.units_combo.itemData(self.units_combo.currentIndex()) + + # Split number and unit + match = re.match(r"^([+-]?\d*\.?\d+)\s*(.*)$", text) + if not match: + edit.setText("0 " + suffix) + return + + num_str, unit_str = match.groups() + try: + value = float(num_str) + except ValueError: + edit.setText("0 " + suffix) + return + + # Determine if this is a rotary axis by checking the suffix + is_angular = suffix in ["deg", "deg/min"] + + # Convert to internal mm (or degrees for angles) + if is_angular: + # For rotary axes, always use degrees, no unit conversion + if unit_str.strip(): + if unit_str.strip() in ["deg", "degree", "degrees", "°"]: + internal_value = value + else: + # Unknown unit for angles, assume degrees + internal_value = value + else: + # No unit, assume degrees + internal_value = value + else: + # Linear axes + if unit_str.strip(): + if unit_str.strip() in ["mm", "millimeter", "millimeters"]: + internal_value = value + elif unit_str.strip() in ["in", "inch", "inches", '"']: + internal_value = value * 25.4 + elif unit_str.strip() in ["cm", "centimeter", "centimeters"]: + internal_value = value * 10 + elif unit_str.strip() in ["m", "meter", "meters"]: + internal_value = value * 1000 + else: + # Unknown unit, assume machine units + if machine_units == "metric": + internal_value = value # assume mm + else: + internal_value = value * 25.4 # assume in + else: + # No unit, assume machine units + if machine_units == "metric": + internal_value = value # assume mm + else: + internal_value = value * 25.4 # assume in + + # Convert to display units + if suffix == "mm": + display_value = round(internal_value, 2) + elif suffix == "in": + display_value = round(internal_value / 25.4, 2) + elif suffix == "mm/min": + display_value = round(internal_value, 2) + elif suffix == "in/min": + display_value = round(internal_value / 25.4, 2) + elif suffix == "deg": + display_value = round(internal_value, 2) + elif suffix == "deg/min": + display_value = round(internal_value, 2) + else: + display_value = round(internal_value, 2) + + edit.setText(f"{display_value:.2f} {suffix}") + + # Update machine's axis directly (store in metric) + if axis and field and self.machine: + # Find the axis in machine.linear_axes or machine.rotary_axes (both are dicts) + if axis in self.machine.linear_axes: + ax = self.machine.linear_axes[axis] + if field == "min": + ax.min_limit = internal_value + elif field == "max": + ax.max_limit = internal_value + elif field == "max_velocity": + ax.max_velocity = internal_value + elif axis in self.machine.rotary_axes: + ax = self.machine.rotary_axes[axis] + if field == "min": + ax.min_limit = internal_value + elif field == "max": + ax.max_limit = internal_value + elif field == "max_velocity": + ax.max_velocity = internal_value + + # Signal handlers that update self.machine directly + def _on_name_changed(self, text): + """Update machine name when text changes.""" + if self.machine: + self.machine.name = text + # Update window title with new name + title = translate("CAM_MachineEditor", "Machine Editor") + if self.machine.name: + title += f" - {self.machine.name}" + self.setWindowTitle(title) + + def _on_rotary_sequence_changed(self, axis_name, value): + """Update rotary axis sequence.""" + if self.machine and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].sequence = value + + def _on_rotary_joint_changed(self, axis_name, combo): + """Update rotary axis joint/rotation vector.""" + if self.machine and axis_name in self.machine.rotary_axes: + vector = combo.itemData(combo.currentIndex()) + self.machine.rotary_axes[axis_name].rotation_vector = FreeCAD.Vector(*vector) + + def _on_linear_direction_changed(self, axis_name, combo): + """Update linear axis direction vector.""" + if self.machine and axis_name in self.machine.linear_axes: + vector = combo.itemData(combo.currentIndex()) + self.machine.linear_axes[axis_name].direction_vector = FreeCAD.Vector(*vector) + + def _on_rotary_prefer_positive_changed(self, axis_name, checked): + """Update rotary axis prefer_positive.""" + if self.machine and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].prefer_positive = checked + + def _on_axis_role_changed(self, axis_name, combo, axis_type): + """Update axis role.""" + if self.machine: + role = combo.itemData(combo.currentIndex()) + if axis_type == "linear" and axis_name in self.machine.linear_axes: + self.machine.linear_axes[axis_name].role = role + elif axis_type == "rotary" and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].role = role + + def _on_axis_parent_changed(self, axis_name, combo, axis_type): + """Update axis parent relationship.""" + if self.machine: + parent = combo.itemData(combo.currentIndex()) + if parent == "None": + parent = None + if axis_type == "linear" and axis_name in self.machine.linear_axes: + self.machine.linear_axes[axis_name].parent = parent + elif axis_type == "rotary" and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].parent = parent + + def _on_axis_sequence_changed(self, axis_name, value, axis_type): + """Update axis sequence.""" + if self.machine: + if axis_type == "linear" and axis_name in self.machine.linear_axes: + self.machine.linear_axes[axis_name].sequence = value + elif axis_type == "rotary" and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].sequence = value + + def _on_joint_origin_changed(self, axis_name, origin_values, axis_type): + """Update axis joint origin.""" + if self.machine: + if axis_type == "linear" and axis_name in self.machine.linear_axes: + self.machine.linear_axes[axis_name].joint_origin = origin_values + elif axis_type == "rotary" and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].joint_origin = origin_values + + def _on_solution_preference_changed(self, axis_name, combo): + """Update rotary axis solution preference.""" + if self.machine and axis_name in self.machine.rotary_axes: + preference = combo.itemData(combo.currentIndex()) + self.machine.rotary_axes[axis_name].solution_preference = preference + + def _on_allow_flip_changed(self, axis_name, checked): + """Update rotary axis allow flip setting.""" + if self.machine and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].allow_flip = checked + + def _on_kinematics_field_changed(self, field_name, value): + """Update kinematics field.""" + if self.machine: + if field_name == "tcp_supported": + self.machine.kinematics.tcp_supported = value + elif field_name == "dwo_supported": + self.machine.kinematics.dwo_supported = value + elif field_name == "notes": + self.machine.kinematics.notes = value + elif field_name == "origin_x": + self.machine.kinematics.base_frame.origin[0] = value + elif field_name == "origin_y": + self.machine.kinematics.base_frame.origin[1] = value + elif field_name == "origin_z": + self.machine.kinematics.base_frame.origin[2] = value + elif field_name == "quat_w": + self.machine.kinematics.base_frame.orientation_quaternion[0] = value + elif field_name == "quat_x": + self.machine.kinematics.base_frame.orientation_quaternion[1] = value + elif field_name == "quat_y": + self.machine.kinematics.base_frame.orientation_quaternion[2] = value + elif field_name == "quat_z": + self.machine.kinematics.base_frame.orientation_quaternion[3] = value + + def _on_toolhead_field_changed(self, toolhead_index, field_name, value): + """Update toolhead field in Machine object when UI field changes. + + Args: + toolhead_index: Index of the toolhead in machine.toolheads + field_name: Name of the field being updated + value: New value for the field + """ + if self.machine and toolhead_index < len(self.machine.toolheads): + toolhead = self.machine.toolheads[toolhead_index] + setattr(toolhead, field_name, value) + + # Update visibility if toolhead type changed + if field_name == "toolhead_type": + self._update_toolhead_type_visibility(toolhead_index) + + def _update_toolhead_type_visibility(self, toolhead_index): + """Update visibility of type-specific fields based on toolhead type.""" + if toolhead_index >= len(self.toolhead_edits): + return + + edits = self.toolhead_edits[toolhead_index] + toolhead_type = edits["type"].itemData(edits["type"].currentIndex()) + + # Show/hide RPM fields (only for rotary toolheads) + show_rpm = toolhead_type == ToolheadType.ROTARY + edits["rpm_widget"].setVisible(show_rpm) + + # Show/hide laser fields + show_laser = toolhead_type == ToolheadType.LASER + edits["laser_widget"].setVisible(show_laser) + + # Show/hide waterjet fields + show_waterjet = toolhead_type == ToolheadType.WATERJET + edits["waterjet_widget"].setVisible(show_waterjet) + + # Show/hide plasma fields + show_plasma = toolhead_type == ToolheadType.PLASMA + edits["plasma_widget"].setVisible(show_plasma) + + # Show/hide coolant fields (only for rotary toolheads) + show_coolant = toolhead_type == ToolheadType.ROTARY + edits["coolant_widget"].setVisible(show_coolant) + + # Hide type-specific group if no type-specific fields are visible + type_specific_visible = show_rpm or show_laser or show_waterjet or show_plasma + edits["type_specific_group"].setVisible(type_specific_visible) + + def _add_toolhead(self): + """Add a new toolhead to the machine.""" + if self.machine and len(self.machine.toolheads) < 9: + new_index = len(self.machine.toolheads) + 1 + new_toolhead = Toolhead( + name=f"Toolhead {new_index}", + toolhead_type=ToolheadType.ROTARY, + id=f"toolhead{new_index}", + max_power_kw=3.0, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + self.machine.toolheads.append(new_toolhead) + self.update_toolheads() + self._update_toolhead_button_state() + # Set focus to the new tab + self.toolheads_tabs.setCurrentIndex(len(self.machine.toolheads) - 1) + + def _remove_toolhead(self, index): + """Remove a toolhead from the machine with confirmation. + + Args: + index: Index of the tab/toolhead to remove + """ + if not self.machine or len(self.machine.toolheads) <= 1: + return # Don't allow removing the last toolhead + + reply = QtGui.QMessageBox.question( + self, + translate("CAM_MachineEditor", "Remove Toolhead"), + translate("CAM_MachineEditor", "Remove toolhead? This action cannot be undone."), + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + QtGui.QMessageBox.No, + ) + + if reply == QtGui.QMessageBox.Yes: + self.machine.toolheads.pop(index) + self.update_toolheads() + self._update_toolhead_button_state() + + def _update_toolhead_button_state(self): + """Enable/disable the add toolhead button based on count.""" + if self.machine: + toolhead_count = len(self.machine.toolheads) + should_enable = toolhead_count < 9 + self.add_toolhead_button.setEnabled(should_enable) + Path.Log.debug( + f"Toolhead button state: count={toolhead_count}, enabled={should_enable}" + ) + else: + self.add_toolhead_button.setEnabled(True) + Path.Log.debug("Toolhead button state: no machine, enabled=True") + + def _on_manufacturer_changed(self, text): + """Update manufacturer when text changes.""" + if self.machine: + self.machine.manufacturer = text + + def _on_description_changed(self, text): + """Update description when text changes.""" + if self.machine: + self.machine.description = text + + def _on_template_changed(self, index): + """Handle template selection changes.""" + template_path = self.template_combo.itemData(index) + + if template_path is None: + # "Custom" selected - reset to default empty machine + self.machine = Machine(name="New Machine") + else: + # Load the selected template + try: + self.machine = MachineFactory.load_configuration(template_path) + # Clear the name so user can provide their own + self.machine.name = "New Machine" + except Exception as e: + Path.Log.error(f"Failed to load template: {e}") + QtGui.QMessageBox.warning( + self, + translate("CAM_MachineEditor", "Template Load Error"), + translate("CAM_MachineEditor", "Could not load template"), + ) + return + + # Repopulate the entire UI with the loaded machine + self.populate_from_machine(self.machine) + + # Set focus to name field for editing + self.name_edit.setFocus() + self.name_edit.selectAll() + + def _on_units_changed(self, index): + """Update units and refresh axes display.""" + if self.machine: + units = self.units_combo.itemData(index) + self.machine.configuration_units = units + self.current_units = units + self.update_axes() + + def _on_type_changed(self, index): + """Update machine type and refresh axes.""" + if self.machine: + machine_type = self.type_combo.itemData(index) + # Note: machine_type is a read-only property determined by axes configuration + # Don't try to set it directly - instead modify the axes below + + # Rebuild axes in machine based on new type + config = self.MACHINE_TYPES.get(machine_type, {}) + + # Store existing axes for preservation + old_linear_axes = self.machine.linear_axes.copy() + old_rotary_axes = self.machine.rotary_axes.copy() + + # Clear and rebuild linear axes + self.machine.linear_axes = {} + for axis_name in config.get("linear", []): + # Preserve existing axis if available + if axis_name in old_linear_axes: + self.machine.linear_axes[axis_name] = old_linear_axes[axis_name] + else: + # Create with enhanced multi-axis support + self.machine.linear_axes[axis_name] = LinearAxis( + name=axis_name, + direction_vector=( + FreeCAD.Vector(1, 0, 0) + if axis_name == "X" + else ( + FreeCAD.Vector(0, 1, 0) + if axis_name == "Y" + else FreeCAD.Vector(0, 0, 1) + ) + ), + min_limit=0, + max_limit=1000, + max_velocity=10000, + sequence=0, + role=AxisRole.TABLE_LINEAR, # Default role + parent=None, # Default no parent + joint_origin=[0, 0, 0], # Default origin + ) + + # Clear and rebuild rotary axes + self.machine.rotary_axes = {} + for axis_name in config.get("rotary", []): + # Preserve existing axis if available + if axis_name in old_rotary_axes: + self.machine.rotary_axes[axis_name] = old_rotary_axes[axis_name] + else: + # Create with defaults + default_vector = ( + [1, 0, 0] + if axis_name == "A" + else [0, 1, 0] if axis_name == "B" else [0, 0, 1] + ) + self.machine.rotary_axes[axis_name] = RotaryAxis( + name=axis_name, + rotation_vector=FreeCAD.Vector(*default_vector), + min_limit=-180, + max_limit=180, + max_velocity=36000, + sequence=0, + prefer_positive=True, + role=AxisRole.TABLE_ROTARY, # Default role + parent=None, # Default no parent + joint_origin=[0, 0, 0], # Default origin + solution_preference="shortest", # Default solution + allow_flip=True, # Default allow flip + ) + + self.update_axes() + + def setup_machine_tab(self): + """Set up the machine configuration tab with form fields. + + Creates input fields for machine name, manufacturer, description, + units, type, toolhead count, axes configuration, and toolheads. + Connects change handlers for dynamic updates. + """ + layout = QtGui.QFormLayout(self.machine_tab) + + self.name_edit = QtGui.QLineEdit() + self.name_edit.textChanged.connect(self._on_name_changed) + layout.addRow(translate("CAM_MachineEditor", "Name"), self.name_edit) + + # Template selector (only for new machines) + if self.is_new_machine: + self.template_combo = QtGui.QComboBox() + self.template_combo.addItem(translate("CAM_MachineEditor", "Custom"), None) + + # Add user's own machines + user_machines = MachineFactory.list_configuration_files() + if len(user_machines) > 1: # More than just "" + self.template_combo.insertSeparator(self.template_combo.count()) + for name, filename in user_machines: + if filename: # Skip "" + # Get full path using the factory method + config_dir = MachineFactory.get_config_directory() + user_path = config_dir / filename + display_name = f"📁 {name}" + self.template_combo.addItem(display_name, str(user_path)) + + # Add built-in templates using MachineFactory method + builtin_templates = MachineFactory.list_builtin_templates() + if builtin_templates: + self.template_combo.insertSeparator(self.template_combo.count()) + for name, filepath in builtin_templates: + display_name = f"📋 {name}" + self.template_combo.addItem(display_name, filepath) + + self.template_combo.currentIndexChanged.connect(self._on_template_changed) + self.template_combo.setToolTip( + translate("CAM_MachineEditor", "Load settings from an existing machine template") + ) + layout.addRow(translate("CAM_MachineEditor", "Template"), self.template_combo) + + self.manufacturer_edit = QtGui.QLineEdit() + self.manufacturer_edit.textChanged.connect(self._on_manufacturer_changed) + layout.addRow(translate("CAM_MachineEditor", "Manufacturer"), self.manufacturer_edit) + + self.description_edit = QtGui.QLineEdit() + self.description_edit.textChanged.connect(self._on_description_changed) + layout.addRow(translate("CAM_MachineEditor", "Description"), self.description_edit) + + self.units_combo = QtGui.QComboBox() + self.units_combo.addItem(translate("CAM_MachineEditor", "Metric"), "metric") + self.units_combo.addItem(translate("CAM_MachineEditor", "Imperial"), "imperial") + self.units_combo.currentIndexChanged.connect(self._on_units_changed) + layout.addRow(translate("CAM_MachineEditor", "Units"), self.units_combo) + + self.type_combo = QtGui.QComboBox() + for key, value in self.MACHINE_TYPES.items(): + self.type_combo.addItem(value["name"], key) + self.type_combo.currentIndexChanged.connect(self._on_type_changed) + layout.addRow(translate("CAM_MachineEditor", "Type"), self.type_combo) + + # Kinematics group + self.kinematics_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Kinematics")) + kinematics_layout = QtGui.QFormLayout(self.kinematics_group) + + # Base frame section + base_frame_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Base Frame")) + base_frame_group.setCheckable(True) + base_frame_group.setChecked(True) # Ensure it's expanded by default + base_frame_layout = QtGui.QGridLayout(base_frame_group) + + # Origin fields + base_frame_layout.addWidget(QtGui.QLabel("X:"), 0, 0) + self.origin_x_spin = QtGui.QDoubleSpinBox() + self.origin_x_spin.setRange(-9999, 9999) + self.origin_x_spin.setDecimals(3) + self.origin_x_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("origin_x", v) + ) + base_frame_layout.addWidget(self.origin_x_spin, 0, 1) + + base_frame_layout.addWidget(QtGui.QLabel("Y:"), 0, 2) + self.origin_y_spin = QtGui.QDoubleSpinBox() + self.origin_y_spin.setRange(-9999, 9999) + self.origin_y_spin.setDecimals(3) + self.origin_y_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("origin_y", v) + ) + base_frame_layout.addWidget(self.origin_y_spin, 0, 3) + + base_frame_layout.addWidget(QtGui.QLabel("Z:"), 0, 4) + self.origin_z_spin = QtGui.QDoubleSpinBox() + self.origin_z_spin.setRange(-9999, 9999) + self.origin_z_spin.setDecimals(3) + self.origin_z_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("origin_z", v) + ) + base_frame_layout.addWidget(self.origin_z_spin, 0, 5) + + # Quaternion fields + base_frame_layout.addWidget(QtGui.QLabel("W:"), 1, 0) + self.quat_w_spin = QtGui.QDoubleSpinBox() + self.quat_w_spin.setRange(-1, 1) + self.quat_w_spin.setDecimals(3) + self.quat_w_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("quat_w", v) + ) + base_frame_layout.addWidget(self.quat_w_spin, 1, 1) + + base_frame_layout.addWidget(QtGui.QLabel("X:"), 1, 2) + self.quat_x_spin = QtGui.QDoubleSpinBox() + self.quat_x_spin.setRange(-1, 1) + self.quat_x_spin.setDecimals(3) + self.quat_x_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("quat_x", v) + ) + base_frame_layout.addWidget(self.quat_x_spin, 1, 3) + + base_frame_layout.addWidget(QtGui.QLabel("Y:"), 1, 4) + self.quat_y_spin = QtGui.QDoubleSpinBox() + self.quat_y_spin.setRange(-1, 1) + self.quat_y_spin.setDecimals(3) + self.quat_y_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("quat_y", v) + ) + base_frame_layout.addWidget(self.quat_y_spin, 1, 5) + + base_frame_layout.addWidget(QtGui.QLabel("Z:"), 1, 6) + self.quat_z_spin = QtGui.QDoubleSpinBox() + self.quat_z_spin.setRange(-1, 1) + self.quat_z_spin.setDecimals(3) + self.quat_z_spin.valueChanged.connect( + lambda v: self._on_kinematics_field_changed("quat_z", v) + ) + base_frame_layout.addWidget(self.quat_z_spin, 1, 7) + + kinematics_layout.addRow(base_frame_group) + + # TCP/DWO support + self.tcp_supported_check = QtGui.QCheckBox() + self.tcp_supported_check.toggled.connect( + lambda v: self._on_kinematics_field_changed("tcp_supported", v) + ) + kinematics_layout.addRow( + translate("CAM_MachineEditor", "TCP Supported"), self.tcp_supported_check + ) + + self.dwo_supported_check = QtGui.QCheckBox() + self.dwo_supported_check.toggled.connect( + lambda v: self._on_kinematics_field_changed("dwo_supported", v) + ) + kinematics_layout.addRow( + translate("CAM_MachineEditor", "DWO Supported"), self.dwo_supported_check + ) + + # Notes + self.kinematics_notes_edit = QtGui.QLineEdit() + self.kinematics_notes_edit.textChanged.connect( + lambda: self._on_kinematics_field_changed("notes", self.kinematics_notes_edit.text()) + ) + kinematics_layout.addRow( + translate("CAM_MachineEditor", "Notes"), self.kinematics_notes_edit + ) + + layout.addRow(self.kinematics_group) + + # Axes group + self.axes_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Axes")) + self.axes_layout = QtGui.QVBoxLayout(self.axes_group) + self.axes_group.setVisible(False) # Initially hidden, shown when axes are configured + layout.addRow(self.axes_group) + + # Toolheads group + self.toolheads_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Toolheads")) + toolheads_layout = QtGui.QVBoxLayout(self.toolheads_group) + + self.toolheads_tabs = QtGui.QTabWidget() + self.toolheads_tabs.setTabsClosable(True) + self.toolheads_tabs.tabCloseRequested.connect(self._remove_toolhead) + + # Add + button to the tab bar corner, vertically centered + corner_container = QtGui.QWidget() + corner_container_layout = QtGui.QVBoxLayout(corner_container) + corner_container_layout.setContentsMargins(0, 0, 0, 0) + corner_container_layout.setSpacing(0) + corner_container_layout.addStretch() + self.add_toolhead_button = QtGui.QPushButton("+") + self.add_toolhead_button.setToolTip(translate("CAM_MachineEditor", "Add Toolhead")) + self.add_toolhead_button.clicked.connect(self._add_toolhead) + self.add_toolhead_button.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + # Ensure button is initially enabled + self.add_toolhead_button.setEnabled(True) + corner_container_layout.addWidget(self.add_toolhead_button, 0, QtCore.Qt.AlignCenter) + corner_container_layout.addStretch() + self.toolheads_tabs.setCornerWidget(corner_container, QtCore.Qt.TopRightCorner) + + toolheads_layout.addWidget(self.toolheads_tabs) + layout.addRow(self.toolheads_group) + + def update_axes(self): + """Update the axes configuration UI based on machine type and units. + + Dynamically creates input fields for all axes based on the selected + machine type. Supports enhanced multi-axis configuration with roles, + parent relationships, and joint configuration. + """ + # Get current units for suffixes and conversion + units = self.units_combo.itemData(self.units_combo.currentIndex()) + length_suffix = " mm" if units == "metric" else " in" + vel_suffix = " mm/min" if units == "metric" else " in/min" + angle_suffix = " deg" + angle_vel_suffix = " deg/min" + + # Convert saved data if units changed + if hasattr(self, "current_units") and self.current_units != units: + self.current_units = units + + # Clear references before deleting widgets + self.axis_edits = {} + + # Clear existing axes widgets and disconnect signals + for i in reversed(range(self.axes_layout.count())): + widget = self.axes_layout.itemAt(i).widget() + if widget: + # Disconnect all signals to prevent callbacks to deleted widgets + widget.blockSignals(True) + widget.deleteLater() + self.axes_layout.removeWidget(widget) + + # Get current type + type_key = self.type_combo.itemData(self.type_combo.currentIndex()) + if not type_key: + return + config = self.MACHINE_TYPES[type_key] + + # Check if machine has axes (from loaded machine or from type config) + linear_axes = list(self.machine.linear_axes.keys()) if self.machine else [] + rotary_axes = list(self.machine.rotary_axes.keys()) if self.machine else [] + all_axis_names = linear_axes + rotary_axes + + # If no axes in machine, try to get from type config + if not all_axis_names: + config_axes = config.get("linear", []) + config.get("rotary", []) + if not config_axes: + self.axes_group.setVisible(False) + return + # Use config axes for new machines + all_axis_names = config_axes + + # Show axes group since we have axes + self.axes_group.setVisible(True) + + # Linear axes + if linear_axes: + linear_group = QtGui.QGroupBox("Linear Axes") + linear_layout = QtGui.QFormLayout(linear_group) + + for axis in linear_axes: + axis_obj = self.machine.linear_axes[axis] + + # Create grid layout for this axis + axis_grid = QtGui.QGridLayout() + + # Role dropdown + role_combo = QtGui.QComboBox() + for role in AxisRole: + if role in [AxisRole.TABLE_LINEAR, AxisRole.HEAD_LINEAR]: + role_combo.addItem(role.value.replace("_", " ").title(), role) + current_role_index = role_combo.findData(axis_obj.role) + if current_role_index >= 0: + role_combo.setCurrentIndex(current_role_index) + role_combo.currentIndexChanged.connect( + lambda idx, ax=axis, combo=role_combo: self._on_axis_role_changed( + ax, combo, "linear" + ) + ) + + # Parent dropdown + parent_combo = QtGui.QComboBox() + parent_combo.addItem("None", None) + for other_axis in all_axis_names: + if other_axis != axis: + parent_combo.addItem(other_axis, other_axis) + if axis_obj.parent: + parent_index = parent_combo.findData(axis_obj.parent) + if parent_index >= 0: + parent_combo.setCurrentIndex(parent_index) + parent_combo.currentIndexChanged.connect( + lambda idx, ax=axis, combo=parent_combo: self._on_axis_parent_changed( + ax, combo, "linear" + ) + ) + + # Sequence + sequence_spin = QtGui.QSpinBox() + sequence_spin.setRange(0, 10) + sequence_spin.setValue(axis_obj.sequence) + sequence_spin.valueChanged.connect( + lambda value, ax=axis: self._on_axis_sequence_changed(ax, value, "linear") + ) + + # Direction vector combo + direction_combo = QtGui.QComboBox() + for label, vector in self.ROTATIONAL_AXIS_OPTIONS: + direction_combo.addItem(label, vector) + direction_vec = [ + axis_obj.direction_vector.x, + axis_obj.direction_vector.y, + axis_obj.direction_vector.z, + ] + for i, (label, vector) in enumerate(self.ROTATIONAL_AXIS_OPTIONS): + if vector == direction_vec: + direction_combo.setCurrentIndex(i) + break + direction_combo.currentIndexChanged.connect( + lambda idx, ax=axis, combo=direction_combo: self._on_linear_direction_changed( + ax, combo + ) + ) + + # Limits and velocity + converted_min = ( + axis_obj.min_limit if units == "metric" else axis_obj.min_limit / 25.4 + ) + min_edit = QtGui.QLineEdit() + min_edit.setText(f"{converted_min:.2f}{length_suffix}") + min_edit.editingFinished.connect( + lambda edit=min_edit, suffix=length_suffix.strip(), ax=axis, fld="min": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + converted_max = ( + axis_obj.max_limit if units == "metric" else axis_obj.max_limit / 25.4 + ) + max_edit = QtGui.QLineEdit() + max_edit.setText(f"{converted_max:.2f}{length_suffix}") + max_edit.editingFinished.connect( + lambda edit=max_edit, suffix=length_suffix.strip(), ax=axis, fld="max": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + converted_vel = ( + axis_obj.max_velocity if units == "metric" else axis_obj.max_velocity / 25.4 + ) + vel_edit = QtGui.QLineEdit() + vel_edit.setText(f"{converted_vel:.2f}{vel_suffix}") + vel_edit.editingFinished.connect( + lambda edit=vel_edit, suffix=vel_suffix.strip(), ax=axis, fld="max_velocity": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + # Joint origin + origin_x_spin = QtGui.QDoubleSpinBox() + origin_x_spin.setRange(-9999, 9999) + origin_x_spin.setDecimals(3) + origin_x_spin.setValue(axis_obj.joint_origin[0]) + + origin_y_spin = QtGui.QDoubleSpinBox() + origin_y_spin.setRange(-9999, 9999) + origin_y_spin.setDecimals(3) + origin_y_spin.setValue(axis_obj.joint_origin[1]) + + origin_z_spin = QtGui.QDoubleSpinBox() + origin_z_spin.setRange(-9999, 9999) + origin_z_spin.setDecimals(3) + origin_z_spin.setValue(axis_obj.joint_origin[2]) + + def update_joint_origin(ax, x_val, y_val, z_val): + self._on_joint_origin_changed(ax, [x_val, y_val, z_val], "linear") + + origin_x_spin.valueChanged.connect( + lambda v, ax=axis: update_joint_origin( + ax, v, origin_y_spin.value(), origin_z_spin.value() + ) + ) + origin_y_spin.valueChanged.connect( + lambda v, ax=axis: update_joint_origin( + ax, origin_x_spin.value(), v, origin_z_spin.value() + ) + ) + origin_z_spin.valueChanged.connect( + lambda v, ax=axis: update_joint_origin( + ax, origin_x_spin.value(), origin_y_spin.value(), v + ) + ) + + # Layout the grid + axis_grid.addWidget(QtGui.QLabel("Role:"), 0, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(role_combo, 0, 1) + axis_grid.addWidget(QtGui.QLabel("Parent:"), 0, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(parent_combo, 0, 3) + axis_grid.addWidget(QtGui.QLabel("Seq:"), 0, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(sequence_spin, 0, 5) + axis_grid.addWidget(QtGui.QLabel("Dir:"), 0, 6, QtCore.Qt.AlignRight) + axis_grid.addWidget(direction_combo, 0, 7) + + axis_grid.addWidget(QtGui.QLabel("Min:"), 1, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(min_edit, 1, 1) + axis_grid.addWidget(QtGui.QLabel("Max:"), 1, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(max_edit, 1, 3) + axis_grid.addWidget(QtGui.QLabel("Vel:"), 1, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(vel_edit, 1, 5) + + axis_grid.addWidget(QtGui.QLabel("Origin:"), 2, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(origin_x_spin, 2, 1) + axis_grid.addWidget(origin_y_spin, 2, 2) + axis_grid.addWidget(origin_z_spin, 2, 3) + + axis_label = QtGui.QLabel(f"{axis}") + axis_label.setMinimumWidth(30) + linear_layout.addRow(axis_label, axis_grid) + + self.axis_edits[axis] = { + "role": role_combo, + "parent": parent_combo, + "sequence": sequence_spin, + "direction": direction_combo, + "min": min_edit, + "max": max_edit, + "max_velocity": vel_edit, + "origin_x": origin_x_spin, + "origin_y": origin_y_spin, + "origin_z": origin_z_spin, + "type": "linear", + } + self.axes_layout.addWidget(linear_group) + + # Rotary axes + if rotary_axes: + rotary_group = QtGui.QGroupBox("Rotary Axes") + rotary_layout = QtGui.QFormLayout(rotary_group) + + for axis in rotary_axes: + axis_obj = self.machine.rotary_axes[axis] + + # Create grid layout for this axis + axis_grid = QtGui.QGridLayout() + + # Role dropdown + role_combo = QtGui.QComboBox() + for role in AxisRole: + if role in [AxisRole.TABLE_ROTARY, AxisRole.HEAD_ROTARY]: + role_combo.addItem(role.value.replace("_", " ").title(), role) + current_role_index = role_combo.findData(axis_obj.role) + if current_role_index >= 0: + role_combo.setCurrentIndex(current_role_index) + role_combo.currentIndexChanged.connect( + lambda idx, ax=axis, combo=role_combo: self._on_axis_role_changed( + ax, combo, "rotary" + ) + ) + + # Parent dropdown + parent_combo = QtGui.QComboBox() + parent_combo.addItem("None", None) + for other_axis in all_axis_names: + if other_axis != axis: + parent_combo.addItem(other_axis, other_axis) + if axis_obj.parent: + parent_index = parent_combo.findData(axis_obj.parent) + if parent_index >= 0: + parent_combo.setCurrentIndex(parent_index) + parent_combo.currentIndexChanged.connect( + lambda idx, ax=axis, combo=parent_combo: self._on_axis_parent_changed( + ax, combo, "rotary" + ) + ) + + # Sequence + sequence_spin = QtGui.QSpinBox() + sequence_spin.setRange(0, 10) + sequence_spin.setValue(axis_obj.sequence) + sequence_spin.valueChanged.connect( + lambda value, ax=axis: self._on_axis_sequence_changed(ax, value, "rotary") + ) + + # Limits and velocity + min_edit = QtGui.QLineEdit() + min_edit.setText(f"{axis_obj.min_limit:.2f}{angle_suffix}") + min_edit.editingFinished.connect( + lambda edit=min_edit, suffix=angle_suffix.strip(), ax=axis, fld="min": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + max_edit = QtGui.QLineEdit() + max_edit.setText(f"{axis_obj.max_limit:.2f}{angle_suffix}") + max_edit.editingFinished.connect( + lambda edit=max_edit, suffix=angle_suffix.strip(), ax=axis, fld="max": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + vel_edit = QtGui.QLineEdit() + vel_edit.setText(f"{axis_obj.max_velocity:.2f}{angle_vel_suffix}") + vel_edit.editingFinished.connect( + lambda edit=vel_edit, suffix=angle_vel_suffix.strip(), ax=axis, fld="max_velocity": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + # Joint axis combo + joint_combo = QtGui.QComboBox() + for label, vector in self.ROTATIONAL_AXIS_OPTIONS: + joint_combo.addItem(label, vector) + rotation_vec = [ + axis_obj.rotation_vector.x, + axis_obj.rotation_vector.y, + axis_obj.rotation_vector.z, + ] + for i, (label, vector) in enumerate(self.ROTATIONAL_AXIS_OPTIONS): + if vector == rotation_vec: + joint_combo.setCurrentIndex(i) + break + joint_combo.currentIndexChanged.connect( + lambda index, ax=axis, combo=joint_combo: self._on_rotary_joint_changed( + ax, combo + ) + ) + + # Joint origin + origin_x_spin = QtGui.QDoubleSpinBox() + origin_x_spin.setRange(-9999, 9999) + origin_x_spin.setDecimals(3) + origin_x_spin.setValue(axis_obj.joint_origin[0]) + + origin_y_spin = QtGui.QDoubleSpinBox() + origin_y_spin.setRange(-9999, 9999) + origin_y_spin.setDecimals(3) + origin_y_spin.setValue(axis_obj.joint_origin[1]) + + origin_z_spin = QtGui.QDoubleSpinBox() + origin_z_spin.setRange(-9999, 9999) + origin_z_spin.setDecimals(3) + origin_z_spin.setValue(axis_obj.joint_origin[2]) + + def update_rotary_origin(ax, x_val, y_val, z_val): + self._on_joint_origin_changed(ax, [x_val, y_val, z_val], "rotary") + + origin_x_spin.valueChanged.connect( + lambda v, ax=axis: update_rotary_origin( + ax, v, origin_y_spin.value(), origin_z_spin.value() + ) + ) + origin_y_spin.valueChanged.connect( + lambda v, ax=axis: update_rotary_origin( + ax, origin_x_spin.value(), v, origin_z_spin.value() + ) + ) + origin_z_spin.valueChanged.connect( + lambda v, ax=axis: update_rotary_origin( + ax, origin_x_spin.value(), origin_y_spin.value(), v + ) + ) + + # Solution preference + solution_combo = QtGui.QComboBox() + solution_combo.addItem("Shortest", "shortest") + solution_combo.addItem("Positive", "positive") + solution_combo.addItem("Negative", "negative") + current_solution_index = solution_combo.findData(axis_obj.solution_preference) + if current_solution_index >= 0: + solution_combo.setCurrentIndex(current_solution_index) + solution_combo.currentIndexChanged.connect( + lambda idx, ax=axis, combo=solution_combo: self._on_solution_preference_changed( + ax, combo + ) + ) + + # Allow flip checkbox + allow_flip_check = QtGui.QCheckBox() + allow_flip_check.setChecked(axis_obj.allow_flip) + allow_flip_check.toggled.connect( + lambda checked, ax=axis: self._on_allow_flip_changed(ax, checked) + ) + + # Legacy prefer_positive checkbox + prefer_positive_check = QtGui.QCheckBox() + prefer_positive_check.setChecked(axis_obj.prefer_positive) + prefer_positive_check.stateChanged.connect( + lambda state, ax=axis: self._on_rotary_prefer_positive_changed( + ax, state == QtCore.Qt.Checked + ) + ) + + # Layout the grid + axis_grid.addWidget(QtGui.QLabel("Role:"), 0, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(role_combo, 0, 1) + axis_grid.addWidget(QtGui.QLabel("Parent:"), 0, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(parent_combo, 0, 3) + axis_grid.addWidget(QtGui.QLabel("Seq:"), 0, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(sequence_spin, 0, 5) + + axis_grid.addWidget(QtGui.QLabel("Min:"), 1, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(min_edit, 1, 1) + axis_grid.addWidget(QtGui.QLabel("Max:"), 1, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(max_edit, 1, 3) + axis_grid.addWidget(QtGui.QLabel("Vel:"), 1, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(vel_edit, 1, 5) + + axis_grid.addWidget(QtGui.QLabel("Joint:"), 2, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(joint_combo, 2, 1) + axis_grid.addWidget(QtGui.QLabel("Origin:"), 2, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(origin_x_spin, 2, 3) + axis_grid.addWidget(origin_y_spin, 2, 4) + axis_grid.addWidget(origin_z_spin, 2, 5) + + axis_grid.addWidget(QtGui.QLabel("Solution:"), 3, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(solution_combo, 3, 1) + axis_grid.addWidget(QtGui.QLabel("Flip:"), 3, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(allow_flip_check, 3, 3) + axis_grid.addWidget(QtGui.QLabel("Prefer+:"), 3, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(prefer_positive_check, 3, 5) + + axis_label = QtGui.QLabel(f"{axis}") + axis_label.setMinimumWidth(30) + rotary_layout.addRow(axis_label, axis_grid) + + self.axis_edits[axis] = { + "role": role_combo, + "parent": parent_combo, + "sequence": sequence_spin, + "min": min_edit, + "max": max_edit, + "max_velocity": vel_edit, + "joint": joint_combo, + "origin_x": origin_x_spin, + "origin_y": origin_y_spin, + "origin_z": origin_z_spin, + "solution": solution_combo, + "allow_flip": allow_flip_check, + "prefer_positive": prefer_positive_check, + "type": "rotary", + } + self.axes_layout.addWidget(rotary_group) + + # Show axes group if any axes configured + self.axes_group.setVisible(bool(linear_axes or rotary_axes)) + + def update_toolheads(self): + """Update the toolhead configuration UI based on toolhead count. + + Dynamically creates tabbed interface for multiple toolheads with + input fields for type, name, ID, power, speed, and type-specific parameters. + Updates Machine.toolheads directly. + """ + initial_count = len(self.machine.toolheads) if self.machine else 0 + Path.Log.debug(f"update_toolheads() called with {initial_count} existing toolheads") + + # Clear existing toolhead tabs - this properly disconnects signals + while self.toolheads_tabs.count() > 0: + tab = self.toolheads_tabs.widget(0) + if tab: + tab.blockSignals(True) + tab.deleteLater() + self.toolheads_tabs.removeTab(0) + + self.toolhead_edits = [] + count = len(self.machine.toolheads) if self.machine else 1 + Path.Log.debug(f"Target toolhead count: {count}") + + # Ensure machine has at least 1 toolhead + if self.machine: + # Always ensure at least 1 toolhead, even if current count is 0 + target_count = max(count, 1) + Path.Log.debug(f"Target count after max(count, 1): {target_count}") + while len(self.machine.toolheads) < target_count: + Path.Log.debug(f"Adding toolhead, current count: {len(self.machine.toolheads)}") + self.machine.toolheads.append( + Toolhead( + name=f"Toolhead {len(self.machine.toolheads) + 1}", + toolhead_type=ToolheadType.ROTARY, + id=f"toolhead{len(self.machine.toolheads) + 1}", + max_power_kw=3.0, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + ) + while len(self.machine.toolheads) > count: + self.machine.toolheads.pop() + + Path.Log.debug(f"After toolhead adjustment, count: {len(self.machine.toolheads)}") + + for i in range(max(count, 1)): + tab = QtGui.QWidget() + layout = QtGui.QFormLayout(tab) + + # Get toolhead object or use defaults + toolhead = ( + self.machine.toolheads[i] + if self.machine and i < len(self.machine.toolheads) + else None + ) + + # Toolhead type selection + type_combo = QtGui.QComboBox() + for toolhead_type in ToolheadType: + type_combo.addItem(toolhead_type.value.title(), toolhead_type) + + if toolhead: + index = type_combo.findData(toolhead.toolhead_type) + if index >= 0: + type_combo.setCurrentIndex(index) + + type_combo.currentIndexChanged.connect( + lambda idx, toolhead_idx=i, combo=type_combo: self._on_toolhead_field_changed( + toolhead_idx, "toolhead_type", combo.itemData(combo.currentIndex()) + ) + ) + layout.addRow("Type", type_combo) + + # Name field + name_edit = QtGui.QLineEdit() + name_edit.setText(toolhead.name if toolhead else f"Toolhead {i+1}") + name_edit.textChanged.connect( + lambda text, idx=i: self._on_toolhead_field_changed(idx, "name", text) + ) + layout.addRow("Name", name_edit) + + # ID field + id_edit = QtGui.QLineEdit() + id_edit.setText(toolhead.id if toolhead and toolhead.id else f"toolhead{i+1}") + id_edit.textChanged.connect( + lambda text, idx=i: self._on_toolhead_field_changed(idx, "id", text) + ) + layout.addRow("ID", id_edit) + + # Tool change method + tool_change_combo = QtGui.QComboBox() + tool_change_options = [ + ("Manual", "manual"), + ("Automatic", "automatic"), + ("Probe", "probe"), + ("Fixed", "fixed"), + ("None", "none"), + ] + for label, value in tool_change_options: + tool_change_combo.addItem(label, value) + + if toolhead and toolhead.tool_change: + index = tool_change_combo.findData(toolhead.tool_change) + if index >= 0: + tool_change_combo.setCurrentIndex(index) + + tool_change_combo.currentIndexChanged.connect( + lambda idx, toolhead_idx=i, combo=tool_change_combo: self._on_toolhead_field_changed( + toolhead_idx, "tool_change", combo.itemData(combo.currentIndex()) + ) + ) + layout.addRow("Tool Change", tool_change_combo) + + # Power specification + max_power_edit = QtGui.QDoubleSpinBox() + max_power_edit.setRange(0, 100) + max_power_edit.setValue(toolhead.max_power_kw if toolhead else 3.0) + max_power_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "max_power_kw", value) + ) + layout.addRow("Max Power (kW)", max_power_edit) + + # Type-specific fields container + type_specific_group = QtGui.QGroupBox("Type-Specific Settings") + type_specific_layout = QtGui.QVBoxLayout(type_specific_group) + + # Rotary toolhead fields (RPM) + rpm_widget = QtGui.QWidget() + rpm_layout = QtGui.QFormLayout(rpm_widget) + + max_rpm_edit = QtGui.QSpinBox() + max_rpm_edit.setRange(0, 100000) + max_rpm_edit.setValue(int(toolhead.max_rpm) if toolhead else 24000) + max_rpm_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "max_rpm", value) + ) + rpm_layout.addRow("Max RPM", max_rpm_edit) + + min_rpm_edit = QtGui.QSpinBox() + min_rpm_edit.setRange(0, 100000) + min_rpm_edit.setValue(int(toolhead.min_rpm) if toolhead else 6000) + min_rpm_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "min_rpm", value) + ) + rpm_layout.addRow("Min RPM", min_rpm_edit) + + type_specific_layout.addWidget(rpm_widget) + + # Laser toolhead fields + laser_widget = QtGui.QWidget() + laser_layout = QtGui.QFormLayout(laser_widget) + + wavelength_edit = QtGui.QDoubleSpinBox() + wavelength_edit.setRange(200, 2000) + wavelength_edit.setDecimals(1) + wavelength_edit.setValue( + toolhead.laser_wavelength + if toolhead and toolhead.laser_wavelength is not None + else 1064.0 + ) + wavelength_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "laser_wavelength", value) + ) + laser_layout.addRow("Wavelength (nm)", wavelength_edit) + + type_specific_layout.addWidget(laser_widget) + + # Waterjet toolhead fields + waterjet_widget = QtGui.QWidget() + waterjet_layout = QtGui.QFormLayout(waterjet_widget) + + pressure_edit = QtGui.QDoubleSpinBox() + pressure_edit.setRange(0, 10000) + pressure_edit.setDecimals(0) + pressure_edit.setValue( + toolhead.waterjet_pressure + if toolhead and toolhead.waterjet_pressure is not None + else 4000.0 + ) + pressure_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed( + idx, "waterjet_pressure", value + ) + ) + waterjet_layout.addRow("Pressure (bar)", pressure_edit) + + type_specific_layout.addWidget(waterjet_widget) + + # Plasma toolhead fields + plasma_widget = QtGui.QWidget() + plasma_layout = QtGui.QFormLayout(plasma_widget) + + amperage_edit = QtGui.QDoubleSpinBox() + amperage_edit.setRange(0, 500) + amperage_edit.setDecimals(1) + amperage_edit.setValue( + toolhead.plasma_amperage + if toolhead and toolhead.plasma_amperage is not None + else 45.0 + ) + amperage_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "plasma_amperage", value) + ) + plasma_layout.addRow("Amperage", amperage_edit) + + type_specific_layout.addWidget(plasma_widget) + + # Coolant fields + coolant_widget = QtGui.QWidget() + coolant_layout = QtGui.QFormLayout(coolant_widget) + + coolant_flood_check = QtGui.QCheckBox() + coolant_flood_check.setChecked(toolhead.coolant_flood if toolhead else False) + coolant_flood_check.toggled.connect( + lambda checked, idx=i: self._on_toolhead_field_changed( + idx, "coolant_flood", checked + ) + ) + coolant_layout.addRow("Coolant Flood", coolant_flood_check) + + coolant_mist_check = QtGui.QCheckBox() + coolant_mist_check.setChecked(toolhead.coolant_mist if toolhead else False) + coolant_mist_check.toggled.connect( + lambda checked, idx=i: self._on_toolhead_field_changed(idx, "coolant_mist", checked) + ) + coolant_layout.addRow("Coolant Mist", coolant_mist_check) + + coolant_delay_edit = QtGui.QDoubleSpinBox() + coolant_delay_edit.setRange(0, 60) + coolant_delay_edit.setDecimals(1) + coolant_delay_edit.setValue(toolhead.coolant_delay if toolhead else 0.0) + coolant_delay_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "coolant_delay", value) + ) + coolant_layout.addRow("Coolant Delay (s)", coolant_delay_edit) + + # Wait time field + wait_edit = QtGui.QDoubleSpinBox() + wait_edit.setRange(0, 60) + wait_edit.setDecimals(1) + wait_edit.setValue(toolhead.toolhead_wait if toolhead else 0.0) + wait_edit.valueChanged.connect( + lambda value, idx=i: self._on_toolhead_field_changed(idx, "toolhead_wait", value) + ) + coolant_layout.addRow("Wait Time (s)", wait_edit) + + type_specific_layout.addWidget(coolant_widget) + + layout.addWidget(type_specific_group) + + # Add tab to toolheads tabs + self.toolheads_tabs.addTab(tab, toolhead.name if toolhead else f"Toolhead {i+1}") + + # Store widget references for updates + self.toolhead_edits.append( + { + "type": type_combo, + "name": name_edit, + "id": id_edit, + "max_power_kw": max_power_edit, + "max_rpm": max_rpm_edit, + "min_rpm": min_rpm_edit, + "wavelength": wavelength_edit, + "pressure": pressure_edit, + "amperage": amperage_edit, + "coolant_flood": coolant_flood_check, + "coolant_mist": coolant_mist_check, + "coolant_delay": coolant_delay_edit, + "wait": wait_edit, + "rpm_widget": rpm_widget, + "laser_widget": laser_widget, + "waterjet_widget": waterjet_widget, + "plasma_widget": plasma_widget, + "coolant_widget": coolant_widget, + "type_specific_group": type_specific_group, + } + ) + + # Update visibility based on initial toolhead type + self._update_toolhead_type_visibility(i) + + # Update button state + self._update_toolhead_button_state() + final_count = len(self.machine.toolheads) if self.machine else 0 + Path.Log.debug(f"update_toolheads() completed with {final_count} toolheads") + + def setup_output_tab(self): + """Set up the output options configuration tab.""" + # Use scroll area for all the options + scroll = QtGui.QScrollArea() + scroll.setWidgetResizable(True) + scroll_widget = QtGui.QWidget() + layout = QtGui.QVBoxLayout(scroll_widget) + scroll.setWidget(scroll_widget) + + main_layout = QtGui.QVBoxLayout(self.output_tab) + main_layout.addWidget(scroll) + + # === Output Options === + if self.machine: + # Main output options + main_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Main Options")) + main_layout = QtGui.QFormLayout(main_group) + + # Units + units_combo = QtGui.QComboBox() + units_combo.addItem("Metric", "metric") + units_combo.addItem("Imperial", "imperial") + units_str = self.machine.output.units.value + index = units_combo.findData(units_str) + if index >= 0: + units_combo.setCurrentIndex(index) + units_combo.currentIndexChanged.connect( + lambda idx: self._on_output_field_changed("units", units_combo.itemData(idx)) + ) + main_layout.addRow("Units", units_combo) + + # Output tool length offset + tool_length_check = QtGui.QCheckBox() + tool_length_check.setChecked(self.machine.output.output_tool_length_offset) + tool_length_check.toggled.connect( + lambda checked: self._on_output_field_changed("output_tool_length_offset", checked) + ) + main_layout.addRow("Output Tool Length Offset (G43)", tool_length_check) + + # Output header + output_header_check = QtGui.QCheckBox() + output_header_check.setChecked(self.machine.output.output_header) + output_header_check.toggled.connect( + lambda checked: self._on_output_field_changed("output_header", checked) + ) + main_layout.addRow("Output Header", output_header_check) + + # Remote posting + remote_post_check = QtGui.QCheckBox() + remote_post_check.setChecked(self.machine.output.remote_post) + remote_post_check.toggled.connect( + lambda checked: self._on_output_field_changed("remote_post", checked) + ) + main_layout.addRow("Enable Remote Posting", remote_post_check) + + layout.addWidget(main_group) + + # Header options + header_group, header_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.output.header, "Header Options" + ) + layout.addWidget(header_group) + self._connect_widgets_to_machine(header_widgets, "output.header") + + # Comment options + comments_group, comments_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.output.comments, "Comment Options" + ) + layout.addWidget(comments_group) + self._connect_widgets_to_machine(comments_widgets, "output.comments") + + # Formatting options + formatting_group, formatting_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.output.formatting, "Formatting Options" + ) + layout.addWidget(formatting_group) + self._connect_widgets_to_machine(formatting_widgets, "output.formatting") + + # Precision options + precision_group, precision_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.output.precision, "Precision Options" + ) + layout.addWidget(precision_group) + self._connect_widgets_to_machine(precision_widgets, "output.precision") + + # Duplicate options + duplicates_group, duplicates_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.output.duplicates, "Duplicate Output Options" + ) + layout.addWidget(duplicates_group) + self._connect_widgets_to_machine(duplicates_widgets, "output.duplicates") + + layout.addStretch() + + def _on_output_field_changed(self, field_name: str, value): + """Handle changes to main output option fields.""" + if self.machine and hasattr(self.machine.output, field_name): + # Special handling for units field - convert string to enum + if field_name == "units": + from Machine.models.machine import OutputUnits + + value = OutputUnits.METRIC if value == "metric" else OutputUnits.IMPERIAL + setattr(self.machine.output, field_name, value) + + def setup_postprocessor_tab(self): + """Set up the postprocessor configuration tab with selector and properties.""" + # Use scroll area for all the options + scroll = QtGui.QScrollArea() + scroll.setWidgetResizable(True) + scroll_widget = QtGui.QWidget() + layout = QtGui.QVBoxLayout(scroll_widget) + scroll.setWidget(scroll_widget) + + main_layout = QtGui.QVBoxLayout(self.postprocessor_tab) + main_layout.addWidget(scroll) + + # === Postprocessor Selection === + selector_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Postprocessor Selection")) + selector_layout = QtGui.QFormLayout(selector_group) + layout.addWidget(selector_group) + + # Postprocessor combo box + self.post_processor_combo = QtGui.QComboBox() + self.post_processor_combo.setEditable(False) + self.postProcessorDefaultTooltip = translate( + "CAM_MachineEditor", "Select the postprocessor file for this machine" + ) + 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 + 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}") + + 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 + + # Ensure generic post processor is always available as fallback + if not found_generic: + 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)" + ) + + # Connect signals + self.post_processor_combo.currentIndexChanged.connect(self.updatePostProcessorTooltip) + self.post_processor_combo.currentIndexChanged.connect(self.updatePostProcessorProperties) + self.post_processor_combo.currentIndexChanged.connect( + lambda: self._update_machine_field( + "postprocessor_file_name", self.post_processor_combo.currentText() + ) + ) + + selector_layout.addRow( + translate("CAM_MachineEditor", "Post Processor:"), self.post_processor_combo + ) + + # === Postprocessor Properties === + self.post_properties_group = QtGui.QGroupBox( + translate("CAM_MachineEditor", "Postprocessor Configuration") + ) + self.post_properties_layout = QtGui.QFormLayout(self.post_properties_group) + self.post_properties_widgets = {} # Store widgets for property access + self.post_properties_group.setVisible(False) # Hidden until postprocessor selected + layout.addWidget(self.post_properties_group) + + layout.addStretch() + + def setup_processing_tab(self): + """Set up the processing options configuration tab.""" + # Use scroll area for all the options + scroll = QtGui.QScrollArea() + scroll.setWidgetResizable(True) + scroll_widget = QtGui.QWidget() + layout = QtGui.QVBoxLayout(scroll_widget) + scroll.setWidget(scroll_widget) + + main_layout = QtGui.QVBoxLayout(self.processing_tab) + main_layout.addWidget(scroll) + + # === Processing Options === + if self.machine: + processing_group, processing_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.processing, "Processing Options" + ) + layout.addWidget(processing_group) + self._connect_widgets_to_machine(processing_widgets, "processing") + + layout.addStretch() + + def _connect_widgets_to_machine(self, widgets: Dict[str, QtGui.QWidget], parent_path: str): + """Connect widgets to update Machine object fields. + + Args: + widgets: Dictionary of field_name -> widget + parent_path: Path to parent object (e.g., 'output', 'precision') + """ + for field_name, widget in widgets.items(): + field_path = f"{parent_path}.{field_name}" + + # Store widget for later population + self.post_widgets[field_path] = widget + + # Connect based on widget type + if isinstance(widget, QtGui.QCheckBox): + # Use a wrapper to avoid lambda capture issues + def make_checkbox_handler(path): + def handler(state): + # Qt.Checked = 2, Qt.Unchecked = 0 + value = state == 2 + self._update_machine_field(path, value) + + return handler + + handler_func = make_checkbox_handler(field_path) + widget.stateChanged.connect(handler_func) + elif isinstance(widget, QtGui.QLineEdit): + + def make_lineedit_handler(path): + def handler(text): + self._update_machine_field(path, text) + + return handler + + widget.textChanged.connect(make_lineedit_handler(field_path)) + elif isinstance(widget, QtGui.QPlainTextEdit): + + def make_plaintext_handler(path, w): + def handler(): + self._update_machine_field(path, w.value_getter()) + + return handler + + widget.textChanged.connect(make_plaintext_handler(field_path, widget)) + elif isinstance(widget, (QtGui.QSpinBox, QtGui.QDoubleSpinBox)): + + def make_spinbox_handler(path): + def handler(value): + self._update_machine_field(path, value) + + return handler + + widget.valueChanged.connect(make_spinbox_handler(field_path)) + elif isinstance(widget, QtGui.QComboBox): + + def make_combo_handler(path, w): + def handler(index): + self._update_machine_field(path, w.value_getter()) + + return handler + + widget.currentIndexChanged.connect(make_combo_handler(field_path, widget)) + + def _update_machine_field(self, field_path, value): + """Update a nested field in the machine object using dot notation.""" + if not self.machine: + return + + try: + parts = field_path.split(".") + obj = self.machine + + # Navigate to the parent object + for part in parts[:-1]: + obj = getattr(obj, part) + + # Set the final field + final_field = parts[-1] + current_value = getattr(obj, final_field, None) + + # Only update if value actually changed + if current_value != value: + setattr(obj, final_field, value) + except Exception as e: + Path.Log.error(f"Error updating {field_path}: {e}") + + def _populate_post_widgets_from_machine(self, machine: Machine): + """Populate dynamically generated post-processor widgets from machine object. + + This updates all the nested dataclass widgets (output, precision, formatting, + blocks, processing) and top-level machine post-processing options. + + Args: + machine: Machine object to read values from + """ + + # Helper to set widget value without triggering signals + def set_widget_value_silent(widget, value): + if isinstance(widget, QtGui.QCheckBox): + widget.blockSignals(True) + widget.setChecked(value) + widget.blockSignals(False) + elif isinstance(widget, QtGui.QLineEdit): + widget.blockSignals(True) + widget.setText(str(value) if value is not None else "") + widget.blockSignals(False) + elif isinstance(widget, QtGui.QPlainTextEdit): + widget.blockSignals(True) + if isinstance(value, list): + widget.setPlainText("\n".join(value)) + else: + widget.setPlainText(str(value) if value is not None else "") + widget.blockSignals(False) + elif isinstance(widget, QtGui.QSpinBox): + widget.blockSignals(True) + widget.setValue(int(value) if value is not None else 0) + widget.blockSignals(False) + elif isinstance(widget, QtGui.QDoubleSpinBox): + widget.blockSignals(True) + widget.setValue(float(value) if value is not None else 0.0) + widget.blockSignals(False) + elif isinstance(widget, QtGui.QComboBox): + widget.blockSignals(True) + if hasattr(value, "value"): # Enum + value = value.value + # Find the item with this value + for i in range(widget.count()): + item_data = widget.itemData(i) + if item_data == value or widget.itemText(i) == str(value): + widget.setCurrentIndex(i) + break + widget.blockSignals(False) + + # Update nested dataclass fields + dataclass_groups = [ + ("output", machine.output), + ("processing", machine.processing), + ] + + for group_name, dataclass_obj in dataclass_groups: + if dataclass_obj is None: + continue + + # Get all fields from the dataclass + from dataclasses import fields as dataclass_fields + + for field in dataclass_fields(dataclass_obj): + widget_key = f"{group_name}.{field.name}" + # Find the widget in post_widgets (it might be stored with or without the parent path) + widget = None + if widget_key in self.post_widgets: + widget = self.post_widgets[widget_key] + elif field.name in self.post_widgets: + widget = self.post_widgets[field.name] + + if widget: + value = getattr(dataclass_obj, field.name) + set_widget_value_silent(widget, value) + + # Update top-level machine post-processing fields + top_level_fields = [ + "use_tlo", + "stop_toolhead_for_tool_change", + "enable_coolant", + "enable_machine_specific_commands", + ] + + for field_name in top_level_fields: + if field_name in self.post_widgets: + widget = self.post_widgets[field_name] + value = getattr(machine, field_name, None) + if value is not None: + set_widget_value_silent(widget, value) + + def getPostProcessor(self, name): + if name not in self.processor: + processor = PostProcessorFactory.get_post_processor(None, name) + self.processor[name] = processor + return processor + return self.processor[name] + + def setPostProcessorTooltip(self, widget, name, default): + processor = self.getPostProcessor(name) + if processor.tooltip: + widget.setToolTip(processor.tooltip) + else: + widget.setToolTip(default) + + def updatePostProcessorTooltip(self): + name = str(self.post_processor_combo.currentText()) + if name: + self.setPostProcessorTooltip( + self.post_processor_combo, name, self.postProcessorDefaultTooltip + ) + else: + self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip) + # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) + + def updatePostProcessorProperties(self): + """Dynamically update postprocessor properties UI based on selected postprocessor's schema.""" + # Clear existing property widgets + for i in reversed(range(self.post_properties_layout.count())): + item = self.post_properties_layout.itemAt(i) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self.post_properties_layout.removeItem(item) + + self.post_properties_widgets.clear() + + # Get selected postprocessor + post_name = str(self.post_processor_combo.currentText()) + if not post_name: + self.post_properties_group.setVisible(False) + return + + # Try to get the postprocessor class and its property schema + try: + processor = self.getPostProcessor(post_name) + if processor is None: + self.post_properties_group.setVisible(False) + return + + # Get property schema from the processor class + processor_class = processor.__class__ + + # Use get_full_property_schema if available (includes common + specific properties) + # Fall back to get_property_schema for backward compatibility + if hasattr(processor_class, "get_full_property_schema"): + schema = processor_class.get_full_property_schema() + elif hasattr(processor_class, "get_property_schema"): + schema = processor_class.get_property_schema() + else: + self.post_properties_group.setVisible(False) + return + + if not schema: + self.post_properties_group.setVisible(False) + return + + # Create widgets for each property in the schema + for prop in schema: + prop_name = prop.get("name") + prop_label = prop.get("label", prop_name) + prop_default = prop.get("default") + prop_help = prop.get("help", "") + prop_type = prop.get("type") + + # Get current value from machine config or use default + current_value = self.machine.postprocessor_properties.get(prop_name, prop_default) + + # Create appropriate widget based on type + widget = PostProcessorPropertyManager.create_property_widget(prop, current_value) + if widget: + widget.setToolTip(prop_help) + self.post_properties_layout.addRow(prop_label, widget) + self.post_properties_widgets[prop_name] = widget + + # Connect widget to update machine properties + PostProcessorPropertyManager.connect_property_widget( + widget, prop_name, prop_type, self.machine + ) + + self.post_properties_group.setVisible(True) + + except Exception as e: + Path.Log.warning(f"Failed to load postprocessor properties for {post_name}: {e}") + self.post_properties_group.setVisible(False) + + def populate_kinematics_fields(self): + """Populate kinematics fields from machine object.""" + if not self.machine: + return + + # Base frame origin + self.origin_x_spin.setValue(self.machine.kinematics.base_frame.origin[0]) + self.origin_y_spin.setValue(self.machine.kinematics.base_frame.origin[1]) + self.origin_z_spin.setValue(self.machine.kinematics.base_frame.origin[2]) + + # Base frame quaternion + self.quat_w_spin.setValue(self.machine.kinematics.base_frame.orientation_quaternion[0]) + self.quat_x_spin.setValue(self.machine.kinematics.base_frame.orientation_quaternion[1]) + self.quat_y_spin.setValue(self.machine.kinematics.base_frame.orientation_quaternion[2]) + self.quat_z_spin.setValue(self.machine.kinematics.base_frame.orientation_quaternion[3]) + + # TCP/DWO support + self.tcp_supported_check.setChecked(self.machine.kinematics.tcp_supported) + self.dwo_supported_check.setChecked(self.machine.kinematics.dwo_supported) + + # Notes + self.kinematics_notes_edit.setText(self.machine.kinematics.notes) + + def populate_from_machine(self, machine: Machine): + """Populate UI fields from Machine object. + + Args: + machine: Machine object containing configuration + """ + self.name_edit.setText(machine.name) + self.manufacturer_edit.setText(machine.manufacturer) + self.description_edit.setText(machine.description) + units = machine.configuration_units + + # Populate kinematics fields + self.populate_kinematics_fields() + self.units_combo.blockSignals(True) + index = self.units_combo.findData(units) + if index >= 0: + self.units_combo.setCurrentIndex(index) + self.units_combo.blockSignals(False) + self.current_units = units + machine_type = machine.machine_type + self.type_combo.blockSignals(True) + index = self.type_combo.findData(machine_type) + if index >= 0: + self.type_combo.setCurrentIndex(index) + self.type_combo.blockSignals(False) + + # Post processor selection (in Postprocessor tab) + if self.enable_machine_postprocessor: + post_processor = machine.postprocessor_file_name + self.post_processor_combo.blockSignals(True) + index = self.post_processor_combo.findText(post_processor, QtCore.Qt.MatchFixedString) + if index >= 0: + self.post_processor_combo.setCurrentIndex(index) + else: + self.post_processor_combo.setCurrentIndex(0) + self.post_processor_combo.blockSignals(False) + + # Trigger property loading after setting postprocessor + self.updatePostProcessorTooltip() + self.updatePostProcessorProperties() + + # Get units for suffixes in populate + self.update_toolheads() # Update toolheads UI + self._update_toolhead_button_state() + + # Update axes to show joint details from loaded machine + self.update_axes() + + # Post processor configuration - populate dynamically generated widgets + if self.enable_machine_postprocessor and hasattr(self, "post_widgets"): + # Update all post-processor widgets from machine object + self._populate_post_widgets_from_machine(machine) + + def to_machine(self) -> Machine: + """Convert UI state to Machine object. + + Returns: + Machine object with configuration from UI + """ + # The machine object is already up-to-date from signal handlers + # (both axes and toolheads update in real-time) + return self.machine + + def to_data(self) -> Dict[str, Any]: + """Convert UI state to machine data dictionary. + + Returns: + Dict containing complete machine configuration in JSON format + """ + # Update machine from UI first (toolheads need to be synchronized) + machine = self.to_machine() + + # Use Machine's to_dict method for serialization + return machine.to_dict() + + def toggle_editor_mode(self): + """Toggle between form view and text editor view.""" + if self.text_mode: + # Switching from text to form mode + try: + # Parse JSON from text editor + json_text = self.text_editor.toPlainText() + data = json.loads(json_text) + # Create Machine object from JSON and populate form + self.machine = Machine.from_dict(data) + self.populate_from_machine(self.machine) + # Show form, hide editor + self.tabs.show() + self.text_editor.hide() + self.toggle_button.setText(translate("CAM_MachineEditor", "Edit as Text")) + self.text_mode = False + except json.JSONDecodeError as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "JSON Error"), + translate("CAM_MachineEditor", "Invalid JSON: {}").format(str(e)), + ) + except Exception as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "Error"), + translate("CAM_MachineEditor", "Failed to parse data: {}").format(str(e)), + ) + else: + # Switching from form to text mode + try: + # self.machine is already up-to-date from signal handlers + # Just serialize it to JSON + data = self.machine.to_dict() + json_text = json.dumps(data, indent=4, sort_keys=True) + self.text_editor.setPlainText(json_text) + # Hide form, show editor + self.tabs.hide() + self.text_editor.show() + self.toggle_button.setText(translate("CAM_MachineEditor", "Edit as Form")) + self.text_mode = True + except Exception as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "Error"), + translate("CAM_MachineEditor", "Failed to generate JSON: {}").format(str(e)), + ) + + def accept(self): + """Handle save and close action.""" + # Check for duplicate machine names when creating new machines + if self.text_mode: + try: + json_text = self.text_editor.toPlainText() + data = json.loads(json_text) + machine_name = data.get("machine", {}).get("name", "") + except json.JSONDecodeError: + machine_name = "" + else: + machine_name = self.name_edit.text().strip() + + # Check for duplicate machine names + if machine_name: + existing_machines = MachineFactory.list_configurations() + # Case-insensitive check to match get_machine behavior + machine_name_lower = machine_name.lower() + existing_names_lower = [name.lower() for name in existing_machines] + + # For existing machines, allow keeping the same name (case-insensitive) + current_name_allowed = False + if self.filename: + try: + current_machine = MachineFactory.load_configuration(self.filename) + current_name = current_machine.name.lower() + if machine_name_lower == current_name: + current_name_allowed = True + except Exception: + # Failed to load current machine configuration, assume name is not allowed + pass + + if machine_name_lower in existing_names_lower and not current_name_allowed: + QtGui.QMessageBox.warning( + self, + translate("CAM_MachineEditor", "Duplicate Machine Name"), + translate( + "CAM_MachineEditor", + "A machine with the name '{}' already exists. Please choose a different name.", + ).format(machine_name), + ) + return + + try: + if self.text_mode: + # If in text mode, parse JSON and update Machine + json_text = self.text_editor.toPlainText() + data = json.loads(json_text) + self.machine = Machine.from_dict(data) + + # self.machine is already up-to-date from signal handlers, just save it + if self.filename: + saved_path = MachineFactory.save_configuration(self.machine, self.filename) + else: + saved_path = MachineFactory.save_configuration(self.machine) + self.filename = saved_path.name + self.path = str(saved_path) # Keep for compatibility + + except json.JSONDecodeError as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "JSON Error"), + translate("CAM_MachineEditor", "Invalid JSON: {}").format(str(e)), + ) + return + except Exception as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "Error"), + translate("CAM_MachineEditor", "Failed to save: {}").format(str(e)), + ) + return + super().accept() diff --git a/src/Mod/CAM/Machine/ui/editor/postprocessor_properties.py b/src/Mod/CAM/Machine/ui/editor/postprocessor_properties.py new file mode 100644 index 0000000000..ee4ee1647d --- /dev/null +++ b/src/Mod/CAM/Machine/ui/editor/postprocessor_properties.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Billy Huddleston +# 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 # +# # +################################################################################ + +""" +Helper module for managing postprocessor properties in the machine editor. +""" +from PySide import QtGui +from typing import Dict, Any + + +class PostProcessorPropertyManager: + """Manages dynamic creation and handling of postprocessor property widgets.""" + + @staticmethod + def create_property_widget(prop: Dict[str, Any], current_value: Any) -> QtGui.QWidget: + """Create a widget for a postprocessor property based on its type. + + Args: + prop: Property schema dictionary with keys: name, type, label, default, help, etc. + current_value: Current value of the property + + Returns: + QWidget configured for the property type, with a value_getter() method + """ + prop_type = prop.get("type") + + if prop_type == "bool": + widget = QtGui.QCheckBox() + widget.setChecked(current_value if current_value is not None else False) + widget.value_getter = lambda: widget.isChecked() + return widget + + elif prop_type == "int": + widget = QtGui.QSpinBox() + widget.setRange(prop.get("min", -999999), prop.get("max", 999999)) + widget.setValue(current_value if current_value is not None else 0) + widget.value_getter = lambda: widget.value() + return widget + + elif prop_type == "float": + widget = QtGui.QDoubleSpinBox() + widget.setRange(prop.get("min", -999999.0), prop.get("max", 999999.0)) + widget.setDecimals(prop.get("decimals", 4)) + widget.setValue(current_value if current_value is not None else 0.0) + widget.value_getter = lambda: widget.value() + return widget + + elif prop_type == "choice": + widget = QtGui.QComboBox() + choices = prop.get("choices", []) + for choice in choices: + widget.addItem(str(choice), choice) + if current_value: + index = widget.findData(current_value) + if index >= 0: + widget.setCurrentIndex(index) + widget.value_getter = lambda: widget.itemData(widget.currentIndex()) + return widget + + elif prop_type == "text": + widget = QtGui.QPlainTextEdit() + widget.setMaximumHeight(100) + widget.setPlainText(current_value if current_value else "") + widget.value_getter = lambda: widget.toPlainText() + return widget + + elif prop_type == "file": + # File picker widget + container = QtGui.QWidget() + layout = QtGui.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + + line_edit = QtGui.QLineEdit() + line_edit.setText(current_value if current_value else "") + + browse_button = QtGui.QPushButton("...") + browse_button.setMaximumWidth(30) + + def browse_file(): + filters = prop.get("filters", "All Files (*)") + filename, _ = QtGui.QFileDialog.getOpenFileName( + container, "Select File", "", filters + ) + if filename: + line_edit.setText(filename) + + browse_button.clicked.connect(browse_file) + + layout.addWidget(line_edit) + layout.addWidget(browse_button) + + container.value_getter = lambda: line_edit.text() + return container + + else: # Default to string + widget = QtGui.QLineEdit() + widget.setText(str(current_value) if current_value else "") + widget.value_getter = lambda: widget.text() + return widget + + @staticmethod + def connect_property_widget( + widget: QtGui.QWidget, prop_name: str, prop_type: str, machine, callback=None + ): + """Connect a property widget to update the machine configuration. + + Args: + widget: The widget to connect + prop_name: Name of the property + prop_type: Type of the property + machine: Machine object to update + callback: Optional callback function to call after update + """ + + def update_property(): + if hasattr(widget, "value_getter"): + value = widget.value_getter() + machine.postprocessor_properties[prop_name] = value + if callback: + callback(prop_name, value) + + # Connect appropriate signal based on widget type + if isinstance(widget, QtGui.QCheckBox): + widget.stateChanged.connect(update_property) + elif isinstance(widget, QtGui.QSpinBox) or isinstance(widget, QtGui.QDoubleSpinBox): + widget.valueChanged.connect(update_property) + elif isinstance(widget, QtGui.QComboBox): + widget.currentIndexChanged.connect(update_property) + elif isinstance(widget, QtGui.QPlainTextEdit): + widget.textChanged.connect(update_property) + elif isinstance(widget, QtGui.QLineEdit): + widget.textChanged.connect(update_property) + elif isinstance(widget, QtGui.QWidget): + # For composite widgets (like file picker), find the line edit + line_edit = widget.findChild(QtGui.QLineEdit) + if line_edit: + line_edit.textChanged.connect(update_property) diff --git a/src/Mod/CAM/Path/Machine/models/__init__.py b/src/Mod/CAM/Path/Machine/models/__init__.py deleted file mode 100644 index 979362e01c..0000000000 --- a/src/Mod/CAM/Path/Machine/models/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# *************************************************************************** -# * * -# * Copyright (c) 2025 Billy Huddleston * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** - -from .machine import ( - Machine, - MachineFactory, - LinearAxis, - RotaryAxis, - Spindle, - MachineUnits, - OutputUnits, - OutputOptions, - GCodeBlocks, - ProcessingOptions, -) - -__all__ = [ - "Machine", - "MachineFactory", - "LinearAxis", - "RotaryAxis", - "Spindle", - "MachineUnits", - "OutputUnits", - "OutputOptions", - "GCodeBlocks", - "ProcessingOptions", -] diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py deleted file mode 100644 index 616725cfb4..0000000000 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ /dev/null @@ -1,1398 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# *************************************************************************** -# * * -# * Copyright (c) 2025 Billy Huddleston * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** -import json -import Path -import FreeCAD -import pathlib -from dataclasses import dataclass, field -from typing import Dict, Any, List, Optional, Tuple, Callable -from collections import namedtuple -from enum import Enum - - -if False: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - - -# Reference axis vectors -RefAxes = namedtuple("RefAxes", ["x", "y", "z"]) -refAxis = RefAxes( - FreeCAD.Vector(1, 0, 0), # x: linear direction - FreeCAD.Vector(0, 1, 0), # y: linear direction - FreeCAD.Vector(0, 0, 1), # z: linear direction -) - -RefRotAxes = namedtuple("RefRotAxes", ["a", "b", "c"]) -refRotAxis = RefRotAxes( - FreeCAD.Vector(1, 0, 0), # a: rotational direction - FreeCAD.Vector(0, 1, 0), # b: rotational direction - FreeCAD.Vector(0, 0, 1), # c: rotational direction -) - - -# ============================================================================ -# Enums for Machine Configuration -# ============================================================================ - - -class MachineUnits(Enum): - """Machine unit system.""" - - METRIC = "G21" - IMPERIAL = "G20" - - -class OutputUnits(Enum): - """Output unit system for G-code generation.""" - - METRIC = "metric" - IMPERIAL = "imperial" - - -# ============================================================================ -# Post-Processor Configuration Dataclasses -# ============================================================================ - - -@dataclass -class OutputOptions: - """Controls what gets included in the G-code output and its formatting.""" - - # These options control conversion of Path Objects to actual gcode. - - output_units: OutputUnits = OutputUnits.METRIC # G-code output units - - # Line formatting options - command_space: str = " " - comment_symbol: str = "(" - end_of_line_chars: str = "\n" - line_increment: int = 10 - line_number_start: int = 100 - line_numbers: bool = False - line_number_prefix: str = "N" - - # Output content options - output_comments: bool = True # Renamed from 'comments' - output_blank_lines: bool = True # Renamed from 'blank_lines' - output_bcnc_comments: bool = True - output_header: bool = True # Renamed from 'header' - output_labels: bool = False # Renamed from 'path_labels' - output_operation_labels: bool = True # Renamed from 'show_operation_labels' - - # Header content options - list_tools_in_header: bool = False # Renamed from 'list_tools_in_preamble' - list_fixtures_in_header: bool = True - machine_name_in_header: bool = False # Renamed from 'machine_name' - description_in_header: bool = True - date_in_header: bool = True - document_name_in_header: bool = True - - # Filter options - filter_double_parameters: bool = False # Renamed from 'output_double_parameters' - filter_double_commands: bool = False # Renamed from 'modal' (moved from ProcessingOptions) - - # Numeric precision settings - axis_precision: int = 3 # Decimal places for axis coordinates - feed_precision: int = 3 # Decimal places for feed rates - spindle_precision: int = 0 # Renamed from 'spindle_decimals' - - -@dataclass -class GCodeBlocks: - """ - G-code block templates for various lifecycle hooks. - - These templates are inserted at specific points during postprocessing - to provide customization points for machine-specific behavior. - """ - - safetyblock: str = "" # Safety commands (G40, G49, etc.) Reset machine to known safe condition - - # Legacy aliases (maintained for compatibility) - preamble: str = "" # Typically inserted at start of job - - # Job lifecycle - pre_job: str = "" - - # Operation lifecycle - pre_operation: str = "" - post_operation: str = "" - - # Tool change lifecycle - pre_tool_change: str = "" - post_tool_change: str = "" - tool_return: str = "" # Return to tool change position - - # Fixture/WCS change lifecycle - pre_fixture_change: str = "" - post_fixture_change: str = "" - - # Rotary axis lifecycle - pre_rotary_move: str = "" - post_rotary_move: str = "" - - post_job: str = "" - postamble: str = "" # Typically inserted at end of job - - -@dataclass -class ProcessingOptions: - """Processing and transformation options.""" - - # Conversion and expansion of Path Objects. Does not affect final gcode generation - - drill_cycles_to_translate: List[str] = field( - default_factory=lambda: ["G73", "G81", "G82", "G83"] - ) - - show_machine_units: bool = True - spindle_wait: float = 0.0 # seconds - split_arcs: bool = False - suppress_commands: List[str] = field(default_factory=list) - tool_before_change: bool = False # Output T before M6 (e.g., T1 M6 instead of M6 T1) - tool_change: bool = True # Enable tool change commands - translate_drill_cycles: bool = False - - return_to: Optional[Tuple[float, float, float]] = None # (x, y, z) or None - - -# ============================================================================ -# Machine Component Dataclasses -# ============================================================================ - - -@dataclass -class LinearAxis: - """Represents a single linear axis in a machine configuration""" - - name: str - direction_vector: FreeCAD.Vector - min_limit: float = 0 - max_limit: float = 1000 - max_velocity: float = 10000 - sequence: int = 0 - - def __post_init__(self): - """Normalize direction vector and validate parameters after initialization""" - self.direction_vector = self.direction_vector.normalize() - - # Validate limits - if self.min_limit >= self.max_limit: - Path.Log.warning( - f"LinearAxis {self.name}: min_limit ({self.min_limit}) >= max_limit ({self.max_limit})" - ) - - # Validate velocity - if self.max_velocity <= 0: - Path.Log.warning( - f"LinearAxis {self.name}: max_velocity must be positive, got {self.max_velocity}" - ) - - def is_valid_position(self, position): - """Check if a position is within this axis's limits""" - return self.min_limit <= position <= self.max_limit - - def to_dict(self): - """Serialize to dictionary for JSON persistence""" - return { - "name": self.name, - "direction_vector": [ - self.direction_vector.x, - self.direction_vector.y, - self.direction_vector.z, - ], - "min_limit": self.min_limit, - "max_limit": self.max_limit, - "max_velocity": self.max_velocity, - "sequence": self.sequence, - } - - @classmethod - def from_dict(cls, data): - """Deserialize from dictionary""" - vec = FreeCAD.Vector( - data["direction_vector"][0], data["direction_vector"][1], data["direction_vector"][2] - ) - return cls( - data["name"], - vec, - data.get("min_limit", 0), - data.get("max_limit", 1000), - data.get("max_velocity", 10000), - data.get("sequence", 0), - ) - - -@dataclass -class RotaryAxis: - """Represents a single rotary axis in a machine configuration""" - - name: str - rotation_vector: FreeCAD.Vector - min_limit: float = -360 - max_limit: float = 360 - max_velocity: float = 36000 - sequence: int = 0 - prefer_positive: bool = True - - def __post_init__(self): - """Normalize rotation vector and validate parameters after initialization""" - if self.rotation_vector is None or self.rotation_vector.Length < 1e-6: - # Default to Z-axis rotation if vector is null or zero-length - self.rotation_vector = FreeCAD.Vector(0, 0, 1) - else: - self.rotation_vector = self.rotation_vector.normalize() - - # Validate limits - if self.min_limit >= self.max_limit: - Path.Log.warning( - f"RotaryAxis {self.name}: min_limit ({self.min_limit}) >= max_limit ({self.max_limit})" - ) - - # Validate velocity - if self.max_velocity <= 0: - Path.Log.warning( - f"RotaryAxis {self.name}: max_velocity must be positive, got {self.max_velocity}" - ) - - def is_valid_angle(self, angle): - """Check if an angle is within this axis's limits""" - return self.min_limit <= angle <= self.max_limit - - def to_dict(self): - """Serialize to dictionary for JSON persistence""" - return { - "name": self.name, - "rotation_vector": [ - self.rotation_vector.x, - self.rotation_vector.y, - self.rotation_vector.z, - ], - "min_limit": self.min_limit, - "max_limit": self.max_limit, - "max_velocity": self.max_velocity, - "sequence": self.sequence, - "prefer_positive": self.prefer_positive, - } - - @classmethod - def from_dict(cls, data): - """Deserialize from dictionary""" - vec = FreeCAD.Vector( - data["rotation_vector"][0], data["rotation_vector"][1], data["rotation_vector"][2] - ) - return cls( - data["name"], - vec, - data["min_limit"], - data["max_limit"], - data.get("max_velocity", 36000), - data.get("sequence", 0), - data.get("prefer_positive", True), - ) - - -@dataclass -class Spindle: - """Represents a single spindle in a machine configuration""" - - name: str - id: Optional[str] = None - max_power_kw: float = 0 - max_rpm: float = 0 - min_rpm: float = 0 - tool_change: str = "manual" - tool_axis: Optional[FreeCAD.Vector] = None - - def __post_init__(self): - """Set default tool axis if not provided""" - if self.tool_axis is None: - self.tool_axis = FreeCAD.Vector(0, 0, -1) - - def to_dict(self): - """Serialize to dictionary for JSON persistence""" - data = { - "name": self.name, - "max_power_kw": self.max_power_kw, - "max_rpm": self.max_rpm, - "min_rpm": self.min_rpm, - "tool_change": self.tool_change, - "tool_axis": [self.tool_axis.x, self.tool_axis.y, self.tool_axis.z], - } - if self.id is not None: - data["id"] = self.id - return data - - @classmethod - def from_dict(cls, data): - """Deserialize from dictionary""" - tool_axis_data = data.get("tool_axis", [0, 0, -1]) - tool_axis = FreeCAD.Vector(tool_axis_data[0], tool_axis_data[1], tool_axis_data[2]) - return cls( - data["name"], - data.get("id"), - data.get("max_power_kw", 0), - data.get("max_rpm", 0), - data.get("min_rpm", 0), - data.get("tool_change", "manual"), - tool_axis, - ) - - -@dataclass -class Machine: - """Represents a CNC machine configuration with axes, spindles, and output settings. - - This class encapsulates all machine parameters including linear and rotary axes, - spindles, post-processor settings, and G-code generation options. It provides - methods for serialization to/from JSON and various factory methods for common - machine configurations (3-axis, 4-axis, 5-axis). - """ - - """ - Unified machine configuration combining physical machine definition - with post-processor settings. - - This is the single source of truth for all machine-related configuration, - including physical capabilities (axes, spindles) and G-code generation - preferences (output options, formatting, processing). - """ - - # ======================================================================== - # PHYSICAL MACHINE DEFINITION - # ======================================================================== - - # Basic identification - name: str = "Default Machine" - manufacturer: str = "" - description: str = "" - - # Machine components - linear_axes: Dict[str, LinearAxis] = field(default_factory=dict) - rotary_axes: Dict[str, RotaryAxis] = field(default_factory=dict) - spindles: List[Spindle] = field(default_factory=list) - - # Coordinate system - reference_system: Dict[str, FreeCAD.Vector] = field( - default_factory=lambda: { - "X": FreeCAD.Vector(1, 0, 0), - "Y": FreeCAD.Vector(0, 1, 0), - "Z": FreeCAD.Vector(0, 0, 1), - } - ) - tool_axis: FreeCAD.Vector = field(default_factory=lambda: FreeCAD.Vector(0, 0, -1)) - - # Rotary axis configuration - primary_rotary_axis: Optional[str] = None - secondary_rotary_axis: Optional[str] = None - compound_moves: bool = True - prefer_positive_rotation: bool = True - - # Units and versioning - configuration_units: str = "metric" # Internal storage for configuration_units - version: int = 1 - freecad_version: str = field(init=False) - - # ======================================================================== - # POST-PROCESSOR CONFIGURATION - # ======================================================================== - - # Output options - output: OutputOptions = field(default_factory=OutputOptions) - blocks: GCodeBlocks = field(default_factory=GCodeBlocks) - processing: ProcessingOptions = field(default_factory=ProcessingOptions) - - # Post-processor selection - postprocessor_file_name: str = "" - postprocessor_args: str = "" - - # Dynamic state (for runtime) - parameter_functions: Dict[str, Callable] = field(default_factory=dict) - - def __post_init__(self): - """Initialize computed fields and handle backward compatibility""" - # Initialize computed fields - self.freecad_version = ".".join(FreeCAD.Version()[0:3]) - - # Validate configuration_units - if self.configuration_units not in ["metric", "imperial"]: - raise ValueError( - f"configuration_units must be 'metric' or 'imperial', got '{self.configuration_units}'" - ) - - # Backward compatibility for renamed fields - # Support old field names by creating aliases - if not hasattr(self.output, "filter_double_parameters"): - self.output.filter_double_parameters = getattr( - self.output, "output_double_parameters", False - ) - if not hasattr(self.output, "filter_double_commands"): - self.output.filter_double_commands = getattr(self.processing, "modal", False) - - # ======================================================================== - # PROPERTIES - Bridge between physical machine and post-processor - # ======================================================================== - - @property - def machine_units(self) -> MachineUnits: - """Get machine configuration units as enum""" - return ( - MachineUnits.METRIC if self.configuration_units == "metric" else MachineUnits.IMPERIAL - ) - - @property - def output_machine_units(self) -> MachineUnits: - """Get output units as enum for G-code generation""" - return ( - MachineUnits.METRIC - if self.output.output_units == OutputUnits.METRIC - else MachineUnits.IMPERIAL - ) - - @property - def gcode_units(self) -> MachineUnits: - """Get G-code output units as enum for post-processor""" - return ( - MachineUnits.METRIC - if self.output.output_units == OutputUnits.METRIC - else MachineUnits.IMPERIAL - ) - - @property - def unit_format(self) -> str: - """Get machine configuration unit format string (mm or in)""" - return "mm" if self.configuration_units == "metric" else "in" - - @property - def output_unit_format(self) -> str: - """Get G-code output unit format string (mm or in)""" - return "mm" if self.output.output_units == OutputUnits.METRIC else "in" - - @property - def unit_speed_format(self) -> str: - """Get machine configuration unit speed format string (mm/min or in/min)""" - return "mm/min" if self.configuration_units == "metric" else "in/min" - - @property - def output_unit_speed_format(self) -> str: - """Get G-code output unit speed format string (mm/min or in/min)""" - return "mm/min" if self.output.output_units == OutputUnits.METRIC else "in/min" - - @property - def machine_type(self) -> str: - """ - Determine machine type based on available axes. - Returns one of: 'xyz', 'xyza', 'xyzb', 'xyzac', 'xyzbc', or 'custom' - """ - if not all(axis in self.linear_axes for axis in ["X", "Y", "Z"]): - return "custom" - - rot_axes = set(self.rotary_axes.keys()) - - # Check for 5-axis configurations - if {"A", "C"}.issubset(rot_axes): - return "xyzac" - if {"B", "C"}.issubset(rot_axes): - return "xyzbc" - - # Check for 4-axis configurations - if "A" in rot_axes: - return "xyza" - if "B" in rot_axes: - return "xyzb" - - # 3-axis configuration - return "xyz" - - @property - def has_rotary_axes(self) -> bool: - """Check if machine has any rotary axes""" - return len(self.rotary_axes) > 0 - - @property - def is_5axis(self) -> bool: - """Check if machine is 5-axis (2 rotary axes)""" - return len(self.rotary_axes) >= 2 - - @property - def is_4axis(self) -> bool: - """Check if machine is 4-axis (1 rotary axis)""" - return len(self.rotary_axes) == 1 - - @property - def motion_commands(self) -> List[str]: - """Get list of motion commands that change position""" - import Path.Geom as PathGeom - - return PathGeom.CmdMoveAll - - @property - def rapid_moves(self) -> List[str]: - """Get list of rapid move commands""" - import Path.Geom as PathGeom - - return PathGeom.CmdMoveRapid - - # ======================================================================== - # BUILDER METHODS - Fluent interface for machine construction - # ======================================================================== - - def add_linear_axis( - self, name, direction_vector, min_limit=0, max_limit=1000, max_velocity=10000 - ): - """Add a linear axis to the configuration""" - self.linear_axes[name] = LinearAxis( - name, direction_vector, min_limit, max_limit, max_velocity - ) - return self - - def add_rotary_axis( - self, name, rotation_vector, min_limit=-360, max_limit=360, max_velocity=36000 - ): - """Add a rotary axis to the configuration""" - self.rotary_axes[name] = RotaryAxis( - name, rotation_vector, min_limit, max_limit, max_velocity - ) - return self - - def add_spindle( - self, - name, - id=None, - max_power_kw=0, - max_rpm=0, - min_rpm=0, - tool_change="manual", - tool_axis=None, - ): - """Add a spindle to the configuration""" - if tool_axis is None: - tool_axis = FreeCAD.Vector(0, 0, -1) - self.spindles.append( - Spindle(name, id, max_power_kw, max_rpm, min_rpm, tool_change, tool_axis) - ) - return self - - def save(self, filepath): - """Save this configuration to a file - - Args: - filepath: Path to save the configuration file - - Returns: - Path object of saved file - """ - filepath = pathlib.Path(filepath) - data = self.to_dict() - - try: - with open(filepath, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - Path.Log.debug(f"Saved machine configuration to {filepath}") - return filepath - except Exception as e: - Path.Log.error(f"Failed to save configuration: {e}") - raise Exception(f"Failed to save machine file {filepath}: {e}") - - def set_alignment_axes(self, primary, secondary=None): - """Set the primary and secondary rotary axes for alignment strategy - - For 4-axis machines, secondary can be None (single rotary axis) - For 5-axis machines, both primary and secondary are required - """ - if primary not in self.rotary_axes: - raise ValueError(f"Primary axis {primary} not found in configuration") - - if secondary is not None and secondary not in self.rotary_axes: - raise ValueError(f"Secondary axis {secondary} not found in configuration") - - self.primary_rotary_axis = primary - self.secondary_rotary_axis = secondary - return self - - def get_axis_by_name(self, name): - """Get a rotary axis by name""" - return self.rotary_axes.get(name) - - def get_spindle_by_index(self, index): - """Get a spindle by its index in the list""" - if 0 <= index < len(self.spindles): - return self.spindles[index] - raise ValueError(f"Spindle index {index} out of range") - - def get_spindle_by_name(self, name): - """Get a spindle by name (case-insensitive)""" - name_lower = name.lower() - for spindle in self.spindles: - if spindle.name.lower() == name_lower: - return spindle - raise ValueError(f"Spindle with name '{name}' not found") - - def get_spindle_by_id(self, id): - """Get a spindle by ID (if present)""" - if id is None: - raise ValueError("ID cannot be None") - for spindle in self.spindles: - if spindle.id == id: - return spindle - raise ValueError(f"Spindle with ID '{id}' not found") - - @classmethod - def create_AC_table_config(cls, a_limits=(-120, 120), c_limits=(-360, 360)): - """Create standard A/C table configuration""" - config = cls("AC Table Configuration") - config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) - config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) - config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) - config.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), c_limits[0], c_limits[1]) - config.set_alignment_axes("C", "A") - return config - - @classmethod - def create_BC_head_config(cls, b_limits=(-120, 120), c_limits=(-360, 360)): - """Create standard B/C head configuration""" - config = cls("BC Head Configuration") - config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) - config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) - config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) - config.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), c_limits[0], c_limits[1]) - config.set_alignment_axes("C", "B") - config.compound_moves = True # Ensure compound moves are enabled for test compatibility - return config - - @classmethod - def create_AB_table_config(cls, a_limits=(-120, 120), b_limits=(-120, 120)): - """Create standard A/B table configuration""" - config = cls("AB Table Configuration") - # AB configuration will be detected as 'custom' by the machine_type property - config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) - config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) - config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) - config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) - config.set_alignment_axes("A", "B") - return config - - @classmethod - def create_4axis_A_config(cls, a_limits=(-120, 120)): - """Create standard 4-axis XYZA configuration (rotary table around X)""" - config = cls("4-Axis XYZA Configuration") - config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) - config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) - config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) - config.set_alignment_axes("A", None) - config.description = "4-axis machine with A-axis rotary table (rotation around X-axis)" - return config - - @classmethod - def create_4axis_B_config(cls, b_limits=(-120, 120)): - """Create standard 4-axis XYZB configuration (rotary table around Y)""" - config = cls("4-Axis XYZB Configuration") - config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) - config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) - config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) - config.set_alignment_axes("B", None) - config.description = "4-axis machine with B-axis rotary table (rotation around Y-axis)" - return config - - @classmethod - def create_3axis_config(cls): - """Create standard 3-axis XYZ configuration (no rotary axes)""" - config = cls("3-Axis XYZ Configuration") - config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) - config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) - config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) - config.description = "Standard 3-axis machine with no rotary axes" - # No rotary axes to add, no alignment axes to set - return config - - def to_dict(self): - """Serialize configuration to dictionary for JSON persistence""" - # Build flattened axes structure - axes = {} - - # Add linear axes from LinearAxis objects - for axis_name, axis_obj in self.linear_axes.items(): - dir_vec = axis_obj.direction_vector - joint = [[dir_vec.x, dir_vec.y, dir_vec.z], [0, 0, 0]] - - axes[axis_name] = { - "type": "linear", - "min": axis_obj.min_limit, - "max": axis_obj.max_limit, - "max_velocity": axis_obj.max_velocity, - "joint": joint, - "sequence": axis_obj.sequence, - } - - # Add rotary axes - for axis_name, axis_obj in self.rotary_axes.items(): - rot_vec = axis_obj.rotation_vector - joint = [[0, 0, 0], [rot_vec.x, rot_vec.y, rot_vec.z]] - axes[axis_name] = { - "type": "angular", - "min": axis_obj.min_limit, - "max": axis_obj.max_limit, - "max_velocity": axis_obj.max_velocity, - "joint": joint, - "sequence": axis_obj.sequence, - "prefer_positive": axis_obj.prefer_positive, - } - - data = { - "freecad_version": self.freecad_version, - "machine": { - "name": self.name, - "manufacturer": self.manufacturer, - "description": self.description, - "units": self.configuration_units, - "axes": axes, - "spindles": [spindle.to_dict() for spindle in self.spindles], - }, - "version": self.version, - } - - # Add post-processor configuration - data["postprocessor"] = { - "file_name": self.postprocessor_file_name, - "args": self.postprocessor_args, - } - - # Output options - data["output"] = { - "command_space": self.output.command_space, - "comment_symbol": self.output.comment_symbol, - "output_comments": self.output.output_comments, - "end_of_line_chars": self.output.end_of_line_chars, - "line_increment": self.output.line_increment, - "line_number_start": self.output.line_number_start, - "line_numbers": self.output.line_numbers, - "line_number_prefix": self.output.line_number_prefix, - "list_tools_in_header": self.output.list_tools_in_header, - "list_fixtures_in_header": self.output.list_fixtures_in_header, - "machine_name_in_header": self.output.machine_name_in_header, - "description_in_header": self.output.description_in_header, - "date_in_header": self.output.date_in_header, - "document_name_in_header": self.output.document_name_in_header, - "filter_double_parameters": self.output.filter_double_parameters, - "filter_double_commands": self.output.filter_double_commands, - "output_blank_lines": self.output.output_blank_lines, - "output_bcnc_comments": self.output.output_bcnc_comments, - "output_header": self.output.output_header, - "output_labels": self.output.output_labels, - "output_operation_labels": self.output.output_operation_labels, - "output_units": self.output.output_units.value, - "axis_precision": self.output.axis_precision, - "feed_precision": self.output.feed_precision, - "spindle_precision": self.output.spindle_precision, - } - - # G-code blocks (only non-empty ones) - blocks = {} - if self.blocks.pre_job: - blocks["pre_job"] = self.blocks.pre_job - if self.blocks.post_job: - blocks["post_job"] = self.blocks.post_job - if self.blocks.preamble: - blocks["preamble"] = self.blocks.preamble - if self.blocks.postamble: - blocks["postamble"] = self.blocks.postamble - if self.blocks.safetyblock: - blocks["safetyblock"] = self.blocks.safetyblock - if self.blocks.pre_operation: - blocks["pre_operation"] = self.blocks.pre_operation - if self.blocks.post_operation: - blocks["post_operation"] = self.blocks.post_operation - if self.blocks.pre_tool_change: - blocks["pre_tool_change"] = self.blocks.pre_tool_change - if self.blocks.post_tool_change: - blocks["post_tool_change"] = self.blocks.post_tool_change - if self.blocks.tool_return: - blocks["tool_return"] = self.blocks.tool_return - if self.blocks.pre_fixture_change: - blocks["pre_fixture_change"] = self.blocks.pre_fixture_change - if self.blocks.post_fixture_change: - blocks["post_fixture_change"] = self.blocks.post_fixture_change - if self.blocks.pre_rotary_move: - blocks["pre_rotary_move"] = self.blocks.pre_rotary_move - if self.blocks.post_rotary_move: - blocks["post_rotary_move"] = self.blocks.post_rotary_move - - if blocks: - data["blocks"] = blocks - - # Processing options - data["processing"] = { - "drill_cycles_to_translate": self.processing.drill_cycles_to_translate, - "show_machine_units": self.processing.show_machine_units, - "spindle_wait": self.processing.spindle_wait, - "split_arcs": self.processing.split_arcs, - "suppress_commands": self.processing.suppress_commands, - "tool_before_change": self.processing.tool_before_change, - "tool_change": self.processing.tool_change, - "translate_drill_cycles": self.processing.translate_drill_cycles, - } - if self.processing.return_to: - data["processing"]["return_to"] = list(self.processing.return_to) - - return data - - def _initialize_3axis_config(self) -> None: - """Initialize as a standard 3-axis XYZ configuration (no rotary axes)""" - self.linear_axes = { - "X": LinearAxis("X", FreeCAD.Vector(1, 0, 0)), - "Y": LinearAxis("Y", FreeCAD.Vector(0, 1, 0)), - "Z": LinearAxis("Z", FreeCAD.Vector(0, 0, 1)), - } - self.rotary_axes = {} - self.primary_rotary_axis = None - self.secondary_rotary_axis = None - self.compound_moves = True - - @classmethod - def create_3axis_config(cls) -> "Machine": - """Create standard 3-axis XYZ configuration (no rotary axes)""" - config = cls("3-Axis XYZ Configuration") - config._initialize_3axis_config() - return config - - def _initialize_4axis_A_config(self, a_limits=(-120, 120)) -> None: - """Initialize as a 4-axis XYZA configuration (rotary table around X)""" - self._initialize_3axis_config() - self.rotary_axes["A"] = RotaryAxis( - "A", FreeCAD.Vector(1, 0, 0), min_limit=a_limits[0], max_limit=a_limits[1] - ) - self.primary_rotary_axis = "A" - - def _initialize_4axis_B_config(self, b_limits=(-120, 120)) -> None: - """Initialize as a 4-axis XYZB configuration (rotary table around Y)""" - self._initialize_3axis_config() - self.rotary_axes["B"] = RotaryAxis( - "B", FreeCAD.Vector(0, 1, 0), min_limit=b_limits[0], max_limit=b_limits[1] - ) - self.primary_rotary_axis = "B" - - def _initialize_AC_table_config(self, a_limits=(-120, 120), c_limits=(-360, 360)) -> None: - """Initialize as a 5-axis AC table configuration""" - self._initialize_4axis_A_config(a_limits) - self.rotary_axes["C"] = RotaryAxis( - "C", FreeCAD.Vector(0, 0, 1), min_limit=c_limits[0], max_limit=c_limits[1] - ) - self.secondary_rotary_axis = "C" - - def _initialize_BC_head_config(self, b_limits=(-120, 120), c_limits=(-360, 360)) -> None: - """Initialize as a 5-axis BC head configuration""" - self._initialize_4axis_B_config(b_limits) - self.rotary_axes["C"] = RotaryAxis( - "C", FreeCAD.Vector(0, 0, 1), min_limit=c_limits[0], max_limit=c_limits[1] - ) - self.secondary_rotary_axis = "C" - - def _initialize_from_machine_type(self, machine_type: str) -> None: - """Initialize machine configuration based on machine type""" - if machine_type == "xyz": - self._initialize_3axis_config() - elif machine_type == "xyza": - self._initialize_4axis_A_config() - elif machine_type == "xyzb": - self._initialize_4axis_B_config() - elif machine_type == "xyzac": - self._initialize_AC_table_config() - elif machine_type == "xyzbc": - self._initialize_BC_head_config() - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Machine": - """Deserialize configuration from dictionary (supports both old and new formats)""" - machine_data = data.get("machine", data) # Support both old and new formats - - # Extract basic configuration - config = cls( - name=machine_data.get("name", "Loaded Machine"), - configuration_units=machine_data.get("units", "metric"), - manufacturer=machine_data.get("manufacturer", ""), - description=machine_data.get("description", ""), - ) - - # Parse axes from new flattened structure - axes = machine_data.get("axes", {}) - config.linear_axes = {} - config.rotary_axes = {} - - # Determine primary/secondary rotary axes - rotary_axis_names = [ - name for name, axis_data in axes.items() if axis_data.get("type") == "angular" - ] - rotary_axis_names.sort() # Sort to get consistent ordering - - if len(rotary_axis_names) > 0: - config.primary_rotary_axis = rotary_axis_names[0] - if len(rotary_axis_names) > 1: - config.secondary_rotary_axis = rotary_axis_names[1] - - # Parse linear and rotary axes - for axis_name, axis_data in axes.items(): - axis_type = axis_data.get("type", "linear") - - if axis_type == "linear": - # Extract direction vector from joint - joint = axis_data.get("joint", [[1, 0, 0], [0, 0, 0]]) - direction_vec = FreeCAD.Vector(joint[0][0], joint[0][1], joint[0][2]) - - min_limit = axis_data.get("min", 0) - max_limit = axis_data.get("max", 1000) - max_velocity = axis_data.get("max_velocity", 10000) - - config.linear_axes[axis_name] = LinearAxis( - name=axis_name, - direction_vector=direction_vec, - min_limit=min_limit, - max_limit=max_limit, - max_velocity=max_velocity, - ) - elif axis_type == "angular": - joint = axis_data.get("joint", [[0, 0, 0], [0, 0, 1]]) - rotation_vec = FreeCAD.Vector(joint[1][0], joint[1][1], joint[1][2]) - - min_limit = axis_data.get("min", -360) - max_limit = axis_data.get("max", 360) - max_velocity = axis_data.get("max_velocity", 36000) - prefer_positive = axis_data.get("prefer_positive", True) - - config.rotary_axes[axis_name] = RotaryAxis( - name=axis_name, - rotation_vector=rotation_vec, - min_limit=min_limit, - max_limit=max_limit, - max_velocity=max_velocity, - prefer_positive=prefer_positive, - ) - - # Parse spindles if present - spindles = machine_data.get("spindles", []) - config.spindles = [Spindle.from_dict(s) for s in spindles] - - # Parse post-processor settings if present - post_data = data.get("postprocessor", {}) - if post_data: - config.postprocessor_file_name = post_data.get("file_name", "") - config.postprocessor_args = post_data.get("args", "") - - # Load output options - output_data = data.get("output", {}) - if output_data: - # Line formatting options - config.output.command_space = output_data.get("command_space", " ") - config.output.comment_symbol = output_data.get("comment_symbol", "(") - config.output.end_of_line_chars = output_data.get("end_of_line_chars", "\n") - config.output.line_increment = output_data.get("line_increment", 10) - config.output.line_number_start = output_data.get("line_number_start", 100) - config.output.line_numbers = output_data.get("line_numbers", False) - config.output.line_number_prefix = output_data.get("line_number_prefix", "N") - - # Output content options (with backward compatibility) - config.output.output_comments = output_data.get( - "output_comments", output_data.get("comments", True) - ) - config.output.output_blank_lines = output_data.get( - "output_blank_lines", output_data.get("blank_lines", True) - ) - config.output.output_bcnc_comments = output_data.get("output_bcnc_comments", True) - config.output.output_header = output_data.get( - "output_header", output_data.get("header", True) - ) - config.output.output_labels = output_data.get( - "output_labels", output_data.get("path_labels", False) - ) - config.output.output_operation_labels = output_data.get( - "output_operation_labels", output_data.get("show_operation_labels", True) - ) - - # Header content options (with backward compatibility) - config.output.list_tools_in_header = output_data.get( - "list_tools_in_header", output_data.get("list_tools_in_preamble", False) - ) - config.output.list_fixtures_in_header = output_data.get("list_fixtures_in_header", True) - config.output.machine_name_in_header = output_data.get( - "machine_name_in_header", output_data.get("machine_name", False) - ) - config.output.description_in_header = output_data.get("description_in_header", True) - config.output.date_in_header = output_data.get("date_in_header", True) - config.output.document_name_in_header = output_data.get("document_name_in_header", True) - - # Filter options (with backward compatibility) - config.output.filter_double_parameters = output_data.get( - "filter_double_parameters", output_data.get("output_double_parameters", False) - ) - # filter_double_commands comes from processing.modal in old format - config.output.filter_double_commands = output_data.get("filter_double_commands", False) - - # Numeric precision settings (with backward compatibility) - config.output.axis_precision = output_data.get("axis_precision", 3) - config.output.feed_precision = output_data.get("feed_precision", 3) - config.output.spindle_precision = output_data.get( - "spindle_precision", output_data.get("spindle_decimals", 0) - ) - - # Handle output_units conversion from string to enum - output_units_str = output_data.get("output_units", "metric") - config.output.output_units = ( - OutputUnits.METRIC if output_units_str == "metric" else OutputUnits.IMPERIAL - ) - - # These fields are now in ProcessingOptions (backward compatibility) - if "tool_change" in output_data: - config.processing.tool_change = output_data["tool_change"] - - # Load processing options - processing_data = data.get("processing", {}) - if processing_data: - config.processing.drill_cycles_to_translate = processing_data.get( - "drill_cycles_to_translate", ["G73", "G81", "G82", "G83"] - ) - config.processing.show_machine_units = processing_data.get("show_machine_units", True) - config.processing.spindle_wait = processing_data.get("spindle_wait", 0.0) - config.processing.split_arcs = processing_data.get("split_arcs", False) - config.processing.suppress_commands = processing_data.get("suppress_commands", []) - config.processing.tool_before_change = processing_data.get("tool_before_change", False) - config.processing.tool_change = processing_data.get("tool_change", True) - config.processing.translate_drill_cycles = processing_data.get( - "translate_drill_cycles", False - ) - return_to = processing_data.get("return_to", None) - config.processing.return_to = tuple(return_to) if return_to is not None else None - - # Backward compatibility: modal moved to output.filter_double_commands - if "modal" in processing_data: - config.output.filter_double_commands = processing_data["modal"] - - # Load G-code blocks - blocks_data = data.get("blocks", {}) - if blocks_data: - for block_name in [ - "pre_job", - "post_job", - "preamble", - "postamble", - "safetyblock", - "pre_operation", - "post_operation", - "pre_tool_change", - "post_tool_change", - "tool_return", - "pre_fixture_change", - "post_fixture_change", - "pre_rotary_move", - "post_rotary_move", - "pre_spindle_change", - "post_spindle_change", - "finish_label", - ]: - if block_name in blocks_data: - setattr(config.blocks, block_name, blocks_data[block_name]) - - return config - - -class MachineFactory: - """Factory class for creating, loading, and saving machine configurations""" - - # Default configuration directory - _config_dir = None - - @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 get_config_directory(cls): - """Get the configuration directory, creating default if not set""" - if cls._config_dir is None: - # Use FreeCAD user data directory + CAM/Machines - try: - cls._config_dir = Path.Preferences.getAssetPath() / "Machines" - cls._config_dir.mkdir(parents=True, exist_ok=True) - except Exception as e: - Path.Log.warning(f"Could not create default config directory: {e}") - cls._config_dir = pathlib.Path.cwd() / "Machines" - cls._config_dir.mkdir(parents=True, exist_ok=True) - return cls._config_dir - - @classmethod - def save_configuration(cls, config, filename=None): - """ - Save a machine configuration to a JSON file - - Args: - config: Machine object to save - filename: Optional filename (without path). If None, uses sanitized config name - - Returns: - Path to the saved file - """ - if filename is None: - # Sanitize the config name for use as filename - filename = config.name.replace(" ", "_").replace("/", "_") + ".fcm" - - config_dir = cls.get_config_directory() - filepath = config_dir / filename - - try: - data = config.to_dict() - with open(filepath, "w", encoding="utf-8") as f: - json.dump(data, f, sort_keys=True, indent=4) - Path.Log.debug(f"Saved machine file: {filepath}") - return filepath - except Exception as e: - Path.Log.error(f"Failed to save configuration: {e}") - raise Exception(f"Failed to save machine file {filepath}: {e}") - - @classmethod - def load_configuration(cls, filename): - """ - Load a machine configuration from a JSON file - - Args: - filename: Filename (with or without path). If no path, searches config directory - - Returns: - Dictionary containing machine configuration data (new format) or - Machine object if loading old format - - Raises: - FileNotFoundError: If the file does not exist - json.JSONDecodeError: If the file is not valid JSON - Exception: For other I/O errors - """ - filepath = pathlib.Path(filename) - - # If no directory specified, look in config directory - if not filepath.parent or filepath.parent == pathlib.Path("."): - filepath = cls.get_config_directory() / filename - - try: - with open(filepath, "r", encoding="utf-8") as f: - data = json.load(f) - Path.Log.debug(f"Loaded machine file: {filepath}") - machine = Machine.from_dict(data) - Path.Log.debug(f"Loaded machine configuration from {filepath}") - return machine - - except FileNotFoundError: - raise FileNotFoundError(f"Machine file not found: {filepath}") - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in machine file {filepath}: {e}") - except Exception as e: - raise Exception(f"Failed to load machine file {filepath}: {e}") - - @classmethod - def create_default_machine_data(cls): - """ - Create a default machine configuration dictionary for the editor. - - Returns: - Dictionary with default machine configuration structure - """ - machine = Machine(name="New Machine") - return machine.to_dict() - - @classmethod - def list_configuration_files(cls) -> list[tuple[str, pathlib.Path]]: - """Get list of available machine files from the asset directory. - - Scans the Machine subdirectory of the asset path for .fcm files - and returns tuples of (display_name, file_path). - - Returns: - list: List of (name, path) tuples for discovered machine files - """ - machines = [("", None)] - try: - asset_base = cls.get_config_directory() - if asset_base.exists(): - for p in sorted(asset_base.glob("*.fcm")): - name = cls.get_machine_display_name(p.name) - machines.append((name, p.name)) - except Exception: - # Failed to access machine directory or read files, return default list only - pass - return machines - - @classmethod - def list_configurations(cls) -> list[str]: - """Get list of available machines from the asset directory. - - Scans the Machine subdirectory of the asset path for .fcm files - and extracts machine names. Returns [""] plus discovered machine names. - - Returns: - list: List of machine names starting with "" - """ - machines = cls.list_configuration_files() - return [name for name, path in machines] - - @classmethod - def delete_configuration(cls, filename): - """ - Delete a machine configuration file - - Args: - filename: Name of the configuration file to delete - - Returns: - True if deleted successfully, False otherwise - """ - filepath = cls.get_config_directory() / filename - try: - if filepath.exists(): - filepath.unlink() - Path.Log.debug(f"Deleted machine: {filepath}") - return True - else: - Path.Log.warning(f"Machine file not found: {filepath}") - return False - except Exception as e: - Path.Log.error(f"Failed to delete machine: {e}") - return False - - @classmethod - def create_standard_configs(cls): - """ - Create and save all standard machine configurations - - Returns: - Dictionary mapping config names to file paths - """ - configs = { - "XYZ": Machine.create_3axis_config(), - "XYZAC": Machine.create_AC_table_config(), - "XYZBC": Machine.create_BC_head_config(), - "XYZA": Machine.create_4axis_A_config(), - "XYZB": Machine.create_4axis_B_config(), - } - - saved_paths = {} - for name, config in configs.items(): - try: - filepath = cls.save_configuration(config, f"{name}.fcm") - saved_paths[name] = filepath - except Exception as e: - Path.Log.error(f"Failed to save {name}: {e}") - - return saved_paths - - @classmethod - def get_builtin_config(cls, config_type): - """ - Get a built-in machine configuration without loading from disk - - Args: - config_type: One of "XYZ", "XYZAC", "XYZBC", "XYZA", "XYZB" - - Returns: - Machine object - """ - config_map = { - "XYZ": Machine.create_3axis_config, - "XYZAC": Machine.create_AC_table_config, - "XYZBC": Machine.create_BC_head_config, - "XYZA": Machine.create_4axis_A_config, - "XYZB": Machine.create_4axis_B_config, - } - - if config_type not in config_map: - raise ValueError( - f"Unknown config type: {config_type}. Available: {list(config_map.keys())}" - ) - - return config_map[config_type]() - - @classmethod - def get_machine(cls, machine_name): - """ - Get a machine configuration by name from the assets folder - - Args: - machine_name: Name of the machine to load (without .fcm extension) - - Returns: - Machine object - - Raises: - FileNotFoundError: If no machine with that name is found - ValueError: If the loaded data is not a valid machine configuration - """ - # Get list of available machine files - machine_files = cls.list_configuration_files() - - # Find the file matching the machine name (case-insensitive) - target_path = None - machine_name_lower = machine_name.lower() - for name, path in machine_files: - if name.lower() == machine_name_lower and path is not None: - target_path = path - break - - if target_path is None: - available = [name for name, path in machine_files if path is not None] - raise FileNotFoundError( - f"Machine '{machine_name}' not found. Available machines: {available}" - ) - - # Load the configuration using the path from list_configuration_files() - data = cls.load_configuration(target_path) - - # If load_configuration returned a dict (new format), convert to Machine - if isinstance(data, dict): - return Machine.from_dict(data) - else: - # Already a Machine object (old format) - return data - - @classmethod - def get_machine_display_name(cls, filename): - """ - Get the display name for a machine from its filename in the config directory. - - Args: - filename: Name of the machine file (without path) - - Returns: - str: Display name (machine name from JSON or filename stem) - """ - filepath = cls.get_config_directory() / filename - try: - with open(filepath, "r", encoding="utf-8") as f: - data = json.load(f) - return data.get("machine", {}).get("name", filepath.stem) - except Exception: - return filepath.stem diff --git a/src/Mod/CAM/Path/Machine/ui/editor/__init__.py b/src/Mod/CAM/Path/Machine/ui/editor/__init__.py deleted file mode 100644 index 5f85f7f775..0000000000 --- a/src/Mod/CAM/Path/Machine/ui/editor/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# *************************************************************************** -# * * -# * Copyright (c) 2025 Billy Huddleston * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** -"""Machine editor package.""" -from .machine_editor import MachineEditorDialog - -__all__ = ["MachineEditorDialog"] diff --git a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py deleted file mode 100644 index a9f171f415..0000000000 --- a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py +++ /dev/null @@ -1,1570 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# *************************************************************************** -# * * -# * Copyright (c) 2025 Billy Huddleston * -# * * -# * 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 * -# * . * -# * * -# *************************************************************************** -from PySide import QtGui, QtCore -import FreeCAD -import Path -import json -from typing import Optional, Dict, Any, get_origin, get_args -from dataclasses import fields -from enum import Enum -from ...models import Machine, MachineFactory, LinearAxis, RotaryAxis, Spindle -from ....Main.Gui.Editor import CodeEditor -from Path.Post.Processor import PostProcessorFactory -import re - -translate = FreeCAD.Qt.translate - - -class DataclassGUIGenerator: - """Generates Qt widgets dynamically from dataclass definitions. - - This class introspects dataclass fields and creates appropriate GUI widgets - based on field types. It supports nested dataclasses, creating grouped layouts - for better organization. - """ - - # Fields that should use multi-line text editors instead of single-line - MULTILINE_FIELDS = { - "preamble", - "postamble", - "pre_job", - "post_job", - "pre_operation", - "post_operation", - "pre_tool_change", - "post_tool_change", - "tool_return", - "pre_fixture_change", - "post_fixture_change", - "pre_rotary_move", - "post_rotary_move", - "pre_spindle_change", - "post_spindle_change", - "safetyblock", - "drill_cycles_to_translate", - "suppress_commands", - } - - # Field display name overrides - FIELD_LABELS = { - "comments": translate("CAM_MachineEditor", "Include Comments"), - "blank_lines": translate("CAM_MachineEditor", "Include Blank Lines"), - "header": translate("CAM_MachineEditor", "Include Header"), - "line_numbers": translate("CAM_MachineEditor", "Line Numbers"), - "path_labels": translate("CAM_MachineEditor", "Path Labels"), - "machine_name": translate("CAM_MachineEditor", "Include Machine Name"), - "output_double_parameters": translate("CAM_MachineEditor", "Output Duplicate Axis Values"), - "output_bcnc_comments": translate("CAM_MachineEditor", "Output bCNC Comments"), - "doubles": translate("CAM_MachineEditor", "Output Duplicate Axis Values"), - "adaptive": translate("CAM_MachineEditor", "Adaptive Output"), - "axis_precision": translate("CAM_MachineEditor", "Axis Precision"), - "feed_precision": translate("CAM_MachineEditor", "Feed Precision"), - "spindle_decimals": translate("CAM_MachineEditor", "Spindle Decimals"), - "command_space": translate("CAM_MachineEditor", "Command Spacing"), - "comment_symbol": translate("CAM_MachineEditor", "Comment Symbol"), - "line_increment": translate("CAM_MachineEditor", "Line Number Increment"), - "line_number_start": translate("CAM_MachineEditor", "Line Number Start"), - "end_of_line_chars": translate("CAM_MachineEditor", "End of Line Characters"), - "modal": translate("CAM_MachineEditor", "Modal Output (Suppress Repeats)"), - "translate_drill_cycles": translate("CAM_MachineEditor", "Translate Drill Cycles"), - "split_arcs": translate("CAM_MachineEditor", "Split Arcs"), - "show_editor": translate("CAM_MachineEditor", "Show Editor After Generation"), - "list_tools_in_preamble": translate("CAM_MachineEditor", "List Tools in Preamble"), - "show_machine_units": translate("CAM_MachineEditor", "Show Machine Units"), - "show_operation_labels": translate("CAM_MachineEditor", "Show Operation Labels"), - "tool_before_change": translate("CAM_MachineEditor", "Output T Before M6"), - "chipbreaking_amount": translate("CAM_MachineEditor", "Chipbreaking Amount (mm)"), - "spindle_wait": translate("CAM_MachineEditor", "Spindle Wait Time (seconds)"), - "postprocessor_file_name": translate("CAM_MachineEditor", "Post Processor"), - "postprocessor_args": translate("CAM_MachineEditor", "Post Processor Arguments"), - "use_tlo": translate("CAM_MachineEditor", "Use Tool Length Offset"), - "stop_spindle_for_tool_change": translate( - "CAM_MachineEditor", "Stop Spindle for Tool Change" - ), - "enable_coolant": translate("CAM_MachineEditor", "Enable Coolant"), - "enable_machine_specific_commands": translate( - "CAM_MachineEditor", "Enable Machine-Specific Commands" - ), - } - - @staticmethod - def get_field_label(field_name: str) -> str: - """Get a human-readable label for a field name.""" - if field_name in DataclassGUIGenerator.FIELD_LABELS: - return DataclassGUIGenerator.FIELD_LABELS[field_name] - # Convert snake_case to Title Case - return " ".join(word.capitalize() for word in field_name.split("_")) - - @staticmethod - def create_widget_for_field( - field_name: str, field_type: type, current_value: Any - ) -> QtGui.QWidget: - """Create an appropriate widget for a dataclass field. - - Args: - field_name: Name of the field - field_type: Type annotation of the field - current_value: Current value of the field - - Returns: - Tuple of (widget, getter_function) where getter returns the current value - """ - origin = get_origin(field_type) - - # Handle Optional types - if origin is type(None) or (origin and str(origin).startswith("typing.Union")): - args = get_args(field_type) - if args: - # Get the non-None type - field_type = next((arg for arg in args if arg is not type(None)), args[0]) - origin = get_origin(field_type) - - # Boolean -> Checkbox - if field_type is bool: - widget = QtGui.QCheckBox() - widget.setChecked(current_value if current_value is not None else False) - widget.value_getter = lambda: widget.isChecked() - return widget - - # Enum -> ComboBox - if isinstance(field_type, type) and issubclass(field_type, Enum): - widget = QtGui.QComboBox() - for member in field_type: - widget.addItem(member.value if hasattr(member, "value") else str(member), member) - if current_value: - index = widget.findData(current_value) - if index >= 0: - widget.setCurrentIndex(index) - widget.value_getter = lambda: widget.itemData(widget.currentIndex()) - return widget - - # List[str] -> Multi-line text area - if origin is list: - args = get_args(field_type) - if args and args[0] is str: - widget = QtGui.QPlainTextEdit() - widget.setMaximumHeight(100) - if current_value: - widget.setPlainText("\n".join(current_value)) - else: - widget.setPlainText("") - widget.value_getter = lambda: [ - line.strip() for line in widget.toPlainText().split("\n") if line.strip() - ] - return widget - - # Int -> SpinBox - if field_type is int: - widget = QtGui.QSpinBox() - widget.setRange(-999999, 999999) - widget.setValue(current_value if current_value is not None else 0) - widget.value_getter = lambda: widget.value() - return widget - - # Float -> DoubleSpinBox - if field_type is float: - widget = QtGui.QDoubleSpinBox() - widget.setRange(-999999.0, 999999.0) - widget.setDecimals(4) - widget.setValue(current_value if current_value is not None else 0.0) - widget.value_getter = lambda: widget.value() - return widget - - # String -> LineEdit or PlainTextEdit - if field_type is str: - if field_name in DataclassGUIGenerator.MULTILINE_FIELDS: - widget = QtGui.QPlainTextEdit() - widget.setMaximumHeight(100) - widget.setPlainText(current_value if current_value else "") - widget.value_getter = lambda: widget.toPlainText() - else: - widget = QtGui.QLineEdit() - widget.setText(current_value if current_value else "") - widget.value_getter = lambda: widget.text() - return widget - - # Fallback to string representation - widget = QtGui.QLineEdit() - widget.setText(str(current_value) if current_value is not None else "") - widget.value_getter = lambda: widget.text() - return widget - - @staticmethod - def create_group_for_dataclass( - dataclass_instance: Any, group_title: str - ) -> tuple[QtGui.QGroupBox, Dict[str, QtGui.QWidget]]: - """Create a QGroupBox with widgets for all fields in a dataclass. - - Args: - dataclass_instance: Instance of a dataclass - group_title: Title for the group box - - Returns: - Tuple of (QGroupBox, dict mapping field_name to widget) - """ - group = QtGui.QGroupBox(group_title) - layout = QtGui.QFormLayout(group) - widgets = {} - - for field in fields(dataclass_instance): - # Skip private fields and complex types we don't handle - if field.name.startswith("_"): - continue - - current_value = getattr(dataclass_instance, field.name) - field_type = field.type - - # Skip parameter_functions and other callable/complex types - if field.name in ["parameter_functions", "parameter_order"]: - continue - - widget = DataclassGUIGenerator.create_widget_for_field( - field.name, field_type, current_value - ) - - label = DataclassGUIGenerator.get_field_label(field.name) - layout.addRow(label, widget) - widgets[field.name] = widget - - return group, widgets - - -class MachineEditorDialog(QtGui.QDialog): - """A dialog to edit machine JSON assets with proper form fields.""" - - # Todo - Make this a json schema driven form in the future - MACHINE_TYPES = { - "custom": { - "name": translate("CAM_MachineEditor", "Custom Machine"), - "linear": [], - "rotary": [], - }, - "xz": { - "name": translate("CAM_MachineEditor", "2-Axis Lathe (X, Z)"), - "linear": ["X", "Z"], - "rotary": [], - }, - "xyz": { - "name": translate("CAM_MachineEditor", "3-Axis Mill (XYZ)"), - "linear": ["X", "Y", "Z"], - "rotary": [], - }, - "xyza": { - "name": translate("CAM_MachineEditor", "4-Axis Mill (XYZ + A)"), - "linear": ["X", "Y", "Z"], - "rotary": ["A"], - }, - "xyzb": { - "name": translate("CAM_MachineEditor", "4-Axis Mill (XYZ + B)"), - "linear": ["X", "Y", "Z"], - "rotary": ["B"], - }, - "xyzac": { - "name": translate("CAM_MachineEditor", "5-Axis Mill (XYZ + A, C)"), - "linear": ["X", "Y", "Z"], - "rotary": ["A", "C"], - }, - "xyzbc": { - "name": translate("CAM_MachineEditor", "5-Axis Mill (XYZ + B, C)"), - "linear": ["X", "Y", "Z"], - "rotary": ["B", "C"], - }, - } - - ROTATIONAL_AXIS_OPTIONS = [ - ("+X", [1, 0, 0]), - ("-X", [-1, 0, 0]), - ("+Y", [0, 1, 0]), - ("-Y", [0, -1, 0]), - ("+Z", [0, 0, 1]), - ("-Z", [0, 0, -1]), - ] - - def __init__(self, machine_filename: Optional[str] = None, parent=None): - super().__init__(parent) - self.setMinimumSize(700, 900) - self.resize(700, 900) - - self.current_units = "metric" - - # Initialize machine object first (needed by setup methods) - self.filename = machine_filename - self.machine = None # Store the Machine object - - if machine_filename: - self.machine = MachineFactory.load_configuration(machine_filename) - else: - self.machine = Machine(name="New Machine") - - # Set window title with machine name - title = translate("CAM_MachineEditor", "Machine Editor") - if self.machine and self.machine.name: - title += f" - {self.machine.name}" - self.setWindowTitle(title) - - # Initialize widget and processor caches - self.post_widgets = {} - self.processor = {} - - self.layout = QtGui.QVBoxLayout(self) - - # Tab widget for sections - self.tabs = QtGui.QTabWidget() - self.layout.addWidget(self.tabs) - - # Machine tab - self.machine_tab = QtGui.QWidget() - self.tabs.addTab(self.machine_tab, translate("CAM_MachineEditor", "Machine")) - self.setup_machine_tab() - - # Output Options tab - self.output_tab = QtGui.QWidget() - self.tabs.addTab(self.output_tab, translate("CAM_MachineEditor", "Output Options")) - self.setup_output_tab() - - # G-Code Blocks tab - self.blocks_tab = QtGui.QWidget() - self.tabs.addTab(self.blocks_tab, translate("CAM_MachineEditor", "G-Code Blocks")) - self.setup_blocks_tab() - - # Processing Options tab - self.processing_tab = QtGui.QWidget() - self.tabs.addTab(self.processing_tab, translate("CAM_MachineEditor", "Processing Options")) - self.setup_processing_tab() - - # Check experimental flag for machine post processor - param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") - self.enable_machine_postprocessor = param.GetBool("EnableMachinePostprocessor", True) - self.tabs.setTabVisible( - self.tabs.indexOf(self.output_tab), self.enable_machine_postprocessor - ) - self.tabs.setTabVisible( - self.tabs.indexOf(self.blocks_tab), self.enable_machine_postprocessor - ) - self.tabs.setTabVisible( - self.tabs.indexOf(self.processing_tab), self.enable_machine_postprocessor - ) - # Text editor (initially hidden) - self.text_editor = CodeEditor() - - p = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Editor") - font = QtGui.QFont() - font.setFamily(p.GetString("Font", "Courier")) - font.setFixedPitch(True) - font.setPointSize(p.GetInt("FontSize", 10)) - - self.text_editor.setFont(font) - self.layout.addWidget(self.text_editor) - self.text_editor.hide() - - button_layout = QtGui.QHBoxLayout() - - self.toggle_button = QtGui.QPushButton(translate("CAM_MachineEditor", "Edit as Text")) - self.toggle_button.clicked.connect(self.toggle_editor_mode) - button_layout.addWidget(self.toggle_button) - - button_layout.addStretch() - - buttons = QtGui.QDialogButtonBox( - QtGui.QDialogButtonBox.Save | QtGui.QDialogButtonBox.Close, - QtCore.Qt.Horizontal, - ) - buttons.button(QtGui.QDialogButtonBox.Save).setText(translate("CAM_MachineEditor", "Save")) - buttons.button(QtGui.QDialogButtonBox.Close).setText( - translate("CAM_MachineEditor", "Close") - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - button_layout.addWidget(buttons) - - self.layout.addLayout(button_layout) - self.text_mode = False - - # Populate GUI from machine object - self.populate_from_machine(self.machine) - - # Update spindle button state - self._update_spindle_button_state() - - # Set focus and select the name field for new machines - if not machine_filename: - self.name_edit.setFocus() - self.name_edit.selectAll() - - def normalize_text(self, edit, suffix, axis=None, field=None): - """Normalize and validate text input for numeric fields with units. - - Parses user input, converts between metric/imperial units, and ensures - proper formatting. Updates stored data for axes configuration. - - Args: - edit: QLineEdit widget to update - suffix: Unit suffix (e.g., "mm", "in", "deg") - axis: Axis identifier for data storage - field: Field name for data storage - """ - text = edit.text().strip() - if not text: - edit.setText("0 " + suffix) - return - - # Parse input manually based on machine units - machine_units = self.units_combo.itemData(self.units_combo.currentIndex()) - - # Split number and unit - match = re.match(r"^([+-]?\d*\.?\d+)\s*(.*)$", text) - if not match: - edit.setText("0 " + suffix) - return - - num_str, unit_str = match.groups() - try: - value = float(num_str) - except ValueError: - edit.setText("0 " + suffix) - return - - # Determine if this is a rotary axis by checking the suffix - is_angular = suffix in ["deg", "deg/min"] - - # Convert to internal mm (or degrees for angles) - if is_angular: - # For rotary axes, always use degrees, no unit conversion - if unit_str.strip(): - if unit_str.strip() in ["deg", "degree", "degrees", "°"]: - internal_value = value - else: - # Unknown unit for angles, assume degrees - internal_value = value - else: - # No unit, assume degrees - internal_value = value - else: - # Linear axes - if unit_str.strip(): - if unit_str.strip() in ["mm", "millimeter", "millimeters"]: - internal_value = value - elif unit_str.strip() in ["in", "inch", "inches", '"']: - internal_value = value * 25.4 - elif unit_str.strip() in ["cm", "centimeter", "centimeters"]: - internal_value = value * 10 - elif unit_str.strip() in ["m", "meter", "meters"]: - internal_value = value * 1000 - else: - # Unknown unit, assume machine units - if machine_units == "metric": - internal_value = value # assume mm - else: - internal_value = value * 25.4 # assume in - else: - # No unit, assume machine units - if machine_units == "metric": - internal_value = value # assume mm - else: - internal_value = value * 25.4 # assume in - - # Convert to display units - if suffix == "mm": - display_value = round(internal_value, 2) - elif suffix == "in": - display_value = round(internal_value / 25.4, 2) - elif suffix == "mm/min": - display_value = round(internal_value, 2) - elif suffix == "in/min": - display_value = round(internal_value / 25.4, 2) - elif suffix == "deg": - display_value = round(internal_value, 2) - elif suffix == "deg/min": - display_value = round(internal_value, 2) - else: - display_value = round(internal_value, 2) - - edit.setText(f"{display_value:.2f} {suffix}") - - # Update machine's axis directly (store in metric) - if axis and field and self.machine: - # Find the axis in machine.linear_axes or machine.rotary_axes (both are dicts) - if axis in self.machine.linear_axes: - ax = self.machine.linear_axes[axis] - if field == "min": - ax.min_limit = internal_value - elif field == "max": - ax.max_limit = internal_value - elif field == "max_velocity": - ax.max_velocity = internal_value - elif axis in self.machine.rotary_axes: - ax = self.machine.rotary_axes[axis] - if field == "min": - ax.min_limit = internal_value - elif field == "max": - ax.max_limit = internal_value - elif field == "max_velocity": - ax.max_velocity = internal_value - - # Signal handlers that update self.machine directly - def _on_name_changed(self, text): - """Update machine name when text changes.""" - if self.machine: - self.machine.name = text - # Update window title with new name - title = translate("CAM_MachineEditor", "Machine Editor") - if self.machine.name: - title += f" - {self.machine.name}" - self.setWindowTitle(title) - - def _on_rotary_sequence_changed(self, axis_name, value): - """Update rotary axis sequence.""" - if self.machine and axis_name in self.machine.rotary_axes: - self.machine.rotary_axes[axis_name].sequence = value - - def _on_rotary_joint_changed(self, axis_name, combo): - """Update rotary axis joint/rotation vector.""" - if self.machine and axis_name in self.machine.rotary_axes: - vector = combo.itemData(combo.currentIndex()) - self.machine.rotary_axes[axis_name].rotation_vector = FreeCAD.Vector(*vector) - - def _on_rotary_prefer_positive_changed(self, axis_name, checked): - """Update rotary axis prefer_positive.""" - if self.machine and axis_name in self.machine.rotary_axes: - self.machine.rotary_axes[axis_name].prefer_positive = checked - - def _on_spindle_field_changed(self, spindle_index, field_name, value): - """Update spindle field in Machine object when UI field changes. - - Args: - spindle_index: Index of the spindle in machine.spindles - field_name: Name of the field being updated - value: New value for the field - """ - if self.machine and spindle_index < len(self.machine.spindles): - spindle = self.machine.spindles[spindle_index] - setattr(spindle, field_name, value) - - def _add_spindle(self): - """Add a new spindle to the machine.""" - if self.machine and len(self.machine.spindles) < 9: - new_index = len(self.machine.spindles) + 1 - new_spindle = Spindle( - name=f"Spindle {new_index}", - id=f"spindle{new_index}", - max_power_kw=3.0, - max_rpm=24000, - min_rpm=6000, - tool_change="manual", - ) - self.machine.spindles.append(new_spindle) - self.update_spindles() - self._update_spindle_button_state() - # Set focus to the new tab - self.spindles_tabs.setCurrentIndex(len(self.machine.spindles) - 1) - - def _remove_spindle(self, index): - """Remove a spindle from the machine with confirmation. - - Args: - index: Index of the tab/spindle to remove - """ - if not self.machine or len(self.machine.spindles) <= 1: - return # Don't allow removing the last spindle - - spindle = self.machine.spindles[index] - reply = QtGui.QMessageBox.question( - self, - translate("CAM_MachineEditor", "Remove Spindle"), - translate("CAM_MachineEditor", f"Remove '{spindle.name}'? This cannot be undone."), - QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, - QtGui.QMessageBox.No, - ) - - if reply == QtGui.QMessageBox.Yes: - self.machine.spindles.pop(index) - self.update_spindles() - self._update_spindle_button_state() - - def _update_spindle_button_state(self): - """Enable/disable the add spindle button based on count.""" - if self.machine: - self.add_spindle_button.setEnabled(len(self.machine.spindles) < 9) - - def _on_manufacturer_changed(self, text): - """Update manufacturer when text changes.""" - if self.machine: - self.machine.manufacturer = text - - def _on_description_changed(self, text): - """Update description when text changes.""" - if self.machine: - self.machine.description = text - - def _on_units_changed(self, index): - """Update units and refresh axes display.""" - if self.machine: - units = self.units_combo.itemData(index) - self.machine.configuration_units = units - self.current_units = units - self.update_axes() - - def _on_type_changed(self, index): - """Update machine type and refresh axes.""" - if self.machine: - machine_type = self.type_combo.itemData(index) - # Note: machine_type is a read-only property determined by axes configuration - # Don't try to set it directly - instead modify the axes below - - # Rebuild axes in machine based on new type - config = self.MACHINE_TYPES.get(machine_type, {}) - - # Store existing axes for preservation - old_linear_axes = self.machine.linear_axes.copy() - old_rotary_axes = self.machine.rotary_axes.copy() - - # Clear and rebuild linear axes - self.machine.linear_axes = {} - for axis_name in config.get("linear", []): - # Preserve existing axis if available - if axis_name in old_linear_axes: - self.machine.linear_axes[axis_name] = old_linear_axes[axis_name] - else: - # Create with defaults - self.machine.linear_axes[axis_name] = LinearAxis( - name=axis_name, - direction_vector=( - FreeCAD.Vector(1, 0, 0) - if axis_name == "X" - else ( - FreeCAD.Vector(0, 1, 0) - if axis_name == "Y" - else FreeCAD.Vector(0, 0, 1) - ) - ), - min_limit=0, - max_limit=1000, - max_velocity=10000, - ) - - # Clear and rebuild rotary axes - self.machine.rotary_axes = {} - for axis_name in config.get("rotary", []): - # Preserve existing axis if available - if axis_name in old_rotary_axes: - self.machine.rotary_axes[axis_name] = old_rotary_axes[axis_name] - else: - # Create with defaults - default_vector = ( - [1, 0, 0] - if axis_name == "A" - else [0, 1, 0] if axis_name == "B" else [0, 0, 1] - ) - self.machine.rotary_axes[axis_name] = RotaryAxis( - name=axis_name, - rotation_vector=FreeCAD.Vector(*default_vector), - min_limit=-180, - max_limit=180, - max_velocity=36000, - sequence=0, - prefer_positive=True, - ) - - self.update_axes() - - def setup_machine_tab(self): - """Set up the machine configuration tab with form fields. - - Creates input fields for machine name, manufacturer, description, - units, type, spindle count, axes configuration, and spindles. - Connects change handlers for dynamic updates. - """ - layout = QtGui.QFormLayout(self.machine_tab) - - self.name_edit = QtGui.QLineEdit() - self.name_edit.textChanged.connect(self._on_name_changed) - layout.addRow(translate("CAM_MachineEditor", "Name"), self.name_edit) - - self.manufacturer_edit = QtGui.QLineEdit() - self.manufacturer_edit.textChanged.connect(self._on_manufacturer_changed) - layout.addRow(translate("CAM_MachineEditor", "Manufacturer"), self.manufacturer_edit) - - self.description_edit = QtGui.QLineEdit() - self.description_edit.textChanged.connect(self._on_description_changed) - layout.addRow(translate("CAM_MachineEditor", "Description"), self.description_edit) - - self.units_combo = QtGui.QComboBox() - self.units_combo.addItem(translate("CAM_MachineEditor", "Metric"), "metric") - self.units_combo.addItem(translate("CAM_MachineEditor", "Imperial"), "imperial") - self.units_combo.currentIndexChanged.connect(self._on_units_changed) - layout.addRow(translate("CAM_MachineEditor", "Units"), self.units_combo) - - self.type_combo = QtGui.QComboBox() - for key, value in self.MACHINE_TYPES.items(): - self.type_combo.addItem(value["name"], key) - self.type_combo.currentIndexChanged.connect(self._on_type_changed) - layout.addRow(translate("CAM_MachineEditor", "Type"), self.type_combo) - - # Post Processor Selection - self.post_processor_combo = QtGui.QComboBox() - postProcessors = Path.Preferences.allEnabledPostProcessors([""]) - for post in postProcessors: - self.post_processor_combo.addItem(post) - self.post_processor_combo.currentIndexChanged.connect(self.updatePostProcessorTooltip) - self.post_processor_combo.currentIndexChanged.connect( - lambda: self._update_machine_field( - "postprocessor_file_name", self.post_processor_combo.currentText() - ) - ) - self.postProcessorDefaultTooltip = translate("CAM_MachineEditor", "Select a post processor") - self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip) - layout.addRow(translate("CAM_MachineEditor", "Post Processor"), self.post_processor_combo) - - # self.post_processor_args_edit = QtGui.QLineEdit() - # self.post_processor_args_edit.textChanged.connect( - # lambda text: self._update_machine_field("postprocessor_args", text) - # ) - # self.postProcessorArgsDefaultTooltip = translate( - # "CAM_MachineEditor", "Additional arguments" - # ) - # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) - # layout.addRow(translate("CAM_MachineEditor", "Arguments"), self.post_processor_args_edit) - - # Axes group - self.axes_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Axes")) - self.axes_layout = QtGui.QVBoxLayout(self.axes_group) - self.axes_group.setVisible(False) # Initially hidden, shown when axes are configured - layout.addRow(self.axes_group) - - # Spindles group - self.spindles_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Spindles")) - spindles_layout = QtGui.QVBoxLayout(self.spindles_group) - - self.spindles_tabs = QtGui.QTabWidget() - self.spindles_tabs.setTabsClosable(True) - self.spindles_tabs.tabCloseRequested.connect(self._remove_spindle) - - # Add + button to the tab bar corner, vertically centered - corner_container = QtGui.QWidget() - corner_container_layout = QtGui.QVBoxLayout(corner_container) - corner_container_layout.setContentsMargins(0, 0, 0, 0) - corner_container_layout.setSpacing(0) - corner_container_layout.addStretch() - self.add_spindle_button = QtGui.QPushButton("+") - self.add_spindle_button.setToolTip(translate("CAM_MachineEditor", "Add Spindle")) - self.add_spindle_button.clicked.connect(self._add_spindle) - self.add_spindle_button.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) - corner_container_layout.addWidget(self.add_spindle_button, 0, QtCore.Qt.AlignCenter) - corner_container_layout.addStretch() - self.spindles_tabs.setCornerWidget(corner_container, QtCore.Qt.TopRightCorner) - - spindles_layout.addWidget(self.spindles_tabs) - layout.addRow(self.spindles_group) - - def update_axes(self): - """Update the axes configuration UI based on machine type and units. - - Dynamically creates input fields for all axes based on the selected - machine type. Uses flattened structure with type property. - """ - # Get current units for suffixes and conversion - units = self.units_combo.itemData(self.units_combo.currentIndex()) - length_suffix = " mm" if units == "metric" else " in" - vel_suffix = " mm/min" if units == "metric" else " in/min" - angle_suffix = " deg" - angle_vel_suffix = " deg/min" - - # Convert saved data if units changed - if hasattr(self, "current_units") and self.current_units != units: - self.current_units = units - - # Clear references before deleting widgets - self.axis_edits = {} - - # Clear existing axes widgets and disconnect signals - for i in reversed(range(self.axes_layout.count())): - widget = self.axes_layout.itemAt(i).widget() - if widget: - # Disconnect all signals to prevent callbacks to deleted widgets - widget.blockSignals(True) - widget.deleteLater() - self.axes_layout.removeWidget(widget) - - # Get current type - type_key = self.type_combo.itemData(self.type_combo.currentIndex()) - if not type_key: - return - config = self.MACHINE_TYPES[type_key] - - # Create axes group - all_axes = config.get("linear", []) + config.get("rotary", []) - if not all_axes: - self.axes_group.setVisible(False) - return - - # Get axes directly from machine object - linear_axes = list(self.machine.linear_axes.keys()) if self.machine else [] - rotary_axes = list(self.machine.rotary_axes.keys()) if self.machine else [] - - # Linear axes - if linear_axes: - linear_group = QtGui.QGroupBox("Linear Axes") - linear_layout = QtGui.QFormLayout(linear_group) - - for axis in linear_axes: - axis_obj = self.machine.linear_axes[axis] - converted_min = ( - axis_obj.min_limit if units == "metric" else axis_obj.min_limit / 25.4 - ) - min_edit = QtGui.QLineEdit() - min_edit.setText(f"{converted_min:.2f}{length_suffix}") - min_edit.editingFinished.connect( - lambda edit=min_edit, suffix=length_suffix.strip(), ax=axis, fld="min": self.normalize_text( - edit, suffix, ax, fld - ) - ) - - converted_max = ( - axis_obj.max_limit if units == "metric" else axis_obj.max_limit / 25.4 - ) - max_edit = QtGui.QLineEdit() - max_edit.setText(f"{converted_max:.2f}{length_suffix}") - max_edit.editingFinished.connect( - lambda edit=max_edit, suffix=length_suffix.strip(), ax=axis, fld="max": self.normalize_text( - edit, suffix, ax, fld - ) - ) - - converted_vel = ( - axis_obj.max_velocity if units == "metric" else axis_obj.max_velocity / 25.4 - ) - vel_edit = QtGui.QLineEdit() - vel_edit.setText(f"{converted_vel:.2f}{vel_suffix}") - vel_edit.editingFinished.connect( - lambda edit=vel_edit, suffix=vel_suffix.strip(), ax=axis, fld="max_velocity": self.normalize_text( - edit, suffix, ax, fld - ) - ) - - axis_layout = QtGui.QHBoxLayout() - axis_layout.addWidget(QtGui.QLabel("Min")) - axis_layout.addWidget(min_edit) - axis_layout.addWidget(QtGui.QLabel("Max")) - axis_layout.addWidget(max_edit) - axis_layout.addWidget(QtGui.QLabel("Max Vel")) - axis_layout.addWidget(vel_edit) - - linear_layout.addRow(f"{axis}", axis_layout) - self.axis_edits[axis] = { - "min": min_edit, - "max": max_edit, - "max_velocity": vel_edit, - "type": "linear", - } - self.axes_layout.addWidget(linear_group) - - # Rotary axes - if rotary_axes: - rotary_group = QtGui.QGroupBox("Rotary Axes") - rotary_layout = QtGui.QFormLayout(rotary_group) - - for axis in rotary_axes: - axis_obj = self.machine.rotary_axes[axis] - - min_edit = QtGui.QLineEdit() - min_edit.setText(f"{axis_obj.min_limit:.2f}{angle_suffix}") - min_edit.editingFinished.connect( - lambda edit=min_edit, suffix=angle_suffix.strip(), ax=axis, fld="min": self.normalize_text( - edit, suffix, ax, fld - ) - ) - - max_edit = QtGui.QLineEdit() - max_edit.setText(f"{axis_obj.max_limit:.2f}{angle_suffix}") - max_edit.editingFinished.connect( - lambda edit=max_edit, suffix=angle_suffix.strip(), ax=axis, fld="max": self.normalize_text( - edit, suffix, ax, fld - ) - ) - - vel_edit = QtGui.QLineEdit() - vel_edit.setText(f"{axis_obj.max_velocity:.2f}{angle_vel_suffix}") - vel_edit.editingFinished.connect( - lambda edit=vel_edit, suffix=angle_vel_suffix.strip(), ax=axis, fld="max_velocity": self.normalize_text( - edit, suffix, ax, fld - ) - ) - - # Sequence number for rotary axes - sequence_spin = QtGui.QSpinBox() - sequence_spin.setRange(0, 10) - sequence_spin.setValue(axis_obj.sequence) - sequence_spin.valueChanged.connect( - lambda value, ax=axis: self._on_rotary_sequence_changed(ax, value) - ) - - # Joint (rotation axis) combo - joint_combo = QtGui.QComboBox() - for label, vector in self.ROTATIONAL_AXIS_OPTIONS: - joint_combo.addItem(label, vector) - # Get rotation vector from axis object - rotation_vec = [ - axis_obj.rotation_vector.x, - axis_obj.rotation_vector.y, - axis_obj.rotation_vector.z, - ] - # Find matching option and set it - for i, (label, vector) in enumerate(self.ROTATIONAL_AXIS_OPTIONS): - if vector == rotation_vec: - joint_combo.setCurrentIndex(i) - break - joint_combo.currentIndexChanged.connect( - lambda index, ax=axis, combo=joint_combo: self._on_rotary_joint_changed( - ax, combo - ) - ) - - prefer_positive = QtGui.QCheckBox() - prefer_positive.setChecked(axis_obj.prefer_positive) - prefer_positive.stateChanged.connect( - lambda state, ax=axis: self._on_rotary_prefer_positive_changed( - ax, state == QtCore.Qt.Checked - ) - ) - - # Grid layout - axis_grid = QtGui.QGridLayout() - - # Row 0: Min, Max, Vel - axis_grid.addWidget(QtGui.QLabel("Min"), 0, 0, QtCore.Qt.AlignRight) - axis_grid.addWidget(min_edit, 0, 1) - axis_grid.addWidget(QtGui.QLabel("Max"), 0, 2, QtCore.Qt.AlignRight) - axis_grid.addWidget(max_edit, 0, 3) - axis_grid.addWidget(QtGui.QLabel("Max Vel"), 0, 4, QtCore.Qt.AlignRight) - axis_grid.addWidget(vel_edit, 0, 5) - - # Row 1: Sequence, Joint, Prefer+ - axis_grid.addWidget(QtGui.QLabel("Sequence"), 1, 0, QtCore.Qt.AlignRight) - axis_grid.addWidget(sequence_spin, 1, 1) - axis_grid.addWidget(QtGui.QLabel("Joint"), 1, 2, QtCore.Qt.AlignRight) - axis_grid.addWidget(joint_combo, 1, 3) - axis_grid.addWidget(QtGui.QLabel("Prefer+"), 1, 4, QtCore.Qt.AlignRight) - axis_grid.addWidget(prefer_positive, 1, 5) - - axis_label = QtGui.QLabel(f"{axis}") - axis_label.setMinimumWidth(30) # Prevent layout shift when axis names change - rotary_layout.addRow(axis_label, axis_grid) - - self.axis_edits[axis] = { - "min": min_edit, - "max": max_edit, - "max_velocity": vel_edit, - "sequence": sequence_spin, - "joint": joint_combo, - "prefer_positive": prefer_positive, - "type": "angular", - } - self.axes_layout.addWidget(rotary_group) - - # Show axes group if any axes configured - self.axes_group.setVisible(bool(linear_axes or rotary_axes)) - - def update_spindles(self): - """Update the spindle configuration UI based on spindle count. - - Dynamically creates tabbed interface for multiple spindles with - input fields for name, ID, power, speed, and tool holder. - Updates Machine.spindles directly. - """ - # Clear existing spindle tabs - this properly disconnects signals - while self.spindles_tabs.count() > 0: - tab = self.spindles_tabs.widget(0) - if tab: - tab.blockSignals(True) - tab.deleteLater() - self.spindles_tabs.removeTab(0) - - self.spindle_edits = [] - count = len(self.machine.spindles) if self.machine else 1 - - # Ensure machine has at least 1 spindle - if self.machine: - while len(self.machine.spindles) < count: - self.machine.spindles.append( - Spindle( - name=f"Spindle {len(self.machine.spindles) + 1}", - id=f"spindle{len(self.machine.spindles) + 1}", - max_power_kw=3.0, - max_rpm=24000, - min_rpm=6000, - tool_change="manual", - ) - ) - while len(self.machine.spindles) > count: - self.machine.spindles.pop() - - for i in range(count): - tab = QtGui.QWidget() - layout = QtGui.QFormLayout(tab) - - # Get spindle object or use defaults - spindle = ( - self.machine.spindles[i] - if self.machine and i < len(self.machine.spindles) - else None - ) - - name_edit = QtGui.QLineEdit() - name_edit.setText(spindle.name if spindle else f"Spindle {i+1}") - name_edit.textChanged.connect( - lambda text, idx=i: self._on_spindle_field_changed(idx, "name", text) - ) - layout.addRow("Name", name_edit) - - id_edit = QtGui.QLineEdit() - id_edit.setText(spindle.id if spindle and spindle.id else f"spindle{i+1}") - id_edit.textChanged.connect( - lambda text, idx=i: self._on_spindle_field_changed(idx, "id", text) - ) - layout.addRow("ID", id_edit) - - max_power_edit = QtGui.QDoubleSpinBox() - max_power_edit.setRange(0, 100) - max_power_edit.setValue(spindle.max_power_kw if spindle else 3.0) - max_power_edit.valueChanged.connect( - lambda value, idx=i: self._on_spindle_field_changed(idx, "max_power_kw", value) - ) - layout.addRow("Max Power (kW)", max_power_edit) - - max_rpm_edit = QtGui.QSpinBox() - max_rpm_edit.setRange(0, 100000) - max_rpm_edit.setValue(int(spindle.max_rpm) if spindle else 24000) - max_rpm_edit.valueChanged.connect( - lambda value, idx=i: self._on_spindle_field_changed(idx, "max_rpm", value) - ) - layout.addRow("Max RPM", max_rpm_edit) - - min_rpm_edit = QtGui.QSpinBox() - min_rpm_edit.setRange(0, 100000) - min_rpm_edit.setValue(int(spindle.min_rpm) if spindle else 6000) - min_rpm_edit.valueChanged.connect( - lambda value, idx=i: self._on_spindle_field_changed(idx, "min_rpm", value) - ) - layout.addRow("Min RPM", min_rpm_edit) - - tool_change_combo = QtGui.QComboBox() - tool_change_combo.addItem("Manual", "manual") - tool_change_combo.addItem("ATC", "atc") - if spindle: - index = tool_change_combo.findData(spindle.tool_change) - if index >= 0: - tool_change_combo.setCurrentIndex(index) - tool_change_combo.currentIndexChanged.connect( - lambda idx, spindle_idx=i, combo=tool_change_combo: self._on_spindle_field_changed( - spindle_idx, "tool_change", combo.itemData(combo.currentIndex()) - ) - ) - layout.addRow("Tool Change:", tool_change_combo) - - self.spindles_tabs.addTab(tab, f"Spindle {i+1}") - self.spindle_edits.append( - { - "name": name_edit, - "id": id_edit, - "max_power_kw": max_power_edit, - "max_rpm": max_rpm_edit, - "min_rpm": min_rpm_edit, - "tool_change": tool_change_combo, - } - ) - - def setup_output_tab(self): - """Set up the output options configuration tab.""" - # Use scroll area for all the options - scroll = QtGui.QScrollArea() - scroll.setWidgetResizable(True) - scroll_widget = QtGui.QWidget() - layout = QtGui.QVBoxLayout(scroll_widget) - scroll.setWidget(scroll_widget) - - main_layout = QtGui.QVBoxLayout(self.output_tab) - main_layout.addWidget(scroll) - - # === Output Options === - if self.machine: - output_group, output_widgets = DataclassGUIGenerator.create_group_for_dataclass( - self.machine.output, "Output Options" - ) - layout.addWidget(output_group) - self._connect_widgets_to_machine(output_widgets, "output") - - layout.addStretch() - - def setup_blocks_tab(self): - """Set up the G-Code blocks configuration tab.""" - # Use scroll area for all the options - scroll = QtGui.QScrollArea() - scroll.setWidgetResizable(True) - scroll_widget = QtGui.QWidget() - layout = QtGui.QVBoxLayout(scroll_widget) - scroll.setWidget(scroll_widget) - - main_layout = QtGui.QVBoxLayout(self.blocks_tab) - main_layout.addWidget(scroll) - - # === G-Code Blocks === - if self.machine: - blocks_group, blocks_widgets = DataclassGUIGenerator.create_group_for_dataclass( - self.machine.blocks, "G-Code Blocks" - ) - layout.addWidget(blocks_group) - self._connect_widgets_to_machine(blocks_widgets, "blocks") - - layout.addStretch() - - def setup_processing_tab(self): - """Set up the processing options configuration tab.""" - # Use scroll area for all the options - scroll = QtGui.QScrollArea() - scroll.setWidgetResizable(True) - scroll_widget = QtGui.QWidget() - layout = QtGui.QVBoxLayout(scroll_widget) - scroll.setWidget(scroll_widget) - - main_layout = QtGui.QVBoxLayout(self.processing_tab) - main_layout.addWidget(scroll) - - # === Processing Options === - if self.machine: - processing_group, processing_widgets = DataclassGUIGenerator.create_group_for_dataclass( - self.machine.processing, "Processing Options" - ) - layout.addWidget(processing_group) - self._connect_widgets_to_machine(processing_widgets, "processing") - - layout.addStretch() - - def _connect_widgets_to_machine(self, widgets: Dict[str, QtGui.QWidget], parent_path: str): - """Connect widgets to update Machine object fields. - - Args: - widgets: Dictionary of field_name -> widget - parent_path: Path to parent object (e.g., 'output', 'precision') - """ - for field_name, widget in widgets.items(): - field_path = f"{parent_path}.{field_name}" - - # Store widget for later population - self.post_widgets[field_path] = widget - - # Connect based on widget type - if isinstance(widget, QtGui.QCheckBox): - # Use a wrapper to avoid lambda capture issues - def make_checkbox_handler(path): - def handler(state): - # Qt.Checked = 2, Qt.Unchecked = 0 - value = state == 2 - self._update_machine_field(path, value) - - return handler - - handler_func = make_checkbox_handler(field_path) - widget.stateChanged.connect(handler_func) - elif isinstance(widget, QtGui.QLineEdit): - - def make_lineedit_handler(path): - def handler(text): - self._update_machine_field(path, text) - - return handler - - widget.textChanged.connect(make_lineedit_handler(field_path)) - elif isinstance(widget, QtGui.QPlainTextEdit): - - def make_plaintext_handler(path, w): - def handler(): - self._update_machine_field(path, w.value_getter()) - - return handler - - widget.textChanged.connect(make_plaintext_handler(field_path, widget)) - elif isinstance(widget, (QtGui.QSpinBox, QtGui.QDoubleSpinBox)): - - def make_spinbox_handler(path): - def handler(value): - self._update_machine_field(path, value) - - return handler - - widget.valueChanged.connect(make_spinbox_handler(field_path)) - elif isinstance(widget, QtGui.QComboBox): - - def make_combo_handler(path, w): - def handler(index): - self._update_machine_field(path, w.value_getter()) - - return handler - - widget.currentIndexChanged.connect(make_combo_handler(field_path, widget)) - - def _update_machine_field(self, field_path, value): - """Update a nested field in the machine object using dot notation.""" - if not self.machine: - return - - try: - parts = field_path.split(".") - obj = self.machine - - # Navigate to the parent object - for part in parts[:-1]: - obj = getattr(obj, part) - - # Set the final field - final_field = parts[-1] - current_value = getattr(obj, final_field, None) - - # Only update if value actually changed - if current_value != value: - setattr(obj, final_field, value) - except Exception as e: - Path.Log.error(f"Error updating {field_path}: {e}") - - def _populate_post_widgets_from_machine(self, machine: Machine): - """Populate dynamically generated post-processor widgets from machine object. - - This updates all the nested dataclass widgets (output, precision, formatting, - blocks, processing) and top-level machine post-processing options. - - Args: - machine: Machine object to read values from - """ - - # Helper to set widget value without triggering signals - def set_widget_value_silent(widget, value): - if isinstance(widget, QtGui.QCheckBox): - widget.blockSignals(True) - widget.setChecked(value) - widget.blockSignals(False) - elif isinstance(widget, QtGui.QLineEdit): - widget.blockSignals(True) - widget.setText(str(value) if value is not None else "") - widget.blockSignals(False) - elif isinstance(widget, QtGui.QPlainTextEdit): - widget.blockSignals(True) - if isinstance(value, list): - widget.setPlainText("\n".join(value)) - else: - widget.setPlainText(str(value) if value is not None else "") - widget.blockSignals(False) - elif isinstance(widget, QtGui.QSpinBox): - widget.blockSignals(True) - widget.setValue(int(value) if value is not None else 0) - widget.blockSignals(False) - elif isinstance(widget, QtGui.QDoubleSpinBox): - widget.blockSignals(True) - widget.setValue(float(value) if value is not None else 0.0) - widget.blockSignals(False) - elif isinstance(widget, QtGui.QComboBox): - widget.blockSignals(True) - if hasattr(value, "value"): # Enum - value = value.value - # Find the item with this value - for i in range(widget.count()): - item_data = widget.itemData(i) - if item_data == value or widget.itemText(i) == str(value): - widget.setCurrentIndex(i) - break - widget.blockSignals(False) - - # Update nested dataclass fields - dataclass_groups = [ - ("output", machine.output), - ("blocks", machine.blocks), - ("processing", machine.processing), - ] - - for group_name, dataclass_obj in dataclass_groups: - if dataclass_obj is None: - continue - - # Get all fields from the dataclass - from dataclasses import fields as dataclass_fields - - for field in dataclass_fields(dataclass_obj): - widget_key = f"{group_name}.{field.name}" - # Find the widget in post_widgets (it might be stored with or without the parent path) - widget = None - if widget_key in self.post_widgets: - widget = self.post_widgets[widget_key] - elif field.name in self.post_widgets: - widget = self.post_widgets[field.name] - - if widget: - value = getattr(dataclass_obj, field.name) - set_widget_value_silent(widget, value) - - # Update top-level machine post-processing fields - top_level_fields = [ - "use_tlo", - "stop_spindle_for_tool_change", - "enable_coolant", - "enable_machine_specific_commands", - ] - - for field_name in top_level_fields: - if field_name in self.post_widgets: - widget = self.post_widgets[field_name] - value = getattr(machine, field_name, None) - if value is not None: - set_widget_value_silent(widget, value) - - def getPostProcessor(self, name): - if name not in self.processor: - processor = PostProcessorFactory.get_post_processor(None, name) - self.processor[name] = processor - return processor - return self.processor[name] - - def setPostProcessorTooltip(self, widget, name, default): - processor = self.getPostProcessor(name) - if processor.tooltip: - widget.setToolTip(processor.tooltip) - else: - widget.setToolTip(default) - - def updatePostProcessorTooltip(self): - name = str(self.post_processor_combo.currentText()) - if name: - self.setPostProcessorTooltip( - self.post_processor_combo, name, self.postProcessorDefaultTooltip - ) - processor = self.getPostProcessor(name) - # if processor.tooltipArgs: - # self.post_processor_args_edit.setToolTip(processor.tooltipArgs) - # else: - # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) - else: - self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip) - # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) - - def populate_from_machine(self, machine: Machine): - """Populate UI fields from Machine object. - - Args: - machine: Machine object containing configuration - """ - self.name_edit.setText(machine.name) - self.manufacturer_edit.setText(machine.manufacturer) - self.description_edit.setText(machine.description) - units = machine.configuration_units - self.units_combo.blockSignals(True) - index = self.units_combo.findData(units) - if index >= 0: - self.units_combo.setCurrentIndex(index) - self.units_combo.blockSignals(False) - self.current_units = units - machine_type = machine.machine_type - self.type_combo.blockSignals(True) - index = self.type_combo.findData(machine_type) - if index >= 0: - self.type_combo.setCurrentIndex(index) - self.type_combo.blockSignals(False) - - # Post processor selection - if self.enable_machine_postprocessor: - post_processor = machine.postprocessor_file_name - index = self.post_processor_combo.findText(post_processor, QtCore.Qt.MatchFixedString) - if index >= 0: - self.post_processor_combo.setCurrentIndex(index) - else: - self.post_processor_combo.setCurrentIndex(0) - self.updatePostProcessorTooltip() - - # Post processor arguments - # self.post_processor_args_edit.setText(machine.postprocessor_args) - - # Get units for suffixes in populate - units = self.units_combo.itemData(self.units_combo.currentIndex()) - - # Update axes UI after loading machine data - self.update_axes() - - # Ensure at least 1 spindle - if len(machine.spindles) == 0: - machine.spindles.append( - Spindle( - name="Spindle 1", - id="spindle1", - max_power_kw=3.0, - max_rpm=24000, - min_rpm=6000, - tool_change="manual", - ) - ) - self.update_spindles() # Update spindles UI - self._update_spindle_button_state() - - # Post processor configuration - populate dynamically generated widgets - if self.enable_machine_postprocessor and hasattr(self, "post_widgets"): - # Update all post-processor widgets from machine object - self._populate_post_widgets_from_machine(machine) - - def to_machine(self) -> Machine: - """Convert UI state to Machine object. - - Returns: - Machine object with configuration from UI - """ - # The machine object is already up-to-date from signal handlers - # (both axes and spindles update in real-time) - return self.machine - - def to_data(self) -> Dict[str, Any]: - """Convert UI state to machine data dictionary. - - Returns: - Dict containing complete machine configuration in JSON format - """ - # Update machine from UI first (spindles need to be synchronized) - machine = self.to_machine() - - # Use Machine's to_dict method for serialization - return machine.to_dict() - - def toggle_editor_mode(self): - """Toggle between form view and text editor view.""" - if self.text_mode: - # Switching from text to form mode - try: - # Parse JSON from text editor - json_text = self.text_editor.toPlainText() - data = json.loads(json_text) - # Create Machine object from JSON and populate form - self.machine = Machine.from_dict(data) - self.populate_from_machine(self.machine) - # Show form, hide editor - self.tabs.show() - self.text_editor.hide() - self.toggle_button.setText(translate("CAM_MachineEditor", "Edit as Text")) - self.text_mode = False - except json.JSONDecodeError as e: - QtGui.QMessageBox.critical( - self, - translate("CAM_MachineEditor", "JSON Error"), - translate("CAM_MachineEditor", "Invalid JSON: {}").format(str(e)), - ) - except Exception as e: - QtGui.QMessageBox.critical( - self, - translate("CAM_MachineEditor", "Error"), - translate("CAM_MachineEditor", "Failed to parse data: {}").format(str(e)), - ) - else: - # Switching from form to text mode - try: - # self.machine is already up-to-date from signal handlers - # Just serialize it to JSON - data = self.machine.to_dict() - json_text = json.dumps(data, indent=4, sort_keys=True) - self.text_editor.setPlainText(json_text) - # Hide form, show editor - self.tabs.hide() - self.text_editor.show() - self.toggle_button.setText(translate("CAM_MachineEditor", "Edit as Form")) - self.text_mode = True - except Exception as e: - QtGui.QMessageBox.critical( - self, - translate("CAM_MachineEditor", "Error"), - translate("CAM_MachineEditor", "Failed to generate JSON: {}").format(str(e)), - ) - - def accept(self): - """Handle save and close action.""" - # Check for duplicate machine names when creating new machines - if self.text_mode: - try: - json_text = self.text_editor.toPlainText() - data = json.loads(json_text) - machine_name = data.get("machine", {}).get("name", "") - except json.JSONDecodeError: - machine_name = "" - else: - machine_name = self.name_edit.text().strip() - - # Check for duplicate machine names - if machine_name: - existing_machines = MachineFactory.list_configurations() - # Case-insensitive check to match get_machine behavior - machine_name_lower = machine_name.lower() - existing_names_lower = [name.lower() for name in existing_machines] - - # For existing machines, allow keeping the same name (case-insensitive) - current_name_allowed = False - if self.filename: - try: - current_machine = MachineFactory.load_configuration(self.filename) - current_name = current_machine.name.lower() - if machine_name_lower == current_name: - current_name_allowed = True - except Exception: - # Failed to load current machine configuration, assume name is not allowed - pass - - if machine_name_lower in existing_names_lower and not current_name_allowed: - QtGui.QMessageBox.warning( - self, - translate("CAM_MachineEditor", "Duplicate Machine Name"), - translate( - "CAM_MachineEditor", - "A machine with the name '{}' already exists. Please choose a different name.", - ).format(machine_name), - ) - return - - try: - if self.text_mode: - # If in text mode, parse JSON and update Machine - json_text = self.text_editor.toPlainText() - data = json.loads(json_text) - self.machine = Machine.from_dict(data) - - # self.machine is already up-to-date from signal handlers, just save it - if self.filename: - saved_path = MachineFactory.save_configuration(self.machine, self.filename) - else: - saved_path = MachineFactory.save_configuration(self.machine) - self.filename = saved_path.name - self.path = str(saved_path) # Keep for compatibility - - except json.JSONDecodeError as e: - QtGui.QMessageBox.critical( - self, - translate("CAM_MachineEditor", "JSON Error"), - translate("CAM_MachineEditor", "Invalid JSON: {}").format(str(e)), - ) - return - except Exception as e: - QtGui.QMessageBox.critical( - self, - translate("CAM_MachineEditor", "Error"), - translate("CAM_MachineEditor", "Failed to save: {}").format(str(e)), - ) - return - super().accept() diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index 252de63165..db3a72bb88 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -1,33 +1,31 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 Samuel Abels +# SPDX-FileNotice: Part of the FreeCAD project. -# *************************************************************************** -# * Copyright (c) 2025 Samuel Abels * -# * * -# * 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 * -# * * -# *************************************************************************** +################################################################################ +# # +# 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 pathlib import tempfile import FreeCAD import Path from PySide import QtGui, QtCore -from ....Machine.ui.editor import MachineEditorDialog -from ....Machine.models import MachineFactory +from Machine.ui.editor import MachineEditorDialog +from Machine.models.machine import MachineFactory translate = FreeCAD.Qt.translate diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index 7413336f50..97e1768a51 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -1,36 +1,31 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2025 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 # +# # +################################################################################ # flake8: noqa import -# *************************************************************************** -# * Copyright (c) 2016 sliptonic * -# * * -# * This program is free software; you can redistribute it and/or modify * -# * it under the terms of the GNU Lesser General Public License (LGPL) * -# * as published by the Free Software Foundation; either version 2 of * -# * the License, or (at your option) any later version. * -# * for detail see the LICENCE text file. * -# * * -# * This program is distributed in the hope that it will be useful, * -# * but WITHOUT ANY WARRANTY; without even the implied warranty of * -# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * -# * GNU Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** import TestApp from CAMTests.TestCAMSanity import TestCAMSanity from CAMTests.TestLinkingGenerator import TestGetLinkingMoves -from CAMTests.TestMachine import ( - TestMachineDataclass, - TestMachineFactory, - TestSpindle, -) +from CAMTests.TestMachine import TestMachineDataclass, TestMachineFactory, TestToolhead from CAMTests.TestPathProfile import TestPathProfile from CAMTests.TestPathAdaptive import TestPathAdaptive