CAM: Add Machine Library and Editor

This PR introduces a machine object and a machine library, along with a
new machine editor dialog for creating and editing *.fcm machine asset
files. The editor is integrated into the CAM preferences panel, with new
Python modules for the dialog and minimal model validation. Machine
management (add, edit, delete) is now available in the CAM asset
preferences panel.

Key Features:
- Machines now have a type and units property. The machine type can be
used to distinguish between different classes of machines (e.g., mill,
lathe, laser).
- Machine units are stored internally and in the .fcm JSON file as
metric. Note: This differs from how toolbits work (which store units in
their native units)
- Support for 2-5 axis machines.
- Support for multiple spindles (up to 9)
- Processor defaults
- JSON Text Editor with basic validation and line numbers.
This commit is contained in:
Billy Huddleston
2025-12-09 13:07:18 -05:00
parent c6ede8614f
commit dc7991bd9d
14 changed files with 3548 additions and 674 deletions

View File

@@ -0,0 +1,417 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Brad Collette *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# ***************************************************************************
import FreeCAD
import unittest
import tempfile
import pathlib
import json
import CAMTests.PathTestUtils as PathTestUtils
from Path.Machine.models.machine import (
Machine,
Spindle,
OutputOptions,
GCodeBlocks,
ProcessingOptions,
MotionMode,
MachineFactory,
)
class TestMachineDataclass(PathTestUtils.PathTestBase):
"""Test the unified Machine dataclass"""
def setUp(self):
"""Set up test fixtures"""
self.default_machine = Machine()
def test_default_initialization(self):
"""Test that Machine initializes with sensible defaults"""
machine = Machine()
# Basic identification
self.assertEqual(machine.name, "Default Machine")
self.assertEqual(machine.manufacturer, "")
self.assertEqual(machine.description, "")
# Machine type is derived from axes configuration
self.assertEqual(machine.machine_type, "custom") # No axes configured yet
# Add axes and verify machine type updates
machine.add_linear_axis("X", FreeCAD.Vector(1, 0, 0))
machine.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0))
self.assertEqual(machine.machine_type, "custom") # Still missing Z axis
machine.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1))
self.assertEqual(machine.machine_type, "xyz") # Now has XYZ axes
# Add rotary axes and verify machine type updates
machine.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), -120, 120)
self.assertEqual(machine.machine_type, "xyza")
machine.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), -360, 360)
self.assertEqual(machine.machine_type, "xyzac")
# Coordinate system defaults
self.assertEqual(machine.reference_system["X"], FreeCAD.Vector(1, 0, 0))
self.assertEqual(machine.reference_system["Y"], FreeCAD.Vector(0, 1, 0))
self.assertEqual(machine.reference_system["Z"], FreeCAD.Vector(0, 0, 1))
self.assertEqual(machine.tool_axis, FreeCAD.Vector(0, 0, -1))
# Units and versioning
self.assertEqual(machine.configuration_units, "metric")
self.assertEqual(machine.version, 1)
self.assertIsNotNone(machine.freecad_version)
# Post-processor defaults
self.assertIsInstance(machine.output, OutputOptions)
self.assertIsInstance(machine.blocks, GCodeBlocks)
self.assertIsInstance(machine.processing, ProcessingOptions)
# Motion mode
self.assertEqual(machine.motion_mode, MotionMode.ABSOLUTE)
def test_custom_initialization(self):
"""Test Machine initialization with custom values and verify machine_type is derived"""
# Create a 5-axis machine (XYZAC)
machine = Machine(
name="Test Mill",
manufacturer="ACME Corp",
description="5-axis mill",
configuration_units="imperial",
)
# Add axes to make it a 5-axis machine
machine.add_linear_axis("X", FreeCAD.Vector(1, 0, 0))
machine.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0))
machine.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1))
machine.add_rotary_axis("A", FreeCAD.Vector(1, 0, 0), -120, 120)
machine.add_rotary_axis("C", FreeCAD.Vector(0, 0, 1), -360, 360)
self.assertEqual(machine.name, "Test Mill")
self.assertEqual(machine.manufacturer, "ACME Corp")
self.assertEqual(machine.description, "5-axis mill")
self.assertEqual(machine.machine_type, "xyzac")
self.assertEqual(machine.configuration_units, "imperial")
def test_configuration_units_property(self):
"""Test configuration_units property returns correct values"""
metric_machine = Machine(configuration_units="metric")
self.assertEqual(metric_machine.configuration_units, "metric")
imperial_machine = Machine(configuration_units="imperial")
self.assertEqual(imperial_machine.configuration_units, "imperial")
class TestOutputOptions(PathTestUtils.PathTestBase):
"""Test OutputOptions dataclass"""
def test_default_initialization(self):
"""Test OutputOptions initialization with defaults"""
opts = OutputOptions()
# Default values
self.assertTrue(opts.comments)
self.assertTrue(opts.blank_lines)
self.assertTrue(opts.header)
self.assertFalse(opts.line_numbers)
self.assertFalse(opts.bcnc_blocks)
self.assertFalse(opts.path_labels)
self.assertFalse(opts.machine_name)
self.assertTrue(opts.tool_change)
self.assertTrue(opts.doubles)
self.assertFalse(opts.adaptive)
def test_custom_initialization(self):
"""Test OutputOptions initialization with custom values"""
opts = OutputOptions(
comments=False,
blank_lines=False,
header=False,
line_numbers=True,
bcnc_blocks=True,
path_labels=True,
machine_name=True,
tool_change=False,
doubles=False,
adaptive=True,
)
# Verify custom values
self.assertFalse(opts.comments)
self.assertFalse(opts.blank_lines)
self.assertFalse(opts.header)
self.assertTrue(opts.line_numbers)
self.assertTrue(opts.bcnc_blocks)
self.assertTrue(opts.path_labels)
self.assertTrue(opts.machine_name)
self.assertFalse(opts.tool_change)
self.assertFalse(opts.doubles)
self.assertTrue(opts.adaptive)
def test_equality(self):
"""Test OutputOptions equality comparison"""
opts1 = OutputOptions()
opts2 = OutputOptions()
self.assertEqual(opts1, opts2)
opts2.comments = False
self.assertNotEqual(opts1, opts2)
class TestSpindle(PathTestUtils.PathTestBase):
"""Test Spindle dataclass"""
def test_spindle_initialization(self):
"""Test Spindle initialization with defaults"""
spindle = Spindle(
name="Main Spindle",
max_power_kw=5.5,
max_rpm=24000,
min_rpm=1000,
tool_change="automatic",
)
self.assertEqual(spindle.name, "Main Spindle")
self.assertEqual(spindle.max_power_kw, 5.5)
self.assertEqual(spindle.max_rpm, 24000)
self.assertEqual(spindle.min_rpm, 1000)
self.assertEqual(spindle.tool_change, "automatic")
# Default tool axis should be set
self.assertEqual(spindle.tool_axis, FreeCAD.Vector(0, 0, -1))
def test_spindle_custom_tool_axis(self):
"""Test Spindle with custom tool axis"""
spindle = Spindle(
name="Side Spindle",
tool_axis=FreeCAD.Vector(1, 0, 0),
)
self.assertEqual(spindle.tool_axis, FreeCAD.Vector(1, 0, 0))
def test_spindle_serialization(self):
"""Test to_dict and from_dict"""
spindle = Spindle(
name="Test Spindle",
id="spindle-001",
max_power_kw=3.0,
max_rpm=18000,
min_rpm=500,
tool_change="manual",
tool_axis=FreeCAD.Vector(0, 1, 0),
)
data = spindle.to_dict()
self.assertEqual(data["name"], "Test Spindle")
self.assertEqual(data["id"], "spindle-001")
self.assertEqual(data["max_power_kw"], 3.0)
self.assertEqual(data["tool_axis"], [0, 1, 0])
restored = Spindle.from_dict(data)
self.assertEqual(restored.name, spindle.name)
self.assertEqual(restored.id, spindle.id)
self.assertEqual(restored.max_power_kw, spindle.max_power_kw)
self.assertEqual(restored.tool_axis, spindle.tool_axis)
class TestMachineFactory(PathTestUtils.PathTestBase):
"""Test MachineFactory class for loading/saving configurations"""
def setUp(self):
"""Set up test fixtures with temporary directory"""
self.temp_dir = tempfile.mkdtemp()
self.temp_path = pathlib.Path(self.temp_dir)
MachineFactory.set_config_directory(self.temp_dir)
def tearDown(self):
"""Clean up temporary directory"""
import shutil
if self.temp_path.exists():
shutil.rmtree(self.temp_path)
def test_set_and_get_config_directory(self):
"""Test setting and getting configuration directory"""
test_dir = self.temp_path / "test_configs"
MachineFactory.set_config_directory(test_dir)
config_dir = MachineFactory.get_config_directory()
self.assertEqual(config_dir, test_dir)
self.assertTrue(config_dir.exists())
def test_save_and_load_configuration(self):
"""Test saving and loading a machine configuration"""
# Create a test machine
machine = Machine(
name="Test Machine",
manufacturer="Test Corp",
description="Test description",
configuration_units="metric",
)
# Add axes to make it an XYZ machine
machine.add_linear_axis("X", FreeCAD.Vector(1, 0, 0))
machine.add_linear_axis("Y", FreeCAD.Vector(0, 1, 0))
machine.add_linear_axis("Z", FreeCAD.Vector(0, 0, 1))
# Add a spindle
spindle = Spindle(
name="Main Spindle",
max_power_kw=5.5,
max_rpm=24000,
min_rpm=1000,
)
machine.spindles.append(spindle)
# Save configuration
filepath = MachineFactory.save_configuration(machine, "test_machine.fcm")
self.assertTrue(filepath.exists())
# Load configuration
loaded_machine = MachineFactory.load_configuration("test_machine.fcm")
# Verify loaded data
self.assertEqual(loaded_machine.name, "Test Machine")
self.assertEqual(loaded_machine.manufacturer, "Test Corp")
self.assertEqual(loaded_machine.description, "Test description")
self.assertEqual(loaded_machine.machine_type, "xyz")
self.assertEqual(loaded_machine.configuration_units, "metric")
self.assertEqual(len(loaded_machine.spindles), 1)
self.assertEqual(loaded_machine.spindles[0].name, "Main Spindle")
def test_save_configuration_auto_filename(self):
"""Test saving with automatic filename generation"""
machine = Machine(name="My Test Machine")
filepath = MachineFactory.save_configuration(machine)
# Should create file with sanitized name
self.assertTrue(filepath.exists())
self.assertEqual(filepath.name, "My_Test_Machine.fcm")
def test_load_nonexistent_file(self):
"""Test loading a file that doesn't exist"""
with self.assertRaises(FileNotFoundError):
MachineFactory.load_configuration("nonexistent.fcm")
def test_create_default_machine_data(self):
"""Test creating default machine data dictionary"""
data = MachineFactory.create_default_machine_data()
self.assertIsInstance(data, dict)
# The data structure has nested "machine" key
self.assertIn("machine", data)
self.assertEqual(data["machine"]["name"], "New Machine")
self.assertIn("spindles", data["machine"])
def test_list_configuration_files(self):
"""Test listing available configuration files"""
# Create some test configurations
machine1 = Machine(name="Machine 1")
machine2 = Machine(name="Machine 2")
MachineFactory.save_configuration(machine1, "machine1.fcm")
MachineFactory.save_configuration(machine2, "machine2.fcm")
# List configurations
configs = MachineFactory.list_configuration_files()
# Should include <any> plus our two machines
self.assertGreaterEqual(len(configs), 3)
self.assertEqual(configs[0][0], "<any>")
# 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("<any>", configs)
# Returns display name from JSON, not filename
self.assertIn("Test Machine", configs)
def test_delete_configuration(self):
"""Test deleting a configuration file"""
machine = Machine(name="To Delete")
filepath = MachineFactory.save_configuration(machine, "delete_me.fcm")
self.assertTrue(filepath.exists())
# Delete the configuration
result = MachineFactory.delete_configuration("delete_me.fcm")
self.assertTrue(result)
self.assertFalse(filepath.exists())
# Try deleting again (should return False)
result = MachineFactory.delete_configuration("delete_me.fcm")
self.assertFalse(result)
def test_get_builtin_config(self):
"""Test getting built-in machine configurations"""
# Test each built-in config type
config_types = ["XYZ", "XYZAC", "XYZBC", "XYZA", "XYZB"]
for config_type in config_types:
machine = MachineFactory.get_builtin_config(config_type)
self.assertIsInstance(machine, Machine)
self.assertIsNotNone(machine.name)
def test_get_builtin_config_invalid_type(self):
"""Test getting built-in config with invalid type"""
with self.assertRaises(ValueError):
MachineFactory.get_builtin_config("INVALID")
def test_serialization_roundtrip(self):
"""Test full serialization roundtrip with complex machine"""
# Create a complex machine with all components
machine = Machine(
name="Complex Machine",
manufacturer="Test Mfg",
description="Full featured machine",
machine_type="xyzac",
configuration_units="metric",
)
# Add spindle
machine.spindles.append(
Spindle(
name="Main",
max_power_kw=7.5,
max_rpm=30000,
)
)
# Configure post-processor settings
machine.output.comments = False
machine.output.axis_precision = 4
machine.output.line_increment = 5
# line_increment is set to default 10 in OutputOptions
# Save and load
filepath = MachineFactory.save_configuration(machine, "complex.fcm")
loaded = MachineFactory.load_configuration("complex.fcm")
# Verify all components
self.assertEqual(loaded.name, machine.name)
self.assertEqual(loaded.manufacturer, machine.manufacturer)
self.assertEqual(len(loaded.spindles), 1)
self.assertFalse(loaded.output.comments)
self.assertEqual(loaded.output.axis_precision, 4)
self.assertEqual(loaded.output.line_increment, 5)

View File

@@ -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()

View File

@@ -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}

View File

@@ -0,0 +1,22 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""Machine editor package."""
from .machine_editor import MachineEditorDialog
__all__ = ["MachineEditorDialog"]

File diff suppressed because it is too large Load Diff

View File

@@ -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):

View File

@@ -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.

View File

@@ -25,7 +25,10 @@ import pathlib
import tempfile
import FreeCAD
import Path
import json
from PySide import QtGui, QtCore
from ....Machine.ui.editor import MachineEditorDialog
from ....Machine.models.machine import MachineFactory
translate = FreeCAD.Qt.translate
@@ -81,18 +84,48 @@ class AssetPreferencesPage:
edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop)
edit_button_layout.addItem(
QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding),
2,
0,
1,
4,
)
edit_button_layout.setRowStretch(3, 1)
main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop)
self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets"))
# Integrate machines list into the Assets panel
machines_list_layout = QtGui.QVBoxLayout()
machines_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Machines"))
machines_list_layout.addWidget(machines_label)
self.machines_list = QtGui.QListWidget()
machines_list_layout.addWidget(self.machines_list)
# Buttons: Add / Edit / Delete
btn_layout = QtGui.QHBoxLayout()
self.add_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Add"))
self.edit_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Edit"))
self.delete_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Delete"))
btn_layout.addWidget(self.add_machine_btn)
btn_layout.addWidget(self.edit_machine_btn)
btn_layout.addWidget(self.delete_machine_btn)
machines_list_layout.addLayout(btn_layout)
# Insert the machines list directly under the path controls
edit_button_layout.addLayout(machines_list_layout, 2, 0, 1, 4)
# Wire up buttons
self.add_machine_btn.clicked.connect(self.add_machine)
self.edit_machine_btn.clicked.connect(self.edit_machine)
self.delete_machine_btn.clicked.connect(self.delete_machine)
# Connect double-click to edit
self.machines_list.itemDoubleClicked.connect(self.edit_machine)
for name, filename in MachineFactory.list_configuration_files():
if name == "<any>" or filename is None:
continue
item = QtGui.QListWidgetItem(name)
item.setData(QtCore.Qt.UserRole, filename)
self.machines_list.addItem(item)
def selectAssetPath(self):
# Implement directory selection dialog
path = QtGui.QFileDialog.getExistingDirectory(
@@ -131,3 +164,61 @@ class AssetPreferencesPage:
if not asset_path:
asset_path = str(Path.Preferences.getDefaultAssetPath())
self.asset_path_edit.setText(asset_path)
def add_machine(self):
# Create a new machine JSON file in the user's machine asset folder
try:
# Open editor for new machine, filename will be generated on save
editor = MachineEditorDialog()
if editor.exec_() == QtGui.QDialog.Accepted:
# add to list
filename = editor.filename
display_name = MachineFactory.get_machine_display_name(filename)
item = QtGui.QListWidgetItem(display_name)
item.setData(QtCore.Qt.UserRole, filename) # Store filename only
self.machines_list.addItem(item)
except Exception as e:
Path.Log.error(f"Failed to create machine file: {e}")
def edit_machine(self):
try:
item = self.machines_list.currentItem()
if not item:
return
filename = item.data(QtCore.Qt.UserRole)
if not filename:
return
dlg = MachineEditorDialog(filename)
if dlg.exec_() == QtGui.QDialog.Accepted:
# Reload display name from file after save
display = MachineFactory.get_machine_display_name(filename)
if display:
item.setText(display)
except Exception as e:
Path.Log.error(f"Failed to open machine editor: {e}")
def delete_machine(self):
try:
item = self.machines_list.currentItem()
if not item:
return
filename = item.data(QtCore.Qt.UserRole)
if not filename:
return
# Confirm delete
resp = QtGui.QMessageBox.question(
self.form,
translate("CAM_PreferencesAssets", "Delete Machine"),
translate(
"CAM_PreferencesAssets", "Are you sure you want to delete this machine file?"
),
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if resp != QtGui.QMessageBox.Yes:
return
if MachineFactory.delete_configuration(filename):
self.machines_list.takeItem(self.machines_list.currentRow())
else:
Path.Log.error("Failed to delete machine file.")
except Exception as e:
Path.Log.error(f"Failed to delete machine: {e}")

View File

@@ -1,7 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
from .models.machine import Machine
__all__ = [
"Machine",
]

View File

@@ -1 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later

View File

@@ -1,435 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * 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

View File

@@ -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