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:
417
src/Mod/CAM/CAMTests/TestMachine.py
Normal file
417
src/Mod/CAM/CAMTests/TestMachine.py
Normal 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)
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
|
||||
22
src/Mod/CAM/Path/Machine/models/__init__.py
Normal file
22
src/Mod/CAM/Path/Machine/models/__init__.py
Normal 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/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
1474
src/Mod/CAM/Path/Machine/models/machine.py
Normal file
1474
src/Mod/CAM/Path/Machine/models/machine.py
Normal file
File diff suppressed because it is too large
Load Diff
26
src/Mod/CAM/Path/Machine/ui/editor/__init__.py
Normal file
26
src/Mod/CAM/Path/Machine/ui/editor/__init__.py
Normal 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"]
|
||||
1478
src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py
Normal file
1478
src/Mod/CAM/Path/Machine/ui/editor/machine_editor.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
from .models.machine import Machine
|
||||
|
||||
__all__ = [
|
||||
"Machine",
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user