From dc7991bd9db6b95a525c2a0563d675ab4b176dc0 Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Tue, 9 Dec 2025 13:07:18 -0500 Subject: [PATCH] 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