Merge pull request #26533 from Connor9220/Machine

CAM: Add Machine Library and Editor
This commit is contained in:
sliptonic
2026-01-10 14:33:54 -06:00
committed by GitHub
18 changed files with 3785 additions and 724 deletions

164
generate_machine_box.py Normal file
View File

@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""
FreeCAD Macro: Generate Machine Boundary Box
This macro creates a wireframe box representing the working envelope
of a CNC machine based on its configuration.
COORDINATE SYSTEM:
- Uses MACHINE coordinates (absolute travel limits of the machine)
- Not work coordinates (relative to workpiece)
- Shows the full extent the machine can move in X, Y, Z directions
Author: Generated for FreeCAD CAM
"""
import FreeCAD
import Part
import Path
from Path.Machine.models.machine import MachineFactory
import os
def get_machine_file():
"""Prompt user to select a machine configuration file."""
# Get available machine files
machines = MachineFactory.list_configuration_files()
machine_names = [name for name, path in machines if path is not None]
if not machine_names:
FreeCAD.Console.PrintError("No machine configuration files found.\n")
return None
# For now, use the first machine. In a real macro, you'd use a dialog
# to let the user choose
selected_name = machine_names[0] # Default to first
selected_path = None
for name, path in machines:
if name == selected_name and path:
selected_path = path
break
if not selected_path:
FreeCAD.Console.PrintError("Could not find selected machine file.\n")
return None
return selected_path
def create_machine_boundary_box(machine_file, color=(1.0, 0.0, 0.0), line_width=2.0, draw_style="Dashed"):
"""Create a wireframe box showing machine boundaries.
Args:
machine_file: Path to the machine configuration file
color: RGB tuple for wire color (default: red)
line_width: Width of the wires (default: 2.0)
draw_style: "Solid", "Dashed", or "Dotted" (default: "Dashed")
"""
try:
# Load the machine configuration
machine = MachineFactory.load_configuration(machine_file)
FreeCAD.Console.PrintMessage(f"Loaded machine: {machine.name}\n")
# Get axis limits
x_min = y_min = z_min = float('inf')
x_max = y_max = z_max = float('-inf')
# Find min/max for linear axes
for axis_name, axis_obj in machine.linear_axes.items():
if axis_name.upper() == 'X':
x_min = min(x_min, axis_obj.min_limit)
x_max = max(x_max, axis_obj.max_limit)
elif axis_name.upper() == 'Y':
y_min = min(y_min, axis_obj.min_limit)
y_max = max(y_max, axis_obj.max_limit)
elif axis_name.upper() == 'Z':
z_min = min(z_min, axis_obj.min_limit)
z_max = max(z_max, axis_obj.max_limit)
# Check if we have valid limits
if x_min == float('inf') or y_min == float('inf') or z_min == float('inf'):
FreeCAD.Console.PrintError("Machine does not have X, Y, Z linear axes defined.\n")
return None
FreeCAD.Console.PrintMessage(f"Machine boundaries: X({x_min:.3f}, {x_max:.3f}), Y({y_min:.3f}, {y_max:.3f}), Z({z_min:.3f}, {z_max:.3f})\n")
FreeCAD.Console.PrintMessage("Note: These are MACHINE coordinates showing the absolute travel limits.\n")
FreeCAD.Console.PrintMessage("Work coordinates would be relative to the workpiece origin.\n")
# Create the 8 corner points of the box
p1 = FreeCAD.Vector(x_min, y_min, z_min)
p2 = FreeCAD.Vector(x_max, y_min, z_min)
p3 = FreeCAD.Vector(x_max, y_max, z_min)
p4 = FreeCAD.Vector(x_min, y_max, z_min)
p5 = FreeCAD.Vector(x_min, y_min, z_max)
p6 = FreeCAD.Vector(x_max, y_min, z_max)
p7 = FreeCAD.Vector(x_max, y_max, z_max)
p8 = FreeCAD.Vector(x_min, y_max, z_max)
# Create edges (12 edges for wireframe box)
edges = [
Part.makeLine(p1, p2), # bottom face
Part.makeLine(p2, p3),
Part.makeLine(p3, p4),
Part.makeLine(p4, p1),
Part.makeLine(p5, p6), # top face
Part.makeLine(p6, p7),
Part.makeLine(p7, p8),
Part.makeLine(p8, p5),
Part.makeLine(p1, p5), # vertical edges
Part.makeLine(p2, p6),
Part.makeLine(p3, p7),
Part.makeLine(p4, p8),
]
# Create a compound of all edges (wireframe)
compound = Part.makeCompound(edges)
# Create a new document if none exists
if not FreeCAD.ActiveDocument:
FreeCAD.newDocument("MachineBoundary")
# Create the shape in the document
obj = FreeCAD.ActiveDocument.addObject("Part::Feature", f"MachineBoundary_{machine.name.replace(' ', '_')}")
obj.Shape = compound
obj.Label = f"Machine Boundary: {machine.name}"
# Set visual properties
obj.ViewObject.ShapeColor = color
obj.ViewObject.LineWidth = line_width
obj.ViewObject.DrawStyle = draw_style
FreeCAD.ActiveDocument.recompute()
FreeCAD.Console.PrintMessage(f"Created machine boundary box for {machine.name}\n")
return obj
except Exception as e:
FreeCAD.Console.PrintError(f"Error creating machine boundary box: {str(e)}\n")
return None
def main():
"""Main macro function."""
FreeCAD.Console.PrintMessage("FreeCAD Macro: Generate Machine Boundary Box\n")
# Get machine file
machine_file = get_machine_file()
if not machine_file:
return
# Create the boundary box with customizable appearance
# You can change these parameters:
# color: (R, G, B) tuple, e.g., (1.0, 0.0, 0.0) for red, (0.0, 1.0, 0.0) for green
# line_width: thickness of the wires
# draw_style: "Solid", "Dashed", or "Dotted"
obj = create_machine_boundary_box(machine_file,
color=(1.0, 0.0, 0.0), # Red
line_width=2.0,
draw_style="Dashed") # Broken/dashed lines
if obj:
FreeCAD.Console.PrintMessage("Macro completed successfully.\n")
else:
FreeCAD.Console.PrintError("Macro failed.\n")
# Run the macro
if __name__ == "__main__":
main()

View File

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

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

@@ -15,20 +15,12 @@
</property>
<layout class="QVBoxLayout">
<item>
<widget class="QToolBox" name="toolBox">
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="page">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>695</width>
<height>308</height>
</rect>
</property>
<attribute name="label">
<widget class="QWidget" name="tab">
<attribute name="title">
<string>General</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
@@ -118,16 +110,8 @@ If left empty no template will be preselected.</string>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_2">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>695</width>
<height>480</height>
</rect>
</property>
<attribute name="label">
<widget class="QWidget" name="tab_2">
<attribute name="title">
<string>Post processor</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
@@ -334,16 +318,8 @@ See the file save policy below on how to deal with name conflicts.</string>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_3">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>674</width>
<height>619</height>
</rect>
</property>
<attribute name="label">
<widget class="QWidget" name="tab_3">
<attribute name="title">
<string>Setup</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">

View File

@@ -36,15 +36,14 @@ def RegisterDressup(dressup):
class DressupPreferencesPage:
def __init__(self, parent=None):
self.form = QtGui.QToolBox()
self.form = QtGui.QWidget()
self.form.setWindowTitle(translate("CAM_PreferencesPathDressup", "Dressups"))
layout = QtGui.QVBoxLayout(self.form)
pages = []
for dressup in _dressups:
page = dressup.preferencesPage()
if hasattr(page, "icon") and page.icon:
self.form.addItem(page.form, page.icon, page.label)
else:
self.form.addItem(page.form, page.label)
layout.addWidget(page.form)
pages.append(page)
self.pages = pages

View File

@@ -0,0 +1,48 @@
# 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/>. *
# * *
# ***************************************************************************
from .machine import (
Machine,
MachineFactory,
LinearAxis,
RotaryAxis,
Spindle,
MachineUnits,
OutputUnits,
OutputOptions,
GCodeBlocks,
ProcessingOptions,
)
__all__ = [
"Machine",
"MachineFactory",
"LinearAxis",
"RotaryAxis",
"Spindle",
"MachineUnits",
"OutputUnits",
"OutputOptions",
"GCodeBlocks",
"ProcessingOptions",
]

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

@@ -39,7 +39,7 @@ class JobPreferencesPage:
import FreeCADGui
self.form = FreeCADGui.PySideUic.loadUi(":preferences/PathJob.ui")
self.form.toolBox.setCurrentIndex(0) # Take that qt designer!
self.form.tabWidget.setCurrentIndex(0) # Take that qt designer!
self.postProcessorDefaultTooltip = self.form.defaultPostProcessor.toolTip()
self.postProcessorArgsDefaultTooltip = self.form.defaultPostProcessorArgs.toolTip()

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

@@ -26,6 +26,8 @@ import tempfile
import FreeCAD
import Path
from PySide import QtGui, QtCore
from ....Machine.ui.editor import MachineEditorDialog
from ....Machine.models import MachineFactory
translate = FreeCAD.Qt.translate
@@ -46,20 +48,21 @@ def _is_writable_dir(path: pathlib.Path) -> bool:
class AssetPreferencesPage:
def __init__(self, parent=None):
self.form = QtGui.QToolBox()
self.form = QtGui.QWidget()
self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets"))
asset_path_widget = QtGui.QWidget()
main_layout = QtGui.QHBoxLayout(asset_path_widget)
# Set up main layout directly on the form
self.main_layout = QtGui.QVBoxLayout(self.form)
# Create widgets
self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:"))
self.assets_group = QtGui.QGroupBox(translate("CAM_PreferencesAssets", "Asset Location"))
self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Default path"))
self.asset_path_edit = QtGui.QLineEdit()
self.asset_path_note_label = QtGui.QLabel(
translate(
"CAM_PreferencesAssets",
"Note: Select the directory that will contain the "
"Tool folder with Bit/, Shape/, and Library/ subfolders.",
"Tools folder with Bit/, Shape/, and Library/ subfolders and the Machines/ folder.",
)
)
self.asset_path_note_label.setWordWrap(True)
@@ -74,24 +77,67 @@ class AssetPreferencesPage:
font.setItalic(True)
self.asset_path_note_label.setFont(font)
# Layout for asset path section
edit_button_layout = QtGui.QGridLayout()
edit_button_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop)
edit_button_layout.addItem(
QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding),
2,
0,
1,
4,
# Assets group box
self.assets_layout = QtGui.QGridLayout(self.assets_group)
self.assets_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter)
self.assets_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter)
self.assets_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter)
self.assets_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter)
self.assets_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop)
self.main_layout.addWidget(self.assets_group)
# Machines group box
self.machines_group = QtGui.QGroupBox(translate("CAM_PreferencesAssets", "Machines"))
self.machines_layout = QtGui.QVBoxLayout(self.machines_group)
self.warning_label = QtGui.QLabel(
translate(
"CAM_PreferencesAssets",
"Warning: Machine definition is an experimental feature. Changes "
"made here will not affect any CAM functionality",
)
)
self.warning_label.setWordWrap(True)
warning_font = self.warning_label.font()
warning_font.setItalic(True)
self.warning_label.setFont(warning_font)
self.warning_label.setContentsMargins(0, 0, 0, 10)
self.machines_layout.addWidget(self.warning_label)
main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop)
self.machines_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Machines"))
self.machines_layout.addWidget(self.machines_label)
self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets"))
self.machines_list = QtGui.QListWidget()
self.machines_layout.addWidget(self.machines_list)
# Buttons: Add / Edit / Delete
self.btn_layout = QtGui.QHBoxLayout()
self.add_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Add"))
self.edit_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Edit"))
self.delete_machine_btn = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Delete"))
self.btn_layout.addWidget(self.add_machine_btn)
self.btn_layout.addWidget(self.edit_machine_btn)
self.btn_layout.addWidget(self.delete_machine_btn)
self.machines_layout.addLayout(self.btn_layout)
self.machines_layout.addStretch() # Prevent the list from stretching
self.main_layout.addWidget(self.machines_group)
# Wire up buttons
self.add_machine_btn.clicked.connect(self.add_machine)
self.edit_machine_btn.clicked.connect(self.edit_machine)
self.delete_machine_btn.clicked.connect(self.delete_machine)
# Connect double-click to edit
self.machines_list.itemDoubleClicked.connect(self.edit_machine)
for name, filename in MachineFactory.list_configuration_files():
if name == "<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
@@ -131,3 +177,61 @@ class AssetPreferencesPage:
if not asset_path:
asset_path = str(Path.Preferences.getDefaultAssetPath())
self.asset_path_edit.setText(asset_path)
def add_machine(self):
# Create a new machine JSON file in the user's machine asset folder
try:
# Open editor for new machine, filename will be generated on save
editor = MachineEditorDialog()
if editor.exec_() == QtGui.QDialog.Accepted:
# add to list
filename = editor.filename
display_name = MachineFactory.get_machine_display_name(filename)
item = QtGui.QListWidgetItem(display_name)
item.setData(QtCore.Qt.UserRole, filename) # Store filename only
self.machines_list.addItem(item)
except Exception as e:
Path.Log.error(f"Failed to create machine file: {e}")
def edit_machine(self):
try:
item = self.machines_list.currentItem()
if not item:
return
filename = item.data(QtCore.Qt.UserRole)
if not filename:
return
dlg = MachineEditorDialog(filename)
if dlg.exec_() == QtGui.QDialog.Accepted:
# Reload display name from file after save
display = MachineFactory.get_machine_display_name(filename)
if display:
item.setText(display)
except Exception as e:
Path.Log.error(f"Failed to open machine editor: {e}")
def delete_machine(self):
try:
item = self.machines_list.currentItem()
if not item:
return
filename = item.data(QtCore.Qt.UserRole)
if not filename:
return
# Confirm delete
resp = QtGui.QMessageBox.question(
self.form,
translate("CAM_PreferencesAssets", "Delete Machine"),
translate(
"CAM_PreferencesAssets", "Are you sure you want to delete this machine file?"
),
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
)
if resp != QtGui.QMessageBox.Yes:
return
if MachineFactory.delete_configuration(filename):
self.machines_list.takeItem(self.machines_list.currentRow())
else:
Path.Log.error("Failed to delete machine file.")
except Exception as e:
Path.Log.error(f"Failed to delete machine: {e}")

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