diff --git a/generate_machine_box.py b/generate_machine_box.py new file mode 100644 index 0000000000..47584c57b7 --- /dev/null +++ b/generate_machine_box.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +FreeCAD Macro: Generate Machine Boundary Box + +This macro creates a wireframe box representing the working envelope +of a CNC machine based on its configuration. + +COORDINATE SYSTEM: +- Uses MACHINE coordinates (absolute travel limits of the machine) +- Not work coordinates (relative to workpiece) +- Shows the full extent the machine can move in X, Y, Z directions + +Author: Generated for FreeCAD CAM +""" + +import FreeCAD +import Part +import Path +from Path.Machine.models.machine import MachineFactory +import os + +def get_machine_file(): + """Prompt user to select a machine configuration file.""" + # Get available machine files + machines = MachineFactory.list_configuration_files() + machine_names = [name for name, path in machines if path is not None] + + if not machine_names: + FreeCAD.Console.PrintError("No machine configuration files found.\n") + return None + + # For now, use the first machine. In a real macro, you'd use a dialog + # to let the user choose + selected_name = machine_names[0] # Default to first + selected_path = None + for name, path in machines: + if name == selected_name and path: + selected_path = path + break + + if not selected_path: + FreeCAD.Console.PrintError("Could not find selected machine file.\n") + return None + + return selected_path + +def create_machine_boundary_box(machine_file, color=(1.0, 0.0, 0.0), line_width=2.0, draw_style="Dashed"): + """Create a wireframe box showing machine boundaries. + + Args: + machine_file: Path to the machine configuration file + color: RGB tuple for wire color (default: red) + line_width: Width of the wires (default: 2.0) + draw_style: "Solid", "Dashed", or "Dotted" (default: "Dashed") + """ + + try: + # Load the machine configuration + machine = MachineFactory.load_configuration(machine_file) + FreeCAD.Console.PrintMessage(f"Loaded machine: {machine.name}\n") + + # Get axis limits + x_min = y_min = z_min = float('inf') + x_max = y_max = z_max = float('-inf') + + # Find min/max for linear axes + for axis_name, axis_obj in machine.linear_axes.items(): + if axis_name.upper() == 'X': + x_min = min(x_min, axis_obj.min_limit) + x_max = max(x_max, axis_obj.max_limit) + elif axis_name.upper() == 'Y': + y_min = min(y_min, axis_obj.min_limit) + y_max = max(y_max, axis_obj.max_limit) + elif axis_name.upper() == 'Z': + z_min = min(z_min, axis_obj.min_limit) + z_max = max(z_max, axis_obj.max_limit) + + # Check if we have valid limits + if x_min == float('inf') or y_min == float('inf') or z_min == float('inf'): + FreeCAD.Console.PrintError("Machine does not have X, Y, Z linear axes defined.\n") + return None + + FreeCAD.Console.PrintMessage(f"Machine boundaries: X({x_min:.3f}, {x_max:.3f}), Y({y_min:.3f}, {y_max:.3f}), Z({z_min:.3f}, {z_max:.3f})\n") + FreeCAD.Console.PrintMessage("Note: These are MACHINE coordinates showing the absolute travel limits.\n") + FreeCAD.Console.PrintMessage("Work coordinates would be relative to the workpiece origin.\n") + + # Create the 8 corner points of the box + p1 = FreeCAD.Vector(x_min, y_min, z_min) + p2 = FreeCAD.Vector(x_max, y_min, z_min) + p3 = FreeCAD.Vector(x_max, y_max, z_min) + p4 = FreeCAD.Vector(x_min, y_max, z_min) + p5 = FreeCAD.Vector(x_min, y_min, z_max) + p6 = FreeCAD.Vector(x_max, y_min, z_max) + p7 = FreeCAD.Vector(x_max, y_max, z_max) + p8 = FreeCAD.Vector(x_min, y_max, z_max) + + # Create edges (12 edges for wireframe box) + edges = [ + Part.makeLine(p1, p2), # bottom face + Part.makeLine(p2, p3), + Part.makeLine(p3, p4), + Part.makeLine(p4, p1), + Part.makeLine(p5, p6), # top face + Part.makeLine(p6, p7), + Part.makeLine(p7, p8), + Part.makeLine(p8, p5), + Part.makeLine(p1, p5), # vertical edges + Part.makeLine(p2, p6), + Part.makeLine(p3, p7), + Part.makeLine(p4, p8), + ] + + # Create a compound of all edges (wireframe) + compound = Part.makeCompound(edges) + + # Create a new document if none exists + if not FreeCAD.ActiveDocument: + FreeCAD.newDocument("MachineBoundary") + + # Create the shape in the document + obj = FreeCAD.ActiveDocument.addObject("Part::Feature", f"MachineBoundary_{machine.name.replace(' ', '_')}") + obj.Shape = compound + obj.Label = f"Machine Boundary: {machine.name}" + + # Set visual properties + obj.ViewObject.ShapeColor = color + obj.ViewObject.LineWidth = line_width + obj.ViewObject.DrawStyle = draw_style + + FreeCAD.ActiveDocument.recompute() + + FreeCAD.Console.PrintMessage(f"Created machine boundary box for {machine.name}\n") + return obj + + except Exception as e: + FreeCAD.Console.PrintError(f"Error creating machine boundary box: {str(e)}\n") + return None + +def main(): + """Main macro function.""" + FreeCAD.Console.PrintMessage("FreeCAD Macro: Generate Machine Boundary Box\n") + + # Get machine file + machine_file = get_machine_file() + if not machine_file: + return + + # Create the boundary box with customizable appearance + # You can change these parameters: + # color: (R, G, B) tuple, e.g., (1.0, 0.0, 0.0) for red, (0.0, 1.0, 0.0) for green + # line_width: thickness of the wires + # draw_style: "Solid", "Dashed", or "Dotted" + obj = create_machine_boundary_box(machine_file, + color=(1.0, 0.0, 0.0), # Red + line_width=2.0, + draw_style="Dashed") # Broken/dashed lines + if obj: + FreeCAD.Console.PrintMessage("Macro completed successfully.\n") + else: + FreeCAD.Console.PrintError("Macro failed.\n") + +# Run the macro +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/Mod/CAM/CAMTests/TestMachine.py b/src/Mod/CAM/CAMTests/TestMachine.py new file mode 100644 index 0000000000..2c63b55e92 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestMachine.py @@ -0,0 +1,410 @@ +# -*- 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 tempfile +import pathlib +import CAMTests.PathTestUtils as PathTestUtils +from Path.Machine.models.machine import ( + Machine, + Spindle, + OutputOptions, + GCodeBlocks, + ProcessingOptions, + 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) + + 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", + 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.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(filepath) + + # Verify all components + self.assertEqual(loaded.name, machine.name) + self.assertEqual(loaded.manufacturer, machine.manufacturer) + self.assertEqual(len(loaded.spindles), 1) + self.assertFalse(loaded.output.output_comments) + self.assertEqual(loaded.output.axis_precision, 4) + self.assertEqual(loaded.output.line_increment, 5) 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/Gui/Resources/preferences/PathJob.ui b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui index 73482d95e4..d4646a2db1 100644 --- a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui +++ b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui @@ -15,20 +15,12 @@ - + 0 - - - - 0 - 0 - 695 - 308 - - - + + General @@ -118,16 +110,8 @@ If left empty no template will be preselected. - - - - 0 - 0 - 695 - 480 - - - + + Post processor @@ -334,16 +318,8 @@ See the file save policy below on how to deal with name conflicts. - - - - 0 - 0 - 674 - 619 - - - + + Setup diff --git a/src/Mod/CAM/Path/Dressup/Gui/Preferences.py b/src/Mod/CAM/Path/Dressup/Gui/Preferences.py index 0d42921161..eb3e6630dc 100644 --- a/src/Mod/CAM/Path/Dressup/Gui/Preferences.py +++ b/src/Mod/CAM/Path/Dressup/Gui/Preferences.py @@ -36,15 +36,14 @@ def RegisterDressup(dressup): class DressupPreferencesPage: def __init__(self, parent=None): - self.form = QtGui.QToolBox() + self.form = QtGui.QWidget() self.form.setWindowTitle(translate("CAM_PreferencesPathDressup", "Dressups")) + + layout = QtGui.QVBoxLayout(self.form) pages = [] for dressup in _dressups: page = dressup.preferencesPage() - if hasattr(page, "icon") and page.icon: - self.form.addItem(page.form, page.icon, page.label) - else: - self.form.addItem(page.form, page.label) + layout.addWidget(page.form) pages.append(page) self.pages = pages diff --git a/src/Mod/CAM/Path/Machine/models/__init__.py b/src/Mod/CAM/Path/Machine/models/__init__.py new file mode 100644 index 0000000000..979362e01c --- /dev/null +++ b/src/Mod/CAM/Path/Machine/models/__init__.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Billy Huddleston * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +from .machine import ( + Machine, + MachineFactory, + LinearAxis, + RotaryAxis, + Spindle, + MachineUnits, + OutputUnits, + OutputOptions, + GCodeBlocks, + ProcessingOptions, +) + +__all__ = [ + "Machine", + "MachineFactory", + "LinearAxis", + "RotaryAxis", + "Spindle", + "MachineUnits", + "OutputUnits", + "OutputOptions", + "GCodeBlocks", + "ProcessingOptions", +] diff --git a/src/Mod/CAM/Path/Machine/models/machine.py b/src/Mod/CAM/Path/Machine/models/machine.py new file mode 100644 index 0000000000..616725cfb4 --- /dev/null +++ b/src/Mod/CAM/Path/Machine/models/machine.py @@ -0,0 +1,1398 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Billy Huddleston * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +import json +import Path +import FreeCAD +import pathlib +from dataclasses import dataclass, field +from typing import Dict, Any, List, Optional, Tuple, Callable +from collections import namedtuple +from enum import Enum + + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +# Reference axis vectors +RefAxes = namedtuple("RefAxes", ["x", "y", "z"]) +refAxis = RefAxes( + FreeCAD.Vector(1, 0, 0), # x: linear direction + FreeCAD.Vector(0, 1, 0), # y: linear direction + FreeCAD.Vector(0, 0, 1), # z: linear direction +) + +RefRotAxes = namedtuple("RefRotAxes", ["a", "b", "c"]) +refRotAxis = RefRotAxes( + FreeCAD.Vector(1, 0, 0), # a: rotational direction + FreeCAD.Vector(0, 1, 0), # b: rotational direction + FreeCAD.Vector(0, 0, 1), # c: rotational direction +) + + +# ============================================================================ +# Enums for Machine Configuration +# ============================================================================ + + +class MachineUnits(Enum): + """Machine unit system.""" + + METRIC = "G21" + IMPERIAL = "G20" + + +class OutputUnits(Enum): + """Output unit system for G-code generation.""" + + METRIC = "metric" + IMPERIAL = "imperial" + + +# ============================================================================ +# Post-Processor Configuration Dataclasses +# ============================================================================ + + +@dataclass +class OutputOptions: + """Controls what gets included in the G-code output and its formatting.""" + + # These options control conversion of Path Objects to actual gcode. + + output_units: OutputUnits = OutputUnits.METRIC # G-code output units + + # Line formatting options + command_space: str = " " + comment_symbol: str = "(" + end_of_line_chars: str = "\n" + line_increment: int = 10 + line_number_start: int = 100 + line_numbers: bool = False + line_number_prefix: str = "N" + + # Output content options + output_comments: bool = True # Renamed from 'comments' + output_blank_lines: bool = True # Renamed from 'blank_lines' + output_bcnc_comments: bool = True + output_header: bool = True # Renamed from 'header' + output_labels: bool = False # Renamed from 'path_labels' + output_operation_labels: bool = True # Renamed from 'show_operation_labels' + + # Header content options + list_tools_in_header: bool = False # Renamed from 'list_tools_in_preamble' + list_fixtures_in_header: bool = True + machine_name_in_header: bool = False # Renamed from 'machine_name' + description_in_header: bool = True + date_in_header: bool = True + document_name_in_header: bool = True + + # Filter options + filter_double_parameters: bool = False # Renamed from 'output_double_parameters' + filter_double_commands: bool = False # Renamed from 'modal' (moved from ProcessingOptions) + + # Numeric precision settings + axis_precision: int = 3 # Decimal places for axis coordinates + feed_precision: int = 3 # Decimal places for feed rates + spindle_precision: int = 0 # Renamed from 'spindle_decimals' + + +@dataclass +class GCodeBlocks: + """ + G-code block templates for various lifecycle hooks. + + These templates are inserted at specific points during postprocessing + to provide customization points for machine-specific behavior. + """ + + safetyblock: str = "" # Safety commands (G40, G49, etc.) Reset machine to known safe condition + + # Legacy aliases (maintained for compatibility) + preamble: str = "" # Typically inserted at start of job + + # Job lifecycle + pre_job: str = "" + + # Operation lifecycle + pre_operation: str = "" + post_operation: str = "" + + # Tool change lifecycle + pre_tool_change: str = "" + post_tool_change: str = "" + tool_return: str = "" # Return to tool change position + + # Fixture/WCS change lifecycle + pre_fixture_change: str = "" + post_fixture_change: str = "" + + # Rotary axis lifecycle + pre_rotary_move: str = "" + post_rotary_move: str = "" + + post_job: str = "" + postamble: str = "" # Typically inserted at end of job + + +@dataclass +class ProcessingOptions: + """Processing and transformation options.""" + + # Conversion and expansion of Path Objects. Does not affect final gcode generation + + drill_cycles_to_translate: List[str] = field( + default_factory=lambda: ["G73", "G81", "G82", "G83"] + ) + + show_machine_units: bool = True + spindle_wait: float = 0.0 # seconds + split_arcs: bool = False + suppress_commands: List[str] = field(default_factory=list) + tool_before_change: bool = False # Output T before M6 (e.g., T1 M6 instead of M6 T1) + tool_change: bool = True # Enable tool change commands + translate_drill_cycles: bool = False + + return_to: Optional[Tuple[float, float, float]] = None # (x, y, z) or None + + +# ============================================================================ +# Machine Component Dataclasses +# ============================================================================ + + +@dataclass +class LinearAxis: + """Represents a single linear axis in a machine configuration""" + + name: str + direction_vector: FreeCAD.Vector + min_limit: float = 0 + max_limit: float = 1000 + max_velocity: float = 10000 + sequence: int = 0 + + def __post_init__(self): + """Normalize direction vector and validate parameters after initialization""" + self.direction_vector = self.direction_vector.normalize() + + # Validate limits + if self.min_limit >= self.max_limit: + Path.Log.warning( + f"LinearAxis {self.name}: min_limit ({self.min_limit}) >= max_limit ({self.max_limit})" + ) + + # Validate velocity + if self.max_velocity <= 0: + Path.Log.warning( + f"LinearAxis {self.name}: max_velocity must be positive, got {self.max_velocity}" + ) + + def is_valid_position(self, position): + """Check if a position is within this axis's limits""" + return self.min_limit <= position <= self.max_limit + + def to_dict(self): + """Serialize to dictionary for JSON persistence""" + return { + "name": self.name, + "direction_vector": [ + self.direction_vector.x, + self.direction_vector.y, + self.direction_vector.z, + ], + "min_limit": self.min_limit, + "max_limit": self.max_limit, + "max_velocity": self.max_velocity, + "sequence": self.sequence, + } + + @classmethod + def from_dict(cls, data): + """Deserialize from dictionary""" + vec = FreeCAD.Vector( + data["direction_vector"][0], data["direction_vector"][1], data["direction_vector"][2] + ) + return cls( + data["name"], + vec, + data.get("min_limit", 0), + data.get("max_limit", 1000), + data.get("max_velocity", 10000), + data.get("sequence", 0), + ) + + +@dataclass +class RotaryAxis: + """Represents a single rotary axis in a machine configuration""" + + name: str + rotation_vector: FreeCAD.Vector + min_limit: float = -360 + max_limit: float = 360 + max_velocity: float = 36000 + sequence: int = 0 + prefer_positive: bool = True + + def __post_init__(self): + """Normalize rotation vector and validate parameters after initialization""" + if self.rotation_vector is None or self.rotation_vector.Length < 1e-6: + # Default to Z-axis rotation if vector is null or zero-length + self.rotation_vector = FreeCAD.Vector(0, 0, 1) + else: + self.rotation_vector = self.rotation_vector.normalize() + + # Validate limits + if self.min_limit >= self.max_limit: + Path.Log.warning( + f"RotaryAxis {self.name}: min_limit ({self.min_limit}) >= max_limit ({self.max_limit})" + ) + + # Validate velocity + if self.max_velocity <= 0: + Path.Log.warning( + f"RotaryAxis {self.name}: max_velocity must be positive, got {self.max_velocity}" + ) + + def is_valid_angle(self, angle): + """Check if an angle is within this axis's limits""" + return self.min_limit <= angle <= self.max_limit + + def to_dict(self): + """Serialize to dictionary for JSON persistence""" + return { + "name": self.name, + "rotation_vector": [ + self.rotation_vector.x, + self.rotation_vector.y, + self.rotation_vector.z, + ], + "min_limit": self.min_limit, + "max_limit": self.max_limit, + "max_velocity": self.max_velocity, + "sequence": self.sequence, + "prefer_positive": self.prefer_positive, + } + + @classmethod + def from_dict(cls, data): + """Deserialize from dictionary""" + vec = FreeCAD.Vector( + data["rotation_vector"][0], data["rotation_vector"][1], data["rotation_vector"][2] + ) + return cls( + data["name"], + vec, + data["min_limit"], + data["max_limit"], + data.get("max_velocity", 36000), + data.get("sequence", 0), + data.get("prefer_positive", True), + ) + + +@dataclass +class Spindle: + """Represents a single spindle in a machine configuration""" + + name: str + id: Optional[str] = None + max_power_kw: float = 0 + max_rpm: float = 0 + min_rpm: float = 0 + tool_change: str = "manual" + tool_axis: Optional[FreeCAD.Vector] = None + + def __post_init__(self): + """Set default tool axis if not provided""" + if self.tool_axis is None: + self.tool_axis = FreeCAD.Vector(0, 0, -1) + + def to_dict(self): + """Serialize to dictionary for JSON persistence""" + data = { + "name": self.name, + "max_power_kw": self.max_power_kw, + "max_rpm": self.max_rpm, + "min_rpm": self.min_rpm, + "tool_change": self.tool_change, + "tool_axis": [self.tool_axis.x, self.tool_axis.y, self.tool_axis.z], + } + if self.id is not None: + data["id"] = self.id + return data + + @classmethod + def from_dict(cls, data): + """Deserialize from dictionary""" + tool_axis_data = data.get("tool_axis", [0, 0, -1]) + tool_axis = FreeCAD.Vector(tool_axis_data[0], tool_axis_data[1], tool_axis_data[2]) + return cls( + data["name"], + data.get("id"), + data.get("max_power_kw", 0), + data.get("max_rpm", 0), + data.get("min_rpm", 0), + data.get("tool_change", "manual"), + tool_axis, + ) + + +@dataclass +class Machine: + """Represents a CNC machine configuration with axes, spindles, and output settings. + + This class encapsulates all machine parameters including linear and rotary axes, + spindles, post-processor settings, and G-code generation options. It provides + methods for serialization to/from JSON and various factory methods for common + machine configurations (3-axis, 4-axis, 5-axis). + """ + + """ + Unified machine configuration combining physical machine definition + with post-processor settings. + + This is the single source of truth for all machine-related configuration, + including physical capabilities (axes, spindles) and G-code generation + preferences (output options, formatting, processing). + """ + + # ======================================================================== + # PHYSICAL MACHINE DEFINITION + # ======================================================================== + + # Basic identification + name: str = "Default Machine" + manufacturer: str = "" + description: str = "" + + # Machine components + linear_axes: Dict[str, LinearAxis] = field(default_factory=dict) + rotary_axes: Dict[str, RotaryAxis] = field(default_factory=dict) + spindles: List[Spindle] = field(default_factory=list) + + # Coordinate system + reference_system: Dict[str, FreeCAD.Vector] = field( + default_factory=lambda: { + "X": FreeCAD.Vector(1, 0, 0), + "Y": FreeCAD.Vector(0, 1, 0), + "Z": FreeCAD.Vector(0, 0, 1), + } + ) + tool_axis: FreeCAD.Vector = field(default_factory=lambda: FreeCAD.Vector(0, 0, -1)) + + # Rotary axis configuration + primary_rotary_axis: Optional[str] = None + secondary_rotary_axis: Optional[str] = None + compound_moves: bool = True + prefer_positive_rotation: bool = True + + # Units and versioning + configuration_units: str = "metric" # Internal storage for configuration_units + version: int = 1 + freecad_version: str = field(init=False) + + # ======================================================================== + # POST-PROCESSOR CONFIGURATION + # ======================================================================== + + # Output options + output: OutputOptions = field(default_factory=OutputOptions) + blocks: GCodeBlocks = field(default_factory=GCodeBlocks) + processing: ProcessingOptions = field(default_factory=ProcessingOptions) + + # Post-processor selection + postprocessor_file_name: str = "" + postprocessor_args: str = "" + + # Dynamic state (for runtime) + parameter_functions: Dict[str, Callable] = field(default_factory=dict) + + def __post_init__(self): + """Initialize computed fields and handle backward compatibility""" + # Initialize computed fields + self.freecad_version = ".".join(FreeCAD.Version()[0:3]) + + # Validate configuration_units + if self.configuration_units not in ["metric", "imperial"]: + raise ValueError( + f"configuration_units must be 'metric' or 'imperial', got '{self.configuration_units}'" + ) + + # Backward compatibility for renamed fields + # Support old field names by creating aliases + if not hasattr(self.output, "filter_double_parameters"): + self.output.filter_double_parameters = getattr( + self.output, "output_double_parameters", False + ) + if not hasattr(self.output, "filter_double_commands"): + self.output.filter_double_commands = getattr(self.processing, "modal", False) + + # ======================================================================== + # PROPERTIES - Bridge between physical machine and post-processor + # ======================================================================== + + @property + def machine_units(self) -> MachineUnits: + """Get machine configuration units as enum""" + return ( + MachineUnits.METRIC if self.configuration_units == "metric" else MachineUnits.IMPERIAL + ) + + @property + def output_machine_units(self) -> MachineUnits: + """Get output units as enum for G-code generation""" + return ( + MachineUnits.METRIC + if self.output.output_units == OutputUnits.METRIC + else MachineUnits.IMPERIAL + ) + + @property + def gcode_units(self) -> MachineUnits: + """Get G-code output units as enum for post-processor""" + return ( + MachineUnits.METRIC + if self.output.output_units == OutputUnits.METRIC + else MachineUnits.IMPERIAL + ) + + @property + def unit_format(self) -> str: + """Get machine configuration unit format string (mm or in)""" + return "mm" if self.configuration_units == "metric" else "in" + + @property + def output_unit_format(self) -> str: + """Get G-code output unit format string (mm or in)""" + return "mm" if self.output.output_units == OutputUnits.METRIC else "in" + + @property + def unit_speed_format(self) -> str: + """Get machine configuration unit speed format string (mm/min or in/min)""" + return "mm/min" if self.configuration_units == "metric" else "in/min" + + @property + def output_unit_speed_format(self) -> str: + """Get G-code output unit speed format string (mm/min or in/min)""" + return "mm/min" if self.output.output_units == OutputUnits.METRIC else "in/min" + + @property + def machine_type(self) -> str: + """ + Determine machine type based on available axes. + Returns one of: 'xyz', 'xyza', 'xyzb', 'xyzac', 'xyzbc', or 'custom' + """ + if not all(axis in self.linear_axes for axis in ["X", "Y", "Z"]): + return "custom" + + rot_axes = set(self.rotary_axes.keys()) + + # Check for 5-axis configurations + if {"A", "C"}.issubset(rot_axes): + return "xyzac" + if {"B", "C"}.issubset(rot_axes): + return "xyzbc" + + # Check for 4-axis configurations + if "A" in rot_axes: + return "xyza" + if "B" in rot_axes: + return "xyzb" + + # 3-axis configuration + return "xyz" + + @property + def has_rotary_axes(self) -> bool: + """Check if machine has any rotary axes""" + return len(self.rotary_axes) > 0 + + @property + def is_5axis(self) -> bool: + """Check if machine is 5-axis (2 rotary axes)""" + return len(self.rotary_axes) >= 2 + + @property + def is_4axis(self) -> bool: + """Check if machine is 4-axis (1 rotary axis)""" + return len(self.rotary_axes) == 1 + + @property + def motion_commands(self) -> List[str]: + """Get list of motion commands that change position""" + import Path.Geom as PathGeom + + return PathGeom.CmdMoveAll + + @property + def rapid_moves(self) -> List[str]: + """Get list of rapid move commands""" + import Path.Geom as PathGeom + + return PathGeom.CmdMoveRapid + + # ======================================================================== + # BUILDER METHODS - Fluent interface for machine construction + # ======================================================================== + + def add_linear_axis( + self, name, direction_vector, min_limit=0, max_limit=1000, max_velocity=10000 + ): + """Add a linear axis to the configuration""" + self.linear_axes[name] = LinearAxis( + name, direction_vector, min_limit, max_limit, max_velocity + ) + return self + + def add_rotary_axis( + self, name, rotation_vector, min_limit=-360, max_limit=360, max_velocity=36000 + ): + """Add a rotary axis to the configuration""" + self.rotary_axes[name] = RotaryAxis( + name, rotation_vector, min_limit, max_limit, max_velocity + ) + return self + + def add_spindle( + self, + name, + id=None, + max_power_kw=0, + max_rpm=0, + min_rpm=0, + tool_change="manual", + tool_axis=None, + ): + """Add a spindle to the configuration""" + if tool_axis is None: + tool_axis = FreeCAD.Vector(0, 0, -1) + self.spindles.append( + Spindle(name, id, max_power_kw, max_rpm, min_rpm, tool_change, tool_axis) + ) + return self + + def save(self, filepath): + """Save this configuration to a file + + Args: + filepath: Path to save the configuration file + + Returns: + Path object of saved file + """ + filepath = pathlib.Path(filepath) + data = self.to_dict() + + try: + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + Path.Log.debug(f"Saved machine configuration to {filepath}") + return filepath + except Exception as e: + Path.Log.error(f"Failed to save configuration: {e}") + raise Exception(f"Failed to save machine file {filepath}: {e}") + + def set_alignment_axes(self, primary, secondary=None): + """Set the primary and secondary rotary axes for alignment strategy + + For 4-axis machines, secondary can be None (single rotary axis) + For 5-axis machines, both primary and secondary are required + """ + if primary not in self.rotary_axes: + raise ValueError(f"Primary axis {primary} not found in configuration") + + if secondary is not None and secondary not in self.rotary_axes: + raise ValueError(f"Secondary axis {secondary} not found in configuration") + + self.primary_rotary_axis = primary + self.secondary_rotary_axis = secondary + return self + + def get_axis_by_name(self, name): + """Get a rotary axis by name""" + return self.rotary_axes.get(name) + + def get_spindle_by_index(self, index): + """Get a spindle by its index in the list""" + if 0 <= index < len(self.spindles): + return self.spindles[index] + raise ValueError(f"Spindle index {index} out of range") + + def get_spindle_by_name(self, name): + """Get a spindle by name (case-insensitive)""" + name_lower = name.lower() + for spindle in self.spindles: + if spindle.name.lower() == name_lower: + return spindle + raise ValueError(f"Spindle with name '{name}' not found") + + def get_spindle_by_id(self, id): + """Get a spindle by ID (if present)""" + if id is None: + raise ValueError("ID cannot be None") + for spindle in self.spindles: + if spindle.id == id: + return spindle + raise ValueError(f"Spindle with ID '{id}' not found") + + @classmethod + def create_AC_table_config(cls, a_limits=(-120, 120), c_limits=(-360, 360)): + """Create standard A/C table configuration""" + config = cls("AC Table Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) + config.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), c_limits[0], c_limits[1]) + config.set_alignment_axes("C", "A") + return config + + @classmethod + def create_BC_head_config(cls, b_limits=(-120, 120), c_limits=(-360, 360)): + """Create standard B/C head configuration""" + config = cls("BC Head Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) + config.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), c_limits[0], c_limits[1]) + config.set_alignment_axes("C", "B") + config.compound_moves = True # Ensure compound moves are enabled for test compatibility + return config + + @classmethod + def create_AB_table_config(cls, a_limits=(-120, 120), b_limits=(-120, 120)): + """Create standard A/B table configuration""" + config = cls("AB Table Configuration") + # AB configuration will be detected as 'custom' by the machine_type property + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) + config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) + config.set_alignment_axes("A", "B") + return config + + @classmethod + def create_4axis_A_config(cls, a_limits=(-120, 120)): + """Create standard 4-axis XYZA configuration (rotary table around X)""" + config = cls("4-Axis XYZA Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), a_limits[0], a_limits[1]) + config.set_alignment_axes("A", None) + config.description = "4-axis machine with A-axis rotary table (rotation around X-axis)" + return config + + @classmethod + def create_4axis_B_config(cls, b_limits=(-120, 120)): + """Create standard 4-axis XYZB configuration (rotary table around Y)""" + config = cls("4-Axis XYZB Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.add_rotary_axis("B", FreeCAD.Vector(0, 1, 0), b_limits[0], b_limits[1]) + config.set_alignment_axes("B", None) + config.description = "4-axis machine with B-axis rotary table (rotation around Y-axis)" + return config + + @classmethod + def create_3axis_config(cls): + """Create standard 3-axis XYZ configuration (no rotary axes)""" + config = cls("3-Axis XYZ Configuration") + config.add_linear_axis("X", FreeCAD.Vector(1, 0, 0)) + config.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0)) + config.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1)) + config.description = "Standard 3-axis machine with no rotary axes" + # No rotary axes to add, no alignment axes to set + return config + + def to_dict(self): + """Serialize configuration to dictionary for JSON persistence""" + # Build flattened axes structure + axes = {} + + # Add linear axes from LinearAxis objects + for axis_name, axis_obj in self.linear_axes.items(): + dir_vec = axis_obj.direction_vector + joint = [[dir_vec.x, dir_vec.y, dir_vec.z], [0, 0, 0]] + + axes[axis_name] = { + "type": "linear", + "min": axis_obj.min_limit, + "max": axis_obj.max_limit, + "max_velocity": axis_obj.max_velocity, + "joint": joint, + "sequence": axis_obj.sequence, + } + + # Add rotary axes + for axis_name, axis_obj in self.rotary_axes.items(): + rot_vec = axis_obj.rotation_vector + joint = [[0, 0, 0], [rot_vec.x, rot_vec.y, rot_vec.z]] + axes[axis_name] = { + "type": "angular", + "min": axis_obj.min_limit, + "max": axis_obj.max_limit, + "max_velocity": axis_obj.max_velocity, + "joint": joint, + "sequence": axis_obj.sequence, + "prefer_positive": axis_obj.prefer_positive, + } + + data = { + "freecad_version": self.freecad_version, + "machine": { + "name": self.name, + "manufacturer": self.manufacturer, + "description": self.description, + "units": self.configuration_units, + "axes": axes, + "spindles": [spindle.to_dict() for spindle in self.spindles], + }, + "version": self.version, + } + + # Add post-processor configuration + data["postprocessor"] = { + "file_name": self.postprocessor_file_name, + "args": self.postprocessor_args, + } + + # Output options + data["output"] = { + "command_space": self.output.command_space, + "comment_symbol": self.output.comment_symbol, + "output_comments": self.output.output_comments, + "end_of_line_chars": self.output.end_of_line_chars, + "line_increment": self.output.line_increment, + "line_number_start": self.output.line_number_start, + "line_numbers": self.output.line_numbers, + "line_number_prefix": self.output.line_number_prefix, + "list_tools_in_header": self.output.list_tools_in_header, + "list_fixtures_in_header": self.output.list_fixtures_in_header, + "machine_name_in_header": self.output.machine_name_in_header, + "description_in_header": self.output.description_in_header, + "date_in_header": self.output.date_in_header, + "document_name_in_header": self.output.document_name_in_header, + "filter_double_parameters": self.output.filter_double_parameters, + "filter_double_commands": self.output.filter_double_commands, + "output_blank_lines": self.output.output_blank_lines, + "output_bcnc_comments": self.output.output_bcnc_comments, + "output_header": self.output.output_header, + "output_labels": self.output.output_labels, + "output_operation_labels": self.output.output_operation_labels, + "output_units": self.output.output_units.value, + "axis_precision": self.output.axis_precision, + "feed_precision": self.output.feed_precision, + "spindle_precision": self.output.spindle_precision, + } + + # G-code blocks (only non-empty ones) + blocks = {} + if self.blocks.pre_job: + blocks["pre_job"] = self.blocks.pre_job + if self.blocks.post_job: + blocks["post_job"] = self.blocks.post_job + if self.blocks.preamble: + blocks["preamble"] = self.blocks.preamble + if self.blocks.postamble: + blocks["postamble"] = self.blocks.postamble + if self.blocks.safetyblock: + blocks["safetyblock"] = self.blocks.safetyblock + if self.blocks.pre_operation: + blocks["pre_operation"] = self.blocks.pre_operation + if self.blocks.post_operation: + blocks["post_operation"] = self.blocks.post_operation + if self.blocks.pre_tool_change: + blocks["pre_tool_change"] = self.blocks.pre_tool_change + if self.blocks.post_tool_change: + blocks["post_tool_change"] = self.blocks.post_tool_change + if self.blocks.tool_return: + blocks["tool_return"] = self.blocks.tool_return + if self.blocks.pre_fixture_change: + blocks["pre_fixture_change"] = self.blocks.pre_fixture_change + if self.blocks.post_fixture_change: + blocks["post_fixture_change"] = self.blocks.post_fixture_change + if self.blocks.pre_rotary_move: + blocks["pre_rotary_move"] = self.blocks.pre_rotary_move + if self.blocks.post_rotary_move: + blocks["post_rotary_move"] = self.blocks.post_rotary_move + + if blocks: + data["blocks"] = blocks + + # Processing options + data["processing"] = { + "drill_cycles_to_translate": self.processing.drill_cycles_to_translate, + "show_machine_units": self.processing.show_machine_units, + "spindle_wait": self.processing.spindle_wait, + "split_arcs": self.processing.split_arcs, + "suppress_commands": self.processing.suppress_commands, + "tool_before_change": self.processing.tool_before_change, + "tool_change": self.processing.tool_change, + "translate_drill_cycles": self.processing.translate_drill_cycles, + } + if self.processing.return_to: + data["processing"]["return_to"] = list(self.processing.return_to) + + return data + + def _initialize_3axis_config(self) -> None: + """Initialize as a standard 3-axis XYZ configuration (no rotary axes)""" + self.linear_axes = { + "X": LinearAxis("X", FreeCAD.Vector(1, 0, 0)), + "Y": LinearAxis("Y", FreeCAD.Vector(0, 1, 0)), + "Z": LinearAxis("Z", FreeCAD.Vector(0, 0, 1)), + } + self.rotary_axes = {} + self.primary_rotary_axis = None + self.secondary_rotary_axis = None + self.compound_moves = True + + @classmethod + def create_3axis_config(cls) -> "Machine": + """Create standard 3-axis XYZ configuration (no rotary axes)""" + config = cls("3-Axis XYZ Configuration") + config._initialize_3axis_config() + return config + + def _initialize_4axis_A_config(self, a_limits=(-120, 120)) -> None: + """Initialize as a 4-axis XYZA configuration (rotary table around X)""" + self._initialize_3axis_config() + self.rotary_axes["A"] = RotaryAxis( + "A", FreeCAD.Vector(1, 0, 0), min_limit=a_limits[0], max_limit=a_limits[1] + ) + self.primary_rotary_axis = "A" + + def _initialize_4axis_B_config(self, b_limits=(-120, 120)) -> None: + """Initialize as a 4-axis XYZB configuration (rotary table around Y)""" + self._initialize_3axis_config() + self.rotary_axes["B"] = RotaryAxis( + "B", FreeCAD.Vector(0, 1, 0), min_limit=b_limits[0], max_limit=b_limits[1] + ) + self.primary_rotary_axis = "B" + + def _initialize_AC_table_config(self, a_limits=(-120, 120), c_limits=(-360, 360)) -> None: + """Initialize as a 5-axis AC table configuration""" + self._initialize_4axis_A_config(a_limits) + self.rotary_axes["C"] = RotaryAxis( + "C", FreeCAD.Vector(0, 0, 1), min_limit=c_limits[0], max_limit=c_limits[1] + ) + self.secondary_rotary_axis = "C" + + def _initialize_BC_head_config(self, b_limits=(-120, 120), c_limits=(-360, 360)) -> None: + """Initialize as a 5-axis BC head configuration""" + self._initialize_4axis_B_config(b_limits) + self.rotary_axes["C"] = RotaryAxis( + "C", FreeCAD.Vector(0, 0, 1), min_limit=c_limits[0], max_limit=c_limits[1] + ) + self.secondary_rotary_axis = "C" + + def _initialize_from_machine_type(self, machine_type: str) -> None: + """Initialize machine configuration based on machine type""" + if machine_type == "xyz": + self._initialize_3axis_config() + elif machine_type == "xyza": + self._initialize_4axis_A_config() + elif machine_type == "xyzb": + self._initialize_4axis_B_config() + elif machine_type == "xyzac": + self._initialize_AC_table_config() + elif machine_type == "xyzbc": + self._initialize_BC_head_config() + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Machine": + """Deserialize configuration from dictionary (supports both old and new formats)""" + machine_data = data.get("machine", data) # Support both old and new formats + + # Extract basic configuration + config = cls( + name=machine_data.get("name", "Loaded Machine"), + configuration_units=machine_data.get("units", "metric"), + manufacturer=machine_data.get("manufacturer", ""), + description=machine_data.get("description", ""), + ) + + # Parse axes from new flattened structure + axes = machine_data.get("axes", {}) + config.linear_axes = {} + config.rotary_axes = {} + + # Determine primary/secondary rotary axes + rotary_axis_names = [ + name for name, axis_data in axes.items() if axis_data.get("type") == "angular" + ] + rotary_axis_names.sort() # Sort to get consistent ordering + + if len(rotary_axis_names) > 0: + config.primary_rotary_axis = rotary_axis_names[0] + if len(rotary_axis_names) > 1: + config.secondary_rotary_axis = rotary_axis_names[1] + + # Parse linear and rotary axes + for axis_name, axis_data in axes.items(): + axis_type = axis_data.get("type", "linear") + + if axis_type == "linear": + # Extract direction vector from joint + joint = axis_data.get("joint", [[1, 0, 0], [0, 0, 0]]) + direction_vec = FreeCAD.Vector(joint[0][0], joint[0][1], joint[0][2]) + + min_limit = axis_data.get("min", 0) + max_limit = axis_data.get("max", 1000) + max_velocity = axis_data.get("max_velocity", 10000) + + config.linear_axes[axis_name] = LinearAxis( + name=axis_name, + direction_vector=direction_vec, + min_limit=min_limit, + max_limit=max_limit, + max_velocity=max_velocity, + ) + elif axis_type == "angular": + joint = axis_data.get("joint", [[0, 0, 0], [0, 0, 1]]) + rotation_vec = FreeCAD.Vector(joint[1][0], joint[1][1], joint[1][2]) + + min_limit = axis_data.get("min", -360) + max_limit = axis_data.get("max", 360) + max_velocity = axis_data.get("max_velocity", 36000) + prefer_positive = axis_data.get("prefer_positive", True) + + config.rotary_axes[axis_name] = RotaryAxis( + name=axis_name, + rotation_vector=rotation_vec, + min_limit=min_limit, + max_limit=max_limit, + max_velocity=max_velocity, + prefer_positive=prefer_positive, + ) + + # Parse spindles if present + spindles = machine_data.get("spindles", []) + config.spindles = [Spindle.from_dict(s) for s in spindles] + + # Parse post-processor settings if present + post_data = data.get("postprocessor", {}) + if post_data: + config.postprocessor_file_name = post_data.get("file_name", "") + config.postprocessor_args = post_data.get("args", "") + + # Load output options + output_data = data.get("output", {}) + if output_data: + # Line formatting options + config.output.command_space = output_data.get("command_space", " ") + config.output.comment_symbol = output_data.get("comment_symbol", "(") + config.output.end_of_line_chars = output_data.get("end_of_line_chars", "\n") + config.output.line_increment = output_data.get("line_increment", 10) + config.output.line_number_start = output_data.get("line_number_start", 100) + config.output.line_numbers = output_data.get("line_numbers", False) + config.output.line_number_prefix = output_data.get("line_number_prefix", "N") + + # Output content options (with backward compatibility) + config.output.output_comments = output_data.get( + "output_comments", output_data.get("comments", True) + ) + config.output.output_blank_lines = output_data.get( + "output_blank_lines", output_data.get("blank_lines", True) + ) + config.output.output_bcnc_comments = output_data.get("output_bcnc_comments", True) + config.output.output_header = output_data.get( + "output_header", output_data.get("header", True) + ) + config.output.output_labels = output_data.get( + "output_labels", output_data.get("path_labels", False) + ) + config.output.output_operation_labels = output_data.get( + "output_operation_labels", output_data.get("show_operation_labels", True) + ) + + # Header content options (with backward compatibility) + config.output.list_tools_in_header = output_data.get( + "list_tools_in_header", output_data.get("list_tools_in_preamble", False) + ) + config.output.list_fixtures_in_header = output_data.get("list_fixtures_in_header", True) + config.output.machine_name_in_header = output_data.get( + "machine_name_in_header", output_data.get("machine_name", False) + ) + config.output.description_in_header = output_data.get("description_in_header", True) + config.output.date_in_header = output_data.get("date_in_header", True) + config.output.document_name_in_header = output_data.get("document_name_in_header", True) + + # Filter options (with backward compatibility) + config.output.filter_double_parameters = output_data.get( + "filter_double_parameters", output_data.get("output_double_parameters", False) + ) + # filter_double_commands comes from processing.modal in old format + config.output.filter_double_commands = output_data.get("filter_double_commands", False) + + # Numeric precision settings (with backward compatibility) + config.output.axis_precision = output_data.get("axis_precision", 3) + config.output.feed_precision = output_data.get("feed_precision", 3) + config.output.spindle_precision = output_data.get( + "spindle_precision", output_data.get("spindle_decimals", 0) + ) + + # Handle output_units conversion from string to enum + output_units_str = output_data.get("output_units", "metric") + config.output.output_units = ( + OutputUnits.METRIC if output_units_str == "metric" else OutputUnits.IMPERIAL + ) + + # These fields are now in ProcessingOptions (backward compatibility) + if "tool_change" in output_data: + config.processing.tool_change = output_data["tool_change"] + + # Load processing options + processing_data = data.get("processing", {}) + if processing_data: + config.processing.drill_cycles_to_translate = processing_data.get( + "drill_cycles_to_translate", ["G73", "G81", "G82", "G83"] + ) + config.processing.show_machine_units = processing_data.get("show_machine_units", True) + config.processing.spindle_wait = processing_data.get("spindle_wait", 0.0) + config.processing.split_arcs = processing_data.get("split_arcs", False) + config.processing.suppress_commands = processing_data.get("suppress_commands", []) + config.processing.tool_before_change = processing_data.get("tool_before_change", False) + config.processing.tool_change = processing_data.get("tool_change", True) + config.processing.translate_drill_cycles = processing_data.get( + "translate_drill_cycles", False + ) + return_to = processing_data.get("return_to", None) + config.processing.return_to = tuple(return_to) if return_to is not None else None + + # Backward compatibility: modal moved to output.filter_double_commands + if "modal" in processing_data: + config.output.filter_double_commands = processing_data["modal"] + + # Load G-code blocks + blocks_data = data.get("blocks", {}) + if blocks_data: + for block_name in [ + "pre_job", + "post_job", + "preamble", + "postamble", + "safetyblock", + "pre_operation", + "post_operation", + "pre_tool_change", + "post_tool_change", + "tool_return", + "pre_fixture_change", + "post_fixture_change", + "pre_rotary_move", + "post_rotary_move", + "pre_spindle_change", + "post_spindle_change", + "finish_label", + ]: + if block_name in blocks_data: + setattr(config.blocks, block_name, blocks_data[block_name]) + + return config + + +class MachineFactory: + """Factory class for creating, loading, and saving machine configurations""" + + # Default configuration directory + _config_dir = None + + @classmethod + def set_config_directory(cls, directory): + """Set the directory for storing machine configuration files""" + cls._config_dir = pathlib.Path(directory) + cls._config_dir.mkdir(parents=True, exist_ok=True) + + @classmethod + def get_config_directory(cls): + """Get the configuration directory, creating default if not set""" + if cls._config_dir is None: + # Use FreeCAD user data directory + CAM/Machines + try: + cls._config_dir = Path.Preferences.getAssetPath() / "Machines" + cls._config_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + Path.Log.warning(f"Could not create default config directory: {e}") + cls._config_dir = pathlib.Path.cwd() / "Machines" + cls._config_dir.mkdir(parents=True, exist_ok=True) + return cls._config_dir + + @classmethod + def save_configuration(cls, config, filename=None): + """ + Save a machine configuration to a JSON file + + Args: + config: Machine object to save + filename: Optional filename (without path). If None, uses sanitized config name + + Returns: + Path to the saved file + """ + if filename is None: + # Sanitize the config name for use as filename + filename = config.name.replace(" ", "_").replace("/", "_") + ".fcm" + + config_dir = cls.get_config_directory() + filepath = config_dir / filename + + try: + data = config.to_dict() + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, sort_keys=True, indent=4) + Path.Log.debug(f"Saved machine file: {filepath}") + return filepath + except Exception as e: + Path.Log.error(f"Failed to save configuration: {e}") + raise Exception(f"Failed to save machine file {filepath}: {e}") + + @classmethod + def load_configuration(cls, filename): + """ + Load a machine configuration from a JSON file + + Args: + filename: Filename (with or without path). If no path, searches config directory + + Returns: + Dictionary containing machine configuration data (new format) or + Machine object if loading old format + + Raises: + FileNotFoundError: If the file does not exist + json.JSONDecodeError: If the file is not valid JSON + Exception: For other I/O errors + """ + filepath = pathlib.Path(filename) + + # If no directory specified, look in config directory + if not filepath.parent or filepath.parent == pathlib.Path("."): + filepath = cls.get_config_directory() / filename + + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + Path.Log.debug(f"Loaded machine file: {filepath}") + machine = Machine.from_dict(data) + Path.Log.debug(f"Loaded machine configuration from {filepath}") + return machine + + except FileNotFoundError: + raise FileNotFoundError(f"Machine file not found: {filepath}") + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in machine file {filepath}: {e}") + except Exception as e: + raise Exception(f"Failed to load machine file {filepath}: {e}") + + @classmethod + def create_default_machine_data(cls): + """ + Create a default machine configuration dictionary for the editor. + + Returns: + Dictionary with default machine configuration structure + """ + machine = Machine(name="New Machine") + return machine.to_dict() + + @classmethod + def list_configuration_files(cls) -> list[tuple[str, pathlib.Path]]: + """Get list of available machine files from the asset directory. + + Scans the Machine subdirectory of the asset path for .fcm files + and returns tuples of (display_name, file_path). + + Returns: + list: List of (name, path) tuples for discovered machine files + """ + machines = [("", None)] + try: + asset_base = cls.get_config_directory() + if asset_base.exists(): + for p in sorted(asset_base.glob("*.fcm")): + name = cls.get_machine_display_name(p.name) + machines.append((name, p.name)) + except Exception: + # Failed to access machine directory or read files, return default list only + pass + return machines + + @classmethod + def list_configurations(cls) -> list[str]: + """Get list of available machines from the asset directory. + + Scans the Machine subdirectory of the asset path for .fcm files + and extracts machine names. Returns [""] plus discovered machine names. + + Returns: + list: List of machine names starting with "" + """ + machines = cls.list_configuration_files() + return [name for name, path in machines] + + @classmethod + def delete_configuration(cls, filename): + """ + Delete a machine configuration file + + Args: + filename: Name of the configuration file to delete + + Returns: + True if deleted successfully, False otherwise + """ + filepath = cls.get_config_directory() / filename + try: + if filepath.exists(): + filepath.unlink() + Path.Log.debug(f"Deleted machine: {filepath}") + return True + else: + Path.Log.warning(f"Machine file not found: {filepath}") + return False + except Exception as e: + Path.Log.error(f"Failed to delete machine: {e}") + return False + + @classmethod + def create_standard_configs(cls): + """ + Create and save all standard machine configurations + + Returns: + Dictionary mapping config names to file paths + """ + configs = { + "XYZ": Machine.create_3axis_config(), + "XYZAC": Machine.create_AC_table_config(), + "XYZBC": Machine.create_BC_head_config(), + "XYZA": Machine.create_4axis_A_config(), + "XYZB": Machine.create_4axis_B_config(), + } + + saved_paths = {} + for name, config in configs.items(): + try: + filepath = cls.save_configuration(config, f"{name}.fcm") + saved_paths[name] = filepath + except Exception as e: + Path.Log.error(f"Failed to save {name}: {e}") + + return saved_paths + + @classmethod + def get_builtin_config(cls, config_type): + """ + Get a built-in machine configuration without loading from disk + + Args: + config_type: One of "XYZ", "XYZAC", "XYZBC", "XYZA", "XYZB" + + Returns: + Machine object + """ + config_map = { + "XYZ": Machine.create_3axis_config, + "XYZAC": Machine.create_AC_table_config, + "XYZBC": Machine.create_BC_head_config, + "XYZA": Machine.create_4axis_A_config, + "XYZB": Machine.create_4axis_B_config, + } + + if config_type not in config_map: + raise ValueError( + f"Unknown config type: {config_type}. Available: {list(config_map.keys())}" + ) + + return config_map[config_type]() + + @classmethod + def get_machine(cls, machine_name): + """ + Get a machine configuration by name from the assets folder + + Args: + machine_name: Name of the machine to load (without .fcm extension) + + Returns: + Machine object + + Raises: + FileNotFoundError: If no machine with that name is found + ValueError: If the loaded data is not a valid machine configuration + """ + # Get list of available machine files + machine_files = cls.list_configuration_files() + + # Find the file matching the machine name (case-insensitive) + target_path = None + machine_name_lower = machine_name.lower() + for name, path in machine_files: + if name.lower() == machine_name_lower and path is not None: + target_path = path + break + + if target_path is None: + available = [name for name, path in machine_files if path is not None] + raise FileNotFoundError( + f"Machine '{machine_name}' not found. Available machines: {available}" + ) + + # Load the configuration using the path from list_configuration_files() + data = cls.load_configuration(target_path) + + # If load_configuration returned a dict (new format), convert to Machine + if isinstance(data, dict): + return Machine.from_dict(data) + else: + # Already a Machine object (old format) + return data + + @classmethod + def get_machine_display_name(cls, filename): + """ + Get the display name for a machine from its filename in the config directory. + + Args: + filename: Name of the machine file (without path) + + Returns: + str: Display name (machine name from JSON or filename stem) + """ + filepath = cls.get_config_directory() / filename + try: + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("machine", {}).get("name", filepath.stem) + except Exception: + return filepath.stem diff --git a/src/Mod/CAM/Path/Machine/ui/editor/__init__.py b/src/Mod/CAM/Path/Machine/ui/editor/__init__.py 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..a9f171f415 --- /dev/null +++ b/src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py @@ -0,0 +1,1570 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Billy Huddleston * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +from PySide import QtGui, QtCore +import FreeCAD +import Path +import json +from typing import Optional, Dict, Any, get_origin, get_args +from dataclasses import fields +from enum import Enum +from ...models import Machine, MachineFactory, LinearAxis, RotaryAxis, Spindle +from ....Main.Gui.Editor import CodeEditor +from Path.Post.Processor import PostProcessorFactory +import re + +translate = FreeCAD.Qt.translate + + +class DataclassGUIGenerator: + """Generates Qt widgets dynamically from dataclass definitions. + + This class introspects dataclass fields and creates appropriate GUI widgets + based on field types. It supports nested dataclasses, creating grouped layouts + for better organization. + """ + + # Fields that should use multi-line text editors instead of single-line + MULTILINE_FIELDS = { + "preamble", + "postamble", + "pre_job", + "post_job", + "pre_operation", + "post_operation", + "pre_tool_change", + "post_tool_change", + "tool_return", + "pre_fixture_change", + "post_fixture_change", + "pre_rotary_move", + "post_rotary_move", + "pre_spindle_change", + "post_spindle_change", + "safetyblock", + "drill_cycles_to_translate", + "suppress_commands", + } + + # Field display name overrides + FIELD_LABELS = { + "comments": translate("CAM_MachineEditor", "Include Comments"), + "blank_lines": translate("CAM_MachineEditor", "Include Blank Lines"), + "header": translate("CAM_MachineEditor", "Include Header"), + "line_numbers": translate("CAM_MachineEditor", "Line Numbers"), + "path_labels": translate("CAM_MachineEditor", "Path Labels"), + "machine_name": translate("CAM_MachineEditor", "Include Machine Name"), + "output_double_parameters": translate("CAM_MachineEditor", "Output Duplicate Axis Values"), + "output_bcnc_comments": translate("CAM_MachineEditor", "Output bCNC Comments"), + "doubles": translate("CAM_MachineEditor", "Output Duplicate Axis Values"), + "adaptive": translate("CAM_MachineEditor", "Adaptive Output"), + "axis_precision": translate("CAM_MachineEditor", "Axis Precision"), + "feed_precision": translate("CAM_MachineEditor", "Feed Precision"), + "spindle_decimals": translate("CAM_MachineEditor", "Spindle Decimals"), + "command_space": translate("CAM_MachineEditor", "Command Spacing"), + "comment_symbol": translate("CAM_MachineEditor", "Comment Symbol"), + "line_increment": translate("CAM_MachineEditor", "Line Number Increment"), + "line_number_start": translate("CAM_MachineEditor", "Line Number Start"), + "end_of_line_chars": translate("CAM_MachineEditor", "End of Line Characters"), + "modal": translate("CAM_MachineEditor", "Modal Output (Suppress Repeats)"), + "translate_drill_cycles": translate("CAM_MachineEditor", "Translate Drill Cycles"), + "split_arcs": translate("CAM_MachineEditor", "Split Arcs"), + "show_editor": translate("CAM_MachineEditor", "Show Editor After Generation"), + "list_tools_in_preamble": translate("CAM_MachineEditor", "List Tools in Preamble"), + "show_machine_units": translate("CAM_MachineEditor", "Show Machine Units"), + "show_operation_labels": translate("CAM_MachineEditor", "Show Operation Labels"), + "tool_before_change": translate("CAM_MachineEditor", "Output T Before M6"), + "chipbreaking_amount": translate("CAM_MachineEditor", "Chipbreaking Amount (mm)"), + "spindle_wait": translate("CAM_MachineEditor", "Spindle Wait Time (seconds)"), + "postprocessor_file_name": translate("CAM_MachineEditor", "Post Processor"), + "postprocessor_args": translate("CAM_MachineEditor", "Post Processor Arguments"), + "use_tlo": translate("CAM_MachineEditor", "Use Tool Length Offset"), + "stop_spindle_for_tool_change": translate( + "CAM_MachineEditor", "Stop Spindle for Tool Change" + ), + "enable_coolant": translate("CAM_MachineEditor", "Enable Coolant"), + "enable_machine_specific_commands": translate( + "CAM_MachineEditor", "Enable Machine-Specific Commands" + ), + } + + @staticmethod + def get_field_label(field_name: str) -> str: + """Get a human-readable label for a field name.""" + if field_name in DataclassGUIGenerator.FIELD_LABELS: + return DataclassGUIGenerator.FIELD_LABELS[field_name] + # Convert snake_case to Title Case + return " ".join(word.capitalize() for word in field_name.split("_")) + + @staticmethod + def create_widget_for_field( + field_name: str, field_type: type, current_value: Any + ) -> QtGui.QWidget: + """Create an appropriate widget for a dataclass field. + + Args: + field_name: Name of the field + field_type: Type annotation of the field + current_value: Current value of the field + + Returns: + Tuple of (widget, getter_function) where getter returns the current value + """ + origin = get_origin(field_type) + + # Handle Optional types + if origin is type(None) or (origin and str(origin).startswith("typing.Union")): + args = get_args(field_type) + if args: + # Get the non-None type + field_type = next((arg for arg in args if arg is not type(None)), args[0]) + origin = get_origin(field_type) + + # Boolean -> Checkbox + if field_type is bool: + widget = QtGui.QCheckBox() + widget.setChecked(current_value if current_value is not None else False) + widget.value_getter = lambda: widget.isChecked() + return widget + + # Enum -> ComboBox + if isinstance(field_type, type) and issubclass(field_type, Enum): + widget = QtGui.QComboBox() + for member in field_type: + widget.addItem(member.value if hasattr(member, "value") else str(member), member) + if current_value: + index = widget.findData(current_value) + if index >= 0: + widget.setCurrentIndex(index) + widget.value_getter = lambda: widget.itemData(widget.currentIndex()) + return widget + + # List[str] -> Multi-line text area + if origin is list: + args = get_args(field_type) + if args and args[0] is str: + widget = QtGui.QPlainTextEdit() + widget.setMaximumHeight(100) + if current_value: + widget.setPlainText("\n".join(current_value)) + else: + widget.setPlainText("") + widget.value_getter = lambda: [ + line.strip() for line in widget.toPlainText().split("\n") if line.strip() + ] + return widget + + # Int -> SpinBox + if field_type is int: + widget = QtGui.QSpinBox() + widget.setRange(-999999, 999999) + widget.setValue(current_value if current_value is not None else 0) + widget.value_getter = lambda: widget.value() + return widget + + # Float -> DoubleSpinBox + if field_type is float: + widget = QtGui.QDoubleSpinBox() + widget.setRange(-999999.0, 999999.0) + widget.setDecimals(4) + widget.setValue(current_value if current_value is not None else 0.0) + widget.value_getter = lambda: widget.value() + return widget + + # String -> LineEdit or PlainTextEdit + if field_type is str: + if field_name in DataclassGUIGenerator.MULTILINE_FIELDS: + widget = QtGui.QPlainTextEdit() + widget.setMaximumHeight(100) + widget.setPlainText(current_value if current_value else "") + widget.value_getter = lambda: widget.toPlainText() + else: + widget = QtGui.QLineEdit() + widget.setText(current_value if current_value else "") + widget.value_getter = lambda: widget.text() + return widget + + # Fallback to string representation + widget = QtGui.QLineEdit() + widget.setText(str(current_value) if current_value is not None else "") + widget.value_getter = lambda: widget.text() + return widget + + @staticmethod + def create_group_for_dataclass( + dataclass_instance: Any, group_title: str + ) -> tuple[QtGui.QGroupBox, Dict[str, QtGui.QWidget]]: + """Create a QGroupBox with widgets for all fields in a dataclass. + + Args: + dataclass_instance: Instance of a dataclass + group_title: Title for the group box + + Returns: + Tuple of (QGroupBox, dict mapping field_name to widget) + """ + group = QtGui.QGroupBox(group_title) + layout = QtGui.QFormLayout(group) + widgets = {} + + for field in fields(dataclass_instance): + # Skip private fields and complex types we don't handle + if field.name.startswith("_"): + continue + + current_value = getattr(dataclass_instance, field.name) + field_type = field.type + + # Skip parameter_functions and other callable/complex types + if field.name in ["parameter_functions", "parameter_order"]: + continue + + widget = DataclassGUIGenerator.create_widget_for_field( + field.name, field_type, current_value + ) + + label = DataclassGUIGenerator.get_field_label(field.name) + layout.addRow(label, widget) + widgets[field.name] = widget + + return group, widgets + + +class MachineEditorDialog(QtGui.QDialog): + """A dialog to edit machine JSON assets with proper form fields.""" + + # Todo - Make this a json schema driven form in the future + MACHINE_TYPES = { + "custom": { + "name": translate("CAM_MachineEditor", "Custom Machine"), + "linear": [], + "rotary": [], + }, + "xz": { + "name": translate("CAM_MachineEditor", "2-Axis Lathe (X, Z)"), + "linear": ["X", "Z"], + "rotary": [], + }, + "xyz": { + "name": translate("CAM_MachineEditor", "3-Axis Mill (XYZ)"), + "linear": ["X", "Y", "Z"], + "rotary": [], + }, + "xyza": { + "name": translate("CAM_MachineEditor", "4-Axis Mill (XYZ + A)"), + "linear": ["X", "Y", "Z"], + "rotary": ["A"], + }, + "xyzb": { + "name": translate("CAM_MachineEditor", "4-Axis Mill (XYZ + B)"), + "linear": ["X", "Y", "Z"], + "rotary": ["B"], + }, + "xyzac": { + "name": translate("CAM_MachineEditor", "5-Axis Mill (XYZ + A, C)"), + "linear": ["X", "Y", "Z"], + "rotary": ["A", "C"], + }, + "xyzbc": { + "name": translate("CAM_MachineEditor", "5-Axis Mill (XYZ + B, C)"), + "linear": ["X", "Y", "Z"], + "rotary": ["B", "C"], + }, + } + + ROTATIONAL_AXIS_OPTIONS = [ + ("+X", [1, 0, 0]), + ("-X", [-1, 0, 0]), + ("+Y", [0, 1, 0]), + ("-Y", [0, -1, 0]), + ("+Z", [0, 0, 1]), + ("-Z", [0, 0, -1]), + ] + + def __init__(self, machine_filename: Optional[str] = None, parent=None): + super().__init__(parent) + self.setMinimumSize(700, 900) + self.resize(700, 900) + + self.current_units = "metric" + + # Initialize machine object first (needed by setup methods) + self.filename = machine_filename + self.machine = None # Store the Machine object + + if machine_filename: + self.machine = MachineFactory.load_configuration(machine_filename) + else: + self.machine = Machine(name="New Machine") + + # Set window title with machine name + title = translate("CAM_MachineEditor", "Machine Editor") + if self.machine and self.machine.name: + title += f" - {self.machine.name}" + self.setWindowTitle(title) + + # Initialize widget and processor caches + self.post_widgets = {} + self.processor = {} + + self.layout = QtGui.QVBoxLayout(self) + + # Tab widget for sections + self.tabs = QtGui.QTabWidget() + self.layout.addWidget(self.tabs) + + # Machine tab + self.machine_tab = QtGui.QWidget() + self.tabs.addTab(self.machine_tab, translate("CAM_MachineEditor", "Machine")) + self.setup_machine_tab() + + # Output Options tab + self.output_tab = QtGui.QWidget() + self.tabs.addTab(self.output_tab, translate("CAM_MachineEditor", "Output Options")) + self.setup_output_tab() + + # G-Code Blocks tab + self.blocks_tab = QtGui.QWidget() + self.tabs.addTab(self.blocks_tab, translate("CAM_MachineEditor", "G-Code Blocks")) + self.setup_blocks_tab() + + # Processing Options tab + self.processing_tab = QtGui.QWidget() + self.tabs.addTab(self.processing_tab, translate("CAM_MachineEditor", "Processing Options")) + self.setup_processing_tab() + + # Check experimental flag for machine post processor + param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + self.enable_machine_postprocessor = param.GetBool("EnableMachinePostprocessor", True) + self.tabs.setTabVisible( + self.tabs.indexOf(self.output_tab), self.enable_machine_postprocessor + ) + self.tabs.setTabVisible( + self.tabs.indexOf(self.blocks_tab), self.enable_machine_postprocessor + ) + self.tabs.setTabVisible( + self.tabs.indexOf(self.processing_tab), self.enable_machine_postprocessor + ) + # Text editor (initially hidden) + self.text_editor = CodeEditor() + + p = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Editor") + font = QtGui.QFont() + font.setFamily(p.GetString("Font", "Courier")) + font.setFixedPitch(True) + font.setPointSize(p.GetInt("FontSize", 10)) + + self.text_editor.setFont(font) + self.layout.addWidget(self.text_editor) + self.text_editor.hide() + + button_layout = QtGui.QHBoxLayout() + + self.toggle_button = QtGui.QPushButton(translate("CAM_MachineEditor", "Edit as Text")) + self.toggle_button.clicked.connect(self.toggle_editor_mode) + button_layout.addWidget(self.toggle_button) + + button_layout.addStretch() + + buttons = QtGui.QDialogButtonBox( + QtGui.QDialogButtonBox.Save | QtGui.QDialogButtonBox.Close, + QtCore.Qt.Horizontal, + ) + buttons.button(QtGui.QDialogButtonBox.Save).setText(translate("CAM_MachineEditor", "Save")) + buttons.button(QtGui.QDialogButtonBox.Close).setText( + translate("CAM_MachineEditor", "Close") + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + button_layout.addWidget(buttons) + + self.layout.addLayout(button_layout) + self.text_mode = False + + # Populate GUI from machine object + self.populate_from_machine(self.machine) + + # Update spindle button state + self._update_spindle_button_state() + + # Set focus and select the name field for new machines + if not machine_filename: + self.name_edit.setFocus() + self.name_edit.selectAll() + + def normalize_text(self, edit, suffix, axis=None, field=None): + """Normalize and validate text input for numeric fields with units. + + Parses user input, converts between metric/imperial units, and ensures + proper formatting. Updates stored data for axes configuration. + + Args: + edit: QLineEdit widget to update + suffix: Unit suffix (e.g., "mm", "in", "deg") + axis: Axis identifier for data storage + field: Field name for data storage + """ + text = edit.text().strip() + if not text: + edit.setText("0 " + suffix) + return + + # Parse input manually based on machine units + machine_units = self.units_combo.itemData(self.units_combo.currentIndex()) + + # Split number and unit + match = re.match(r"^([+-]?\d*\.?\d+)\s*(.*)$", text) + if not match: + edit.setText("0 " + suffix) + return + + num_str, unit_str = match.groups() + try: + value = float(num_str) + except ValueError: + edit.setText("0 " + suffix) + return + + # Determine if this is a rotary axis by checking the suffix + is_angular = suffix in ["deg", "deg/min"] + + # Convert to internal mm (or degrees for angles) + if is_angular: + # For rotary axes, always use degrees, no unit conversion + if unit_str.strip(): + if unit_str.strip() in ["deg", "degree", "degrees", "°"]: + internal_value = value + else: + # Unknown unit for angles, assume degrees + internal_value = value + else: + # No unit, assume degrees + internal_value = value + else: + # Linear axes + if unit_str.strip(): + if unit_str.strip() in ["mm", "millimeter", "millimeters"]: + internal_value = value + elif unit_str.strip() in ["in", "inch", "inches", '"']: + internal_value = value * 25.4 + elif unit_str.strip() in ["cm", "centimeter", "centimeters"]: + internal_value = value * 10 + elif unit_str.strip() in ["m", "meter", "meters"]: + internal_value = value * 1000 + else: + # Unknown unit, assume machine units + if machine_units == "metric": + internal_value = value # assume mm + else: + internal_value = value * 25.4 # assume in + else: + # No unit, assume machine units + if machine_units == "metric": + internal_value = value # assume mm + else: + internal_value = value * 25.4 # assume in + + # Convert to display units + if suffix == "mm": + display_value = round(internal_value, 2) + elif suffix == "in": + display_value = round(internal_value / 25.4, 2) + elif suffix == "mm/min": + display_value = round(internal_value, 2) + elif suffix == "in/min": + display_value = round(internal_value / 25.4, 2) + elif suffix == "deg": + display_value = round(internal_value, 2) + elif suffix == "deg/min": + display_value = round(internal_value, 2) + else: + display_value = round(internal_value, 2) + + edit.setText(f"{display_value:.2f} {suffix}") + + # Update machine's axis directly (store in metric) + if axis and field and self.machine: + # Find the axis in machine.linear_axes or machine.rotary_axes (both are dicts) + if axis in self.machine.linear_axes: + ax = self.machine.linear_axes[axis] + if field == "min": + ax.min_limit = internal_value + elif field == "max": + ax.max_limit = internal_value + elif field == "max_velocity": + ax.max_velocity = internal_value + elif axis in self.machine.rotary_axes: + ax = self.machine.rotary_axes[axis] + if field == "min": + ax.min_limit = internal_value + elif field == "max": + ax.max_limit = internal_value + elif field == "max_velocity": + ax.max_velocity = internal_value + + # Signal handlers that update self.machine directly + def _on_name_changed(self, text): + """Update machine name when text changes.""" + if self.machine: + self.machine.name = text + # Update window title with new name + title = translate("CAM_MachineEditor", "Machine Editor") + if self.machine.name: + title += f" - {self.machine.name}" + self.setWindowTitle(title) + + def _on_rotary_sequence_changed(self, axis_name, value): + """Update rotary axis sequence.""" + if self.machine and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].sequence = value + + def _on_rotary_joint_changed(self, axis_name, combo): + """Update rotary axis joint/rotation vector.""" + if self.machine and axis_name in self.machine.rotary_axes: + vector = combo.itemData(combo.currentIndex()) + self.machine.rotary_axes[axis_name].rotation_vector = FreeCAD.Vector(*vector) + + def _on_rotary_prefer_positive_changed(self, axis_name, checked): + """Update rotary axis prefer_positive.""" + if self.machine and axis_name in self.machine.rotary_axes: + self.machine.rotary_axes[axis_name].prefer_positive = checked + + def _on_spindle_field_changed(self, spindle_index, field_name, value): + """Update spindle field in Machine object when UI field changes. + + Args: + spindle_index: Index of the spindle in machine.spindles + field_name: Name of the field being updated + value: New value for the field + """ + if self.machine and spindle_index < len(self.machine.spindles): + spindle = self.machine.spindles[spindle_index] + setattr(spindle, field_name, value) + + def _add_spindle(self): + """Add a new spindle to the machine.""" + if self.machine and len(self.machine.spindles) < 9: + new_index = len(self.machine.spindles) + 1 + new_spindle = Spindle( + name=f"Spindle {new_index}", + id=f"spindle{new_index}", + max_power_kw=3.0, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + self.machine.spindles.append(new_spindle) + self.update_spindles() + self._update_spindle_button_state() + # Set focus to the new tab + self.spindles_tabs.setCurrentIndex(len(self.machine.spindles) - 1) + + def _remove_spindle(self, index): + """Remove a spindle from the machine with confirmation. + + Args: + index: Index of the tab/spindle to remove + """ + if not self.machine or len(self.machine.spindles) <= 1: + return # Don't allow removing the last spindle + + spindle = self.machine.spindles[index] + reply = QtGui.QMessageBox.question( + self, + translate("CAM_MachineEditor", "Remove Spindle"), + translate("CAM_MachineEditor", f"Remove '{spindle.name}'? This cannot be undone."), + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + QtGui.QMessageBox.No, + ) + + if reply == QtGui.QMessageBox.Yes: + self.machine.spindles.pop(index) + self.update_spindles() + self._update_spindle_button_state() + + def _update_spindle_button_state(self): + """Enable/disable the add spindle button based on count.""" + if self.machine: + self.add_spindle_button.setEnabled(len(self.machine.spindles) < 9) + + def _on_manufacturer_changed(self, text): + """Update manufacturer when text changes.""" + if self.machine: + self.machine.manufacturer = text + + def _on_description_changed(self, text): + """Update description when text changes.""" + if self.machine: + self.machine.description = text + + def _on_units_changed(self, index): + """Update units and refresh axes display.""" + if self.machine: + units = self.units_combo.itemData(index) + self.machine.configuration_units = units + self.current_units = units + self.update_axes() + + def _on_type_changed(self, index): + """Update machine type and refresh axes.""" + if self.machine: + machine_type = self.type_combo.itemData(index) + # Note: machine_type is a read-only property determined by axes configuration + # Don't try to set it directly - instead modify the axes below + + # Rebuild axes in machine based on new type + config = self.MACHINE_TYPES.get(machine_type, {}) + + # Store existing axes for preservation + old_linear_axes = self.machine.linear_axes.copy() + old_rotary_axes = self.machine.rotary_axes.copy() + + # Clear and rebuild linear axes + self.machine.linear_axes = {} + for axis_name in config.get("linear", []): + # Preserve existing axis if available + if axis_name in old_linear_axes: + self.machine.linear_axes[axis_name] = old_linear_axes[axis_name] + else: + # Create with defaults + self.machine.linear_axes[axis_name] = LinearAxis( + name=axis_name, + direction_vector=( + FreeCAD.Vector(1, 0, 0) + if axis_name == "X" + else ( + FreeCAD.Vector(0, 1, 0) + if axis_name == "Y" + else FreeCAD.Vector(0, 0, 1) + ) + ), + min_limit=0, + max_limit=1000, + max_velocity=10000, + ) + + # Clear and rebuild rotary axes + self.machine.rotary_axes = {} + for axis_name in config.get("rotary", []): + # Preserve existing axis if available + if axis_name in old_rotary_axes: + self.machine.rotary_axes[axis_name] = old_rotary_axes[axis_name] + else: + # Create with defaults + default_vector = ( + [1, 0, 0] + if axis_name == "A" + else [0, 1, 0] if axis_name == "B" else [0, 0, 1] + ) + self.machine.rotary_axes[axis_name] = RotaryAxis( + name=axis_name, + rotation_vector=FreeCAD.Vector(*default_vector), + min_limit=-180, + max_limit=180, + max_velocity=36000, + sequence=0, + prefer_positive=True, + ) + + self.update_axes() + + def setup_machine_tab(self): + """Set up the machine configuration tab with form fields. + + Creates input fields for machine name, manufacturer, description, + units, type, spindle count, axes configuration, and spindles. + Connects change handlers for dynamic updates. + """ + layout = QtGui.QFormLayout(self.machine_tab) + + self.name_edit = QtGui.QLineEdit() + self.name_edit.textChanged.connect(self._on_name_changed) + layout.addRow(translate("CAM_MachineEditor", "Name"), self.name_edit) + + self.manufacturer_edit = QtGui.QLineEdit() + self.manufacturer_edit.textChanged.connect(self._on_manufacturer_changed) + layout.addRow(translate("CAM_MachineEditor", "Manufacturer"), self.manufacturer_edit) + + self.description_edit = QtGui.QLineEdit() + self.description_edit.textChanged.connect(self._on_description_changed) + layout.addRow(translate("CAM_MachineEditor", "Description"), self.description_edit) + + self.units_combo = QtGui.QComboBox() + self.units_combo.addItem(translate("CAM_MachineEditor", "Metric"), "metric") + self.units_combo.addItem(translate("CAM_MachineEditor", "Imperial"), "imperial") + self.units_combo.currentIndexChanged.connect(self._on_units_changed) + layout.addRow(translate("CAM_MachineEditor", "Units"), self.units_combo) + + self.type_combo = QtGui.QComboBox() + for key, value in self.MACHINE_TYPES.items(): + self.type_combo.addItem(value["name"], key) + self.type_combo.currentIndexChanged.connect(self._on_type_changed) + layout.addRow(translate("CAM_MachineEditor", "Type"), self.type_combo) + + # Post Processor Selection + self.post_processor_combo = QtGui.QComboBox() + postProcessors = Path.Preferences.allEnabledPostProcessors([""]) + for post in postProcessors: + self.post_processor_combo.addItem(post) + self.post_processor_combo.currentIndexChanged.connect(self.updatePostProcessorTooltip) + self.post_processor_combo.currentIndexChanged.connect( + lambda: self._update_machine_field( + "postprocessor_file_name", self.post_processor_combo.currentText() + ) + ) + self.postProcessorDefaultTooltip = translate("CAM_MachineEditor", "Select a post processor") + self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip) + layout.addRow(translate("CAM_MachineEditor", "Post Processor"), self.post_processor_combo) + + # self.post_processor_args_edit = QtGui.QLineEdit() + # self.post_processor_args_edit.textChanged.connect( + # lambda text: self._update_machine_field("postprocessor_args", text) + # ) + # self.postProcessorArgsDefaultTooltip = translate( + # "CAM_MachineEditor", "Additional arguments" + # ) + # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) + # layout.addRow(translate("CAM_MachineEditor", "Arguments"), self.post_processor_args_edit) + + # Axes group + self.axes_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Axes")) + self.axes_layout = QtGui.QVBoxLayout(self.axes_group) + self.axes_group.setVisible(False) # Initially hidden, shown when axes are configured + layout.addRow(self.axes_group) + + # Spindles group + self.spindles_group = QtGui.QGroupBox(translate("CAM_MachineEditor", "Spindles")) + spindles_layout = QtGui.QVBoxLayout(self.spindles_group) + + self.spindles_tabs = QtGui.QTabWidget() + self.spindles_tabs.setTabsClosable(True) + self.spindles_tabs.tabCloseRequested.connect(self._remove_spindle) + + # Add + button to the tab bar corner, vertically centered + corner_container = QtGui.QWidget() + corner_container_layout = QtGui.QVBoxLayout(corner_container) + corner_container_layout.setContentsMargins(0, 0, 0, 0) + corner_container_layout.setSpacing(0) + corner_container_layout.addStretch() + self.add_spindle_button = QtGui.QPushButton("+") + self.add_spindle_button.setToolTip(translate("CAM_MachineEditor", "Add Spindle")) + self.add_spindle_button.clicked.connect(self._add_spindle) + self.add_spindle_button.setSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + corner_container_layout.addWidget(self.add_spindle_button, 0, QtCore.Qt.AlignCenter) + corner_container_layout.addStretch() + self.spindles_tabs.setCornerWidget(corner_container, QtCore.Qt.TopRightCorner) + + spindles_layout.addWidget(self.spindles_tabs) + layout.addRow(self.spindles_group) + + def update_axes(self): + """Update the axes configuration UI based on machine type and units. + + Dynamically creates input fields for all axes based on the selected + machine type. Uses flattened structure with type property. + """ + # Get current units for suffixes and conversion + units = self.units_combo.itemData(self.units_combo.currentIndex()) + length_suffix = " mm" if units == "metric" else " in" + vel_suffix = " mm/min" if units == "metric" else " in/min" + angle_suffix = " deg" + angle_vel_suffix = " deg/min" + + # Convert saved data if units changed + if hasattr(self, "current_units") and self.current_units != units: + self.current_units = units + + # Clear references before deleting widgets + self.axis_edits = {} + + # Clear existing axes widgets and disconnect signals + for i in reversed(range(self.axes_layout.count())): + widget = self.axes_layout.itemAt(i).widget() + if widget: + # Disconnect all signals to prevent callbacks to deleted widgets + widget.blockSignals(True) + widget.deleteLater() + self.axes_layout.removeWidget(widget) + + # Get current type + type_key = self.type_combo.itemData(self.type_combo.currentIndex()) + if not type_key: + return + config = self.MACHINE_TYPES[type_key] + + # Create axes group + all_axes = config.get("linear", []) + config.get("rotary", []) + if not all_axes: + self.axes_group.setVisible(False) + return + + # Get axes directly from machine object + linear_axes = list(self.machine.linear_axes.keys()) if self.machine else [] + rotary_axes = list(self.machine.rotary_axes.keys()) if self.machine else [] + + # Linear axes + if linear_axes: + linear_group = QtGui.QGroupBox("Linear Axes") + linear_layout = QtGui.QFormLayout(linear_group) + + for axis in linear_axes: + axis_obj = self.machine.linear_axes[axis] + converted_min = ( + axis_obj.min_limit if units == "metric" else axis_obj.min_limit / 25.4 + ) + min_edit = QtGui.QLineEdit() + min_edit.setText(f"{converted_min:.2f}{length_suffix}") + min_edit.editingFinished.connect( + lambda edit=min_edit, suffix=length_suffix.strip(), ax=axis, fld="min": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + converted_max = ( + axis_obj.max_limit if units == "metric" else axis_obj.max_limit / 25.4 + ) + max_edit = QtGui.QLineEdit() + max_edit.setText(f"{converted_max:.2f}{length_suffix}") + max_edit.editingFinished.connect( + lambda edit=max_edit, suffix=length_suffix.strip(), ax=axis, fld="max": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + converted_vel = ( + axis_obj.max_velocity if units == "metric" else axis_obj.max_velocity / 25.4 + ) + vel_edit = QtGui.QLineEdit() + vel_edit.setText(f"{converted_vel:.2f}{vel_suffix}") + vel_edit.editingFinished.connect( + lambda edit=vel_edit, suffix=vel_suffix.strip(), ax=axis, fld="max_velocity": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + axis_layout = QtGui.QHBoxLayout() + axis_layout.addWidget(QtGui.QLabel("Min")) + axis_layout.addWidget(min_edit) + axis_layout.addWidget(QtGui.QLabel("Max")) + axis_layout.addWidget(max_edit) + axis_layout.addWidget(QtGui.QLabel("Max Vel")) + axis_layout.addWidget(vel_edit) + + linear_layout.addRow(f"{axis}", axis_layout) + self.axis_edits[axis] = { + "min": min_edit, + "max": max_edit, + "max_velocity": vel_edit, + "type": "linear", + } + self.axes_layout.addWidget(linear_group) + + # Rotary axes + if rotary_axes: + rotary_group = QtGui.QGroupBox("Rotary Axes") + rotary_layout = QtGui.QFormLayout(rotary_group) + + for axis in rotary_axes: + axis_obj = self.machine.rotary_axes[axis] + + min_edit = QtGui.QLineEdit() + min_edit.setText(f"{axis_obj.min_limit:.2f}{angle_suffix}") + min_edit.editingFinished.connect( + lambda edit=min_edit, suffix=angle_suffix.strip(), ax=axis, fld="min": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + max_edit = QtGui.QLineEdit() + max_edit.setText(f"{axis_obj.max_limit:.2f}{angle_suffix}") + max_edit.editingFinished.connect( + lambda edit=max_edit, suffix=angle_suffix.strip(), ax=axis, fld="max": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + vel_edit = QtGui.QLineEdit() + vel_edit.setText(f"{axis_obj.max_velocity:.2f}{angle_vel_suffix}") + vel_edit.editingFinished.connect( + lambda edit=vel_edit, suffix=angle_vel_suffix.strip(), ax=axis, fld="max_velocity": self.normalize_text( + edit, suffix, ax, fld + ) + ) + + # Sequence number for rotary axes + sequence_spin = QtGui.QSpinBox() + sequence_spin.setRange(0, 10) + sequence_spin.setValue(axis_obj.sequence) + sequence_spin.valueChanged.connect( + lambda value, ax=axis: self._on_rotary_sequence_changed(ax, value) + ) + + # Joint (rotation axis) combo + joint_combo = QtGui.QComboBox() + for label, vector in self.ROTATIONAL_AXIS_OPTIONS: + joint_combo.addItem(label, vector) + # Get rotation vector from axis object + rotation_vec = [ + axis_obj.rotation_vector.x, + axis_obj.rotation_vector.y, + axis_obj.rotation_vector.z, + ] + # Find matching option and set it + for i, (label, vector) in enumerate(self.ROTATIONAL_AXIS_OPTIONS): + if vector == rotation_vec: + joint_combo.setCurrentIndex(i) + break + joint_combo.currentIndexChanged.connect( + lambda index, ax=axis, combo=joint_combo: self._on_rotary_joint_changed( + ax, combo + ) + ) + + prefer_positive = QtGui.QCheckBox() + prefer_positive.setChecked(axis_obj.prefer_positive) + prefer_positive.stateChanged.connect( + lambda state, ax=axis: self._on_rotary_prefer_positive_changed( + ax, state == QtCore.Qt.Checked + ) + ) + + # Grid layout + axis_grid = QtGui.QGridLayout() + + # Row 0: Min, Max, Vel + axis_grid.addWidget(QtGui.QLabel("Min"), 0, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(min_edit, 0, 1) + axis_grid.addWidget(QtGui.QLabel("Max"), 0, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(max_edit, 0, 3) + axis_grid.addWidget(QtGui.QLabel("Max Vel"), 0, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(vel_edit, 0, 5) + + # Row 1: Sequence, Joint, Prefer+ + axis_grid.addWidget(QtGui.QLabel("Sequence"), 1, 0, QtCore.Qt.AlignRight) + axis_grid.addWidget(sequence_spin, 1, 1) + axis_grid.addWidget(QtGui.QLabel("Joint"), 1, 2, QtCore.Qt.AlignRight) + axis_grid.addWidget(joint_combo, 1, 3) + axis_grid.addWidget(QtGui.QLabel("Prefer+"), 1, 4, QtCore.Qt.AlignRight) + axis_grid.addWidget(prefer_positive, 1, 5) + + axis_label = QtGui.QLabel(f"{axis}") + axis_label.setMinimumWidth(30) # Prevent layout shift when axis names change + rotary_layout.addRow(axis_label, axis_grid) + + self.axis_edits[axis] = { + "min": min_edit, + "max": max_edit, + "max_velocity": vel_edit, + "sequence": sequence_spin, + "joint": joint_combo, + "prefer_positive": prefer_positive, + "type": "angular", + } + self.axes_layout.addWidget(rotary_group) + + # Show axes group if any axes configured + self.axes_group.setVisible(bool(linear_axes or rotary_axes)) + + def update_spindles(self): + """Update the spindle configuration UI based on spindle count. + + Dynamically creates tabbed interface for multiple spindles with + input fields for name, ID, power, speed, and tool holder. + Updates Machine.spindles directly. + """ + # Clear existing spindle tabs - this properly disconnects signals + while self.spindles_tabs.count() > 0: + tab = self.spindles_tabs.widget(0) + if tab: + tab.blockSignals(True) + tab.deleteLater() + self.spindles_tabs.removeTab(0) + + self.spindle_edits = [] + count = len(self.machine.spindles) if self.machine else 1 + + # Ensure machine has at least 1 spindle + if self.machine: + while len(self.machine.spindles) < count: + self.machine.spindles.append( + Spindle( + name=f"Spindle {len(self.machine.spindles) + 1}", + id=f"spindle{len(self.machine.spindles) + 1}", + max_power_kw=3.0, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + ) + while len(self.machine.spindles) > count: + self.machine.spindles.pop() + + for i in range(count): + tab = QtGui.QWidget() + layout = QtGui.QFormLayout(tab) + + # Get spindle object or use defaults + spindle = ( + self.machine.spindles[i] + if self.machine and i < len(self.machine.spindles) + else None + ) + + name_edit = QtGui.QLineEdit() + name_edit.setText(spindle.name if spindle else f"Spindle {i+1}") + name_edit.textChanged.connect( + lambda text, idx=i: self._on_spindle_field_changed(idx, "name", text) + ) + layout.addRow("Name", name_edit) + + id_edit = QtGui.QLineEdit() + id_edit.setText(spindle.id if spindle and spindle.id else f"spindle{i+1}") + id_edit.textChanged.connect( + lambda text, idx=i: self._on_spindle_field_changed(idx, "id", text) + ) + layout.addRow("ID", id_edit) + + max_power_edit = QtGui.QDoubleSpinBox() + max_power_edit.setRange(0, 100) + max_power_edit.setValue(spindle.max_power_kw if spindle else 3.0) + max_power_edit.valueChanged.connect( + lambda value, idx=i: self._on_spindle_field_changed(idx, "max_power_kw", value) + ) + layout.addRow("Max Power (kW)", max_power_edit) + + max_rpm_edit = QtGui.QSpinBox() + max_rpm_edit.setRange(0, 100000) + max_rpm_edit.setValue(int(spindle.max_rpm) if spindle else 24000) + max_rpm_edit.valueChanged.connect( + lambda value, idx=i: self._on_spindle_field_changed(idx, "max_rpm", value) + ) + layout.addRow("Max RPM", max_rpm_edit) + + min_rpm_edit = QtGui.QSpinBox() + min_rpm_edit.setRange(0, 100000) + min_rpm_edit.setValue(int(spindle.min_rpm) if spindle else 6000) + min_rpm_edit.valueChanged.connect( + lambda value, idx=i: self._on_spindle_field_changed(idx, "min_rpm", value) + ) + layout.addRow("Min RPM", min_rpm_edit) + + tool_change_combo = QtGui.QComboBox() + tool_change_combo.addItem("Manual", "manual") + tool_change_combo.addItem("ATC", "atc") + if spindle: + index = tool_change_combo.findData(spindle.tool_change) + if index >= 0: + tool_change_combo.setCurrentIndex(index) + tool_change_combo.currentIndexChanged.connect( + lambda idx, spindle_idx=i, combo=tool_change_combo: self._on_spindle_field_changed( + spindle_idx, "tool_change", combo.itemData(combo.currentIndex()) + ) + ) + layout.addRow("Tool Change:", tool_change_combo) + + self.spindles_tabs.addTab(tab, f"Spindle {i+1}") + self.spindle_edits.append( + { + "name": name_edit, + "id": id_edit, + "max_power_kw": max_power_edit, + "max_rpm": max_rpm_edit, + "min_rpm": min_rpm_edit, + "tool_change": tool_change_combo, + } + ) + + def setup_output_tab(self): + """Set up the output options configuration tab.""" + # Use scroll area for all the options + scroll = QtGui.QScrollArea() + scroll.setWidgetResizable(True) + scroll_widget = QtGui.QWidget() + layout = QtGui.QVBoxLayout(scroll_widget) + scroll.setWidget(scroll_widget) + + main_layout = QtGui.QVBoxLayout(self.output_tab) + main_layout.addWidget(scroll) + + # === Output Options === + if self.machine: + output_group, output_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.output, "Output Options" + ) + layout.addWidget(output_group) + self._connect_widgets_to_machine(output_widgets, "output") + + layout.addStretch() + + def setup_blocks_tab(self): + """Set up the G-Code blocks configuration tab.""" + # Use scroll area for all the options + scroll = QtGui.QScrollArea() + scroll.setWidgetResizable(True) + scroll_widget = QtGui.QWidget() + layout = QtGui.QVBoxLayout(scroll_widget) + scroll.setWidget(scroll_widget) + + main_layout = QtGui.QVBoxLayout(self.blocks_tab) + main_layout.addWidget(scroll) + + # === G-Code Blocks === + if self.machine: + blocks_group, blocks_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.blocks, "G-Code Blocks" + ) + layout.addWidget(blocks_group) + self._connect_widgets_to_machine(blocks_widgets, "blocks") + + layout.addStretch() + + def setup_processing_tab(self): + """Set up the processing options configuration tab.""" + # Use scroll area for all the options + scroll = QtGui.QScrollArea() + scroll.setWidgetResizable(True) + scroll_widget = QtGui.QWidget() + layout = QtGui.QVBoxLayout(scroll_widget) + scroll.setWidget(scroll_widget) + + main_layout = QtGui.QVBoxLayout(self.processing_tab) + main_layout.addWidget(scroll) + + # === Processing Options === + if self.machine: + processing_group, processing_widgets = DataclassGUIGenerator.create_group_for_dataclass( + self.machine.processing, "Processing Options" + ) + layout.addWidget(processing_group) + self._connect_widgets_to_machine(processing_widgets, "processing") + + layout.addStretch() + + def _connect_widgets_to_machine(self, widgets: Dict[str, QtGui.QWidget], parent_path: str): + """Connect widgets to update Machine object fields. + + Args: + widgets: Dictionary of field_name -> widget + parent_path: Path to parent object (e.g., 'output', 'precision') + """ + for field_name, widget in widgets.items(): + field_path = f"{parent_path}.{field_name}" + + # Store widget for later population + self.post_widgets[field_path] = widget + + # Connect based on widget type + if isinstance(widget, QtGui.QCheckBox): + # Use a wrapper to avoid lambda capture issues + def make_checkbox_handler(path): + def handler(state): + # Qt.Checked = 2, Qt.Unchecked = 0 + value = state == 2 + self._update_machine_field(path, value) + + return handler + + handler_func = make_checkbox_handler(field_path) + widget.stateChanged.connect(handler_func) + elif isinstance(widget, QtGui.QLineEdit): + + def make_lineedit_handler(path): + def handler(text): + self._update_machine_field(path, text) + + return handler + + widget.textChanged.connect(make_lineedit_handler(field_path)) + elif isinstance(widget, QtGui.QPlainTextEdit): + + def make_plaintext_handler(path, w): + def handler(): + self._update_machine_field(path, w.value_getter()) + + return handler + + widget.textChanged.connect(make_plaintext_handler(field_path, widget)) + elif isinstance(widget, (QtGui.QSpinBox, QtGui.QDoubleSpinBox)): + + def make_spinbox_handler(path): + def handler(value): + self._update_machine_field(path, value) + + return handler + + widget.valueChanged.connect(make_spinbox_handler(field_path)) + elif isinstance(widget, QtGui.QComboBox): + + def make_combo_handler(path, w): + def handler(index): + self._update_machine_field(path, w.value_getter()) + + return handler + + widget.currentIndexChanged.connect(make_combo_handler(field_path, widget)) + + def _update_machine_field(self, field_path, value): + """Update a nested field in the machine object using dot notation.""" + if not self.machine: + return + + try: + parts = field_path.split(".") + obj = self.machine + + # Navigate to the parent object + for part in parts[:-1]: + obj = getattr(obj, part) + + # Set the final field + final_field = parts[-1] + current_value = getattr(obj, final_field, None) + + # Only update if value actually changed + if current_value != value: + setattr(obj, final_field, value) + except Exception as e: + Path.Log.error(f"Error updating {field_path}: {e}") + + def _populate_post_widgets_from_machine(self, machine: Machine): + """Populate dynamically generated post-processor widgets from machine object. + + This updates all the nested dataclass widgets (output, precision, formatting, + blocks, processing) and top-level machine post-processing options. + + Args: + machine: Machine object to read values from + """ + + # Helper to set widget value without triggering signals + def set_widget_value_silent(widget, value): + if isinstance(widget, QtGui.QCheckBox): + widget.blockSignals(True) + widget.setChecked(value) + widget.blockSignals(False) + elif isinstance(widget, QtGui.QLineEdit): + widget.blockSignals(True) + widget.setText(str(value) if value is not None else "") + widget.blockSignals(False) + elif isinstance(widget, QtGui.QPlainTextEdit): + widget.blockSignals(True) + if isinstance(value, list): + widget.setPlainText("\n".join(value)) + else: + widget.setPlainText(str(value) if value is not None else "") + widget.blockSignals(False) + elif isinstance(widget, QtGui.QSpinBox): + widget.blockSignals(True) + widget.setValue(int(value) if value is not None else 0) + widget.blockSignals(False) + elif isinstance(widget, QtGui.QDoubleSpinBox): + widget.blockSignals(True) + widget.setValue(float(value) if value is not None else 0.0) + widget.blockSignals(False) + elif isinstance(widget, QtGui.QComboBox): + widget.blockSignals(True) + if hasattr(value, "value"): # Enum + value = value.value + # Find the item with this value + for i in range(widget.count()): + item_data = widget.itemData(i) + if item_data == value or widget.itemText(i) == str(value): + widget.setCurrentIndex(i) + break + widget.blockSignals(False) + + # Update nested dataclass fields + dataclass_groups = [ + ("output", machine.output), + ("blocks", machine.blocks), + ("processing", machine.processing), + ] + + for group_name, dataclass_obj in dataclass_groups: + if dataclass_obj is None: + continue + + # Get all fields from the dataclass + from dataclasses import fields as dataclass_fields + + for field in dataclass_fields(dataclass_obj): + widget_key = f"{group_name}.{field.name}" + # Find the widget in post_widgets (it might be stored with or without the parent path) + widget = None + if widget_key in self.post_widgets: + widget = self.post_widgets[widget_key] + elif field.name in self.post_widgets: + widget = self.post_widgets[field.name] + + if widget: + value = getattr(dataclass_obj, field.name) + set_widget_value_silent(widget, value) + + # Update top-level machine post-processing fields + top_level_fields = [ + "use_tlo", + "stop_spindle_for_tool_change", + "enable_coolant", + "enable_machine_specific_commands", + ] + + for field_name in top_level_fields: + if field_name in self.post_widgets: + widget = self.post_widgets[field_name] + value = getattr(machine, field_name, None) + if value is not None: + set_widget_value_silent(widget, value) + + def getPostProcessor(self, name): + if name not in self.processor: + processor = PostProcessorFactory.get_post_processor(None, name) + self.processor[name] = processor + return processor + return self.processor[name] + + def setPostProcessorTooltip(self, widget, name, default): + processor = self.getPostProcessor(name) + if processor.tooltip: + widget.setToolTip(processor.tooltip) + else: + widget.setToolTip(default) + + def updatePostProcessorTooltip(self): + name = str(self.post_processor_combo.currentText()) + if name: + self.setPostProcessorTooltip( + self.post_processor_combo, name, self.postProcessorDefaultTooltip + ) + processor = self.getPostProcessor(name) + # if processor.tooltipArgs: + # self.post_processor_args_edit.setToolTip(processor.tooltipArgs) + # else: + # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) + else: + self.post_processor_combo.setToolTip(self.postProcessorDefaultTooltip) + # self.post_processor_args_edit.setToolTip(self.postProcessorArgsDefaultTooltip) + + def populate_from_machine(self, machine: Machine): + """Populate UI fields from Machine object. + + Args: + machine: Machine object containing configuration + """ + self.name_edit.setText(machine.name) + self.manufacturer_edit.setText(machine.manufacturer) + self.description_edit.setText(machine.description) + units = machine.configuration_units + self.units_combo.blockSignals(True) + index = self.units_combo.findData(units) + if index >= 0: + self.units_combo.setCurrentIndex(index) + self.units_combo.blockSignals(False) + self.current_units = units + machine_type = machine.machine_type + self.type_combo.blockSignals(True) + index = self.type_combo.findData(machine_type) + if index >= 0: + self.type_combo.setCurrentIndex(index) + self.type_combo.blockSignals(False) + + # Post processor selection + if self.enable_machine_postprocessor: + post_processor = machine.postprocessor_file_name + index = self.post_processor_combo.findText(post_processor, QtCore.Qt.MatchFixedString) + if index >= 0: + self.post_processor_combo.setCurrentIndex(index) + else: + self.post_processor_combo.setCurrentIndex(0) + self.updatePostProcessorTooltip() + + # Post processor arguments + # self.post_processor_args_edit.setText(machine.postprocessor_args) + + # Get units for suffixes in populate + units = self.units_combo.itemData(self.units_combo.currentIndex()) + + # Update axes UI after loading machine data + self.update_axes() + + # Ensure at least 1 spindle + if len(machine.spindles) == 0: + machine.spindles.append( + Spindle( + name="Spindle 1", + id="spindle1", + max_power_kw=3.0, + max_rpm=24000, + min_rpm=6000, + tool_change="manual", + ) + ) + self.update_spindles() # Update spindles UI + self._update_spindle_button_state() + + # Post processor configuration - populate dynamically generated widgets + if self.enable_machine_postprocessor and hasattr(self, "post_widgets"): + # Update all post-processor widgets from machine object + self._populate_post_widgets_from_machine(machine) + + def to_machine(self) -> Machine: + """Convert UI state to Machine object. + + Returns: + Machine object with configuration from UI + """ + # The machine object is already up-to-date from signal handlers + # (both axes and spindles update in real-time) + return self.machine + + def to_data(self) -> Dict[str, Any]: + """Convert UI state to machine data dictionary. + + Returns: + Dict containing complete machine configuration in JSON format + """ + # Update machine from UI first (spindles need to be synchronized) + machine = self.to_machine() + + # Use Machine's to_dict method for serialization + return machine.to_dict() + + def toggle_editor_mode(self): + """Toggle between form view and text editor view.""" + if self.text_mode: + # Switching from text to form mode + try: + # Parse JSON from text editor + json_text = self.text_editor.toPlainText() + data = json.loads(json_text) + # Create Machine object from JSON and populate form + self.machine = Machine.from_dict(data) + self.populate_from_machine(self.machine) + # Show form, hide editor + self.tabs.show() + self.text_editor.hide() + self.toggle_button.setText(translate("CAM_MachineEditor", "Edit as Text")) + self.text_mode = False + except json.JSONDecodeError as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "JSON Error"), + translate("CAM_MachineEditor", "Invalid JSON: {}").format(str(e)), + ) + except Exception as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "Error"), + translate("CAM_MachineEditor", "Failed to parse data: {}").format(str(e)), + ) + else: + # Switching from form to text mode + try: + # self.machine is already up-to-date from signal handlers + # Just serialize it to JSON + data = self.machine.to_dict() + json_text = json.dumps(data, indent=4, sort_keys=True) + self.text_editor.setPlainText(json_text) + # Hide form, show editor + self.tabs.hide() + self.text_editor.show() + self.toggle_button.setText(translate("CAM_MachineEditor", "Edit as Form")) + self.text_mode = True + except Exception as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "Error"), + translate("CAM_MachineEditor", "Failed to generate JSON: {}").format(str(e)), + ) + + def accept(self): + """Handle save and close action.""" + # Check for duplicate machine names when creating new machines + if self.text_mode: + try: + json_text = self.text_editor.toPlainText() + data = json.loads(json_text) + machine_name = data.get("machine", {}).get("name", "") + except json.JSONDecodeError: + machine_name = "" + else: + machine_name = self.name_edit.text().strip() + + # Check for duplicate machine names + if machine_name: + existing_machines = MachineFactory.list_configurations() + # Case-insensitive check to match get_machine behavior + machine_name_lower = machine_name.lower() + existing_names_lower = [name.lower() for name in existing_machines] + + # For existing machines, allow keeping the same name (case-insensitive) + current_name_allowed = False + if self.filename: + try: + current_machine = MachineFactory.load_configuration(self.filename) + current_name = current_machine.name.lower() + if machine_name_lower == current_name: + current_name_allowed = True + except Exception: + # Failed to load current machine configuration, assume name is not allowed + pass + + if machine_name_lower in existing_names_lower and not current_name_allowed: + QtGui.QMessageBox.warning( + self, + translate("CAM_MachineEditor", "Duplicate Machine Name"), + translate( + "CAM_MachineEditor", + "A machine with the name '{}' already exists. Please choose a different name.", + ).format(machine_name), + ) + return + + try: + if self.text_mode: + # If in text mode, parse JSON and update Machine + json_text = self.text_editor.toPlainText() + data = json.loads(json_text) + self.machine = Machine.from_dict(data) + + # self.machine is already up-to-date from signal handlers, just save it + if self.filename: + saved_path = MachineFactory.save_configuration(self.machine, self.filename) + else: + saved_path = MachineFactory.save_configuration(self.machine) + self.filename = saved_path.name + self.path = str(saved_path) # Keep for compatibility + + except json.JSONDecodeError as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "JSON Error"), + translate("CAM_MachineEditor", "Invalid JSON: {}").format(str(e)), + ) + return + except Exception as e: + QtGui.QMessageBox.critical( + self, + translate("CAM_MachineEditor", "Error"), + translate("CAM_MachineEditor", "Failed to save: {}").format(str(e)), + ) + return + super().accept() diff --git a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py index 6900846bd6..4678029d40 100644 --- a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py +++ b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py @@ -39,7 +39,7 @@ class JobPreferencesPage: import FreeCADGui self.form = FreeCADGui.PySideUic.loadUi(":preferences/PathJob.ui") - self.form.toolBox.setCurrentIndex(0) # Take that qt designer! + self.form.tabWidget.setCurrentIndex(0) # Take that qt designer! self.postProcessorDefaultTooltip = self.form.defaultPostProcessor.toolTip() self.postProcessorArgsDefaultTooltip = self.form.defaultPostProcessorArgs.toolTip() diff --git a/src/Mod/CAM/Path/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..252de63165 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -26,6 +26,8 @@ import tempfile import FreeCAD import Path from PySide import QtGui, QtCore +from ....Machine.ui.editor import MachineEditorDialog +from ....Machine.models import MachineFactory translate = FreeCAD.Qt.translate @@ -46,20 +48,21 @@ def _is_writable_dir(path: pathlib.Path) -> bool: class AssetPreferencesPage: def __init__(self, parent=None): - self.form = QtGui.QToolBox() + self.form = QtGui.QWidget() self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets")) - asset_path_widget = QtGui.QWidget() - main_layout = QtGui.QHBoxLayout(asset_path_widget) + # Set up main layout directly on the form + self.main_layout = QtGui.QVBoxLayout(self.form) # Create widgets - self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:")) + self.assets_group = QtGui.QGroupBox(translate("CAM_PreferencesAssets", "Asset Location")) + self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Default path")) self.asset_path_edit = QtGui.QLineEdit() self.asset_path_note_label = QtGui.QLabel( translate( "CAM_PreferencesAssets", "Note: Select the directory that will contain the " - "Tool folder with Bit/, Shape/, and Library/ subfolders.", + "Tools folder with Bit/, Shape/, and Library/ subfolders and the Machines/ folder.", ) ) self.asset_path_note_label.setWordWrap(True) @@ -74,24 +77,67 @@ class AssetPreferencesPage: font.setItalic(True) self.asset_path_note_label.setFont(font) - # Layout for asset path section - edit_button_layout = QtGui.QGridLayout() - edit_button_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter) - edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop) - edit_button_layout.addItem( - QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding), - 2, - 0, - 1, - 4, + # Assets group box + self.assets_layout = QtGui.QGridLayout(self.assets_group) + self.assets_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter) + self.assets_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop) + self.main_layout.addWidget(self.assets_group) + + # Machines group box + self.machines_group = QtGui.QGroupBox(translate("CAM_PreferencesAssets", "Machines")) + self.machines_layout = QtGui.QVBoxLayout(self.machines_group) + + self.warning_label = QtGui.QLabel( + translate( + "CAM_PreferencesAssets", + "Warning: Machine definition is an experimental feature. Changes " + "made here will not affect any CAM functionality", + ) ) + self.warning_label.setWordWrap(True) + warning_font = self.warning_label.font() + warning_font.setItalic(True) + self.warning_label.setFont(warning_font) + self.warning_label.setContentsMargins(0, 0, 0, 10) + self.machines_layout.addWidget(self.warning_label) - main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop) + self.machines_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Machines")) + self.machines_layout.addWidget(self.machines_label) - self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets")) + self.machines_list = QtGui.QListWidget() + self.machines_layout.addWidget(self.machines_list) + + # Buttons: Add / Edit / Delete + self.btn_layout = QtGui.QHBoxLayout() + self.add_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Add")) + self.edit_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Edit")) + self.delete_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Delete")) + self.btn_layout.addWidget(self.add_machine_btn) + self.btn_layout.addWidget(self.edit_machine_btn) + self.btn_layout.addWidget(self.delete_machine_btn) + self.machines_layout.addLayout(self.btn_layout) + + self.machines_layout.addStretch() # Prevent the list from stretching + + self.main_layout.addWidget(self.machines_group) + + # Wire up buttons + self.add_machine_btn.clicked.connect(self.add_machine) + 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 @@ -131,3 +177,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