From dc7991bd9db6b95a525c2a0563d675ab4b176dc0 Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Tue, 9 Dec 2025 13:07:18 -0500 Subject: [PATCH 1/8] CAM: Add Machine Library and Editor This PR introduces a machine object and a machine library, along with a new machine editor dialog for creating and editing *.fcm machine asset files. The editor is integrated into the CAM preferences panel, with new Python modules for the dialog and minimal model validation. Machine management (add, edit, delete) is now available in the CAM asset preferences panel. Key Features: - Machines now have a type and units property. The machine type can be used to distinguish between different classes of machines (e.g., mill, lathe, laser). - Machine units are stored internally and in the .fcm JSON file as metric. Note: This differs from how toolbits work (which store units in their native units) - Support for 2-5 axis machines. - Support for multiple spindles (up to 9) - Processor defaults - JSON Text Editor with basic validation and line numbers. --- src/Mod/CAM/CAMTests/TestMachine.py | 417 +++++ src/Mod/CAM/CAMTests/TestPathToolMachine.py | 208 --- src/Mod/CAM/CMakeLists.txt | 39 +- src/Mod/CAM/Path/Machine/models/__init__.py | 22 + src/Mod/CAM/Path/Machine/models/machine.py | 1474 ++++++++++++++++ .../CAM/Path/Machine/ui/editor/__init__.py | 26 + .../Path/Machine/ui/editor/machine_editor.py | 1478 +++++++++++++++++ src/Mod/CAM/Path/Post/Processor.py | 2 +- src/Mod/CAM/Path/Tool/__init__.py | 2 - .../CAM/Path/Tool/assets/ui/preferences.py | 105 +- src/Mod/CAM/Path/Tool/machine/__init__.py | 7 - .../CAM/Path/Tool/machine/models/__init__.py | 1 - .../CAM/Path/Tool/machine/models/machine.py | 435 ----- src/Mod/CAM/TestCAMApp.py | 6 +- 14 files changed, 3548 insertions(+), 674 deletions(-) create mode 100644 src/Mod/CAM/CAMTests/TestMachine.py delete mode 100644 src/Mod/CAM/CAMTests/TestPathToolMachine.py create mode 100644 src/Mod/CAM/Path/Machine/models/__init__.py create mode 100644 src/Mod/CAM/Path/Machine/models/machine.py create mode 100644 src/Mod/CAM/Path/Machine/ui/editor/__init__.py create mode 100644 src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py delete mode 100644 src/Mod/CAM/Path/Tool/machine/__init__.py delete mode 100644 src/Mod/CAM/Path/Tool/machine/models/__init__.py delete mode 100644 src/Mod/CAM/Path/Tool/machine/models/machine.py diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py new file mode 100644 index 0000000000..f4f179b92a --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -0,0 +1,417 @@ +# -*- 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. * +# * * +# *************************************************************************** + +import FreeCAD +import unittest +import tempfile +import pathlib +import json +import CAMTests.PathTestUtils as PathTestUtils +from Path.Machine.models.machine import ( + Machine, + Spindle, + OutputOptions, + GCodeBlocks, + ProcessingOptions, + MotionMode, + MachineFactory, +) + + +class TestMachineDataclass(PathTestUtils.PathTestBase): + """Test the unified Machine dataclass""" + + def setUp(self): + """Set up test fixtures""" + self.default_machine = Machine() + + def test_default_initialization(self): + """Test that Machine initializes with sensible defaults""" + machine = Machine() + + # Basic identification + self.assertEqual(machine.name, "Default Machine") + self.assertEqual(machine.manufacturer, "") + self.assertEqual(machine.description, "") + + # Machine type is derived from axes configuration + self.assertEqual(machine.machine_type, "custom") # No axes configured yet + + # Add axes and verify machine type updates + machine.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + machine.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + self.assertEqual(machine.machine_type, "custom") # Still missing Z axis + + machine.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + self.assertEqual(machine.machine_type, "xyz") # Now has XYZ axes + + # Add rotary axes and verify machine type updates + machine.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), -120, 120) + self.assertEqual(machine.machine_type, "xyza") + + 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) + self.assertIsNotNone(machine.freecad_version) + + # Post-processor defaults + self.assertIsInstance(machine.output, OutputOptions) + self.assertIsInstance(machine.blocks, GCodeBlocks) + self.assertIsInstance(machine.processing, ProcessingOptions) + + # Motion mode + self.assertEqual(machine.motion_mode, MotionMode.ABSOLUTE) + + def test_custom_initialization(self): + """Test Machine initialization with custom values and verify machine_type is derived""" + # Create a 5-axis machine (XYZAC) + machine = Machine( + name="Test Mill", + manufacturer="ACME Corp", + description="5-axis mill", + configuration_units="imperial", + ) + + # Add axes to make it a 5-axis machine + machine.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + machine.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + machine.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + machine.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), -120, 120) + machine.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), -360, 360) + + self.assertEqual(machine.name, "Test Mill") + self.assertEqual(machine.manufacturer, "ACME Corp") + self.assertEqual(machine.description, "5-axis mill") + self.assertEqual(machine.machine_type, "xyzac") + self.assertEqual(machine.configuration_units, "imperial") + + def test_configuration_units_property(self): + """Test configuration_units property returns correct values""" + metric_machine = Machine(configuration_units="metric") + self.assertEqual(metric_machine.configuration_units, "metric") + + imperial_machine = Machine(configuration_units="imperial") + self.assertEqual(imperial_machine.configuration_units, "imperial") + + +class TestOutputOptions(PathTestUtils.PathTestBase): + """Test OutputOptions dataclass""" + + def test_default_initialization(self): + """Test OutputOptions initialization with defaults""" + opts = OutputOptions() + + # Default values + self.assertTrue(opts.comments) + self.assertTrue(opts.blank_lines) + self.assertTrue(opts.header) + 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) + + def test_custom_initialization(self): + """Test OutputOptions initialization with custom values""" + opts = OutputOptions( + comments=False, + blank_lines=False, + header=False, + line_numbers=True, + bcnc_blocks=True, + path_labels=True, + machine_name=True, + tool_change=False, + doubles=False, + adaptive=True, + ) + + # Verify custom values + self.assertFalse(opts.comments) + self.assertFalse(opts.blank_lines) + self.assertFalse(opts.header) + 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) + + def test_equality(self): + """Test OutputOptions equality comparison""" + opts1 = OutputOptions() + opts2 = OutputOptions() + self.assertEqual(opts1, opts2) + + opts2.comments = False + self.assertNotEqual(opts1, opts2) + + +class TestSpindle(PathTestUtils.PathTestBase): + """Test Spindle dataclass""" + + def test_spindle_initialization(self): + """Test Spindle initialization with defaults""" + spindle = Spindle( + name="Main Spindle", + 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)) + + 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): + """Test to_dict and from_dict""" + spindle = Spindle( + name="Test Spindle", + id="spindle-001", + max_power_kw=3.0, + max_rpm=18000, + min_rpm=500, + tool_change="manual", + tool_axis=FreeCAD.Vector(0, 1, 0), + ) + + data = spindle.to_dict() + self.assertEqual(data["name"], "Test Spindle") + self.assertEqual(data["id"], "spindle-001") + self.assertEqual(data["max_power_kw"], 3.0) + self.assertEqual(data["tool_axis"], [0, 1, 0]) + + 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) + + +class TestMachineFactory(PathTestUtils.PathTestBase): + """Test MachineFactory class for loading/saving configurations""" + + def setUp(self): + """Set up test fixtures with temporary directory""" + self.temp_dir = tempfile.mkdtemp() + self.temp_path = pathlib.Path(self.temp_dir) + MachineFactory.set_config_directory(self.temp_dir) + + def tearDown(self): + """Clean up temporary directory""" + import shutil + + if self.temp_path.exists(): + shutil.rmtree(self.temp_path) + + def test_set_and_get_config_directory(self): + """Test setting and getting configuration directory""" + test_dir = self.temp_path / "test_configs" + MachineFactory.set_config_directory(test_dir) + + config_dir = MachineFactory.get_config_directory() + self.assertEqual(config_dir, test_dir) + self.assertTrue(config_dir.exists()) + + def test_save_and_load_configuration(self): + """Test saving and loading a machine configuration""" + # Create a test machine + machine = Machine( + name="Test Machine", + manufacturer="Test Corp", + description="Test description", + configuration_units="metric", + ) + + # Add axes to make it an XYZ machine + machine.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + 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", + max_power_kw=5.5, + max_rpm=24000, + min_rpm=1000, + ) + machine.spindles.append(spindle) + + # Save configuration + filepath = MachineFactory.save_configuration(machine, "test_machine.fcm") + self.assertTrue(filepath.exists()) + + # Load configuration + loaded_machine = MachineFactory.load_configuration("test_machine.fcm") + + # Verify loaded data + self.assertEqual(loaded_machine.name, "Test Machine") + self.assertEqual(loaded_machine.manufacturer, "Test Corp") + 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") + + def test_save_configuration_auto_filename(self): + """Test saving with automatic filename generation""" + machine = Machine(name="My Test Machine") + + filepath = MachineFactory.save_configuration(machine) + + # Should create file with sanitized name + self.assertTrue(filepath.exists()) + self.assertEqual(filepath.name, "My_Test_Machine.fcm") + + def test_load_nonexistent_file(self): + """Test loading a file that doesn't exist""" + with self.assertRaises(FileNotFoundError): + MachineFactory.load_configuration("nonexistent.fcm") + + def test_create_default_machine_data(self): + """Test creating default machine data dictionary""" + data = MachineFactory.create_default_machine_data() + + self.assertIsInstance(data, dict) + # The data structure has nested "machine" key + self.assertIn("machine", data) + self.assertEqual(data["machine"]["name"], "New Machine") + self.assertIn("spindles", data["machine"]) + + def test_list_configuration_files(self): + """Test listing available configuration files""" + # Create some test configurations + machine1 = Machine(name="Machine 1") + machine2 = Machine(name="Machine 2") + + MachineFactory.save_configuration(machine1, "machine1.fcm") + MachineFactory.save_configuration(machine2, "machine2.fcm") + + # List configurations + configs = MachineFactory.list_configuration_files() + + # Should include plus our two machines + self.assertGreaterEqual(len(configs), 3) + self.assertEqual(configs[0][0], "") + + # Check that our machines are in the list (by display name, not filename) + names = [name for name, path in configs] + self.assertIn("Machine 1", names) + self.assertIn("Machine 2", names) + + def test_list_configurations(self): + """Test listing configuration names""" + machine = Machine(name="Test Machine") + MachineFactory.save_configuration(machine, "test.fcm") + + configs = MachineFactory.list_configurations() + + self.assertIsInstance(configs, list) + self.assertIn("", configs) + # Returns display name from JSON, not filename + self.assertIn("Test Machine", configs) + + def test_delete_configuration(self): + """Test deleting a configuration file""" + machine = Machine(name="To Delete") + filepath = MachineFactory.save_configuration(machine, "delete_me.fcm") + + self.assertTrue(filepath.exists()) + + # Delete the configuration + result = MachineFactory.delete_configuration("delete_me.fcm") + self.assertTrue(result) + self.assertFalse(filepath.exists()) + + # Try deleting again (should return False) + result = MachineFactory.delete_configuration("delete_me.fcm") + self.assertFalse(result) + + def test_get_builtin_config(self): + """Test getting built-in machine configurations""" + # Test each built-in config type + config_types = ["XYZ", "XYZAC", "XYZBC", "XYZA", "XYZB"] + + for config_type in config_types: + machine = MachineFactory.get_builtin_config(config_type) + self.assertIsInstance(machine, Machine) + self.assertIsNotNone(machine.name) + + def test_get_builtin_config_invalid_type(self): + """Test getting built-in config with invalid type""" + with self.assertRaises(ValueError): + MachineFactory.get_builtin_config("INVALID") + + def test_serialization_roundtrip(self): + """Test full serialization roundtrip with complex machine""" + # Create a complex machine with all components + machine = Machine( + name="Complex Machine", + manufacturer="Test Mfg", + description="Full featured machine", + machine_type="xyzac", + configuration_units="metric", + ) + + # Add spindle + machine.spindles.append( + Spindle( + name="Main", + max_power_kw=7.5, + max_rpm=30000, + ) + ) + + # Configure post-processor settings + machine.output.comments = False + machine.output.axis_precision = 4 + machine.output.line_increment = 5 + + # line_increment is set to default 10 in OutputOptions + + # Save and load + filepath = MachineFactory.save_configuration(machine, "complex.fcm") + loaded = MachineFactory.load_configuration("complex.fcm") + + # 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.comments) + self.assertEqual(loaded.output.axis_precision, 4) + self.assertEqual(loaded.output.line_increment, 5) diff --git a/src/Mod/CAM/CAMTests/TestPathToolMachine.py b/src/Mod/CAM/CAMTests/TestPathToolMachine.py deleted file mode 100644 index a71442a753..0000000000 --- a/src/Mod/CAM/CAMTests/TestPathToolMachine.py +++ /dev/null @@ -1,208 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -import unittest -import FreeCAD -from Path.Tool.machine.models.machine import Machine - - -class TestPathToolMachine(unittest.TestCase): - def setUp(self): - self.default_machine = Machine() - - def test_initialization_defaults(self): - self.assertEqual(self.default_machine.label, "Machine") - self.assertAlmostEqual(self.default_machine.max_power.getValueAs("W").Value, 2000) - self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 3000) - self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 60000) - self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 1) - self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 2000) - expected_peak_torque_rpm = 60000 / 3 - self.assertAlmostEqual( - self.default_machine.get_peak_torque_rpm_value(), - expected_peak_torque_rpm, - ) - expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm - self.assertAlmostEqual( - self.default_machine.max_torque.getValueAs("Nm").Value, - expected_max_torque_nm, - ) - self.assertIsNotNone(self.default_machine.id) - - def test_initialization_custom_values(self): - custom_machine = Machine( - label="Custom Machine", - max_power=5, - min_rpm=1000, - max_rpm=20000, - max_torque=50, - peak_torque_rpm=15000, - min_feed=10, - max_feed=5000, - id="custom-id", - ) - self.assertEqual(custom_machine.label, "Custom Machine") - self.assertAlmostEqual(custom_machine.max_power.getValueAs("W").Value, 5000) - self.assertAlmostEqual(custom_machine.get_min_rpm_value(), 1000) - self.assertAlmostEqual(custom_machine.get_max_rpm_value(), 20000) - self.assertAlmostEqual(custom_machine.max_torque.getValueAs("Nm").Value, 50) - self.assertAlmostEqual(custom_machine.get_peak_torque_rpm_value(), 15000) - self.assertAlmostEqual(custom_machine.min_feed.getValueAs("mm/min").Value, 10) - self.assertAlmostEqual(custom_machine.max_feed.getValueAs("mm/min").Value, 5000) - self.assertEqual(custom_machine.id, "custom-id") - - def test_initialization_custom_torque_quantity(self): - custom_torque_machine = Machine(max_torque=FreeCAD.Units.Quantity(100, "Nm")) - self.assertAlmostEqual(custom_torque_machine.max_torque.getValueAs("Nm").Value, 100) - - def test_validate_valid(self): - try: - self.default_machine.validate() - except AttributeError as e: - self.fail(f"Validation failed unexpectedly: {e}") - - def test_validate_missing_label(self): - self.default_machine.label = "" - with self.assertRaisesRegex(AttributeError, "Machine name is required"): - self.default_machine.validate() - - def test_validate_peak_torque_rpm_greater_than_max_rpm(self): - self.default_machine.set_peak_torque_rpm(70000) - with self.assertRaisesRegex(AttributeError, "Peak Torque RPM.*must be less than max RPM"): - self.default_machine.validate() - - def test_validate_max_rpm_less_than_min_rpm(self): - self.default_machine = Machine() - self.default_machine.set_min_rpm(4000) # min_rpm = 4000 RPM - self.default_machine.set_peak_torque_rpm(1000) # peak_torque_rpm = 1000 RPM - self.default_machine._max_rpm = 2000 / 60.0 # max_rpm = 2000 RPM (33.33 1/s) - self.assertLess( - self.default_machine.get_max_rpm_value(), - self.default_machine.get_min_rpm_value(), - ) - with self.assertRaisesRegex(AttributeError, "Max RPM must be larger than min RPM"): - self.default_machine.validate() - - def test_validate_max_feed_less_than_min_feed(self): - self.default_machine.set_min_feed(1000) - self.default_machine._max_feed = 500 - with self.assertRaisesRegex(AttributeError, "Max feed must be larger than min feed"): - self.default_machine.validate() - - def test_get_torque_at_rpm(self): - torque_below_peak = self.default_machine.get_torque_at_rpm(10000) - expected_peak_torque_rpm = 60000 / 3 - expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm - expected_torque_below_peak = expected_max_torque_nm / expected_peak_torque_rpm * 10000 - self.assertAlmostEqual(torque_below_peak, expected_torque_below_peak) - - torque_at_peak = self.default_machine.get_torque_at_rpm( - self.default_machine.get_peak_torque_rpm_value() - ) - self.assertAlmostEqual( - torque_at_peak, - self.default_machine.max_torque.getValueAs("Nm").Value, - ) - - torque_above_peak = self.default_machine.get_torque_at_rpm(50000) - expected_torque_above_peak = 2000 * 9.5488 / 50000 - self.assertAlmostEqual(torque_above_peak, expected_torque_above_peak) - - def test_set_label(self): - self.default_machine.label = "New Label" - self.assertEqual(self.default_machine.label, "New Label") - - def test_set_max_power(self): - self.default_machine = Machine() - self.default_machine.set_max_power(5, "hp") - self.assertAlmostEqual( - self.default_machine.max_power.getValueAs("W").Value, - 5 * 745.7, - places=4, - ) - with self.assertRaisesRegex(AttributeError, "Max power must be positive"): - self.default_machine.set_max_power(0) - - def test_set_min_rpm(self): - self.default_machine = Machine() - self.default_machine.set_min_rpm(5000) - self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 5000) - with self.assertRaisesRegex(AttributeError, "Min RPM cannot be negative"): - self.default_machine.set_min_rpm(-100) - self.default_machine = Machine() - self.default_machine.set_min_rpm(70000) - self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 70000) - self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 70001) - - def test_set_max_rpm(self): - self.default_machine = Machine() - self.default_machine.set_max_rpm(50000) - self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 50000) - with self.assertRaisesRegex(AttributeError, "Max RPM must be positive"): - self.default_machine.set_max_rpm(0) - self.default_machine = Machine() - self.default_machine.set_max_rpm(2000) - self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 2000) - self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 1999) - self.default_machine = Machine() - self.default_machine.set_max_rpm(0.5) - self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 0.5) - self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 0) - - def test_set_min_feed(self): - self.default_machine = Machine() - self.default_machine.set_min_feed(500, "inch/min") - self.assertAlmostEqual( - self.default_machine.min_feed.getValueAs("mm/min").Value, - 500 * 25.4, - places=4, - ) - with self.assertRaisesRegex(AttributeError, "Min feed cannot be negative"): - self.default_machine.set_min_feed(-10) - self.default_machine = Machine() - self.default_machine.set_min_feed(3000) - self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 3000) - self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3001) - - def test_set_max_feed(self): - self.default_machine = Machine() - self.default_machine.set_max_feed(3000) - self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3000) - with self.assertRaisesRegex(AttributeError, "Max feed must be positive"): - self.default_machine.set_max_feed(0) - self.default_machine = Machine() - self.default_machine.set_min_feed(600) - self.default_machine.set_max_feed(500) - self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 500) - self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 499) - self.default_machine = Machine() - self.default_machine.set_max_feed(0.5) - self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 0.5) - self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 0) - - def test_set_peak_torque_rpm(self): - self.default_machine = Machine() - self.default_machine.set_peak_torque_rpm(40000) - self.assertAlmostEqual(self.default_machine.get_peak_torque_rpm_value(), 40000) - with self.assertRaisesRegex(AttributeError, "Peak torque RPM cannot be negative"): - self.default_machine.set_peak_torque_rpm(-100) - - def test_set_max_torque(self): - self.default_machine = Machine() - self.default_machine.set_max_torque(200, "in-lbf") - self.assertAlmostEqual( - self.default_machine.max_torque.getValueAs("Nm").Value, - 200 * 0.112985, - places=4, - ) - with self.assertRaisesRegex(AttributeError, "Max torque must be positive"): - self.default_machine.set_max_torque(0) - - def test_dump(self): - try: - self.default_machine.dump(False) - except Exception as e: - self.fail(f"dump() method failed unexpectedly: {e}") - - -if __name__ == "__main__": - unittest.main() diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index f44ce6771b..66e38be32c 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -170,6 +170,16 @@ SET(PathPythonToolsGui_SRCS Path/Tool/Gui/Controller.py ) +SET(PathPythonMachineUiEditor_SRCS + Path/Machine/ui/editor/machine_editor.py + Path/Machine/ui/editor/__init__.py +) + +SET(PathPythonMachineModels_SRCS + Path/Machine/models/__init__.py + Path/Machine/models/machine.py +) + SET(PathPythonToolsToolBit_SRCS Path/Tool/toolbit/__init__.py Path/Tool/toolbit/util.py @@ -248,15 +258,6 @@ SET(PathPythonToolsLibraryUi_SRCS Path/Tool/library/ui/properties.py ) -SET(PathPythonToolsMachine_SRCS - Path/Tool/machine/__init__.py -) - -SET(PathPythonToolsMachineModels_SRCS - Path/Tool/machine/models/__init__.py - Path/Tool/machine/models/machine.py -) - SET(PathPythonToolsShape_SRCS Path/Tool/shape/__init__.py Path/Tool/shape/util.py @@ -508,6 +509,7 @@ SET(Tests_SRCS CAMTests/TestGrblLegacyPost.py CAMTests/TestLinuxCNCPost.py CAMTests/TestLinkingGenerator.py + CAMTests/TestMachine.py CAMTests/TestMach3Mach4Post.py CAMTests/TestMach3Mach4LegacyPost.py CAMTests/TestMassoG3Post.py @@ -559,7 +561,6 @@ SET(Tests_SRCS CAMTests/TestPathToolShapeIcon.py CAMTests/TestPathToolLibrary.py CAMTests/TestPathToolLibrarySerializer.py - CAMTests/TestPathToolMachine.py CAMTests/TestPathToolController.py CAMTests/TestPathUtil.py CAMTests/TestPathVcarve.py @@ -639,8 +640,8 @@ SET(all_files ${PathPythonToolsLibraryModels_SRCS} ${PathPythonToolsLibrarySerializers_SRCS} ${PathPythonToolsLibraryUi_SRCS} - ${PathPythonToolsMachine_SRCS} - ${PathPythonToolsMachineModels_SRCS} + ${PathPythonMachineModels_SRCS} + ${PathPythonMachineUiEditor_SRCS} ${PathPythonGui_SRCS} ${Tools_SRCS} ${Tools_Bit_SRCS} @@ -916,6 +917,20 @@ INSTALL( Mod/CAM/Path/Tool/machine ) +INSTALL( + FILES + ${PathPythonMachineModels_SRCS} + DESTINATION + Mod/CAM/Path/Machine/models +) + +INSTALL( + FILES + ${PathPythonToolsMachineUiEditor_SRCS} + DESTINATION + Mod/CAM/Path/Machine/ui/editor +) + INSTALL( FILES ${PathPythonToolsMachineModels_SRCS} diff --git a/src/Mod/CAM/Path/Machine/models/__init__.py b/src/Mod/CAM/Path/Machine/models/__init__.py new file mode 100644 index 0000000000..0e2dc0e8a2 --- /dev/null +++ b/src/Mod/CAM/Path/Machine/models/__init__.py @@ -0,0 +1,22 @@ +# 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 * +# * . * +# * * +# *************************************************************************** diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py new file mode 100644 index 0000000000..0baed3fc83 --- /dev/null +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -0,0 +1,1474 @@ +# 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 MotionMode(Enum): + """Motion mode for machine movements.""" + + ABSOLUTE = "G90" + RELATIVE = "G91" + + +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 + comments: bool = True + blank_lines: bool = True + header: bool = True + line_numbers: bool = False + path_labels: bool = False + machine_name: bool = False + doubles: bool = False + + # Line formatting options + command_space: str = " " + comment_symbol: str = "(" + line_increment: int = 10 + line_number_start: int = 100 + end_of_line_chars: str = "\n" + + # Numeric precision settings + axis_precision: int = 3 # Decimal places for axis coordinates + feed_precision: int = 3 # Decimal places for feed rates + spindle_decimals: int = 0 # Decimal places for spindle speed + + +@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 # do we need this? + + # Fixture/WCS change lifecycle + pre_fixture_change: str = "" + post_fixture_change: str = "" + + # Rotary axis lifecycle + pre_rotary_move: str = "" + post_rotary_move: str = "" + + # Spindle lifecycle + # pre_spindle_change: str = "" # Futures + # post_spindle_change: str = "" # Futures + + # Miscellaneous + # finish_label: str = "Finish" # do we need this? Looks like bullshit + + post_job: str = "" + postamble: str = "" # Typically inserted at end of job + + +@dataclass +class ProcessingOptions: + """Processing and transformation options.""" + + # connversion and expansion of Path Objects. Does not affect final gcode generation + + modal: bool = False # Suppress repeated commands + translate_drill_cycles: bool = False + split_arcs: bool = False + show_editor: bool = True + list_tools_in_preamble: bool = False + show_machine_units: bool = True + show_operation_labels: bool = True + tool_before_change: bool = False # Output T before M6 (e.g., T1 M6 instead of M6 T1) + + # Lists of commands + drill_cycles_to_translate: List[str] = field( + default_factory=lambda: ["G73", "G81", "G82", "G83"] + ) + suppress_commands: List[str] = field(default_factory=list) + + # Output processing options + tool_change: bool = True # Enable tool change commands + adaptive: bool = False # Enable adaptive toolpath optimization + + # Numeric settings + chipbreaking_amount: float = 0.25 # mm + spindle_wait: float = 0.0 # seconds + 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 after initialization""" + self.direction_vector = self.direction_vector.normalize() + + 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 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() + + 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: + def __init__( + self, name: str = "Default Machine", configuration_units: str = "metric", **kwargs + ): + # Set default values for all fields + self.name = name + self.manufacturer = kwargs.get("manufacturer", "") + self.description = kwargs.get("description", "") + self.linear_axes = {} + self.rotary_axes = {} + self.spindles = [] + self.reference_system = kwargs.get( + "reference_system", + { + "X": FreeCAD.Vector(1, 0, 0), + "Y": FreeCAD.Vector(0, 1, 0), + "Z": FreeCAD.Vector(0, 0, 1), + }, + ) + self.tool_axis = kwargs.get("tool_axis", FreeCAD.Vector(0, 0, -1)) + self.primary_rotary_axis = kwargs.get("primary_rotary_axis") + self.secondary_rotary_axis = kwargs.get("secondary_rotary_axis") + self.compound_moves = kwargs.get("compound_moves", True) + self.prefer_positive_rotation = kwargs.get("prefer_positive_rotation", True) + self._configuration_units = configuration_units + self.version = kwargs.get("version", 1) + self.output = kwargs.get("output", OutputOptions()) + # Handle legacy precision settings if provided + precision = kwargs.get("precision") + if precision is not None: + self.output.axis_precision = precision.axis_precision + self.output.feed_precision = precision.feed_precision + self.output.spindle_decimals = precision.spindle_decimals + self.blocks = kwargs.get("blocks", GCodeBlocks()) + self.processing = kwargs.get("processing", ProcessingOptions()) + self.postprocessor_file_name = kwargs.get("postprocessor_file_name", "") + self.postprocessor_args = kwargs.get("postprocessor_args", "") + self.motion_mode = kwargs.get("motion_mode", MotionMode.ABSOLUTE) + self.parameter_functions = kwargs.get("parameter_functions", {}) + self.parameter_order = kwargs.get( + "parameter_order", + [ + "D", + "H", + "L", + "X", + "Y", + "Z", + "A", + "B", + "C", + "U", + "V", + "W", + "I", + "J", + "K", + "R", + "P", + "E", + "Q", + "F", + "S", + "T", + ], + ) + + # Initialize computed fields + self.freecad_version = ".".join(FreeCAD.Version()[0:3]) + + # Handle machine_type if provided (for backward compatibility) + if "machine_type" in kwargs and kwargs["machine_type"]: + self._initialize_from_machine_type(kwargs["machine_type"]) + + """ + 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 = field( + default="metric", init=False + ) # 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 = "" + + # Motion mode + motion_mode: MotionMode = MotionMode.ABSOLUTE + + # Dynamic state (for runtime) + parameter_functions: Dict[str, Callable] = field(default_factory=dict) + parameter_order: List[str] = field( + default_factory=lambda: [ + "D", + "H", + "L", + "X", + "Y", + "Z", + "A", + "B", + "C", + "U", + "V", + "W", + "I", + "J", + "K", + "R", + "P", + "E", + "Q", + "F", + "S", + "T", + ] + ) + + def __post_init__(self): + """Initialize computed fields""" + self.freecad_version = ".".join(FreeCAD.Version()[0:3]) + # Initialize configuration_units if not set + if not hasattr(self, "_configuration_units"): + self._configuration_units = "metric" + + # ======================================================================== + # PROPERTIES - Bridge between physical machine and post-processor + # ======================================================================== + + @property + def configuration_units(self) -> str: + """Get machine configuration units ("metric" or "imperial")""" + if not hasattr(self, "_configuration_units"): + self._configuration_units = "metric" + return self._configuration_units + + @configuration_units.setter + def configuration_units(self, value: str) -> None: + """Set machine configuration units ("metric" or "imperial")""" + if not value: # Skip empty strings + return + if value not in ["metric", "imperial"]: + raise ValueError("configuration_units must be 'metric' or 'imperial'") + self._configuration_units = value + + @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, + # "type": self.machine_type, + "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, + "motion_mode": self.motion_mode.value, + } + + # Output options + data["output"] = { + "comments": self.output.comments, + "blank_lines": self.output.blank_lines, + "header": self.output.header, + "line_numbers": self.output.line_numbers, + "path_labels": self.output.path_labels, + "machine_name": self.output.machine_name, + "doubles": self.output.doubles, + "output_units": self.output.output_units.value, + "axis_precision": self.output.axis_precision, + "feed_precision": self.output.feed_precision, + "spindle_decimals": self.output.spindle_decimals, + "command_space": self.output.command_space, + "comment_symbol": self.output.comment_symbol, + "line_increment": self.output.line_increment, + "line_number_start": self.output.line_number_start, + "end_of_line_chars": self.output.end_of_line_chars, + } + + # 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"] = { + "modal": self.processing.modal, + "translate_drill_cycles": self.processing.translate_drill_cycles, + "split_arcs": self.processing.split_arcs, + "show_editor": self.processing.show_editor, + "list_tools_in_preamble": self.processing.list_tools_in_preamble, + "show_machine_units": self.processing.show_machine_units, + "show_operation_labels": self.processing.show_operation_labels, + "tool_before_change": self.processing.tool_before_change, + "drill_cycles_to_translate": self.processing.drill_cycles_to_translate, + "suppress_commands": self.processing.suppress_commands, + "tool_change": self.processing.tool_change, + "adaptive": self.processing.adaptive, + "chipbreaking_amount": self.processing.chipbreaking_amount, + "spindle_wait": self.processing.spindle_wait, + } + 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.name = self.name or "3-Axis XYZ Configuration" + 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)), + } + + @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_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 _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", ""), + # machine_type=machine_data.get("type"), + **{ + k: v + for k, v in machine_data.items() + if k not in ["name", "units", "manufacturer", "description", "type"] + }, + ) + + # 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, 1], [0, 0, 0]]) + rotation_vec = FreeCAD.Vector(joint[0][0], joint[0][1], joint[0][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", "") + config.motion_mode = MotionMode(post_data.get("motion_mode", "G90")) + + # Load output options + output_data = data.get("output", {}) + if output_data: + config.output.comments = output_data.get("comments", True) + config.output.blank_lines = output_data.get("blank_lines", True) + config.output.header = output_data.get("header", True) + config.output.line_numbers = output_data.get("line_numbers", False) + config.output.path_labels = output_data.get("path_labels", False) + config.output.machine_name = output_data.get("machine_name", False) + config.output.doubles = output_data.get("doubles", False) + + # 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 + if "tool_change" in output_data: + config.processing.tool_change = output_data["tool_change"] + if "adaptive" in output_data: + config.processing.adaptive = output_data["adaptive"] + + # Set precision values from output_data + if "axis_precision" in output_data: + config.output.axis_precision = output_data["axis_precision"] + if "feed_precision" in output_data: + config.output.feed_precision = output_data["feed_precision"] + if "spindle_decimals" in output_data: + config.output.spindle_decimals = output_data["spindle_decimals"] + + # Load formatting fields from output_data + config.output.command_space = output_data.get("command_space", " ") + config.output.comment_symbol = output_data.get("comment_symbol", "(") + config.output.line_increment = output_data.get("line_increment", 10) + config.output.line_number_start = output_data.get("line_number_start", 100) + config.output.end_of_line_chars = output_data.get("end_of_line_chars", "\n") + + # Load processing options + processing_data = data.get("processing", {}) + if processing_data: + config.processing.modal = processing_data.get("modal", False) + config.processing.translate_drill_cycles = processing_data.get( + "translate_drill_cycles", False + ) + config.processing.split_arcs = processing_data.get("split_arcs", False) + config.processing.show_editor = processing_data.get("show_editor", True) + config.processing.list_tools_in_preamble = processing_data.get( + "list_tools_in_preamble", False + ) + config.processing.show_machine_units = processing_data.get("show_machine_units", True) + config.processing.show_operation_labels = processing_data.get( + "show_operation_labels", True + ) + config.processing.tool_before_change = processing_data.get("tool_before_change", False) + config.processing.drill_cycles_to_translate = processing_data.get( + "drill_cycles_to_translate", ["G73", "G81", "G82", "G83"] + ) + config.processing.suppress_commands = processing_data.get("suppress_commands", []) + config.processing.tool_change = processing_data.get("tool_change", True) + config.processing.adaptive = processing_data.get("adaptive", False) + config.processing.chipbreaking_amount = processing_data.get("chipbreaking_amount", 0.25) + config.processing.spindle_wait = processing_data.get("spindle_wait", 0.0) + return_to = processing_data.get("return_to", None) + config.processing.return_to = tuple(return_to) if return_to is not None else None + + # 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: + 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 new file mode 100644 index 0000000000..5f85f7f775 --- /dev/null +++ b/src/Mod/CAM/Path/Machine/ui/editor/__init__.py @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 0000000000..2dec6e594a --- /dev/null +++ b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py @@ -0,0 +1,1478 @@ +# 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 json +from typing import Optional, Dict, Any, get_type_hints, get_origin, get_args +from dataclasses import fields, is_dataclass +from enum import Enum +from ...models.machine import * +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"), + "tool_change": translate("CAM_MachineEditor", "Tool Change Commands"), + "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.setWindowTitle(translate("CAM_MachineEditor", "Machine Editor")) + self.setMinimumSize(700, 900) + self.resize(700, 900) + + self.current_units = "metric" + + # Initialize machine object first (needed by setup_post_tab) + 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") + + 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() + + # Post tab + self.post_tab = QtGui.QWidget() + self.tabs.addTab(self.post_tab, translate("CAM_MachineEditor", "Post Processor")) + self.setup_post_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.post_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 + + 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 _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) + + self.spindle_count_combo = QtGui.QComboBox() + for i in range(1, 10): # 1 to 9 spindles + self.spindle_count_combo.addItem(str(i), i) + self.spindle_count_combo.currentIndexChanged.connect(self.update_spindles) + layout.addRow( + translate("CAM_MachineEditor", "Number of Spindles:"), self.spindle_count_combo + ) + + # 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() + 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 + for i in reversed(range(self.axes_layout.count())): + widget = self.axes_layout.itemAt(i).widget() + if widget: + widget.setParent(None) + + # 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 + + axes_form = QtGui.QFormLayout() + + # 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) + + rotary_layout.addRow(f"{axis}:", 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. + """ + # Update machine spindles with current edits before rebuilding UI + if hasattr(self, "spindle_edits") and self.machine: + # Resize spindles list to match current edits + while len(self.machine.spindles) < len(self.spindle_edits): + self.machine.spindles.append(Spindle(name="")) + while len(self.machine.spindles) > len(self.spindle_edits): + self.machine.spindles.pop() + + # Update each spindle from UI + for i, edits in enumerate(self.spindle_edits): + spindle = self.machine.spindles[i] + spindle.name = edits["name"].text() + spindle.id = edits["id"].text() + spindle.max_power_kw = edits["max_power_kw"].value() + spindle.max_rpm = edits["max_rpm"].value() + spindle.min_rpm = edits["min_rpm"].value() + spindle.tool_change = edits["tool_change"].itemData( + edits["tool_change"].currentIndex() + ) + + # Clear existing spindle tabs + self.spindles_tabs.clear() + self.spindle_edits = [] + count = self.spindle_count_combo.itemData(self.spindle_count_combo.currentIndex()) + + # Ensure machine has enough spindles + 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_post_tab(self): + """Set up the post processor configuration tab dynamically from Machine dataclass.""" + # 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.post_tab) + main_layout.addWidget(scroll) + + # Store widgets for later population + self.post_widgets = {} + + # === Post Processor Selection (special handling for combo box) === + pp_group = QtGui.QGroupBox("Post Processor Selection") + pp_layout = QtGui.QFormLayout(pp_group) + + 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) + pp_layout.addRow("Post Processor:", self.post_processor_combo) + self.post_widgets["postprocessor_file_name"] = 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) + pp_layout.addRow("Arguments:", self.post_processor_args_edit) + self.post_widgets["postprocessor_args"] = self.post_processor_args_edit + + layout.addWidget(pp_group) + + # === Dynamically generate groups for nested dataclasses === + if self.machine: + # Output Options + 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") + + # # Precision Settings + # precision_group, precision_widgets = DataclassGUIGenerator.create_group_for_dataclass( + # self.machine.precision, "Precision Settings" + # ) + # layout.addWidget(precision_group) + # self._connect_widgets_to_machine(precision_widgets, "precision") + + # Line Formatting + # formatting_group, formatting_widgets = DataclassGUIGenerator.create_group_for_dataclass( + # self.machine.formatting, "Line Formatting" + # ) + # layout.addWidget(formatting_group) + # self._connect_widgets_to_machine(formatting_widgets, "formatting") + + # G-Code Blocks + 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") + + # Processing Options + 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() + + # Cache for post processors + self.processor = {} + + 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), + # ("precision", machine.precision), + # ("formatting", machine.formatting), + ("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 + index = self.units_combo.findData(units) + if index >= 0: + self.units_combo.setCurrentIndex(index) + self.current_units = units + machine_type = machine.machine_type + index = self.type_combo.findData(machine_type) + if index >= 0: + self.type_combo.setCurrentIndex(index) + + # Get units for suffixes in populate + 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" + + # Update axes UI after loading machine data + self.update_axes() + + spindles = machine.spindles + spindle_count = len(spindles) + if spindle_count == 0: + spindle_count = 1 # Default to 1 if none + spindle_count = min(spindle_count, 9) # Cap at 9 + self.spindle_count_combo.setCurrentText(str(spindle_count)) + self.update_spindles() # Update spindles after setting count (will populate from machine.spindles) + + # Post processor configuration - populate dynamically generated widgets + if self.enable_machine_postprocessor and hasattr(self, "post_widgets"): + # Post processor selection + 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) + + # Post processor arguments + self.post_processor_args_edit.setText(machine.postprocessor_args) + self.updatePostProcessorTooltip() + + # 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: + 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/Post/Processor.py b/src/Mod/CAM/Path/Post/Processor.py index 4518f1d55a..5465b9f90c 100644 --- a/src/Mod/CAM/Path/Post/Processor.py +++ b/src/Mod/CAM/Path/Post/Processor.py @@ -146,7 +146,7 @@ class PostProcessor: else: # get all operations from 'Operations' group self._job = job - self._operations = getattr(job.Operations, "Group", []) + self._operations = getattr(job.Operations, "Group", []) if job is not None else [] @classmethod def exists(cls, processor): diff --git a/src/Mod/CAM/Path/Tool/__init__.py b/src/Mod/CAM/Path/Tool/__init__.py index 43cfa3adb7..449d0d8136 100644 --- a/src/Mod/CAM/Path/Tool/__init__.py +++ b/src/Mod/CAM/Path/Tool/__init__.py @@ -10,7 +10,6 @@ from .library.serializers import FCTLSerializer from .toolbit import ToolBit from .toolbit.serializers import FCTBSerializer from .shape import ToolBitShape, ToolBitShapePngIcon, ToolBitShapeSvgIcon -from .machine import Machine # Register asset classes and serializers. cam_assets.register_asset(Library, FCTLSerializer) @@ -18,7 +17,6 @@ cam_assets.register_asset(ToolBit, FCTBSerializer) cam_assets.register_asset(ToolBitShape, DummyAssetSerializer) cam_assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer) cam_assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer) -cam_assets.register_asset(Machine, DummyAssetSerializer) # For backward compatibility with files saved before the toolbit rename # This makes the Path.Tool.toolbit.base module available as Path.Tool.Bit. diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index 27552ea76f..f51bb90fa3 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -25,7 +25,10 @@ import pathlib import tempfile import FreeCAD import Path +import json from PySide import QtGui, QtCore +from ....Machine.ui.editor import MachineEditorDialog +from ....Machine.models.machine import MachineFactory translate = FreeCAD.Qt.translate @@ -81,18 +84,48 @@ class AssetPreferencesPage: edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter) edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter) edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop) - edit_button_layout.addItem( - QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding), - 2, - 0, - 1, - 4, - ) + edit_button_layout.setRowStretch(3, 1) main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop) self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets")) + # Integrate machines list into the Assets panel + machines_list_layout = QtGui.QVBoxLayout() + machines_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Machines")) + machines_list_layout.addWidget(machines_label) + + self.machines_list = QtGui.QListWidget() + machines_list_layout.addWidget(self.machines_list) + + # Buttons: Add / Edit / Delete + btn_layout = QtGui.QHBoxLayout() + self.add_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Add")) + self.edit_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Edit")) + self.delete_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Delete")) + btn_layout.addWidget(self.add_machine_btn) + btn_layout.addWidget(self.edit_machine_btn) + btn_layout.addWidget(self.delete_machine_btn) + machines_list_layout.addLayout(btn_layout) + + # Insert the machines list directly under the path controls + edit_button_layout.addLayout(machines_list_layout, 2, 0, 1, 4) + + # Wire up buttons + self.add_machine_btn.clicked.connect(self.add_machine) + self.edit_machine_btn.clicked.connect(self.edit_machine) + self.delete_machine_btn.clicked.connect(self.delete_machine) + + # Connect double-click to edit + self.machines_list.itemDoubleClicked.connect(self.edit_machine) + + for name, filename in MachineFactory.list_configuration_files(): + if name == "" or filename is None: + continue + item = QtGui.QListWidgetItem(name) + item.setData(QtCore.Qt.UserRole, filename) + self.machines_list.addItem(item) + def selectAssetPath(self): # Implement directory selection dialog path = QtGui.QFileDialog.getExistingDirectory( @@ -131,3 +164,61 @@ class AssetPreferencesPage: if not asset_path: asset_path = str(Path.Preferences.getDefaultAssetPath()) self.asset_path_edit.setText(asset_path) + + def add_machine(self): + # Create a new machine JSON file in the user's machine asset folder + try: + # Open editor for new machine, filename will be generated on save + editor = MachineEditorDialog() + if editor.exec_() == QtGui.QDialog.Accepted: + # add to list + filename = editor.filename + display_name = MachineFactory.get_machine_display_name(filename) + item = QtGui.QListWidgetItem(display_name) + item.setData(QtCore.Qt.UserRole, filename) # Store filename only + self.machines_list.addItem(item) + except Exception as e: + Path.Log.error(f"Failed to create machine file: {e}") + + def edit_machine(self): + try: + item = self.machines_list.currentItem() + if not item: + return + filename = item.data(QtCore.Qt.UserRole) + if not filename: + return + dlg = MachineEditorDialog(filename) + if dlg.exec_() == QtGui.QDialog.Accepted: + # Reload display name from file after save + display = MachineFactory.get_machine_display_name(filename) + if display: + item.setText(display) + except Exception as e: + Path.Log.error(f"Failed to open machine editor: {e}") + + def delete_machine(self): + try: + item = self.machines_list.currentItem() + if not item: + return + filename = item.data(QtCore.Qt.UserRole) + if not filename: + return + # Confirm delete + resp = QtGui.QMessageBox.question( + self.form, + translate("CAM_PreferencesAssets", "Delete Machine"), + translate( + "CAM_PreferencesAssets", "Are you sure you want to delete this machine file?" + ), + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + ) + if resp != QtGui.QMessageBox.Yes: + return + if MachineFactory.delete_configuration(filename): + self.machines_list.takeItem(self.machines_list.currentRow()) + else: + Path.Log.error("Failed to delete machine file.") + except Exception as e: + Path.Log.error(f"Failed to delete machine: {e}") diff --git a/src/Mod/CAM/Path/Tool/machine/__init__.py b/src/Mod/CAM/Path/Tool/machine/__init__.py deleted file mode 100644 index a86511da9d..0000000000 --- a/src/Mod/CAM/Path/Tool/machine/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -from .models.machine import Machine - -__all__ = [ - "Machine", -] diff --git a/src/Mod/CAM/Path/Tool/machine/models/__init__.py b/src/Mod/CAM/Path/Tool/machine/models/__init__.py deleted file mode 100644 index bd4fba4dfe..0000000000 --- a/src/Mod/CAM/Path/Tool/machine/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later diff --git a/src/Mod/CAM/Path/Tool/machine/models/machine.py b/src/Mod/CAM/Path/Tool/machine/models/machine.py deleted file mode 100644 index a8d962c288..0000000000 --- a/src/Mod/CAM/Path/Tool/machine/models/machine.py +++ /dev/null @@ -1,435 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later - -# *************************************************************************** -# * 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 * -# * * -# *************************************************************************** -import uuid -import json -import FreeCAD -from FreeCAD import Base -from typing import Optional, Union, Mapping, List -from ...assets import Asset, AssetUri, AssetSerializer - - -class Machine(Asset): - """Represents a machine with various operational parameters.""" - - asset_type: str = "machine" - API_VERSION = 1 - - UNIT_CONVERSIONS = { - "hp": 745.7, # hp to W - "in-lbf": 0.112985, # in-lbf to N*m - "inch/min": 25.4, # inch/min to mm/min - "rpm": 1.0 / 60.0, # rpm to 1/s - "kW": 1000.0, # kW to W - "Nm": 1.0, # Nm to N*m - "mm/min": 1.0, # mm/min to mm/min - } - - def __init__( - self, - label: str = "Machine", - max_power: Union[int, float, FreeCAD.Units.Quantity] = 2, - min_rpm: Union[int, float, FreeCAD.Units.Quantity] = 3000, - max_rpm: Union[int, float, FreeCAD.Units.Quantity] = 60000, - max_torque: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None, - peak_torque_rpm: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None, - min_feed: Union[int, float, FreeCAD.Units.Quantity] = 1, - max_feed: Union[int, float, FreeCAD.Units.Quantity] = 2000, - id: Optional[str] = None, - ) -> None: - """ - Initializes a Machine object. - - Args: - label: The label of the machine. - max_power: The maximum power of the machine (kW or Quantity). - min_rpm: The minimum RPM of the machine (RPM or Quantity). - max_rpm: The maximum RPM of the machine (RPM or Quantity). - max_torque: The maximum torque of the machine (Nm or Quantity). - peak_torque_rpm: The RPM at which peak torque is achieved - (RPM or Quantity). - min_feed: The minimum feed rate of the machine - (mm/min or Quantity). - max_feed: The maximum feed rate of the machine - (mm/min or Quantity). - id: The unique identifier of the machine. - """ - self.id = id or str(uuid.uuid1()) - self._label = label - - # Initialize max_power (W) - if isinstance(max_power, FreeCAD.Units.Quantity): - self._max_power = max_power.getValueAs("W").Value - elif isinstance(max_power, (int, float)): - self._max_power = max_power * self.UNIT_CONVERSIONS["kW"] - else: - self._max_power = 2000.0 - - # Initialize min_rpm (1/s) - if isinstance(min_rpm, FreeCAD.Units.Quantity): - try: - self._min_rpm = min_rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - self._min_rpm = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"] - elif isinstance(min_rpm, (int, float)): - self._min_rpm = min_rpm * self.UNIT_CONVERSIONS["rpm"] - else: - self._min_rpm = 3000 * self.UNIT_CONVERSIONS["rpm"] - - # Initialize max_rpm (1/s) - if isinstance(max_rpm, FreeCAD.Units.Quantity): - try: - self._max_rpm = max_rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - self._max_rpm = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"] - elif isinstance(max_rpm, (int, float)): - self._max_rpm = max_rpm * self.UNIT_CONVERSIONS["rpm"] - else: - self._max_rpm = 60000 * self.UNIT_CONVERSIONS["rpm"] - - # Initialize min_feed (mm/min) - if isinstance(min_feed, FreeCAD.Units.Quantity): - self._min_feed = min_feed.getValueAs("mm/min").Value - elif isinstance(min_feed, (int, float)): - self._min_feed = min_feed - else: - self._min_feed = 1.0 - - # Initialize max_feed (mm/min) - if isinstance(max_feed, FreeCAD.Units.Quantity): - self._max_feed = max_feed.getValueAs("mm/min").Value - elif isinstance(max_feed, (int, float)): - self._max_feed = max_feed - else: - self._max_feed = 2000.0 - - # Initialize peak_torque_rpm (1/s) - if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity): - try: - self._peak_torque_rpm = peak_torque_rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - self._peak_torque_rpm = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"] - elif isinstance(peak_torque_rpm, (int, float)): - self._peak_torque_rpm = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"] - else: - self._peak_torque_rpm = self._max_rpm / 3 - - # Initialize max_torque (N*m) - if isinstance(max_torque, FreeCAD.Units.Quantity): - self._max_torque = max_torque.getValueAs("Nm").Value - elif isinstance(max_torque, (int, float)): - self._max_torque = max_torque - else: - # Convert 1/s to rpm - peak_rpm_for_calc = self._peak_torque_rpm * 60 - self._max_torque = ( - self._max_power * 9.5488 / peak_rpm_for_calc if peak_rpm_for_calc else float("inf") - ) - - def get_id(self) -> str: - """Returns the unique identifier for the Machine instance.""" - return self.id - - def to_dict(self) -> dict: - """Returns a dictionary representation of the Machine.""" - return { - "version": self.API_VERSION, - "id": self.id, - "label": self.label, - "max_power": self._max_power, # W - "min_rpm": self._min_rpm, # 1/s - "max_rpm": self._max_rpm, # 1/s - "max_torque": self._max_torque, # Nm - "peak_torque_rpm": self._peak_torque_rpm, # 1/s - "min_feed": self._min_feed, # mm/min - "max_feed": self._max_feed, # mm/min - } - - def to_bytes(self, serializer: AssetSerializer) -> bytes: - """Serializes the Machine object to bytes using to_dict.""" - data_dict = self.to_dict() - json_str = json.dumps(data_dict) - return json_str.encode("utf-8") - - @classmethod - def from_dict(cls, data_dict: dict, id: str) -> "Machine": - """Creates a Machine instance from a dictionary.""" - machine = cls( - label=data_dict.get("label", "Machine"), - max_power=data_dict.get("max_power", 2000.0), # W - min_rpm=data_dict.get("min_rpm", 3000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s - max_rpm=data_dict.get("max_rpm", 60000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s - max_torque=data_dict.get("max_torque", None), # Nm - peak_torque_rpm=data_dict.get("peak_torque_rpm", None), # 1/s - min_feed=data_dict.get("min_feed", 1.0), # mm/min - max_feed=data_dict.get("max_feed", 2000.0), # mm/min - id=id, - ) - return machine - - @classmethod - def from_bytes( - cls, - data: bytes, - id: str, - dependencies: Optional[Mapping[AssetUri, Asset]], - ) -> "Machine": - """ - Deserializes bytes into a Machine instance using from_dict. - """ - # If dependencies is None, it's fine as Machine doesn't use it. - data_dict = json.loads(data.decode("utf-8")) - return cls.from_dict(data_dict, id) - - @classmethod - def dependencies(cls, data: bytes) -> List[AssetUri]: - """Returns a list of AssetUri dependencies parsed from the serialized data.""" - return [] # Machine has no dependencies - - @property - def max_power(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._max_power, "W") - - @property - def min_rpm(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._min_rpm, "1/s") - - @property - def max_rpm(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._max_rpm, "1/s") - - @property - def max_torque(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._max_torque, "Nm") - - @property - def peak_torque_rpm(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._peak_torque_rpm, "1/s") - - @property - def min_feed(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._min_feed, "mm/min") - - @property - def max_feed(self) -> FreeCAD.Units.Quantity: - return FreeCAD.Units.Quantity(self._max_feed, "mm/min") - - @property - def label(self) -> str: - return self._label - - @label.setter - def label(self, label: str) -> None: - self._label = label - - def get_min_rpm_value(self) -> float: - """Helper method to get minimum RPM value for display/testing.""" - return self._min_rpm * 60 - - def get_max_rpm_value(self) -> float: - """Helper method to get maximum RPM value for display/testing.""" - return self._max_rpm * 60 - - def get_peak_torque_rpm_value(self) -> float: - """Helper method to get peak torque RPM value for display/testing.""" - return self._peak_torque_rpm * 60 - - def validate(self) -> None: - """Validates the machine parameters.""" - if not self.label: - raise AttributeError("Machine name is required") - if self._peak_torque_rpm > self._max_rpm: - err = ("Peak Torque RPM {ptrpm:.2f} must be less than max RPM " "{max_rpm:.2f}").format( - ptrpm=self._peak_torque_rpm * 60, max_rpm=self._max_rpm * 60 - ) - raise AttributeError(err) - if self._max_rpm <= self._min_rpm: - raise AttributeError("Max RPM must be larger than min RPM") - if self._max_feed <= self._min_feed: - raise AttributeError("Max feed must be larger than min feed") - - def get_torque_at_rpm(self, rpm: Union[int, float, FreeCAD.Units.Quantity]) -> float: - """ - Calculates the torque at a given RPM. - - Args: - rpm: The RPM value (int, float, or Quantity). - - Returns: - The torque at the given RPM in Nm. - """ - if isinstance(rpm, FreeCAD.Units.Quantity): - try: - rpm_hz = rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - rpm_hz = rpm.Value * self.UNIT_CONVERSIONS["rpm"] - else: - rpm_hz = rpm * self.UNIT_CONVERSIONS["rpm"] - max_torque_nm = self._max_torque - peak_torque_rpm_hz = self._peak_torque_rpm - peak_rpm_for_calc = peak_torque_rpm_hz * 60 - rpm_for_calc = rpm_hz * 60 - torque_at_current_rpm = ( - self._max_power * 9.5488 / rpm_for_calc if rpm_for_calc else float("inf") - ) - if rpm_for_calc <= peak_rpm_for_calc: - torque_at_current_rpm = ( - max_torque_nm / peak_rpm_for_calc * rpm_for_calc - if peak_rpm_for_calc - else float("inf") - ) - return min(max_torque_nm, torque_at_current_rpm) - - def set_max_power(self, power: Union[int, float], unit: Optional[str] = None) -> None: - """Sets the maximum power of the machine.""" - unit = unit or "kW" - if unit in self.UNIT_CONVERSIONS: - power_value = power * self.UNIT_CONVERSIONS[unit] - else: - power_value = FreeCAD.Units.Quantity(power, unit).getValueAs("W").Value - self._max_power = power_value - if self._max_power <= 0: - raise AttributeError("Max power must be positive") - - def set_min_rpm(self, min_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None: - """Sets the minimum RPM of the machine.""" - if isinstance(min_rpm, FreeCAD.Units.Quantity): - try: - min_rpm_value = min_rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - min_rpm_value = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"] - else: - min_rpm_value = min_rpm * self.UNIT_CONVERSIONS["rpm"] - self._min_rpm = min_rpm_value - if self._min_rpm < 0: - raise AttributeError("Min RPM cannot be negative") - if self._min_rpm >= self._max_rpm: - self._max_rpm = min_rpm_value + 1.0 / 60.0 - - def set_max_rpm(self, max_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None: - """Sets the maximum RPM of the machine.""" - if isinstance(max_rpm, FreeCAD.Units.Quantity): - try: - max_rpm_value = max_rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - max_rpm_value = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"] - else: - max_rpm_value = max_rpm * self.UNIT_CONVERSIONS["rpm"] - self._max_rpm = max_rpm_value - if self._max_rpm <= 0: - raise AttributeError("Max RPM must be positive") - if self._max_rpm <= self._min_rpm: - self._min_rpm = max(0, max_rpm_value - 1.0 / 60.0) - - def set_min_feed( - self, - min_feed: Union[int, float, FreeCAD.Units.Quantity], - unit: Optional[str] = None, - ) -> None: - """Sets the minimum feed rate of the machine.""" - unit = unit or "mm/min" - if unit in self.UNIT_CONVERSIONS: - min_feed_value = min_feed * self.UNIT_CONVERSIONS[unit] - else: - min_feed_value = FreeCAD.Units.Quantity(min_feed, unit).getValueAs("mm/min").Value - self._min_feed = min_feed_value - if self._min_feed < 0: - raise AttributeError("Min feed cannot be negative") - if self._min_feed >= self._max_feed: - self._max_feed = min_feed_value + 1.0 - - def set_max_feed( - self, - max_feed: Union[int, float, FreeCAD.Units.Quantity], - unit: Optional[str] = None, - ) -> None: - """Sets the maximum feed rate of the machine.""" - unit = unit or "mm/min" - if unit in self.UNIT_CONVERSIONS: - max_feed_value = max_feed * self.UNIT_CONVERSIONS[unit] - else: - max_feed_value = FreeCAD.Units.Quantity(max_feed, unit).getValueAs("mm/min").Value - self._max_feed = max_feed_value - if self._max_feed <= 0: - raise AttributeError("Max feed must be positive") - if self._max_feed <= self._min_feed: - self._min_feed = max(0, max_feed_value - 1.0) - - def set_peak_torque_rpm( - self, peak_torque_rpm: Union[int, float, FreeCAD.Units.Quantity] - ) -> None: - """Sets the peak torque RPM of the machine.""" - if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity): - try: - peak_torque_rpm_value = peak_torque_rpm.getValueAs("1/s").Value - except (Base.ParserError, ValueError): - peak_torque_rpm_value = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"] - else: - peak_torque_rpm_value = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"] - self._peak_torque_rpm = peak_torque_rpm_value - if self._peak_torque_rpm < 0: - raise AttributeError("Peak torque RPM cannot be negative") - - def set_max_torque( - self, - max_torque: Union[int, float, FreeCAD.Units.Quantity], - unit: Optional[str] = None, - ) -> None: - """Sets the maximum torque of the machine.""" - unit = unit or "Nm" - if unit in self.UNIT_CONVERSIONS: - max_torque_value = max_torque * self.UNIT_CONVERSIONS[unit] - else: - max_torque_value = FreeCAD.Units.Quantity(max_torque, unit).getValueAs("Nm").Value - self._max_torque = max_torque_value - if self._max_torque <= 0: - raise AttributeError("Max torque must be positive") - - def dump(self, do_print: bool = True) -> Optional[str]: - """ - Dumps machine information to console or returns it as a string. - - Args: - do_print: If True, prints the information to the console. - If False, returns the information as a string. - - Returns: - A formatted string containing machine information if do_print is - False, otherwise None. - """ - min_rpm_value = self._min_rpm * 60 - max_rpm_value = self._max_rpm * 60 - peak_torque_rpm_value = self._peak_torque_rpm * 60 - - output = "" - output += f"Machine {self.label}:\n" - output += f" Max power: {self._max_power:.2f} W\n" - output += f" RPM: {min_rpm_value:.2f} RPM - {max_rpm_value:.2f} RPM\n" - output += f" Feed: {self.min_feed.UserString} - " f"{self.max_feed.UserString}\n" - output += ( - f" Peak torque: {self._max_torque:.2f} Nm at " f"{peak_torque_rpm_value:.2f} RPM\n" - ) - output += f" Max_torque: {self._max_torque} Nm\n" - - if do_print: - print(output) - return output diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index f03cd14422..e56a6f3891 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -26,6 +26,11 @@ import TestApp from CAMTests.TestCAMSanity import TestCAMSanity from CAMTests.TestLinkingGenerator import TestGetLinkingMoves +from CAMTests.TestMachine import ( + TestMachineDataclass, + TestMachineFactory, + TestSpindle, +) from CAMTests.TestPathProfile import TestPathProfile from CAMTests.TestPathAdaptive import TestPathAdaptive @@ -94,7 +99,6 @@ from CAMTests.TestPathToolLibrarySerializer import ( ) from CAMTests.TestPathToolChangeGenerator import TestPathToolChangeGenerator from CAMTests.TestPathToolController import TestPathToolController -from CAMTests.TestPathToolMachine import TestPathToolMachine from CAMTests.TestPathUtil import TestPathUtil from CAMTests.TestPathVcarve import TestPathVcarve from CAMTests.TestPathVoronoi import TestPathVoronoi From a71fef6f13dac11be9904b76bc59e80558639b7d Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Wed, 31 Dec 2025 13:21:14 -0500 Subject: [PATCH 2/8] CAM: Remove colons from labels, fix lint, and update imports Removed explicit colons from all form labels. Fixed lint warnings and replaced wildcard imports with explicit imports for clarity and maintainability. src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py: - Removed ':' from all form labels - Fixed lint warnings (missing docstrings, empty except, etc.) - Replaced wildcard imports with explicit imports src/Mod/CAM/Path/Machine/models/__init__.py: - Updated __all__ and imports for explicit API src/Mod/CAM/Path/Tool/assets/ui/preferences.py: - Updated imports to use package-level import src/Mod/CAM/Path/Machine/models/machine.py: - Added explanatory comments to empty except blocks - Fixed duplicate variable assignment - Added missing class docstrings src/Mod/CAM/CAMTests/TestMachine.py: - Fixed unused variable warning by using returned filepath --- src/Mod/CAM/CAMTests/TestMachine.py | 4 +- src/Mod/CAM/Path/Machine/models/__init__.py | 28 ++++++++ src/Mod/CAM/Path/Machine/models/machine.py | 32 +++++----- .../Path/Machine/ui/editor/machine_editor.py | 64 +++++++++---------- .../CAM/Path/Tool/assets/ui/preferences.py | 3 +- 5 files changed, 77 insertions(+), 54 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py index f4f179b92a..5ff6364d22 100644 --- a/src/Mod/CAM/CAMTests/TestMachine.py +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -11,10 +11,8 @@ # *************************************************************************** import FreeCAD -import unittest import tempfile import pathlib -import json import CAMTests.PathTestUtils as PathTestUtils from Path.Machine.models.machine import ( Machine, @@ -406,7 +404,7 @@ class TestMachineFactory(PathTestUtils.PathTestBase): # Save and load filepath = MachineFactory.save_configuration(machine, "complex.fcm") - loaded = MachineFactory.load_configuration("complex.fcm") + loaded = MachineFactory.load_configuration(filepath) # Verify all components self.assertEqual(loaded.name, machine.name) diff --git a/src/Mod/CAM/Path/Machine/models/__init__.py b/src/Mod/CAM/Path/Machine/models/__init__.py index 0e2dc0e8a2..1287852469 100644 --- a/src/Mod/CAM/Path/Machine/models/__init__.py +++ b/src/Mod/CAM/Path/Machine/models/__init__.py @@ -20,3 +20,31 @@ # * . * # * * # *************************************************************************** + +from .machine import ( + Machine, + MachineFactory, + LinearAxis, + RotaryAxis, + Spindle, + MachineUnits, + MotionMode, + OutputUnits, + OutputOptions, + GCodeBlocks, + ProcessingOptions, +) + +__all__ = [ + "Machine", + "MachineFactory", + "LinearAxis", + "RotaryAxis", + "Spindle", + "MachineUnits", + "MotionMode", + "OutputUnits", + "OutputOptions", + "GCodeBlocks", + "ProcessingOptions", +] diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py index 0baed3fc83..350ba65349 100644 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -349,6 +349,14 @@ class Spindle: @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). + """ + def __init__( self, name: str = "Default Machine", configuration_units: str = "metric", **kwargs ): @@ -947,22 +955,6 @@ class Machine: return data - def _initialize_3axis_config(self) -> None: - """Initialize as a standard 3-axis XYZ configuration (no rotary axes)""" - self.name = self.name or "3-Axis XYZ Configuration" - 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)), - } - - @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_3axis_config(self) -> None: """Initialize as a standard 3-axis XYZ configuration (no rotary axes)""" self.linear_axes = { @@ -975,6 +967,13 @@ class Machine: 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() @@ -1320,6 +1319,7 @@ class MachineFactory: 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 diff --git a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py index 2dec6e594a..1d7b073e03 100644 --- a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py +++ b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py @@ -22,11 +22,12 @@ # *************************************************************************** from PySide import QtGui, QtCore import FreeCAD +import Path import json -from typing import Optional, Dict, Any, get_type_hints, get_origin, get_args -from dataclasses import fields, is_dataclass +from typing import Optional, Dict, Any, get_origin, get_args +from dataclasses import fields from enum import Enum -from ...models.machine import * +from ...models import Machine, MachineFactory, LinearAxis, RotaryAxis, Spindle from ....Main.Gui.Editor import CodeEditor from Path.Post.Processor import PostProcessorFactory import re @@ -241,7 +242,7 @@ class DataclassGUIGenerator: ) label = DataclassGUIGenerator.get_field_label(field.name) - layout.addRow(label + ":", widget) + layout.addRow(label, widget) widgets[field.name] = widget return group, widgets @@ -615,34 +616,34 @@ class MachineEditorDialog(QtGui.QDialog): self.name_edit = QtGui.QLineEdit() self.name_edit.textChanged.connect(self._on_name_changed) - layout.addRow(translate("CAM_MachineEditor", "Name:"), self.name_edit) + 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) + 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) + 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) + 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) + layout.addRow(translate("CAM_MachineEditor", "Type"), self.type_combo) self.spindle_count_combo = QtGui.QComboBox() for i in range(1, 10): # 1 to 9 spindles self.spindle_count_combo.addItem(str(i), i) self.spindle_count_combo.currentIndexChanged.connect(self.update_spindles) layout.addRow( - translate("CAM_MachineEditor", "Number of Spindles:"), self.spindle_count_combo + translate("CAM_MachineEditor", "Number of Spindles"), self.spindle_count_combo ) # Axes group @@ -743,14 +744,14 @@ class MachineEditorDialog(QtGui.QDialog): ) axis_layout = QtGui.QHBoxLayout() - axis_layout.addWidget(QtGui.QLabel("Min:")) + axis_layout.addWidget(QtGui.QLabel("Min")) axis_layout.addWidget(min_edit) - axis_layout.addWidget(QtGui.QLabel("Max:")) + axis_layout.addWidget(QtGui.QLabel("Max")) axis_layout.addWidget(max_edit) - axis_layout.addWidget(QtGui.QLabel("Max Vel:")) + axis_layout.addWidget(QtGui.QLabel("Max Vel")) axis_layout.addWidget(vel_edit) - linear_layout.addRow(f"{axis}:", axis_layout) + linear_layout.addRow(f"{axis}", axis_layout) self.axis_edits[axis] = { "min": min_edit, "max": max_edit, @@ -832,22 +833,22 @@ class MachineEditorDialog(QtGui.QDialog): axis_grid = QtGui.QGridLayout() # Row 0: Min, Max, Vel - axis_grid.addWidget(QtGui.QLabel("Min:"), 0, 0, QtCore.Qt.AlignRight) + 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(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(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(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(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(QtGui.QLabel("Prefer+"), 1, 4, QtCore.Qt.AlignRight) axis_grid.addWidget(prefer_positive, 1, 5) - rotary_layout.addRow(f"{axis}:", axis_grid) + rotary_layout.addRow(f"{axis}", axis_grid) self.axis_edits[axis] = { "min": min_edit, "max": max_edit, @@ -926,14 +927,14 @@ class MachineEditorDialog(QtGui.QDialog): name_edit.textChanged.connect( lambda text, idx=i: self._on_spindle_field_changed(idx, "name", text) ) - layout.addRow("Name:", name_edit) + 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) + layout.addRow("ID", id_edit) max_power_edit = QtGui.QDoubleSpinBox() max_power_edit.setRange(0, 100) @@ -941,7 +942,7 @@ class MachineEditorDialog(QtGui.QDialog): 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) + layout.addRow("Max Power (kW)", max_power_edit) max_rpm_edit = QtGui.QSpinBox() max_rpm_edit.setRange(0, 100000) @@ -949,7 +950,7 @@ class MachineEditorDialog(QtGui.QDialog): 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) + layout.addRow("Max RPM", max_rpm_edit) min_rpm_edit = QtGui.QSpinBox() min_rpm_edit.setRange(0, 100000) @@ -957,7 +958,7 @@ class MachineEditorDialog(QtGui.QDialog): 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) + layout.addRow("Min RPM", min_rpm_edit) tool_change_combo = QtGui.QComboBox() tool_change_combo.addItem("Manual", "manual") @@ -1016,7 +1017,7 @@ class MachineEditorDialog(QtGui.QDialog): ) self.postProcessorDefaultTooltip = translate("CAM_MachineEditor", "Select a post processor") self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip) - pp_layout.addRow("Post Processor:", self.post_processor_combo) + pp_layout.addRow("Post Processor", self.post_processor_combo) self.post_widgets["postprocessor_file_name"] = self.post_processor_combo self.post_processor_args_edit = QtGui.QLineEdit() @@ -1027,7 +1028,7 @@ class MachineEditorDialog(QtGui.QDialog): "CAM_MachineEditor", "Additional arguments" ) self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) - pp_layout.addRow("Arguments:", self.post_processor_args_edit) + pp_layout.addRow("Arguments", self.post_processor_args_edit) self.post_widgets["postprocessor_args"] = self.post_processor_args_edit layout.addWidget(pp_group) @@ -1301,10 +1302,6 @@ class MachineEditorDialog(QtGui.QDialog): # Get units for suffixes in populate 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" # Update axes UI after loading machine data self.update_axes() @@ -1432,7 +1429,8 @@ class MachineEditorDialog(QtGui.QDialog): current_name = current_machine.name.lower() if machine_name_lower == current_name: current_name_allowed = True - except: + 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: diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index f51bb90fa3..ad6b751bc8 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -25,10 +25,9 @@ import pathlib import tempfile import FreeCAD import Path -import json from PySide import QtGui, QtCore from ....Machine.ui.editor import MachineEditorDialog -from ....Machine.models.machine import MachineFactory +from ....Machine.models import MachineFactory translate = FreeCAD.Qt.translate From d67aa28992634b4bcf755e79b6ed1c76f277f8b5 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Thu, 1 Jan 2026 10:11:33 -0600 Subject: [PATCH 3/8] Rearranged some properties. added back bcnc comment output --- src/Mod/CAM/Path/Machine/models/machine.py | 99 +++++-------------- .../Path/Machine/ui/editor/machine_editor.py | 5 +- 2 files changed, 30 insertions(+), 74 deletions(-) diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py index 350ba65349..911a24d4ea 100644 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -97,7 +97,8 @@ class OutputOptions: line_numbers: bool = False path_labels: bool = False machine_name: bool = False - doubles: bool = False + output_double_parameters: bool = False # Controls whether to output duplicate axis values + output_bcnc_comments: bool = True # Line formatting options command_space: str = " " @@ -106,6 +107,11 @@ class OutputOptions: line_number_start: int = 100 end_of_line_chars: str = "\n" + # UI and display options + show_editor: bool = True # Show editor after G-code generation + list_tools_in_preamble: bool = False # List tools in preamble + show_operation_labels: bool = True # Show operation labels in output + # Numeric precision settings axis_precision: int = 3 # Decimal places for axis coordinates feed_precision: int = 3 # Decimal places for feed rates @@ -166,10 +172,7 @@ class ProcessingOptions: modal: bool = False # Suppress repeated commands translate_drill_cycles: bool = False split_arcs: bool = False - show_editor: bool = True - list_tools_in_preamble: bool = False show_machine_units: bool = True - show_operation_labels: bool = True tool_before_change: bool = False # Output T before M6 (e.g., T1 M6 instead of M6 T1) # Lists of commands @@ -183,7 +186,6 @@ class ProcessingOptions: adaptive: bool = False # Enable adaptive toolpath optimization # Numeric settings - chipbreaking_amount: float = 0.25 # mm spindle_wait: float = 0.0 # seconds return_to: Optional[Tuple[float, float, float]] = None # (x, y, z) or None @@ -395,33 +397,6 @@ class Machine: self.postprocessor_args = kwargs.get("postprocessor_args", "") self.motion_mode = kwargs.get("motion_mode", MotionMode.ABSOLUTE) self.parameter_functions = kwargs.get("parameter_functions", {}) - self.parameter_order = kwargs.get( - "parameter_order", - [ - "D", - "H", - "L", - "X", - "Y", - "Z", - "A", - "B", - "C", - "U", - "V", - "W", - "I", - "J", - "K", - "R", - "P", - "E", - "Q", - "F", - "S", - "T", - ], - ) # Initialize computed fields self.freecad_version = ".".join(FreeCAD.Version()[0:3]) @@ -494,36 +469,15 @@ class Machine: # Dynamic state (for runtime) parameter_functions: Dict[str, Callable] = field(default_factory=dict) - parameter_order: List[str] = field( - default_factory=lambda: [ - "D", - "H", - "L", - "X", - "Y", - "Z", - "A", - "B", - "C", - "U", - "V", - "W", - "I", - "J", - "K", - "R", - "P", - "E", - "Q", - "F", - "S", - "T", - ] - ) def __post_init__(self): """Initialize computed fields""" self.freecad_version = ".".join(FreeCAD.Version()[0:3]) + # Backward compatibility for doubles + if not hasattr(self, 'output_double_parameters'): + self.output.output_double_parameters = getattr(self.output, 'doubles', False) + # Set deprecated doubles for backward compatibility + self.output.doubles = self.output.output_double_parameters # Initialize configuration_units if not set if not hasattr(self, "_configuration_units"): self._configuration_units = "metric" @@ -887,8 +841,12 @@ class Machine: "line_numbers": self.output.line_numbers, "path_labels": self.output.path_labels, "machine_name": self.output.machine_name, - "doubles": self.output.doubles, + "output_double_parameters": self.output.output_double_parameters, + "output_bcnc_comments": self.output.output_bcnc_comments, "output_units": self.output.output_units.value, + "show_editor": self.output.show_editor, + "list_tools_in_preamble": self.output.list_tools_in_preamble, + "show_operation_labels": self.output.show_operation_labels, "axis_precision": self.output.axis_precision, "feed_precision": self.output.feed_precision, "spindle_decimals": self.output.spindle_decimals, @@ -938,16 +896,12 @@ class Machine: "modal": self.processing.modal, "translate_drill_cycles": self.processing.translate_drill_cycles, "split_arcs": self.processing.split_arcs, - "show_editor": self.processing.show_editor, - "list_tools_in_preamble": self.processing.list_tools_in_preamble, "show_machine_units": self.processing.show_machine_units, - "show_operation_labels": self.processing.show_operation_labels, "tool_before_change": self.processing.tool_before_change, "drill_cycles_to_translate": self.processing.drill_cycles_to_translate, "suppress_commands": self.processing.suppress_commands, "tool_change": self.processing.tool_change, "adaptive": self.processing.adaptive, - "chipbreaking_amount": self.processing.chipbreaking_amount, "spindle_wait": self.processing.spindle_wait, } if self.processing.return_to: @@ -1112,7 +1066,8 @@ class Machine: config.output.line_numbers = output_data.get("line_numbers", False) config.output.path_labels = output_data.get("path_labels", False) config.output.machine_name = output_data.get("machine_name", False) - config.output.doubles = output_data.get("doubles", False) + config.output.output_double_parameters = output_data.get("output_double_parameters", False) + config.output.output_bcnc_comments = output_data.get("output_bcnc_comments", True) # Handle output_units conversion from string to enum output_units_str = output_data.get("output_units", "metric") @@ -1125,6 +1080,14 @@ class Machine: config.processing.tool_change = output_data["tool_change"] if "adaptive" in output_data: config.processing.adaptive = output_data["adaptive"] + + # Migrate fields from ProcessingOptions to OutputOptions if needed + if "show_editor" in output_data: + config.output.show_editor = output_data["show_editor"] + if "list_tools_in_preamble" in output_data: + config.output.list_tools_in_preamble = output_data["list_tools_in_preamble"] + if "show_operation_labels" in output_data: + config.output.show_operation_labels = output_data["show_operation_labels"] # Set precision values from output_data if "axis_precision" in output_data: @@ -1149,14 +1112,7 @@ class Machine: "translate_drill_cycles", False ) config.processing.split_arcs = processing_data.get("split_arcs", False) - config.processing.show_editor = processing_data.get("show_editor", True) - config.processing.list_tools_in_preamble = processing_data.get( - "list_tools_in_preamble", False - ) config.processing.show_machine_units = processing_data.get("show_machine_units", True) - config.processing.show_operation_labels = processing_data.get( - "show_operation_labels", True - ) config.processing.tool_before_change = processing_data.get("tool_before_change", False) config.processing.drill_cycles_to_translate = processing_data.get( "drill_cycles_to_translate", ["G73", "G81", "G82", "G83"] @@ -1164,7 +1120,6 @@ class Machine: config.processing.suppress_commands = processing_data.get("suppress_commands", []) config.processing.tool_change = processing_data.get("tool_change", True) config.processing.adaptive = processing_data.get("adaptive", False) - config.processing.chipbreaking_amount = processing_data.get("chipbreaking_amount", 0.25) config.processing.spindle_wait = processing_data.get("spindle_wait", 0.0) return_to = processing_data.get("return_to", None) config.processing.return_to = tuple(return_to) if return_to is not None else None diff --git a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py index 1d7b073e03..1fe0df2d9c 100644 --- a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py +++ b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py @@ -73,7 +73,8 @@ class DataclassGUIGenerator: "line_numbers": translate("CAM_MachineEditor", "Line Numbers"), "path_labels": translate("CAM_MachineEditor", "Path Labels"), "machine_name": translate("CAM_MachineEditor", "Include Machine Name"), - "tool_change": translate("CAM_MachineEditor", "Tool Change Commands"), + "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"), @@ -643,7 +644,7 @@ class MachineEditorDialog(QtGui.QDialog): self.spindle_count_combo.addItem(str(i), i) self.spindle_count_combo.currentIndexChanged.connect(self.update_spindles) layout.addRow( - translate("CAM_MachineEditor", "Number of Spindles"), self.spindle_count_combo + translate("CAM_MachineEditor", "Number of spindles"), self.spindle_count_combo ) # Axes group From 4bba7364f56aa06b5725ecb1ac36bf6809d0b630 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Thu, 1 Jan 2026 10:29:36 -0600 Subject: [PATCH 4/8] Code cleanup. --- src/Mod/CAM/CAMTests/TestMachine.py | 1 - src/Mod/CAM/Path/Machine/models/machine.py | 137 ++++++------------ .../Path/Machine/ui/editor/machine_editor.py | 33 ++--- .../CAM/Path/Tool/assets/ui/preferences.py | 2 +- 4 files changed, 59 insertions(+), 114 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py index 5ff6364d22..c21497dfea 100644 --- a/src/Mod/CAM/CAMTests/TestMachine.py +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -382,7 +382,6 @@ class TestMachineFactory(PathTestUtils.PathTestBase): name="Complex Machine", manufacturer="Test Mfg", description="Full featured machine", - machine_type="xyzac", configuration_units="metric", ) diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py index 911a24d4ea..8cea9ffabc 100644 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -142,7 +142,7 @@ class GCodeBlocks: # Tool change lifecycle pre_tool_change: str = "" post_tool_change: str = "" - tool_return: str = "" # Return to tool change position # do we need this? + tool_return: str = "" # Return to tool change position # Fixture/WCS change lifecycle pre_fixture_change: str = "" @@ -152,13 +152,6 @@ class GCodeBlocks: pre_rotary_move: str = "" post_rotary_move: str = "" - # Spindle lifecycle - # pre_spindle_change: str = "" # Futures - # post_spindle_change: str = "" # Futures - - # Miscellaneous - # finish_label: str = "Finish" # do we need this? Looks like bullshit - post_job: str = "" postamble: str = "" # Typically inserted at end of job @@ -207,9 +200,21 @@ class LinearAxis: sequence: int = 0 def __post_init__(self): - """Normalize direction vector after initialization""" + """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 @@ -258,13 +263,25 @@ class RotaryAxis: prefer_positive: bool = True def __post_init__(self): - """Normalize rotation vector after initialization""" + """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 @@ -359,52 +376,6 @@ class Machine: machine configurations (3-axis, 4-axis, 5-axis). """ - def __init__( - self, name: str = "Default Machine", configuration_units: str = "metric", **kwargs - ): - # Set default values for all fields - self.name = name - self.manufacturer = kwargs.get("manufacturer", "") - self.description = kwargs.get("description", "") - self.linear_axes = {} - self.rotary_axes = {} - self.spindles = [] - self.reference_system = kwargs.get( - "reference_system", - { - "X": FreeCAD.Vector(1, 0, 0), - "Y": FreeCAD.Vector(0, 1, 0), - "Z": FreeCAD.Vector(0, 0, 1), - }, - ) - self.tool_axis = kwargs.get("tool_axis", FreeCAD.Vector(0, 0, -1)) - self.primary_rotary_axis = kwargs.get("primary_rotary_axis") - self.secondary_rotary_axis = kwargs.get("secondary_rotary_axis") - self.compound_moves = kwargs.get("compound_moves", True) - self.prefer_positive_rotation = kwargs.get("prefer_positive_rotation", True) - self._configuration_units = configuration_units - self.version = kwargs.get("version", 1) - self.output = kwargs.get("output", OutputOptions()) - # Handle legacy precision settings if provided - precision = kwargs.get("precision") - if precision is not None: - self.output.axis_precision = precision.axis_precision - self.output.feed_precision = precision.feed_precision - self.output.spindle_decimals = precision.spindle_decimals - self.blocks = kwargs.get("blocks", GCodeBlocks()) - self.processing = kwargs.get("processing", ProcessingOptions()) - self.postprocessor_file_name = kwargs.get("postprocessor_file_name", "") - self.postprocessor_args = kwargs.get("postprocessor_args", "") - self.motion_mode = kwargs.get("motion_mode", MotionMode.ABSOLUTE) - self.parameter_functions = kwargs.get("parameter_functions", {}) - - # Initialize computed fields - self.freecad_version = ".".join(FreeCAD.Version()[0:3]) - - # Handle machine_type if provided (for backward compatibility) - if "machine_type" in kwargs and kwargs["machine_type"]: - self._initialize_from_machine_type(kwargs["machine_type"]) - """ Unified machine configuration combining physical machine definition with post-processor settings. @@ -445,9 +416,7 @@ class Machine: prefer_positive_rotation: bool = True # Units and versioning - _configuration_units: str = field( - default="metric", init=False - ) # Internal storage for configuration_units + configuration_units: str = "metric" # Internal storage for configuration_units version: int = 1 freecad_version: str = field(init=False) @@ -471,37 +440,26 @@ class Machine: parameter_functions: Dict[str, Callable] = field(default_factory=dict) def __post_init__(self): - """Initialize computed fields""" + """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 doubles - if not hasattr(self, 'output_double_parameters'): - self.output.output_double_parameters = getattr(self.output, 'doubles', False) + if not hasattr(self.output, "output_double_parameters"): + self.output.output_double_parameters = getattr(self.output, "doubles", False) # Set deprecated doubles for backward compatibility self.output.doubles = self.output.output_double_parameters - # Initialize configuration_units if not set - if not hasattr(self, "_configuration_units"): - self._configuration_units = "metric" # ======================================================================== # PROPERTIES - Bridge between physical machine and post-processor # ======================================================================== - @property - def configuration_units(self) -> str: - """Get machine configuration units ("metric" or "imperial")""" - if not hasattr(self, "_configuration_units"): - self._configuration_units = "metric" - return self._configuration_units - - @configuration_units.setter - def configuration_units(self, value: str) -> None: - """Set machine configuration units ("metric" or "imperial")""" - if not value: # Skip empty strings - return - if value not in ["metric", "imperial"]: - raise ValueError("configuration_units must be 'metric' or 'imperial'") - self._configuration_units = value - @property def machine_units(self) -> MachineUnits: """Get machine configuration units as enum""" @@ -818,7 +776,6 @@ class Machine: "name": self.name, "manufacturer": self.manufacturer, "description": self.description, - # "type": self.machine_type, "units": self.configuration_units, "axes": axes, "spindles": [spindle.to_dict() for spindle in self.spindles], @@ -984,12 +941,6 @@ class Machine: configuration_units=machine_data.get("units", "metric"), manufacturer=machine_data.get("manufacturer", ""), description=machine_data.get("description", ""), - # machine_type=machine_data.get("type"), - **{ - k: v - for k, v in machine_data.items() - if k not in ["name", "units", "manufacturer", "description", "type"] - }, ) # Parse axes from new flattened structure @@ -1029,8 +980,8 @@ class Machine: max_velocity=max_velocity, ) elif axis_type == "angular": - joint = axis_data.get("joint", [[0, 0, 1], [0, 0, 0]]) - rotation_vec = FreeCAD.Vector(joint[0][0], joint[0][1], joint[0][2]) + 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) @@ -1066,7 +1017,9 @@ class Machine: config.output.line_numbers = output_data.get("line_numbers", False) config.output.path_labels = output_data.get("path_labels", False) config.output.machine_name = output_data.get("machine_name", False) - config.output.output_double_parameters = output_data.get("output_double_parameters", False) + config.output.output_double_parameters = output_data.get( + "output_double_parameters", False + ) config.output.output_bcnc_comments = output_data.get("output_bcnc_comments", True) # Handle output_units conversion from string to enum @@ -1080,7 +1033,7 @@ class Machine: config.processing.tool_change = output_data["tool_change"] if "adaptive" in output_data: config.processing.adaptive = output_data["adaptive"] - + # Migrate fields from ProcessingOptions to OutputOptions if needed if "show_editor" in output_data: config.output.show_editor = output_data["show_editor"] diff --git a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py index 1fe0df2d9c..95bf2d8bda 100644 --- a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py +++ b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py @@ -680,11 +680,14 @@ class MachineEditorDialog(QtGui.QDialog): # Clear references before deleting widgets self.axis_edits = {} - # Clear existing axes widgets + # 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: - widget.setParent(None) + # 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()) @@ -891,8 +894,14 @@ class MachineEditorDialog(QtGui.QDialog): edits["tool_change"].currentIndex() ) - # Clear existing spindle tabs - self.spindles_tabs.clear() + # 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 = self.spindle_count_combo.itemData(self.spindle_count_combo.currentIndex()) @@ -1043,20 +1052,6 @@ class MachineEditorDialog(QtGui.QDialog): layout.addWidget(output_group) self._connect_widgets_to_machine(output_widgets, "output") - # # Precision Settings - # precision_group, precision_widgets = DataclassGUIGenerator.create_group_for_dataclass( - # self.machine.precision, "Precision Settings" - # ) - # layout.addWidget(precision_group) - # self._connect_widgets_to_machine(precision_widgets, "precision") - - # Line Formatting - # formatting_group, formatting_widgets = DataclassGUIGenerator.create_group_for_dataclass( - # self.machine.formatting, "Line Formatting" - # ) - # layout.addWidget(formatting_group) - # self._connect_widgets_to_machine(formatting_widgets, "formatting") - # G-Code Blocks blocks_group, blocks_widgets = DataclassGUIGenerator.create_group_for_dataclass( self.machine.blocks, "G-Code Blocks" @@ -1212,8 +1207,6 @@ class MachineEditorDialog(QtGui.QDialog): # Update nested dataclass fields dataclass_groups = [ ("output", machine.output), - # ("precision", machine.precision), - # ("formatting", machine.formatting), ("blocks", machine.blocks), ("processing", machine.processing), ] diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index ad6b751bc8..5f1e763171 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -55,7 +55,7 @@ class AssetPreferencesPage: main_layout = QtGui.QHBoxLayout(asset_path_widget) # Create widgets - self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:")) + self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset directory")) self.asset_path_edit = QtGui.QLineEdit() self.asset_path_note_label = QtGui.QLabel( translate( From e53b2e311a469acf42e9eacc4e7a0d9506f42c1d Mon Sep 17 00:00:00 2001 From: sliptonic Date: Sat, 3 Jan 2026 13:19:10 -0600 Subject: [PATCH 5/8] Revise machine --- src/Mod/CAM/CAMTests/TestMachine.py | 4 +- src/Mod/CAM/Path/Machine/models/machine.py | 220 +++++++++++---------- 2 files changed, 122 insertions(+), 102 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py index c21497dfea..b6be85641c 100644 --- a/src/Mod/CAM/CAMTests/TestMachine.py +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -395,7 +395,7 @@ class TestMachineFactory(PathTestUtils.PathTestBase): ) # Configure post-processor settings - machine.output.comments = False + machine.output.output_comments = False machine.output.axis_precision = 4 machine.output.line_increment = 5 @@ -409,6 +409,6 @@ class TestMachineFactory(PathTestUtils.PathTestBase): self.assertEqual(loaded.name, machine.name) self.assertEqual(loaded.manufacturer, machine.manufacturer) self.assertEqual(len(loaded.spindles), 1) - self.assertFalse(loaded.output.comments) + self.assertFalse(loaded.output.output_comments) self.assertEqual(loaded.output.axis_precision, 4) self.assertEqual(loaded.output.line_increment, 5) diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py index 8cea9ffabc..212ce6ee24 100644 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -91,31 +91,43 @@ class OutputOptions: # These options control conversion of Path Objects to actual gcode. output_units: OutputUnits = OutputUnits.METRIC # G-code output units - comments: bool = True - blank_lines: bool = True - header: bool = True - line_numbers: bool = False - path_labels: bool = False - machine_name: bool = False - output_double_parameters: bool = False # Controls whether to output duplicate axis values - output_bcnc_comments: bool = True - + # Line formatting options command_space: str = " " comment_symbol: str = "(" + end_of_line_chars: str = "\n" line_increment: int = 10 line_number_start: int = 100 - end_of_line_chars: str = "\n" - + 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) + # UI and display options show_editor: bool = True # Show editor after G-code generation - list_tools_in_preamble: bool = False # List tools in preamble - show_operation_labels: bool = True # Show operation labels in output # Numeric precision settings axis_precision: int = 3 # Decimal places for axis coordinates feed_precision: int = 3 # Decimal places for feed rates - spindle_decimals: int = 0 # Decimal places for spindle speed + spindle_precision: int = 0 # Renamed from 'spindle_decimals' @dataclass @@ -160,26 +172,21 @@ class GCodeBlocks: class ProcessingOptions: """Processing and transformation options.""" - # connversion and expansion of Path Objects. Does not affect final gcode generation + # Conversion and expansion of Path Objects. Does not affect final gcode generation - modal: bool = False # Suppress repeated commands - translate_drill_cycles: bool = False - split_arcs: bool = False - show_machine_units: bool = True - tool_before_change: bool = False # Output T before M6 (e.g., T1 M6 instead of M6 T1) - - # Lists of commands + adaptive: bool = False # Enable adaptive toolpath optimization drill_cycles_to_translate: List[str] = field( default_factory=lambda: ["G73", "G81", "G82", "G83"] ) - suppress_commands: List[str] = field(default_factory=list) - - # Output processing options - tool_change: bool = True # Enable tool change commands - adaptive: bool = False # Enable adaptive toolpath optimization - - # Numeric settings + + 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 @@ -450,11 +457,12 @@ class Machine: f"configuration_units must be 'metric' or 'imperial', got '{self.configuration_units}'" ) - # Backward compatibility for doubles - if not hasattr(self.output, "output_double_parameters"): - self.output.output_double_parameters = getattr(self.output, "doubles", False) - # Set deprecated doubles for backward compatibility - self.output.doubles = self.output.output_double_parameters + # 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 @@ -792,26 +800,32 @@ class Machine: # Output options data["output"] = { - "comments": self.output.comments, - "blank_lines": self.output.blank_lines, - "header": self.output.header, - "line_numbers": self.output.line_numbers, - "path_labels": self.output.path_labels, - "machine_name": self.output.machine_name, - "output_double_parameters": self.output.output_double_parameters, - "output_bcnc_comments": self.output.output_bcnc_comments, - "output_units": self.output.output_units.value, - "show_editor": self.output.show_editor, - "list_tools_in_preamble": self.output.list_tools_in_preamble, - "show_operation_labels": self.output.show_operation_labels, - "axis_precision": self.output.axis_precision, - "feed_precision": self.output.feed_precision, - "spindle_decimals": self.output.spindle_decimals, "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, - "end_of_line_chars": self.output.end_of_line_chars, + "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, + "show_editor": self.output.show_editor, + "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) @@ -850,16 +864,15 @@ class Machine: # Processing options data["processing"] = { - "modal": self.processing.modal, - "translate_drill_cycles": self.processing.translate_drill_cycles, - "split_arcs": self.processing.split_arcs, - "show_machine_units": self.processing.show_machine_units, - "tool_before_change": self.processing.tool_before_change, - "drill_cycles_to_translate": self.processing.drill_cycles_to_translate, - "suppress_commands": self.processing.suppress_commands, - "tool_change": self.processing.tool_change, "adaptive": self.processing.adaptive, + "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) @@ -1011,16 +1024,43 @@ class Machine: # Load output options output_data = data.get("output", {}) if output_data: - config.output.comments = output_data.get("comments", True) - config.output.blank_lines = output_data.get("blank_lines", True) - config.output.header = output_data.get("header", True) + # 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.path_labels = output_data.get("path_labels", False) - config.output.machine_name = output_data.get("machine_name", False) - config.output.output_double_parameters = output_data.get( - "output_double_parameters", 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) + + # UI and display options + config.output.show_editor = output_data.get("show_editor", True) + + # 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") @@ -1028,54 +1068,34 @@ class Machine: OutputUnits.METRIC if output_units_str == "metric" else OutputUnits.IMPERIAL ) - # These fields are now in ProcessingOptions + # These fields are now in ProcessingOptions (backward compatibility) if "tool_change" in output_data: config.processing.tool_change = output_data["tool_change"] if "adaptive" in output_data: config.processing.adaptive = output_data["adaptive"] - # Migrate fields from ProcessingOptions to OutputOptions if needed - if "show_editor" in output_data: - config.output.show_editor = output_data["show_editor"] - if "list_tools_in_preamble" in output_data: - config.output.list_tools_in_preamble = output_data["list_tools_in_preamble"] - if "show_operation_labels" in output_data: - config.output.show_operation_labels = output_data["show_operation_labels"] - - # Set precision values from output_data - if "axis_precision" in output_data: - config.output.axis_precision = output_data["axis_precision"] - if "feed_precision" in output_data: - config.output.feed_precision = output_data["feed_precision"] - if "spindle_decimals" in output_data: - config.output.spindle_decimals = output_data["spindle_decimals"] - - # Load formatting fields from output_data - config.output.command_space = output_data.get("command_space", " ") - config.output.comment_symbol = output_data.get("comment_symbol", "(") - config.output.line_increment = output_data.get("line_increment", 10) - config.output.line_number_start = output_data.get("line_number_start", 100) - config.output.end_of_line_chars = output_data.get("end_of_line_chars", "\n") - # Load processing options processing_data = data.get("processing", {}) if processing_data: - config.processing.modal = processing_data.get("modal", False) - config.processing.translate_drill_cycles = processing_data.get( - "translate_drill_cycles", False - ) - config.processing.split_arcs = processing_data.get("split_arcs", False) - config.processing.show_machine_units = processing_data.get("show_machine_units", True) - config.processing.tool_before_change = processing_data.get("tool_before_change", False) + config.processing.adaptive = processing_data.get("adaptive", False) config.processing.drill_cycles_to_translate = processing_data.get( "drill_cycles_to_translate", ["G73", "G81", "G82", "G83"] ) - config.processing.suppress_commands = processing_data.get("suppress_commands", []) - config.processing.tool_change = processing_data.get("tool_change", True) - config.processing.adaptive = processing_data.get("adaptive", False) + 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", {}) From db2e3cd5673abd494220fe0d6a975eba233a1556 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:35:57 +0000 Subject: [PATCH 6/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/Mod/CAM/Path/Machine/models/machine.py | 66 ++++++++++++++-------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py index 212ce6ee24..d6983e15f4 100644 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -91,7 +91,7 @@ class OutputOptions: # 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 = "(" @@ -100,7 +100,7 @@ class OutputOptions: 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' @@ -108,7 +108,7 @@ class OutputOptions: 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 @@ -116,11 +116,11 @@ class OutputOptions: 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) - + # UI and display options show_editor: bool = True # Show editor after G-code generation @@ -178,7 +178,7 @@ class ProcessingOptions: 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 @@ -186,7 +186,7 @@ class ProcessingOptions: 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 @@ -460,7 +460,9 @@ class Machine: # 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) + 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) @@ -1032,35 +1034,53 @@ class Machine: 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_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)) - + 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_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.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)) + 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) - + # UI and display options config.output.show_editor = output_data.get("show_editor", True) - + # 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)) + 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") @@ -1092,7 +1112,7 @@ class Machine: ) 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"] From ca0894c6f423ac963a311d518faf3bb80eaa9466 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Tue, 6 Jan 2026 16:44:45 -0600 Subject: [PATCH 7/8] more machine cleanup --- src/Mod/CAM/CAMTests/TestMachine.py | 4 ---- src/Mod/CAM/Path/Machine/models/__init__.py | 2 -- src/Mod/CAM/Path/Machine/models/machine.py | 24 --------------------- 3 files changed, 30 deletions(-) diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py index b6be85641c..2c63b55e92 100644 --- a/src/Mod/CAM/CAMTests/TestMachine.py +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -20,7 +20,6 @@ from Path.Machine.models.machine import ( OutputOptions, GCodeBlocks, ProcessingOptions, - MotionMode, MachineFactory, ) @@ -75,9 +74,6 @@ class TestMachineDataclass(PathTestUtils.PathTestBase): self.assertIsInstance(machine.blocks, GCodeBlocks) self.assertIsInstance(machine.processing, ProcessingOptions) - # Motion mode - self.assertEqual(machine.motion_mode, MotionMode.ABSOLUTE) - def test_custom_initialization(self): """Test Machine initialization with custom values and verify machine_type is derived""" # Create a 5-axis machine (XYZAC) diff --git a/src/Mod/CAM/Path/Machine/models/__init__.py b/src/Mod/CAM/Path/Machine/models/__init__.py index 1287852469..979362e01c 100644 --- a/src/Mod/CAM/Path/Machine/models/__init__.py +++ b/src/Mod/CAM/Path/Machine/models/__init__.py @@ -28,7 +28,6 @@ from .machine import ( RotaryAxis, Spindle, MachineUnits, - MotionMode, OutputUnits, OutputOptions, GCodeBlocks, @@ -42,7 +41,6 @@ __all__ = [ "RotaryAxis", "Spindle", "MachineUnits", - "MotionMode", "OutputUnits", "OutputOptions", "GCodeBlocks", diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py index d6983e15f4..616725cfb4 100644 --- a/src/Mod/CAM/Path/Machine/models/machine.py +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -65,13 +65,6 @@ class MachineUnits(Enum): IMPERIAL = "G20" -class MotionMode(Enum): - """Motion mode for machine movements.""" - - ABSOLUTE = "G90" - RELATIVE = "G91" - - class OutputUnits(Enum): """Output unit system for G-code generation.""" @@ -121,9 +114,6 @@ class OutputOptions: filter_double_parameters: bool = False # Renamed from 'output_double_parameters' filter_double_commands: bool = False # Renamed from 'modal' (moved from ProcessingOptions) - # UI and display options - show_editor: bool = True # Show editor after G-code generation - # Numeric precision settings axis_precision: int = 3 # Decimal places for axis coordinates feed_precision: int = 3 # Decimal places for feed rates @@ -174,7 +164,6 @@ class ProcessingOptions: # Conversion and expansion of Path Objects. Does not affect final gcode generation - adaptive: bool = False # Enable adaptive toolpath optimization drill_cycles_to_translate: List[str] = field( default_factory=lambda: ["G73", "G81", "G82", "G83"] ) @@ -440,9 +429,6 @@ class Machine: postprocessor_file_name: str = "" postprocessor_args: str = "" - # Motion mode - motion_mode: MotionMode = MotionMode.ABSOLUTE - # Dynamic state (for runtime) parameter_functions: Dict[str, Callable] = field(default_factory=dict) @@ -797,7 +783,6 @@ class Machine: data["postprocessor"] = { "file_name": self.postprocessor_file_name, "args": self.postprocessor_args, - "motion_mode": self.motion_mode.value, } # Output options @@ -824,7 +809,6 @@ class Machine: "output_labels": self.output.output_labels, "output_operation_labels": self.output.output_operation_labels, "output_units": self.output.output_units.value, - "show_editor": self.output.show_editor, "axis_precision": self.output.axis_precision, "feed_precision": self.output.feed_precision, "spindle_precision": self.output.spindle_precision, @@ -866,7 +850,6 @@ class Machine: # Processing options data["processing"] = { - "adaptive": self.processing.adaptive, "drill_cycles_to_translate": self.processing.drill_cycles_to_translate, "show_machine_units": self.processing.show_machine_units, "spindle_wait": self.processing.spindle_wait, @@ -1021,7 +1004,6 @@ class Machine: if post_data: config.postprocessor_file_name = post_data.get("file_name", "") config.postprocessor_args = post_data.get("args", "") - config.motion_mode = MotionMode(post_data.get("motion_mode", "G90")) # Load output options output_data = data.get("output", {}) @@ -1072,9 +1054,6 @@ class Machine: # filter_double_commands comes from processing.modal in old format config.output.filter_double_commands = output_data.get("filter_double_commands", False) - # UI and display options - config.output.show_editor = output_data.get("show_editor", True) - # 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) @@ -1091,13 +1070,10 @@ class Machine: # These fields are now in ProcessingOptions (backward compatibility) if "tool_change" in output_data: config.processing.tool_change = output_data["tool_change"] - if "adaptive" in output_data: - config.processing.adaptive = output_data["adaptive"] # Load processing options processing_data = data.get("processing", {}) if processing_data: - config.processing.adaptive = processing_data.get("adaptive", False) config.processing.drill_cycles_to_translate = processing_data.get( "drill_cycles_to_translate", ["G73", "G81", "G82", "G83"] ) From 9af98c121eccecb1c2b6e41a84ce7620fdce3c46 Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Thu, 8 Jan 2026 19:28:32 -0500 Subject: [PATCH 8/8] CAM: Refactor Machine Editor UI, replace QToolBox with tabs Major refactor of the Machine Editor to use QTabWidget for section navigation. Added tabbed spindle management with add/remove functionality, split machine configuration into Output Options, G-Code Blocks, and Processing Options tabs. Updated preferences UI to use tabs instead of QToolBox. src/Mod/CAM/Gui/Resources/preferences/PathJob.ui: - Replace QToolBox with QTabWidget for preferences tabs src/Mod/CAM/Path/Dressup/Gui/Preferences.py: - Use QWidget with vertical layout instead of QToolBox for dressup preferences src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py: - Refactor to use QTabWidget for editor sections - Implement tabbed spindle management with add/remove - Split configuration into Output Options, G-Code Blocks, and Processing Options tabs - Update post processor selection logic src/Mod/CAM/Path/Main/Gui/PreferencesJob.py: - Update to use tabWidget instead of toolBox src/Mod/CAM/Path/Tool/assets/ui/preferences.py: - Use QWidget and direct layout instead of QToolBox for asset preferences --- generate_machine_box.py | 164 +++++++++ .../CAM/Gui/Resources/preferences/PathJob.ui | 38 +-- src/Mod/CAM/Path/Dressup/Gui/Preferences.py | 9 +- .../Path/Machine/ui/editor/machine_editor.py | 314 ++++++++++++------ src/Mod/CAM/Path/Main/Gui/PreferencesJob.py | 2 +- .../CAM/Path/Tool/assets/ui/preferences.py | 68 ++-- 6 files changed, 424 insertions(+), 171 deletions(-) create mode 100644 generate_machine_box.py diff --git a/generate_machine_box.py b/generate_machine_box.py new file mode 100644 index 0000000000..47584c57b7 --- /dev/null +++ b/generate_machine_box.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +FreeCAD Macro: Generate Machine Boundary Box + +This macro creates a wireframe box representing the working envelope +of a CNC machine based on its configuration. + +COORDINATE SYSTEM: +- Uses MACHINE coordinates (absolute travel limits of the machine) +- Not work coordinates (relative to workpiece) +- Shows the full extent the machine can move in X, Y, Z directions + +Author: Generated for FreeCAD CAM +""" + +import FreeCAD +import Part +import Path +from Path.Machine.models.machine import MachineFactory +import os + +def get_machine_file(): + """Prompt user to select a machine configuration file.""" + # Get available machine files + machines = MachineFactory.list_configuration_files() + machine_names = [name for name, path in machines if path is not None] + + if not machine_names: + FreeCAD.Console.PrintError("No machine configuration files found.\n") + return None + + # For now, use the first machine. In a real macro, you'd use a dialog + # to let the user choose + selected_name = machine_names[0] # Default to first + selected_path = None + for name, path in machines: + if name == selected_name and path: + selected_path = path + break + + if not selected_path: + FreeCAD.Console.PrintError("Could not find selected machine file.\n") + return None + + return selected_path + +def create_machine_boundary_box(machine_file, color=(1.0, 0.0, 0.0), line_width=2.0, draw_style="Dashed"): + """Create a wireframe box showing machine boundaries. + + Args: + machine_file: Path to the machine configuration file + color: RGB tuple for wire color (default: red) + line_width: Width of the wires (default: 2.0) + draw_style: "Solid", "Dashed", or "Dotted" (default: "Dashed") + """ + + try: + # Load the machine configuration + machine = MachineFactory.load_configuration(machine_file) + FreeCAD.Console.PrintMessage(f"Loaded machine: {machine.name}\n") + + # Get axis limits + x_min = y_min = z_min = float('inf') + x_max = y_max = z_max = float('-inf') + + # Find min/max for linear axes + for axis_name, axis_obj in machine.linear_axes.items(): + if axis_name.upper() == 'X': + x_min = min(x_min, axis_obj.min_limit) + x_max = max(x_max, axis_obj.max_limit) + elif axis_name.upper() == 'Y': + y_min = min(y_min, axis_obj.min_limit) + y_max = max(y_max, axis_obj.max_limit) + elif axis_name.upper() == 'Z': + z_min = min(z_min, axis_obj.min_limit) + z_max = max(z_max, axis_obj.max_limit) + + # Check if we have valid limits + if x_min == float('inf') or y_min == float('inf') or z_min == float('inf'): + FreeCAD.Console.PrintError("Machine does not have X, Y, Z linear axes defined.\n") + return None + + FreeCAD.Console.PrintMessage(f"Machine boundaries: X({x_min:.3f}, {x_max:.3f}), Y({y_min:.3f}, {y_max:.3f}), Z({z_min:.3f}, {z_max:.3f})\n") + FreeCAD.Console.PrintMessage("Note: These are MACHINE coordinates showing the absolute travel limits.\n") + FreeCAD.Console.PrintMessage("Work coordinates would be relative to the workpiece origin.\n") + + # Create the 8 corner points of the box + p1 = FreeCAD.Vector(x_min, y_min, z_min) + p2 = FreeCAD.Vector(x_max, y_min, z_min) + p3 = FreeCAD.Vector(x_max, y_max, z_min) + p4 = FreeCAD.Vector(x_min, y_max, z_min) + p5 = FreeCAD.Vector(x_min, y_min, z_max) + p6 = FreeCAD.Vector(x_max, y_min, z_max) + p7 = FreeCAD.Vector(x_max, y_max, z_max) + p8 = FreeCAD.Vector(x_min, y_max, z_max) + + # Create edges (12 edges for wireframe box) + edges = [ + Part.makeLine(p1, p2), # bottom face + Part.makeLine(p2, p3), + Part.makeLine(p3, p4), + Part.makeLine(p4, p1), + Part.makeLine(p5, p6), # top face + Part.makeLine(p6, p7), + Part.makeLine(p7, p8), + Part.makeLine(p8, p5), + Part.makeLine(p1, p5), # vertical edges + Part.makeLine(p2, p6), + Part.makeLine(p3, p7), + Part.makeLine(p4, p8), + ] + + # Create a compound of all edges (wireframe) + compound = Part.makeCompound(edges) + + # Create a new document if none exists + if not FreeCAD.ActiveDocument: + FreeCAD.newDocument("MachineBoundary") + + # Create the shape in the document + obj = FreeCAD.ActiveDocument.addObject("Part::Feature", f"MachineBoundary_{machine.name.replace(' ', '_')}") + obj.Shape = compound + obj.Label = f"Machine Boundary: {machine.name}" + + # Set visual properties + obj.ViewObject.ShapeColor = color + obj.ViewObject.LineWidth = line_width + obj.ViewObject.DrawStyle = draw_style + + FreeCAD.ActiveDocument.recompute() + + FreeCAD.Console.PrintMessage(f"Created machine boundary box for {machine.name}\n") + return obj + + except Exception as e: + FreeCAD.Console.PrintError(f"Error creating machine boundary box: {str(e)}\n") + return None + +def main(): + """Main macro function.""" + FreeCAD.Console.PrintMessage("FreeCAD Macro: Generate Machine Boundary Box\n") + + # Get machine file + machine_file = get_machine_file() + if not machine_file: + return + + # Create the boundary box with customizable appearance + # You can change these parameters: + # color: (R, G, B) tuple, e.g., (1.0, 0.0, 0.0) for red, (0.0, 1.0, 0.0) for green + # line_width: thickness of the wires + # draw_style: "Solid", "Dashed", or "Dotted" + obj = create_machine_boundary_box(machine_file, + color=(1.0, 0.0, 0.0), # Red + line_width=2.0, + draw_style="Dashed") # Broken/dashed lines + if obj: + FreeCAD.Console.PrintMessage("Macro completed successfully.\n") + else: + FreeCAD.Console.PrintError("Macro failed.\n") + +# Run the macro +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui index 73482d95e4..d4646a2db1 100644 --- a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui +++ b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui @@ -15,20 +15,12 @@ - + 0 - - - - 0 - 0 - 695 - 308 - - - + + General @@ -118,16 +110,8 @@ If left empty no template will be preselected. - - - - 0 - 0 - 695 - 480 - - - + + Post processor @@ -334,16 +318,8 @@ See the file save policy below on how to deal with name conflicts. - - - - 0 - 0 - 674 - 619 - - - + + Setup diff --git a/src/Mod/CAM/Path/Dressup/Gui/Preferences.py b/src/Mod/CAM/Path/Dressup/Gui/Preferences.py index 0d42921161..eb3e6630dc 100644 --- a/src/Mod/CAM/Path/Dressup/Gui/Preferences.py +++ b/src/Mod/CAM/Path/Dressup/Gui/Preferences.py @@ -36,15 +36,14 @@ def RegisterDressup(dressup): class DressupPreferencesPage: def __init__(self, parent=None): - self.form = QtGui.QToolBox() + self.form = QtGui.QWidget() self.form.setWindowTitle(translate("CAM_PreferencesPathDressup", "Dressups")) + + layout = QtGui.QVBoxLayout(self.form) pages = [] for dressup in _dressups: page = dressup.preferencesPage() - if hasattr(page, "icon") and page.icon: - self.form.addItem(page.form, page.icon, page.label) - else: - self.form.addItem(page.form, page.label) + layout.addWidget(page.form) pages.append(page) self.pages = pages diff --git a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py index 95bf2d8bda..a9f171f415 100644 --- a/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py +++ b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py @@ -302,13 +302,12 @@ class MachineEditorDialog(QtGui.QDialog): def __init__(self, machine_filename: Optional[str] = None, parent=None): super().__init__(parent) - self.setWindowTitle(translate("CAM_MachineEditor", "Machine Editor")) self.setMinimumSize(700, 900) self.resize(700, 900) self.current_units = "metric" - # Initialize machine object first (needed by setup_post_tab) + # Initialize machine object first (needed by setup methods) self.filename = machine_filename self.machine = None # Store the Machine object @@ -317,6 +316,16 @@ class MachineEditorDialog(QtGui.QDialog): 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 @@ -328,15 +337,33 @@ class MachineEditorDialog(QtGui.QDialog): self.tabs.addTab(self.machine_tab, translate("CAM_MachineEditor", "Machine")) self.setup_machine_tab() - # Post tab - self.post_tab = QtGui.QWidget() - self.tabs.addTab(self.post_tab, translate("CAM_MachineEditor", "Post Processor")) - self.setup_post_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.post_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.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() @@ -376,6 +403,9 @@ class MachineEditorDialog(QtGui.QDialog): # 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() @@ -496,6 +526,11 @@ class MachineEditorDialog(QtGui.QDialog): """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.""" @@ -525,6 +560,52 @@ class MachineEditorDialog(QtGui.QDialog): 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: @@ -639,13 +720,30 @@ class MachineEditorDialog(QtGui.QDialog): self.type_combo.currentIndexChanged.connect(self._on_type_changed) layout.addRow(translate("CAM_MachineEditor", "Type"), self.type_combo) - self.spindle_count_combo = QtGui.QComboBox() - for i in range(1, 10): # 1 to 9 spindles - self.spindle_count_combo.addItem(str(i), i) - self.spindle_count_combo.currentIndexChanged.connect(self.update_spindles) - layout.addRow( - translate("CAM_MachineEditor", "Number of spindles"), self.spindle_count_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")) @@ -656,7 +754,25 @@ class MachineEditorDialog(QtGui.QDialog): # 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) @@ -701,8 +817,6 @@ class MachineEditorDialog(QtGui.QDialog): self.axes_group.setVisible(False) return - axes_form = QtGui.QFormLayout() - # 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 [] @@ -852,7 +966,10 @@ class MachineEditorDialog(QtGui.QDialog): axis_grid.addWidget(QtGui.QLabel("Prefer+"), 1, 4, QtCore.Qt.AlignRight) axis_grid.addWidget(prefer_positive, 1, 5) - rotary_layout.addRow(f"{axis}", axis_grid) + 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, @@ -874,26 +991,6 @@ class MachineEditorDialog(QtGui.QDialog): input fields for name, ID, power, speed, and tool holder. Updates Machine.spindles directly. """ - # Update machine spindles with current edits before rebuilding UI - if hasattr(self, "spindle_edits") and self.machine: - # Resize spindles list to match current edits - while len(self.machine.spindles) < len(self.spindle_edits): - self.machine.spindles.append(Spindle(name="")) - while len(self.machine.spindles) > len(self.spindle_edits): - self.machine.spindles.pop() - - # Update each spindle from UI - for i, edits in enumerate(self.spindle_edits): - spindle = self.machine.spindles[i] - spindle.name = edits["name"].text() - spindle.id = edits["id"].text() - spindle.max_power_kw = edits["max_power_kw"].value() - spindle.max_rpm = edits["max_rpm"].value() - spindle.min_rpm = edits["min_rpm"].value() - spindle.tool_change = edits["tool_change"].itemData( - edits["tool_change"].currentIndex() - ) - # Clear existing spindle tabs - this properly disconnects signals while self.spindles_tabs.count() > 0: tab = self.spindles_tabs.widget(0) @@ -903,9 +1000,9 @@ class MachineEditorDialog(QtGui.QDialog): self.spindles_tabs.removeTab(0) self.spindle_edits = [] - count = self.spindle_count_combo.itemData(self.spindle_count_combo.currentIndex()) + count = len(self.machine.spindles) if self.machine else 1 - # Ensure machine has enough spindles + # Ensure machine has at least 1 spindle if self.machine: while len(self.machine.spindles) < count: self.machine.spindles.append( @@ -996,8 +1093,8 @@ class MachineEditorDialog(QtGui.QDialog): } ) - def setup_post_tab(self): - """Set up the post processor configuration tab dynamically from Machine dataclass.""" + 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) @@ -1005,61 +1102,55 @@ class MachineEditorDialog(QtGui.QDialog): layout = QtGui.QVBoxLayout(scroll_widget) scroll.setWidget(scroll_widget) - main_layout = QtGui.QVBoxLayout(self.post_tab) + main_layout = QtGui.QVBoxLayout(self.output_tab) main_layout.addWidget(scroll) - # Store widgets for later population - self.post_widgets = {} - - # === Post Processor Selection (special handling for combo box) === - pp_group = QtGui.QGroupBox("Post Processor Selection") - pp_layout = QtGui.QFormLayout(pp_group) - - 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) - pp_layout.addRow("Post Processor", self.post_processor_combo) - self.post_widgets["postprocessor_file_name"] = 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) - pp_layout.addRow("Arguments", self.post_processor_args_edit) - self.post_widgets["postprocessor_args"] = self.post_processor_args_edit - - layout.addWidget(pp_group) - - # === Dynamically generate groups for nested dataclasses === + # === Output Options === if self.machine: - # Output Options 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") - # G-Code Blocks + 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") - # Processing Options + 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" ) @@ -1068,9 +1159,6 @@ class MachineEditorDialog(QtGui.QDialog): layout.addStretch() - # Cache for post processors - self.processor = {} - def _connect_widgets_to_machine(self, widgets: Dict[str, QtGui.QWidget], parent_path: str): """Connect widgets to update Machine object fields. @@ -1267,13 +1355,13 @@ class MachineEditorDialog(QtGui.QDialog): 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) + # 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) + # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) def populate_from_machine(self, machine: Machine): """Populate UI fields from Machine object. @@ -1285,14 +1373,31 @@ class MachineEditorDialog(QtGui.QDialog): 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()) @@ -1300,28 +1405,23 @@ class MachineEditorDialog(QtGui.QDialog): # Update axes UI after loading machine data self.update_axes() - spindles = machine.spindles - spindle_count = len(spindles) - if spindle_count == 0: - spindle_count = 1 # Default to 1 if none - spindle_count = min(spindle_count, 9) # Cap at 9 - self.spindle_count_combo.setCurrentText(str(spindle_count)) - self.update_spindles() # Update spindles after setting count (will populate from machine.spindles) + # 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"): - # Post processor selection - 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) - - # Post processor arguments - self.post_processor_args_edit.setText(machine.postprocessor_args) - self.updatePostProcessorTooltip() - # Update all post-processor widgets from machine object self._populate_post_widgets_from_machine(machine) diff --git a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py index 6900846bd6..4678029d40 100644 --- a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py +++ b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py @@ -39,7 +39,7 @@ class JobPreferencesPage: import FreeCADGui self.form = FreeCADGui.PySideUic.loadUi(":preferences/PathJob.ui") - self.form.toolBox.setCurrentIndex(0) # Take that qt designer! + self.form.tabWidget.setCurrentIndex(0) # Take that qt designer! self.postProcessorDefaultTooltip = self.form.defaultPostProcessor.toolTip() self.postProcessorArgsDefaultTooltip = self.form.defaultPostProcessorArgs.toolTip() diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index 5f1e763171..252de63165 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -48,20 +48,21 @@ def _is_writable_dir(path: pathlib.Path) -> bool: class AssetPreferencesPage: def __init__(self, parent=None): - self.form = QtGui.QToolBox() + self.form = QtGui.QWidget() self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets")) - asset_path_widget = QtGui.QWidget() - main_layout = QtGui.QHBoxLayout(asset_path_widget) + # Set up main layout directly on the form + self.main_layout = QtGui.QVBoxLayout(self.form) # Create widgets - self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset directory")) + self.assets_group = QtGui.QGroupBox(translate("CAM_PreferencesAssets", "Asset Location")) + self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Default path")) self.asset_path_edit = QtGui.QLineEdit() self.asset_path_note_label = QtGui.QLabel( translate( "CAM_PreferencesAssets", "Note: Select the directory that will contain the " - "Tool folder with Bit/, Shape/, and Library/ subfolders.", + "Tools folder with Bit/, Shape/, and Library/ subfolders and the Machines/ folder.", ) ) self.asset_path_note_label.setWordWrap(True) @@ -76,39 +77,52 @@ class AssetPreferencesPage: font.setItalic(True) self.asset_path_note_label.setFont(font) - # Layout for asset path section - edit_button_layout = QtGui.QGridLayout() - edit_button_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop) - edit_button_layout.setRowStretch(3, 1) + # Assets group box + self.assets_layout = QtGui.QGridLayout(self.assets_group) + self.assets_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop) + self.main_layout.addWidget(self.assets_group) - main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop) + # Machines group box + self.machines_group = QtGui.QGroupBox(translate("CAM_PreferencesAssets", "Machines")) + self.machines_layout = QtGui.QVBoxLayout(self.machines_group) - self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets")) + self.warning_label = QtGui.QLabel( + translate( + "CAM_PreferencesAssets", + "Warning: Machine definition is an experimental feature. Changes " + "made here will not affect any CAM functionality", + ) + ) + self.warning_label.setWordWrap(True) + warning_font = self.warning_label.font() + warning_font.setItalic(True) + self.warning_label.setFont(warning_font) + self.warning_label.setContentsMargins(0, 0, 0, 10) + self.machines_layout.addWidget(self.warning_label) - # Integrate machines list into the Assets panel - machines_list_layout = QtGui.QVBoxLayout() - machines_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Machines")) - machines_list_layout.addWidget(machines_label) + self.machines_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Machines")) + self.machines_layout.addWidget(self.machines_label) self.machines_list = QtGui.QListWidget() - machines_list_layout.addWidget(self.machines_list) + self.machines_layout.addWidget(self.machines_list) # Buttons: Add / Edit / Delete - btn_layout = QtGui.QHBoxLayout() + self.btn_layout = QtGui.QHBoxLayout() self.add_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Add")) self.edit_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Edit")) self.delete_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Delete")) - btn_layout.addWidget(self.add_machine_btn) - btn_layout.addWidget(self.edit_machine_btn) - btn_layout.addWidget(self.delete_machine_btn) - machines_list_layout.addLayout(btn_layout) + self.btn_layout.addWidget(self.add_machine_btn) + self.btn_layout.addWidget(self.edit_machine_btn) + self.btn_layout.addWidget(self.delete_machine_btn) + self.machines_layout.addLayout(self.btn_layout) - # Insert the machines list directly under the path controls - edit_button_layout.addLayout(machines_list_layout, 2, 0, 1, 4) + self.machines_layout.addStretch() # Prevent the list from stretching + + self.main_layout.addWidget(self.machines_group) # Wire up buttons self.add_machine_btn.clicked.connect(self.add_machine)