diff --git a/src/Mod/CAM/CAMTests/PathTestUtils.py b/src/Mod/CAM/CAMTests/PathTestUtils.py index ee0ce931d8..34e073c017 100644 --- a/src/Mod/CAM/CAMTests/PathTestUtils.py +++ b/src/Mod/CAM/CAMTests/PathTestUtils.py @@ -21,10 +21,20 @@ # *************************************************************************** import FreeCAD +import os import Part import Path import math +import pathlib import unittest +from Path.Tool.assets import AssetManager, MemoryStore, DummyAssetSerializer +from Path.Tool.library.serializers import FCTLSerializer +from Path.Tool.toolbit.serializers import FCTBSerializer +from Path.Tool.camassets import ensure_assets_initialized +from Path.Tool.library import Library +from Path.Tool.toolbit import ToolBit +from Path.Tool.shape import ToolBitShape +from Path.Tool.shape.models.icon import ToolBitShapeSvgIcon, ToolBitShapePngIcon from FreeCAD import Vector @@ -196,3 +206,43 @@ class PathTestBase(unittest.TestCase): failed_objects = [o.Name for o in objs if "Invalid" in o.State] if len(failed_objects) > 0: self.fail(msg or f"Recompute failed for {failed_objects}") + + +class PathTestWithAssets(PathTestBase): + """ + A base class that creates an AssetManager, so tests can easily fetch + test data. Examples: + + toolbit = self.assets.get("toolbit://ballend") + toolbit = self.assets.get("toolbitshape://chamfer") + """ + + __tool_dir = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools" + + def setUp(self): + # Set up the manager with an in-memory store. + self.assets: AssetManager = AssetManager() + self.asset_store: MemoryStore = MemoryStore("local") + self.assets.register_store(self.asset_store) + + # Register some asset classes. + self.assets.register_asset(Library, FCTLSerializer) + self.assets.register_asset(ToolBit, FCTBSerializer) + self.assets.register_asset(ToolBitShape, DummyAssetSerializer) + self.assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer) + self.assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer) + + # Include the built-in assets from src/Mod/CAM/Tools. + # These functions only copy if there are no assets, so this + # must be done BEFORE adding the additional test assets below. + ensure_assets_initialized(self.assets, self.asset_store.name) + + # Additional test assets. + for path in pathlib.Path(self.__tool_dir / "Bit").glob("*.fctb"): + self.assets.add_file("toolbit", path) + for path in pathlib.Path(self.__tool_dir / "Shape").glob("*.fcstd"): + self.assets.add_file("toolbitshape", path) + + def tearDown(self): + del self.assets + del self.asset_store diff --git a/src/Mod/CAM/CAMTests/TestPathHelpers.py b/src/Mod/CAM/CAMTests/TestPathHelpers.py index ce556e2ecc..f299fedd3b 100644 --- a/src/Mod/CAM/CAMTests/TestPathHelpers.py +++ b/src/Mod/CAM/CAMTests/TestPathHelpers.py @@ -25,7 +25,7 @@ import Part import Path import Path.Base.FeedRate as PathFeedRate import Path.Base.MachineState as PathMachineState -import Path.Tool.Bit as PathToolBit +from Path.Tool.toolbit import ToolBit import Path.Tool.Controller as PathToolController import PathScripts.PathUtils as PathUtils @@ -34,12 +34,13 @@ from CAMTests.PathTestUtils import PathTestBase def createTool(name="t1", diameter=1.75): attrs = { - "shape": None, - "name": name, + "name": name or "t1", + "shape": "endmill.fcstd", "parameter": {"Diameter": diameter}, - "attribute": [], + "attribute": {}, } - return PathToolBit.Factory.CreateFromAttrs(attrs, name) + toolbit = ToolBit.from_dict(attrs) + return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument) class TestPathHelpers(PathTestBase): diff --git a/src/Mod/CAM/CAMTests/TestPathOpDeburr.py b/src/Mod/CAM/CAMTests/TestPathOpDeburr.py index dc911b0b86..d850adcad7 100644 --- a/src/Mod/CAM/CAMTests/TestPathOpDeburr.py +++ b/src/Mod/CAM/CAMTests/TestPathOpDeburr.py @@ -22,7 +22,6 @@ import Path import Path.Op.Deburr as PathDeburr -import Path.Tool.Bit as PathToolBit import CAMTests.PathTestUtils as PathTestUtils Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) diff --git a/src/Mod/CAM/CAMTests/TestPathPreferences.py b/src/Mod/CAM/CAMTests/TestPathPreferences.py index af4a490784..93b1028b0a 100644 --- a/src/Mod/CAM/CAMTests/TestPathPreferences.py +++ b/src/Mod/CAM/CAMTests/TestPathPreferences.py @@ -51,11 +51,19 @@ class TestPathPreferences(PathTestUtils.PathTestBase): def test10(self): """Default paths for tools are resolved correctly""" - self.assertTrue(Path.Preferences.pathDefaultToolsPath().endswith("/CAM/Tools/")) - self.assertTrue(Path.Preferences.pathDefaultToolsPath("Bit").endswith("/CAM/Tools/Bit")) - self.assertTrue( - Path.Preferences.pathDefaultToolsPath("Library").endswith("/CAM/Tools/Library") + self.assertEqual( + Path.Preferences.getDefaultAssetPath().parts[-2:], + ("CAM", "Tools"), + str(Path.Preferences.getDefaultAssetPath()), ) - self.assertTrue( - Path.Preferences.pathDefaultToolsPath("Template").endswith("/CAM/Tools/Template") + self.assertEqual( + Path.Preferences.getBuiltinToolPath().parts[-2:], + ("CAM", "Tools"), + str(Path.Preferences.getBuiltinToolPath()), ) + self.assertEqual( + Path.Preferences.getBuiltinShapePath().parts[-3:], + ("CAM", "Tools", "Shape"), + str(Path.Preferences.getBuiltinShapePath()), + ) + self.assertEqual(Path.Preferences.getToolBitPath().name, "Bit") diff --git a/src/Mod/CAM/CAMTests/TestPathToolAsset.py b/src/Mod/CAM/CAMTests/TestPathToolAsset.py new file mode 100644 index 0000000000..7969b6397c --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAsset.py @@ -0,0 +1,41 @@ +import unittest +from typing import Any, List, Mapping +from Path.Tool.assets import Asset, AssetUri + + +class TestAsset(Asset): + asset_type: str = "test_asset" + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def from_bytes(cls, data: bytes, id: str, dependencies: Mapping[AssetUri, Any]) -> Any: + return "dummy_object" + + def to_bytes(self) -> bytes: + return b"dummy_serialized_data" + + def get_id(self) -> str: + # Dummy implementation for testing purposes + return "dummy_id" + + +class TestPathToolAsset(unittest.TestCase): + def test_asset_cannot_be_instantiated(self): + with self.assertRaises(TypeError): + Asset() # type: ignore + + def test_asset_can_be_instantiated_and_has_members(self): + asset = TestAsset() + self.assertIsInstance(asset, Asset) + self.assertEqual(asset.asset_type, "test_asset") + self.assertEqual(asset.to_bytes(), b"dummy_serialized_data") + self.assertEqual(TestAsset.dependencies(b"some_data"), []) + self.assertEqual(TestAsset.from_bytes(b"some_data", "some_id", {}), "dummy_object") + self.assertEqual(asset.get_id(), "dummy_id") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetCache.py b/src/Mod/CAM/CAMTests/TestPathToolAssetCache.py new file mode 100644 index 0000000000..42d632f776 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetCache.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +import unittest +import asyncio +import hashlib +from typing import Any, Type, Optional, List, Mapping +from Path.Tool.assets.cache import AssetCache, CacheKey +from Path.Tool.assets import ( + AssetManager, + Asset, + AssetUri, + AssetSerializer, + DummyAssetSerializer, + MemoryStore, +) + + +class MockAsset(Asset): + asset_type: str = "mock_asset" + _build_counter = 0 + + def __init__( + self, + asset_id: str, + raw_data: bytes, + dependencies: Optional[Mapping[AssetUri, Any]] = None, + ): + super().__init__() # Initialize Asset ABC + self._asset_id = asset_id # Store id internally + self.raw_data_content = raw_data + self.resolved_dependencies = dependencies or {} + MockAsset._build_counter += 1 + self.build_id = MockAsset._build_counter + + def get_id(self) -> str: # Implement abstract method + return self._asset_id + + # get_uri() is inherited from Asset and uses self.asset_type and self.get_id() + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + # This mock implementation handles the simple "dep:" format + data_str = data.decode() + if data_str.startswith("dep:"): + try: + # Get content after the first "dep:" + dep_content = data_str.split(":", 1)[1] + except IndexError: + # This case should ideally not be reached if startswith("dep:") is true + # and there's content after "dep:", but good for robustness. + return [] + + dep_uri_strings = dep_content.split(",") + uris = [] + for uri_string in dep_uri_strings: + uri_string = uri_string.strip() # Remove leading/trailing whitespace + if not uri_string: + continue + try: + uris.append(AssetUri(uri_string)) + except ValueError: + # This print will now show the full problematic uri_string + print(f"Warning: Could not parse mock dependency URI: '{uri_string}'") + return uris + return [] + + @classmethod + def from_bytes( + cls: Type["MockAsset"], + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Any]], + serializer: AssetSerializer, + ) -> "MockAsset": + return cls(asset_id=id, raw_data=data, dependencies=dependencies) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: # Implement abstract method + return self.raw_data_content + + +class MockAssetB(Asset): # New mock asset class for type 'mock_asset_b' + asset_type: str = "mock_asset_b" + _build_counter = 0 # Separate counter if needed, or share MockAsset's + + def __init__( + self, + asset_id: str, + raw_data: bytes, + dependencies: Optional[Mapping[AssetUri, Any]] = None, + ): + super().__init__() + self._asset_id = asset_id + self.raw_data_content = raw_data + self.resolved_dependencies = dependencies or {} + MockAssetB._build_counter += 1 + self.build_id = MockAssetB._build_counter + + def get_id(self) -> str: + return self._asset_id + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + # Keep simple, or adapt if MockAssetB has different dep logic + return [] + + @classmethod + def from_bytes( + cls: Type["MockAssetB"], + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Any]], + serializer: AssetSerializer, + ) -> "MockAssetB": + return cls(asset_id=id, raw_data=data, dependencies=dependencies) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + return self.raw_data_content + + +def _get_raw_data_hash(raw_data: bytes) -> int: + return int(hashlib.sha256(raw_data).hexdigest(), 16) + + +class TestPathToolAssetCache(unittest.TestCase): + def setUp(self): + self.cache = AssetCache(max_size_bytes=1000) + + def test_put_and_get_simple(self): + key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), ("dep1",)) + asset_obj = MockAsset(asset_id="id1", raw_data=b"data1") + self.cache.put(key, asset_obj, len(b"data1"), {"dep_uri_str"}) + + retrieved = self.cache.get(key) + self.assertIsNotNone(retrieved) + # Assuming retrieved is MockAsset, it will have get_id() + self.assertEqual(retrieved.get_id(), "id1") + + def test_get_miss(self): + key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), tuple()) + self.assertIsNone(self.cache.get(key)) + + def test_lru_eviction(self): + asset_data_size = 300 + asset1_data = b"a" * asset_data_size + asset2_data = b"b" * asset_data_size + asset3_data = b"c" * asset_data_size + asset4_data = b"d" * asset_data_size + + key1 = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(asset1_data), tuple()) + key2 = CacheKey("s", "mock_asset://id2", _get_raw_data_hash(asset2_data), tuple()) + key3 = CacheKey("s", "mock_asset://id3", _get_raw_data_hash(asset3_data), tuple()) + key4 = CacheKey("s", "mock_asset://id4", _get_raw_data_hash(asset4_data), tuple()) + + self.cache.put(key1, MockAsset("id1", asset1_data), asset_data_size, set()) + self.cache.put(key2, MockAsset("id2", asset2_data), asset_data_size, set()) + self.cache.put(key3, MockAsset("id3", asset3_data), asset_data_size, set()) + + self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size) + self.assertIsNotNone(self.cache.get(key1)) # Access key1 to make it MRU + + # Adding key4 should evict key2 (oldest after key1 accessed) + self.cache.put(key4, MockAsset("id4", asset4_data), asset_data_size, set()) + self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size) + self.assertIsNotNone(self.cache.get(key1)) + self.assertIsNone(self.cache.get(key2)) # Evicted + self.assertIsNotNone(self.cache.get(key3)) + self.assertIsNotNone(self.cache.get(key4)) + + def test_invalidate_direct(self): + key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple()) + self.cache.put(key, MockAsset("id1", b"data"), 4, set()) + retrieved = self.cache.get(key) # Ensure it's there + self.assertIsNotNone(retrieved) + + self.cache.invalidate_for_uri("mock_asset://id1") + self.assertIsNone(self.cache.get(key)) + self.assertEqual(self.cache.current_size_bytes, 0) + + def test_invalidate_recursive(self): + data_a = b"data_a_dep:mock_asset_b://idB" # A depends on B + data_b = b"data_b" + data_c = b"data_c_dep:mock_asset_a://idA" # C depends on A + + uri_a_str = "mock_asset_a://idA" + uri_b_str = "mock_asset_b://idB" + uri_c_str = "mock_asset_c://idC" + + key_b = CacheKey("s", uri_b_str, _get_raw_data_hash(data_b), tuple()) + key_a = CacheKey("s", uri_a_str, _get_raw_data_hash(data_a), (uri_b_str,)) + key_c = CacheKey("s", uri_c_str, _get_raw_data_hash(data_c), (uri_a_str,)) + + self.cache.put(key_b, MockAsset("idB", data_b), len(data_b), set()) + self.cache.put(key_a, MockAsset("idA", data_a), len(data_a), {uri_b_str}) + self.cache.put(key_c, MockAsset("idC", data_c), len(data_c), {uri_a_str}) + + self.assertIsNotNone(self.cache.get(key_a)) + self.assertIsNotNone(self.cache.get(key_b)) + self.assertIsNotNone(self.cache.get(key_c)) + + self.cache.invalidate_for_uri(uri_b_str) # Invalidate B + + self.assertIsNone(self.cache.get(key_a), "Asset A should be invalidated") + self.assertIsNone(self.cache.get(key_b), "Asset B should be invalidated") + self.assertIsNone(self.cache.get(key_c), "Asset C should be invalidated") + self.assertEqual(self.cache.current_size_bytes, 0) + + def test_clear_cache(self): + key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple()) + self.cache.put(key, MockAsset("id1", b"data"), 4, set()) + self.assertNotEqual(self.cache.current_size_bytes, 0) + + self.cache.clear() + self.assertIsNone(self.cache.get(key)) + self.assertEqual(self.cache.current_size_bytes, 0) + self.assertEqual(len(self.cache._cache_dependencies_map), 0) + self.assertEqual(len(self.cache._cache_dependents_map), 0) + + +class TestPathToolAssetCacheIntegration(unittest.TestCase): + def setUp(self): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + self.manager = AssetManager(cache_max_size_bytes=10 * 1024) # 10KB cache + self.store_name = "test_store" + self.store = MemoryStore(name=self.store_name) + self.manager.register_store(self.store, cacheable=True) + self.manager.register_asset(MockAsset, DummyAssetSerializer) + self.manager.register_asset(MockAssetB, DummyAssetSerializer) # Register the new mock type + MockAsset._build_counter = 0 + MockAssetB._build_counter = 0 + + def tearDown(self): + self.loop.close() + + def _run_async(self, coro): + return self.loop.run_until_complete(coro) + + def test_get_caches_asset(self): + uri_str = "mock_asset://asset1" + raw_data = b"asset1_data" + self._run_async(self.store.create("mock_asset", "asset1", raw_data)) + + # First get - should build and cache + asset1 = self.manager.get(uri_str, store=self.store_name) + self.assertIsInstance(asset1, MockAsset) + self.assertEqual(asset1.get_id(), "asset1") + self.assertEqual(MockAsset._build_counter, 1) # Built once + + # Second get - should hit cache + asset2 = self.manager.get(uri_str, store=self.store_name) + self.assertIsInstance(asset2, MockAsset) + self.assertEqual(asset2.get_id(), "asset1") + self.assertEqual(MockAsset._build_counter, 1) # Still 1, not rebuilt + self.assertIs(asset1, asset2) # Should be the same instance from cache + + def test_get_respects_depth_in_cache_key(self): + uri_str = "mock_asset://asset_depth" + # A depends on B (mock_asset_b://dep_b) + raw_data_a = b"dep:mock_asset_b://dep_b" + raw_data_b = b"dep_b_data" + + self._run_async(self.store.create("mock_asset", "asset_depth", raw_data_a)) + self._run_async(self.store.create("mock_asset_b", "dep_b", raw_data_b)) + + # Get with depth=0 (shallow) + asset_shallow = self.manager.get(uri_str, store=self.store_name, depth=0) + self.assertIsInstance(asset_shallow, MockAsset) + self.assertEqual(len(asset_shallow.resolved_dependencies), 0) + self.assertEqual(MockAsset._build_counter, 1) # asset_depth built + + # Get with depth=None (full) + asset_full = self.manager.get(uri_str, store=self.store_name, depth=None) + self.assertIsInstance(asset_full, MockAsset) + self.assertEqual(len(asset_full.resolved_dependencies), 1) + # asset_depth (MockAsset) built twice (once shallow, once full) + self.assertEqual(MockAsset._build_counter, 2) + # dep_b (MockAssetB) built once as a dependency of the full asset_depth + self.assertEqual(MockAssetB._build_counter, 1) + + # Get shallow again - should hit shallow cache + asset_shallow_2 = self.manager.get(uri_str, store=self.store_name, depth=0) + self.assertIs(asset_shallow, asset_shallow_2) + self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds + self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds + + # Get full again - should hit full cache + asset_full_2 = self.manager.get(uri_str, store=self.store_name, depth=None) + self.assertIs(asset_full, asset_full_2) + self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds + self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds + + def test_update_invalidates_cache(self): + uri_str = "mock_asset://asset_upd" + raw_data_v1 = b"version1" + raw_data_v2 = b"version2" + asset_uri = AssetUri(uri_str) # Use real AssetUri + + self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data_v1)) + + asset_v1 = self.manager.get(asset_uri, store=self.store_name) + self.assertEqual(asset_v1.raw_data_content, raw_data_v1) + self.assertEqual(MockAsset._build_counter, 1) + + # Update the asset in the store (MemoryStore creates new version) + # For this test, let's simulate an update by re-adding with add_raw + # which should trigger invalidation. + # Note: MemoryStore's update creates a new version, so get() would get latest. + # To test invalidation of the *exact* cached object, we'd need to ensure + # the cache key changes (e.g. raw_data_hash). + # Let's use add_raw which calls invalidate. + self.manager.add_raw( + asset_uri.asset_type, asset_uri.asset_id, raw_data_v2, store=self.store_name + ) + + # Get again - should rebuild because v1 was invalidated + # And MemoryStore's get() for a URI without version gets latest. + # The add_raw invalidates based on URI (type+id), so all versions of it. + asset_v2 = self.manager.get(asset_uri, store=self.store_name) + self.assertEqual(asset_v2.raw_data_content, raw_data_v2) + self.assertEqual(MockAsset._build_counter, 2) # Rebuilt + + def test_delete_invalidates_cache(self): + uri_str = "mock_asset://asset_del" + raw_data = b"delete_me" + asset_uri = AssetUri(uri_str) + self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data)) + + asset1 = self.manager.get(asset_uri, store=self.store_name) + self.assertIsNotNone(asset1) + self.assertEqual(MockAsset._build_counter, 1) + + self.manager.delete(asset_uri, store=self.store_name) + + with self.assertRaises(FileNotFoundError): + self.manager.get(asset_uri, store=self.store_name) + # Check build counter didn't increase due to trying to get deleted asset + self.assertEqual(MockAsset._build_counter, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py new file mode 100644 index 0000000000..d014475bc1 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py @@ -0,0 +1,468 @@ +import unittest +import asyncio +from unittest.mock import Mock +import pathlib +import tempfile +from typing import Any, Mapping, List +from Path.Tool.assets import ( + AssetManager, + FileStore, + Asset, + AssetUri, + MemoryStore, + AssetSerializer, + DummyAssetSerializer, +) + + +# Mock Asset class for testing +class MockAsset(Asset): + asset_type: str = "mock_asset" + + def __init__(self, data: Any = None, id: str = "mock_id"): + self._data = data + self._id = id + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + # Mock implementation doesn't use data or format for dependencies + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Mapping[AssetUri, Asset] | None, + serializer: AssetSerializer, + ) -> "MockAsset": + # Create instance with provided id + return cls(data, id) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + return self._data + + def get_id(self) -> str: + return self._id + + +class TestPathToolAssetManager(unittest.TestCase): + def test_register_store(self): + manager = AssetManager() + mock_store_local = Mock() + mock_store_local.name = "local" + mock_store_remote = Mock() + mock_store_remote.name = "remote" + + manager.register_store(mock_store_local) + self.assertEqual(manager.stores["local"], mock_store_local) + + manager.register_store(mock_store_remote) + self.assertEqual(manager.stores["remote"], mock_store_remote) + + # Test overwriting + mock_store_local_new = Mock() + mock_store_local_new.name = "local" + manager.register_store(mock_store_local_new) + self.assertEqual(manager.stores["local"], mock_store_local_new) + + def test_register_asset(self): + manager = AssetManager() + # Register the actual MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset) + + # Test registering a different actual Asset class + class AnotherMockAsset(Asset): + asset_type: str = "another_mock_asset" + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Mapping[AssetUri, Asset] | None, + serializer: AssetSerializer, + ) -> "AnotherMockAsset": + return cls() + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + return b"" + + def get_id(self) -> str: + return "another_mock_id" + + manager.register_asset(AnotherMockAsset, DummyAssetSerializer) + self.assertEqual(manager._asset_classes[AnotherMockAsset.asset_type], AnotherMockAsset) + + # Test overwriting + manager.register_asset( + MockAsset, DummyAssetSerializer + ) # Registering again should overwrite + self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset) + + # Test registering non-Asset class + with self.assertRaises(TypeError): + + class NotAnAsset(Asset): # Inherit from Asset + pass + + manager.register_asset(NotAnAsset, DummyAssetSerializer) + + def test_get(self): + # Setup AssetManager with a real LocalStore and the MockAsset class + with tempfile.TemporaryDirectory() as tmpdir: + base_dir = pathlib.Path(tmpdir) + local_store = FileStore("local", base_dir) + manager = AssetManager() + manager.register_store(local_store) + + # Register the MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create a test asset file via AssetManager + test_data = b"test asset data" + test_uri = manager.add_raw( + asset_type=MockAsset.asset_type, + asset_id="dummy_id_get", + data=test_data, + store="local", + ) + + # Call AssetManager.get + retrieved_object = manager.get(test_uri) + + # Assert the retrieved object is an instance of MockAsset + self.assertIsInstance(retrieved_object, MockAsset) + # Assert the data was passed to from_bytes + self.assertEqual(retrieved_object._data, test_data) + + # Test error handling for non-existent URI + non_existent_uri = AssetUri.build(MockAsset.asset_type, "non_existent", "1") + with self.assertRaises(FileNotFoundError): + manager.get(non_existent_uri) + + # Test error handling for no asset class registered + non_registered_uri = AssetUri.build("non_existent_type", "dummy_id", "1") + # Need to create a dummy file for the store to find + dummy_data = b"dummy" + manager.add_raw( + asset_type="non_existent_type", asset_id="dummy_id", data=dummy_data, store="local" + ) + + with self.assertRaises(ValueError) as cm: + manager.get(non_registered_uri) + self.assertIn("No asset class registered for asset type:", str(cm.exception)) + + def test_delete(self): + # Setup AssetManager with a real LocalStore + with tempfile.TemporaryDirectory() as tmpdir: + base_dir = pathlib.Path(tmpdir) + local_store = FileStore("local", base_dir) + manager = AssetManager() + manager.register_store(local_store) + + # Create a test asset file + test_data = b"test asset data to delete" + test_uri = manager.add_raw( + asset_type="temp_asset", asset_id="dummy_id_delete", data=test_data, store="local" + ) + test_path = base_dir / "temp_asset" / str(test_uri.asset_id) / str(test_uri.version) + self.assertTrue(test_path.exists()) + + # Call AssetManager.delete + manager.delete(test_uri) + + # Verify file deletion + self.assertFalse(test_path.exists()) + + # Test error handling for non-existent URI (should not raise error + # as LocalStore.delete handles this) + non_existent_uri = AssetUri.build( + "temp_asset", "non_existent", "1" # Keep original for logging + ) + manager.delete(non_existent_uri) # Should not raise + + def test_create(self): + # Setup AssetManager with LocalStore and MockAsset class + with tempfile.TemporaryDirectory() as tmpdir: + base_dir = pathlib.Path(tmpdir) + local_store = FileStore("local", base_dir) + manager = AssetManager() + manager.register_store(local_store) + + # Register the MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create a MockAsset instance with a specific id + test_obj = MockAsset(b"object data", id="mocked_asset_id") + + # Call manager.add to create + created_uri = manager.add(test_obj, store="local") + + # Assert returned URI is as expected + expected_uri = AssetUri.build(MockAsset.asset_type, "mocked_asset_id", "1") + self.assertEqual(created_uri, expected_uri) + + # Verify the asset was created + retrieved_data = asyncio.run(local_store.get(created_uri)) + self.assertEqual(retrieved_data, test_obj.to_bytes(DummyAssetSerializer)) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.add(test_obj, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + with tempfile.TemporaryDirectory() as tmpdir: + local_store = MemoryStore("local") + manager = AssetManager() + manager.register_store(local_store) + + # Register the MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + + # First, create an asset + initial_data = b"initial data" + asset_id = "some_asset_id" + test_uri = manager.add_raw(MockAsset.asset_type, asset_id, initial_data, "local") + self.assertEqual(test_uri.version, "1") + + # Create a MockAsset instance with the same id for update + updated_data = b"updated object data" + test_obj = MockAsset(updated_data, id=asset_id) + + # Call manager.add to update + updated_uri = manager.add(test_obj, store="local") + + # Assert returned URI matches the original except for version + self.assertEqual(updated_uri.asset_type, test_uri.asset_type) + self.assertEqual(updated_uri.asset_id, test_uri.asset_id) + self.assertEqual(updated_uri.version, "2") + + # Verify the asset was updated + obj = manager.get(updated_uri, store="local") + self.assertEqual(updated_data, test_obj.to_bytes(DummyAssetSerializer)) + self.assertEqual(updated_data, obj.to_bytes(DummyAssetSerializer)) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.add(test_obj, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_create_raw(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_raw") + manager = AssetManager() + manager.register_store(memory_store) + + asset_type = "raw_test_type" + asset_id = "raw_test_id" + data = b"raw test data" + + # Expected URI with version 1 (assuming MemoryStore uses integer versions) + expected_uri = AssetUri.build(asset_type, asset_id, "1") + + # Call manager.add_raw + created_uri = manager.add_raw( + asset_type=asset_type, asset_id=asset_id, data=data, store="memory_raw" + ) + + # Assert returned URI is correct (check asset_type and asset_id) + self.assertEqual(created_uri.asset_type, asset_type) + self.assertEqual(created_uri.asset_id, asset_id) + self.assertEqual(created_uri, expected_uri) + + # Verify data was stored using the actual created_uri + # Await the async get method using asyncio.run + retrieved_data = asyncio.run(memory_store.get(created_uri)) + self.assertEqual(retrieved_data, data) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.add_raw( + asset_type=asset_type, asset_id=asset_id, data=data, store="non_existent_store" + ) + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_get_raw(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_raw_get") + manager = AssetManager() + manager.register_store(memory_store) + + test_uri_str = "test_type://test_id/1" + test_uri = AssetUri(test_uri_str) + expected_data = b"retrieved raw data" + + # Manually put data into the memory store + manager.add_raw("test_type", "test_id", expected_data, "memory_raw_get") + + # Call manager.get_raw using the URI returned by add_raw + retrieved_data = manager.get_raw(test_uri, store="memory_raw_get") + + # Assert returned data matches store's result + self.assertEqual(retrieved_data, expected_data) + + # Test error handling (store not found) + non_existent_uri = AssetUri("type://id/1") + with self.assertRaises(ValueError) as cm: + manager.get_raw(non_existent_uri, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_is_empty(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_empty") + manager = AssetManager() + manager.register_store(memory_store) + + # Test when store is empty + self.assertTrue(manager.is_empty(store="memory_empty")) + + # Add an asset and test again + manager.add_raw("test_type", "test_id", b"data", "memory_empty") + self.assertFalse(manager.is_empty(store="memory_empty")) + + # Test with asset type + self.assertTrue(manager.is_empty(store="memory_empty", asset_type="another_type")) + self.assertFalse(manager.is_empty(store="memory_empty", asset_type="test_type")) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.is_empty(store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_count_assets(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_count") + manager = AssetManager() + manager.register_store(memory_store) + + # Test when store is empty + self.assertEqual(manager.count_assets(store="memory_count"), 0) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 0) + + # Add assets and test counts + manager.add_raw("type1", "asset1", b"data1", "memory_count") + self.assertEqual(manager.count_assets(store="memory_count"), 1) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 1) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 0) + + manager.add_raw("type2", "asset2", b"data2", "memory_count") + manager.add_raw("type1", "asset3", b"data3", "memory_count") + self.assertEqual(manager.count_assets(store="memory_count"), 3) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 2) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 1) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.count_assets(store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_get_bulk(self): + # Setup AssetManager with a real MemoryStore and MockAsset class + memory_store = MemoryStore("memory_bulk") + manager = AssetManager() + manager.register_store(memory_store) + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create some assets in the memory store + data1 = b"data for id1" + data2 = b"data for id2" + uri1 = manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_bulk") + uri2 = manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_bulk") + uri3 = AssetUri.build(MockAsset.asset_type, "non_existent", "1") + uris = [uri1, uri2, uri3] + + # Call manager.get_bulk + retrieved_assets = manager.get_bulk(uris, store="memory_bulk") + + # Assert the correct number of assets were returned + self.assertEqual(len(retrieved_assets), 3) + + # Assert the retrieved assets are MockAsset instances with correct data + self.assertIsInstance(retrieved_assets[0], MockAsset) + self.assertEqual( + retrieved_assets[0].to_bytes(DummyAssetSerializer), + data1, + ) + + self.assertIsInstance(retrieved_assets[1], MockAsset) + self.assertEqual( + retrieved_assets[1].to_bytes(DummyAssetSerializer), + data2, + ) + + # Assert the non-existent asset is None + self.assertIsNone(retrieved_assets[2]) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.get_bulk(uris, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_fetch(self): + # Setup AssetManager with a real MemoryStore and MockAsset class + memory_store = MemoryStore("memory_fetch") + manager = AssetManager() + manager.register_store(memory_store) + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create some assets in the memory store + data1 = b"data for id1" + data2 = b"data for id2" + manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch") + manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch") + # Create an asset of a different type + manager.add_raw("another_type", "id3", b"data for id3", "memory_fetch") + AssetUri.build(MockAsset.asset_type, "non_existent", "1") + + # Call manager.fetch without filters + # This should raise ValueError because uri3 has an unregistered type + with self.assertRaises(ValueError) as cm: + manager.fetch(store="memory_fetch") + self.assertIn("No asset class registered for asset type:", str(cm.exception)) + + # Now test fetching with a registered asset type filter + # Setup a new manager and store to avoid state from previous test + memory_store_filtered = MemoryStore("memory_fetch_filtered") + manager_filtered = AssetManager() + manager_filtered.register_store(memory_store_filtered) + manager_filtered.register_asset(MockAsset, DummyAssetSerializer) + + # Create assets again + manager_filtered.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch_filtered") + manager_filtered.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch_filtered") + manager_filtered.add_raw("another_type", "id3", b"data for id3", "memory_fetch_filtered") + + retrieved_assets_filtered = manager_filtered.fetch( + asset_type=MockAsset.asset_type, store="memory_fetch_filtered" + ) + + # Assert the correct number of assets were returned + self.assertEqual(len(retrieved_assets_filtered), 2) + + # Assert the retrieved assets are MockAsset instances with correct data + self.assertIsInstance(retrieved_assets_filtered[0], MockAsset) + self.assertEqual( + retrieved_assets_filtered[0].to_bytes(DummyAssetSerializer).decode("utf-8"), + data1.decode("utf-8"), + ) + + self.assertIsInstance(retrieved_assets_filtered[1], MockAsset) + self.assertEqual( + retrieved_assets_filtered[1].to_bytes(DummyAssetSerializer).decode("utf-8"), + data2.decode("utf-8"), + ) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.fetch(store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetStore.py b/src/Mod/CAM/CAMTests/TestPathToolAssetStore.py new file mode 100644 index 0000000000..a7b400a683 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetStore.py @@ -0,0 +1,389 @@ +import unittest +import pathlib +import asyncio +import tempfile +from uuid import uuid4 +from Path.Tool.assets import ( + AssetUri, + AssetStore, + MemoryStore, + FileStore, +) + + +class BaseTestPathToolAssetStore(unittest.TestCase): + """ + Base test suite for Path Tool Asset Stores assuming full versioning support. + Store-agnostic tests without direct file system access. + """ + + store: AssetStore + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.tmp_path = pathlib.Path(self.tmp_dir.name) + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_name(self): + self.assertIsNotNone(self.store) + self.assertIsInstance(self.store.name, str) + self.assertTrue(len(self.store.name) > 0) + + def test_create_and_get(self): + async def async_test(): + data = b"test data" + asset_type = f"type_{uuid4()}" + asset_id = f"asset_{uuid4()}" + uri = await self.store.create(asset_type, asset_id, data) + + self.assertIsInstance(uri, AssetUri) + self.assertEqual(uri.asset_type, asset_type) + self.assertEqual(uri.asset_id, asset_id) + self.assertIsNotNone(uri.version) + + retrieved_data = await self.store.get(uri) + self.assertEqual(retrieved_data, data) + + # Test non-existent URI + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + with self.assertRaises(FileNotFoundError): + await self.store.get(non_existent_uri) + + asyncio.run(async_test()) + + def test_delete(self): + async def async_test(): + data = b"data to delete" + asset_type = "delete_type" + asset_id = f"asset_{uuid4()}" + uri = await self.store.create(asset_type, asset_id, data) + + await self.store.delete(uri) + + with self.assertRaises(FileNotFoundError): + await self.store.get(uri) + + # Deleting non-existent URI should not raise + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + await self.store.delete(non_existent_uri) + + asyncio.run(async_test()) + + def test_is_empty(self): + async def async_test(): + self.assertTrue(await self.store.is_empty()) + self.assertTrue(await self.store.is_empty("type1")) + + uri1 = await self.store.create("type1", f"asset_{uuid4()}", b"data") + self.assertFalse(await self.store.is_empty()) + self.assertFalse(await self.store.is_empty("type1")) + self.assertTrue(await self.store.is_empty("type2")) + + uri2 = await self.store.create("type2", f"asset_{uuid4()}", b"data") + await self.store.delete(uri1) + self.assertFalse(await self.store.is_empty()) + self.assertTrue(await self.store.is_empty("type1")) + self.assertFalse(await self.store.is_empty("type2")) + + await self.store.delete(uri2) + self.assertTrue(await self.store.is_empty()) + + asyncio.run(async_test()) + + def test_count_assets(self): + async def async_test(): + self.assertEqual(await self.store.count_assets(), 0) + self.assertEqual(await self.store.count_assets("type1"), 0) + + uri1 = await self.store.create("type1", f"asset1_{uuid4()}", b"data1") + self.assertEqual(await self.store.count_assets(), 1) + self.assertEqual(await self.store.count_assets("type1"), 1) + self.assertEqual(await self.store.count_assets("type2"), 0) + + uri2 = await self.store.create("type2", f"asset2_{uuid4()}", b"data2") + uri3 = await self.store.create("type1", f"asset3_{uuid4()}", b"data3") + self.assertEqual(await self.store.count_assets(), 3) + self.assertEqual(await self.store.count_assets("type1"), 2) + self.assertEqual(await self.store.count_assets("type2"), 1) + + await self.store.delete(uri1) + self.assertEqual(await self.store.count_assets(), 2) + self.assertEqual(await self.store.count_assets("type1"), 1) + self.assertEqual(await self.store.count_assets("type2"), 1) + + await self.store.delete(uri2) + await self.store.delete(uri3) + self.assertEqual(await self.store.count_assets(), 0) + self.assertEqual(await self.store.count_assets("type1"), 0) + self.assertEqual(await self.store.count_assets("type2"), 0) + + asyncio.run(async_test()) + + def test_list_assets(self): + async def async_test(): + asset_typedata = [ + ("type1", f"asset1_{uuid4()}", b"data1"), + ("type1", f"asset2_{uuid4()}", b"data2"), + ("type2", f"asset3_{uuid4()}", b"data3"), + ] + + uris = [] + for asset_type, asset_id, data in asset_typedata: + uri = await self.store.create(asset_type, asset_id, data) + uris.append(uri) + + all_assets = await self.store.list_assets() + self.assertEqual(len(all_assets), 3) + for uri in uris: + self.assertTrue(any(u.asset_id == uri.asset_id for u in all_assets)) + + type1_assets = await self.store.list_assets(asset_type="type1") + self.assertEqual(len(type1_assets), 2) + self.assertTrue(any(u.asset_id == uris[0].asset_id for u in type1_assets)) + self.assertTrue(any(u.asset_id == uris[1].asset_id for u in type1_assets)) + + paginated = await self.store.list_assets(limit=2) + self.assertEqual(len(paginated), 2) + + asyncio.run(async_test()) + + def test_update_versioning(self): + async def async_test(): + initial_data = b"initial data" + updated_data = b"updated data" + asset_type = f"update_{uuid4()}" + asset_id = f"asset_{uuid4()}" + + uri1 = await self.store.create(asset_type, asset_id, initial_data) + uri2 = await self.store.update(uri1, updated_data) + + self.assertEqual(uri1.asset_type, uri2.asset_type) + self.assertEqual(uri1.asset_id, uri2.asset_id) + self.assertEqual(uri1.version, "1") + self.assertEqual(uri2.version, "2") + self.assertNotEqual(uri1.version, uri2.version) + + self.assertEqual(await self.store.get(uri1), initial_data) + self.assertEqual(await self.store.get(uri2), updated_data) + + with self.assertRaises(FileNotFoundError): + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + await self.store.update(non_existent_uri, b"data") + + asyncio.run(async_test()) + + def test_list_versions(self): + async def async_test(): + asset_type = f"version_{uuid4()}" + asset_id = f"asset_{uuid4()}" + data1 = b"version1" + data2 = b"version2" + + uri1 = await self.store.create(asset_type, asset_id, data1) + uri2 = await self.store.update(uri1, data2) + + versions = await self.store.list_versions(uri1) + self.assertEqual(len(versions), 2) + version_ids = {v.version for v in versions if v.version} + self.assertEqual(version_ids, {uri1.version, uri2.version}) + + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + self.assertEqual(await self.store.list_versions(non_existent_uri), []) + + asyncio.run(async_test()) + + def test_create_with_empty_data(self): + async def async_test(): + data = b"" + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + uri = await self.store.create(asset_type, asset_id, data) + + self.assertIsInstance(uri, AssetUri) + retrieved_data = await self.store.get(uri) + self.assertEqual(retrieved_data, data) + + asyncio.run(async_test()) + + def test_list_assets_non_existent_type(self): + async def async_test(): + assets = await self.store.list_assets(asset_type=f"non_existent_type_{uuid4()}") + self.assertEqual(len(assets), 0) + + asyncio.run(async_test()) + + def test_list_assets_pagination_offset_too_high(self): + async def async_test(): + await self.store.create("shape", f"asset1_{uuid4()}", b"data") + assets = await self.store.list_assets(offset=100) # Assuming less than 100 assets + self.assertEqual(len(assets), 0) + + asyncio.run(async_test()) + + def test_list_assets_pagination_limit_zero(self): + async def async_test(): + await self.store.create("shape", f"asset1_{uuid4()}", b"data") + assets = await self.store.list_assets(limit=0) + self.assertEqual(len(assets), 0) + + asyncio.run(async_test()) + + def test_create_delete_recreate(self): + async def async_test(): + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + data1 = b"first data" + data2 = b"second data" + + uri1 = await self.store.create(asset_type, asset_id, data1) + self.assertEqual(await self.store.get(uri1), data1) + # For versioned stores, this would be version "1" + # For stores that don't deeply track versions, it's important what happens on recreate + + await self.store.delete(uri1) + with self.assertRaises(FileNotFoundError): + await self.store.get(uri1) + + uri2 = await self.store.create(asset_type, asset_id, data2) + self.assertEqual(await self.store.get(uri2), data2) + + # Behavior of uri1.version vs uri2.version depends on store implementation + # For a fully versioned store that starts fresh: + self.assertEqual( + uri2.version, "1", "Recreating should yield version 1 for a fresh start" + ) + + # Ensure only the new asset exists if the store fully removes old versions + versions = await self.store.list_versions( + AssetUri.build(asset_type=asset_type, asset_id=asset_id) + ) + self.assertEqual(len(versions), 1) + self.assertEqual(versions[0].version, "1") + + asyncio.run(async_test()) + + def test_get_non_existent_specific_version(self): + async def async_test(): + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + await self.store.create(asset_type, asset_id, b"data_v1") + + non_existent_version_uri = AssetUri.build( + asset_type=asset_type, + asset_id=asset_id, + version="99", # Assuming version 99 won't exist + ) + with self.assertRaises(FileNotFoundError): + await self.store.get(non_existent_version_uri) + + asyncio.run(async_test()) + + def test_delete_last_version(self): + async def async_test(): + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + + uri_v1 = await self.store.create(asset_type, asset_id, b"v1_data") + uri_v2 = await self.store.update(uri_v1, b"v2_data") + + await self.store.delete(uri_v2) # Delete latest version + with self.assertRaises(FileNotFoundError): + await self.store.get(uri_v2) + + # v1 should still exist + self.assertEqual(await self.store.get(uri_v1), b"v1_data") + versions_after_v2_delete = await self.store.list_versions(uri_v1) + self.assertEqual(len(versions_after_v2_delete), 1) + self.assertEqual(versions_after_v2_delete[0].version, "1") + + await self.store.delete(uri_v1) # Delete the now last version (v1) + with self.assertRaises(FileNotFoundError): + await self.store.get(uri_v1) + + versions_after_all_delete = await self.store.list_versions(uri_v1) + self.assertEqual(len(versions_after_all_delete), 0) + + # Asset should not appear in list_assets + listed_assets = await self.store.list_assets(asset_type=asset_type) + self.assertFalse(any(a.asset_id == asset_id for a in listed_assets)) + self.assertTrue(await self.store.is_empty(asset_type)) + + asyncio.run(async_test()) + + def test_get_latest_on_non_existent_asset(self): + async def async_test(): + latest_uri = AssetUri.build( + asset_type="shape", + asset_id=f"non_existent_id_for_latest_{uuid4()}", + version="latest", + ) + with self.assertRaises( + FileNotFoundError + ): # Or custom NoVersionsFoundError if that's how store behaves + await self.store.get(latest_uri) + + asyncio.run(async_test()) + + +class TestPathToolFileStore(BaseTestPathToolAssetStore): + """Test suite for FileStore with full versioning support.""" + + def setUp(self): + super().setUp() + asset_type_map = { + "*": "{asset_type}/{asset_id}/{version}", + "special1": "Especial/{asset_id}/{version}", + "special2": "my/super/{asset_id}.spcl", + } + self.store = FileStore("versioned", self.tmp_path, asset_type_map) + + def test_get_latest_version(self): + async def async_test(): + asset_type = f"latest_{uuid4()}" + asset_id = f"asset_{uuid4()}" + + uri1 = await self.store.create(asset_type, asset_id, b"v1") + await self.store.update(uri1, b"v2") + + latest_uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="latest") + self.assertEqual(await self.store.get(latest_uri), b"v2") + + asyncio.run(async_test()) + + def test_delete_all_versions(self): + async def async_test(): + asset_type = f"delete_{uuid4()}" + asset_id = f"asset_{uuid4()}" + + uri1 = await self.store.create(asset_type, asset_id, b"v1") + await self.store.update(uri1, b"v2") + + uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id) + await self.store.delete(uri) + + with self.assertRaises(FileNotFoundError): + await self.store.get(uri1) + + asyncio.run(async_test()) + + +class TestPathToolMemoryStore(BaseTestPathToolAssetStore): + """Test suite for MemoryStore.""" + + def setUp(self): + super().setUp() + self.store = MemoryStore("memory_test") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetUri.py b/src/Mod/CAM/CAMTests/TestPathToolAssetUri.py new file mode 100644 index 0000000000..c22aa8c8a9 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetUri.py @@ -0,0 +1,101 @@ +import unittest +from Path.Tool.assets.uri import AssetUri + + +class TestPathToolAssetUri(unittest.TestCase): + """ + Test suite for the AssetUri utility class. + """ + + def test_uri_parsing_full(self): + uri_string = "remote://asset_id/version?" "param1=value1¶m2=value2" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "remote") + self.assertEqual(uri.asset_id, "asset_id") + self.assertEqual(uri.version, "version") + self.assertEqual(uri.params, {"param1": ["value1"], "param2": ["value2"]}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_parsing_local(self): + uri_string = "local://id/2?param=value" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "local") + self.assertEqual(uri.asset_id, "id") + self.assertEqual(uri.version, "2") + self.assertEqual(uri.params, {"param": ["value"]}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_parsing_no_params(self): + uri_string = "file://asset_id/1" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "file") + self.assertEqual(uri.asset_id, "asset_id") + self.assertEqual(uri.version, "1") + self.assertEqual(uri.params, {}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_version_missing(self): + uri_string = "foo://asset" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "foo") + self.assertEqual(uri.asset_id, "asset") + self.assertIsNone(uri.version) + self.assertEqual(uri.params, {}) + self.assertEqual(str(uri), uri_string) + + def test_uri_parsing_with_version(self): + """ + Test parsing a URI string with asset_type, asset_id, and version. + """ + uri_string = "test_type://test_id/1" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "test_type") + self.assertEqual(uri.asset_id, "test_id") + self.assertEqual(uri.version, "1") + self.assertEqual(uri.params, {}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_build_full(self): + expected_uri_string = "local://asset_id/version?param1=value1" + uri = AssetUri.build( + asset_type="local", asset_id="asset_id", version="version", params={"param1": "value1"} + ) + self.assertEqual(str(uri), expected_uri_string) + self.assertEqual(uri.asset_type, "local") + self.assertEqual(uri.asset_id, "asset_id") + self.assertEqual(uri.version, "version") + self.assertEqual(uri.params, {"param1": ["value1"]}) # parse_qs always returns list + + def test_uri_build_latest_version_no_params(self): + expected_uri_string = "remote://id/latest" + uri = AssetUri.build(asset_type="remote", asset_id="id", version="latest") + self.assertEqual(str(uri), expected_uri_string) + self.assertEqual(uri.asset_type, "remote") + self.assertEqual(uri.asset_id, "id") + self.assertEqual(uri.version, "latest") + self.assertEqual(uri.params, {}) + + def test_uri_equality(self): + uri1 = AssetUri("local://asset/version") + uri2 = AssetUri("local://asset/version") + uri3 = AssetUri("local://asset/another_version") + self.assertEqual(uri1, uri2) + self.assertNotEqual(uri1, uri3) + self.assertNotEqual(uri1, "not a uri") + + def test_uri_parsing_invalid_path_structure(self): + """ + Test that parsing a URI string with an invalid path structure + (more than one component) raises a ValueError. + """ + uri_string = "local://foo/bar/1" + with self.assertRaisesRegex(ValueError, "Invalid URI path structure:"): + AssetUri(uri_string) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBit.py b/src/Mod/CAM/CAMTests/TestPathToolBit.py index 90f3400fbb..d5b9fbe758 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBit.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBit.py @@ -20,134 +20,62 @@ # * * # *************************************************************************** -import Path.Tool.Bit as PathToolBit -import CAMTests.PathTestUtils as PathTestUtils -import glob +from typing import cast import os - -TestToolDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "Tools") -TestInvalidDir = os.path.join( - TestToolDir, "some", "silly", "path", "that", "should", "not", "exist" -) - -TestToolBitName = "test-path-tool-bit-bit-00.fctb" -TestToolShapeName = "test-path-tool-bit-shape-00.fcstd" -TestToolLibraryName = "test-path-tool-bit-library-00.fctl" +import uuid +import pathlib +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.library import Library +from Path.Tool.shape import ToolBitShapeBullnose +from Path.Tool.toolbit import ToolBitEndmill, ToolBitBullnose -def testToolShape(path=TestToolDir, name=TestToolShapeName): - return os.path.join(path, "Shape", name) +TOOL_DIR = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools" +SHAPE_DIR = TOOL_DIR / "Shape" +BIT_DIR = TOOL_DIR / "Bit" -def testToolBit(path=TestToolDir, name=TestToolBitName): - return os.path.join(path, "Bit", name) - - -def testToolLibrary(path=TestToolDir, name=TestToolLibraryName): - return os.path.join(path, "Library", name) - - -def printTree(path, indent): - print("{} {}".format(indent, os.path.basename(path))) - if os.path.isdir(path): - if os.path.basename(path).startswith("__"): - print("{} ...".format(indent)) - else: - for foo in sorted(glob.glob(os.path.join(path, "*"))): - printTree(foo, "{} ".format(indent)) - - -class TestPathToolBit(PathTestUtils.PathTestBase): - def test(self): - """Log test setup directory structure""" - # Enable this test if there are errors showing up in the build system with the - # paths that work OK locally. It'll print out the directory tree, and if it - # doesn't look right you know where to look for it - print() - print("realpath : {}".format(os.path.realpath(__file__))) - print(" Tools : {}".format(TestToolDir)) - print(" dir : {}".format(os.path.dirname(os.path.realpath(__file__)))) - printTree(os.path.dirname(os.path.realpath(__file__)), " :") - - def test00(self): - """Find a tool shape from file name""" - path = PathToolBit.findToolShape("endmill.fcstd") - self.assertIsNot(path, None) - self.assertNotEqual(path, "endmill.fcstd") - - def test01(self): - """Not find a relative path shape if not stored in default location""" - path = PathToolBit.findToolShape(TestToolShapeName) - self.assertIsNone(path) - - def test02(self): - """Find a relative path shape if it's local to a bit path""" - path = PathToolBit.findToolShape(TestToolShapeName, testToolBit()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolShape()) - - def test03(self): - """Not find a tool shape from an invalid absolute path.""" - path = PathToolBit.findToolShape(testToolShape(TestInvalidDir)) - self.assertIsNone(path) - - def test04(self): - """Find a tool shape from a valid absolute path.""" - path = PathToolBit.findToolShape(testToolShape()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolShape()) - - def test10(self): +class TestPathToolBit(PathTestWithAssets): + def testGetToolBit(self): """Find a tool bit from file name""" - path = PathToolBit.findToolBit("5mm_Endmill.fctb") - self.assertIsNot(path, None) - self.assertNotEqual(path, "5mm_Endmill.fctb") + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.assertIsInstance(toolbit, ToolBitEndmill) + self.assertEqual(toolbit.id, "5mm_Endmill") - def test11(self): - """Not find a relative path bit if not stored in default location""" - path = PathToolBit.findToolBit(TestToolBitName) - self.assertIsNone(path) - - def test12(self): - """Find a relative path bit if it's local to a library path""" - path = PathToolBit.findToolBit(TestToolBitName, testToolLibrary()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolBit()) - - def test13(self): - """Not find a tool bit from an invalid absolute path.""" - path = PathToolBit.findToolBit(testToolBit(TestInvalidDir)) - self.assertIsNone(path) - - def test14(self): - """Find a tool bit from a valid absolute path.""" - path = PathToolBit.findToolBit(testToolBit()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolBit()) - - def test20(self): + def testGetLibrary(self): """Find a tool library from file name""" - path = PathToolBit.findToolLibrary("Default.fctl") - self.assertIsNot(path, None) - self.assertNotEqual(path, "Default.fctl") + library = self.assets.get("toolbitlibrary://Default") + self.assertIsInstance(library, Library) + self.assertEqual(library.id, "Default") - def test21(self): - """Not find a relative path library if not stored in default location""" - path = PathToolBit.findToolLibrary(TestToolLibraryName) - self.assertIsNone(path) + def testBullnose(self): + """Test ToolBitBullnose basic parameters""" + shape = self.assets.get("toolbitshape://bullnose") + shape = cast(ToolBitShapeBullnose, shape) - def test22(self): - """[skipped] Find a relative path library if it's local to """ - # this is not a valid test for libraries because t - self.assertTrue(True) + bullnose_bit = ToolBitBullnose(shape, id="mybullnose") + self.assertEqual(bullnose_bit.get_id(), "mybullnose") - def test23(self): - """Not find a tool library from an invalid absolute path.""" - path = PathToolBit.findToolLibrary(testToolLibrary(TestInvalidDir)) - self.assertIsNone(path) + bullnose_bit = ToolBitBullnose(shape) + uuid.UUID(bullnose_bit.get_id()) # will raise if not valid UUID - def test24(self): - """Find a tool library from a valid absolute path.""" - path = PathToolBit.findToolBit(testToolBit()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolBit()) + # Parameters should be loaded from the shape file and set on the tool bit's object + self.assertEqual(bullnose_bit.obj.Diameter, FreeCAD.Units.Quantity("5.0 mm")) + self.assertEqual(bullnose_bit.obj.FlatRadius, FreeCAD.Units.Quantity("1.5 mm")) + + def testToolBitPickle(self): + """Test if ToolBit is picklable""" + import pickle + + shape = self.assets.get("toolbitshape://bullnose") + shape = cast(ToolBitShapeBullnose, shape) + bullnose_bit = ToolBitBullnose(shape, id="mybullnose") + try: + pickled_bit = pickle.dumps(bullnose_bit) + unpickled_bit = pickle.loads(pickled_bit) + self.assertIsInstance(unpickled_bit, ToolBitBullnose) + self.assertEqual(unpickled_bit.get_id(), "mybullnose") + # Add more assertions here to check if other attributes are preserved + except Exception as e: + self.fail(f"ToolBit is not picklable: {e}") diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py new file mode 100644 index 0000000000..a0ee20711b --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE 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 FreeCAD * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the ToolBitBrowserWidget.""" + +import unittest +from unittest.mock import MagicMock +from typing import cast +from Path.Tool.toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole +from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell +from Path.Tool.toolbit.models.base import ToolBit +from .PathTestUtils import PathTestWithAssets + + +class TestToolBitBrowserWidget(PathTestWithAssets): + """Tests for ToolBitBrowserWidget using real assets and widgets.""" + + def setUp(self): + super().setUp() # Call the base class setUp to initialize assets + # The browser widget uses the global cam_assets, which is set up + # by PathTestWithAssets. + self.widget = ToolBitBrowserWidget(self.assets) + + def test_initial_fetch(self): + # Verify that the list widget is populated after initialization + # The default test assets include some toolbits. + self.assertGreater(self.widget._tool_list_widget.count(), 0) + + # Verify apply_filter was called on the list widget with empty string + # We can check the search_highlight property on the cell widgets + # as apply_filter sets this. + for i in range(self.widget._tool_list_widget.count()): + item = self.widget._tool_list_widget.item(i) + cell = self.widget._tool_list_widget.itemWidget(item) + self.assertIsInstance(cell, TwoLineTableCell) + self.assertEqual(cell.search_highlight, "") + + def test_search_filtering(self): + # Simulate typing in the search box + search_term = "Endmill" + self.widget._search_edit.setText(search_term) + + # Directly trigger the fetch and filtering logic + self.widget._trigger_fetch() + + # Verify that the filter was applied to the list widget + # We can check if items are hidden/shown based on the filter term + # This requires knowing the content of the test assets. + # Assuming '5mm_Endmill' and '10mm_Endmill' contain "Endmill" + # and 'BallEndmill_3mm' does not. + + # Re-fetch assets to know their labels/names for verification + all_assets = self.assets.fetch(asset_type="toolbit", depth=0) + expected_visible_uris = set() + for asset in all_assets: + tb = cast(ToolBit, asset) + is_expected = ( + search_term.lower() in tb.label.lower() or search_term.lower() in tb.summary.lower() + ) + if is_expected: + expected_visible_uris.add(str(tb.get_uri())) + + actual_visible_uris = set() + for i in range(self.widget._tool_list_widget.count()): + item = self.widget._tool_list_widget.item(i) + cell = self.widget._tool_list_widget.itemWidget(item) + self.assertIsInstance(cell, TwoLineTableCell) + item_uri = item.data(ToolBitUriRole) + + # Verify highlight was called on all cells + self.assertEqual(cell.search_highlight, search_term) + + if not item.isHidden(): + actual_visible_uris.add(item_uri) + + self.assertEqual(actual_visible_uris, expected_visible_uris) + + def test_lazy_loading_on_scroll(self): + # This test requires more than self._batch_size toolbits to be effective. + # The default test assets might not have enough. + # We'll assume there are enough for the test structure. + + initial_count = self.widget._tool_list_widget.count() + if initial_count < self.widget._batch_size: + self.skipTest("Not enough toolbits for lazy loading test.") + + # Simulate scrolling to the bottom by emitting the signal + scrollbar = self.widget._tool_list_widget.verticalScrollBar() + # Set the scrollbar value to its maximum to simulate reaching the end + scrollbar.valueChanged.emit(scrollbar.maximum()) + + # Verify that more items were loaded + new_count = self.widget._tool_list_widget.count() + self.assertGreater(new_count, initial_count) + # Verify that the number of new items is approximately the batch size + self.assertAlmostEqual( + new_count - initial_count, self.widget._batch_size, delta=5 + ) # Allow small delta + + def test_tool_selected_signal(self): + mock_slot = MagicMock() + self.widget.toolSelected.connect(mock_slot) + + # Select the first item in the list widget + if self.widget._tool_list_widget.count() == 0: + self.skipTest("Not enough toolbits for selection test.") + first_item = self.widget._tool_list_widget.item(0) + self.widget._tool_list_widget.setCurrentItem(first_item) + + # Verify signal was emitted with the correct URI + expected_uri = first_item.data(ToolBitUriRole) + mock_slot.assert_called_once_with(expected_uri) + + def test_tool_edit_requested_signal(self): + mock_slot = MagicMock() + self.widget.itemDoubleClicked.connect(mock_slot) + + # Double-click the first item in the list widget + if self.widget._tool_list_widget.count() == 0: + self.skipTest("Not enough toolbits for double-click test.") + + first_item = self.widget._tool_list_widget.item(0) + # Simulate double-click signal emission from the list widget + self.widget._tool_list_widget.itemDoubleClicked.emit(first_item) + + # Verify signal was emitted with the correct URI + expected_uri = first_item.data(ToolBitUriRole) + mock_slot.assert_called_once_with(expected_uri) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py new file mode 100644 index 0000000000..f3102bf6c1 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE 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 FreeCAD * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the ToolBitEditorWidget.""" + +import unittest +from unittest.mock import MagicMock +from Path.Tool.toolbit.ui.editor import ToolBitPropertiesWidget +from Path.Tool.toolbit.models.base import ToolBit +from Path.Tool.shape.ui.shapewidget import ShapeWidget +from Path.Tool.ui.property import BasePropertyEditorWidget +from .PathTestUtils import PathTestWithAssets + + +class TestToolBitPropertiesWidget(PathTestWithAssets): + """Tests for ToolBitEditorWidget using real assets and widgets.""" + + def setUp(self): + super().setUp() # Call the base class setUp to initialize assets + self.widget = ToolBitPropertiesWidget() + + def test_load_toolbit(self): + # Get a real ToolBit asset + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.assertIsInstance(toolbit, ToolBit) + + self.widget.load_toolbit(toolbit) + + # Verify label and ID are populated + self.assertEqual(self.widget._label_edit.text(), toolbit.obj.Label) + self.assertEqual(self.widget._id_label.text(), toolbit.get_id()) + + # Verify DocumentObjectEditorWidget is populated + self.assertEqual(self.widget._property_editor._obj, toolbit.obj) + # Check if properties were passed to the property editor + self.assertGreater(len(self.widget._property_editor._properties_to_show), 0) + + # Verify ShapeWidget is created and populated + self.assertIsNotNone(self.widget._shape_widget) + self.assertIsInstance(self.widget._shape_widget, ShapeWidget) + # We can't easily check the internal shape of ShapeWidget without mocks, + # but we can verify it was created. + + def test_label_changed_updates_object(self): + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.widget.load_toolbit(toolbit) + + new_label = "Updated Endmill" + self.widget._label_edit.setText(new_label) + # Simulate editing finished signal + self.widget._label_edit.editingFinished.emit() + + # Verify the toolbit object's label is updated + self.assertEqual(toolbit.obj.Label, new_label) + + def test_property_changed_signal_emitted(self): + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.widget.load_toolbit(toolbit) + + mock_slot = MagicMock() + self.widget.toolBitChanged.connect(mock_slot) + + # Simulate a property change in the DocumentObjectEditorWidget + # We need to trigger the signal from the real property editor. + # This requires accessing a child editor and emitting its signal. + # Find a child editor (e.g., the first one) + if self.widget._property_editor._property_editors: + first_prop_name = list(self.widget._property_editor._property_editors.keys())[0] + child_editor = self.widget._property_editor._property_editors[first_prop_name] + self.assertIsInstance(child_editor, BasePropertyEditorWidget) + + # Emit the propertyChanged signal from the child editor + child_editor.propertyChanged.emit() + + # Verify the ToolBitEditorWidget's signal was emitted + mock_slot.assert_called_once() + else: + self.skipTest("DocumentObjectEditorWidget has no property editors.") + + def test_save_toolbit(self): + # The save_toolbit method primarily ensures the label is updated + # and potentially calls updateObject on the property editor. + # Since property changes are signal-driven, this method is more + # for explicit save actions. + + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.widget.load_toolbit(toolbit) + + initial_label = toolbit.obj.Label + new_label = "Another Label" + self.widget._label_edit.setText(new_label) + + # Call save_toolbit + self.widget.save_toolbit() + + # Verify the label was updated + self.assertEqual(toolbit.obj.Label, new_label) + + # We can't easily verify if updateObject was called on the property + # editor without mocks, but we trust the implementation based on + # the DocumentObjectEditorWidget's own tests. + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py new file mode 100644 index 0000000000..4b358e9d13 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE 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 * +# * * +# *************************************************************************** + +"""Unit tests for the ToolBitListWidget.""" + +import unittest +from Path.Tool.toolbit.ui.toollist import ToolBitListWidget, ToolBitUriRole +from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell +from .PathTestUtils import PathTestWithAssets # Import the base test class + + +class TestToolBitListWidget(PathTestWithAssets): + """Tests for ToolBitListWidget using real assets.""" + + def setUp(self): + super().setUp() # Call the base class setUp to initialize assets + self.widget = ToolBitListWidget() + + def test_add_toolbit(self): + # Get a real ToolBit asset + toolbit = self.assets.get("toolbit://5mm_Endmill") + tool_no = 1 + + self.widget.add_toolbit(toolbit, str(tool_no)) + + self.assertEqual(self.widget.count(), 1) + item = self.widget.item(0) + self.assertIsNotNone(item) + + cell_widget = self.widget.itemWidget(item) + self.assertIsInstance(cell_widget, TwoLineTableCell) # Check against real class + + # Verify cell widget properties are set correctly + self.assertEqual(cell_widget.tool_no, str(tool_no)) + self.assertEqual(cell_widget.upper_text, toolbit.label) + # Assuming the 5mm_Endmill asset has a shape named 'Endmill' + self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge") + + # Verify URI is stored in item data + stored_uri = item.data(ToolBitUriRole) + self.assertEqual(stored_uri, str(toolbit.get_uri())) + + def test_clear_list(self): + # Add some real items first + toolbit1 = self.assets.get("toolbit://5mm_Endmill") + toolbit2 = self.assets.get("toolbit://slittingsaw") + self.widget.add_toolbit(toolbit1, 1) + self.widget.add_toolbit(toolbit2, 2) + self.assertEqual(self.widget.count(), 2) + + self.widget.clear_list() + self.assertEqual(self.widget.count(), 0) + + def test_apply_filter(self): + # Add items with distinct text for filtering + toolbit1 = self.assets.get("toolbit://5mm_Endmill") + toolbit2 = self.assets.get("toolbit://slittingsaw") + toolbit3 = self.assets.get("toolbit://probe") + + self.widget.add_toolbit(toolbit1, 1) + self.widget.add_toolbit(toolbit2, 2) + self.widget.add_toolbit(toolbit3, 3) + + items = [self.widget.item(i) for i in range(self.widget.count())] + cells = [self.widget.itemWidget(item) for item in items] + + # Test filter "Endmill" + self.widget.apply_filter("Endmill") + + self.assertFalse(items[0].isHidden()) # 5mm Endmill + self.assertTrue(items[1].isHidden()) # slittingsaw + self.assertTrue(items[2].isHidden()) # probe + + # Verify highlight was called on all cells + for cell in cells: + self.assertEqual(cell.search_highlight, "Endmill") + + # Test filter "Ballnose" + self.widget.apply_filter("Ballnose") + + self.assertTrue(items[0].isHidden()) # 5mm Endmill + self.assertTrue(items[1].isHidden()) # slittingsaw + self.assertTrue(items[2].isHidden()) # probe + + # Verify highlight was called again + for cell in cells: + self.assertEqual(cell.search_highlight, "Ballnose") + + # Test filter "3mm" + self.widget.apply_filter("3mm") + + self.assertTrue(items[0].isHidden()) # 5mm Endmill + self.assertTrue(items[1].isHidden()) # slittingsaw + self.assertTrue(items[2].isHidden()) # probe + + # Verify highlight was called again + for cell in cells: + self.assertEqual(cell.search_highlight, "3mm") + + def test_get_selected_toolbit_uri(self): + toolbit1 = self.assets.get("toolbit://5mm_Endmill") + toolbit2 = self.assets.get("toolbit://slittingsaw") + + self.widget.add_toolbit(toolbit1, 1) + self.widget.add_toolbit(toolbit2, 2) + + # No selection initially + self.assertIsNone(self.widget.get_selected_toolbit_uri()) + + # Select the first item + self.widget.setCurrentItem(self.widget.item(0)) + self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit1.get_uri())) + + # Select the second item + self.widget.setCurrentItem(self.widget.item(1)) + self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit2.get_uri())) + + # Clear selection (simulate by setting current item to None) + self.widget.setCurrentItem(None) + self.assertIsNone(self.widget.get_selected_toolbit_uri()) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py new file mode 100644 index 0000000000..1361115a6d --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE 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 * +# * * +# *************************************************************************** + +"""Unit tests for the Property Editor Widgets.""" + +import unittest +import FreeCAD +from Path.Tool.ui.property import ( + BasePropertyEditorWidget, + QuantityPropertyEditorWidget, + BoolPropertyEditorWidget, + IntPropertyEditorWidget, + EnumPropertyEditorWidget, + LabelPropertyEditorWidget, +) +from Path.Tool.toolbit.docobject import DetachedDocumentObject + + +class TestPropertyEditorFactory(unittest.TestCase): + """Tests the BasePropertyEditorWidget.for_property factory method.""" + + def setUp(self): + # Use the real DetachedDocumentObject + self.obj = DetachedDocumentObject() + # Add properties using the DetachedDocumentObject API with correct signature + self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property") + self.obj.Length = FreeCAD.Units.Quantity(10.0) # Set value separately + + self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property") + self.obj.IsEnabled = True # Set value separately + + self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property") + self.obj.Count = 5 # Set value separately + + self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property") + # Set enums and initial value separately + self.obj.Mode = ["Auto", "Manual"] + + self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property") + self.obj.Comment = "Test" # Set value separately + + def test_quantity_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Length") + self.assertIsInstance(widget, QuantityPropertyEditorWidget) + + def test_bool_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "IsEnabled") + self.assertIsInstance(widget, BoolPropertyEditorWidget) + + def test_int_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Count") + self.assertIsInstance(widget, IntPropertyEditorWidget) + + def test_enum_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Mode") + self.assertIsInstance(widget, EnumPropertyEditorWidget) + + def test_label_creation_for_string(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Comment") + self.assertIsInstance(widget, LabelPropertyEditorWidget) + + def test_label_creation_for_invalid_prop(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "NonExistent") + self.assertIsInstance(widget, LabelPropertyEditorWidget) + + +class TestQuantityPropertyEditorWidget(unittest.TestCase): + """Tests for QuantityPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property") + self.obj.Length = FreeCAD.Units.Quantity("10.0 mm") + self.widget = QuantityPropertyEditorWidget(self.obj, "Length") + # Access the real editor widget + self.editor = self.widget._editor_widget + self.widget.updateWidget() + + def test_update_property(self): + # Check if the real widget's value is updated + self.assertEqual(self.editor.property("rawValue"), 10.0) + # Check if the real widget's value and unit are updated + self.assertEqual(self.editor.property("value").UserString, "10.00 mm") + + # Simulate changing the raw value and check if the object's value updates + self.editor.lineEdit().setText("12.0") + self.widget.updateProperty() + self.assertEqual(self.obj.Length.Value, 12.0) + self.assertEqual(self.obj.Length.UserString, "12.00 mm") + + # Try assignment with unit. + self.editor.lineEdit().setText("15.5 in") + self.widget.updateProperty() + updated_value = self.obj.getPropertyByName("Length") + self.assertIsInstance(updated_value, FreeCAD.Units.Quantity) + self.assertEqual(updated_value, FreeCAD.Units.Quantity("15.5 in")) + + +class TestBoolPropertyEditorWidget(unittest.TestCase): + """Tests for BoolPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property") + self.obj.IsEnabled = True + self.widget = BoolPropertyEditorWidget(self.obj, "IsEnabled") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.currentIndex(), 1) # True is index 1 + + self.obj.setPropertyByName("IsEnabled", False) + self.widget.updateWidget() + self.assertEqual(self.editor.currentIndex(), 0) # False is index 0 + + def test_update_property(self): + # Simulate user changing value in the combobox + self.editor.setCurrentIndex(0) # Select False + self.widget._on_index_changed(0) + self.assertEqual(self.obj.getPropertyByName("IsEnabled"), False) + + self.editor.setCurrentIndex(1) # Select True + self.widget._on_index_changed(1) + self.assertEqual(self.obj.getPropertyByName("IsEnabled"), True) + + +class TestIntPropertyEditorWidget(unittest.TestCase): + """Tests for IntPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property") + self.obj.Count = 5 + self.widget = IntPropertyEditorWidget(self.obj, "Count") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.value(), 5) + + self.obj.setPropertyByName("Count", 100) + self.widget.updateWidget() + self.assertEqual(self.editor.value(), 100) + + def test_update_property(self): + # Simulate user changing value in the spinbox + self.editor.setValue(42) + self.widget.updateProperty() + self.assertEqual(self.obj.getPropertyByName("Count"), 42) + + +class TestEnumPropertyEditorWidget(unittest.TestCase): + """Tests for EnumPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property") + self.obj.Mode = ["Auto", "Manual", "Semi"] # Set enums and initial value + self.widget = EnumPropertyEditorWidget(self.obj, "Mode") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_populate_enum(self): + # Check if the real widget is populated + self.assertEqual(self.editor.count(), 3) + self.assertEqual(self.editor.itemText(0), "Auto") + self.assertEqual(self.editor.itemText(1), "Manual") + self.assertEqual(self.editor.itemText(2), "Semi") + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.currentText(), "Auto") + + self.obj.setPropertyByName("Mode", "Manual") + self.widget.updateWidget() + self.assertEqual(self.editor.currentText(), "Manual") + + def test_update_property(self): + # Simulate user changing value in the combobox + self.editor.setCurrentIndex(1) # Select Manual + self.widget._on_index_changed(1) + self.assertEqual(self.obj.getPropertyByName("Mode"), "Manual") + + self.editor.setCurrentIndex(2) # Select Semi + self.widget._on_index_changed(2) + self.assertEqual(self.obj.getPropertyByName("Mode"), "Semi") + + +class TestLabelPropertyEditorWidget(unittest.TestCase): + """Tests for LabelPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property") + self.obj.Comment = "Test Comment" + self.widget = LabelPropertyEditorWidget(self.obj, "Comment") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.text(), "Test Comment") + + self.obj.setPropertyByName("Comment", "New Comment") + self.widget.updateWidget() + self.assertEqual(self.editor.text(), "New Comment") + + def test_update_property_is_noop(self): + # updateProperty should do nothing for a read-only label + self.widget.updateProperty() + # No assertions needed, just ensure it doesn't raise errors + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py new file mode 100644 index 0000000000..773641c33e --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py @@ -0,0 +1,134 @@ +import json +from typing import Type, cast +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.toolbit import ToolBit, ToolBitEndmill +from Path.Tool.toolbit.serializers import ( + FCTBSerializer, + CamoticsToolBitSerializer, +) +from Path.Tool.assets.asset import Asset +from Path.Tool.assets.serializer import AssetSerializer +from Path.Tool.assets.uri import AssetUri +from Path.Tool.shape import ToolBitShapeEndmill +from typing import Mapping + + +class _BaseToolBitSerializerTestCase(PathTestWithAssets): + """Base test case for ToolBit Serializers.""" + + __test__ = False + + serializer_class: Type[AssetSerializer] + test_tool_bit: ToolBit + + def setUp(self): + """Create a tool bit for each test.""" + super().setUp() + if self.serializer_class is None or not issubclass(self.serializer_class, AssetSerializer): + raise NotImplementedError("Subclasses must define a valid serializer_class") + + self.test_tool_bit = cast(ToolBitEndmill, self.assets.get("toolbit://5mm_Endmill")) + self.test_tool_bit.label = "Test Tool" + self.test_tool_bit.set_diameter(FreeCAD.Units.Quantity("4.12 mm")) + self.test_tool_bit.set_length(FreeCAD.Units.Quantity("15.0 mm")) + + def test_serialize(self): + """Test serialization of a toolbit.""" + if self.test_tool_bit is None: + raise NotImplementedError("Subclasses must define a test_tool_bit") + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + self.assertIsInstance(serialized_data, bytes) + + def test_extract_dependencies(self): + """Test dependency extraction.""" + # This test assumes that the serializers don't have dependencies + # and can be overridden in subclasses if needed. + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + dependencies = self.serializer_class.extract_dependencies(serialized_data) + self.assertIsInstance(dependencies, list) + self.assertEqual(len(dependencies), 0) + + +class TestCamoticsToolBitSerializer(_BaseToolBitSerializerTestCase): + serializer_class = CamoticsToolBitSerializer + + def test_serialize(self): + super().test_serialize() + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + # Camotics specific assertions + expected_substrings = [ + b'"units": "metric"', + b'"shape": "Cylindrical"', + b'"length": 15', + b'"diameter": 4.12', + b'"description": "Test Tool"', + ] + for substring in expected_substrings: + self.assertIn(substring, serialized_data) + + def test_deserialize(self): + # Create a known serialized data string based on the Camotics format + camotics_data = ( + b'{"units": "metric", "shape": "Cylindrical", "length": 15, ' + b'"diameter": 4.12, "description": "Test Tool"}' + ) + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(camotics_data, id="test_id", dependencies=None), + ) + + self.assertIsInstance(deserialized_bit, ToolBit) + self.assertEqual(deserialized_bit.label, "Test Tool") + self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm") + self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm") + self.assertEqual(deserialized_bit.get_shape_name(), "Endmill") + + +class TestFCTBSerializer(_BaseToolBitSerializerTestCase): + serializer_class = FCTBSerializer + + def test_serialize(self): + super().test_serialize() + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + # FCTB specific assertions (JSON format) + data = json.loads(serialized_data.decode("utf-8")) + self.assertEqual(data.get("name"), "Test Tool") + self.assertEqual(data.get("shape"), "endmill.fcstd") + self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm") + self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm", data) + + def test_extract_dependencies(self): + """Test dependency extraction for FCTB.""" + fctb_data = ( + b'{"name": "Test Tool", "pocket": null, "shape": "endmill", ' + b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}' + ) + dependencies = self.serializer_class.extract_dependencies(fctb_data) + self.assertIsInstance(dependencies, list) + self.assertEqual(len(dependencies), 1) + self.assertEqual(dependencies[0], AssetUri.build("toolbitshape", "endmill")) + + def test_deserialize(self): + # Create a known serialized data string based on the FCTB format + fctb_data = ( + b'{"name": "Test Tool", "pocket": null, "shape": "endmill", ' + b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}' + ) + # Create a ToolBitShapeEndmill instance for 'endmill' + shape = ToolBitShapeEndmill("endmill") + + # Create the dependencies dictionary with the shape instance + dependencies: Mapping[AssetUri, Asset] = {AssetUri.build("toolbitshape", "endmill"): shape} + + # Provide dummy id and dependencies for deserialization test + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(fctb_data, id="test_id", dependencies=dependencies), + ) + + self.assertIsInstance(deserialized_bit, ToolBit) + self.assertEqual(deserialized_bit.label, "Test Tool") + self.assertEqual(deserialized_bit.get_shape_name(), "Endmill") + self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm") + self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm") diff --git a/src/Mod/CAM/CAMTests/TestPathToolController.py b/src/Mod/CAM/CAMTests/TestPathToolController.py index 2214b60734..6d8c966a0f 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolController.py +++ b/src/Mod/CAM/CAMTests/TestPathToolController.py @@ -21,10 +21,8 @@ # *************************************************************************** import FreeCAD -import Path -import Path.Tool.Bit as PathToolBit +from Path.Tool.toolbit import ToolBit import Path.Tool.Controller as PathToolController - from CAMTests.PathTestUtils import PathTestBase @@ -39,12 +37,14 @@ class TestPathToolController(PathTestBase): def createTool(self, name="t1", diameter=1.75): attrs = { - "shape": None, - "name": name, + "name": name or "t1", + "shape": "endmill.fcstd", "parameter": {"Diameter": diameter}, - "attribute": [], + "attribute": {}, } - return PathToolBit.Factory.CreateFromAttrs(attrs, name) + print(f"Debug: attrs['attribute'] is {attrs['attribute']}") + toolbit = ToolBit.from_dict(attrs) + return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument) def test00(self): """Verify ToolController templateAttrs""" @@ -72,7 +72,7 @@ class TestPathToolController(PathTestBase): self.assertEqual(attrs["hrapid"], "28.0 mm/s") self.assertEqual(attrs["dir"], "Reverse") self.assertEqual(attrs["speed"], 12000) - self.assertEqual(attrs["tool"], t.Proxy.templateAttrs(t)) + self.assertEqual(attrs["tool"], t.Proxy.to_dict()) return tc diff --git a/src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py b/src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py new file mode 100644 index 0000000000..22c14625fb --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE 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 * +# * * +# *************************************************************************** + +"""Unit tests for the DocumentObjectEditorWidget.""" + +import unittest +from unittest.mock import MagicMock +import FreeCAD +from PySide import QtGui +from Path.Tool.ui.property import ( + BasePropertyEditorWidget, + QuantityPropertyEditorWidget, + BoolPropertyEditorWidget, + IntPropertyEditorWidget, + EnumPropertyEditorWidget, + LabelPropertyEditorWidget, +) +from Path.Tool.ui.docobject import DocumentObjectEditorWidget, _get_label_text +from Path.Tool.toolbit.docobject import DetachedDocumentObject + + +class TestDocumentObjectEditorWidget(unittest.TestCase): + """Tests for DocumentObjectEditorWidget.""" + + def test_populate_form(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + obj.addProperty("App::PropertyLength", "Prop3", "Group1", "Doc3") + obj.Prop3 = FreeCAD.Units.Quantity(5.0, "mm") + obj.addProperty("App::PropertyBool", "Prop4", "Group1", "Doc4") + obj.Prop4 = False + obj.addProperty("App::PropertyEnumeration", "Prop5", "Group1", "Doc5") + obj.Prop5 = ["OptionA", "OptionB"] + + properties_to_show = ["Prop1", "Prop2", "Prop3", "Prop4", "Prop5", "NonExistent"] + property_suffixes = {"Prop1": "Suffix1", "Prop3": "Len"} + + widget = DocumentObjectEditorWidget( + obj=obj, properties_to_show=properties_to_show, property_suffixes=property_suffixes + ) + + # Verify the layout contains the correct number of rows (excluding non-existent) + expected_row_count = len([p for p in properties_to_show if hasattr(obj, p)]) + self.assertEqual(widget._layout.rowCount(), expected_row_count) + + # Verify labels and widgets are added correctly and are of the expected types + prop_names_in_layout = [] + for i in range(widget._layout.rowCount()): + label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole) + field_item = widget._layout.itemAt(i, QtGui.QFormLayout.FieldRole) + + self.assertIsNotNone(label_item) + self.assertIsNotNone(field_item) + + label_widget = label_item.widget() + field_widget = field_item.widget() + + self.assertIsInstance(label_widget, QtGui.QLabel) + self.assertIsInstance( + field_widget, BasePropertyEditorWidget + ) # Check against base class + + # Determine the property name from the label text (reverse of _get_label_text) + # This is a bit fragile, but necessary without storing prop_name in the label widget + label_text = label_widget.text() + prop_name = None + for original_prop_name in properties_to_show: + expected_label = _get_label_text(original_prop_name) + suffix = property_suffixes.get(original_prop_name) + if suffix: + expected_label = f"{expected_label} ({suffix}):" + else: + expected_label = f"{expected_label}:" + if label_text == expected_label: + prop_name = original_prop_name + break + + self.assertIsNotNone( + prop_name, f"Could not determine property name for label: {label_text}" + ) + prop_names_in_layout.append(prop_name) + + # Verify widget type based on property type + if prop_name == "Prop1": + self.assertIsInstance(field_widget, LabelPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop1 (Suffix1):") + elif prop_name == "Prop2": + self.assertIsInstance(field_widget, IntPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop2:") + elif prop_name == "Prop3": + self.assertIsInstance(field_widget, QuantityPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop3 (Len):") + elif prop_name == "Prop4": + self.assertIsInstance(field_widget, BoolPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop4:") + elif prop_name == "Prop5": + self.assertIsInstance(field_widget, EnumPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop5:") + + # Verify property editors are stored + self.assertEqual(len(widget._property_editors), expected_row_count) + for prop_name in prop_names_in_layout: + self.assertIn(prop_name, widget._property_editors) + self.assertIsInstance(widget._property_editors[prop_name], BasePropertyEditorWidget) + + def test_set_object(self): + obj1 = DetachedDocumentObject() + obj1.addProperty("App::PropertyString", "PropA", "GroupA", "DocA") + obj1.PropA = "ValueA" + + obj2 = DetachedDocumentObject() + obj2.addProperty("App::PropertyString", "PropA", "GroupA", "DocA") + obj2.PropA = "ValueB" + + properties_to_show = ["PropA"] + widget = DocumentObjectEditorWidget(obj=obj1, properties_to_show=properties_to_show) + + # Get the initial editor widget instance + initial_editor = widget._property_editors["PropA"] + self.assertIsInstance(initial_editor, BasePropertyEditorWidget) + + # Set a new object + widget.setObject(obj2) + + # Verify that the editor widget instance is the same + self.assertEqual(widget._property_editors["PropA"], initial_editor) + # Verify that attachTo was called on the existing editor widget + # This requires the real attachTo method to be implemented correctly + # and the editor widget to update its internal object reference. + # We can't easily assert the internal state change without mocks, + # but we can trust the implementation of attachTo in PropertyEditorWidget. + # We can verify updateWidget was called. + # Note: This test relies on the side effect of attachTo calling updateWidget + # in the real implementation. + # We can't directly assert method calls without mocks, so we'll rely on + # the fact that setting the object and then updating the UI should + # reflect the new object's values if attachTo worked. + widget.updateUI() + # Check if the child widget's display reflects obj2's value + # This requires accessing the child widget's internal editor widget + # which might be fragile. A better approach is to trust the unit tests + # for the individual PropertyEditorWidgets and focus on the + # DocumentObjectEditorWidget's logic of calling attachTo and updateUI. + + def test_set_properties_to_show(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"]) + + # Store the initial editor instance for Prop1 + initial_prop1_editor = widget._property_editors["Prop1"] + + # Set new properties to show + new_properties_to_show = ["Prop1", "Prop2"] + new_suffixes = {"Prop2": "Suffix2"} + widget.setPropertiesToShow(new_properties_to_show, new_suffixes) + + # Verify that the form was repopulated with the new properties + self.assertEqual(widget._layout.rowCount(), len(new_properties_to_show)) + + # Verify property editors are updated and new ones created + self.assertEqual(len(widget._property_editors), len(new_properties_to_show)) + self.assertIn("Prop1", widget._property_editors) + self.assertIn("Prop2", widget._property_editors) + + # Verify that the editor for Prop1 is a *new* instance after repopulation + self.assertIsNot(widget._property_editors["Prop1"], initial_prop1_editor) + self.assertIsInstance(widget._property_editors["Prop2"], IntPropertyEditorWidget) + + # Verify labels including suffixes + prop_names_in_layout = [] + for i in range(widget._layout.rowCount()): + label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole) + label_widget = label_item.widget() + label_text = label_widget.text() + + prop_name = None + for original_prop_name in new_properties_to_show: + expected_label = _get_label_text(original_prop_name) + suffix = new_suffixes.get(original_prop_name) + if suffix: + expected_label = f"{expected_label} ({suffix}):" + else: + expected_label = f"{expected_label}:" + if label_text == expected_label: + prop_name = original_prop_name + break + prop_names_in_layout.append(prop_name) + + self.assertIn("Prop1", prop_names_in_layout) + self.assertIn("Prop2", prop_names_in_layout) + self.assertEqual( + widget._layout.itemAt(0, QtGui.QFormLayout.LabelRole).widget().text(), "Prop1:" + ) + self.assertEqual( + widget._layout.itemAt(1, QtGui.QFormLayout.LabelRole).widget().text(), + "Prop2 (Suffix2):", + ) + + def test_property_changed_signal(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"]) + + # Connect to the widget's propertyChanged signal + mock_slot = MagicMock() + widget.propertyChanged.connect(mock_slot) + + # Get the real child editor widget + child_editor = widget._property_editors["Prop1"] + self.assertIsInstance(child_editor, BasePropertyEditorWidget) + + # Emit the signal from the real child editor + child_editor.propertyChanged.emit() + + # Verify that the widget's signal was emitted + mock_slot.assert_called_once() + + def test_update_ui(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + + properties_to_show = ["Prop1", "Prop2"] + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show) + + # Get the real child editor widgets + editor1 = widget._property_editors["Prop1"] + editor2 = widget._property_editors["Prop2"] + + # Mock their updateWidget methods to check if they are called + editor1.updateWidget = MagicMock() + editor2.updateWidget = MagicMock() + + # Call updateUI + widget.updateUI() + + # Verify that updateWidget was called on all child editors + editor1.updateWidget.assert_called_once() + editor2.updateWidget.assert_called_once() + + def test_update_object(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + + properties_to_show = ["Prop1", "Prop2"] + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show) + + # Get the real child editor widgets + editor1 = widget._property_editors["Prop1"] + editor2 = widget._property_editors["Prop2"] + + # Mock their updateProperty methods to check if they are called + editor1.updateProperty = MagicMock() + editor2.updateProperty = MagicMock() + + # Call updateObject + widget.updateObject() + + # Verify that updateProperty was called on all child editors + editor1.updateProperty.assert_called_once() + editor2.updateProperty.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolLibrary.py b/src/Mod/CAM/CAMTests/TestPathToolLibrary.py new file mode 100644 index 0000000000..01291c5f0d --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolLibrary.py @@ -0,0 +1,381 @@ +import unittest +from Path.Tool.library import Library +from Path.Tool.toolbit import ToolBitEndmill, ToolBitDrill, ToolBitVBit +from Path.Tool.shape import ( + ToolBitShapeEndmill, + ToolBitShapeDrill, + ToolBitShapeVBit, +) + + +class TestPathToolLibrary(unittest.TestCase): + def test_init(self): + library = Library("Test Library") + self.assertEqual(library.label, "Test Library") + self.assertIsNotNone(library.id) + self.assertEqual(len(library._bits), 0) + self.assertEqual(len(library._bit_nos), 0) + + def test_str(self): + library = Library("My Library", "123-abc") + self.assertEqual(str(library), '123-abc "My Library"') + + def test_eq(self): + library1 = Library("Lib1", "same-id") + library2 = Library("Lib2", "same-id") + library3 = Library("Lib3", "different-id") + self.assertEqual(library1, library2) + self.assertNotEqual(library1, library3) + + def test_iter(self): + library = Library("Test Library") + # Create ToolBitShape instances with required parameters + shape1 = ToolBitShapeEndmill( + id="dummy_endmill", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + library.add_bit(bit1) + library.add_bit(bit2) + _bits_list = list(library) + self.assertEqual(len(_bits_list), 2) + self.assertIn(bit1, _bits_list) + self.assertIn(bit2, _bits_list) + + def test_get_next_bit_no(self): + library = Library("Test Library") + self.assertEqual(library.get_next_bit_no(), 1) + # Using ToolBit instances in _bit_nos with ToolBitShape + shape_a = ToolBitShapeEndmill( + id="dummy_a", + CuttingEdgeHeight=1.0, + Diameter=1.0, + Flutes=1, + Length=10.0, + ShankDiameter=1.0, + ) + shape_b = ToolBitShapeDrill( + id="dummy_b", + Diameter=2.0, + Length=20.0, + ShankDiameter=2.0, + Flutes=2, + TipAngle=118.0, + ) + shape_c = ToolBitShapeVBit( + id="dummy_c", + Diameter=3.0, + Angle=90.0, + Length=30.0, + ShankDiameter=3.0, + CuttingEdgeAngle=30.0, + CuttingEdgeHeight=10.0, + Flutes=2, + TipDiameter=1.0, + ) + library._bit_nos = { + 1: ToolBitEndmill(shape_a), + 5: ToolBitDrill(shape_b), + 2: ToolBitVBit(shape_c), + } + self.assertEqual(library.get_next_bit_no(), 6) + library._bit_nos = {} + self.assertEqual(library.get_next_bit_no(), 1) + + def test_get_bit_no_from_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_1", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_1", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + library.add_bit(bit1, 1) + library.add_bit(bit2, 2) + self.assertEqual(library.get_bit_no_from_bit(bit1), 1) + self.assertEqual(library.get_bit_no_from_bit(bit2), 2) + shape_cutter = ToolBitShapeVBit( + id="dummy_cutter_1", + Diameter=3.0, + Angle=90.0, + Length=30.0, + ShankDiameter=3.0, + CuttingEdgeAngle=30.0, + CuttingEdgeHeight=10.0, + Flutes=2, + TipDiameter=1.0, + ) + self.assertIsNone(library.get_bit_no_from_bit(ToolBitVBit(shape_cutter))) + + def test_assign_new_bit_no(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_2", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_2", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + + # Assign bit1 without specifying number (should get 1) + library.add_bit(bit1) + self.assertEqual(len(library._bits), 1) + self.assertEqual(len(library._bit_nos), 1) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit1) + + # Assign bit2 to number 1 (should reassign bit1) + library.add_bit(bit2, 1) + self.assertEqual(len(library._bits), 2) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit2) + # Check if bit1 was reassigned to a new bit number (should be 2) + self.assertIn(2, library._bit_nos) + self.assertEqual(library._bit_nos[2], bit1) + + # Assign bit2 to number 10 + library.assign_new_bit_no(bit2, 10) + self.assertEqual(len(library._bits), 2) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(10, library._bit_nos) + self.assertEqual(library._bit_nos[10], bit2) + self.assertNotIn(1, library._bit_nos) # bit2 should no longer be at 1 + self.assertIn(2, library._bit_nos) + self.assertEqual(library._bit_nos[2], bit1) + + # Assign bit1 to number 5 + library.assign_new_bit_no(bit1, 5) + self.assertEqual(len(library._bits), 2) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(5, library._bit_nos) + self.assertEqual(library._bit_nos[5], bit1) + self.assertNotIn(2, library._bit_nos) # bit1 should no longer be at 2 + self.assertIn(10, library._bit_nos) + self.assertEqual(library._bit_nos[10], bit2) + + def test_add_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_3", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_3", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + + library.add_bit(bit1) + self.assertEqual(len(library._bits), 1) + self.assertIn(bit1, library._bits) + self.assertEqual(len(library._bit_nos), 1) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit1) + + library.add_bit(bit2, 5) + self.assertEqual(len(library._bits), 2) + self.assertIn(bit1, library._bits) + self.assertIn(bit2, library._bits) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit1) + self.assertIn(5, library._bit_nos) + self.assertEqual(library._bit_nos[5], bit2) + + # Add bit1 again (should not increase bit count in _bits list) + library.add_bit(bit1, 10) + self.assertEqual(len(library._bits), 2) + self.assertIn(bit1, library._bits) + self.assertIn(bit2, library._bits) + self.assertEqual(len(library._bit_nos), 2) # _bit_nos count remains 2 + self.assertIn(10, library._bit_nos) + self.assertEqual(library._bit_nos[10], bit1) + self.assertIn(5, library._bit_nos) + self.assertEqual(library._bit_nos[5], bit2) + self.assertNotIn(1, library._bit_nos) # bit1 should no longer be at 1 + + def test_get_bits(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_4", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_4", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + self.assertEqual(library.get_bits(), []) + library.add_bit(bit1) + library.add_bit(bit2) + _bits_list = library.get_bits() + self.assertEqual(len(_bits_list), 2) + self.assertIn(bit1, _bits_list) + self.assertIn(bit2, _bits_list) + + def test_has_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_5", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_5", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + library.add_bit(bit1) + self.assertTrue(library.has_bit(bit1)) + self.assertFalse(library.has_bit(bit2)) + # Create a new ToolBit with the same properties but different instance + shape1_copy = ToolBitShapeEndmill( + id="dummy_endmill_5_copy", # Use a different ID for the copy + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + bit1_copy = ToolBitEndmill(shape1_copy) + self.assertFalse(library.has_bit(bit1_copy)) + + def test_remove_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_6", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_6", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + shape3 = ToolBitShapeVBit( + id="dummy_cutter_6", + Diameter=3.0, + Angle=90.0, + Length=30.0, + ShankDiameter=3.0, + CuttingEdgeAngle=30.0, + CuttingEdgeHeight=10.0, + Flutes=2, + TipDiameter=1.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + bit3 = ToolBitVBit(shape3) + + library.add_bit(bit1, 1) + library.add_bit(bit2, 2) + library.add_bit(bit3, 3) + self.assertEqual(len(library._bits), 3) + self.assertEqual(len(library._bit_nos), 3) + + library.remove_bit(bit2) + self.assertEqual(len(library._bits), 2) + self.assertNotIn(bit2, library._bits) + self.assertEqual(len(library._bit_nos), 2) + self.assertNotIn(2, library._bit_nos) + self.assertNotIn(bit2, library._bit_nos.values()) + + library.remove_bit(bit1) + self.assertEqual(len(library._bits), 1) + self.assertNotIn(bit1, library._bits) + self.assertEqual(len(library._bit_nos), 1) + self.assertNotIn(1, library._bit_nos) + self.assertNotIn(bit1, library._bit_nos.values()) + + library.remove_bit(bit3) + self.assertEqual(len(library._bits), 0) + self.assertNotIn(bit3, library._bits) + self.assertEqual(len(library._bit_nos), 0) + self.assertNotIn(3, library._bit_nos) + self.assertNotIn(bit3, library._bit_nos.values()) + + # Removing a non-existent bit should not raise an error + shape_nonexistent = ToolBitShapeEndmill( + id="dummy_nonexistent_6", + CuttingEdgeHeight=99.0, + Diameter=99.0, + Flutes=1, + Length=99.0, + ShankDiameter=99.0, + ) + library.remove_bit(ToolBitEndmill(shape_nonexistent)) + self.assertEqual(len(library._bits), 0) + self.assertEqual(len(library._bit_nos), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py b/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py new file mode 100644 index 0000000000..3ac4f6c253 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py @@ -0,0 +1,128 @@ +import unittest +import json +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.library import Library +from Path.Tool.toolbit import ToolBitEndmill +from Path.Tool.shape import ToolBitShapeEndmill +from Path.Tool.library.serializers import CamoticsLibrarySerializer, LinuxCNCSerializer + + +class TestPathToolLibrarySerializerBase(PathTestWithAssets): + """Base class for Library serializer tests.""" + + def setUp(self): + super().setUp() + self.test_library_id = "test_library" + self.test_library_label = "Test Library" + self.test_library = Library(self.test_library_label, id=self.test_library_id) + + # Create some dummy tool bits + shape1 = ToolBitShapeEndmill("endmill_1") + shape1.set_parameter("Diameter", FreeCAD.Units.Quantity("6.0 mm")) + shape1.set_parameter("Length", FreeCAD.Units.Quantity("20.0 mm")) + tool1 = ToolBitEndmill(shape1, id="tool_1") + tool1.label = "Endmill 6mm" + + shape2 = ToolBitShapeEndmill("endmill_2") + shape2.set_parameter("Diameter", FreeCAD.Units.Quantity("3.0 mm")) + shape2.set_parameter("Length", FreeCAD.Units.Quantity("15.0 mm")) + tool2 = ToolBitEndmill(shape2, id="tool_2") + tool2.label = "Endmill 3mm" + + self.test_library.add_bit(tool1, 1) + self.test_library.add_bit(tool2, 2) + + +class TestCamoticsLibrarySerializer(TestPathToolLibrarySerializerBase): + """Tests for the CamoticsLibrarySerializer.""" + + def test_camotics_serialize(self): + serializer = CamoticsLibrarySerializer + serialized_data = serializer.serialize(self.test_library) + self.assertIsInstance(serialized_data, bytes) + + # Verify the content structure (basic check) + data_dict = json.loads(serialized_data.decode("utf-8")) + self.assertIn("1", data_dict) + self.assertIn("2", data_dict) + self.assertEqual(data_dict["1"]["description"], self.test_library._bit_nos[1].label) + self.assertEqual( + data_dict["2"]["diameter"], + self.test_library._bit_nos[2]._tool_bit_shape.get_parameter("Diameter"), + ) + + def test_camotics_deserialize(self): + serializer = CamoticsLibrarySerializer + # Create a dummy serialized data matching the expected format + dummy_data = { + "10": { + "units": "metric", + "shape": "Ballnose", + "length": 25, + "diameter": 8, + "description": "Ballnose 8mm", + }, + "20": { + "units": "metric", + "shape": "Cylindrical", + "length": 30, + "diameter": 10, + "description": "Endmill 10mm", + }, + } + dummy_bytes = json.dumps(dummy_data, indent=2).encode("utf-8") + + # Deserialize the data + deserialized_library = serializer.deserialize(dummy_bytes, "deserialized_lib", {}) + + self.assertIsInstance(deserialized_library, Library) + self.assertEqual(deserialized_library.get_id(), "deserialized_lib") + self.assertEqual(len(deserialized_library._bit_nos), 2) + + tool_10 = deserialized_library._bit_nos.get(10) + assert tool_10 is not None, "tool not in the library" + self.assertEqual(tool_10.label, "Ballnose 8mm") + self.assertEqual(tool_10._tool_bit_shape.name, "Ballend") + self.assertEqual( + tool_10._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("8 mm") + ) + self.assertEqual( + tool_10._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("25 mm") + ) + + tool_20 = deserialized_library._bit_nos.get(20) + assert tool_20 is not None, "tool not in the library" + self.assertEqual(tool_20.label, "Endmill 10mm") + self.assertEqual(tool_20._tool_bit_shape.name, "Endmill") + self.assertEqual( + tool_20._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("10 mm") + ) + self.assertEqual( + tool_20._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("30 mm") + ) + + +class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase): + """Tests for the LinuxCNCLibrarySerializer.""" + + def test_linuxcnc_serialize(self): + serializer = LinuxCNCSerializer + serialized_data = serializer.serialize(self.test_library) + self.assertIsInstance(serialized_data, bytes) + + # Verify the content format (basic check) + lines = serialized_data.decode("ascii", "ignore").strip().split("\n") + self.assertEqual(len(lines), 2) + self.assertTrue(lines[0].startswith("T1 P D6.0 ;Endmill 6mm")) + self.assertTrue(lines[1].startswith("T2 P D3.0 ;Endmill 3mm")) + + def test_linuxcnc_deserialize_not_implemented(self): + serializer = LinuxCNCSerializer + dummy_data = b"T1 D6.0 ;Endmill 6mm\n" + with self.assertRaises(NotImplementedError): + serializer.deserialize(dummy_data, "dummy_id", {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolMachine.py b/src/Mod/CAM/CAMTests/TestPathToolMachine.py new file mode 100644 index 0000000000..d158b0ff39 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolMachine.py @@ -0,0 +1,206 @@ +import unittest +import FreeCAD +from Path.Tool.machine.models.machine import Machine + + +class TestPathToolMachine(unittest.TestCase): + def setUp(self): + self.default_machine = Machine() + + def test_initialization_defaults(self): + self.assertEqual(self.default_machine.label, "Machine") + self.assertAlmostEqual(self.default_machine.max_power.getValueAs("W").Value, 2000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 3000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 60000) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 1) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 2000) + expected_peak_torque_rpm = 60000 / 3 + self.assertAlmostEqual( + self.default_machine.get_peak_torque_rpm_value(), + expected_peak_torque_rpm, + ) + expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm + self.assertAlmostEqual( + self.default_machine.max_torque.getValueAs("Nm").Value, + expected_max_torque_nm, + ) + self.assertIsNotNone(self.default_machine.id) + + def test_initialization_custom_values(self): + custom_machine = Machine( + label="Custom Machine", + max_power=5, + min_rpm=1000, + max_rpm=20000, + max_torque=50, + peak_torque_rpm=15000, + min_feed=10, + max_feed=5000, + id="custom-id", + ) + self.assertEqual(custom_machine.label, "Custom Machine") + self.assertAlmostEqual(custom_machine.max_power.getValueAs("W").Value, 5000) + self.assertAlmostEqual(custom_machine.get_min_rpm_value(), 1000) + self.assertAlmostEqual(custom_machine.get_max_rpm_value(), 20000) + self.assertAlmostEqual(custom_machine.max_torque.getValueAs("Nm").Value, 50) + self.assertAlmostEqual(custom_machine.get_peak_torque_rpm_value(), 15000) + self.assertAlmostEqual(custom_machine.min_feed.getValueAs("mm/min").Value, 10) + self.assertAlmostEqual(custom_machine.max_feed.getValueAs("mm/min").Value, 5000) + self.assertEqual(custom_machine.id, "custom-id") + + def test_initialization_custom_torque_quantity(self): + custom_torque_machine = Machine(max_torque=FreeCAD.Units.Quantity(100, "Nm")) + self.assertAlmostEqual(custom_torque_machine.max_torque.getValueAs("Nm").Value, 100) + + def test_validate_valid(self): + try: + self.default_machine.validate() + except AttributeError as e: + self.fail(f"Validation failed unexpectedly: {e}") + + def test_validate_missing_label(self): + self.default_machine.label = "" + with self.assertRaisesRegex(AttributeError, "Machine name is required"): + self.default_machine.validate() + + def test_validate_peak_torque_rpm_greater_than_max_rpm(self): + self.default_machine.set_peak_torque_rpm(70000) + with self.assertRaisesRegex(AttributeError, "Peak Torque RPM.*must be less than max RPM"): + self.default_machine.validate() + + def test_validate_max_rpm_less_than_min_rpm(self): + self.default_machine = Machine() + self.default_machine.set_min_rpm(4000) # min_rpm = 4000 RPM + self.default_machine.set_peak_torque_rpm(1000) # peak_torque_rpm = 1000 RPM + self.default_machine._max_rpm = 2000 / 60.0 # max_rpm = 2000 RPM (33.33 1/s) + self.assertLess( + self.default_machine.get_max_rpm_value(), + self.default_machine.get_min_rpm_value(), + ) + with self.assertRaisesRegex(AttributeError, "Max RPM must be larger than min RPM"): + self.default_machine.validate() + + def test_validate_max_feed_less_than_min_feed(self): + self.default_machine.set_min_feed(1000) + self.default_machine._max_feed = 500 + with self.assertRaisesRegex(AttributeError, "Max feed must be larger than min feed"): + self.default_machine.validate() + + def test_get_torque_at_rpm(self): + torque_below_peak = self.default_machine.get_torque_at_rpm(10000) + expected_peak_torque_rpm = 60000 / 3 + expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm + expected_torque_below_peak = expected_max_torque_nm / expected_peak_torque_rpm * 10000 + self.assertAlmostEqual(torque_below_peak, expected_torque_below_peak) + + torque_at_peak = self.default_machine.get_torque_at_rpm( + self.default_machine.get_peak_torque_rpm_value() + ) + self.assertAlmostEqual( + torque_at_peak, + self.default_machine.max_torque.getValueAs("Nm").Value, + ) + + torque_above_peak = self.default_machine.get_torque_at_rpm(50000) + expected_torque_above_peak = 2000 * 9.5488 / 50000 + self.assertAlmostEqual(torque_above_peak, expected_torque_above_peak) + + def test_set_label(self): + self.default_machine.label = "New Label" + self.assertEqual(self.default_machine.label, "New Label") + + def test_set_max_power(self): + self.default_machine = Machine() + self.default_machine.set_max_power(5, "hp") + self.assertAlmostEqual( + self.default_machine.max_power.getValueAs("W").Value, + 5 * 745.7, + places=4, + ) + with self.assertRaisesRegex(AttributeError, "Max power must be positive"): + self.default_machine.set_max_power(0) + + def test_set_min_rpm(self): + self.default_machine = Machine() + self.default_machine.set_min_rpm(5000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 5000) + with self.assertRaisesRegex(AttributeError, "Min RPM cannot be negative"): + self.default_machine.set_min_rpm(-100) + self.default_machine = Machine() + self.default_machine.set_min_rpm(70000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 70000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 70001) + + def test_set_max_rpm(self): + self.default_machine = Machine() + self.default_machine.set_max_rpm(50000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 50000) + with self.assertRaisesRegex(AttributeError, "Max RPM must be positive"): + self.default_machine.set_max_rpm(0) + self.default_machine = Machine() + self.default_machine.set_max_rpm(2000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 2000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 1999) + self.default_machine = Machine() + self.default_machine.set_max_rpm(0.5) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 0.5) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 0) + + def test_set_min_feed(self): + self.default_machine = Machine() + self.default_machine.set_min_feed(500, "inch/min") + self.assertAlmostEqual( + self.default_machine.min_feed.getValueAs("mm/min").Value, + 500 * 25.4, + places=4, + ) + with self.assertRaisesRegex(AttributeError, "Min feed cannot be negative"): + self.default_machine.set_min_feed(-10) + self.default_machine = Machine() + self.default_machine.set_min_feed(3000) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 3000) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3001) + + def test_set_max_feed(self): + self.default_machine = Machine() + self.default_machine.set_max_feed(3000) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3000) + with self.assertRaisesRegex(AttributeError, "Max feed must be positive"): + self.default_machine.set_max_feed(0) + self.default_machine = Machine() + self.default_machine.set_min_feed(600) + self.default_machine.set_max_feed(500) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 500) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 499) + self.default_machine = Machine() + self.default_machine.set_max_feed(0.5) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 0.5) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 0) + + def test_set_peak_torque_rpm(self): + self.default_machine = Machine() + self.default_machine.set_peak_torque_rpm(40000) + self.assertAlmostEqual(self.default_machine.get_peak_torque_rpm_value(), 40000) + with self.assertRaisesRegex(AttributeError, "Peak torque RPM cannot be negative"): + self.default_machine.set_peak_torque_rpm(-100) + + def test_set_max_torque(self): + self.default_machine = Machine() + self.default_machine.set_max_torque(200, "in-lbf") + self.assertAlmostEqual( + self.default_machine.max_torque.getValueAs("Nm").Value, + 200 * 0.112985, + places=4, + ) + with self.assertRaisesRegex(AttributeError, "Max torque must be positive"): + self.default_machine.set_max_torque(0) + + def test_dump(self): + try: + self.default_machine.dump(False) + except Exception as e: + self.fail(f"dump() method failed unexpectedly: {e}") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py new file mode 100644 index 0000000000..aa34fd4f66 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +# Unit tests for the Path.Tool.Shape module and its utilities. + +from pathlib import Path +from typing import Mapping, Tuple +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.assets import DummyAssetSerializer +from Path.Tool.shape import ( + ToolBitShape, + ToolBitShapeBallend, + ToolBitShapeVBit, + ToolBitShapeBullnose, + ToolBitShapeSlittingSaw, +) + + +# Helper dummy class for testing abstract methods +class DummyShape(ToolBitShape): + name = "dummy" + + def __init__(self, id, **kwargs): + super().__init__(id=id, **kwargs) + # Always define defaults in the subclass + self._defaults = { + "Param1": FreeCAD.Units.Quantity("10 mm"), + "Param2": FreeCAD.Units.Quantity("5 deg"), + } + # Merge defaults into _params, allowing kwargs to override + self._params = self._defaults | self._params + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Param1": ( + FreeCAD.Qt.translate("Param1", "Parameter 1"), + "App::PropertyLength", + ), + "Param2": ( + FreeCAD.Qt.translate("Param2", "Parameter 2"), + "App::PropertyAngle", + ), + } + + @property + def label(self): + return "Dummy Shape" + + +def unit(param): + return param.getUserPreferred()[2] + + +class TestPathToolShapeClasses(PathTestWithAssets): + """Tests for the concrete ToolBitShape subclasses.""" + + def _test_shape_common(self, alias): + uri = ToolBitShape.resolve_name(alias) + shape = self.assets.get(uri) + return shape.get_parameters() + + def test_base_init_with_defaults(self): + """Test base class initialization uses default parameters.""" + # Provide a dummy filepath and id for instantiation + shape = DummyShape(id="dummy_shape_1", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(str(shape.get_parameter("Param1")), "10.0 mm") + self.assertEqual(str(shape.get_parameter("Param2")), "5.0 deg") + + def test_base_init_with_kwargs(self): + """Test base class initialization overrides defaults with kwargs.""" + # Provide a dummy filepath for instantiation + shape = DummyShape( + id="dummy_shape_2", + filepath=Path("/fake/dummy.fcstd"), + Param1=FreeCAD.Units.Quantity("20 mm"), + Param3="Ignored", + ) + self.assertEqual(shape.get_parameter("Param1").Value, 20.0) + self.assertEqual(shape.get_parameter("Param1").Value, 20.0) + self.assertEqual( + str(shape.get_parameter("Param1").Unit), + "Unit: mm (1,0,0,0,0,0,0,0) [Length]", + ) + self.assertEqual(shape.get_parameter("Param2").Value, 5.0) + self.assertEqual( + str(shape.get_parameter("Param2").Unit), + "Unit: deg (0,0,0,0,0,0,0,1) [Angle]", + ) # Should remain default + + def test_base_get_set_parameter(self): + """Test getting and setting individual parameters.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_3", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(shape.get_parameter("Param1").Value, 10.0) + self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm")) + shape.set_parameter("Param1", FreeCAD.Units.Quantity("15 mm")) + self.assertEqual(shape.get_parameter("Param1").Value, 15.0) + self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm")) + with self.assertRaisesRegex(KeyError, "Shape 'dummy' has no parameter 'InvalidParam'"): + shape.get_parameter("InvalidParam") + + def test_base_get_parameters(self): + """Test getting the full parameter dictionary.""" + # Provide a dummy filepath for instantiation + shape = DummyShape( + id="dummy_shape_4", + filepath=Path("/fake/dummy.fcstd"), + Param1=FreeCAD.Units.Quantity("12 mm"), + ) + # Create mock quantity instances using the configured mock class + expected_param1 = FreeCAD.Units.Quantity("12.0 mm") + expected_param2 = FreeCAD.Units.Quantity("5.0 deg") + + expected = {"Param1": expected_param1, "Param2": expected_param2} + params = shape.get_parameters() + self.assertEqual(params["Param1"].Value, expected["Param1"].Value) + self.assertEqual(str(params["Param1"].Unit), str(expected["Param1"].Unit)) + self.assertEqual(params["Param2"].Value, expected["Param2"].Value) + self.assertEqual(str(params["Param2"].Unit), str(expected["Param2"].Unit)) + + def test_base_name_property(self): + """Test the name property returns the primary alias.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_5", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(shape.name, "dummy") + + def test_base_get_parameter_label(self): + """Test retrieving parameter labels.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_6", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(shape.get_parameter_label("Param1"), "Parameter 1") + self.assertEqual(shape.get_parameter_label("Param2"), "Parameter 2") + # Test fallback for unknown parameter + self.assertEqual(shape.get_parameter_label("UnknownParam"), "UnknownParam") + + def test_base_get_expected_shape_parameters(self): + """Test retrieving the list of expected parameter names.""" + expected = ["Param1", "Param2"] + self.assertCountEqual(DummyShape.get_expected_shape_parameters(), expected) + + def test_base_str_repr(self): + """Test string representation.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_7", filepath=Path("/fake/dummy.fcstd")) + # Dynamically construct the expected string using the actual parameter string representations + params_str = ", ".join(f"{name}={str(val)}" for name, val in shape.get_parameters().items()) + expected_str = f"dummy({params_str})" + self.assertEqual(str(shape), expected_str) + self.assertEqual(repr(shape), expected_str) + + def test_base_resolve_name(self): + """Test resolving shape aliases to canonical names.""" + self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend") + self.assertEqual(ToolBitShape.resolve_name("Ballend").asset_id, "ballend") + self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "vbit") + self.assertEqual(ToolBitShape.resolve_name("VBit").asset_id, "vbit") + self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "bullnose") + self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose") + self.assertEqual(ToolBitShape.resolve_name("slitting-saw").asset_id, "slittingsaw") + self.assertEqual(ToolBitShape.resolve_name("SlittingSaw").asset_id, "slittingsaw") + # Test unknown name - should return the input name + self.assertEqual(ToolBitShape.resolve_name("nonexistent").asset_id, "nonexistent") + self.assertEqual(ToolBitShape.resolve_name("UnknownShape").asset_id, "UnknownShape") + + def test_concrete_classes_instantiation(self): + """Test that all concrete classes can be instantiated.""" + # No patching of FreeCAD document operations here. + # The test relies on the actual FreeCAD environment. + + shape_uris = self.assets.list_assets(asset_type="toolbitshape") + for uri in shape_uris: + # Skip the DummyShape asset if it exists + if uri.asset_id == "dummy": + continue + + with self.subTest(uri=uri): + instance = self.assets.get(uri) + self.assertIsInstance(instance, ToolBitShape) + # Check if default params were set by checking if the + # parameters dictionary is not empty. + self.assertTrue(instance.get_parameters()) + + def test_get_shape_class(self): + """Test the get_shape_class function.""" + uri = ToolBitShape.resolve_name("ballend") + self.assets.get(uri) # Ensure it's loadable + + self.assertEqual(ToolBitShape.get_subclass_by_name("ballend"), ToolBitShapeBallend) + self.assertEqual(ToolBitShape.get_subclass_by_name("v-bit"), ToolBitShapeVBit) + self.assertEqual(ToolBitShape.get_subclass_by_name("VBit"), ToolBitShapeVBit) + self.assertEqual(ToolBitShape.get_subclass_by_name("torus"), ToolBitShapeBullnose) + self.assertEqual(ToolBitShape.get_subclass_by_name("slitting-saw"), ToolBitShapeSlittingSaw) + self.assertIsNone(ToolBitShape.get_subclass_by_name("nonexistent")) + + # The following tests for default parameters and labels + # should also not use mocks for FreeCAD document operations or Units. + # They should rely on the actual FreeCAD environment and the + # load_file method of the base class. + + def test_toolbitshapeballend_defaults(self): + """Test ToolBitShapeBallend default parameters and labels.""" + # Provide a dummy filepath for instantiation. + # The actual file content is not loaded in this test, + # only the default parameters are checked. + shape = self._test_shape_common("ballend") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["Length"].Value, 50.0) + self.assertEqual(unit(shape["Length"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("ballend") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + self.assertEqual(instance.get_parameter_label("Length"), "Overall tool length") + + def test_toolbitshapedrill_defaults(self): + """Test ToolBitShapeDrill default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("drill") + self.assertEqual(shape["Diameter"].Value, 3.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["TipAngle"].Value, 119.0) + self.assertEqual(unit(shape["TipAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("drill") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle") + + def test_toolbitshapechamfer_defaults(self): + """Test ToolBitShapeChamfer default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("chamfer") + self.assertEqual(shape["Diameter"].Value, 12.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0) + self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("chamfer") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + + def test_toolbitshapedovetail_defaults(self): + """Test ToolBitShapeDovetail default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("dovetail") + self.assertEqual(shape["Diameter"].Value, 20.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0) + self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("dovetail") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting angle") + + def test_toolbitshapeendmill_defaults(self): + """Test ToolBitShapeEndmill default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("endmill") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeHeight"].Value, 30.0) + self.assertEqual(unit(shape["CuttingEdgeHeight"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("endmill") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("CuttingEdgeHeight"), "Cutting edge height") + + def test_toolbitshapeprobe_defaults(self): + """Test ToolBitShapeProbe default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("probe") + self.assertEqual(shape["Diameter"].Value, 6.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["ShaftDiameter"].Value, 4.0) + self.assertEqual(unit(shape["ShaftDiameter"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("probe") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Ball diameter") + self.assertEqual(instance.get_parameter_label("ShaftDiameter"), "Shaft diameter") + + def test_toolbitshapereamer_defaults(self): + """Test ToolBitShapeReamer default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("reamer") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["Length"].Value, 50.0) + self.assertEqual(unit(shape["Length"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("reamer") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + + def test_toolbitshapeslittingsaw_defaults(self): + """Test ToolBitShapeSlittingSaw default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("slittingsaw") + self.assertEqual(shape["Diameter"].Value, 100.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["BladeThickness"].Value, 3.0) + self.assertEqual(unit(shape["BladeThickness"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("slittingsaw") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("BladeThickness"), "Blade thickness") + + def test_toolbitshapetap_defaults(self): + """Test ToolBitShapeTap default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("tap") + self.assertAlmostEqual(shape["Diameter"].Value, 8, 4) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["TipAngle"].Value, 90.0) + self.assertEqual(unit(shape["TipAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("tap") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle") + + def test_toolbitshapethreadmill_defaults(self): + """Test ToolBitShapeThreadMill default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("threadmill") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["cuttingAngle"].Value, 60.0) + self.assertEqual(unit(shape["cuttingAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("threadmill") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("cuttingAngle"), "Cutting angle") + + def test_toolbitshapebullnose_defaults(self): + """Test ToolBitShapeBullnose default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("bullnose") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["FlatRadius"].Value, 1.5) + self.assertEqual(unit(shape["FlatRadius"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("bullnose") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("FlatRadius"), "Torus radius") + + def test_toolbitshapevbit_defaults(self): + """Test ToolBitShapeVBit default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("vbit") + self.assertEqual(shape["Diameter"].Value, 10.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeAngle"].Value, 90.0) + self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°") + self.assertEqual(shape["TipDiameter"].Value, 1.0) + self.assertEqual(unit(shape["TipDiameter"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("vbit") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting edge angle") + + def test_serialize_deserialize(self): + """ + Tests serialization and deserialization of a ToolBitShape object + using the Asset interface methods. + """ + # Load a shape instance from a fixture file + fixture_path = ( + Path(__file__).parent / "Tools" / "Shape" / "test-path-tool-bit-shape-00.fcstd" + ) + original_shape = ToolBitShape.from_file(fixture_path) + + # Serialize the shape using the to_bytes method + serialized_data = original_shape.to_bytes(DummyAssetSerializer) + + # Assert that the serialized data is bytes and not empty + self.assertIsInstance(serialized_data, bytes) + self.assertTrue(len(serialized_data) > 0) + + # Deserialize the data using the from_bytes classmethod + # Provide an empty dependencies mapping for this test + deserialized_shape = ToolBitShape.from_bytes( + serialized_data, original_shape.get_id(), {}, DummyAssetSerializer + ) + + # Assert that the deserialized object is a ToolBitShape instance + self.assertIsInstance(deserialized_shape, ToolBitShape) + # Assert that the deserialized shape has the same parameters as the original + self.assertEqual(original_shape.get_parameters(), deserialized_shape.get_parameters()) + self.assertEqual(original_shape.name, deserialized_shape.name) diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py new file mode 100644 index 0000000000..02e024c732 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# Unit tests for the Path.Tool.Shape module and its document utilities. +import unittest +from unittest.mock import patch, MagicMock, call +from Path.Tool.shape import doc +import os + + +mock_freecad = MagicMock(Name="FreeCAD_Mock") +mock_freecad.Console = MagicMock() +mock_freecad.Console.PrintWarning = MagicMock() +mock_freecad.Console.PrintError = MagicMock() + +mock_obj = MagicMock(Name="Object_Mock") +mock_obj.Label = "MockObjectLabel" +mock_obj.Name = "MockObjectName" + +mock_doc = MagicMock(Name="Document_Mock") +mock_doc.Objects = [mock_obj] + + +class TestPathToolShapeDoc(unittest.TestCase): + def setUp(self): + """Reset mocks before each test.""" + # Resetting the top-level mock recursively resets its children + # (newDocument, getDocument, openDocument, closeDocument, Console, etc.) + # and their call counts, return_values, side_effects. + mock_freecad.reset_mock() + mock_doc.reset_mock() + mock_obj.reset_mock() + + # Re-establish default state/attributes potentially cleared by reset_mock + # or needed for tests. + mock_doc.Objects = [mock_obj] + mock_obj.Label = "MockObjectLabel" + mock_obj.Name = "MockObjectName" + # Ensure mock_doc also has a Name attribute used in tests/code + mock_doc.Name = "Document_Mock" # Used in closeDocument calls + + # Clear attributes potentially added by setattr in previous tests. + # reset_mock() doesn't remove attributes added this way. + # Focus on attributes known to be added by tests in this file. + for attr_name in ["Diameter", "Length", "Height"]: + if hasattr(mock_obj, attr_name): + try: + delattr(mock_obj, attr_name) + except AttributeError: + pass # Ignore if already gone + + """Tests for the document utility functions in Path/Tool/Shape/doc.py""" + + def test_doc_find_shape_object_body_priority(self): + """Test find_shape_object prioritizes PartDesign::Body.""" + body_obj = MagicMock(Name="Body_Mock") + body_obj.isDerivedFrom = lambda typeName: typeName == "PartDesign::Body" + part_obj = MagicMock(Name="Part_Mock") + part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature" + mock_doc.Objects = [part_obj, body_obj] + found = doc.find_shape_object(mock_doc) + self.assertEqual(found, body_obj) + + def test_doc_find_shape_object_part_fallback(self): + """Test find_shape_object falls back to Part::Feature.""" + part_obj = MagicMock(Name="Part_Mock") + part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature" + other_obj = MagicMock(Name="Other_Mock") + other_obj.isDerivedFrom = lambda typeName: False + mock_doc.Objects = [other_obj, part_obj] + found = doc.find_shape_object(mock_doc) + self.assertEqual(found, part_obj) + + def test_doc_find_shape_object_first_obj_fallback(self): + """Test find_shape_object falls back to the first object.""" + other_obj1 = MagicMock(Name="Other1_Mock") + other_obj1.isDerivedFrom = lambda typeName: False + other_obj2 = MagicMock(Name="Other2_Mock") + other_obj2.isDerivedFrom = lambda typeName: False + mock_doc.Objects = [other_obj1, other_obj2] + found = doc.find_shape_object(mock_doc) + self.assertEqual(found, other_obj1) + + def test_doc_find_shape_object_no_objects(self): + """Test find_shape_object returns None if document has no objects.""" + mock_doc.Objects = [] + found = doc.find_shape_object(mock_doc) + self.assertIsNone(found) + + def test_doc_get_object_properties_found(self): + """Test get_object_properties extracts existing properties.""" + setattr(mock_obj, "Diameter", "10 mm") + setattr(mock_obj, "Length", "50 mm") + params = doc.get_object_properties(mock_obj, ["Diameter", "Length"]) + # Expecting just the values, not tuples + self.assertEqual(params, {"Diameter": "10 mm", "Length": "50 mm"}) + mock_freecad.Console.PrintWarning.assert_not_called() + + @patch("Path.Tool.shape.doc.FreeCAD", new=mock_freecad) + def test_doc_get_object_properties_missing(self): + """Test get_object_properties handles missing properties with warning.""" + # Re-import doc within the patch context to use the mocked FreeCAD + import Path.Tool.shape.doc as doc_patched + + setattr(mock_obj, "Diameter", "10 mm") + # Explicitly delete Height to ensure hasattr returns False for MagicMock + if hasattr(mock_obj, "Height"): + delattr(mock_obj, "Height") + params = doc_patched.get_object_properties(mock_obj, ["Diameter", "Height"]) + # Expecting just the values, not tuples + self.assertEqual(params, {"Diameter": "10 mm", "Height": None}) # Height is missing + expected_calls = [ + # The 'Could not get type' warning is from base.py's set_parameter, + # not get_object_properties. Removing it from expected calls here. + call( + "Parameter 'Height' not found on object 'MockObjectLabel' " + "(MockObjectName). Default value will be used by the shape " + "class.\n" + ) + ] + mock_freecad.Console.PrintWarning.assert_has_calls(expected_calls, any_order=True) + + @patch("FreeCAD.openDocument") + @patch("FreeCAD.getDocument") + @patch("FreeCAD.closeDocument") + def test_ShapeDocFromBytes(self, mock_close_doc, mock_get_doc, mock_open_doc): + """Test ShapeDocFromBytes loads doc from a byte string.""" + content = b"fake_content" + mock_opened_doc = MagicMock(Name="OpenedDoc_Mock") + mock_get_doc.return_value = mock_opened_doc + + temp_file_path = None + try: + with doc.ShapeDocFromBytes(content=content) as temp_doc: + # Verify temp file creation and content + mock_open_doc.assert_called_once() + temp_file_path = mock_open_doc.call_args[0][0] + self.assertTrue(os.path.exists(temp_file_path)) + with open(temp_file_path, "rb") as f: + self.assertEqual(f.read(), content) + + self.assertEqual(temp_doc, mock_open_doc.return_value) + + # Verify cleanup after exiting the context + mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name) + self.assertFalse(os.path.exists(temp_file_path)) + + finally: + # Ensure cleanup even if test fails before assertion + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + + @patch("FreeCAD.openDocument") + @patch("FreeCAD.getDocument") + @patch("FreeCAD.closeDocument") + def test_ShapeDocFromBytes_open_exception(self, mock_close_doc, mock_get_doc, mock_open_doc): + """Test ShapeDocFromBytes propagates exceptions and cleans up.""" + content = b"fake_content_exception" + load_error = Exception("Fake load error") + mock_open_doc.side_effect = load_error + + temp_file_path = None + try: + with self.assertRaises(Exception) as cm: + with doc.ShapeDocFromBytes(content=content): + pass + + self.assertEqual(cm.exception, load_error) + + # Verify temp file was created before the exception + mock_open_doc.assert_called_once() + temp_file_path = mock_open_doc.call_args[0][0] + self.assertTrue(os.path.exists(temp_file_path)) + with open(temp_file_path, "rb") as f: + self.assertEqual(f.read(), content) + + mock_get_doc.assert_not_called() + # closeDocument is called in __exit__ only if _doc is not None, + # which it will be if openDocument failed. + mock_close_doc.assert_not_called() + + finally: + # Verify cleanup after exiting the context (even with exception) + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + # Assert removal only if temp_file_path was set + if temp_file_path: + self.assertFalse(os.path.exists(temp_file_path)) + + @patch("FreeCAD.openDocument") + @patch("FreeCAD.getDocument") + @patch("FreeCAD.closeDocument") + def test_ShapeDocFromBytes_exit_cleans_up(self, mock_close_doc, mock_get_doc, mock_open_doc): + """Test ShapeDocFromBytes __exit__ cleans up temp file.""" + content = b"fake_content_cleanup" + mock_opened_doc = MagicMock(Name="OpenedDoc_Cleanup_Mock") + mock_get_doc.return_value = mock_opened_doc + + temp_file_path = None + try: + with doc.ShapeDocFromBytes(content=content): + mock_open_doc.assert_called_once() + temp_file_path = mock_open_doc.call_args[0][0] + self.assertTrue(os.path.exists(temp_file_path)) + with open(temp_file_path, "rb") as f: + self.assertEqual(f.read(), content) + # No assertions on the returned doc here, focus is on cleanup + pass # Exit the context + + # Verify cleanup after exiting the context + mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name) + self.assertFalse(os.path.exists(temp_file_path)) + + finally: + # Ensure cleanup even if test fails before assertion + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + + +# Test execution +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py b/src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py new file mode 100644 index 0000000000..0b70cb683b --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +import unittest +import unittest.mock +import pathlib +from tempfile import TemporaryDirectory +from PySide import QtCore, QtGui +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.assets import DummyAssetSerializer +from Path.Tool.shape.models.icon import ( + ToolBitShapeIcon, + ToolBitShapeSvgIcon, + ToolBitShapePngIcon, +) + + +class TestToolBitShapeIconBase(PathTestWithAssets): + """Base class for ToolBitShapeIcon tests.""" + + ICON_CLASS = ToolBitShapeIcon + + def setUp(self): + super().setUp() + # Ensure a QApplication exists for QPixmap tests + self.app = QtGui.QApplication.instance() + + # Create a test shape and a test SVG icon. + self.test_shape = self.assets.get("toolbitshape://ballend") + self.test_svg = self.test_shape.icon + assert self.test_svg is not None + self.icon = self.ICON_CLASS("test_icon_base", b"") + + def tearDown(self): + self.app = None + return super().tearDown() + + def test_create_instance(self): + # Test basic instance creation + icon_id = "test_icon_123.dat" + icon = self.ICON_CLASS(icon_id, b"") + self.assertEqual(icon.get_id(), icon_id) + self.assertEqual(icon.data, b"") + self.assertIsInstance(icon.abbreviations, dict) + + def test_to_bytes(self): + # Test serializing to bytes + icon_id = "test_to_bytes.bin" + icon_data = b"some_binary_data" + icon = ToolBitShapeIcon(icon_id, icon_data) + self.assertEqual(icon.to_bytes(DummyAssetSerializer), icon_data) + + def test_get_size_in_bytes(self): + # Test getting icon data length + icon_with_data = ToolBitShapeIcon("with_data.bin", b"abc") + self.assertEqual(icon_with_data.get_size_in_bytes(), 3) + + icon_no_data = ToolBitShapeIcon("no_data.dat", b"") + self.assertEqual(icon_no_data.get_size_in_bytes(), 0) + + @unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data") + def test_from_shape_data_success(self, mock_create_thumbnail): + # Test creating instance from shape data - success case + shape_id = "test_shape" + thumbnail_data = b"png thumbnail data" + mock_create_thumbnail.return_value = thumbnail_data + icon = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id) + + mock_create_thumbnail.assert_called_once_with(self.test_svg.data) + self.assertIsNotNone(icon) + self.assertIsInstance(icon, ToolBitShapePngIcon) + self.assertEqual(icon.get_id(), shape_id) + self.assertEqual(icon.data, thumbnail_data) + self.assertEqual(icon.abbreviations, {}) + + @unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data") + def test_from_shape_data_failure(self, mock_create_thumbnail): + # Test creating instance from shape data - failure case + shape_id = "test_shape" + mock_create_thumbnail.return_value = None + icon_failed = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id) + + mock_create_thumbnail.assert_called_once_with(self.test_svg.data) + self.assertIsNone(icon_failed) + + def test_get_png(self): + if not self.app: + self.skipTest("QApplication not available, skipping test_get_png") + if type(self) is TestToolBitShapeIconBase: + self.skipTest("Skipping test on abstract base class") + # Test getting PNG data from the icon + icon_size = QtCore.QSize(16, 16) + png_data = self.icon.get_png(icon_size) + self.assertIsInstance(png_data, bytes) + self.assertTrue(len(png_data) > 0) + + def test_get_qpixmap(self): + if not self.app: + self.skipTest("QApplication not available, skipping test_get_qpixmap") + if type(self) is TestToolBitShapeIconBase: + self.skipTest("Skipping test on abstract base class") + # Test getting QPixmap from the icon + icon_size = QtCore.QSize(31, 32) + pixmap = self.icon.get_qpixmap(icon_size) + self.assertIsInstance(pixmap, QtGui.QPixmap) + self.assertFalse(pixmap.isNull()) + self.assertEqual(pixmap.size().width(), 31) + self.assertEqual(pixmap.size().height(), 32) + + +class TestToolBitShapeSvgIcon(TestToolBitShapeIconBase): + """Tests specifically for ToolBitShapeSvgIcon.""" + + ICON_CLASS = ToolBitShapeSvgIcon + + def setUp(self): + super().setUp() + self.icon = ToolBitShapeSvgIcon("test_icon_svg", self.test_svg.data) + + def test_from_bytes_svg(self): + # Test creating instance from bytes with SVG + icon_svg = ToolBitShapeSvgIcon.from_bytes( + self.test_svg.data, "test_from_bytes.svg", {}, DummyAssetSerializer + ) + self.assertEqual(icon_svg.get_id(), "test_from_bytes.svg") + self.assertEqual(icon_svg.data, self.test_svg.data) + self.assertIsInstance(icon_svg.abbreviations, dict) + + def test_round_trip_serialization_svg(self): + # Test serialization and deserialization round trip for SVG + svg_id = "round_trip_svg.svg" + icon_svg = ToolBitShapeSvgIcon(svg_id, self.test_svg.data) + serialized_svg = icon_svg.to_bytes(DummyAssetSerializer) + deserialized_svg = ToolBitShapeSvgIcon.from_bytes( + serialized_svg, svg_id, {}, DummyAssetSerializer + ) + self.assertEqual(deserialized_svg.get_id(), svg_id) + self.assertEqual(deserialized_svg.data, self.test_svg.data) + # Abbreviations are extracted on access, so we don't check the dict directly + self.assertIsInstance(deserialized_svg.abbreviations, dict) + + def test_from_file_svg(self): + # We cannot use NamedTemporaryFile on Windows, because there + # we may not have permission to read the tempfile while it is + # still open. + # So we use TemporaryDirectory instead, to ensure cleanup while + # still having a the temporary file inside it. + with TemporaryDirectory() as thedir: + tempfile = pathlib.Path(thedir, "test.svg") + tempfile.write_bytes(self.test_svg.data) + + icon_id = "dummy_icon" + icon = ToolBitShapeIcon.from_file(tempfile, icon_id) + self.assertIsInstance(icon, ToolBitShapeSvgIcon) + self.assertEqual(icon.get_id(), icon_id) + self.assertEqual(icon.data, self.test_svg.data) + self.assertIsInstance(icon.abbreviations, dict) + + def test_abbreviations_cached_property_svg(self): + # Test abbreviations property and caching for SVG + icon_svg = ToolBitShapeSvgIcon("cached_abbr.svg", self.test_svg.data) + + # Accessing the property should call the static method + with unittest.mock.patch.object( + ToolBitShapeSvgIcon, "get_abbreviations_from_svg" + ) as mock_get_abbr: + mock_get_abbr.return_value = {"param1": "A1"} + abbr1 = icon_svg.abbreviations + abbr2 = icon_svg.abbreviations + mock_get_abbr.assert_called_once_with(self.test_svg.data) + self.assertEqual(abbr1, {"param1": "A1"}) + self.assertEqual(abbr2, {"param1": "A1"}) + + def test_get_abbr_svg(self): + # Test getting abbreviations for SVG + icon_data = self.test_svg.data + icon = ToolBitShapeSvgIcon("abbr_test.svg", icon_data) + # Assuming the test_svg data has 'diameter' and 'length' ids + self.assertIsNotNone(icon.get_abbr("Diameter")) + self.assertIsNotNone(icon.get_abbr("Length")) + self.assertIsNone(icon.get_abbr("NonExistent")) + + def test_get_abbreviations_from_svg_static(self): + # Test static method get_abbreviations_from_svg + svg_content = self.test_svg.data + abbr = ToolBitShapeSvgIcon.get_abbreviations_from_svg(svg_content) + # Assuming the test_svg data has 'diameter' and 'length' ids + self.assertIn("diameter", abbr) + self.assertIn("length", abbr) + + # Test with invalid SVG + invalid_svg = b"A1" # Missing closing tag + abbr_invalid = ToolBitShapeSvgIcon.get_abbreviations_from_svg(invalid_svg) + self.assertEqual(abbr_invalid, {}) + + # Test with no text elements + no_text_svg = b'' + abbr_no_text = ToolBitShapeSvgIcon.get_abbreviations_from_svg(no_text_svg) + self.assertEqual(abbr_no_text, {}) + + +class TestToolBitShapePngIcon(TestToolBitShapeIconBase): + """Tests specifically for ToolBitShapePngIcon.""" + + ICON_CLASS = ToolBitShapePngIcon + + def setUp(self): + super().setUp() + self.png_data = b"\x89PNG\r\n\x1a\n" # Basic PNG signature + self.icon = ToolBitShapePngIcon("test_icon_png", self.png_data) + + def test_from_bytes_png(self): + # Test creating instance from bytes with PNG + icon_png = ToolBitShapePngIcon.from_bytes( + self.png_data, "test_from_bytes.png", {}, DummyAssetSerializer + ) + self.assertEqual(icon_png.get_id(), "test_from_bytes.png") + self.assertEqual(icon_png.data, self.png_data) + self.assertEqual(icon_png.abbreviations, {}) # No abbreviations for PNG + + def test_round_trip_serialization_png(self): + # Test serialization and deserialization round trip for PNG + png_id = "round_trip_png.png" + serialized_png = self.icon.to_bytes(DummyAssetSerializer) + deserialized_png = ToolBitShapePngIcon.from_bytes( + serialized_png, png_id, {}, DummyAssetSerializer + ) + self.assertEqual(deserialized_png.get_id(), png_id) + self.assertEqual(deserialized_png.data, self.png_data) + self.assertEqual(deserialized_png.abbreviations, {}) + + def test_from_file_png(self): + png_data = b"\\x89PNG\\r\\n\\x1a\\n" + # We cannot use NamedTemporaryFile on Windows, because there + # we may not have permission to read the tempfile while it is + # still open. + # So we use TemporaryDirectory instead, to ensure cleanup while + # still having a the temporary file inside it. + with TemporaryDirectory() as thedir: + tempfile = pathlib.Path(thedir, "test.png") + tempfile.write_bytes(png_data) + + icon_id = "dummy_icon" + icon = ToolBitShapeIcon.from_file(tempfile, icon_id) + self.assertIsInstance(icon, ToolBitShapePngIcon) + self.assertEqual(icon.get_id(), icon_id) + self.assertEqual(icon.data, png_data) + self.assertEqual(icon.abbreviations, {}) + + def test_abbreviations_cached_property_png(self): + # Test abbreviations property and caching for PNG + self.assertEqual(self.icon.abbreviations, {}) + + def test_get_abbr_png(self): + # Test getting abbreviations for PNG + self.assertIsNone(self.icon.get_abbr("Diameter")) + + def test_get_qpixmap(self): + self.skipTest("Skipping test, have no test data") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathVcarve.py b/src/Mod/CAM/CAMTests/TestPathVcarve.py index f1beb771a9..6e0345fdeb 100644 --- a/src/Mod/CAM/CAMTests/TestPathVcarve.py +++ b/src/Mod/CAM/CAMTests/TestPathVcarve.py @@ -24,10 +24,8 @@ import FreeCAD import Part import Path.Main.Job as PathJob import Path.Op.Vcarve as PathVcarve -import Path.Tool.Bit as PathToolBit import math - -from CAMTests.PathTestUtils import PathTestBase +from CAMTests.PathTestUtils import PathTestWithAssets class VbitTool(object): @@ -43,10 +41,11 @@ Scale45 = 2.414214 Scale60 = math.sqrt(3) -class TestPathVcarve(PathTestBase): +class TestPathVcarve(PathTestWithAssets): """Test Vcarve milling basics.""" def tearDown(self): + super().tearDown() if hasattr(self, "doc"): FreeCAD.closeDocument(self.doc.Name) @@ -56,8 +55,9 @@ class TestPathVcarve(PathTestBase): rect = Part.makePolygon([(0, 0, 0), (5, 0, 0), (5, 10, 0), (0, 10, 0), (0, 0, 0)]) part.Shape = Part.makeFace(rect, "Part::FaceMakerSimple") job = PathJob.Create("Job", [part]) - tool_file = PathToolBit.findToolBit("60degree_Vbit.fctb") - job.Tools.Group[0].Tool = PathToolBit.Factory.CreateFrom(tool_file) + toolbit = self.assets.get("toolbit://60degree_Vbit") + loaded_tool = toolbit.attach_to_doc(doc=job.Document) + job.Tools.Group[0].Tool = loaded_tool op = PathVcarve.Create("TestVCarve") op.Base = job.Model.Group[0] diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 06312b6300..dcae63ade9 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -10,6 +10,7 @@ set(Path_Scripts Init.py PathCommands.py TestCAMApp.py + TestCAMGui.py ) if(BUILD_GUI) @@ -114,20 +115,160 @@ SET(PathPythonMainSanity_SRCS SET(PathPythonTools_SRCS Path/Tool/__init__.py - Path/Tool/Bit.py + Path/Tool/camassets.py Path/Tool/Controller.py ) +SET(PathPythonToolsAssets_SRCS + Path/Tool/assets/__init__.py + Path/Tool/assets/asset.py + Path/Tool/assets/cache.py + Path/Tool/assets/manager.py + Path/Tool/assets/serializer.py + Path/Tool/assets/uri.py +) + +SET(PathPythonToolsAssetsStore_SRCS + Path/Tool/assets/store/__init__.py + Path/Tool/assets/store/base.py + Path/Tool/assets/store/memory.py + Path/Tool/assets/store/filestore.py +) + +SET(PathPythonToolsAssetsUi_SRCS + Path/Tool/assets/ui/__init__.py + Path/Tool/assets/ui/filedialog.py + Path/Tool/assets/ui/preferences.py + Path/Tool/assets/ui/util.py +) + SET(PathPythonToolsGui_SRCS Path/Tool/Gui/__init__.py - Path/Tool/Gui/Bit.py - Path/Tool/Gui/BitCmd.py - Path/Tool/Gui/BitEdit.py - Path/Tool/Gui/BitLibraryCmd.py - Path/Tool/Gui/BitLibrary.py Path/Tool/Gui/Controller.py ) +SET(PathPythonToolsUi_SRCS + Path/Tool/ui/__init__.py + Path/Tool/ui/docobject.py + Path/Tool/ui/property.py +) + +SET(PathPythonToolsToolBit_SRCS + Path/Tool/toolbit/__init__.py + Path/Tool/toolbit/docobject.py + Path/Tool/toolbit/util.py +) + +SET(PathPythonToolsToolBitMixins_SRCS + Path/Tool/toolbit/mixins/__init__.py + Path/Tool/toolbit/mixins/rotary.py + Path/Tool/toolbit/mixins/cutting.py +) + +SET(PathPythonToolsToolBitModels_SRCS + Path/Tool/toolbit/models/__init__.py + Path/Tool/toolbit/models/ballend.py + Path/Tool/toolbit/models/base.py + Path/Tool/toolbit/models/bullnose.py + Path/Tool/toolbit/models/chamfer.py + Path/Tool/toolbit/models/dovetail.py + Path/Tool/toolbit/models/drill.py + Path/Tool/toolbit/models/endmill.py + Path/Tool/toolbit/models/fillet.py + Path/Tool/toolbit/models/probe.py + Path/Tool/toolbit/models/reamer.py + Path/Tool/toolbit/models/slittingsaw.py + Path/Tool/toolbit/models/tap.py + Path/Tool/toolbit/models/threadmill.py + Path/Tool/toolbit/models/vbit.py +) + +SET(PathPythonToolsToolBitSerializers_SRCS + Path/Tool/toolbit/serializers/__init__.py + Path/Tool/toolbit/serializers/camotics.py + Path/Tool/toolbit/serializers/fctb.py +) + +SET(PathPythonToolsToolBitUi_SRCS + Path/Tool/toolbit/ui/__init__.py + Path/Tool/toolbit/ui/editor.py + Path/Tool/toolbit/ui/cmd.py + Path/Tool/toolbit/ui/browser.py + Path/Tool/toolbit/ui/file.py + Path/Tool/toolbit/ui/panel.py + Path/Tool/toolbit/ui/selector.py + Path/Tool/toolbit/ui/tablecell.py + Path/Tool/toolbit/ui/toollist.py + Path/Tool/toolbit/ui/view.py +) + +SET(PathPythonToolsLibrary_SRCS + Path/Tool/library/__init__.py + Path/Tool/library/util.py +) + +SET(PathPythonToolsLibraryModels_SRCS + Path/Tool/library/models/__init__.py + Path/Tool/library/models/library.py +) + +SET(PathPythonToolsLibrarySerializers_SRCS + Path/Tool/library/serializers/__init__.py + Path/Tool/library/serializers/fctl.py + Path/Tool/library/serializers/camotics.py + Path/Tool/library/serializers/linuxcnc.py +) + +SET(PathPythonToolsLibraryUi_SRCS + Path/Tool/library/ui/__init__.py + Path/Tool/library/ui/cmd.py + Path/Tool/library/ui/dock.py + Path/Tool/library/ui/editor.py + Path/Tool/library/ui/browser.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 + Path/Tool/shape/doc.py +) + +SET(PathPythonToolsShapeModels_SRCS + Path/Tool/shape/models/__init__.py + Path/Tool/shape/models/ballend.py + Path/Tool/shape/models/base.py + Path/Tool/shape/models/bullnose.py + Path/Tool/shape/models/chamfer.py + Path/Tool/shape/models/dovetail.py + Path/Tool/shape/models/drill.py + Path/Tool/shape/models/endmill.py + Path/Tool/shape/models/fillet.py + Path/Tool/shape/models/icon.py + Path/Tool/shape/models/probe.py + Path/Tool/shape/models/reamer.py + Path/Tool/shape/models/slittingsaw.py + Path/Tool/shape/models/tap.py + Path/Tool/shape/models/threadmill.py + Path/Tool/shape/models/vbit.py +) + +SET(PathPythonToolsShapeUi_SRCS + Path/Tool/shape/ui/__init__.py + Path/Tool/shape/ui/flowlayout.py + Path/Tool/shape/ui/shapebutton.py + Path/Tool/shape/ui/shapeselector.py + Path/Tool/shape/ui/shapewidget.py +) + SET(PathPythonPost_SRCS Path/Post/__init__.py Path/Post/Command.py @@ -267,14 +408,17 @@ SET(Tools_SRCS ) SET(Tools_Bit_SRCS + Tools/Bit/30degree_Vbit.fctb Tools/Bit/375-16_Tap.fctb Tools/Bit/45degree_chamfer.fctb + Tools/Bit/45degree_Vbit.fctb Tools/Bit/5mm-thread-cutter.fctb Tools/Bit/5mm_Drill.fctb Tools/Bit/5mm_Endmill.fctb Tools/Bit/60degree_Vbit.fctb Tools/Bit/6mm_Ball_End.fctb Tools/Bit/6mm_Bullnose.fctb + Tools/Bit/90degree_Vbit.fctb Tools/Bit/probe.fctb Tools/Bit/slittingsaw.fctb ) @@ -285,16 +429,31 @@ SET(Tools_Library_SRCS SET(Tools_Shape_SRCS Tools/Shape/ballend.fcstd + Tools/Shape/ballend.svg Tools/Shape/bullnose.fcstd + Tools/Shape/bullnose.svg Tools/Shape/chamfer.fcstd + Tools/Shape/chamfer.svg Tools/Shape/dovetail.fcstd + Tools/Shape/dovetail.svg Tools/Shape/drill.fcstd + Tools/Shape/drill.svg Tools/Shape/endmill.fcstd + Tools/Shape/endmill.svg + Tools/Shape/fillet.fcstd + Tools/Shape/fillet.svg Tools/Shape/probe.fcstd + Tools/Shape/probe.svg + Tools/Shape/reamer.fcstd + Tools/Shape/reamer.svg Tools/Shape/slittingsaw.fcstd + Tools/Shape/slittingsaw.svg Tools/Shape/tap.fcstd - Tools/Shape/thread-mill.fcstd - Tools/Shape/v-bit.fcstd + Tools/Shape/tap.svg + Tools/Shape/threadmill.fcstd + Tools/Shape/threadmill.svg + Tools/Shape/vbit.fcstd + Tools/Shape/vbit.svg ) SET(Tests_SRCS @@ -346,7 +505,24 @@ SET(Tests_SRCS CAMTests/TestPathToolChangeGenerator.py CAMTests/TestPathThreadMilling.py CAMTests/TestPathThreadMillingGenerator.py + CAMTests/TestPathToolAsset.py + CAMTests/TestPathToolAssetCache.py + CAMTests/TestPathToolAssetUri.py + CAMTests/TestPathToolAssetStore.py + CAMTests/TestPathToolAssetManager.py CAMTests/TestPathToolBit.py + CAMTests/TestPathToolBitSerializer.py + CAMTests/TestPathToolBitBrowserWidget.py + CAMTests/TestPathToolBitEditorWidget.py + CAMTests/TestPathToolBitListWidget.py + CAMTests/TestPathToolBitPropertyEditorWidget.py + CAMTests/TestPathToolDocumentObjectEditorWidget.py + CAMTests/TestPathToolShapeClasses.py + CAMTests/TestPathToolShapeDoc.py + CAMTests/TestPathToolShapeIcon.py + CAMTests/TestPathToolLibrary.py + CAMTests/TestPathToolLibrarySerializer.py + CAMTests/TestPathToolMachine.py CAMTests/TestPathToolController.py CAMTests/TestPathUtil.py CAMTests/TestPathVcarve.py @@ -415,7 +591,25 @@ SET(all_files ${PathPythonPost_SRCS} ${PathPythonPostScripts_SRCS} ${PathPythonTools_SRCS} + ${PathPythonToolsAssets_SRCS} + ${PathPythonToolsAssetsStore_SRCS} + ${PathPythonToolsAssetsUi_SRCS} ${PathPythonToolsGui_SRCS} + ${PathPythonToolsUi_SRCS} + ${PathPythonToolsShape_SRCS} + ${PathPythonToolsShapeModels_SRCS} + ${PathPythonToolsShapeUi_SRCS} + ${PathPythonToolsToolBit_SRCS} + ${PathPythonToolsToolBitMixins_SRCS} + ${PathPythonToolsToolBitModels_SRCS} + ${PathPythonToolsToolBitSerializers_SRCS} + ${PathPythonToolsToolBitUi_SRCS} + ${PathPythonToolsLibrary_SRCS} + ${PathPythonToolsLibraryModels_SRCS} + ${PathPythonToolsLibrarySerializers_SRCS} + ${PathPythonToolsLibraryUi_SRCS} + ${PathPythonToolsMachine_SRCS} + ${PathPythonToolsMachineModels_SRCS} ${PathPythonGui_SRCS} ${Tools_SRCS} ${Tools_Bit_SRCS} @@ -544,6 +738,27 @@ INSTALL( Mod/CAM/Path/Tool ) +INSTALL( + FILES + ${PathPythonToolsAssets_SRCS} + DESTINATION + Mod/CAM/Path/Tool/assets +) + +INSTALL( + FILES + ${PathPythonToolsAssetsStore_SRCS} + DESTINATION + Mod/CAM/Path/Tool/assets/store +) + +INSTALL( + FILES + ${PathPythonToolsAssetsUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/assets/ui +) + INSTALL( FILES ${PathPythonToolsGui_SRCS} @@ -551,6 +766,111 @@ INSTALL( Mod/CAM/Path/Tool/Gui ) +INSTALL( + FILES + ${PathPythonToolsUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/ui +) + +INSTALL( + FILES + ${PathPythonToolsShape_SRCS} + DESTINATION + Mod/CAM/Path/Tool/shape +) + +INSTALL( + FILES + ${PathPythonToolsShapeUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/shape/ui +) + +INSTALL( + FILES + ${PathPythonToolsShapeModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/shape/models +) + +INSTALL( + FILES + ${PathPythonToolsToolBit_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit +) + +INSTALL( + FILES + ${PathPythonToolsToolBitMixins_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/mixins +) + +INSTALL( + FILES + ${PathPythonToolsToolBitModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/models +) + +INSTALL( + FILES + ${PathPythonToolsToolBitSerializers_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/serializers +) + +INSTALL( + FILES + ${PathPythonToolsToolBitUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/ui +) + +INSTALL( + FILES + ${PathPythonToolsLibrary_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library +) + +INSTALL( + FILES + ${PathPythonToolsLibraryModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library/models +) + +INSTALL( + FILES + ${PathPythonToolsLibrarySerializers_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library/serializers +) + +INSTALL( + FILES + ${PathPythonToolsLibraryUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library/ui +) + +INSTALL( + FILES + ${PathPythonToolsMachine_SRCS} + DESTINATION + Mod/CAM/Path/Tool/machine +) + +INSTALL( + FILES + ${PathPythonToolsMachineModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/machine/models +) + INSTALL( FILES ${Tests_SRCS} diff --git a/src/Mod/CAM/Gui/Resources/Path.qrc b/src/Mod/CAM/Gui/Resources/Path.qrc index e4967a5efb..287d7a2a61 100644 --- a/src/Mod/CAM/Gui/Resources/Path.qrc +++ b/src/Mod/CAM/Gui/Resources/Path.qrc @@ -119,11 +119,11 @@ panels/PointEdit.ui panels/PropertyBag.ui panels/PropertyCreate.ui + panels/ShapeSelector.ui panels/SetupGlobal.ui panels/SetupOp.ui panels/ToolBitEditor.ui panels/ToolBitLibraryEdit.ui - panels/ToolBitSelector.ui panels/TaskPathCamoticsSim.ui panels/TaskPathSimulator.ui panels/TaskCAMSimulator.ui diff --git a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui new file mode 100644 index 0000000000..395f051c3f --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui @@ -0,0 +1,65 @@ + + + ShapeSelector + + + + 0 + 0 + 900 + 600 + + + + Select a Tool Shape + + + + + + + + 1 + + + + + 0 + 0 + 880 + 487 + + + + Standard Tools + + + + + + 0 + 0 + 880 + 487 + + + + My Tools + + + + + + + + + + QDialogButtonBox::Cancel + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui index 7dae7882cb..ebb60688a5 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui @@ -1,237 +1,232 @@ - ToolBitAttributes - + Dialog + 0 0 - 489 - 715 + 1000 + 900 - Tool Bit Attributes + Tool Parameter Editor - - - - - - 0 - 0 - + + + + + true - - 0 - - - - Shape - - + + + + 0 + 0 + 980 + 849 + + + - - - - 0 - 0 - - - - Tool Bit - - - - - - Name - - - - - - - Display name of the Tool Bit (initial value taken from the shape file). - - - 50 - - - Display Name - - - - - - - Shape File - - - - - + + + + + + 0 + 0 + + + + + 1000 + 1000 + + + + + 1000 + 1000 + + + + 0 + + - + 0 0 - - - 0 - - - 0 - - - 0 - - - 0 - + + Tool + + - - - The file which defines the type and shape of the Tool Bit. - - - path - - + - - - Change file defining type and shape of Tool Bit. + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Notes + + + + + + QLayout::SetDefaultConstraint - - ... + + 12 + + + 6 + + + + + Coating: + + + + + + + + + + Hardness: + + + + + + + + + + Materials: + + + + + + + + + + + + + Supplier: + + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + - - - - - - - - Parameter - - - - QFormLayout::AllNonFixedFieldsGrow - - - - - Point/Tip Angle - - - - - - - 0 ° - - - ° - - - - - - - Cutting Edge Height - - - - - - - 0 mm - - - mm - - - - - - - - - - - 210 - 297 - - - - Image - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - - 20 - 277 - - - - - - - - - Attributes - - - - - - - 0 - 2 - - - - - 0 - 300 - - - - QAbstractItemView::AllEditTriggers - - + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + - - - Gui::InputField - QLineEdit -
Gui/InputField.h
-
-
- - - - + + tabWidget + lineEditCoating + lineEditMaterials + lineEditHardness + lineEditSupplier + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + +
diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui index d07da00243..edb58a2ae2 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui @@ -88,27 +88,7 @@ - - - - 16777215 - 16777215 - - - - Select a working path for the tool library editor. - - - - - - - :/icons/document-open.svg:/icons/document-open.svg - - - - - + 16777215 @@ -133,8 +113,9 @@ + - + Save the selected library with a new name or export to another format @@ -252,7 +233,7 @@ - + 0 @@ -266,7 +247,7 @@ - Save the current Library + Close the library editor Close diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui deleted file mode 100644 index 4112472b0a..0000000000 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui +++ /dev/null @@ -1,132 +0,0 @@ - - - ToolSelector - - - - 0 - 0 - 350 - 542 - - - - - 0 - 0 - - - - Tool Selector - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - Library editor... - - - - - - - :/icons/edit-edit.svg:/icons/edit-edit.svg - - - - 16 - 16 - - - - - - - - - - - - Available Tool Bits to choose from. - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::ExtendedSelection - - - false - - - - - - - - - - - false - - - Create ToolControllers for the selected toolbits and add them to the Job - - - Add To Job - - - - :/icons/edit_OK.svg:/icons/edit_OK.svg - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 29aa791901..bd0c16a02a 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2014 Yorik van Havre * # * * @@ -20,6 +21,10 @@ # * USA * # * * # *************************************************************************** +import FreeCAD + + +FreeCAD.__unit_test__ += ["TestCAMGui"] class PathCommandGroup: @@ -58,6 +63,7 @@ class CAMWorkbench(Workbench): # Add preferences pages - before loading PathGui to properly order pages of Path group import Path.Dressup.Gui.Preferences as PathPreferencesPathDressup + import Path.Tool.assets.ui.preferences as AssetPreferences import Path.Main.Gui.PreferencesJob as PathPreferencesPathJob translate = FreeCAD.Qt.translate @@ -74,8 +80,8 @@ class CAMWorkbench(Workbench): from Path.Main.Gui import JobCmd as PathJobCmd from Path.Main.Gui import SanityCmd as SanityCmd - from Path.Tool.Gui import BitCmd as PathToolBitCmd - from Path.Tool.Gui import BitLibraryCmd as PathToolBitLibraryCmd + from Path.Tool.toolbit.ui import cmd as PathToolBitCmd + from Path.Tool.library.ui import cmd as PathToolBitLibraryCmd from PySide.QtCore import QT_TRANSLATE_NOOP @@ -87,6 +93,10 @@ class CAMWorkbench(Workbench): PathPreferencesPathJob.JobPreferencesPage, QT_TRANSLATE_NOOP("QObject", "CAM"), ) + FreeCADGui.addPreferencePage( + AssetPreferences.AssetPreferencesPage, + QT_TRANSLATE_NOOP("QObject", "CAM"), + ) FreeCADGui.addPreferencePage( PathPreferencesPathDressup.DressupPreferencesPage, QT_TRANSLATE_NOOP("QObject", "CAM"), @@ -335,7 +345,7 @@ class CAMWorkbench(Workbench): for cmd in self.dressupcmds: self.appendContextMenu("", [cmd]) menuAppended = True - if isinstance(obj.Proxy, Path.Tool.Bit.ToolBit): + if isinstance(obj.Proxy, Path.Tool.ToolBit): self.appendContextMenu("", ["CAM_ToolBitSave", "CAM_ToolBitSaveAs"]) menuAppended = True if menuAppended: diff --git a/src/Mod/CAM/Path/Base/Gui/SetupSheet.py b/src/Mod/CAM/Path/Base/Gui/SetupSheet.py index dabcbb610d..97d6e0084e 100644 --- a/src/Mod/CAM/Path/Base/Gui/SetupSheet.py +++ b/src/Mod/CAM/Path/Base/Gui/SetupSheet.py @@ -321,10 +321,10 @@ class GlobalEditor(object): self.form.setupStepDownExpr.setText(self.obj.StepDownExpression) self.form.setupClearanceHeightExpr.setText(self.obj.ClearanceHeightExpression) self.form.setupSafeHeightExpr.setText(self.obj.SafeHeightExpression) - self.clearanceHeightOffs.updateSpinBox() - self.safeHeightOffs.updateSpinBox() - self.rapidVertical.updateSpinBox() - self.rapidHorizontal.updateSpinBox() + self.clearanceHeightOffs.updateWidget() + self.safeHeightOffs.updateWidget() + self.rapidVertical.updateWidget() + self.rapidHorizontal.updateWidget() self.selectInComboBox(self.obj.CoolantMode, self.form.setupCoolantMode) def updateModel(self, recomp=True): diff --git a/src/Mod/CAM/Path/Base/Gui/Util.py b/src/Mod/CAM/Path/Base/Gui/Util.py index 2d311b1cb1..4cf15a4ad8 100644 --- a/src/Mod/CAM/Path/Base/Gui/Util.py +++ b/src/Mod/CAM/Path/Base/Gui/Util.py @@ -138,7 +138,8 @@ class QuantitySpinBox(QtCore.QObject): return False def onWidgetValueChanged(self): - """onWidgetValueChanged()... Slot method for determining if a change + """ + Slot method for determining if a change in widget value is a result of an expression edit, or a simple spinbox change. If the former, emit a manual `editingFinished` signal because the Expression editor window returned a value to the base widget, leaving it in read-only mode, @@ -150,7 +151,7 @@ class QuantitySpinBox(QtCore.QObject): self.widget.editingFinished.emit() def attachTo(self, obj, prop=None): - """attachTo(obj, prop=None) ... use an existing editor for the given object and property""" + """use an existing editor for the given object and property""" Path.Log.track(self.prop, prop) self.obj = obj self.prop = prop @@ -168,21 +169,22 @@ class QuantitySpinBox(QtCore.QObject): self.valid = False def expression(self): - """expression() ... returns the expression if one is bound to the property""" + """returns the expression if one is bound to the property""" Path.Log.track(self.prop, self.valid) if self.valid: return self.widget.property("expression") return "" def setMinimum(self, quantity): - """setMinimum(quantity) ... set the minimum""" + """set the minimum""" Path.Log.track(self.prop, self.valid) if self.valid: value = quantity.Value if hasattr(quantity, "Value") else quantity self.widget.setProperty("setMinimum", value) - def updateSpinBox(self, quantity=None): - """updateSpinBox(quantity=None) ... update the display value of the spin box. + def updateWidget(self, quantity=None): + """ + update the display value of the spin box. If no value is provided the value of the bound property is used. quantity can be of type Quantity or Float.""" Path.Log.track(self.prop, self.valid, quantity) @@ -222,6 +224,215 @@ class QuantitySpinBox(QtCore.QObject): return None +class PropertyComboBox(QtCore.QObject): + """Base controller class for properties represented as QComboBox.""" + + def __init__(self, widget, obj, prop, onBeforeChange=None): + super().__init__() + Path.Log.track(widget) + self.widget = widget + self.onBeforeChange = onBeforeChange + self.prop = None + self.obj = obj + self.valid = False + self.attachTo(obj, prop) + self.widget.currentIndexChanged.connect(self.updateProperty) + + def attachTo(self, obj, prop=None): + """use an existing editor for the given object and property""" + Path.Log.track(self.prop, prop) + self.obj = obj + self.prop = prop + if obj and prop: + attr = PathUtil.getProperty(obj, prop) + if attr is not None: + self.valid = True + self._populateComboBox() + self.updateWidget() + else: + Path.Log.warning("Cannot find property {} of {}".format(prop, obj.Label)) + self.valid = False + else: + self.valid = False + + def _populateComboBox(self): + """To be implemented by subclasses""" + raise NotImplementedError + + def updateWidget(self, value=None): + """update the display value of the combo box.""" + Path.Log.track(self.prop, self.valid, value) + if self.valid: + if value is None: + value = PathUtil.getProperty(self.obj, self.prop) + index = ( + self.widget.findData(value) + if hasattr(self.widget, "findData") + else self.widget.findText(str(value)) + ) + if index >= 0: + self.widget.setCurrentIndex(index) + + def updateProperty(self): + """update the bound property with the value from the combo box""" + Path.Log.track(self.prop, self.valid) + if self.valid and self.prop: + if self.onBeforeChange: + self.onBeforeChange() + + current_value = PathUtil.getProperty(self.obj, self.prop) + new_value = ( + self.widget.currentData() + if hasattr(self.widget, "currentData") + else self.widget.currentText() + ) + + if str(new_value) != str(current_value): + setattr(self.obj, self.prop, new_value) + return True + return False + + +class IntegerSpinBox(QtCore.QObject): + """Controller class for integer properties represented as QSpinBox. + IntegerSpinBox(widget, obj, prop, onBeforeChange=None) + widget ... expected to be reference to a QSpinBox + obj ... document object + prop ... canonical name of the (sub-) property + onBeforeChange ... optional callback before property change + """ + + def __init__(self, widget, obj, prop, onBeforeChange=None): + super().__init__() + self.widget = widget + self.onBeforeChange = onBeforeChange + self.prop = None + self.obj = obj + self.valid = False + + # Configure spin box defaults + self.widget.setMinimum(-2147483647) # Qt's minimum for spin boxes + self.widget.setMaximum(2147483647) # Qt's maximum for spin boxes + + self.attachTo(obj, prop) + self.widget.valueChanged.connect(self.updateProperty) + + def attachTo(self, obj, prop=None): + """bind to the given object and property""" + self.obj = obj + self.prop = prop + if obj and prop: + try: + prop_value = PathUtil.getProperty(obj, prop) + if prop_value is not None: + self.valid = True + self.updateWidget() + else: + Path.Log.warning(f"Cannot get value for property {prop} of {obj.Label}") + self.valid = False + except Exception as e: + Path.Log.error(f"Error attaching to property {prop}: {str(e)}") + self.valid = False + else: + self.valid = False + + def updateWidget(self, value=None): + """update the spin box value""" + if self.valid: + try: + if value is None: + value = PathUtil.getProperty(self.obj, self.prop) + + # Handle both direct values and Quantity objects + if hasattr(value, "Value"): # For Quantity properties + value = int(value.Value) + + self.widget.setValue(int(value)) + except Exception as e: + Path.Log.error(f"Error updating spin box: {str(e)}") + + def updateProperty(self): + """update the bound property with the spin box value""" + if self.valid and self.prop: + if self.onBeforeChange: + self.onBeforeChange() + + new_value = self.widget.value() + current_value = PathUtil.getProperty(self.obj, self.prop) + + # Handle Quantity properties + if hasattr(current_value, "Value"): + if new_value != current_value.Value: + current_value.Value = new_value + return True + elif new_value != current_value: + setattr(self.obj, self.prop, new_value) + return True + return False + + def setRange(self, min_val, max_val): + """set minimum and maximum values""" + self.widget.setMinimum(min_val) + self.widget.setMaximum(max_val) + + def setSingleStep(self, step): + """setSingleStep(step) ... set the step size""" + self.widget.setSingleStep(step) + + +class BooleanComboBox(PropertyComboBox): + """Controller class for boolean properties represented as QComboBox.""" + + def _populateComboBox(self): + self.widget.clear() + self.widget.addItem("True", True) + self.widget.addItem("False", False) + + +class EnumerationComboBox(PropertyComboBox): + """Controller class for enumeration properties represented as QComboBox.""" + + def _populateComboBox(self): + self.widget.clear() + enums = self.obj.getEnumerationsOfProperty(self.prop) + for item in enums: + self.widget.addItem(item, item) + + +class PropertyLabel(QtCore.QObject): + """Controller class for read-only property display as QLabel.""" + + def __init__(self, widget, obj, prop, onBeforeChange=None): + super().__init__() + self.widget = widget + self.obj = obj + self.prop = prop + self.valid = False + self.attachTo(obj, prop) + + def attachTo(self, obj, prop=None): + """bind to the given object and property""" + self.obj = obj + self.prop = prop + if obj and prop: + attr = PathUtil.getProperty(obj, prop) + if attr is not None: + self.valid = True + self.updateWidget() + else: + Path.Log.warning(f"Cannot find property {prop} of {obj.Label}") + self.valid = False + else: + self.valid = False + + def updateWidget(self, value=None): + """update the label text""" + if self.valid: + if value is None: + value = PathUtil.getProperty(self.obj, self.prop) + self.widget.setText(str(value)) + + def getDocNode(): doc = FreeCADGui.ActiveDocument.Document.Name tws = FreeCADGui.getMainWindow().findChildren(QtGui.QTreeWidget) diff --git a/src/Mod/CAM/Path/Base/Util.py b/src/Mod/CAM/Path/Base/Util.py index 999da0a6c7..e8bcbde5f9 100644 --- a/src/Mod/CAM/Path/Base/Util.py +++ b/src/Mod/CAM/Path/Base/Util.py @@ -96,7 +96,7 @@ def isValidBaseObject(obj): # Can't link to anything inside a geo feature group anymore Path.Log.debug("%s is inside a geo feature group" % obj.Label) return False - if hasattr(obj, "BitBody") and hasattr(obj, "BitShape"): + if hasattr(obj, "BitBody") and hasattr(obj, "ShapeName"): # ToolBit's are not valid base objects return False if obj.TypeId in NotValidBaseTypeIds: diff --git a/src/Mod/CAM/Path/Main/Gui/Job.py b/src/Mod/CAM/Path/Main/Gui/Job.py index 6d480cd4ad..7561afbee4 100644 --- a/src/Mod/CAM/Path/Main/Gui/Job.py +++ b/src/Mod/CAM/Path/Main/Gui/Job.py @@ -35,10 +35,9 @@ import Path.Main.Gui.JobCmd as PathJobCmd import Path.Main.Gui.JobDlg as PathJobDlg import Path.Main.Job as PathJob import Path.Main.Stock as PathStock -import Path.Tool.Gui.Bit as PathToolBitGui import Path.Tool.Gui.Controller as PathToolControllerGui import PathScripts.PathUtils as PathUtils -import json +from Path.Tool.toolbit.ui.selector import ToolBitSelector import math import traceback from PySide import QtWidgets @@ -1073,29 +1072,14 @@ class TaskPanel: self.toolControllerSelect() def toolControllerAdd(self): - # adding a TC from a toolbit directly. - # Try to find a tool number from the currently selected lib. Otherwise - # use next available number - - tools = PathToolBitGui.LoadTools() - - curLib = Path.Preferences.lastFileToolLibrary() - - library = None - if curLib is not None: - with open(curLib) as fp: - library = json.load(fp) - - for tool in tools: - toolNum = self.obj.Proxy.nextToolNumber() - if library is not None: - for toolBit in library["tools"]: - - if toolBit["path"] == tool.File: - toolNum = toolBit["nr"] - - tc = PathToolControllerGui.Create(name=tool.Label, tool=tool, toolNumber=toolNum) - self.obj.Proxy.addToolController(tc) + selector = ToolBitSelector(compact=True) + if not selector.exec_(): + return + toolbit = selector.get_selected_tool() + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + toolNum = self.obj.Proxy.nextToolNumber() + tc = PathToolControllerGui.Create(name=toolbit.label, tool=toolbit.obj, toolNumber=toolNum) + self.obj.Proxy.addToolController(tc) FreeCAD.ActiveDocument.recompute() self.updateToolController() diff --git a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py index 6b4abdc7e5..2b98f123b3 100644 --- a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py +++ b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py @@ -67,7 +67,6 @@ class JobPreferencesPage: policy = str(self.form.cboOutputPolicy.currentText()) Path.Preferences.setOutputFileDefaults(path, policy) self.saveStockSettings() - self.saveToolsSettings() def saveStockSettings(self): if self.form.stockGroup.isChecked(): @@ -116,9 +115,6 @@ class JobPreferencesPage: else: Path.Preferences.setDefaultStockTemplate("") - def saveToolsSettings(self): - Path.Preferences.setToolsSettings(self.form.toolsAbsolutePaths.isChecked()) - def selectComboEntry(self, widget, text): index = widget.findText(text, QtCore.Qt.MatchFixedString) if index >= 0: @@ -189,7 +185,6 @@ class JobPreferencesPage: self.form.tbOutputFile.clicked.connect(self.browseOutputFile) self.loadStockSettings() - self.loadToolSettings() def loadStockSettings(self): stock = Path.Preferences.defaultStockTemplate() @@ -283,9 +278,6 @@ class JobPreferencesPage: self.form.stockCreateBox.hide() self.form.stockCreateCylinder.hide() - def loadToolSettings(self): - self.form.toolsAbsolutePaths.setChecked(Path.Preferences.toolsStoreAbsolutePaths()) - def getPostProcessor(self, name): if not name in self.processor: processor = PostProcessorFactory.get_post_processor(None, name) diff --git a/src/Mod/CAM/Path/Main/Sanity/Sanity.py b/src/Mod/CAM/Path/Main/Sanity/Sanity.py index 974ab774da..14aa2a0bea 100644 --- a/src/Mod/CAM/Path/Main/Sanity/Sanity.py +++ b/src/Mod/CAM/Path/Main/Sanity/Sanity.py @@ -368,8 +368,8 @@ class CAMSanity: ) continue # skip old-style tools tooldata = data.setdefault(str(TC.ToolNumber), {}) - bitshape = tooldata.setdefault("BitShape", "") - if bitshape not in ["", TC.Tool.BitShape]: + bitshape = tooldata.setdefault("ShapeType", "") + if bitshape not in ["", TC.Tool.ShapeType]: data["squawkData"].append( self.squawk( "CAMSanity", @@ -379,18 +379,18 @@ class CAMSanity: squawkType="CAUTION", ) ) - tooldata["bitShape"] = TC.Tool.BitShape + tooldata["bitShape"] = TC.Tool.ShapeType tooldata["description"] = TC.Tool.Label tooldata["manufacturer"] = "" tooldata["url"] = "" tooldata["inspectionNotes"] = "" tooldata["diameter"] = str(TC.Tool.Diameter) - tooldata["shape"] = TC.Tool.ShapeName + tooldata["shape"] = TC.Tool.ShapeType tooldata["partNumber"] = "" - if os.path.isfile(TC.Tool.BitShape): - imagedata = TC.Tool.Proxy.getBitThumbnail(TC.Tool) + if os.path.isfile(TC.Tool.ShapeType): + imagedata = TC.Tool.Proxy.get_thumbnail() else: imagedata = None data["squawkData"].append( diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 88dee7a4dc..326c9078e3 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -24,6 +24,10 @@ import FreeCAD import Path import glob import os +import pathlib +from collections import defaultdict +from typing import Optional + if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -31,8 +35,11 @@ if False: else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + translate = FreeCAD.Qt.translate +PreferencesGroup = "User parameter:BaseApp/Preferences/Mod/CAM" + DefaultFilePath = "DefaultFilePath" DefaultJobTemplate = "DefaultJobTemplate" DefaultStockTemplate = "DefaultStockTemplate" @@ -44,17 +51,9 @@ PostProcessorBlacklist = "PostProcessorBlacklist" PostProcessorOutputFile = "PostProcessorOutputFile" PostProcessorOutputPolicy = "PostProcessorOutputPolicy" -LastPathToolBit = "LastPathToolBit" -LastPathToolLibrary = "LastPathToolLibrary" -LastPathToolShape = "LastPathToolShape" -LastPathToolTable = "LastPathToolTable" - -LastFileToolBit = "LastFileToolBit" -LastFileToolLibrary = "LastFileToolLibrary" -LastFileToolShape = "LastFileToolShape" - -UseAbsoluteToolPaths = "UseAbsoluteToolPaths" -# OpenLastLibrary = "OpenLastLibrary" +ToolGroup = PreferencesGroup + "/Tools" +ToolPath = "ToolPath" +LastToolLibrary = "LastToolLibrary" # Linear tolerance to use when generating Paths, eg when tessellating geometry GeometryTolerance = "GeometryTolerance" @@ -69,18 +68,84 @@ EnableExperimentalFeatures = "EnableExperimentalFeatures" EnableAdvancedOCLFeatures = "EnableAdvancedOCLFeatures" +_observers = defaultdict(list) # maps group name to callback functions + + +def _add_group_observer(group, callback): + """Add an observer for any changes on the given parameter group""" + _observers[group].append(callback) + + +def _emit_change(group, *args): + for cb in _observers[group]: + cb(group, *args) + + def preferences(): - return FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + return FreeCAD.ParamGet(PreferencesGroup) + + +def tool_preferences(): + return FreeCAD.ParamGet(ToolGroup) + + +def addToolPreferenceObserver(callback): + _add_group_observer(ToolGroup, callback) def pathPostSourcePath(): return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Path/Post/") -def pathDefaultToolsPath(sub=None): - if sub: - return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Tools/", sub) - return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Tools/") +def getBuiltinToolPath() -> pathlib.Path: + home = pathlib.Path(FreeCAD.getHomePath()) + return home / "Mod" / "CAM" / "Tools" + + +def getBuiltinLibraryPath() -> pathlib.Path: + return getBuiltinToolPath() / "Library" + + +def getBuiltinShapePath() -> pathlib.Path: + return getBuiltinToolPath() / "Shape" + + +def getBuiltinToolBitPath() -> pathlib.Path: + return getBuiltinToolPath() / "Bit" + + +def getDefaultAssetPath(): + config = pathlib.Path(FreeCAD.ConfigGet("UserConfigPath")) + return config / "Mod" / "CAM" / "Tools" + + +def getAssetPath() -> pathlib.Path: + pref = tool_preferences() + default = getDefaultAssetPath() + path = pref.GetString(ToolPath, str(default)) + return pathlib.Path(path or default) + + +def setAssetPath(path: pathlib.Path): + assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}" + pref = tool_preferences() + pref.SetString(ToolPath, str(path)) + _emit_change(ToolGroup, ToolPath, path) + + +def getToolBitPath() -> pathlib.Path: + return getAssetPath() / "Bit" + + +def getLastToolLibrary() -> Optional[str]: + pref = tool_preferences() + return pref.GetString(LastToolLibrary) or None + + +def setLastToolLibrary(name: str): + assert isinstance(name, str), f"Library name '{name}' is not a string" + pref = tool_preferences() + pref.SetString(LastToolLibrary, name) def allAvailablePostProcessors(): @@ -161,26 +226,6 @@ def searchPathsPost(): return paths -def searchPathsTool(sub): - paths = [] - paths.append(os.path.join(FreeCAD.getHomePath(), "Mod", "CAM", "Tools", sub)) - return paths - - -def toolsStoreAbsolutePaths(): - return preferences().GetBool(UseAbsoluteToolPaths, False) - - -# def toolsOpenLastLibrary(): -# return preferences().GetBool(OpenLastLibrary, False) - - -def setToolsSettings(relative): - pref = preferences() - pref.SetBool(UseAbsoluteToolPaths, relative) - # pref.SetBool(OpenLastLibrary, lastlibrary) - - def defaultJobTemplate(): template = preferences().GetString(DefaultJobTemplate) if "xml" not in template: @@ -284,65 +329,3 @@ def setPreferencesAdvanced(ocl, warnSpeeds, warnRapids, warnModes, warnOCL, warn preferences().SetBool(WarningSuppressSelectionMode, warnModes) preferences().SetBool(WarningSuppressOpenCamLib, warnOCL) preferences().SetBool(WarningSuppressVelocity, warnVelocity) - - -def lastFileToolLibrary(): - filename = preferences().GetString(LastFileToolLibrary) - if filename.endswith(".fctl") and os.path.isfile(filename): - return filename - - libpath = preferences().GetString(LastPathToolLibrary, pathDefaultToolsPath("Library")) - libFiles = [f for f in glob.glob(libpath + "/*.fctl")] - libFiles.sort() - if len(libFiles) >= 1: - filename = libFiles[0] - setLastFileToolLibrary(filename) - Path.Log.track(filename) - return filename - else: - return None - - -def setLastFileToolLibrary(path): - Path.Log.track(path) - if os.path.isfile(path): # keep the path and file in sync - preferences().SetString(LastPathToolLibrary, os.path.split(path)[0]) - return preferences().SetString(LastFileToolLibrary, path) - - -def lastPathToolBit(): - return preferences().GetString(LastPathToolBit, pathDefaultToolsPath("Bit")) - - -def setLastPathToolBit(path): - return preferences().SetString(LastPathToolBit, path) - - -def lastPathToolLibrary(): - Path.Log.track() - return preferences().GetString(LastPathToolLibrary, pathDefaultToolsPath("Library")) - - -def setLastPathToolLibrary(path): - Path.Log.track(path) - curLib = lastFileToolLibrary() - Path.Log.debug("curLib: {}".format(curLib)) - if curLib and os.path.split(curLib)[0] != path: - setLastFileToolLibrary("") # a path is known but not specific file - return preferences().SetString(LastPathToolLibrary, path) - - -def lastPathToolShape(): - return preferences().GetString(LastPathToolShape, pathDefaultToolsPath("Shape")) - - -def setLastPathToolShape(path): - return preferences().SetString(LastPathToolShape, path) - - -def lastPathToolTable(): - return preferences().GetString(LastPathToolTable, "") - - -def setLastPathToolTable(table): - return preferences().SetString(LastPathToolTable, table) diff --git a/src/Mod/CAM/Path/Tool/Bit.py b/src/Mod/CAM/Path/Tool/Bit.py deleted file mode 100644 index b2b2905128..0000000000 --- a/src/Mod/CAM/Path/Tool/Bit.py +++ /dev/null @@ -1,500 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * * -# * 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 FreeCAD -import Path -import Path.Base.Util as PathUtil -import Path.Base.PropertyBag as PathPropertyBag -import json -import os -import zipfile -from PySide.QtCore import QT_TRANSLATE_NOOP - -# lazily loaded modules -from lazy_loader.lazy_loader import LazyLoader - -Part = LazyLoader("Part", globals(), "Part") - -__title__ = "Tool bits." -__author__ = "sliptonic (Brad Collette)" -__url__ = "https://www.freecad.org" -__doc__ = "Class to deal with and represent a tool bit." - -PropertyGroupShape = "Shape" - -_DebugFindTool = False - - -if False: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - - -def _findToolFile(name, containerFile, typ): - Path.Log.track(name) - if os.path.exists(name): # absolute reference - return name - - if containerFile: - rootPath = os.path.dirname(os.path.dirname(containerFile)) - paths = [os.path.join(rootPath, typ)] - else: - paths = [] - paths.extend(Path.Preferences.searchPathsTool(typ)) - - def _findFile(path, name): - Path.Log.track(path, name) - fullPath = os.path.join(path, name) - if os.path.exists(fullPath): - return (True, fullPath) - for root, ds, fs in os.walk(path): - for d in ds: - found, fullPath = _findFile(d, name) - if found: - return (True, fullPath) - return (False, None) - - for p in paths: - found, path = _findFile(p, name) - if found: - return path - return None - - -def findToolShape(name, path=None): - """findToolShape(name, path) ... search for name, if relative path look in path""" - Path.Log.track(name, path) - return _findToolFile(name, path, "Shape") - - -def findToolBit(name, path=None): - """findToolBit(name, path) ... search for name, if relative path look in path""" - Path.Log.track(name, path) - if name.endswith(".fctb"): - return _findToolFile(name, path, "Bit") - return _findToolFile("{}.fctb".format(name), path, "Bit") - - -# Only used in ToolBit unit test module: TestPathToolBit.py -def findToolLibrary(name, path=None): - """findToolLibrary(name, path) ... search for name, if relative path look in path""" - Path.Log.track(name, path) - if name.endswith(".fctl"): - return _findToolFile(name, path, "Library") - return _findToolFile("{}.fctl".format(name), path, "Library") - - -def _findRelativePath(path, typ): - Path.Log.track(path, typ) - relative = path - for p in Path.Preferences.searchPathsTool(typ): - if path.startswith(p): - p = path[len(p) :] - if os.path.sep == p[0]: - p = p[1:] - if len(p) < len(relative): - relative = p - return relative - - -# Unused due to bug fix related to relative paths -""" -def findRelativePathShape(path): - return _findRelativePath(path, 'Shape') - - -def findRelativePathTool(path): - return _findRelativePath(path, 'Bit') -""" - - -def findRelativePathLibrary(path): - return _findRelativePath(path, "Library") - - -class ToolBit(object): - def __init__(self, obj, shapeFile, path=None): - Path.Log.track(obj.Label, shapeFile, path) - self.obj = obj - obj.addProperty( - "App::PropertyFile", - "BitShape", - "Base", - QT_TRANSLATE_NOOP("App::Property", "Shape for bit shape"), - ) - obj.addProperty( - "App::PropertyLink", - "BitBody", - "Base", - QT_TRANSLATE_NOOP("App::Property", "The parametrized body representing the tool bit"), - ) - obj.addProperty( - "App::PropertyFile", - "File", - "Base", - QT_TRANSLATE_NOOP("App::Property", "The file of the tool"), - ) - obj.addProperty( - "App::PropertyString", - "ShapeName", - "Base", - QT_TRANSLATE_NOOP("App::Property", "The name of the shape file"), - ) - obj.addProperty( - "App::PropertyStringList", - "BitPropertyNames", - "Base", - QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"), - ) - - if path: - obj.File = path - if shapeFile is None: - obj.BitShape = "endmill.fcstd" - self._setupBitShape(obj) - self.unloadBitBody(obj) - else: - obj.BitShape = shapeFile - self._setupBitShape(obj) - self.onDocumentRestored(obj) - - def dumps(self): - return None - - def loads(self, state): - for obj in FreeCAD.ActiveDocument.Objects: - if hasattr(obj, "Proxy") and obj.Proxy == self: - self.obj = obj - break - return None - - def onDocumentRestored(self, obj): - # when files are shared it is essential to be able to change/set the shape file, - # otherwise the file is hard to use - # obj.setEditorMode('BitShape', 1) - obj.setEditorMode("BitBody", 2) - obj.setEditorMode("File", 1) - obj.setEditorMode("Shape", 2) - if not hasattr(obj, "BitPropertyNames"): - obj.addProperty( - "App::PropertyStringList", - "BitPropertyNames", - "Base", - QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"), - ) - propNames = [] - for prop in obj.PropertiesList: - if obj.getGroupOfProperty(prop) == "Bit": - val = obj.getPropertyByName(prop) - typ = obj.getTypeIdOfProperty(prop) - dsc = obj.getDocumentationOfProperty(prop) - - obj.removeProperty(prop) - obj.addProperty(typ, prop, PropertyGroupShape, dsc) - - PathUtil.setProperty(obj, prop, val) - propNames.append(prop) - elif obj.getGroupOfProperty(prop) == "Attribute": - propNames.append(prop) - obj.BitPropertyNames = propNames - obj.setEditorMode("BitPropertyNames", 2) - - for prop in obj.BitPropertyNames: - if obj.getGroupOfProperty(prop) == PropertyGroupShape: - # properties in the Shape group can only be modified while the actual - # shape is loaded, so we have to disable direct property editing - obj.setEditorMode(prop, 1) - else: - # all other custom properties can and should be edited directly in the - # property editor widget, not much value in re-implementing that - obj.setEditorMode(prop, 0) - - def onChanged(self, obj, prop): - Path.Log.track(obj.Label, prop) - if prop == "BitShape" and "Restore" not in obj.State: - self._setupBitShape(obj) - - def onDelete(self, obj, arg2=None): - Path.Log.track(obj.Label) - self.unloadBitBody(obj) - obj.Document.removeObject(obj.Name) - - def _updateBitShape(self, obj, properties=None): - if obj.BitBody is not None: - for attributes in [ - o - for o in obj.BitBody.Group - if hasattr(o, "Proxy") and hasattr(o.Proxy, "getCustomProperties") - ]: - for prop in attributes.Proxy.getCustomProperties(): - # the property might not exist in our local object (new attribute in shape) - # for such attributes we just keep the default - if hasattr(obj, prop): - setattr(attributes, prop, obj.getPropertyByName(prop)) - else: - # if the template shape has a new attribute defined we should add that - # to the local object - self._setupProperty(obj, prop, attributes) - propNames = obj.BitPropertyNames - propNames.append(prop) - obj.BitPropertyNames = propNames - self._copyBitShape(obj) - - def _copyBitShape(self, obj): - obj.Document.recompute() - if obj.BitBody and obj.BitBody.Shape: - obj.Shape = obj.BitBody.Shape - else: - obj.Shape = Part.Shape() - - def _loadBitBody(self, obj, path=None): - Path.Log.track(obj.Label, path) - p = path if path else obj.BitShape - docOpened = False - doc = None - for d in FreeCAD.listDocuments(): - if FreeCAD.getDocument(d).FileName == p: - doc = FreeCAD.getDocument(d) - break - if doc is None: - p = findToolShape(p, path if path else obj.File) - if p is None: - raise FileNotFoundError - - if not path and p != obj.BitShape: - obj.BitShape = p - Path.Log.debug("ToolBit {} using shape file: {}".format(obj.Label, p)) - doc = FreeCAD.openDocument(p, True) - obj.ShapeName = doc.Name - docOpened = True - else: - Path.Log.debug("ToolBit {} already open: {}".format(obj.Label, doc)) - return (doc, docOpened) - - def _removeBitBody(self, obj): - if obj.BitBody: - obj.BitBody.removeObjectsFromDocument() - obj.Document.removeObject(obj.BitBody.Name) - obj.BitBody = None - - def _deleteBitSetup(self, obj): - Path.Log.track(obj.Label) - self._removeBitBody(obj) - self._copyBitShape(obj) - for prop in obj.BitPropertyNames: - obj.removeProperty(prop) - - def loadBitBody(self, obj, force=False): - if force or not obj.BitBody: - activeDoc = FreeCAD.ActiveDocument - if force: - self._removeBitBody(obj) - (doc, opened) = self._loadBitBody(obj) - obj.BitBody = obj.Document.copyObject(doc.RootObjects[0], True) - if opened: - FreeCAD.setActiveDocument(activeDoc.Name) - FreeCAD.closeDocument(doc.Name) - self._updateBitShape(obj) - - def unloadBitBody(self, obj): - self._removeBitBody(obj) - - def _setupProperty(self, obj, prop, orig): - # extract property parameters and values so it can be copied - val = orig.getPropertyByName(prop) - typ = orig.getTypeIdOfProperty(prop) - grp = orig.getGroupOfProperty(prop) - dsc = orig.getDocumentationOfProperty(prop) - - obj.addProperty(typ, prop, grp, dsc) - if "App::PropertyEnumeration" == typ: - setattr(obj, prop, orig.getEnumerationsOfProperty(prop)) - - obj.setEditorMode(prop, 1) - PathUtil.setProperty(obj, prop, val) - - def _setupBitShape(self, obj, path=None): - Path.Log.track(obj.Label) - - activeDoc = FreeCAD.ActiveDocument - try: - (doc, docOpened) = self._loadBitBody(obj, path) - except FileNotFoundError: - Path.Log.error( - "Could not find shape file {} for tool bit {}".format(obj.BitShape, obj.Label) - ) - return - - obj.Label = doc.RootObjects[0].Label - self._deleteBitSetup(obj) - bitBody = obj.Document.copyObject(doc.RootObjects[0], True) - - docName = doc.Name - if docOpened: - FreeCAD.setActiveDocument(activeDoc.Name) - FreeCAD.closeDocument(doc.Name) - - if bitBody.ViewObject: - bitBody.ViewObject.Visibility = False - - Path.Log.debug("bitBody.{} ({}): {}".format(bitBody.Label, bitBody.Name, type(bitBody))) - - propNames = [] - for attributes in [o for o in bitBody.Group if PathPropertyBag.IsPropertyBag(o)]: - Path.Log.debug("Process properties from {}".format(attributes.Label)) - for prop in attributes.Proxy.getCustomProperties(): - self._setupProperty(obj, prop, attributes) - propNames.append(prop) - if not propNames: - Path.Log.error( - "Did not find a PropertyBag in {} - not a ToolBit shape?".format(docName) - ) - - # has to happen last because it could trigger op.execute evaluations - obj.BitPropertyNames = propNames - obj.BitBody = bitBody - self._copyBitShape(obj) - - def toolShapeProperties(self, obj): - """toolShapeProperties(obj) ... return all properties defining it's shape""" - return sorted( - [ - prop - for prop in obj.BitPropertyNames - if obj.getGroupOfProperty(prop) == PropertyGroupShape - ] - ) - - def toolAdditionalProperties(self, obj): - """toolShapeProperties(obj) ... return all properties unrelated to it's shape""" - return sorted( - [ - prop - for prop in obj.BitPropertyNames - if obj.getGroupOfProperty(prop) != PropertyGroupShape - ] - ) - - def toolGroupsAndProperties(self, obj, includeShape=True): - """toolGroupsAndProperties(obj) ... returns a dictionary of group names with a list of property names.""" - category = {} - for prop in obj.BitPropertyNames: - group = obj.getGroupOfProperty(prop) - if includeShape or group != PropertyGroupShape: - properties = category.get(group, []) - properties.append(prop) - category[group] = properties - return category - - def getBitThumbnail(self, obj): - if obj.BitShape: - path = findToolShape(obj.BitShape) - if path: - with open(path, "rb") as fd: - try: - zf = zipfile.ZipFile(fd) - pf = zf.open("thumbnails/Thumbnail.png", "r") - data = pf.read() - pf.close() - return data - except KeyError: - pass - return None - - def saveToFile(self, obj, path, setFile=True): - Path.Log.track(path) - try: - with open(path, "w") as fp: - json.dump(self.templateAttrs(obj), fp, indent=" ") - if setFile: - obj.File = path - return True - except (OSError, IOError) as e: - Path.Log.error("Could not save tool {} to {} ({})".format(obj.Label, path, e)) - raise - - def templateAttrs(self, obj): - attrs = {} - attrs["version"] = 2 - attrs["name"] = obj.Label - if Path.Preferences.toolsStoreAbsolutePaths(): - attrs["shape"] = obj.BitShape - else: - # attrs['shape'] = findRelativePathShape(obj.BitShape) - # Extract the name of the shape file - __, filShp = os.path.split( - obj.BitShape - ) # __ is an ignored placeholder acknowledged by LGTM - attrs["shape"] = str(filShp) - params = {} - for name in obj.BitPropertyNames: - params[name] = PathUtil.getPropertyValueString(obj, name) - attrs["parameter"] = params - params = {} - attrs["attribute"] = params - return attrs - - -def Declaration(path): - Path.Log.track(path) - with open(path, "r") as fp: - return json.load(fp) - - -class ToolBitFactory(object): - def CreateFromAttrs(self, attrs, name="ToolBit", path=None): - Path.Log.track(attrs, path) - obj = Factory.Create(name, attrs["shape"], path) - obj.Label = attrs["name"] - params = attrs["parameter"] - for prop in params: - PathUtil.setProperty(obj, prop, params[prop]) - attributes = attrs["attribute"] - for att in attributes: - PathUtil.setProperty(obj, att, attributes[att]) - obj.Proxy._updateBitShape(obj) - obj.Proxy.unloadBitBody(obj) - return obj - - def CreateFrom(self, path, name="ToolBit"): - Path.Log.track(name, path) - - if not os.path.isfile(path): - raise FileNotFoundError(f"{path} not found") - try: - data = Declaration(path) - bit = Factory.CreateFromAttrs(data, name, path) - return bit - except (OSError, IOError) as e: - Path.Log.error("%s not a valid tool file (%s)" % (path, e)) - raise - - def Create(self, name="ToolBit", shapeFile=None, path=None): - Path.Log.track(name, shapeFile, path) - obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", name) - obj.Proxy = ToolBit(obj, shapeFile, path) - return obj - - -Factory = ToolBitFactory() diff --git a/src/Mod/CAM/Path/Tool/Controller.py b/src/Mod/CAM/Path/Tool/Controller.py index 8b5e4b3fb5..ebc94ccf82 100644 --- a/src/Mod/CAM/Path/Tool/Controller.py +++ b/src/Mod/CAM/Path/Tool/Controller.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2015 Dan Falck * +# * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -25,7 +26,7 @@ from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD import Path -import Path.Tool.Bit as PathToolBit +from Path.Tool.toolbit import ToolBit import Path.Base.Generator.toolchange as toolchange @@ -113,7 +114,7 @@ class ToolController: self.ensureToolBit(obj) @classmethod - def propertyEnumerations(self, dataType="data"): + def propertyEnumerations(cls, dataType="data"): """helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType. Args: dataType = 'data', 'raw', 'translated' @@ -182,13 +183,11 @@ class ToolController: obj.ToolNumber = int(template.get(ToolControllerTemplate.ToolNumber)) if template.get(ToolControllerTemplate.Tool): self.ensureToolBit(obj) - toolVersion = template.get(ToolControllerTemplate.Tool).get( - ToolControllerTemplate.Version - ) + tool_data = template.get(ToolControllerTemplate.Tool) + toolVersion = tool_data.get(ToolControllerTemplate.Version) if toolVersion == 2: - obj.Tool = PathToolBit.Factory.CreateFromAttrs( - template.get(ToolControllerTemplate.Tool) - ) + toolbit_instance = ToolBit.from_dict(tool_data) + obj.Tool = toolbit_instance.attach_to_doc(doc=obj.Document) else: obj.Tool = None if toolVersion == 1: @@ -230,7 +229,7 @@ class ToolController: attrs[ToolControllerTemplate.HorizRapid] = "%s" % (obj.HorizRapid) attrs[ToolControllerTemplate.SpindleSpeed] = obj.SpindleSpeed attrs[ToolControllerTemplate.SpindleDir] = obj.SpindleDir - attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.templateAttrs(obj.Tool) + attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.to_dict() expressions = [] for expr in obj.ExpressionEngine: Path.Log.debug("%s: %s" % (expr[0], expr[1])) @@ -251,26 +250,9 @@ class ToolController: "toolnumber": obj.ToolNumber, "toollabel": obj.Label, "spindlespeed": obj.SpindleSpeed, - "spindledirection": toolchange.SpindleDirection.OFF, + "spindledirection": obj.Tool.Proxy.get_spindle_direction(), } - if hasattr(obj.Tool, "SpindlePower"): - if not obj.Tool.SpindlePower: - args["spindledirection"] = toolchange.SpindleDirection.OFF - else: - if obj.SpindleDir == "Forward": - args["spindledirection"] = toolchange.SpindleDirection.CW - else: - args["spindledirection"] = toolchange.SpindleDirection.CCW - - elif obj.SpindleDir == "None": - args["spindledirection"] = toolchange.SpindleDirection.OFF - else: - if obj.SpindleDir == "Forward": - args["spindledirection"] = toolchange.SpindleDirection.CW - else: - args["spindledirection"] = toolchange.SpindleDirection.CCW - commands = toolchange.generate(**args) path = Path.Path(commands) @@ -314,7 +296,10 @@ def Create( if assignTool: if not tool: - tool = PathToolBit.Factory.Create() + # Create a default endmill tool bit and attach it to a new DocumentObject + toolbit = ToolBit.from_shape_id("endmill.fcstd") + Path.Log.info(f"Controller.Create: Created toolbit with ID: {toolbit.id}") + tool = toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument) if tool.ViewObject: tool.ViewObject.Visibility = False obj.Tool = tool diff --git a/src/Mod/CAM/Path/Tool/Gui/Bit.py b/src/Mod/CAM/Path/Tool/Gui/Bit.py deleted file mode 100644 index 423e6f9c66..0000000000 --- a/src/Mod/CAM/Path/Tool/Gui/Bit.py +++ /dev/null @@ -1,270 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * * -# * 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 * -# * * -# *************************************************************************** - -from PySide import QtCore, QtGui -from PySide.QtCore import QT_TRANSLATE_NOOP -import FreeCAD -import FreeCADGui -import Path -import Path.Base.Gui.IconViewProvider as PathIconViewProvider -import Path.Tool.Bit as PathToolBit -import Path.Tool.Gui.BitEdit as PathToolBitEdit -import os - -__title__ = "Tool Bit UI" -__author__ = "sliptonic (Brad Collette)" -__url__ = "https://www.freecad.org" -__doc__ = "Task panel editor for a ToolBit" - - -if False: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - -translate = FreeCAD.Qt.translate - - -class ViewProvider(object): - """ViewProvider for a ToolBit. - It's sole job is to provide an icon and invoke the TaskPanel on edit.""" - - def __init__(self, vobj, name): - Path.Log.track(name, vobj.Object) - self.panel = None - self.icon = name - self.obj = vobj.Object - self.vobj = vobj - vobj.Proxy = self - - def attach(self, vobj): - Path.Log.track(vobj.Object) - self.vobj = vobj - self.obj = vobj.Object - - def getIcon(self): - png = self.obj.Proxy.getBitThumbnail(self.obj) - if png: - pixmap = QtGui.QPixmap() - pixmap.loadFromData(png, "PNG") - return QtGui.QIcon(pixmap) - return ":/icons/CAM_ToolBit.svg" - - def dumps(self): - return None - - def loads(self, state): - return None - - def onDelete(self, vobj, arg2=None): - Path.Log.track(vobj.Object.Label) - vobj.Object.Proxy.onDelete(vobj.Object) - - def getDisplayMode(self, mode): - return "Default" - - def _openTaskPanel(self, vobj, deleteOnReject): - Path.Log.track() - self.panel = TaskPanel(vobj, deleteOnReject) - FreeCADGui.Control.closeDialog() - FreeCADGui.Control.showDialog(self.panel) - self.panel.setupUi() - - def setCreate(self, vobj): - Path.Log.track() - self._openTaskPanel(vobj, True) - - def setEdit(self, vobj, mode=0): - self._openTaskPanel(vobj, False) - return True - - def unsetEdit(self, vobj, mode): - FreeCADGui.Control.closeDialog() - self.panel = None - return - - def claimChildren(self): - if self.obj.BitBody: - return [self.obj.BitBody] - return [] - - def doubleClicked(self, vobj): - if os.path.exists(vobj.Object.BitShape): - self.setEdit(vobj) - else: - msg = translate("CAM_Toolbit", "Toolbit cannot be edited: Shapefile not found") - diag = QtGui.QMessageBox(QtGui.QMessageBox.Warning, "Error", msg) - diag.setWindowModality(QtCore.Qt.ApplicationModal) - diag.exec_() - - -class TaskPanel: - """TaskPanel for the SetupSheet - if it is being edited directly.""" - - def __init__(self, vobj, deleteOnReject): - Path.Log.track(vobj.Object.Label) - self.vobj = vobj - self.obj = vobj.Object - self.editor = PathToolBitEdit.ToolBitEditor(self.obj) - self.form = self.editor.form - self.deleteOnReject = deleteOnReject - FreeCAD.ActiveDocument.openTransaction("Edit ToolBit") - - def reject(self): - FreeCAD.ActiveDocument.abortTransaction() - self.editor.reject() - FreeCADGui.Control.closeDialog() - if self.deleteOnReject: - FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit") - self.editor.reject() - FreeCAD.ActiveDocument.removeObject(self.obj.Name) - FreeCAD.ActiveDocument.commitTransaction() - FreeCAD.ActiveDocument.recompute() - - def accept(self): - self.editor.accept() - - FreeCAD.ActiveDocument.commitTransaction() - FreeCADGui.ActiveDocument.resetEdit() - FreeCADGui.Control.closeDialog() - FreeCAD.ActiveDocument.recompute() - - def updateUI(self): - Path.Log.track() - self.editor.updateUI() - - def updateModel(self): - self.editor.updateTool() - FreeCAD.ActiveDocument.recompute() - - def setupUi(self): - self.editor.setupUI() - - -class ToolBitGuiFactory(PathToolBit.ToolBitFactory): - def Create(self, name="ToolBit", shapeFile=None, path=None): - """Create(name = 'ToolBit') ... creates a new tool bit. - It is assumed the tool will be edited immediately so the internal bit body is still attached. - """ - - Path.Log.track(name, shapeFile, path) - FreeCAD.ActiveDocument.openTransaction("Create ToolBit") - tool = PathToolBit.ToolBitFactory.Create(self, name, shapeFile, path) - PathIconViewProvider.Attach(tool.ViewObject, name) - FreeCAD.ActiveDocument.commitTransaction() - return tool - - -def isValidFileName(filename): - print(filename) - try: - with open(filename, "w") as tempfile: - return True - except Exception: - return False - - -def GetNewToolFile(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - - foo = QtGui.QFileDialog.getSaveFileName( - parent, translate("CAM_Toolbit", "Tool"), Path.Preferences.lastPathToolBit(), "*.fctb" - ) - if foo and foo[0]: - if not isValidFileName(foo[0]): - msgBox = QtGui.QMessageBox() - msg = translate("CAM_Toolbit", "Invalid Filename") - msgBox.setText(msg) - msgBox.exec_() - else: - Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0])) - return foo[0] - return None - - -def GetToolFile(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - foo = QtGui.QFileDialog.getOpenFileName( - parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb" - ) - if foo and foo[0]: - Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0])) - return foo[0] - return None - - -def GetToolFiles(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - foo = QtGui.QFileDialog.getOpenFileNames( - parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb" - ) - if foo and foo[0]: - Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0][0])) - return foo[0] - return [] - - -def GetToolShapeFile(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - - location = Path.Preferences.lastPathToolShape() - if os.path.isfile(location): - location = os.path.split(location)[0] - elif not os.path.isdir(location): - location = Path.Preferences.filePath() - - fname = QtGui.QFileDialog.getOpenFileName( - parent, translate("CAM_Toolbit", "Select Tool Shape"), location, "*.fcstd" - ) - if fname and fname[0]: - if fname != location: - newloc = os.path.dirname(fname[0]) - Path.Preferences.setLastPathToolShape(newloc) - return fname[0] - else: - return None - - -def LoadTool(parent=None): - """ - LoadTool(parent=None) ... Open a file dialog to load a tool from a file. - """ - foo = GetToolFile(parent) - return PathToolBit.Factory.CreateFrom(foo) if foo else foo - - -def LoadTools(parent=None): - """ - LoadTool(parent=None) ... Open a file dialog to load a tool from a file. - """ - return [PathToolBit.Factory.CreateFrom(foo) for foo in GetToolFiles(parent)] - - -# Set the factory so all tools are created with UI -PathToolBit.Factory = ToolBitGuiFactory() - -PathIconViewProvider.RegisterViewProvider("ToolBit", ViewProvider) diff --git a/src/Mod/CAM/Path/Tool/Gui/BitEdit.py b/src/Mod/CAM/Path/Tool/Gui/BitEdit.py deleted file mode 100644 index c23af9e196..0000000000 --- a/src/Mod/CAM/Path/Tool/Gui/BitEdit.py +++ /dev/null @@ -1,275 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * * -# * 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 * -# * * -# *************************************************************************** - -from PySide import QtCore, QtGui -import FreeCADGui -import Path -import Path.Base.Gui.PropertyEditor as PathPropertyEditor -import Path.Base.Gui.Util as PathGuiUtil -import Path.Base.Util as PathUtil -import os -import re - - -if False: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - - -class _Delegate(QtGui.QStyledItemDelegate): - """Handles the creation of an appropriate editing widget for a given property.""" - - ObjectRole = QtCore.Qt.UserRole + 1 - PropertyRole = QtCore.Qt.UserRole + 2 - EditorRole = QtCore.Qt.UserRole + 3 - - def createEditor(self, parent, option, index): - editor = index.data(self.EditorRole) - if editor is None: - obj = index.data(self.ObjectRole) - prp = index.data(self.PropertyRole) - editor = PathPropertyEditor.Editor(obj, prp) - index.model().setData(index, editor, self.EditorRole) - return editor.widget(parent) - - def setEditorData(self, widget, index): - # called to update the widget with the current data - index.data(self.EditorRole).setEditorData(widget) - - def setModelData(self, widget, model, index): - # called to update the model with the data from the widget - editor = index.data(self.EditorRole) - editor.setModelData(widget) - index.model().setData( - index, - PathUtil.getPropertyValueString(editor.obj, editor.prop), - QtCore.Qt.DisplayRole, - ) - - -class ToolBitEditor(object): - """UI and controller for editing a ToolBit. - The controller embeds the UI to the parentWidget which has to have a - layout attached to it. - """ - - def __init__(self, tool, parentWidget=None, loadBitBody=True): - Path.Log.track() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui") - - if parentWidget: - self.form.setParent(parentWidget) - parentWidget.layout().addWidget(self.form) - - self.tool = tool - self.loadbitbody = loadBitBody - if not tool.BitShape: - self.tool.BitShape = "endmill.fcstd" - - if self.loadbitbody: - self.tool.Proxy.loadBitBody(self.tool) - - # remove example widgets - layout = self.form.bitParams.layout() - for i in range(layout.rowCount() - 1, -1, -1): - layout.removeRow(i) - # used to track property widgets and editors - self.widgets = [] - - self.setupTool(self.tool) - self.setupAttributes(self.tool) - - def setupTool(self, tool): - Path.Log.track() - # Can't delete and add fields to the form because of dangling references in case of - # a focus change. see https://forum.freecad.org/viewtopic.php?f=10&t=52246#p458583 - # Instead we keep widgets once created and use them for new properties, and hide all - # which aren't being needed anymore. - - def labelText(name): - return re.sub(r"([A-Z][a-z]+)", r" \1", re.sub(r"([A-Z]+)", r" \1", name)) - - layout = self.form.bitParams.layout() - ui = FreeCADGui.UiLoader() - - # for all properties either assign them to existing labels and editors - # or create additional ones for them if not enough have already been - # created. - usedRows = 0 - for nr, name in enumerate(tool.Proxy.toolShapeProperties(tool)): - if nr < len(self.widgets): - Path.Log.debug("reuse row: {} [{}]".format(nr, name)) - label, qsb, editor = self.widgets[nr] - label.setText(labelText(name)) - editor.attachTo(tool, name) - label.show() - qsb.show() - else: - qsb = ui.createWidget("Gui::QuantitySpinBox") - editor = PathGuiUtil.QuantitySpinBox(qsb, tool, name) - label = QtGui.QLabel(labelText(name)) - self.widgets.append((label, qsb, editor)) - Path.Log.debug("create row: {} [{}] {}".format(nr, name, type(qsb))) - if hasattr(qsb, "editingFinished"): - qsb.editingFinished.connect(self.updateTool) - - if nr >= layout.rowCount(): - layout.addRow(label, qsb) - usedRows = usedRows + 1 - - # hide all rows which aren't being used - Path.Log.track(usedRows, len(self.widgets)) - for i in range(usedRows, len(self.widgets)): - label, qsb, editor = self.widgets[i] - label.hide() - qsb.hide() - editor.attachTo(None) - Path.Log.debug(" hide row: {}".format(i)) - - img = tool.Proxy.getBitThumbnail(tool) - if img: - self.form.image.setPixmap(QtGui.QPixmap(QtGui.QImage.fromData(img))) - else: - self.form.image.setPixmap(QtGui.QPixmap()) - - def setupAttributes(self, tool): - Path.Log.track() - - setup = True - if not hasattr(self, "delegate"): - self.delegate = _Delegate(self.form.attrTree) - self.model = QtGui.QStandardItemModel(self.form.attrTree) - self.model.setHorizontalHeaderLabels(["Property", "Value"]) - else: - self.model.removeRows(0, self.model.rowCount()) - setup = False - - attributes = tool.Proxy.toolGroupsAndProperties(tool, False) - for name in attributes: - group = QtGui.QStandardItem() - group.setData(name, QtCore.Qt.EditRole) - group.setEditable(False) - for prop in attributes[name]: - label = QtGui.QStandardItem() - label.setData(prop, QtCore.Qt.EditRole) - label.setEditable(False) - - value = QtGui.QStandardItem() - value.setData(PathUtil.getPropertyValueString(tool, prop), QtCore.Qt.DisplayRole) - value.setData(tool, _Delegate.ObjectRole) - value.setData(prop, _Delegate.PropertyRole) - - group.appendRow([label, value]) - self.model.appendRow(group) - - if setup: - self.form.attrTree.setModel(self.model) - self.form.attrTree.setItemDelegateForColumn(1, self.delegate) - self.form.attrTree.expandAll() - self.form.attrTree.resizeColumnToContents(0) - self.form.attrTree.resizeColumnToContents(1) - # self.form.attrTree.collapseAll() - - def accept(self): - Path.Log.track() - self.refresh() - self.tool.Proxy.unloadBitBody(self.tool) - - def reject(self): - Path.Log.track() - self.tool.Proxy.unloadBitBody(self.tool) - - def updateUI(self): - Path.Log.track() - self.form.toolName.setText(self.tool.Label) - self.form.shapePath.setText(self.tool.BitShape) - - for lbl, qsb, editor in self.widgets: - editor.updateSpinBox() - - def _updateBitShape(self, shapePath): - # Only need to go through this exercise if the shape actually changed. - if self.tool.BitShape != shapePath: - # Before setting a new bitshape we need to make sure that none of - # editors fires an event and tries to access its old property, which - # might not exist anymore. - for lbl, qsb, editor in self.widgets: - editor.attachTo(self.tool, "File") - self.tool.BitShape = shapePath - self.setupTool(self.tool) - self.form.toolName.setText(self.tool.Label) - if self.tool.BitBody and self.tool.BitBody.ViewObject: - if not self.tool.BitBody.ViewObject.Visibility: - self.tool.BitBody.ViewObject.Visibility = True - self.setupAttributes(self.tool) - return True - return False - - def updateShape(self): - Path.Log.track() - shapePath = str(self.form.shapePath.text()) - # Only need to go through this exercise if the shape actually changed. - if self._updateBitShape(shapePath): - for lbl, qsb, editor in self.widgets: - editor.updateSpinBox() - - def updateTool(self): - Path.Log.track() - - label = str(self.form.toolName.text()) - shape = str(self.form.shapePath.text()) - if self.tool.Label != label: - self.tool.Label = label - self._updateBitShape(shape) - - for lbl, qsb, editor in self.widgets: - editor.updateProperty() - - self.tool.Proxy._updateBitShape(self.tool) - - def refresh(self): - Path.Log.track() - self.form.blockSignals(True) - self.updateTool() - self.updateUI() - self.form.blockSignals(False) - - def selectShape(self): - Path.Log.track() - path = self.tool.BitShape - if not path: - path = Path.Preferences.lastPathToolShape() - foo = QtGui.QFileDialog.getOpenFileName(self.form, "Path - Tool Shape", path, "*.fcstd") - if foo and foo[0]: - Path.Preferences.setLastPathToolShape(os.path.dirname(foo[0])) - self.form.shapePath.setText(foo[0]) - self.updateShape() - - def setupUI(self): - Path.Log.track() - self.updateUI() - - self.form.toolName.editingFinished.connect(self.refresh) - self.form.shapePath.editingFinished.connect(self.updateShape) - self.form.shapeSet.clicked.connect(self.selectShape) diff --git a/src/Mod/CAM/Path/Tool/Gui/BitLibrary.py b/src/Mod/CAM/Path/Tool/Gui/BitLibrary.py deleted file mode 100644 index 8604a1575c..0000000000 --- a/src/Mod/CAM/Path/Tool/Gui/BitLibrary.py +++ /dev/null @@ -1,1045 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * Copyright (c) 2020 Schildkroet * -# * * -# * 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 FreeCAD -import FreeCADGui -import Path -import Path.Tool.Bit as PathToolBit -import Path.Tool.Gui.Bit as PathToolBitGui -import Path.Tool.Gui.BitEdit as PathToolBitEdit -import Path.Tool.Gui.Controller as PathToolControllerGui -import PathGui -import PathScripts.PathUtilsGui as PathUtilsGui -import PySide -import glob -import json -import os -import shutil -import uuid as UUID - -from functools import partial - -from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap -from PySide.QtCore import Qt - - -if False: - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) -else: - Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) - - -_UuidRole = PySide.QtCore.Qt.UserRole + 1 -_PathRole = PySide.QtCore.Qt.UserRole + 2 - - -translate = FreeCAD.Qt.translate - - -def checkWorkingDir(): - """Check the tool library directory writable and configure a new library if required""" - # users shouldn't use the example toolbits and libraries. - # working directory should be writable - Path.Log.track() - - workingdir = os.path.dirname(Path.Preferences.lastPathToolLibrary()) - defaultdir = os.path.dirname(Path.Preferences.pathDefaultToolsPath()) - - Path.Log.debug("workingdir: {} defaultdir: {}".format(workingdir, defaultdir)) - - dirOK = lambda: workingdir != defaultdir and (os.access(workingdir, os.W_OK)) - - if dirOK(): - return True - - qm = PySide.QtGui.QMessageBox - ret = qm.question( - None, - "", - translate("CAM_ToolBit", "Toolbit working directory not set up. Do that now?"), - qm.Yes | qm.No, - ) - - if ret == qm.No: - return False - - msg = translate("CAM_ToolBit", "Choose a writable location for your toolbits") - while not dirOK(): - workingdir = PySide.QtGui.QFileDialog.getExistingDirectory( - None, msg, Path.Preferences.filePath() - ) - - if workingdir[-8:] == os.path.sep + "Library": - workingdir = workingdir[:-8] # trim off trailing /Library if user chose it - - Path.Preferences.setLastPathToolLibrary("{}{}Library".format(workingdir, os.path.sep)) - Path.Preferences.setLastPathToolBit("{}{}Bit".format(workingdir, os.path.sep)) - Path.Log.debug("setting workingdir to: {}".format(workingdir)) - - # Copy only files of default Path/Tool folder to working directory - # (targeting the README.md help file) - src_toolfiles = os.listdir(defaultdir) - for file_name in src_toolfiles: - if file_name in ["README.md"]: - full_file_name = os.path.join(defaultdir, file_name) - if os.path.isfile(full_file_name): - shutil.copy(full_file_name, workingdir) - - # Determine which subdirectories are missing - subdirlist = ["Bit", "Library", "Shape"] - mode = 0o777 - for dir in subdirlist.copy(): - subdir = "{}{}{}".format(workingdir, os.path.sep, dir) - if os.path.exists(subdir): - subdirlist.remove(dir) - - # Query user for creation permission of any missing subdirectories - if len(subdirlist) >= 1: - needed = ", ".join([str(d) for d in subdirlist]) - qm = PySide.QtGui.QMessageBox - ret = qm.question( - None, - "", - translate( - "CAM_ToolBit", - "Toolbit Working directory {} needs these sudirectories:\n {} \n Create them?", - ).format(workingdir, needed), - qm.Yes | qm.No, - ) - - if ret == qm.No: - return False - else: - # Create missing subdirectories if user agrees to creation - for dir in subdirlist: - subdir = "{}{}{}".format(workingdir, os.path.sep, dir) - os.mkdir(subdir, mode) - # Query user to copy example files into subdirectories created - if dir != "Shape": - qm = PySide.QtGui.QMessageBox - ret = qm.question( - None, - "", - translate("CAM_ToolBit", "Copy example files to new {} directory?").format( - dir - ), - qm.Yes | qm.No, - ) - if ret == qm.Yes: - src = "{}{}{}".format(defaultdir, os.path.sep, dir) - src_files = os.listdir(src) - for file_name in src_files: - full_file_name = os.path.join(src, file_name) - if os.path.isfile(full_file_name): - shutil.copy(full_file_name, subdir) - - # if no library is set, choose the first one in the Library directory - if Path.Preferences.lastFileToolLibrary() is None: - libFiles = [ - f for f in glob.glob(Path.Preferences.lastPathToolLibrary() + os.path.sep + "*.fctl") - ] - Path.Preferences.setLastFileToolLibrary(libFiles[0]) - - return True - - -class _TableView(PySide.QtGui.QTableView): - """Subclass of QTableView to support rearrange and copying of ToolBits""" - - def __init__(self, parent): - PySide.QtGui.QTableView.__init__(self, parent) - self.setDragEnabled(False) - self.setAcceptDrops(False) - self.setDropIndicatorShown(False) - self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly) - self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction) - self.setSortingEnabled(True) - self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows) - self.verticalHeader().hide() - - def supportedDropActions(self): - return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction] - - def _uuidOfRow(self, row): - model = self.toolModel() - return model.data(model.index(row, 0), _UuidRole) - - def _rowWithUuid(self, uuid): - model = self.toolModel() - for row in range(model.rowCount()): - if self._uuidOfRow(row) == uuid: - return row - return None - - def _copyTool(self, uuid_, dstRow): - model = self.toolModel() - model.insertRow(dstRow) - srcRow = self._rowWithUuid(uuid_) - for col in range(model.columnCount()): - srcItem = model.item(srcRow, col) - - model.setData( - model.index(dstRow, col), - srcItem.data(PySide.QtCore.Qt.EditRole), - PySide.QtCore.Qt.EditRole, - ) - if col == 0: - model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole) - # Even a clone of a tool gets its own uuid so it can be identified when - # rearranging the order or inserting/deleting rows - model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole) - else: - model.item(dstRow, col).setEditable(False) - - def _copyTools(self, uuids, dst): - for i, uuid in enumerate(uuids): - self._copyTool(uuid, dst + i) - - def dropEvent(self, event): - """Handle drop events on the tool table""" - Path.Log.track() - mime = event.mimeData() - data = mime.data("application/x-qstandarditemmodeldatalist") - stream = PySide.QtCore.QDataStream(data) - srcRows = [] - while not stream.atEnd(): - row = stream.readInt32() - srcRows.append(row) - - # get the uuids of all srcRows - model = self.toolModel() - srcUuids = [self._uuidOfRow(row) for row in set(srcRows)] - destRow = self.rowAt(event.pos().y()) - - self._copyTools(srcUuids, destRow) - if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction(): - for uuid in srcUuids: - model.removeRow(self._rowWithUuid(uuid)) - - -class ModelFactory: - """Helper class to generate qtdata models for toolbit libraries""" - - @staticmethod - def find_libraries(model) -> QStandardItemModel: - """ - Finds all the fctl files in a location. - Returns a QStandardItemModel. - """ - Path.Log.track() - path = Path.Preferences.lastPathToolLibrary() - model.clear() - - if os.path.isdir(path): # opening all tables in a directory - libFiles = [f for f in glob.glob(path + os.path.sep + "*.fctl")] - libFiles.sort() - for libFile in libFiles: - loc, fnlong = os.path.split(libFile) - fn, ext = os.path.splitext(fnlong) - libItem = QStandardItem(fn) - libItem.setToolTip(loc) - libItem.setData(libFile, _PathRole) - libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) - model.appendRow(libItem) - - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model - - @staticmethod - def __library_load(path: str, data_model: QStandardItemModel): - Path.Log.track(path) - Path.Preferences.setLastFileToolLibrary(path) - - try: - with open(path) as fp: - library = json.load(fp) - except Exception as e: - Path.Log.error(f"Failed to load library from {path}: {e}") - return - - for tool_bit in library.get("tools", []): - try: - nr = tool_bit["nr"] - bit = PathToolBit.findToolBit(tool_bit["path"], path) - if bit: - Path.Log.track(bit) - tool = PathToolBit.Declaration(bit) - data_model.appendRow(ModelFactory._tool_add(nr, tool, bit)) - else: - Path.Log.error(f"Could not find tool #{nr}: {tool_bit['path']}") - except Exception as e: - msg = f"Error loading tool: {tool_bit['path']} : {e}" - FreeCAD.Console.PrintError(msg) - - @staticmethod - def _generate_tooltip(toolbit: dict) -> str: - """ - Generate an HTML tooltip for a given toolbit dictionary. - - Args: - toolbit (dict): A dictionary containing toolbit information. - - Returns: - str: An HTML string representing the tooltip. - """ - tooltip = f"
Name: {toolbit['name']}
" - tooltip += f"Shape File: {toolbit['shape']}
" - tooltip += "Parameters:
" - parameters = toolbit.get("parameter", {}) - if parameters: - for key, value in parameters.items(): - tooltip += f" {key}: {value}
" - else: - tooltip += " No parameters provided.
" - - attributes = toolbit.get("attribute", {}) - if attributes: - tooltip += "Attributes:
" - for key, value in attributes.items(): - tooltip += f" {key}: {value}
" - - return tooltip - - @staticmethod - def _tool_add(nr: int, tool: dict, path: str): - str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0] - tooltip = ModelFactory._generate_tooltip(tool) - - tool_nr = QStandardItem() - tool_nr.setData(nr, Qt.EditRole) - tool_nr.setData(path, _PathRole) - tool_nr.setData(UUID.uuid4(), _UuidRole) - tool_nr.setToolTip(tooltip) - - tool_name = QStandardItem() - tool_name.setData(tool["name"], Qt.EditRole) - tool_name.setEditable(False) - tool_name.setToolTip(tooltip) - - tool_shape = QStandardItem() - tool_shape.setData(str_shape, Qt.EditRole) - tool_shape.setEditable(False) - - return [tool_nr, tool_name, tool_shape] - - @staticmethod - def new_tool(datamodel: QStandardItemModel, path: str): - """ - Adds a toolbit item to a model. - """ - Path.Log.track() - - try: - nr = ( - max( - ( - int(datamodel.item(row, 0).data(Qt.EditRole)) - for row in range(datamodel.rowCount()) - ), - default=0, - ) - + 1 - ) - tool = PathToolBit.Declaration(path) - except Exception as e: - Path.Log.error(e) - return - - datamodel.appendRow(ModelFactory._tool_add(nr, tool, path)) - - @staticmethod - def library_open(model: QStandardItemModel, lib: str = "") -> QStandardItemModel: - """ - Opens the tools in a library. - Returns a QStandardItemModel. - """ - Path.Log.track(lib) - - if not lib: - lib = Path.Preferences.lastFileToolLibrary() - - if not lib or not os.path.isfile(lib): - return model - - ModelFactory.__library_load(lib, model) - - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model - - -class ToolBitSelector(object): - """Controller for displaying a library and creating ToolControllers""" - - def __init__(self): - checkWorkingDir() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitSelector.ui") - self.factory = ModelFactory() - self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - self.libraryModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - - self.setupUI() - self.title = self.form.windowTitle() - - def columnNames(self): - """Define the column names to display""" - return ["#", "Tool"] - - def currentLibrary(self, shortNameOnly): - """Get the file path for the current tool library""" - libfile = Path.Preferences.lastFileToolLibrary() - if libfile is None or libfile == "": - return "" - elif shortNameOnly: - return os.path.splitext(os.path.basename(libfile))[0] - return libfile - - def loadData(self): - """Load the toolbits for the selected tool library""" - Path.Log.track() - self.toolModel.clear() - self.toolModel.setHorizontalHeaderLabels(self.columnNames()) - - # Get the currently selected index in the combobox - currentIndex = self.form.cboLibraries.currentIndex() - - if currentIndex != -1: - # Get the data for the selected index - libPath = self.libraryModel.item(currentIndex).data(_PathRole) - self.factory.library_open(self.toolModel, libPath) - - self.toolModel.takeColumn(3) - self.toolModel.takeColumn(2) - - def loadToolLibraries(self): - """ - Load the tool libraries in to self.libraryModel - and populate the tooldock form combobox with the - libraries names - """ - Path.Log.track() - - # Get the current library so we can try and maintain any previous selection - current_lib = self.currentLibrary(True) # True to get short name only - - # load the tool libraries - self.factory.find_libraries(self.libraryModel) - - # Set the library model to the combobox - self.form.cboLibraries.setModel(self.libraryModel) - - # Set the current library as the selected item in the combobox - currentIndex = self.form.cboLibraries.findText(current_lib) - - # Set the selected library as the currentIndex in the combobox - if currentIndex == -1 and self.libraryModel.rowCount() > 0: - # If current library is not found, default to the first item - currentIndex = 0 - - self.form.cboLibraries.setCurrentIndex(currentIndex) - - def setupUI(self): - """Setup the form and load the tooltable data""" - Path.Log.track() - - # Connect the library change to reload data and update tooltip - self.form.cboLibraries.currentIndexChanged.connect(self.loadData) - self.form.cboLibraries.currentIndexChanged.connect(self.updateLibraryTooltip) - - # Load the tool libraries. - # This will trigger a change in current index of the cboLibraries combobox - self.loadToolLibraries() - - self.form.tools.setModel(self.toolModel) - self.form.tools.selectionModel().selectionChanged.connect(self.enableButtons) - self.form.tools.doubleClicked.connect(partial(self.selectedOrAllToolControllers)) - - self.form.libraryEditorOpen.clicked.connect(self.libraryEditorOpen) - self.form.addToolController.clicked.connect(self.selectedOrAllToolControllers) - - def updateLibraryTooltip(self, index): - """Add a tooltip to the combobox""" - if index != -1: - item = self.libraryModel.item(index) - if item: - libPath = item.data(_PathRole) - self.form.cboLibraries.setToolTip(f"{libPath}") - else: - self.form.cboLibraries.setToolTip(translate("CAM_Toolbit", "Select a library")) - else: - self.form.cboLibraries.setToolTip(translate("CAM_Toolbit", "No library selected")) - - def enableButtons(self): - """Enable button to add tool controller when a tool is selected""" - # Set buttons inactive - self.form.addToolController.setEnabled(False) - selected = len(self.form.tools.selectedIndexes()) >= 1 - if selected: - jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 - self.form.addToolController.setEnabled(selected and jobs) - - def libraryEditorOpen(self): - library = ToolBitLibrary() - library.open() - self.loadToolLibraries() - - def selectedOrAllTools(self): - """ - Iterate the selection and add individual tools - If a group is selected, iterate and add children - """ - - itemsToProcess = [] - for index in self.form.tools.selectedIndexes(): - item = index.model().itemFromIndex(index) - - if item.hasChildren(): - for i in range(item.rowCount() - 1): - if item.child(i).column() == 0: - itemsToProcess.append(item.child(i)) - - elif item.column() == 0: - itemsToProcess.append(item) - - tools = [] - for item in itemsToProcess: - toolNr = int(item.data(PySide.QtCore.Qt.EditRole)) - toolPath = item.data(_PathRole) - tools.append((toolNr, PathToolBit.Factory.CreateFrom(toolPath))) - return tools - - def selectedOrAllToolControllers(self, index=None): - """ - if no jobs, don't do anything, otherwise all TCs for all - selected toolbits - """ - jobs = PathUtilsGui.PathUtils.GetJobs() - if len(jobs) == 0: - return - elif len(jobs) == 1: - job = jobs[0] - else: - userinput = PathUtilsGui.PathUtilsUserInput() - job = userinput.chooseJob(jobs) - - if job is None: # user may have canceled - return - - tools = self.selectedOrAllTools() - - for tool in tools: - tc = PathToolControllerGui.Create("TC: {}".format(tool[1].Label), tool[1], tool[0]) - job.Proxy.addToolController(tc) - FreeCAD.ActiveDocument.recompute() - - def open(self, path=None): - """load library stored in path and bring up ui""" - docs = FreeCADGui.getMainWindow().findChildren(PySide.QtGui.QDockWidget) - for doc in docs: - if doc.objectName() == "ToolSelector": - if doc.isVisible(): - doc.deleteLater() - return - else: - doc.setVisible(True) - return - - mw = FreeCADGui.getMainWindow() - mw.addDockWidget( - PySide.QtCore.Qt.RightDockWidgetArea, - self.form, - PySide.QtCore.Qt.Orientation.Vertical, - ) - - -class ToolBitLibrary(object): - """ToolBitLibrary is the controller for - displaying/selecting/creating/editing a collection of ToolBits.""" - - def __init__(self): - Path.Log.track() - checkWorkingDir() - self.factory = ModelFactory() - self.temptool = None - self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - self.listModel = PySide.QtGui.QStandardItemModel() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") - self.toolTableView = _TableView(self.form.toolTableGroup) - self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView) - self.form.toolTable.hide() - self.setupUI() - self.title = self.form.windowTitle() - - def toolBitNew(self): - """Create a new toolbit""" - Path.Log.track() - - # select the shape file - shapefile = PathToolBitGui.GetToolShapeFile() - if shapefile is None: # user canceled - return - - # select the bit file location and filename - filename = PathToolBitGui.GetNewToolFile() - if filename is None: - return - - # Parse out the name of the file and write the structure - loc, fil = os.path.split(filename) - fname = os.path.splitext(fil)[0] - fullpath = "{}{}{}.fctb".format(loc, os.path.sep, fname) - Path.Log.debug("fullpath: {}".format(fullpath)) - - self.temptool = PathToolBit.ToolBitFactory().Create(name=fname) - self.temptool.BitShape = shapefile - self.temptool.Proxy.unloadBitBody(self.temptool) - self.temptool.Label = fname - self.temptool.Proxy.saveToFile(self.temptool, fullpath) - self.temptool.Document.removeObject(self.temptool.Name) - self.temptool = None - - # add it to the model - self.factory.new_tool(self.toolModel, fullpath) - self.librarySave() - - def toolBitExisting(self): - """Add an existing toolbit to the library""" - - filenames = PathToolBitGui.GetToolFiles() - - if len(filenames) == 0: - return - - for f in filenames: - - loc, fil = os.path.split(f) - fname = os.path.splitext(fil)[0] - fullpath = "{}{}{}.fctb".format(loc, os.path.sep, fname) - - self.factory.new_tool(self.toolModel, fullpath) - self.librarySave() - - def toolDelete(self): - """Delete a tool""" - Path.Log.track() - selectedRows = set([index.row() for index in self.toolTableView.selectedIndexes()]) - for row in sorted(list(selectedRows), key=lambda r: -r): - self.toolModel.removeRows(row, 1) - self.librarySave() - - def toolSelect(self, selected, deselected): - sel = len(self.toolTableView.selectedIndexes()) > 0 - self.form.toolDelete.setEnabled(sel) - - def tableSelected(self, index): - """loads the tools for the selected tool table""" - Path.Log.track() - item = index.model().itemFromIndex(index) - libpath = item.data(_PathRole) - self.loadData(libpath) - self.path = libpath - - def open(self): - Path.Log.track() - return self.form.exec_() - - def libraryPath(self): - """Select and load a tool library""" - Path.Log.track() - path = PySide.QtGui.QFileDialog.getExistingDirectory( - self.form, "Tool Library Path", Path.Preferences.lastPathToolLibrary() - ) - if len(path) == 0: - return - - Path.Preferences.setLastPathToolLibrary(path) - self.loadData() - - def cleanupDocument(self): - """Clean up the document""" - # This feels like a hack. Remove the toolbit object - # remove the editor from the dialog - # re-enable all the controls - self.temptool.Proxy.unloadBitBody(self.temptool) - self.temptool.Document.removeObject(self.temptool.Name) - self.temptool = None - widget = self.form.toolTableGroup.children()[-1] - widget.setParent(None) - self.editor = None - self.lockoff() - - def accept(self): - """Handle accept signal""" - self.editor.accept() - self.temptool.Proxy.saveToFile(self.temptool, self.temptool.File) - self.librarySave() - self.loadData() - self.cleanupDocument() - - def reject(self): - """Handle reject signal""" - self.cleanupDocument() - - def lockon(self): - """Set the state of the form widgets: inactive""" - self.toolTableView.setEnabled(False) - self.form.toolCreate.setEnabled(False) - self.form.toolDelete.setEnabled(False) - self.form.toolAdd.setEnabled(False) - self.form.TableList.setEnabled(False) - self.form.libraryOpen.setEnabled(False) - self.form.libraryExport.setEnabled(False) - self.form.addToolTable.setEnabled(False) - self.form.librarySave.setEnabled(False) - - def lockoff(self): - """Set the state of the form widgets: active""" - self.toolTableView.setEnabled(True) - self.form.toolCreate.setEnabled(True) - self.form.toolDelete.setEnabled(True) - self.form.toolAdd.setEnabled(True) - self.form.toolTable.setEnabled(True) - self.form.TableList.setEnabled(True) - self.form.libraryOpen.setEnabled(True) - self.form.libraryExport.setEnabled(True) - self.form.addToolTable.setEnabled(True) - self.form.librarySave.setEnabled(True) - - def toolEdit(self, selected): - """Edit the selected tool bit""" - Path.Log.track() - item = self.toolModel.item(selected.row(), 0) - - if self.temptool is not None: - self.temptool.Document.removeObject(self.temptool.Name) - - if selected.column() == 0: # editing Nr - pass - else: - tbpath = item.data(_PathRole) - self.temptool = PathToolBit.ToolBitFactory().CreateFrom(tbpath, "temptool") - self.editor = PathToolBitEdit.ToolBitEditor( - self.temptool, self.form.toolTableGroup, loadBitBody=False - ) - - QBtn = PySide.QtGui.QDialogButtonBox.Ok | PySide.QtGui.QDialogButtonBox.Cancel - buttonBox = PySide.QtGui.QDialogButtonBox(QBtn) - buttonBox.accepted.connect(self.accept) - buttonBox.rejected.connect(self.reject) - - layout = self.editor.form.layout() - layout.addWidget(buttonBox) - self.lockon() - self.editor.setupUI() - - def toolEditDone(self, success=True): - FreeCAD.ActiveDocument.removeObject("temptool") - print("all done") - - def libraryNew(self): - """Create a new tool library""" - TooltableTypeJSON = translate("CAM_ToolBit", "Tooltable JSON (*.fctl)") - - filename = PySide.QtGui.QFileDialog.getSaveFileName( - self.form, - translate("CAM_ToolBit", "Save toolbit library"), - Path.Preferences.lastPathToolLibrary(), - "{}".format(TooltableTypeJSON), - ) - - if not (filename and filename[0]): - self.loadData() - - path = filename[0] if filename[0].endswith(".fctl") else "{}.fctl".format(filename[0]) - library = {} - tools = [] - library["version"] = 1 - library["tools"] = tools - with open(path, "w") as fp: - json.dump(library, fp, sort_keys=True, indent=2) - - self.loadData() - - def librarySave(self): - """Save the tool library""" - library = {} - tools = [] - library["version"] = 1 - library["tools"] = tools - for row in range(self.toolModel.rowCount()): - toolNr = self.toolModel.data(self.toolModel.index(row, 0), PySide.QtCore.Qt.EditRole) - toolPath = self.toolModel.data(self.toolModel.index(row, 0), _PathRole) - if Path.Preferences.toolsStoreAbsolutePaths(): - bitPath = toolPath - else: - # bitPath = PathToolBit.findRelativePathTool(toolPath) - # Extract the name of the shape file - __, filShp = os.path.split( - toolPath - ) # __ is an ignored placeholder acknowledged by LGTM - bitPath = str(filShp) - tools.append({"nr": toolNr, "path": bitPath}) - - if self.path is not None: - with open(self.path, "w") as fp: - json.dump(library, fp, sort_keys=True, indent=2) - - def libraryOk(self): - self.librarySave() - self.form.close() - - def libPaths(self): - """Get the file path for the last used tool library""" - lib = Path.Preferences.lastFileToolLibrary() - loc = Path.Preferences.lastPathToolLibrary() - - Path.Log.track("lib: {} loc: {}".format(lib, loc)) - return lib, loc - - def columnNames(self): - return [ - "Tn", - translate("CAM_ToolBit", "Tool"), - translate("CAM_ToolBit", "Shape"), - ] - - def loadData(self, path=None): - """Load tooltable data""" - Path.Log.track(path) - self.toolTableView.setUpdatesEnabled(False) - self.form.TableList.setUpdatesEnabled(False) - - if path is None: - path, loc = self.libPaths() - - self.toolModel.clear() - self.listModel.clear() - self.factory.library_open(self.toolModel, lib=path) - self.factory.find_libraries(self.listModel) - - else: - self.toolModel.clear() - self.factory.library_open(self.toolModel, lib=path) - - self.path = path - self.form.setWindowTitle("{}".format(Path.Preferences.lastPathToolLibrary())) - self.toolModel.setHorizontalHeaderLabels(self.columnNames()) - self.listModel.setHorizontalHeaderLabels(["Library"]) - - # Select the current library in the list of tables - curIndex = None - for i in range(self.listModel.rowCount()): - item = self.listModel.item(i) - if item.data(_PathRole) == path: - curIndex = self.listModel.indexFromItem(item) - - if curIndex: - sm = self.form.TableList.selectionModel() - sm.select(curIndex, PySide.QtCore.QItemSelectionModel.Select) - - self.toolTableView.setUpdatesEnabled(True) - self.form.TableList.setUpdatesEnabled(True) - - def setupUI(self): - """Setup the form and load the tool library data""" - Path.Log.track() - self.form.TableList.setModel(self.listModel) - self.toolTableView.setModel(self.toolModel) - - self.loadData() - - self.toolTableView.resizeColumnsToContents() - self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect) - self.toolTableView.doubleClicked.connect(self.toolEdit) - - self.form.TableList.clicked.connect(self.tableSelected) - - self.form.toolAdd.clicked.connect(self.toolBitExisting) - self.form.toolDelete.clicked.connect(self.toolDelete) - self.form.toolCreate.clicked.connect(self.toolBitNew) - - self.form.addToolTable.clicked.connect(self.libraryNew) - - self.form.libraryOpen.clicked.connect(self.libraryPath) - self.form.librarySave.clicked.connect(self.libraryOk) - self.form.libraryExport.clicked.connect(self.librarySaveAs) - - self.toolSelect([], []) - - def librarySaveAs(self, path): - """Save the tooltable to a format to use with an external system""" - TooltableTypeJSON = translate("CAM_ToolBit", "Tooltable JSON (*.fctl)") - TooltableTypeLinuxCNC = translate("CAM_ToolBit", "LinuxCNC tooltable (*.tbl)") - TooltableTypeCamotics = translate("CAM_ToolBit", "CAMotics tooltable (*.json)") - - filename = PySide.QtGui.QFileDialog.getSaveFileName( - self.form, - translate("CAM_ToolBit", "Save toolbit library"), - Path.Preferences.lastPathToolLibrary(), - "{};;{};;{}".format(TooltableTypeJSON, TooltableTypeLinuxCNC, TooltableTypeCamotics), - ) - if filename and filename[0]: - if filename[1] == TooltableTypeLinuxCNC: - path = filename[0] if filename[0].endswith(".tbl") else "{}.tbl".format(filename[0]) - self.libararySaveLinuxCNC(path) - elif filename[1] == TooltableTypeCamotics: - path = ( - filename[0] if filename[0].endswith(".json") else "{}.json".format(filename[0]) - ) - self.libararySaveCamotics(path) - else: - path = ( - filename[0] if filename[0].endswith(".fctl") else "{}.fctl".format(filename[0]) - ) - self.path = path - self.librarySave() - - def libararySaveLinuxCNC(self, path): - """Export the tool table to a file for use with linuxcnc""" - LIN = "T{} P{} X{} Y{} Z{} A{} B{} C{} U{} V{} W{} D{} I{} J{} Q{}; {}" - with open(path, "w") as fp: - fp.write(";\n") - - for row in range(self.toolModel.rowCount()): - toolNr = self.toolModel.data( - self.toolModel.index(row, 0), PySide.QtCore.Qt.EditRole - ) - toolPath = self.toolModel.data(self.toolModel.index(row, 0), _PathRole) - - bit = PathToolBit.Factory.CreateFrom(toolPath) - if bit: - Path.Log.track(bit) - - pocket = bit.Pocket if hasattr(bit, "Pocket") else "0" - xoffset = bit.Xoffset if hasattr(bit, "Xoffset") else "0" - yoffset = bit.Yoffset if hasattr(bit, "Yoffset") else "0" - zoffset = bit.Zoffset if hasattr(bit, "Zoffset") else "0" - aoffset = bit.Aoffset if hasattr(bit, "Aoffset") else "0" - boffset = bit.Boffset if hasattr(bit, "Boffset") else "0" - coffset = bit.Coffset if hasattr(bit, "Coffset") else "0" - uoffset = bit.Uoffset if hasattr(bit, "Uoffset") else "0" - voffset = bit.Voffset if hasattr(bit, "Voffset") else "0" - woffset = bit.Woffset if hasattr(bit, "Woffset") else "0" - - diameter = ( - bit.Diameter.getUserPreferred()[0].split()[0] - if hasattr(bit, "Diameter") - else "0" - ) - frontangle = bit.FrontAngle if hasattr(bit, "FrontAngle") else "0" - backangle = bit.BackAngle if hasattr(bit, "BackAngle") else "0" - orientation = bit.Orientation if hasattr(bit, "Orientation") else "0" - remark = bit.Label - - fp.write( - LIN.format( - toolNr, - pocket, - xoffset, - yoffset, - zoffset, - aoffset, - boffset, - coffset, - uoffset, - voffset, - woffset, - diameter, - frontangle, - backangle, - orientation, - remark, - ) - + "\n" - ) - - FreeCAD.ActiveDocument.removeObject(bit.Name) - - else: - Path.Log.error("Could not find tool #{} ".format(toolNr)) - - def libararySaveCamotics(self, path): - """Export the tool table to a file for use with camotics""" - - SHAPEMAP = { - "ballend": "Ballnose", - "endmill": "Cylindrical", - "v-bit": "Conical", - "chamfer": "Snubnose", - } - - tooltemplate = { - "units": "metric", - "shape": "cylindrical", - "length": 10, - "diameter": 3.125, - "description": "", - } - toollist = {} - - unitstring = "imperial" if FreeCAD.Units.getSchema() in [2, 3, 5, 7] else "metric" - - for row in range(self.toolModel.rowCount()): - toolNr = self.toolModel.data(self.toolModel.index(row, 0), PySide.QtCore.Qt.EditRole) - - toolPath = self.toolModel.data(self.toolModel.index(row, 0), _PathRole) - Path.Log.debug(toolPath) - try: - bit = PathToolBit.Factory.CreateFrom(toolPath) - except FileNotFoundError as e: - FreeCAD.Console.PrintError(e) - continue - except Exception as e: - raise e - - if not bit: - continue - - Path.Log.track(bit) - - toolitem = tooltemplate.copy() - - toolitem["diameter"] = ( - float(bit.Diameter.getUserPreferred()[0].split()[0]) - if hasattr(bit, "Diameter") - else 2 - ) - toolitem["description"] = bit.Label - toolitem["length"] = ( - float(bit.Length.getUserPreferred()[0].split()[0]) if hasattr(bit, "Length") else 10 - ) - - if hasattr(bit, "Camotics"): - toolitem["shape"] = bit.Camotics - else: - toolitem["shape"] = SHAPEMAP.get(bit.ShapeName, "Cylindrical") - - toolitem["units"] = unitstring - FreeCAD.ActiveDocument.removeObject(bit.Name) - - toollist[toolNr] = toolitem - - if len(toollist) > 0: - with open(path, "w") as fp: - fp.write(json.dumps(toollist, indent=2)) diff --git a/src/Mod/CAM/Path/Tool/Gui/Controller.py b/src/Mod/CAM/Path/Tool/Gui/Controller.py index 7035ba5bb5..9e07e953b9 100644 --- a/src/Mod/CAM/Path/Tool/Gui/Controller.py +++ b/src/Mod/CAM/Path/Tool/Gui/Controller.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -20,6 +21,7 @@ # * * # *************************************************************************** +from lazy_loader.lazy_loader import LazyLoader from PySide import QtCore, QtGui from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD @@ -28,11 +30,7 @@ import Path import Path.Base.Gui.Util as PathGuiUtil import Path.Base.Util as PathUtil import Path.Tool.Controller as PathToolController -import Path.Tool.Gui.Bit as PathToolBitGui -import PathGui - -# lazily loaded modules -from lazy_loader.lazy_loader import LazyLoader +from Path.Tool.toolbit.ui.selector import ToolBitSelector Part = LazyLoader("Part", globals(), "Part") @@ -162,19 +160,30 @@ class CommandPathToolController(object): def Activated(self): Path.Log.track() job = self.selectedJob() - if job: - tool = PathToolBitGui.ToolBitSelector().getTool() - if tool: - toolNr = None - for tc in job.Tools.Group: - if tc.Tool == tool: - toolNr = tc.ToolNumber - break - if not toolNr: - toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1 - tc = Create("TC: {}".format(tool.Label), tool, toolNr) - job.Proxy.addToolController(tc) - FreeCAD.ActiveDocument.recompute() + if not job: + return + + # Let the user select a toolbit + selector = ToolBitSelector() + if not selector.exec_(): + return + tool = selector.get_selected_tool() + if not tool: + return + + # Find a tool number + toolNr = None + for tc in job.Tools.Group: + if tc.Tool == tool: + toolNr = tc.ToolNumber + break + if not toolNr: + toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1 + + # Create the new tool controller with the tool. + tc = Create("TC: {}".format(tool.Label), tool, toolNr) + job.Proxy.addToolController(tc) + FreeCAD.ActiveDocument.recompute() class ToolControllerEditor(object): diff --git a/src/Mod/CAM/Path/Tool/__init__.py b/src/Mod/CAM/Path/Tool/__init__.py index e69de29bb2..36cb227147 100644 --- a/src/Mod/CAM/Path/Tool/__init__.py +++ b/src/Mod/CAM/Path/Tool/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +import sys +from lazy_loader.lazy_loader import LazyLoader +from . import toolbit +from .assets import DummyAssetSerializer +from .camassets import cam_assets +from .library import Library +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) +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. +# Since C++ does not use the standard Python import mechanism and instead +# unpickles existing objects after looking them up in sys.modules, we +# need to update sys.modules here. +sys.modules[__name__ + ".Bit"] = toolbit.models.base +sys.modules[__name__ + ".Gui.Bit"] = LazyLoader( + "Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view" +) + +# Define __all__ for explicit public interface +__all__ = [ + "ToolBit", + "cam_assets", +] diff --git a/src/Mod/CAM/Path/Tool/assets/README.md b/src/Mod/CAM/Path/Tool/assets/README.md new file mode 100644 index 0000000000..669d456bfb --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/README.md @@ -0,0 +1,323 @@ +# Asset Management Module + +This module implements an asset manager that provides methods for storing, +updating, deleting, and receiving assets for the FreeCAD CAM workbench. + +## Goals of the asset manager + +While currently the AssetManager has no UI yet, the plan is to add one. + +The ultimate vision for the asset manager is to provide a unified UI that +can download assets from arbitrary sources, such as online databases, +Git repositories, and also local storage. It should also allow for copying +between these storages, effectively allowing for publishing assets. + +Essentially, something similar to what Blender has: + +![Blender Asset Manager](docs/blender-assets.jpg) + +## What are assets in CAM? + +Assets are arbitrary data, such as FreeCAD models, Tools, and many more. +Specifically in the context of CAM, assets are: + +- Tool bit libraries +- Tool bits +- Tool bit shape files +- Tool bit shape icons +- Machines +- Fixtures +- Post processors +- ... + +**Assets have dependencies:** For example, a ToolBitLibrary requires ToolBits, +and a ToolBit requires a ToolBitShape (which is a FreeCAD model). + + +## Challenges + +In the current codebase, CAM objects are monoliths that handle everything: +in-memory data, serialization, deserialization, storage. They are tightly +coupled to files, and make assumptions about how other objects are stored. + +Examples: + +- Tool bits have "File" attributes that they use to collect dependencies + such as ToolBit files and shape files. +- It also writes directly to the disk. +- GuiToolBit performs serialization directly in UI functions. +- ToolBits could not be created without an active document. + +As the code base grows, separation of concerns becomes more important. +Managing dependencies between asset becomes a hassle if every object tries +to resolve them in their own way. + + +# Solution + +The main effort went into two key areas: + +1. **The generic AssetManager:** + - **Manages storage** while existing FreeCAD tool library file structures retained + - **Manages dependencies** including detection of cyclic dependencies, deep vs. shallow fetching + - **Manages threading** for asynchronous storage, while FreeCAD objects are assembled in the main UI thread + - **Defining a generic asset interface** that classes can implement to become "storable" + +2. **Refactoring existing CAM objects for clear separation of concerns:** + - **View**: Should handle user interface only. Existing file system access methods were removed. + - **Object Model**: In-memory representation of an object, for example a ToolBit, Icon, or a ToolBitShape. By giving all classes `from_bytes()` and `to_bytes()` methods; the objects no longer need to handle storage themselves. + - **Storage**: Persisting an object to a file system, database system, or API. This is now handled by the AssetManager + - **Serialization** A serialization protocol needs to be defined. This will allow for better import/export mechanisms in the future + +FreeCAD is now fully usable with the changes in place. Work remains to be done on the serializers. + +## Asset Manager API usage example + +```python +import pathlib +from typing import Any, Mapping, List, Type +from Path.Tool.assets import AssetManager, FileStore, AssetUri, Asset + +# Define a simple Material class implementing the Asset interface +class Material(Asset): + asset_type: str = "material" + + def __init__(self, name: str): + self.name = name + + def get_id() -> str: + return self.name.lower().replace(" ", "-") + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def from_bytes(cls, data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) -> Material: + return cls(data.decode('utf-8')) + + def to_bytes(self) -> bytes: + return self.name.encode('utf-8') + +manager = AssetManager() + +# Register FileStore and the simple asset class +manager.register_store(FileStore("local", pathlib.Path("/tmp/assets"))) +manager.register_asset(Material) + +# Create and get an asset +asset_uri = manager.add(Material("Copper")) +print(f"Stored with URI: {asset_uri}") +retrieved_asset = manager.get(asset_uri) +print(f"Retrieved: {retrieved_asset}") +``` + +## The Serializer Protocol + +The serializer protocol defines how assets are converted to and from bytes and how their +dependencies are identified. This separation of concerns allows assets to be stored and +retrieved independently of their specific serialization format. + +The core components of the protocol are the [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11) +and [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) classes. + +- The [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11) class represents an asset object in + memory. It provides methods like [`to_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:69) + and [`from_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:56) which delegate the actual + serialization and deserialization to an [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) + instance. It also has an [`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/asset.py:49) + method that uses the serializer to find dependencies within the raw asset data. + +- The [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) is an abstract base + class that defines the interface for serializers. Concrete implementations of + [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) are responsible for the + specific logic of converting an asset object to bytes ([`serialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:21)), + converting bytes back to an asset object ([`deserialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:27)), + and extracting dependency URIs from the raw byte data + ([`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/serializer.py:15)). + +This design allows the AssetManager to work with various asset types and serialization formats +by simply registering the appropriate [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) +for each asset type. + +## Class diagram + +```mermaid +classDiagram + direction LR + + %% -------------- Asset Manager Module -------------- + note for AssetManager "AssetUri structure: + <asset_type>:\//<asset_id>[/<version>]
+ Examples: + material:\//1234567/1 + toolbitshape:\//endmill/1 + material:\//aluminium-6012/2" + + class AssetManager["AssetManager + Creates, assembles or deletes assets from URIs"] { + stores: Mapping[str, AssetStore] // maps protocol to store + register_store(store: AssetStore, cacheable: bool = False) + register_asset(asset_class: Type[Asset], serializer: Type[AssetSerializer]) + get(uri: AssetUri | str, store: str = "local", depth: Optional[int] = None) Any + get_raw(uri: AssetUri | str, store: str = "local") bytes + add(obj: Asset, store: str = "local") AssetUri + add_raw(asset_type: str, asset_id: str, data: bytes, store: str = "local") AssetUri + delete(uri: AssetUri | str, store: str = "local") + is_empty(asset_type: str | None = None, store: str = "local") bool + list_assets(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local") List[AssetUri] + list_versions(uri: AssetUri | str, store: str = "local") List[AssetUri] + get_bulk(uris: Sequence[AssetUri | str], store: str = "local", depth: Optional[int] = None) List[Any] + fetch(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local", depth: Optional[int] = None) List[Asset] + } + + class AssetStore["AssetStore + Stores/Retrieves assets as raw bytes"] { + <> + async get(uri: AssetUri) bytes + async count_assets(asset_type: str | None = None) int + async delete(uri: AssetUri) + async create(asset_type: str, asset_id: str, data: bytes) AssetUri + async update(uri: AssetUri, data: bytes) AssetUri + async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri] + async list_versions(uri: AssetUri) List[AssetUri] + async is_empty(asset_type: str | None = None) bool + } + AssetStore *-- AssetManager: has many + + class FileStore["FileStore + Stores/Retrieves versioned assets as directories/files"] { + + __init__(name: str, filepath: pathlib.Path) + set_dir(new_dir: pathlib.Path) + async get(uri: AssetUri) bytes + async delete(uri: AssetUri) + async create(asset_type: str, asset_id: str, data: bytes) AssetUri + async update(uri: AssetUri, data: bytes) AssetUri + async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri] + async list_versions(uri: AssetUri) List[AssetUri] + async is_empty(asset_type: str | None = None) bool + } + FileStore <|-- AssetStore: is + + class MemoryStore["MemoryStore + In-memory store, mostly for testing/demonstration"] { + __init__(name: str) + async get(uri: AssetUri) bytes + async delete(uri: AssetUri) + async create(asset_type: str, asset_id: str, data: bytes) AssetUri + async update(uri: AssetUri, data: bytes) AssetUri + async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri] + async list_versions(uri: AssetUri) List[AssetUri] + async is_empty(asset_type: str | None) bool + dump(print: bool) Dict | None + } + MemoryStore <|-- AssetStore: is + + class AssetSerializer["AssetSerializer
Abstract base class for asset serializers"] { + <> + for_class: Type[Asset] + extensions: Tuple[str] + mime_type: str + extract_dependencies(data: bytes) List[AssetUri] + serialize(asset: Asset) bytes + deserialize(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) Asset + } + AssetSerializer *-- AssetManager: has many + Asset --> AssetSerializer: uses + + class Asset["Asset
Common interface for all asset types"] { + <> + asset_type: str // type of the asset type, e.g., toolbit + + get_id() str // Returns a unique ID of the asset + to_bytes(serializer: AssetSerializer) bytes + from_bytes(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]], serializer: Type[AssetSerializer]) Asset + extract_dependencies(data: bytes, serializer: Type[AssetSerializer]) List[AssetUri] // Extracts dependency URIs from bytes + } + Asset *-- AssetManager: creates + + namespace AssetManagerModule { + class AssetManager + class AssetStore + class FileStore + class MemoryStore + class AssetSerializer + class Asset + } + + %% -------------- CAM Module (as an example) -------------- + class ToolBitShape["ToolBitShape
for assets with type toolbitshape"] { + <> + asset_type: str = "toolbitshape" + + get_id() str // Returns a unique ID + from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBitShape + to_bytes(obj: ToolBitShape) bytes + dependencies(data: bytes) List[AssetUri] + } + ToolBitShape ..|> Asset: is + + class ToolBit["ToolBit
for assets with type toolbit"] { + <> + asset_type: str = "toolbit" + + get_id() str // Returns a unique ID + from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBit + to_bytes(obj: ToolBit) bytes + dependencies(data: bytes) List[AssetUri] + } + ToolBit ..|> Asset: is + ToolBit --> ToolBitShape: has + + namespace CAMModule { + class ToolBitShape + class ToolBit + } + + %% -------------- Materials Module (as an example) -------------- + class Material["Material
for assets with type material"] { + <> + asset_type: str = "material" + + get_id() str // Returns a unique ID + dependencies(data: bytes) List[AssetUri] + from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) Material + to_bytes(obj: Material) bytes + } + Material ..|> Asset: is + + namespace MaterialModule { + class Material + class Material + } +``` + +# UI Helpers + +The `ui` directory contains helper modules for the asset manager's user interface. + +- [`filedialog.py`](src/Mod/CAM/Path/Tool/assets/ui/filedialog.py): + Provides file dialogs for importing and exporting assets. + +- [`util.py`](src/Mod/CAM/Path/Tool/assets/ui/util.py): Contains general utility + functions used within the asset manager UI. + +# What's next + +## Shorter term + +- Improving the integration of serializers. Ideally the asset manager could help here too: + We can define a common serializer protocol for **all** assets. It could then become the + central point for imports and exports. + + +## Potential future extensions (longer term) + +- Adding a AssetManager UI, to allow for browsing and searching stores for all kinds of + assets (Machines, Fixtures, Libraries, Tools, Shapes, Post Processors, ...) + from all kings of sources (online DB, git repository, etc.). + +- Adding a GitStore, to connect to things like the [FreeCAD library](https://github.com/FreeCAD/FreeCAD-library). + +- Adding an HttpStore for connectivity to online databases. diff --git a/src/Mod/CAM/Path/Tool/assets/__init__.py b/src/Mod/CAM/Path/Tool/assets/__init__.py new file mode 100644 index 0000000000..ad97f375cc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from .asset import Asset +from .manager import AssetManager +from .uri import AssetUri +from .serializer import AssetSerializer, DummyAssetSerializer +from .store.base import AssetStore +from .store.memory import MemoryStore +from .store.filestore import FileStore + +__all__ = [ + "Asset", + "AssetUri", + "AssetManager", + "AssetSerializer", + "DummyAssetSerializer", + "AssetStore", + "MemoryStore", + "FileStore", +] diff --git a/src/Mod/CAM/Path/Tool/assets/asset.py b/src/Mod/CAM/Path/Tool/assets/asset.py new file mode 100644 index 0000000000..f605d396d0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/asset.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from __future__ import annotations +import abc +from abc import ABC +from typing import Mapping, List, Optional, Type, TYPE_CHECKING +from .uri import AssetUri + +if TYPE_CHECKING: + from .serializer import AssetSerializer + + +class Asset(ABC): + asset_type: str + + def __init__(self, *args, **kwargs): + if not hasattr(self, "asset_type"): + raise ValueError("Asset subclasses must define 'asset_type'.") + + @property + def label(self) -> str: + return self.__class__.__name__ + + @abc.abstractmethod + def get_id(self) -> str: + """Returns the unique ID of an asset object.""" + pass + + def get_uri(self) -> AssetUri: + return AssetUri.build(asset_type=self.asset_type, asset_id=self.get_id()) + + @classmethod + def resolve_name(cls, identifier: str) -> AssetUri: + """ + Resolves an identifier (id, name, or URI) to an AssetUri object. + """ + # 1. If the input is a url string, return the Uri object for it. + if AssetUri.is_uri(identifier): + return AssetUri(identifier) + + # 2. Construct the Uri using Uri.build() and return it + return AssetUri.build( + asset_type=cls.asset_type, + asset_id=identifier, + ) + + @classmethod + def get_uri_from_id(cls, asset_id): + return AssetUri.build(cls.asset_type, asset_id=asset_id) + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + return serializer.extract_dependencies(data) + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], + ) -> Asset: + """ + Creates an object from serialized data and resolved dependencies. + If dependencies is None, it indicates a shallow load where dependencies were not resolved. + """ + return serializer.deserialize(data, id, dependencies) + + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: + """Serializes an object into bytes.""" + return serializer.serialize(self) diff --git a/src/Mod/CAM/Path/Tool/assets/cache.py b/src/Mod/CAM/Path/Tool/assets/cache.py new file mode 100644 index 0000000000..bdbc77108d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/cache.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import time +import hashlib +import logging +from collections import OrderedDict +from typing import Any, Dict, Set, NamedTuple, Optional, Tuple + +# For type hinting Asset and AssetUri to avoid circular imports +# from typing import TYPE_CHECKING +# if TYPE_CHECKING: +# from .asset import Asset +# from .uri import AssetUri + +logger = logging.getLogger(__name__) + + +class CacheKey(NamedTuple): + store_name: str + asset_uri_str: str + raw_data_hash: int + dependency_signature: Tuple + + +class CachedAssetEntry(NamedTuple): + asset: Any # Actually type Asset + size_bytes: int # Estimated size of the raw_data + timestamp: float # For LRU, or just use OrderedDict nature + + +class AssetCache: + def __init__(self, max_size_bytes: int = 100 * 1024 * 1024): # Default 100MB + self.max_size_bytes: int = max_size_bytes + self.current_size_bytes: int = 0 + + self._cache: Dict[CacheKey, CachedAssetEntry] = {} + self._lru_order: OrderedDict[CacheKey, None] = OrderedDict() + + self._cache_dependents_map: Dict[str, Set[CacheKey]] = {} + self._cache_dependencies_map: Dict[CacheKey, Set[str]] = {} + + def _evict_lru(self): + while self.current_size_bytes > self.max_size_bytes and self._lru_order: + oldest_key, _ = self._lru_order.popitem(last=False) + if oldest_key in self._cache: + evicted_entry = self._cache.pop(oldest_key) + self.current_size_bytes -= evicted_entry.size_bytes + logger.debug( + f"Cache Evict (LRU): {oldest_key}, " + f"size {evicted_entry.size_bytes}. " + f"New size: {self.current_size_bytes}" + ) + self._remove_key_from_dependency_maps(oldest_key) + + def _remove_key_from_dependency_maps(self, cache_key_to_remove: CacheKey): + direct_deps_of_removed = self._cache_dependencies_map.pop(cache_key_to_remove, set()) + for dep_uri_str in direct_deps_of_removed: + if dep_uri_str in self._cache_dependents_map: + self._cache_dependents_map[dep_uri_str].discard(cache_key_to_remove) + if not self._cache_dependents_map[dep_uri_str]: + del self._cache_dependents_map[dep_uri_str] + + def get(self, key: CacheKey) -> Optional[Any]: + if key in self._cache: + self._lru_order.move_to_end(key) + logger.debug(f"Cache HIT: {key}") + return self._cache[key].asset + logger.debug(f"Cache MISS: {key}") + return None + + def put( + self, + key: CacheKey, + asset: Any, + raw_data_size_bytes: int, + direct_dependency_uri_strs: Set[str], + ): + if key in self._cache: + self._remove_key_from_dependency_maps(key) + self.current_size_bytes -= self._cache[key].size_bytes + del self._cache[key] + self._lru_order.pop(key, None) + + if raw_data_size_bytes > self.max_size_bytes: + logger.warning( + f"Asset {key.asset_uri_str} (size {raw_data_size_bytes}) " + f"too large for cache (max {self.max_size_bytes}). Not caching." + ) + return + + self.current_size_bytes += raw_data_size_bytes + entry = CachedAssetEntry(asset=asset, size_bytes=raw_data_size_bytes, timestamp=time.time()) + self._cache[key] = entry + self._lru_order[key] = None + self._lru_order.move_to_end(key) + + self._cache_dependencies_map[key] = direct_dependency_uri_strs + for dep_uri_str in direct_dependency_uri_strs: + self._cache_dependents_map.setdefault(dep_uri_str, set()).add(key) + + logger.debug( + f"Cache PUT: {key}, size {raw_data_size_bytes}. " + f"Total cache size: {self.current_size_bytes}" + ) + self._evict_lru() + + def invalidate_for_uri(self, updated_asset_uri_str: str): + keys_to_remove_from_cache: Set[CacheKey] = set() + invalidation_queue: list[str] = [updated_asset_uri_str] + processed_uris_for_invalidation_round: Set[str] = set() + + while invalidation_queue: + current_uri_to_check_str = invalidation_queue.pop(0) + if current_uri_to_check_str in processed_uris_for_invalidation_round: + continue + processed_uris_for_invalidation_round.add(current_uri_to_check_str) + + for ck in list(self._cache.keys()): + if ck.asset_uri_str == current_uri_to_check_str: + keys_to_remove_from_cache.add(ck) + + dependent_cache_keys = self._cache_dependents_map.get( + current_uri_to_check_str, set() + ).copy() + + for dep_ck in dependent_cache_keys: + if dep_ck not in keys_to_remove_from_cache: + keys_to_remove_from_cache.add(dep_ck) + parent_uri_of_dep_ck = dep_ck.asset_uri_str + if parent_uri_of_dep_ck not in processed_uris_for_invalidation_round: + invalidation_queue.append(parent_uri_of_dep_ck) + + for ck_to_remove in keys_to_remove_from_cache: + if ck_to_remove in self._cache: + entry_to_remove = self._cache.pop(ck_to_remove) + self.current_size_bytes -= entry_to_remove.size_bytes + self._lru_order.pop(ck_to_remove, None) + self._remove_key_from_dependency_maps(ck_to_remove) + + if keys_to_remove_from_cache: + logger.debug( + f"Cache invalidated for URI '{updated_asset_uri_str}' and " + f"its dependents. Removed {len(keys_to_remove_from_cache)} " + f"entries. New size: {self.current_size_bytes}" + ) + + def clear(self): + self._cache.clear() + self._lru_order.clear() + self._cache_dependents_map.clear() + self._cache_dependencies_map.clear() + self.current_size_bytes = 0 + logger.info("AssetCache cleared.") diff --git a/src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg b/src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg new file mode 100644 index 0000000000..ce4577ee63 Binary files /dev/null and b/src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg differ diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py new file mode 100644 index 0000000000..165344c588 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -0,0 +1,768 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import logging +import asyncio +import threading +import pathlib +import hashlib +from typing import Dict, Any, Type, Optional, List, Sequence, Union, Set, Mapping, Tuple +from dataclasses import dataclass +from PySide import QtCore, QtGui +from .store.base import AssetStore +from .asset import Asset +from .serializer import AssetSerializer +from .uri import AssetUri +from .cache import AssetCache, CacheKey + + +logger = logging.getLogger(__name__) + + +@dataclass +class _AssetConstructionData: + """Holds raw data and type info needed to construct an asset instance.""" + + uri: AssetUri + raw_data: bytes + asset_class: Type[Asset] + # Stores AssetConstructionData for dependencies, keyed by their AssetUri + dependencies_data: Optional[Dict[AssetUri, Optional["_AssetConstructionData"]]] = None + + +class AssetManager: + def __init__(self, cache_max_size_bytes: int = 100 * 1024 * 1024): + self.stores: Dict[str, AssetStore] = {} + self._serializers: List[Tuple[Type[AssetSerializer], Type[Asset]]] = [] + self._asset_classes: Dict[str, Type[Asset]] = {} + self.asset_cache = AssetCache(max_size_bytes=cache_max_size_bytes) + self._cacheable_stores: Set[str] = set() + logger.debug(f"AssetManager initialized (Thread: {threading.current_thread().name})") + + def register_store(self, store: AssetStore, cacheable: bool = False): + """Registers an AssetStore with the manager.""" + logger.debug(f"Registering store: {store.name}, cacheable: {cacheable}") + self.stores[store.name] = store + if cacheable: + self._cacheable_stores.add(store.name) + + def get_serializer_for_class(self, asset_class: Type[Asset]): + for serializer, theasset_class in self._serializers: + if issubclass(asset_class, theasset_class): + return serializer + raise ValueError(f"No serializer found for class {asset_class}") + + def register_asset(self, asset_class: Type[Asset], serializer: Type[AssetSerializer]): + """Registers an Asset class with the manager.""" + if not issubclass(asset_class, Asset): + raise TypeError(f"Item '{asset_class.__name__}' must be a subclass of Asset.") + if not issubclass(serializer, AssetSerializer): + raise TypeError(f"Item '{serializer.__name__}' must be a subclass of AssetSerializer.") + self._serializers.append((serializer, asset_class)) + + asset_type_name = getattr(asset_class, "asset_type", None) + if not isinstance(asset_type_name, str) or not asset_type_name: # Ensure not empty + raise TypeError( + f"Asset class '{asset_class.__name__}' must have a non-empty string 'asset_type' attribute." + ) + + logger.debug(f"Registering asset type: '{asset_type_name}' -> {asset_class.__name__}") + self._asset_classes[asset_type_name] = asset_class + + async def _fetch_asset_construction_data_recursive_async( + self, + uri: AssetUri, + store_name: str, + visited_uris: Set[AssetUri], + depth: Optional[int] = None, + ) -> Optional[_AssetConstructionData]: + logger.debug( + f"_fetch_asset_construction_data_recursive_async called {store_name} {uri} {depth}" + ) + + if uri in visited_uris: + logger.error(f"Cyclic dependency detected for URI: {uri}") + raise RuntimeError(f"Cyclic dependency encountered for URI: {uri}") + + # Check arguments + store = self.stores.get(store_name) + if not store: + raise ValueError(f"No store registered for name: {store_name}") + asset_class = self._asset_classes.get(uri.asset_type) + if not asset_class: + raise ValueError(f"No asset class registered for asset type: {asset_class}") + + # Fetch the requested asset + try: + raw_data = await store.get(uri) + except FileNotFoundError: + logger.debug( + f"_fetch_asset_construction_data_recursive_async: Asset not found for {uri}" + ) + return None # Primary asset not found + + if depth == 0: + return _AssetConstructionData( + uri=uri, + raw_data=raw_data, + asset_class=asset_class, + dependencies_data=None, # Indicates that no attempt was made to fetch deps + ) + + # Extract the list of dependencies (non-recursive) + serializer = self.get_serializer_for_class(asset_class) + dependency_uris = asset_class.extract_dependencies(raw_data, serializer) + + # Initialize deps_construction_data_map. Any dependencies mapped to None + # indicate that dependencies were intentionally not fetched. + deps_construction_data: Dict[AssetUri, Optional[_AssetConstructionData]] = {} + + for dep_uri in dependency_uris: + visited_uris.add(uri) + try: + dep_data = await self._fetch_asset_construction_data_recursive_async( + dep_uri, + store_name, + visited_uris, + None if depth is None else depth - 1, + ) + finally: + visited_uris.remove(uri) + deps_construction_data[dep_uri] = dep_data + + logger.debug( + f"ToolBitShape '{uri.asset_id}' dependencies_data: {deps_construction_data is None}" + ) + + return _AssetConstructionData( + uri=uri, + raw_data=raw_data, + asset_class=asset_class, + dependencies_data=deps_construction_data, # Can be None or Dict + ) + + def _calculate_cache_key_from_construction_data( + self, + construction_data: _AssetConstructionData, + store_name_for_cache: str, + ) -> Optional[CacheKey]: + if not construction_data or not construction_data.raw_data: + return None + + if construction_data.dependencies_data is None: + deps_signature_tuple: Tuple = ("shallow_children",) + else: + deps_signature_tuple = tuple( + sorted(str(uri) for uri in construction_data.dependencies_data.keys()) + ) + + raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16) + + return CacheKey( + store_name=store_name_for_cache, + asset_uri_str=str(construction_data.uri), + raw_data_hash=raw_data_hash, + dependency_signature=deps_signature_tuple, + ) + + def _build_asset_tree_from_data_sync( + self, + construction_data: Optional[_AssetConstructionData], + store_name_for_cache: str, + ) -> Asset | None: + """ + Synchronously and recursively builds an asset instance. + Integrates caching logic. + """ + if not construction_data: + return None + + cache_key: Optional[CacheKey] = None + if store_name_for_cache in self._cacheable_stores: + cache_key = self._calculate_cache_key_from_construction_data( + construction_data, store_name_for_cache + ) + if cache_key: + cached_asset = self.asset_cache.get(cache_key) + if cached_asset is not None: + return cached_asset + + logger.debug( + f"BuildAssetTreeSync: Instantiating '{construction_data.uri}' " + f"of type '{construction_data.asset_class.__name__}'" + ) + + resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None + if construction_data.dependencies_data is not None: + resolved_dependencies = {} + for ( + dep_uri, + dep_data_node, + ) in construction_data.dependencies_data.items(): + # Assuming dependencies are fetched from the same store context + # for caching purposes. If a dependency *could* be from a + # different store and that store has different cacheability, + # this would need more complex store_name propagation. + # For now, use the parent's store_name_for_cache. + try: + dep = self._build_asset_tree_from_data_sync(dep_data_node, store_name_for_cache) + except Exception as e: + logger.error( + f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}", + exc_info=True, + ) + else: + resolved_dependencies[dep_uri] = dep + + asset_class = construction_data.asset_class + serializer = self.get_serializer_for_class(asset_class) + try: + final_asset = asset_class.from_bytes( + construction_data.raw_data, + construction_data.uri.asset_id, + resolved_dependencies, + serializer, + ) + except Exception as e: + logger.error( + f"Error instantiating asset '{construction_data.uri}' of type '{asset_class.__name__}': {e}", + exc_info=True, + ) + return None + + if final_asset is not None and cache_key: + # This check implies store_name_for_cache was in _cacheable_stores + direct_deps_uris_strs: Set[str] = set() + if construction_data.dependencies_data is not None: + direct_deps_uris_strs = { + str(uri) for uri in construction_data.dependencies_data.keys() + } + raw_data_size = len(construction_data.raw_data) + self.asset_cache.put( + cache_key, + final_asset, + raw_data_size, + direct_deps_uris_strs, + ) + return final_asset + + def get( + self, + uri: Union[AssetUri, str], + store: str = "local", + depth: Optional[int] = None, + ) -> Asset: + """ + Retrieves an asset by its URI (synchronous wrapper), to a specified depth. + IMPORTANT: Assumes this method is CALLED ONLY from the main UI thread + if Asset.from_bytes performs UI operations. + Depth None means infinite depth. Depth 0 means only this asset, no dependencies. + """ + # Log entry with thread info for verification + calling_thread_name = threading.current_thread().name + logger.debug( + f"AssetManager.get(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}" + ) + if ( + QtGui.QApplication.instance() + and QtCore.QThread.currentThread() is not QtGui.QApplication.instance().thread() + ): + logger.warning( + "AssetManager.get() called from a non-main thread! UI in from_bytes may fail!" + ) + + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + # Step 1: Fetch all data using asyncio.run + try: + logger.debug( + f"Get: Starting asyncio.run for data fetching of '{asset_uri_obj}', depth {depth}." + ) + all_construction_data = asyncio.run( + self._fetch_asset_construction_data_recursive_async( + asset_uri_obj, store, set(), depth + ) + ) + logger.debug( + f"Get: asyncio.run for data fetching of '{asset_uri_obj}', depth {depth} completed." + ) + except Exception as e: + logger.error( + f"Get: Error during asyncio.run data fetching for '{asset_uri_obj}': {e}", + exc_info=False, + ) + raise # Re-raise the exception from the async part + + if all_construction_data is None: + # This means the top-level asset itself was not found by _fetch_... + raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in store '{store}'.") + + # Step 2: Synchronously build the asset tree (and call from_bytes) + # This happens in the current thread (which is assumed to be the main UI thread) + deps_count = 0 + found_deps_count = 0 + if all_construction_data.dependencies_data is not None: + deps_count = len(all_construction_data.dependencies_data) + found_deps_count = sum( + 1 + for d in all_construction_data.dependencies_data.values() + if d is not None # Count actual data, not None placeholders + ) + + logger.debug( + f"Get: Starting synchronous asset tree build for '{asset_uri_obj}' " + f"and {deps_count} dependencies ({found_deps_count} resolved)." + ) + final_asset = self._build_asset_tree_from_data_sync( + all_construction_data, store_name_for_cache=store + ) + logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.") + return final_asset + + def get_or_none( + self, + uri: Union[AssetUri, str], + store: str = "local", + depth: Optional[int] = None, + ) -> Asset | None: + """ + Convenience wrapper for get() that does not raise FileNotFoundError; returns + None instead + """ + try: + return self.get(uri, store, depth) + except FileNotFoundError: + return None + + async def get_async( + self, + uri: Union[AssetUri, str], + store: str = "local", + depth: Optional[int] = None, + ) -> Optional[Asset]: + """ + Retrieves an asset by its URI (asynchronous), to a specified depth. + NOTE: If Asset.from_bytes does UI work, this method should ideally be awaited + from an asyncio loop that is integrated with the main UI thread (e.g., via QtAsyncio). + If awaited from a plain worker thread's asyncio loop, from_bytes will run on that worker. + """ + calling_thread_name = threading.current_thread().name + logger.debug( + f"AssetManager.get_async(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}" + ) + + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + all_construction_data = await self._fetch_asset_construction_data_recursive_async( + asset_uri_obj, store, set(), depth + ) + + if all_construction_data is None: + # Consistent with get(), if the top-level asset is not found, + # raise FileNotFoundError. + raise FileNotFoundError( + f"Asset '{asset_uri_obj}' not found in store '{store}' (async path)." + ) + # return None # Alternative: if Optional[Asset] means asset might not exist + + # Instantiation happens in the context of where this get_async was awaited. + logger.debug( + f"get_async: Building asset tree for '{asset_uri_obj}', depth {depth} in current async context." + ) + return self._build_asset_tree_from_data_sync( + all_construction_data, store_name_for_cache=store + ) + + def get_raw(self, uri: Union[AssetUri, str], store: str = "local") -> bytes: + """Retrieves raw asset data by its URI (synchronous wrapper).""" + logger.debug( + f"AssetManager.get_raw(uri='{uri}', store='{store}') from T:{threading.current_thread().name}" + ) + + async def _fetch_raw_async(): + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + logger.debug( + f"GetRawAsync (internal): Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + try: + selected_store = self.stores[store] + except KeyError: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.get(asset_uri_obj) + + try: + return asyncio.run(_fetch_raw_async()) + except Exception as e: + logger.error( + f"GetRaw: Error during asyncio.run for '{uri}': {e}", + exc_info=False, + ) + raise + + async def get_raw_async(self, uri: Union[AssetUri, str], store: str = "local") -> bytes: + """Retrieves raw asset data by its URI (asynchronous).""" + logger.debug( + f"AssetManager.get_raw_async(uri='{uri}', store='{store}') from T:{threading.current_thread().name}" + ) + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + selected_store = self.stores[store] + return await selected_store.get(asset_uri_obj) + + def get_bulk( + self, + uris: Sequence[Union[AssetUri, str]], + store: str = "local", + depth: Optional[int] = None, + ) -> List[Any]: + """Retrieves multiple assets by their URIs (synchronous wrapper), to a specified depth.""" + logger.debug( + f"AssetManager.get_bulk for {len(uris)} URIs from store '{store}', depth '{depth}'" + ) + + async def _fetch_all_construction_data_bulk_async(): + tasks = [ + self._fetch_asset_construction_data_recursive_async( + AssetUri(u) if isinstance(u, str) else u, + store, + set(), + depth, + ) + for u in uris + ] + # Gather all construction data concurrently + # return_exceptions=True means results list can contain exceptions + return await asyncio.gather(*tasks, return_exceptions=True) + + try: + logger.debug("GetBulk: Starting bulk data fetching") + all_construction_data_list = asyncio.run(_fetch_all_construction_data_bulk_async()) + logger.debug("GetBulk: bulk data fetching completed") + except Exception as e: # Should ideally not happen if gather returns exceptions + logger.error( + f"GetBulk: Unexpected error during asyncio.run for bulk data: {e}", + exc_info=False, + ) + raise + + assets = [] + for i, data_or_exc in enumerate(all_construction_data_list): + original_uri_input = uris[i] + # Explicitly re-raise exceptions found in the results list + if isinstance(data_or_exc, Exception): + logger.error( + f"GetBulk: Re-raising exception for '{original_uri_input}': {data_or_exc}", + exc_info=False, + ) + raise data_or_exc + elif isinstance(data_or_exc, _AssetConstructionData): + # Build asset instance synchronously. Exceptions during build should propagate. + assets.append( + self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store) + ) + elif data_or_exc is None: # From _fetch_... returning None for not found + logger.debug(f"GetBulk: Asset '{original_uri_input}' not found") + assets.append(None) + else: # Should not happen + logger.error( + f"GetBulk: Unexpected item in construction data list for '{original_uri_input}': {type(data_or_exc)}" + ) + # Raise an exception for unexpected data types + raise RuntimeError( + f"Unexpected data type for {original_uri_input}: {type(data_or_exc)}" + ) + return assets + + async def get_bulk_async( + self, + uris: Sequence[Union[AssetUri, str]], + store: str = "local", + depth: Optional[int] = None, + ) -> List[Any]: + """Retrieves multiple assets by their URIs (asynchronous), to a specified depth.""" + logger.debug( + f"AssetManager.get_bulk_async for {len(uris)} URIs from store '{store}', depth '{depth}'" + ) + tasks = [ + self._fetch_asset_construction_data_recursive_async( + AssetUri(u) if isinstance(u, str) else u, store, set(), depth + ) + for u in uris + ] + all_construction_data_list = await asyncio.gather(*tasks, return_exceptions=True) + + assets = [] + for i, data_or_exc in enumerate(all_construction_data_list): + if isinstance(data_or_exc, _AssetConstructionData): + assets.append( + self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store) + ) + elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None: + assets.append(None) + elif isinstance(data_or_exc, Exception): + assets.append(data_or_exc) # Caller must check + return assets + + def fetch( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + depth: Optional[int] = None, + ) -> List[Asset]: + """Fetches asset instances based on type, limit, and offset (synchronous), to a specified depth.""" + logger.debug(f"Fetch(type='{asset_type}', store='{store}', depth='{depth}')") + asset_uris = self.list_assets( + asset_type, limit, offset, store + ) # list_assets doesn't need depth + results = self.get_bulk(asset_uris, store, depth) # Pass depth to get_bulk + # Filter out non-Asset objects (e.g., None for not found, or exceptions if collected) + return [asset for asset in results if isinstance(asset, Asset)] + + async def fetch_async( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + depth: Optional[int] = None, + ) -> List[Asset]: + """Fetches asset instances based on type, limit, and offset (asynchronous), to a specified depth.""" + logger.debug(f"FetchAsync(type='{asset_type}', store='{store}', depth='{depth}')") + asset_uris = await self.list_assets_async( + asset_type, limit, offset, store # list_assets_async doesn't need depth + ) + results = await self.get_bulk_async( + asset_uris, store, depth + ) # Pass depth to get_bulk_async + return [asset for asset in results if isinstance(asset, Asset)] + + def list_assets( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + ) -> List[AssetUri]: + logger.debug(f"ListAssets(type='{asset_type}', store='{store}')") + return asyncio.run(self.list_assets_async(asset_type, limit, offset, store)) + + async def list_assets_async( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + ) -> List[AssetUri]: + logger.debug(f"ListAssetsAsync executing for type='{asset_type}', store='{store}'") + logger.debug( + f"ListAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + try: + selected_store = self.stores[store] + except KeyError: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.list_assets(asset_type, limit, offset) + + def count_assets( + self, + asset_type: Optional[str] = None, + store: str = "local", + ) -> int: + logger.debug(f"CountAssets(type='{asset_type}', store='{store}')") + return asyncio.run(self.count_assets_async(asset_type, store)) + + async def count_assets_async( + self, + asset_type: Optional[str] = None, + store: str = "local", + ) -> int: + logger.debug(f"CountAssetsAsync executing for type='{asset_type}', store='{store}'") + logger.debug( + f"CountAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + try: + selected_store = self.stores[store] + except KeyError: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.count_assets(asset_type) + + def _is_registered_type(self, obj: Asset) -> bool: + """Helper to extract asset_type, id, and data from an object instance.""" + for registered_class_type in self._asset_classes.values(): + if isinstance(obj, registered_class_type): + return True + return False + + async def add_async(self, obj: Asset, store: str = "local") -> AssetUri: + """ + Adds an asset to the store, either creating a new one or updating an existing one. + Uses obj.get_url() to determine if the asset exists. + """ + logger.debug(f"AddAsync: Adding {type(obj).__name__} to store '{store}'") + uri = obj.get_uri() + if not self._is_registered_type(obj): + logger.warning(f"Asset has unregistered type '{uri.asset_type}' ({type(obj).__name__})") + + serializer = self.get_serializer_for_class(obj.__class__) + data = obj.to_bytes(serializer) + return await self.add_raw_async(uri.asset_type, uri.asset_id, data, store) + + def add(self, obj: Asset, store: str = "local") -> AssetUri: + """Synchronous wrapper for adding an asset to the store.""" + logger.debug( + f"Add: Adding {type(obj).__name__} to store '{store}' from T:{threading.current_thread().name}" + ) + return asyncio.run(self.add_async(obj, store)) + + async def add_raw_async( + self, asset_type: str, asset_id: str, data: bytes, store: str = "local" + ) -> AssetUri: + """ + Adds raw asset data to the store, either creating a new asset or updating an existing one. + """ + logger.debug(f"AddRawAsync: type='{asset_type}', id='{asset_id}', store='{store}'") + if not asset_type or not asset_id: + raise ValueError("asset_type and asset_id must be provided for add_raw.") + if not isinstance(data, bytes): + raise TypeError("Data for add_raw must be bytes.") + selected_store = self.stores.get(store) + if not selected_store: + raise ValueError(f"No store registered for name: {store}") + uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id) + try: + uri = await selected_store.update(uri, data) + logger.debug(f"AddRawAsync: Updated existing asset at {uri}") + except FileNotFoundError: + logger.debug( + f"AddRawAsync: Asset not found, creating new asset with {asset_type} and {asset_id}" + ) + uri = await selected_store.create(asset_type, asset_id, data) + + if store in self._cacheable_stores: + self.asset_cache.invalidate_for_uri(str(uri)) # Invalidate after add/update + return uri + + def add_raw( + self, asset_type: str, asset_id: str, data: bytes, store: str = "local" + ) -> AssetUri: + """Synchronous wrapper for adding raw asset data to the store.""" + logger.debug( + f"AddRaw: type='{asset_type}', id='{asset_id}', store='{store}' from T:{threading.current_thread().name}" + ) + try: + return asyncio.run(self.add_raw_async(asset_type, asset_id, data, store)) + except Exception as e: + logger.error( + f"AddRaw: Error for type='{asset_type}', id='{asset_id}': {e}", exc_info=False + ) + raise + + def add_file( + self, + asset_type: str, + path: pathlib.Path, + store: str = "local", + asset_id: str | None = None, + ) -> AssetUri: + """ + Convenience wrapper around add_raw(). + If asset_id is None, the path.stem is used as the id. + """ + return self.add_raw(asset_type, asset_id or path.stem, path.read_bytes(), store=store) + + def delete(self, uri: Union[AssetUri, str], store: str = "local") -> None: + logger.debug(f"Delete URI '{uri}' from store '{store}'") + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + async def _do_delete_async(): + selected_store = self.stores[store] + await selected_store.delete(asset_uri_obj) + if store in self._cacheable_stores: + self.asset_cache.invalidate_for_uri(str(asset_uri_obj)) + + asyncio.run(_do_delete_async()) + + async def delete_async(self, uri: Union[AssetUri, str], store: str = "local") -> None: + logger.debug(f"DeleteAsync URI '{uri}' from store '{store}'") + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + selected_store = self.stores[store] + await selected_store.delete(asset_uri_obj) + if store in self._cacheable_stores: + self.asset_cache.invalidate_for_uri(str(asset_uri_obj)) + + async def is_empty_async(self, asset_type: Optional[str] = None, store: str = "local") -> bool: + """Checks if the asset store has any assets of a given type (asynchronous).""" + logger.debug(f"IsEmptyAsync: type='{asset_type}', store='{store}'") + logger.debug( + f"IsEmptyAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + selected_store = self.stores.get(store) + if not selected_store: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.is_empty(asset_type) + + def is_empty(self, asset_type: Optional[str] = None, store: str = "local") -> bool: + """Checks if the asset store has any assets of a given type (synchronous wrapper).""" + logger.debug( + f"IsEmpty: type='{asset_type}', store='{store}' from T:{threading.current_thread().name}" + ) + try: + return asyncio.run(self.is_empty_async(asset_type, store)) + except Exception as e: + logger.error( + f"IsEmpty: Error for type='{asset_type}', store='{store}': {e}", + exc_info=False, + ) # Changed exc_info to False + raise + + async def list_versions_async( + self, uri: Union[AssetUri, str], store: str = "local" + ) -> List[AssetUri]: + """Lists available versions for a given asset URI (asynchronous).""" + logger.debug(f"ListVersionsAsync: uri='{uri}', store='{store}'") + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + logger.debug( + f"ListVersionsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + selected_store = self.stores.get(store) + if not selected_store: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.list_versions(asset_uri_obj) + + def list_versions(self, uri: Union[AssetUri, str], store: str = "local") -> List[AssetUri]: + """Lists available versions for a given asset URI (synchronous wrapper).""" + logger.debug( + f"ListVersions: uri='{uri}', store='{store}' from T:{threading.current_thread().name}" + ) + try: + return asyncio.run(self.list_versions_async(uri, store)) + except Exception as e: + logger.error( + f"ListVersions: Error for uri='{uri}', store='{store}': {e}", + exc_info=False, + ) # Changed exc_info to False + return [] # Return empty list on error to satisfy type hint + + def get_registered_asset_types(self) -> List[str]: + """Returns a list of registered asset type names.""" + return list(self._asset_classes.keys()) diff --git a/src/Mod/CAM/Path/Tool/assets/serializer.py b/src/Mod/CAM/Path/Tool/assets/serializer.py new file mode 100644 index 0000000000..327cc4680f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/serializer.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import abc +from abc import ABC +from typing import Mapping, List, Optional, Tuple, Type +from .uri import AssetUri +from .asset import Asset + + +class AssetSerializer(ABC): + for_class: Type[Asset] + extensions: Tuple[str] = tuple() + mime_type: str + can_import: bool = True + can_export: bool = True + + @classmethod + @abc.abstractmethod + def get_label(cls) -> str: + pass + + @classmethod + @abc.abstractmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + pass + + @classmethod + @abc.abstractmethod + def serialize(cls, asset: Asset) -> bytes: + """Serializes an asset object into bytes.""" + pass + + @classmethod + @abc.abstractmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> "Asset": + """ + Creates an asset object from serialized data and resolved dependencies. + If dependencies is None, it indicates a shallow load where dependencies + were not resolved. + """ + pass + + @classmethod + @abc.abstractmethod + def deep_deserialize(cls, data: bytes) -> Asset: + """ + Like deserialize(), but builds dependencies itself if they are + sufficiently defined in the data. + + This method is used for export/import, where some dependencies + may be embedded in the data, while others may not. + """ + pass + + +class DummyAssetSerializer(AssetSerializer): + """ + A serializer that does nothing. Can be used by simple assets that don't + need a non-native serialization. These type of assets can implement + extract_dependencies(), to_bytes() and from_bytes() methods that ignore + the given serializer. + """ + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + return b"" + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Asset: + raise RuntimeError("DummySerializer.deserialize() was called") diff --git a/src/Mod/CAM/Path/Tool/assets/store/__init__.py b/src/Mod/CAM/Path/Tool/assets/store/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/assets/store/base.py b/src/Mod/CAM/Path/Tool/assets/store/base.py new file mode 100644 index 0000000000..3722d08839 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/store/base.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import abc +from typing import List +from ..uri import AssetUri + + +class AssetStore(abc.ABC): + """ + Abstract base class for storing and retrieving asset data as raw bytes. + + Stores are responsible for handling the low-level interaction with a + specific storage backend (e.g., local filesystem, HTTP server) based + on the URI protocol. + """ + + def __init__(self, name: str, *args, **kwargs): + self.name = name + + @abc.abstractmethod + async def get(self, uri: AssetUri) -> bytes: + """ + Retrieve the raw byte data for the asset at the given URI. + + Args: + uri: The unique identifier for the asset. + + Returns: + The raw byte data of the asset. + + Raises: + FileNotFoundError: If the asset does not exist at the URI. + # Other store-specific exceptions may be raised. + """ + raise NotImplementedError + + @abc.abstractmethod + async def delete(self, uri: AssetUri) -> None: + """ + Delete the asset at the given URI. + + Args: + uri: The unique identifier for the asset to delete. + + Raises: + FileNotFoundError: If the asset does not exist at the URI. + # Other store-specific exceptions may be raised. + """ + raise NotImplementedError + + @abc.abstractmethod + async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri: + """ + Create a new asset in the store with the given data. + + The store determines the final URI for the new asset. The + `asset_type` can be used to influence the storage location + or URI structure (e.g., as part of the path). + + Args: + asset_type: The type of the asset (e.g., 'material', + 'toolbitshape'). + asset_id: The unique identifier for the asset. + data: The raw byte data of the asset to create. + + Returns: + The URI of the newly created asset. + + Raises: + # Store-specific exceptions may be raised (e.g., write errors). + """ + raise NotImplementedError + + @abc.abstractmethod + async def update(self, uri: AssetUri, data: bytes) -> AssetUri: + """ + Update the asset at the given URI with new data, creating a new version. + + Args: + uri: The unique identifier of the asset to update. + data: The new raw byte data for the asset. + + Raises: + FileNotFoundError: If the asset does not exist at the URI. + # Other store-specific exceptions may be raised (e.g., write errors). + """ + raise NotImplementedError + + @abc.abstractmethod + async def list_assets( + self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None + ) -> List[AssetUri]: + """ + List assets in the store, optionally filtered by asset type and + with pagination. For versioned stores, this lists the latest + version of each asset. + + Args: + asset_type: Optional filter for asset type. + limit: Maximum number of assets to return. + offset: Number of assets to skip from the beginning. + + Returns: + A list of URIs for the assets. + """ + raise NotImplementedError + + @abc.abstractmethod + async def count_assets(self, asset_type: str | None = None) -> int: + """ + Counts assets in the store, optionally filtered by asset type. + + Args: + asset_type: Optional filter for asset type. + + Returns: + The number of assets. + """ + raise NotImplementedError + + @abc.abstractmethod + async def list_versions(self, uri: AssetUri) -> List[AssetUri]: + """ + Lists available version identifiers for a specific asset URI. + + Args: + uri: The URI of the asset (version component is ignored). + + Returns: + A list of URIs pointing to the specific versions of the asset. + """ + raise NotImplementedError + + @abc.abstractmethod + async def is_empty(self, asset_type: str | None = None) -> bool: + """ + Checks if the store contains any assets, optionally filtered by asset + type. + + Args: + asset_type: Optional filter for asset type. + + Returns: + True if the store is empty (or empty for the given asset type), + False otherwise. + """ + raise NotImplementedError diff --git a/src/Mod/CAM/Path/Tool/assets/store/filestore.py b/src/Mod/CAM/Path/Tool/assets/store/filestore.py new file mode 100644 index 0000000000..753d8d9cac --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/store/filestore.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import re +import pathlib +from typing import List, Dict, Tuple, Optional, cast +from ..uri import AssetUri +from .base import AssetStore + + +class FileStore(AssetStore): + """ + Asset store implementation for the local filesystem with optional + versioning. + + Maps URIs of the form ://[/] + to paths within a base directory. + + The mapping to file system paths is configurable depending on the asset + type. Example mapping: + + mapping = { + "*": "{asset_type}/{asset_id}/{version}.dat", + "model": "models_dir/{asset_id}-{version}.ml", + "dataset": "data/{asset_id}.csv" # Unversioned (conceptual version "1") + } + + Placeholders like {version} are matched greedily (.*), but for compatibility, + versions are expected to be numeric strings for versioned assets. + """ + + DEFAULT_MAPPING = { + # Default from original problem doc was "{asset_type}/{asset_id}/{id}/" + # Adjusted to a more common simple case: + "*": "{asset_type}/{asset_id}/{version}" + } + + KNOWN_PLACEHOLDERS = {"asset_type", "asset_id", "id", "version"} + + def __init__( + self, + name: str, + base_dir: pathlib.Path, + mapping: Optional[Dict[str, str]] = None, + ): + super().__init__(name) + self._base_dir = base_dir.resolve() + self._mapping = mapping if mapping is not None else self.DEFAULT_MAPPING.copy() + self._validate_patterns_on_init() + # For _path_to_uri: iterate specific keys before '*' to ensure correct pattern matching + self._sorted_mapping_keys = sorted(self._mapping.keys(), key=lambda k: (k == "*", k)) + + def _validate_patterns_on_init(self): + if not self._mapping: + raise ValueError("Asset store mapping cannot be empty.") + + for asset_type_key, path_format in self._mapping.items(): + if not isinstance(path_format, str): + raise TypeError(f"Path format for key '{asset_type_key}' must be a string.") + + placeholders_in_format = set(re.findall(r"\{([^}]+)\}", path_format)) + for ph_name in placeholders_in_format: + if ph_name not in self.KNOWN_PLACEHOLDERS: + raise ValueError( + f"Unknown placeholder {{{ph_name}}} in pattern: '{path_format}'. Allowed: {self.KNOWN_PLACEHOLDERS}" + ) + + has_asset_id_ph = "asset_id" in placeholders_in_format or "id" in placeholders_in_format + if not has_asset_id_ph: + raise ValueError( + f"Pattern '{path_format}' for key '{asset_type_key}' must include {{asset_id}} or {{id}}." + ) + + # CORRECTED LINE: Check for the placeholder name "asset_type" not "{asset_type}" + if asset_type_key == "*" and "asset_type" not in placeholders_in_format: + raise ValueError( + f"Pattern '{path_format}' for wildcard key '*' must include {{asset_type}}." + ) + + @staticmethod + def _match_path_to_format_string(format_str: str, path_str_posix: str) -> Dict[str, str]: + """Matches a POSIX-style path string against a format string.""" + tokens = re.split(r"\{(.*?)\}", format_str) # format_str uses / + if len(tokens) == 1: # No placeholders + if format_str == path_str_posix: + return {} + raise ValueError(f"Path '{path_str_posix}' does not match pattern '{format_str}'") + + keywords = tokens[1::2] + regex_parts = [] + for i, literal_part in enumerate(tokens[0::2]): + # Literal parts from format_str (using /) are escaped. + # The path_str_posix is already normalized to /, so direct matching works. + regex_parts.append(re.escape(literal_part)) + if i < len(keywords): + regex_parts.append(f"(?P<{keywords[i]}>.*)") + + pattern_regex_str = "".join(regex_parts) + match_obj = re.fullmatch(pattern_regex_str, path_str_posix) + + if not match_obj: + raise ValueError( + f"Path '{path_str_posix}' does not match format '{format_str}' (regex: '{pattern_regex_str}')" + ) + return {kw: match_obj.group(kw) for kw in keywords} + + def _get_path_format_for_uri_type(self, uri_asset_type: str) -> str: + if uri_asset_type in self._mapping: + return self._mapping[uri_asset_type] + if "*" in self._mapping: + return self._mapping["*"] + raise ValueError( + f"No mapping pattern for asset_type '{uri_asset_type}' and no '*' fallback." + ) + + def _path_to_uri(self, file_path: pathlib.Path) -> Optional[AssetUri]: + """Converts a filesystem path to an AssetUri, if it matches a pattern.""" + if not file_path.is_file(): + return None + + try: + # Convert to relative path object first, then to POSIX string for matching + relative_path_obj = file_path.relative_to(self._base_dir) + relative_path_posix = relative_path_obj.as_posix() + except ValueError: + return None # Path not under base_dir + + for asset_type_key in self._sorted_mapping_keys: + path_format_str = self._mapping[asset_type_key] # Pattern uses / + try: + components = FileStore._match_path_to_format_string( + path_format_str, relative_path_posix + ) + + asset_id = components.get("asset_id", components.get("id")) + if not asset_id: + continue + + current_asset_type: str + if "{asset_type}" in path_format_str: + current_asset_type = components.get("asset_type", "") + if not current_asset_type: + continue + else: + current_asset_type = asset_type_key + if current_asset_type == "*": + continue # Invalid state, caught by validation + + version_str: str + if "{version}" in path_format_str: + version_str = components.get("version", "") + if not version_str or not version_str.isdigit(): + continue + else: + version_str = "1" + + return AssetUri.build( + asset_type=current_asset_type, + asset_id=asset_id, + version=version_str, + ) + except ValueError: # No match + continue + return None + + def set_dir(self, new_dir: pathlib.Path): + """Sets the base directory for the store.""" + self._base_dir = new_dir.resolve() + + def _uri_to_path(self, uri: AssetUri) -> pathlib.Path: + """Converts an AssetUri to a filesystem path using mapping.""" + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + + format_values: Dict[str, str] = { + "asset_type": uri.asset_type, + "asset_id": uri.asset_id, + "id": uri.asset_id, + } + + # Only add 'version' to format_values if the pattern expects it AND uri.version is set. + # uri.version must be a string for .format() (e.g. "1", not None). + if "{version}" in path_format_str: + if uri.version is None: + # This state implies an issue: a versioned pattern is being used + # but the URI hasn't had its version appropriately set (e.g. to "1" for create, + # or resolved from "latest"). + raise ValueError( + f"URI version is None for versioned pattern '{path_format_str}'. URI: {uri}" + ) + format_values["version"] = uri.version + + try: + # Patterns use '/', pathlib handles OS-specific path construction. + resolved_path_str = path_format_str.format(**format_values) + except KeyError as e: + raise ValueError( + f"Pattern '{path_format_str}' placeholder {{{e}}} missing in URI data for {uri}." + ) + + return self._base_dir / resolved_path_str + + async def get(self, uri: AssetUri) -> bytes: + """Retrieve the raw byte data for the asset at the given URI.""" + path_to_read: pathlib.Path + + if uri.version == "latest": + query_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + params=uri.params, + ) + versions = await self.list_versions(query_uri) + if not versions: + raise FileNotFoundError(f"No versions found for {uri.asset_type}://{uri.asset_id}") + latest_version_uri = versions[-1] # list_versions now returns AssetUri with params + path_to_read = self._uri_to_path(latest_version_uri) + else: + request_uri = uri + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + + if not is_versioned_pattern: + if uri.version is not None and uri.version != "1": + raise FileNotFoundError( + f"Asset type '{uri.asset_type}' is unversioned. " + f"Version '{uri.version}' invalid for URI {uri}. Use '1' or no version." + ) + if uri.version is None: # Conceptual "type://id" -> "type://id/1" + request_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version="1", + params=uri.params, + ) + elif ( + uri.version is None + ): # Versioned pattern but URI has version=None (and not "latest") + raise FileNotFoundError( + f"Version required for asset type '{uri.asset_type}' (pattern: '{path_format_str}'). URI: {uri}" + ) + path_to_read = self._uri_to_path(request_uri) + + try: + with open(path_to_read, mode="rb") as f: + return f.read() + except FileNotFoundError: + raise FileNotFoundError(f"Asset for URI {uri} not found at path {path_to_read}") + except IsADirectoryError: + raise FileNotFoundError(f"Asset URI {uri} resolved to a directory: {path_to_read}") + + async def delete(self, uri: AssetUri) -> None: + """Delete the asset at the given URI.""" + paths_to_delete: List[pathlib.Path] = [] + parent_dirs_of_deleted_files = set() # To track for cleanup + + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + + if uri.version is None: # Delete all versions or the single unversioned file + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) + if ( + parsed_uri + and parsed_uri.asset_type == uri.asset_type + and parsed_uri.asset_id == uri.asset_id + ): + paths_to_delete.append(path_obj) + else: # Delete a specific version or an unversioned file (if version is "1") + target_uri_for_path = uri + if not is_versioned_pattern: + if uri.version != "1": + return # Idempotent: non-"1" version of unversioned asset "deleted" + target_uri_for_path = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version="1", + params=uri.params, + ) + + path = self._uri_to_path(target_uri_for_path) + if path.is_file(): + paths_to_delete.append(path) + + for p_del in paths_to_delete: + try: + p_del.unlink() + parent_dirs_of_deleted_files.add(p_del.parent) + except FileNotFoundError: + pass + + # Clean up empty parent directories, from deepest first + sorted_parents = sorted( + list(parent_dirs_of_deleted_files), + key=lambda p: len(p.parts), + reverse=True, + ) + for parent_dir in sorted_parents: + current_cleanup_path = parent_dir + while ( + current_cleanup_path.exists() + and current_cleanup_path.is_dir() + and current_cleanup_path != self._base_dir + and current_cleanup_path.is_relative_to(self._base_dir) + and not any(current_cleanup_path.iterdir()) + ): + try: + current_cleanup_path.rmdir() + current_cleanup_path = current_cleanup_path.parent + except OSError: + break + + async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri: + """Create a new asset in the store with the given data.""" + # New assets are conceptually version "1" + uri_to_create = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="1") + asset_path = self._uri_to_path(uri_to_create) + + if asset_path.exists(): + # More specific error messages based on what exists + if asset_path.is_file(): + raise FileExistsError(f"Asset file already exists at {asset_path}") + if asset_path.is_dir(): + raise IsADirectoryError(f"A directory exists at target path {asset_path}") + raise FileExistsError(f"Path {asset_path} already exists (unknown type).") + + asset_path.parent.mkdir(parents=True, exist_ok=True) + with open(asset_path, mode="wb") as f: + f.write(data) + return uri_to_create + + async def update(self, uri: AssetUri, data: bytes) -> AssetUri: + """Update the asset at the given URI with new data, creating a new version.""" + # Get a Uri without the version number, use it to find all versions. + query_uri = AssetUri.build( + asset_type=uri.asset_type, asset_id=uri.asset_id, params=uri.params + ) + existing_versions = await self.list_versions(query_uri) + if not existing_versions: + raise FileNotFoundError( + f"No versions for asset {uri.asset_type}://{uri.asset_id} to update." + ) + + # Create a Uri for the NEXT version number. + latest_version_uri = existing_versions[-1] + latest_version_num = int(cast(str, latest_version_uri.version)) + next_version_str = str(latest_version_num + 1) + next_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version=next_version_str, + params=uri.params, + ) + asset_path = self._uri_to_path(next_uri) + + # If the file is versioned, then the new version should not yet exist. + # Double check to be sure. + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + if asset_path.exists() and is_versioned_pattern: + raise FileExistsError(f"Asset path for new version {asset_path} already exists.") + + # Done. Write to disk. + asset_path.parent.mkdir(parents=True, exist_ok=True) + with open(asset_path, mode="wb") as f: + f.write(data) + return next_uri + + async def list_assets( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> List[AssetUri]: + """ + List assets in the store, optionally filtered by asset type and + with pagination. For versioned stores, this lists the latest + version of each asset. + """ + latest_asset_versions: Dict[Tuple[str, str], str] = {} + + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) + if parsed_uri: + if asset_type is not None and parsed_uri.asset_type != asset_type: + continue + + key = (parsed_uri.asset_type, parsed_uri.asset_id) + current_version_str = cast(str, parsed_uri.version) # Is "1" or numeric string + + if key not in latest_asset_versions or int(current_version_str) > int( + latest_asset_versions[key] + ): + latest_asset_versions[key] = current_version_str + + result_uris: List[AssetUri] = [ + AssetUri.build( + asset_type=atype, asset_id=aid, version=vstr + ) # Params not included in list_assets results + for (atype, aid), vstr in latest_asset_versions.items() + ] + result_uris.sort(key=lambda u: (u.asset_type, u.asset_id, int(cast(str, u.version)))) + + start = offset if offset is not None else 0 + end = start + limit if limit is not None else len(result_uris) + return result_uris[start:end] + + async def count_assets(self, asset_type: Optional[str] = None) -> int: + """ + Counts assets in the store, optionally filtered by asset type. + """ + unique_assets: set[Tuple[str, str]] = set() + + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) + if parsed_uri: + if asset_type is not None and parsed_uri.asset_type != asset_type: + continue + unique_assets.add((parsed_uri.asset_type, parsed_uri.asset_id)) + + return len(unique_assets) + + async def list_versions(self, uri: AssetUri) -> List[AssetUri]: + """ + Lists available version identifiers for a specific asset URI. + Args: + uri: The URI of the asset (version component is ignored, params preserved). + Returns: + A list of AssetUri objects, sorted by version in ascending order. + """ + if uri.asset_id is None: + raise ValueError(f"Asset ID must be specified for listing versions: {uri}") + + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + + if not is_versioned_pattern: + # Check existence of the single unversioned file + # Conceptual version is "1", params from input URI are preserved + path_check_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version="1", + params=uri.params, + ) + path_to_asset = self._uri_to_path(path_check_uri) + if path_to_asset.is_file(): + return [path_check_uri] # Returns URI with version "1" and original params + return [] + + found_versions_strs: List[str] = [] + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) # This parsed_uri does not have params + if ( + parsed_uri + and parsed_uri.asset_type == uri.asset_type + and parsed_uri.asset_id == uri.asset_id + ): + # Version from path is guaranteed numeric string by _path_to_uri for versioned patterns + found_versions_strs.append(cast(str, parsed_uri.version)) + + if not found_versions_strs: + return [] + sorted_unique_versions = sorted(list(set(found_versions_strs)), key=int) + + return [ + AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version=v_str, + params=uri.params, + ) + for v_str in sorted_unique_versions + ] + + async def is_empty(self, asset_type: Optional[str] = None) -> bool: + """ + Checks if the store contains any assets, optionally filtered by asset + type. + """ + # Reuses list_assets which iterates files. + # Limit=1 makes it stop after finding the first asset. + assets = await self.list_assets(asset_type=asset_type, limit=1) + return not bool(assets) diff --git a/src/Mod/CAM/Path/Tool/assets/store/memory.py b/src/Mod/CAM/Path/Tool/assets/store/memory.py new file mode 100644 index 0000000000..6410b5acef --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/store/memory.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pprint +from typing import Dict, List, Optional +from ..uri import AssetUri +from .base import AssetStore + + +class MemoryStore(AssetStore): + """ + An in-memory implementation of the AssetStore. + + This store keeps all asset data in memory and is primarily intended for + testing and demonstration purposes. It does not provide persistence. + """ + + def __init__(self, name: str, *args, **kwargs): + super().__init__(name, *args, **kwargs) + self._data: Dict[str, Dict[str, Dict[str, bytes]]] = {} + self._versions: Dict[str, Dict[str, List[str]]] = {} + + async def get(self, uri: AssetUri) -> bytes: + asset_type = uri.asset_type + asset_id = uri.asset_id + version = uri.version or self._get_latest_version(asset_type, asset_id) + + if ( + asset_type not in self._data + or asset_id not in self._data[asset_type] + or version not in self._data[asset_type][asset_id] + ): + raise FileNotFoundError(f"Asset not found: {uri}") + + return self._data[asset_type][asset_id][version] + + async def delete(self, uri: AssetUri) -> None: + asset_type = uri.asset_type + asset_id = uri.asset_id + version = uri.version # Capture the version from the URI + + if asset_type not in self._data or asset_id not in self._data[asset_type]: + # Deleting non-existent asset should not raise an error + return + + if version: + # If a version is specified, try to delete only that version + if version in self._data[asset_type][asset_id]: + del self._data[asset_type][asset_id][version] + # Remove version from the versions list + if ( + asset_type in self._versions + and asset_id in self._versions[asset_type] + and version in self._versions[asset_type][asset_id] + ): + self._versions[asset_type][asset_id].remove(version) + + # If no versions left for this asset_id, clean up + if not self._data[asset_type][asset_id]: + del self._data[asset_type][asset_id] + if asset_type in self._versions and asset_id in self._versions[asset_type]: + del self._versions[asset_type][asset_id] + else: + # If no version is specified, delete the entire asset + del self._data[asset_type][asset_id] + if asset_type in self._versions and asset_id in self._versions[asset_type]: + del self._versions[asset_type][asset_id] + + # Clean up empty asset types + if asset_type in self._data and not self._data[asset_type]: + del self._data[asset_type] + if asset_type in self._versions and not self._versions[asset_type]: + del self._versions[asset_type] + + async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri: + if asset_type not in self._data: + self._data[asset_type] = {} + self._versions[asset_type] = {} + + if asset_id in self._data[asset_type]: + # For simplicity, create overwrites existing in this memory store + # A real store might handle this differently or raise an error + pass + + if asset_id not in self._data[asset_type]: + self._data[asset_type][asset_id] = {} + self._versions[asset_type][asset_id] = [] + + version = "1" + self._data[asset_type][asset_id][version] = data + self._versions[asset_type][asset_id].append(version) + + return AssetUri(f"{asset_type}://{asset_id}/{version}") + + async def update(self, uri: AssetUri, data: bytes) -> AssetUri: + asset_type = uri.asset_type + asset_id = uri.asset_id + + if asset_type not in self._data or asset_id not in self._data[asset_type]: + raise FileNotFoundError(f"Asset not found for update: {uri}") + + # Update should create a new version + latest_version = self._get_latest_version(asset_type, asset_id) + version = str(int(latest_version or 0) + 1) + + self._data[asset_type][asset_id][version] = data + self._versions[asset_type][asset_id].append(version) + + return AssetUri(f"{asset_type}://{asset_id}/{version}") + + async def list_assets( + self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None + ) -> List[AssetUri]: + all_uris: List[AssetUri] = [] + for current_type, assets in self._data.items(): + if asset_type is None or current_type == asset_type: + for asset_id in assets: + latest_version = self._get_latest_version(current_type, asset_id) + if latest_version: + all_uris.append(AssetUri(f"{current_type}://{asset_id}/{latest_version}")) + + # Apply offset and limit + start = offset if offset is not None else 0 + end = start + limit if limit is not None else len(all_uris) + return all_uris[start:end] + + async def count_assets(self, asset_type: str | None = None) -> int: + """ + Counts assets in the store, optionally filtered by asset type. + """ + if asset_type is None: + count = 0 + for assets_by_id in self._data.values(): + count += len(assets_by_id) + return count + else: + if asset_type in self._data: + return len(self._data[asset_type]) + return 0 + + async def list_versions(self, uri: AssetUri) -> List[AssetUri]: + asset_type = uri.asset_type + asset_id = uri.asset_id + + if asset_type not in self._versions or asset_id not in self._versions[asset_type]: + return [] + + version_uris: List[AssetUri] = [] + for version in self._versions[asset_type][asset_id]: + version_uris.append(AssetUri(f"{asset_type}://{asset_id}/{version}")) + return version_uris + + async def is_empty(self, asset_type: str | None = None) -> bool: + if asset_type is None: + return not bool(self._data) + else: + return asset_type not in self._data or not bool(self._data[asset_type]) + + def _get_latest_version(self, asset_type: str, asset_id: str) -> Optional[str]: + if ( + asset_type in self._versions + and asset_id in self._versions[asset_type] + and self._versions[asset_type][asset_id] + ): + return self._versions[asset_type][asset_id][-1] + return None + + def dump(self, print: bool = False) -> Dict[str, Dict[str, Dict[str, bytes]]] | None: + """ + Dumps the entire content of the memory store. + + Args: + print (bool): If True, pretty-prints the data to the console, + excluding the asset data itself. + + Returns: + Dict[str, Dict[str, Dict[str, bytes]]] | None: The stored data as a + dictionary, or None if print is True. + """ + if not print: + return self._data + + printable_data = {} + for asset_type, assets in self._data.items(): + printable_data[asset_type] = {} + for asset_id, versions in assets.items(): + printable_data[asset_type][asset_id] = {} + for version, data_bytes in versions.items(): + printable_data[asset_type][asset_id][ + version + ] = f"" + + pprint.pprint(printable_data, indent=4) + return self._data diff --git a/src/Mod/CAM/Path/Tool/assets/ui/__init__.py b/src/Mod/CAM/Path/Tool/assets/ui/__init__.py new file mode 100644 index 0000000000..d2104431b0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/__init__.py @@ -0,0 +1,6 @@ +from .filedialog import AssetOpenDialog, AssetSaveDialog + +__all__ = [ + "AssetOpenDialog", + "AssetSaveDialog", +] diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py new file mode 100644 index 0000000000..58eadc19c9 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +import FreeCAD +import Path +from typing import Optional, Tuple, Type, Iterable +from PySide.QtWidgets import QFileDialog, QMessageBox +from ..serializer import AssetSerializer, Asset +from .util import ( + make_import_filters, + make_export_filters, + get_serializer_from_extension, +) + + +class AssetOpenDialog(QFileDialog): + def __init__( + self, + asset_type: Type[Asset], + serializers: Iterable[Type[AssetSerializer]], + parent=None, + ): + super().__init__(parent) + self.asset_type = asset_type + self.serializers = list(serializers) + self.setWindowTitle("Open an asset") + self.setFileMode(QFileDialog.ExistingFile) + filters = make_import_filters(self.serializers) + self.setNameFilters(filters) + if filters: + self.selectNameFilter(filters[0]) # Default to "All supported files" + + def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]: + """Deserialize the selected file using the appropriate serializer.""" + file_extension = file_path.suffix.lower() + serializer_class = get_serializer_from_extension( + self.serializers, file_extension, for_import=True + ) + if not serializer_class: + QMessageBox.critical( + self, + "Error", + f"No supported serializer found for file extension '{file_extension}'", + ) + return None + try: + raw_data = file_path.read_bytes() + asset = serializer_class.deep_deserialize(raw_data) + if not isinstance(asset, self.asset_type): + raise TypeError(f"Deserialized asset is not of type {self.asset_type.asset_type}") + return asset + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to import asset: {e}") + return None + + def exec(self) -> Optional[Tuple[pathlib.Path, Asset]]: + if super().exec_(): + filenames = self.selectedFiles() + if filenames: + file_path = pathlib.Path(filenames[0]) + asset = self._deserialize_selected_file(file_path) + if asset: + return file_path, asset + return None + + +class AssetSaveDialog(QFileDialog): + def __init__( + self, + asset_type: Type[Asset], + serializers: Iterable[Type[AssetSerializer]], + parent=None, + ): + super().__init__(parent) + self.asset_type = asset_type + self.serializers = list(serializers) + self.setFileMode(QFileDialog.AnyFile) + self.setAcceptMode(QFileDialog.AcceptSave) + self.filters, self.serializer_map = make_export_filters(self.serializers) + self.setNameFilters(self.filters) + if self.filters: + self.selectNameFilter(self.filters[0]) # Default to "Automatic" + self.filterSelected.connect(self.update_default_suffix) + + def update_default_suffix(self, filter_str: str): + """Update the default suffix based on the selected filter.""" + if filter_str == "Automatic (*)": + self.setDefaultSuffix("") # No default for Automatic + else: + serializer = self.serializer_map.get(filter_str) + if serializer and serializer.extensions: + self.setDefaultSuffix(serializer.extensions[0]) + + def _serialize_selected_file( + self, + file_path: pathlib.Path, + asset: Asset, + serializer_class: Type[AssetSerializer], + ) -> bool: + """Serialize and save the asset.""" + try: + raw_data = serializer_class.serialize(asset) + file_path.write_bytes(raw_data) + return True + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to export asset: {e}") + return False + + def exec(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]: + self.setWindowTitle(f"Save {asset.label}") + if super().exec_(): + selected_filter = self.selectedNameFilter() + file_path = pathlib.Path(self.selectedFiles()[0]) + if selected_filter == "Automatic (*)": + if not file_path.suffix: + QMessageBox.critical( + self, + "Error", + "Please specify a file extension for automatic serializer selection.", + ) + return None + file_extension = file_path.suffix.lower() + serializer_class = get_serializer_from_extension( + self.serializers, file_extension, for_import=False + ) + if not serializer_class: + QMessageBox.critical( + self, + "Error", + f"No serializer found for extension '{file_extension}'", + ) + return None + else: + serializer_class = self.serializer_map.get(selected_filter) + if not serializer_class: + raise ValueError(f"No serializer found for filter '{selected_filter}'") + if self._serialize_selected_file(file_path, asset, serializer_class): + return file_path, serializer_class + return None diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py new file mode 100644 index 0000000000..5231c1194b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import pathlib +import tempfile +import FreeCAD +import Path +from PySide import QtGui, QtCore + +translate = FreeCAD.Qt.translate + + +def _is_writable_dir(path: pathlib.Path) -> bool: + """ + Check if a path is a writable directory. + Returns True if writable, False otherwise. + """ + if not path.is_dir(): + return False + try: + with tempfile.NamedTemporaryFile(dir=str(path), delete=True): + return True + except (OSError, PermissionError): + return False + + +class AssetPreferencesPage: + def __init__(self, parent=None): + self.form = QtGui.QToolBox() + self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets")) + + asset_path_widget = QtGui.QWidget() + main_layout = QtGui.QHBoxLayout(asset_path_widget) + + # Create widgets + self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:")) + self.asset_path_edit = QtGui.QLineEdit() + self.asset_path_note_label = QtGui.QLabel( + translate( + "CAM_PreferencesAssets", + "Note: Select the directory that will contain the " + "Bit/, Shape/, and Library/ subfolders.", + ) + ) + self.asset_path_note_label.setWordWrap(True) + self.select_path_button = QtGui.QToolButton() + self.select_path_button.setIcon(QtGui.QIcon.fromTheme("folder-open")) + self.select_path_button.clicked.connect(self.selectAssetPath) + self.reset_path_button = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Reset")) + self.reset_path_button.clicked.connect(self.resetAssetPath) + + # Set note label font to italic + font = self.asset_path_note_label.font() + 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, + ) + + main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop) + + self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets")) + + def selectAssetPath(self): + # Implement directory selection dialog + path = QtGui.QFileDialog.getExistingDirectory( + self.form, + translate("CAM_PreferencesAssets", "Select Asset Directory"), + self.asset_path_edit.text(), + ) + if path: + self.asset_path_edit.setText(str(path)) + + def resetAssetPath(self): + # Implement resetting path to default + default_path = Path.Preferences.getDefaultAssetPath() + self.asset_path_edit.setText(str(default_path)) + + def saveSettings(self): + # Check path is writable, then call Path.Preferences.setAssetPath() + asset_path = pathlib.Path(self.asset_path_edit.text()) + param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Path") + if param.GetBool("CheckAssetPathWritable", True): + if not _is_writable_dir(asset_path): + QtGui.QMessageBox.warning( + self.form, + translate("CAM_PreferencesAssets", "Warning"), + translate("CAM_PreferencesAssets", "The selected asset path is not writable."), + ) + return False + Path.Preferences.setAssetPath(asset_path) + return True + + def loadSettings(self): + # use getAssetPath() to initialize UI + asset_path = Path.Preferences.getAssetPath() + self.asset_path_edit.setText(str(asset_path)) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/util.py b/src/Mod/CAM/Path/Tool/assets/ui/util.py new file mode 100644 index 0000000000..ffa51da6d1 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/util.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from typing import List, Dict, Optional, Iterable, Type +from ..serializer import AssetSerializer + + +def make_import_filters(serializers: Iterable[Type[AssetSerializer]]) -> List[str]: + """ + Generates file dialog filters for importing assets. + + Args: + serializers: A list of AssetSerializer classes. + + Returns: + A list of filter strings, starting with "All supported files". + """ + all_extensions = [] + filters = [] + + for serializer_class in serializers: + if not serializer_class.can_import or not serializer_class.extensions: + continue + all_extensions.extend(serializer_class.extensions) + label = serializer_class.get_label() + extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions]) + filters.append(f"{label} ({extensions})") + + # Add "All supported files" filter if there are any extensions + if all_extensions: + combined_extensions = " ".join([f"*{ext}" for ext in sorted(list(set(all_extensions)))]) + filters.insert(0, f"All supported files ({combined_extensions})") + + return filters + + +def make_export_filters( + serializers: Iterable[Type[AssetSerializer]], +) -> tuple[List[str], Dict[str, Type[AssetSerializer]]]: + """ + Generates file dialog filters for exporting assets and a serializer map. + + Args: + serializers: A list of AssetSerializer classes. + + Returns: + A tuple of (filters, serializer_map) where filters is a list of filter strings + starting with "Automatic", and serializer_map maps filter strings to serializers. + """ + filters = ["Automatic (*)"] + serializer_map = {} + + for serializer_class in serializers: + if not serializer_class.can_export or not serializer_class.extensions: + continue + label = serializer_class.get_label() + extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions]) + filter_str = f"{label} ({extensions})" + filters.append(filter_str) + serializer_map[filter_str] = serializer_class + + return filters, serializer_map + + +def get_serializer_from_extension( + serializers: Iterable[Type[AssetSerializer]], + file_extension: str, + for_import: bool | None = None, +) -> Optional[Type[AssetSerializer]]: + """ + Finds a serializer class based on the file extension and import/export capability. + + Args: + serializers: A list of AssetSerializer classes. + file_extension: The file extension (without the leading dot). + for_import: None = both, True = import, False = export + + Returns: + The matching AssetSerializer class, or None if not found. + """ + for_export = for_import is not True + for_import = for_import is True + + for ser in serializers: + if for_import and not ser.can_import: + continue + if for_export and not ser.can_export: + continue + if file_extension in ser.extensions: + return ser + return None diff --git a/src/Mod/CAM/Path/Tool/assets/uri.py b/src/Mod/CAM/Path/Tool/assets/uri.py new file mode 100644 index 0000000000..1106806254 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/uri.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from __future__ import annotations +import urllib.parse +from typing import Dict, Any, Mapping + + +class AssetUri: + """ + Represents an asset URI with components. + + The URI structure is: ://[/version] + """ + + def __init__(self, uri_string: str): + # Manually parse the URI string + parts = uri_string.split("://", 1) + if len(parts) != 2: + raise ValueError(f"Invalid URI structure: {uri_string}") + + self.asset_type = parts[0] + rest = parts[1] + + # Split asset_id, version, and params + path_and_query = rest.split("?", 1) + path_parts = path_and_query[0].split("/") + + if not path_parts or not path_parts[0]: + raise ValueError(f"Invalid URI structure: {uri_string}") + + self.asset_id = path_parts[0] + self.version = path_parts[1] if len(path_parts) > 1 else None + + if len(path_parts) > 2: + raise ValueError(f"Invalid URI path structure: {uri_string}") + + self.params: Dict[str, list[str]] = {} + if len(path_and_query) > 1: + self.params = urllib.parse.parse_qs(path_and_query[1]) + + if not self.asset_type or not self.asset_id: + raise ValueError(f"Invalid URI structure: {uri_string}") + + def __str__(self) -> str: + path = f"/{self.version}" if self.version else "" + + query = urllib.parse.urlencode(self.params, doseq=True) if self.params else "" + + uri_string = urllib.parse.urlunparse((self.asset_type, self.asset_id, path, "", query, "")) + return uri_string + + def __repr__(self) -> str: + return f"AssetUri('{str(self)}')" + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, AssetUri): + return NotImplemented + return ( + self.asset_type == other.asset_type + and self.asset_id == other.asset_id + and self.version == other.version + and self.params == other.params + ) + + def __hash__(self) -> int: + """Returns a hash value for the AssetUri.""" + return hash((self.asset_type, self.asset_id, self.version, frozenset(self.params.items()))) + + @classmethod + def is_uri(cls, uri: AssetUri | str) -> bool: + """Checks if the given string is a valid URI.""" + if isinstance(uri, AssetUri): + return True + + try: + AssetUri(uri) + except ValueError: + return False + return True + + @staticmethod + def build( + asset_type: str, + asset_id: str, + version: str | None = None, + params: Mapping[str, str | list[str]] | None = None, + ) -> AssetUri: + """Builds a Uri object from components.""" + uri = AssetUri.__new__(AssetUri) # Create a new instance without calling __init__ + uri.asset_type = asset_type + uri.asset_id = asset_id + uri.version = version + uri.params = {} + if params: + for key, value in params.items(): + if isinstance(value, list): + uri.params[key] = value + else: + uri.params[key] = [value] + return uri diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py new file mode 100644 index 0000000000..1b6d2c57e6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import Path +from Path import Preferences +from Path.Preferences import addToolPreferenceObserver +from .assets import AssetManager, FileStore + + +def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): + """ + Ensures the given store is initialized with built-in library + if it is currently empty. + """ + builtin_library_path = Preferences.getBuiltinLibraryPath() + + if asset_manager.is_empty("toolbitlibrary", store=store_name): + for path in builtin_library_path.glob("*.fctl"): + asset_manager.add_file("toolbitlibrary", path) + + +def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): + """ + Ensures the given store is initialized with built-in bits + if it is currently empty. + """ + builtin_toolbit_path = Preferences.getBuiltinToolBitPath() + + if asset_manager.is_empty("toolbit", store=store_name): + for path in builtin_toolbit_path.glob("*.fctb"): + asset_manager.add_file("toolbit", path) + + +def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): + """ + Ensures the given store is initialized with built-in shapes + if it is currently empty. + """ + builtin_shape_path = Preferences.getBuiltinShapePath() + + if asset_manager.is_empty("toolbitshape", store=store_name): + for path in builtin_shape_path.glob("*.fcstd"): + asset_manager.add_file("toolbitshape", path) + + if asset_manager.is_empty("toolbitshapesvg", store=store_name): + for path in builtin_shape_path.glob("*.svg"): + asset_manager.add_file("toolbitshapesvg", path, asset_id=path.stem + ".svg") + + if asset_manager.is_empty("toolbitshapepng", store=store_name): + for path in builtin_shape_path.glob("*.png"): + asset_manager.add_file("toolbitshapepng", path, asset_id=path.stem + ".png") + + +def ensure_assets_initialized(asset_manager: AssetManager, store="local"): + """ + Ensures the given store is initialized with built-in assets. + """ + ensure_library_assets_initialized(asset_manager, store) + ensure_toolbit_assets_initialized(asset_manager, store) + ensure_toolbitshape_assets_initialized(asset_manager, store) + + +def _on_asset_path_changed(group, key, value): + Path.Log.info(f"CAM asset directory changed in preferences: {group} {key} {value}") + cam_asset_store.set_dir(Preferences.getAssetPath()) + ensure_assets_initialized(cam_assets) + + +# Set up the local CAM asset storage. +cam_asset_store = FileStore( + name="local", + base_dir=Preferences.getAssetPath(), + mapping={ + "toolbitlibrary": "Library/{asset_id}.fctl", + "toolbit": "Bit/{asset_id}.fctb", + "toolbitshape": "Shape/{asset_id}.fcstd", + "toolbitshapesvg": "Shape/{asset_id}", # Asset ID has ".svg" included + "toolbitshapepng": "Shape/{asset_id}", # Asset ID has ".png" included + "machine": "Machine/{asset_id}.fcm", + }, +) + +# Set up the CAM asset manager. +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +cam_assets = AssetManager() +cam_assets.register_store(cam_asset_store) +try: + ensure_assets_initialized(cam_assets) +except Exception as e: + Path.Log.error(f"Failed to initialize CAM assets in {cam_asset_store._base_dir}: {e}") +else: + Path.Log.debug(f"Using CAM assets in {cam_asset_store._base_dir}") +addToolPreferenceObserver(_on_asset_path_changed) diff --git a/src/Mod/CAM/Path/Tool/library/__init__.py b/src/Mod/CAM/Path/Tool/library/__init__.py new file mode 100644 index 0000000000..8eccc2f24b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .models.library import Library + +__all__ = [ + "Library", +] diff --git a/src/Mod/CAM/Path/Tool/library/models/__init__.py b/src/Mod/CAM/Path/Tool/library/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/library/models/library.py b/src/Mod/CAM/Path/Tool/library/models/library.py new file mode 100644 index 0000000000..d2964d0ea8 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/models/library.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import pathlib +from typing import Mapping, Union, Optional, List, Dict, cast +import Path +from ...assets import Asset, AssetUri +from ...toolbit import ToolBit + + +class Library(Asset): + asset_type: str = "toolbitlibrary" + API_VERSION = 1 + + def __init__(self, label, id=None): + self.id = id if id is not None else str(uuid.uuid4()) + self._label = label + self._bits: List[ToolBit] = [] + self._bit_nos: Dict[int, ToolBit] = {} + self._bit_urls: Dict[AssetUri, ToolBit] = {} + + @property + def label(self) -> str: + return self._label + + def get_id(self) -> str: + """Returns the unique identifier for the Library instance.""" + return self.id + + @classmethod + def resolve_name(cls, identifier: Union[str, AssetUri, pathlib.Path]) -> AssetUri: + """ + Resolves various forms of library identifiers to a canonical AssetUri string. + Handles direct AssetUri objects, URI strings, asset IDs, or legacy filenames. + Returns the canonical URI string or None if resolution fails. + """ + if isinstance(identifier, AssetUri): + return identifier + + if isinstance(identifier, str) and AssetUri.is_uri(identifier): + return AssetUri(identifier) + + if isinstance(identifier, pathlib.Path): # Handle direct Path objects (legacy filenames) + identifier = identifier.stem # Use the filename stem as potential ID + + if not isinstance(identifier, str): + raise ValueError("Failed to resolve {identifier} to a Uri") + + return AssetUri.build(asset_type=Library.asset_type, asset_id=identifier) + + def to_dict(self) -> dict: + """Returns a dictionary representation of the Library in the specified format.""" + tools_list = [] + for tool_no, tool in self._bit_nos.items(): + tools_list.append( + {"nr": tool_no, "path": f"{tool.get_id()}.fctb"} # Tool ID with .fctb extension + ) + return {"label": self.label, "tools": tools_list, "version": self.API_VERSION} + + @classmethod + def from_dict( + cls, + data_dict: dict, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> "Library": + """ + Creates a Library instance from a dictionary and resolved dependencies. + If dependencies is None, it's a shallow load, and tools are not populated. + """ + library = cls(data_dict.get("label", id or "Unnamed Library"), id=id) + + if dependencies is None: + Path.Log.debug( + f"Library.from_dict: Shallow load for library '{library.label}' (id: {id}). Tools not populated." + ) + return library # Only process tools if dependencies were resolved + + tools_list = data_dict.get("tools", []) + for tool_data in tools_list: + tool_no = tool_data["nr"] + tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID + tool_uri = AssetUri(f"toolbit://{tool_id}") + bit = cast(ToolBit, dependencies.get(tool_uri)) + if bit: + library.add_bit(bit, bit_no=tool_no) + else: + raise ValueError(f"Tool with id {tool_id} not found in dependencies") + return library + + def __str__(self): + return '{} "{}"'.format(self.id, self.label) + + def __eq__(self, other): + return self.id == other.id + + def __iter__(self): + return self._bits.__iter__() + + def get_next_bit_no(self): + bit_nolist = sorted(self._bit_nos, reverse=True) + return bit_nolist[0] + 1 if bit_nolist else 1 + + def get_bit_no_from_bit(self, bit: ToolBit) -> Optional[int]: + for bit_no, thebit in self._bit_nos.items(): + if bit == thebit: + return bit_no + return None + + def get_tool_by_uri(self, uri: AssetUri) -> Optional[ToolBit]: + for tool in self._bit_nos.values(): + if tool.get_uri() == uri: + return tool + return None + + def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + if bit not in self._bits: + return + + # If no specific bit_no was requested, assign a new one. + if bit_no is None: + bit_no = self.get_next_bit_no() + elif self._bit_nos.get(bit_no) == bit: + return + + # Otherwise, add the bit. Since the requested bit_no may already + # be in use, we need to account for that. In this case, we will + # add the removed bit into a new bit_no. + old_bit = self._bit_nos.pop(bit_no, None) + old_bit_no = self.get_bit_no_from_bit(bit) + if old_bit_no: + del self._bit_nos[old_bit_no] + self._bit_nos[bit_no] = bit + if old_bit: + self.assign_new_bit_no(old_bit) + return bit_no + + def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + if bit not in self._bits: + self._bits.append(bit) + return self.assign_new_bit_no(bit, bit_no) + + def get_bits(self) -> List[ToolBit]: + return self._bits + + def has_bit(self, bit: ToolBit) -> bool: + for t in self._bits: + if bit.id == t.id: + return True + return False + + def remove_bit(self, bit: ToolBit): + self._bits = [t for t in self._bits if t.id != bit.id] + self._bit_nos = {k: v for (k, v) in self._bit_nos.items() if v.id != bit.id} + + def dump(self, summarize: bool = False): + title = 'Library "{}" ({}) (instance {})'.format(self.label, self.id, id(self)) + print("-" * len(title)) + print(title) + print("-" * len(title)) + for bit in self._bits: + print(f"- {bit.label} ({bit.get_id()})") + print() diff --git a/src/Mod/CAM/Path/Tool/library/serializers/__init__.py b/src/Mod/CAM/Path/Tool/library/serializers/__init__.py new file mode 100644 index 0000000000..2c4998e421 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/__init__.py @@ -0,0 +1,13 @@ +from .camotics import CamoticsLibrarySerializer +from .fctl import FCTLSerializer +from .linuxcnc import LinuxCNCSerializer + + +all_serializers = CamoticsLibrarySerializer, FCTLSerializer, LinuxCNCSerializer + + +__all__ = [ + "CamoticsLibrarySerializer", + "FCTLSerializer", + "LinuxCNCSerializer", +] diff --git a/src/Mod/CAM/Path/Tool/library/serializers/camotics.py b/src/Mod/CAM/Path/Tool/library/serializers/camotics.py new file mode 100644 index 0000000000..5d8a317769 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/camotics.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import json +from typing import Mapping, List, Optional, Type +import FreeCAD +from ...assets import Asset, AssetUri, AssetSerializer +from ...toolbit import ToolBit +from ...toolbit.mixins import RotaryToolBitMixin +from ...shape import ToolBitShape, ToolBitShapeEndmill +from ..models.library import Library + +SHAPEMAP = { + "ballend": "Ballnose", + "endmill": "Cylindrical", + "v-bit": "Conical", + "chamfer": "Snubnose", +} +SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items()) + +tooltemplate = { + "units": "metric", + "shape": "Cylindrical", + "length": 10, + "diameter": 3.125, + "description": "", +} + + +class CamoticsLibrarySerializer(AssetSerializer): + for_class: Type[Asset] = Library + extensions: tuple[str] = (".ctbl",) + mime_type: str = "application/json" + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "Camotics Tool Library") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + if not isinstance(asset, Library): + raise TypeError("Asset must be a Library instance") + + toollist = {} + for tool_no, tool in asset._bit_nos.items(): + assert isinstance(tool, RotaryToolBitMixin) + toolitem = tooltemplate.copy() + + diameter_value = tool.get_diameter() + # Ensure diameter is a float, handle Quantity and other types + diameter_serializable = 2.0 # Default value as float + if isinstance(diameter_value, FreeCAD.Units.Quantity): + try: + val_mm = diameter_value.getValueAs("mm") + if val_mm is not None: + diameter_serializable = float(val_mm) + except ValueError: + # Fallback to raw value if unit conversion fails + raw_val = diameter_value.Value if hasattr(diameter_value, "Value") else None + if isinstance(raw_val, (int, float)): + diameter_serializable = float(raw_val) + elif isinstance(diameter_value, (int, float)): + diameter_serializable = float(diameter_value) if diameter_value is not None else 2.0 + + toolitem["diameter"] = diameter_serializable + + toolitem["description"] = tool.label + + length_value = tool.get_length() + # Ensure length is a float, handle Quantity and other types + length_serializable = 10.0 # Default value as float + if isinstance(length_value, FreeCAD.Units.Quantity): + try: + val_mm = length_value.getValueAs("mm") + if val_mm is not None: + length_serializable = float(val_mm) + except ValueError: + # Fallback to raw value if unit conversion fails + raw_val = length_value.Value if hasattr(length_value, "Value") else None + if isinstance(raw_val, (int, float)): + length_serializable = float(raw_val) + elif isinstance(length_value, (int, float)): + length_serializable = float(length_value) if length_value is not None else 10.0 + + toolitem["length"] = length_serializable + + toolitem["shape"] = SHAPEMAP.get(tool._tool_bit_shape.name, "Cylindrical") + toollist[str(tool_no)] = toolitem + + return json.dumps(toollist, indent=2).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Library: + try: + data_dict = json.loads(data.decode("utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to decode JSON data: {e}") from e + + library = Library(id, id=id) + for tool_no_str, toolitem in data_dict.items(): + try: + tool_no = int(tool_no_str) + except ValueError: + print(f"Warning: Skipping invalid tool number: {tool_no_str}") + continue + + # Find the shape class to use + shape_name_str = SHAPEMAP_REVERSE.get(toolitem.get("shape", "Cylindrical"), "endmill") + shape_class = ToolBitShape.get_subclass_by_name(shape_name_str) + if not shape_class: + print(f"Warning: Unknown shape name '{shape_name_str}', defaulting to endmill") + shape_class = ToolBitShapeEndmill + + # Translate parameters to FreeCAD types + params = {} + try: + diameter = float(toolitem.get("diameter", 2)) + params["Diameter"] = FreeCAD.Units.Quantity(f"{diameter} mm") + except (ValueError, TypeError): + print(f"Warning: Invalid diameter for tool {tool_no_str}, skipping.") + + try: + length = float(toolitem.get("length", 10)) + params["Length"] = FreeCAD.Units.Quantity(f"{length} mm") + except (ValueError, TypeError): + print(f"Warning: Invalid length for tool {tool_no_str}, skipping.") + + # Create the shape + shape_id = shape_name_str.lower() + tool_bit_shape = shape_class(shape_id, **params) + + # Create the toolbit + tool = ToolBit(tool_bit_shape, id=f"camotics_tool_{tool_no_str}") + tool.label = toolitem.get("description", "") + + library.add_bit(tool, tool_no) + + return library + + @classmethod + def deep_deserialize(cls, data: bytes) -> Library: + # TODO: Build tools here + return cls.deserialize(data, str(uuid.uuid4()), {}) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py new file mode 100644 index 0000000000..c4de58afc8 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import json +from typing import Mapping, List, Optional +import pathlib +import FreeCAD +import Path +from ...assets import Asset, AssetUri, AssetSerializer +from ...toolbit import ToolBit +from ..models.library import Library + + +class FCTLSerializer(AssetSerializer): + for_class = Library + extensions = (".fctl",) + mime_type = "application/x-freecad-toolbit-library" + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + data_dict = json.loads(data.decode("utf-8")) + tools_list = data_dict.get("tools", []) + tool_ids = [pathlib.Path(tool["path"]).stem for tool in tools_list] + return [AssetUri(f"toolbit://{tool_id}") for tool_id in tool_ids] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + """Serializes a Library object into bytes.""" + if not isinstance(asset, Library): + raise TypeError(f"Expected Library instance, got {type(asset).__name__}") + attrs = asset.to_dict() + return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Library: + """ + Creates a Library instance from serialized data and resolved + dependencies. + """ + data_dict = json.loads(data.decode("utf-8")) + # The id parameter from the Asset.from_bytes method is the canonical ID + # for the asset being deserialized. We should use this ID for the library + # instance, overriding any 'id' that might be in the data_dict (which + # is from an older version of the format). + library = Library(data_dict.get("label", id or "Unnamed Library"), id=id) + + if dependencies is None: + Path.Log.debug( + f"FCTLSerializer.deserialize: Shallow load for library '{library.label}' (id: {id}). Tools not populated." + ) + return library # Only process tools if dependencies were resolved + + tools_list = data_dict.get("tools", []) + for tool_data in tools_list: + tool_no = tool_data["nr"] + tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID + tool_uri = AssetUri(f"toolbit://{tool_id}") + tool = dependencies.get(tool_uri) + if tool: + # Ensure the dependency is a ToolBit instance + if not isinstance(tool, ToolBit): + Path.Log.warning( + f"Dependency for tool '{tool_id}' is not a ToolBit instance. Skipping." + ) + continue + library.add_bit(tool, bit_no=tool_no) + else: + # This should not happen if dependencies were resolved correctly, + # but as a safeguard, log a warning and skip the tool. + Path.Log.warning( + f"Tool with id {tool_id} not found in dependencies during deserialization." + ) + return library + + @classmethod + def deep_deserialize(cls, data: bytes) -> Library: + # TODO: attempt to fetch tools from the asset manager here + return cls.deserialize(data, str(uuid.uuid4()), {}) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py new file mode 100644 index 0000000000..c3880dc44e --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import io +from typing import Mapping, List, Optional, Type +import FreeCAD +import Path +from ...assets import Asset, AssetUri, AssetSerializer +from ...toolbit import ToolBit +from ...toolbit.mixins import RotaryToolBitMixin +from ..models.library import Library + + +class LinuxCNCSerializer(AssetSerializer): + for_class: Type[Asset] = Library + extensions: tuple[str] = (".tbl",) + mime_type: str = "text/plain" + can_import = False + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "LinuxCNC Tool Table") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + if not isinstance(asset, Library): + raise TypeError("Asset must be a Library instance") + + output = io.BytesIO() + for bit_no, bit in sorted(asset._bit_nos.items()): + assert isinstance(bit, ToolBit) + if not isinstance(bit, RotaryToolBitMixin): + Path.Log.warning( + f"Skipping too {bit.label} (bit.id) because it is not a rotary tool" + ) + continue + diameter = bit.get_diameter() + pocket = "P" # TODO: is there a better way? + # Format diameter to one decimal place and remove units + diameter_value = diameter.Value if hasattr(diameter, "Value") else diameter + line = f"T{bit_no} {pocket} D{diameter_value:.1f} ;{bit.label}\n" + output.write(line.encode("ascii", "ignore")) + + return output.getvalue() + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Library: + # LinuxCNC .tbl files do not contain enough information to fully + # reconstruct a Library and its ToolBits. + # Therefore, deserialization is not supported. + raise NotImplementedError("Deserialization is not supported for LinuxCNC .tbl files.") diff --git a/src/Mod/CAM/Path/Tool/library/ui/__init__.py b/src/Mod/CAM/Path/Tool/library/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py new file mode 100644 index 0000000000..24e0c1559e --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for browsing Tool Library assets with filtering and sorting.""" + +from typing import cast +from PySide import QtGui +import Path +from ...toolbit.ui.browser import ToolBitBrowserWidget +from ...assets import AssetManager +from ...library import Library + + +class LibraryBrowserWidget(ToolBitBrowserWidget): + """ + A widget to browse, filter, and select Tool Library assets from the + AssetManager, with sorting and batch insertion, including library selection. + """ + + def __init__( + self, + asset_manager: AssetManager, + store: str = "local", + parent=None, + compact=True, + ): + self._library_combo = QtGui.QComboBox() + + super().__init__( + asset_manager=asset_manager, + store=store, + parent=parent, + tool_no_factory=self.get_tool_no_from_current_library, + compact=compact, + ) + + # Create the library dropdown and insert it into the top layout + self._top_layout.insertWidget(0, self._library_combo) + self._library_combo.currentIndexChanged.connect(self._on_library_changed) + + def refresh(self): + """Refreshes the library dropdown and fetches all assets.""" + self._library_combo.clear() + self._fetch_all_assets() + + def _fetch_all_assets(self): + """Populates the library dropdown with available libraries.""" + # Use list_assets("toolbitlibrary") to get URIs + libraries = self._asset_manager.fetch("toolbitlibrary", store=self._store_name, depth=0) + for library in sorted(libraries, key=lambda x: x.label): + self._library_combo.addItem(library.label, userData=library) + + if not self._library_combo.count(): + return + + # Trigger initial load after populating libraries + self._on_library_changed(0) + + def get_tool_no_from_current_library(self, toolbit): + """ + Retrieves the tool number for a toolbit based on the currently + selected library. + """ + selected_library = self._library_combo.currentData() + if selected_library is None: + return None + # Use the get_bit_no_from_bit method of the Library object + # This method returns the tool number or None + tool_no = selected_library.get_bit_no_from_bit(toolbit) + return tool_no + + def _on_library_changed(self, index): + """Handles library selection change.""" + # Get the selected library from the combo box + selected_library = self._library_combo.currentData() + if not isinstance(selected_library, Library): + self._all_assets = [] + return + + # Fetch the library from the asset manager + library_uri = selected_library.get_uri() + try: + library = self._asset_manager.get(library_uri, store=self._store_name, depth=1) + # Update the combo box item's user data with the fully fetched library + self._library_combo.setItemData(index, library) + except FileNotFoundError: + Path.Log.error(f"Library {library_uri} not found in store {self._store_name}.") + self._all_assets = [] + return + + # Update _all_assets based on the selected library + library = cast(Library, library) + self._all_assets = [t for t in library] + self._sort_assets() + self._tool_list_widget.clear_list() + self._scroll_position = 0 + self._trigger_fetch() # Display data for the selected library diff --git a/src/Mod/CAM/Path/Tool/Gui/BitLibraryCmd.py b/src/Mod/CAM/Path/Tool/library/ui/cmd.py similarity index 83% rename from src/Mod/CAM/Path/Tool/Gui/BitLibraryCmd.py rename to src/Mod/CAM/Path/Tool/library/ui/cmd.py index ceb23ef406..3118935b39 100644 --- a/src/Mod/CAM/Path/Tool/Gui/BitLibraryCmd.py +++ b/src/Mod/CAM/Path/Tool/library/ui/cmd.py @@ -24,6 +24,9 @@ from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD import FreeCADGui import Path +from Path.Tool.library.ui.dock import ToolBitLibraryDock +from Path.Tool.library.ui.editor import LibraryEditor + if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -34,9 +37,9 @@ else: translate = FreeCAD.Qt.translate -class CommandToolBitSelectorOpen: +class CommandToolBitLibraryDockOpen: """ - Command to toggle the ToolBitSelector Dock + Command to toggle the ToolBitLibraryDock """ def __init__(self): @@ -52,16 +55,14 @@ class CommandToolBitSelectorOpen: } def IsActive(self): - return FreeCAD.ActiveDocument is not None + return True def Activated(self): - import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui - - dock = PathToolBitLibraryGui.ToolBitSelector() + dock = ToolBitLibraryDock() dock.open() -class CommandToolBitLibraryOpen: +class CommandLibraryEditorOpen: """ Command to open ToolBitLibrary editor. """ @@ -80,19 +81,16 @@ class CommandToolBitLibraryOpen: } def IsActive(self): - return FreeCAD.ActiveDocument is not None + return True def Activated(self): - import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui - - library = PathToolBitLibraryGui.ToolBitLibrary() - + library = LibraryEditor() library.open() if FreeCAD.GuiUp: - FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandToolBitLibraryOpen()) - FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitSelectorOpen()) + FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandLibraryEditorOpen()) + FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitLibraryDockOpen()) BarList = ["CAM_ToolBitDock"] MenuList = ["CAM_ToolBitLibraryOpen", "CAM_ToolBitDock"] diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py new file mode 100644 index 0000000000..f610de45d5 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2020 Schildkroet * +# * 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""ToolBit Library Dock Widget.""" +import FreeCAD +import FreeCADGui +import Path +import Path.Tool.Gui.Controller as PathToolControllerGui +import PathScripts.PathUtilsGui as PathUtilsGui +from PySide import QtGui, QtCore +from functools import partial +from typing import List, Tuple +from ...assets import AssetUri +from ...camassets import cam_assets, ensure_assets_initialized +from ...toolbit import ToolBit +from .editor import LibraryEditor +from .browser import LibraryBrowserWidget + + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +translate = FreeCAD.Qt.translate + + +class ToolBitLibraryDock(object): + """Controller for displaying a library and creating ToolControllers""" + + def __init__(self): + ensure_assets_initialized(cam_assets) + # Create the main form widget directly + self.form = QtGui.QDockWidget() + self.form.setObjectName("ToolSelector") + self.form.setWindowTitle(translate("CAM_ToolBit", "Tool Selector")) + + # Create the browser widget + self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets) + + self._setup_ui() + + def _setup_ui(self): + """Setup the form and load the tooltable data""" + Path.Log.track() + + # Create a main widget and layout for the dock + main_widget = QtGui.QWidget() + main_layout = QtGui.QVBoxLayout(main_widget) + + # Add the browser widget to the layout + main_layout.addWidget(self.browser_widget) + + # Create buttons + self.libraryEditorOpenButton = QtGui.QPushButton( + translate("CAM_ToolBit", "Open Library Editor") + ) + self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job")) + + # Add buttons to a horizontal layout + button_layout = QtGui.QHBoxLayout() + button_layout.addWidget(self.libraryEditorOpenButton) + button_layout.addWidget(self.addToolControllerButton) + + # Add the button layout to the main layout + main_layout.addLayout(button_layout) + + # Set the main widget as the dock's widget + self.form.setWidget(main_widget) + + # Connect signals from the browser widget and buttons + self.browser_widget.toolSelected.connect(self._update_state) + self.browser_widget.itemDoubleClicked.connect(partial(self._add_tool_controller_to_doc)) + self.libraryEditorOpenButton.clicked.connect(self._open_editor) + self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc)) + + # Initial state of buttons + self._update_state() + + def _update_state(self): + """Enable button to add tool controller when a tool is selected""" + # Set buttons inactive + self.addToolControllerButton.setEnabled(False) + # Check if any tool is selected in the browser widget + selected = self.browser_widget._tool_list_widget.selectedItems() + if selected and FreeCAD.ActiveDocument: + jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 + self.addToolControllerButton.setEnabled(len(selected) >= 1 and jobs) + + def _open_editor(self): + library = LibraryEditor() + library.open() + # After editing, we might need to refresh the libraries in the browser widget + # Assuming _populate_libraries is the correct method to call + self.browser_widget.refresh() + + def _add_tool_to_doc(self) -> List[Tuple[int, ToolBit]]: + """ + Get the selected toolbit assets from the browser widget. + """ + Path.Log.track() + tools = [] + selected_toolbits = self.browser_widget.get_selected_bits() + + for toolbit in selected_toolbits: + # Need to get the tool number for this toolbit from the currently + # selected library in the browser widget. + toolNr = self.browser_widget.get_tool_no_from_current_library(toolbit) + if toolNr is not None: + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + tools.append((toolNr, toolbit)) + else: + Path.Log.warning( + f"Could not get tool number for toolbit {toolbit.get_uri()} in selected library." + ) + + return tools + + def _add_tool_controller_to_doc(self, index=None): + """ + if no jobs, don't do anything, otherwise all TCs for all + selected toolbit assets + """ + Path.Log.track() + jobs = PathUtilsGui.PathUtils.GetJobs() + if len(jobs) == 0: + QtGui.QMessageBox.information( + self.form, + translate("CAM_ToolBit", "No Job Found"), + translate("CAM_ToolBit", "Please create a Job first."), + ) + return + elif len(jobs) == 1: + job = jobs[0] + else: + userinput = PathUtilsGui.PathUtilsUserInput() + job = userinput.chooseJob(jobs) + + if job is None: # user may have canceled + return + + # Get the selected toolbit assets + selected_tools = self._add_tool_to_doc() + + for toolNr, toolbit in selected_tools: + tc = PathToolControllerGui.Create(f"TC: {toolbit.label}", toolbit.obj, toolNr) + job.Proxy.addToolController(tc) + FreeCAD.ActiveDocument.recompute() + + def open(self, path=None): + """load library stored in path and bring up ui""" + docs = FreeCADGui.getMainWindow().findChildren(QtGui.QDockWidget) + for doc in docs: + if doc.objectName() == "ToolSelector": + if doc.isVisible(): + doc.deleteLater() + return + else: + doc.setVisible(True) + return + + mw = FreeCADGui.getMainWindow() + mw.addDockWidget( + QtCore.Qt.RightDockWidgetArea, + self.form, + QtCore.Qt.Orientation.Vertical, + ) diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py new file mode 100644 index 0000000000..d8f9852b65 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -0,0 +1,642 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2020 Schildkroet * +# * 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + + +import FreeCAD +import FreeCADGui +import Path +import PySide +from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap +from PySide.QtCore import Qt +import os +import uuid as UUID +from typing import List, cast +from ...assets import AssetUri +from ...assets.ui import AssetOpenDialog, AssetSaveDialog +from ...camassets import cam_assets, ensure_assets_initialized +from ...shape.ui.shapeselector import ShapeSelector +from ...toolbit import ToolBit +from ...toolbit.serializers import all_serializers as toolbit_serializers +from ...toolbit.ui import ToolBitEditor +from ...library import Library +from ...library.serializers import all_serializers as library_serializers + + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +_UuidRole = PySide.QtCore.Qt.UserRole + 1 +_PathRole = PySide.QtCore.Qt.UserRole + 2 +_LibraryRole = PySide.QtCore.Qt.UserRole + 3 + + +translate = FreeCAD.Qt.translate + + +class _TableView(PySide.QtGui.QTableView): + """Subclass of QTableView to support rearrange and copying of ToolBits""" + + def __init__(self, parent): + PySide.QtGui.QTableView.__init__(self, parent) + self.setDragEnabled(False) + self.setAcceptDrops(False) + self.setDropIndicatorShown(False) + self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly) + self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction) + self.setSortingEnabled(True) + self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows) + self.verticalHeader().hide() + + def supportedDropActions(self): + return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction] + + def _uuidOfRow(self, row): + model = self.toolModel() + return model.data(model.index(row, 0), _UuidRole) + + def _rowWithUuid(self, uuid): + model = self.toolModel() + for row in range(model.rowCount()): + if self._uuidOfRow(row) == uuid: + return row + return None + + def _copyTool(self, uuid_, dstRow): + model = self.toolModel() + model.insertRow(dstRow) + srcRow = self._rowWithUuid(uuid_) + for col in range(model.columnCount()): + srcItem = model.item(srcRow, col) + + model.setData( + model.index(dstRow, col), + srcItem.data(PySide.QtCore.Qt.EditRole), + PySide.QtCore.Qt.EditRole, + ) + if col == 0: + model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole) + # Even a clone of a tool gets its own uuid so it can be identified when + # rearranging the order or inserting/deleting rows + model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole) + else: + model.item(dstRow, col).setEditable(False) + + def _copyTools(self, uuids, dst): + for i, uuid in enumerate(uuids): + self._copyTool(uuid, dst + i) + + def dropEvent(self, event): + """Handle drop events on the tool table""" + Path.Log.track() + mime = event.mimeData() + data = mime.data("application/x-qstandarditemmodeldatalist") + stream = PySide.QtCore.QDataStream(data) + srcRows = [] + while not stream.atEnd(): + row = stream.readInt32() + srcRows.append(row) + + # get the uuids of all srcRows + model = self.toolModel() + srcUuids = [self._uuidOfRow(row) for row in set(srcRows)] + destRow = self.rowAt(event.pos().y()) + + self._copyTools(srcUuids, destRow) + if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction(): + for uuid in srcUuids: + model.removeRow(self._rowWithUuid(uuid)) + + +class ModelFactory: + """Helper class to generate qtdata models for toolbit libraries""" + + @staticmethod + def find_libraries(model) -> QStandardItemModel: + """ + Finds all the fctl files in a location. + Returns a QStandardItemModel. + """ + Path.Log.track() + model.clear() + + # Use AssetManager to fetch library assets (depth=0 for shallow fetch) + try: + # Fetch library assets themselves, not their deep dependencies (toolbits). + # depth=0 means "fetch this asset, but not its dependencies" + # The 'fetch' method returns actual Asset objects. + libraries = cast(List[Library], cam_assets.fetch(asset_type="toolbitlibrary", depth=0)) + except Exception as e: + Path.Log.error(f"Failed to fetch toolbit libraries: {e}") + return model # Return empty model on error + + # Sort by label for consistent ordering, falling back to asset_id if label is missing + def get_sort_key(library): + label = getattr(library, "label", None) + return label if label else library.get_id() + + for library in sorted(libraries, key=get_sort_key): + lib_uri_str = str(library.get_uri()) + libItem = QStandardItem(library.label or library.get_id()) + libItem.setToolTip(f"ID: {library.get_id()}\nURI: {lib_uri_str}") + libItem.setData(lib_uri_str, _LibraryRole) # Store the URI string + libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) + model.appendRow(libItem) + + Path.Log.debug("model rows: {}".format(model.rowCount())) + return model + + @staticmethod + def __library_load(library_uri: str, data_model: QStandardItemModel): + Path.Log.track(library_uri) + + if library_uri: + # Store the AssetUri string, not just the name + Path.Preferences.setLastToolLibrary(library_uri) + + try: + # Load the library asset using AssetManager + loaded_library = cam_assets.get(AssetUri(library_uri), depth=1) + except Exception as e: + Path.Log.error(f"Failed to load library from {library_uri}: {e}") + raise + + # Iterate over the loaded ToolBit asset instances + for tool_no, tool_bit in sorted(loaded_library._bit_nos.items()): + data_model.appendRow( + ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri())) + ) + + @staticmethod + def _generate_tooltip(toolbit: dict) -> str: + """ + Generate an HTML tooltip for a given toolbit dictionary. + + Args: + toolbit (dict): A dictionary containing toolbit information. + + Returns: + str: An HTML string representing the tooltip. + """ + tooltip = f"Name: {toolbit['name']}
" + tooltip += f"Shape File: {toolbit['shape']}
" + tooltip += "Parameters:
" + parameters = toolbit.get("parameter", {}) + if parameters: + for key, value in parameters.items(): + tooltip += f" {key}: {value}
" + else: + tooltip += " No parameters provided.
" + + attributes = toolbit.get("attribute", {}) + if attributes: + tooltip += "Attributes:
" + for key, value in attributes.items(): + tooltip += f" {key}: {value}
" + + return tooltip + + @staticmethod + def _tool_add(nr: int, tool: dict, path: str): + str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0] + tooltip = ModelFactory._generate_tooltip(tool) + + tool_nr = QStandardItem() + tool_nr.setData(nr, Qt.EditRole) + tool_nr.setData(path, _PathRole) + tool_nr.setData(UUID.uuid4(), _UuidRole) + tool_nr.setToolTip(tooltip) + + tool_name = QStandardItem() + tool_name.setData(tool["name"], Qt.EditRole) + tool_name.setEditable(False) + tool_name.setToolTip(tooltip) + + tool_shape = QStandardItem() + tool_shape.setData(str_shape, Qt.EditRole) + tool_shape.setEditable(False) + + return [tool_nr, tool_name, tool_shape] + + @staticmethod + def library_open(model: QStandardItemModel, library_uri: str) -> QStandardItemModel: + """ + Opens the tools in a library using its AssetUri. + Returns a QStandardItemModel. + """ + Path.Log.track(library_uri) + ModelFactory.__library_load(library_uri, model) + Path.Log.debug("model rows: {}".format(model.rowCount())) + return model + + +class LibraryEditor(object): + """LibraryEditor is the controller for + displaying/selecting/creating/editing a collection of ToolBits.""" + + def __init__(self): + Path.Log.track() + ensure_assets_initialized(cam_assets) + self.factory = ModelFactory() + self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) + self.listModel = PySide.QtGui.QStandardItemModel() + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") + self.toolTableView = _TableView(self.form.toolTableGroup) + self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView) + self.form.toolTable.hide() + + self.setupUI() + self.title = self.form.windowTitle() + + # Connect signals for tool editing + self.toolTableView.doubleClicked.connect(self.toolEdit) + + def toolBitNew(self): + """Create a new toolbit asset and add it to the current library""" + Path.Log.track() + + if not self.current_library: + PySide.QtGui.QMessageBox.warning( + self.form, + translate("CAM_ToolBit", "No Library Loaded"), + translate("CAM_ToolBit", "Load or create a tool library first."), + ) + return + + # Select the shape for the new toolbit + selector = ShapeSelector() + shape = selector.show() + if shape is None: # user canceled + return + + try: + # Find the appropriate ToolBit subclass based on the shape name + tool_bit_classes = {b.SHAPE_CLASS.name: b for b in ToolBit.__subclasses__()} + tool_bit_class = tool_bit_classes.get(shape.name) + + if not tool_bit_class: + raise ValueError(f"No ToolBit subclass found for shape '{shape.name}'") + + # Create a new ToolBit instance using the subclass constructor + # The constructor will generate a UUID + toolbit = tool_bit_class(shape) + + # 1. Save the individual toolbit asset first. + tool_asset_uri = cam_assets.add(toolbit) + Path.Log.debug(f"toolBitNew: Saved tool with URI: {tool_asset_uri}") + + # 2. Add the toolbit (which now has a persisted URI) to the current library's model + tool_no = self.current_library.add_bit(toolbit) + Path.Log.debug( + f"toolBitNew: Added toolbit {toolbit.get_id()} (URI: {toolbit.get_uri()}) " + f"to current_library with tool number {tool_no}." + ) + + # 3. Add the new tool directly to the UI model + new_row_items = ModelFactory._tool_add( + tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit + ) + self.toolModel.appendRow(new_row_items) + + # 4. Save the library (which now references the saved toolbit) + self._saveCurrentLibrary() + + except Exception as e: + Path.Log.error(f"Failed to create or add new toolbit: {e}") + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Creating Toolbit"), + str(e), + ) + raise + + def toolBitExisting(self): + """Add an existing toolbit asset to the current library""" + Path.Log.track() + + if not self.current_library: + PySide.QtGui.QMessageBox.warning( + self.form, + translate("CAM_ToolBit", "No Library Loaded"), + translate("CAM_ToolBit", "Load or create a tool library first."), + ) + return + + # Open the file dialog + dialog = AssetOpenDialog(ToolBit, toolbit_serializers, self.form) + dialog_result = dialog.exec() + if not dialog_result: + return # User canceled or error + file_path, toolbit = dialog_result + toolbit = cast(ToolBit, toolbit) + + try: + # Add the existing toolbit to the current library's model + # The add_bit method handles assigning a tool number and returns it. + cam_assets.add(toolbit) + tool_no = self.current_library.add_bit(toolbit) + + # Add the new tool directly to the UI model + new_row_items = ModelFactory._tool_add( + tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit + ) + self.toolModel.appendRow(new_row_items) + + # Save the library (which now references the added toolbit) + # Use cam_assets.add directly for internal save on existing toolbit + self._saveCurrentLibrary() + + except Exception as e: + Path.Log.error( + f"Failed to add imported toolbit {toolbit.get_id()} " + f"from {file_path} to library: {e}" + ) + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Adding Imported Toolbit"), + str(e), + ) + raise + + def toolDelete(self): + """Delete a tool""" + Path.Log.track() + selected_indices = self.toolTableView.selectedIndexes() + if not selected_indices: + return + + if not self.current_library: + Path.Log.error("toolDelete: No current_library loaded. Cannot delete tools.") + return + + # Collect unique rows to process, as selectedIndexes can return multiple indices per row + selected_rows = sorted(list(set(index.row() for index in selected_indices)), reverse=True) + + # Remove the rows from the library model. + for row in selected_rows: + item_tool_nr_or_uri = self.toolModel.item(row, 0) # Column 0 stores _PathRole + tool_uri_string = item_tool_nr_or_uri.data(_PathRole) + tool_uri = AssetUri(tool_uri_string) + bit = self.current_library.get_tool_by_uri(tool_uri) + self.current_library.remove_bit(bit) + self.toolModel.removeRows(row, 1) + + Path.Log.info(f"toolDelete: Removed {len(selected_rows)} rows from UI model.") + + # Save the library after deleting a tool + self._saveCurrentLibrary() + + def toolSelect(self, selected, deselected): + sel = len(self.toolTableView.selectedIndexes()) > 0 + self.form.toolDelete.setEnabled(sel) + + def tableSelected(self, index): + """loads the tools for the selected tool table""" + Path.Log.track() + item = index.model().itemFromIndex(index) + library_uri_string = item.data(_LibraryRole) + self._loadSelectedLibraryTools(library_uri_string) + + def open(self): + Path.Log.track() + return self.form.exec_() + + def toolEdit(self, selected): + """Edit the selected tool bit asset""" + Path.Log.track() + item = self.toolModel.item(selected.row(), 0) + + if selected.column() == 0: + return # Assuming tool number editing is handled directly in the table model + + toolbit_uri_string = item.data(_PathRole) + if not toolbit_uri_string: + Path.Log.error("No toolbit URI found for selected item.") + return + toolbit_uri = AssetUri(toolbit_uri_string) + + # Load the toolbit asset for editing + try: + bit = cam_assets.get(toolbit_uri) + editor_dialog = ToolBitEditor(bit, self.form) # Create dialog instance + result = editor_dialog.show() # Show as modal dialog + + if result == PySide.QtGui.QDialog.Accepted: + # The editor updates the toolbit directly, so we just need to save + cam_assets.add(bit) + Path.Log.info(f"Toolbit {bit.get_id()} saved.") + # Refresh the display and save the library + self._loadSelectedLibraryTools( + self.current_library.get_uri() if self.current_library else None + ) + # Save the library after editing a toolbit + self._saveCurrentLibrary() + + except Exception as e: + Path.Log.error(f"Failed to load or edit toolbit asset {toolbit_uri_string}: {e}") + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Editing Toolbit"), + str(e), + ) + raise + + def libraryNew(self): + """Create a new tool library asset""" + Path.Log.track() + + # Get the desired library name (label) from the user + library_label, ok = PySide.QtGui.QInputDialog.getText( + self.form, + translate("CAM_ToolBit", "New Tool Library"), + translate("CAM_ToolBit", "Enter a name for the new library:"), + ) + if not ok or not library_label: + return + + # Create a new Library asset instance, UUID will be auto-generated + new_library = Library(library_label) + uri = cam_assets.add(new_library) + Path.Log.info(f"New library created: {uri}") + + # Refresh the list of libraries in the UI + self._refreshLibraryListModel() + self._loadSelectedLibraryTools(uri) + + # Attempt to select the newly added library in the list + for i in range(self.listModel.rowCount()): + item = self.listModel.item(i) + if item and item.data(_LibraryRole) == str(uri): + curIndex = self.listModel.indexFromItem(item) + self.form.TableList.setCurrentIndex(curIndex) + Path.Log.debug(f"libraryNew: Selected new library '{str(uri)}' in TableList.") + break + + def _refreshLibraryListModel(self): + """Clears and repopulates the self.listModel with available libraries.""" + Path.Log.track() + self.listModel.clear() + self.factory.find_libraries(self.listModel) + self.listModel.setHorizontalHeaderLabels(["Library"]) + + def _saveCurrentLibrary(self): + """Internal method to save the current tool library asset""" + Path.Log.track() + if not self.current_library: + Path.Log.warning("_saveCurrentLibrary: No library asset loaded to save.") + return + + try: + cam_assets.add(self.current_library) + Path.Log.info( + f"_saveCurrentLibrary: Library " f"{self.current_library.get_uri()} saved." + ) + except Exception as e: + Path.Log.error( + f"_saveCurrentLibrary: Failed to save library " + f"{self.current_library.get_uri()}: {e}" + ) + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Saving Library"), + str(e), + ) + raise + + def exportLibrary(self): + """Export the current tool library asset to a file""" + Path.Log.track() + if not self.current_library: + PySide.QtGui.QMessageBox.warning( + self.form, + translate("CAM_ToolBit", "No Library Loaded"), + translate("CAM_ToolBit", "Load or create a tool library first."), + ) + return + + dialog = AssetSaveDialog(self.current_library, library_serializers, self.form) + dialog_result = dialog.exec(self.current_library) + if not dialog_result: + return # User canceled or error + + file_path, serializer_class = dialog_result + + Path.Log.info( + f"Exported library {self.current_library.label} " + f"to {file_path} using serializer {serializer_class.__name__}" + ) + + def columnNames(self): + return [ + "Tn", + translate("CAM_ToolBit", "Tool"), + translate("CAM_ToolBit", "Shape"), + ] + + def _loadSelectedLibraryTools(self, library_uri: AssetUri | str | None = None): + """Loads tools for the given library_uri into self.toolModel and selects it in the list.""" + Path.Log.track(library_uri) + self.toolModel.clear() + # library_uri is now expected to be a string URI or None when called from setupUI/tableSelected. + # AssetUri object conversion is handled by cam_assets.get() if needed. + + self.current_library = None # Reset current_library before loading + + if not library_uri: + self.form.setWindowTitle("Tool Library Editor - No Library Selected") + return + + # Fetch the library from the asset manager + try: + self.current_library = cam_assets.get(library_uri, depth=1) + except Exception as e: + Path.Log.error(f"Failed to load library asset {library_uri}: {e}") + self.form.setWindowTitle("Tool Library Editor - Error") + return + + # Success! Add the tools to the toolModel. + self.toolTableView.setUpdatesEnabled(False) + self.form.setWindowTitle(f"Tool Library Editor - {self.current_library.label}") + for tool_no, tool_bit in sorted(self.current_library._bit_nos.items()): + self.toolModel.appendRow( + ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri())) + ) + + self.toolModel.setHorizontalHeaderLabels(self.columnNames()) + self.toolTableView.setUpdatesEnabled(True) + + def setupUI(self): + """Setup the form and load the tool library data""" + Path.Log.track() + + self.form.TableList.setModel(self.listModel) + self._refreshLibraryListModel() + + self.toolTableView.setModel(self.toolModel) + + # Find the last used library. + last_used_lib_identifier = Path.Preferences.getLastToolLibrary() + Path.Log.debug( + f"setupUI: Last used library identifier from prefs: '{last_used_lib_identifier}'" + ) + last_used_lib_uri = None + if last_used_lib_identifier: + last_used_lib_uri = Library.resolve_name(last_used_lib_identifier) + + # Find it in the list. + index = 0 + for i in range(self.listModel.rowCount()): + item = self.listModel.item(i) + if item and item.data(_LibraryRole) == str(last_used_lib_uri): + index = i + break + + # Select it. + if index <= self.listModel.rowCount(): + item = self.listModel.item(index) + if item: # Should always be true, but... + library_uri_str = item.data(_LibraryRole) + self.form.TableList.setCurrentIndex(self.listModel.index(index, 0)) + + # Load tools for the selected library. + self._loadSelectedLibraryTools(library_uri_str) + + self.toolTableView.resizeColumnsToContents() + self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect) + + self.form.TableList.clicked.connect(self.tableSelected) + + self.form.toolAdd.clicked.connect(self.toolBitExisting) + self.form.toolDelete.clicked.connect(self.toolDelete) + self.form.toolCreate.clicked.connect(self.toolBitNew) + + self.form.addLibrary.clicked.connect(self.libraryNew) + self.form.exportLibrary.clicked.connect(self.exportLibrary) + + self.form.okButton.clicked.connect(self.form.close) + + self.toolSelect([], []) diff --git a/src/Mod/CAM/Path/Tool/library/util.py b/src/Mod/CAM/Path/Tool/library/util.py new file mode 100644 index 0000000000..4d11d075f6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/util.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 The FreeCAD team * +# * * +# * 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 * +# * * +# *************************************************************************** diff --git a/src/Mod/CAM/Path/Tool/machine/__init__.py b/src/Mod/CAM/Path/Tool/machine/__init__.py new file mode 100644 index 0000000000..79eefb9f19 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/machine/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .models.machine import Machine + +__all__ = [ + "Machine", +] diff --git a/src/Mod/CAM/Path/Tool/machine/models/__init__.py b/src/Mod/CAM/Path/Tool/machine/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/machine/models/machine.py b/src/Mod/CAM/Path/Tool/machine/models/machine.py new file mode 100644 index 0000000000..17519f3649 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/machine/models/machine.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import json +import FreeCAD +from FreeCAD import Base +from typing import Optional, Union, Mapping, List +from ...assets import Asset, AssetUri, AssetSerializer + + +class Machine(Asset): + """Represents a machine with various operational parameters.""" + + asset_type: str = "machine" + API_VERSION = 1 + + UNIT_CONVERSIONS = { + "hp": 745.7, # hp to W + "in-lbf": 0.112985, # in-lbf to N*m + "inch/min": 25.4, # inch/min to mm/min + "rpm": 1.0 / 60.0, # rpm to 1/s + "kW": 1000.0, # kW to W + "Nm": 1.0, # Nm to N*m + "mm/min": 1.0, # mm/min to mm/min + } + + def __init__( + self, + label: str = "Machine", + max_power: Union[int, float, FreeCAD.Units.Quantity] = 2, + min_rpm: Union[int, float, FreeCAD.Units.Quantity] = 3000, + max_rpm: Union[int, float, FreeCAD.Units.Quantity] = 60000, + max_torque: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None, + peak_torque_rpm: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None, + min_feed: Union[int, float, FreeCAD.Units.Quantity] = 1, + max_feed: Union[int, float, FreeCAD.Units.Quantity] = 2000, + id: Optional[str] = None, + ) -> None: + """ + Initializes a Machine object. + + Args: + label: The label of the machine. + max_power: The maximum power of the machine (kW or Quantity). + min_rpm: The minimum RPM of the machine (RPM or Quantity). + max_rpm: The maximum RPM of the machine (RPM or Quantity). + max_torque: The maximum torque of the machine (Nm or Quantity). + peak_torque_rpm: The RPM at which peak torque is achieved + (RPM or Quantity). + min_feed: The minimum feed rate of the machine + (mm/min or Quantity). + max_feed: The maximum feed rate of the machine + (mm/min or Quantity). + id: The unique identifier of the machine. + """ + self.id = id or str(uuid.uuid1()) + self._label = label + + # Initialize max_power (W) + if isinstance(max_power, FreeCAD.Units.Quantity): + self._max_power = max_power.getValueAs("W").Value + elif isinstance(max_power, (int, float)): + self._max_power = max_power * self.UNIT_CONVERSIONS["kW"] + else: + self._max_power = 2000.0 + + # Initialize min_rpm (1/s) + if isinstance(min_rpm, FreeCAD.Units.Quantity): + try: + self._min_rpm = min_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + self._min_rpm = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + elif isinstance(min_rpm, (int, float)): + self._min_rpm = min_rpm * self.UNIT_CONVERSIONS["rpm"] + else: + self._min_rpm = 3000 * self.UNIT_CONVERSIONS["rpm"] + + # Initialize max_rpm (1/s) + if isinstance(max_rpm, FreeCAD.Units.Quantity): + try: + self._max_rpm = max_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + self._max_rpm = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + elif isinstance(max_rpm, (int, float)): + self._max_rpm = max_rpm * self.UNIT_CONVERSIONS["rpm"] + else: + self._max_rpm = 60000 * self.UNIT_CONVERSIONS["rpm"] + + # Initialize min_feed (mm/min) + if isinstance(min_feed, FreeCAD.Units.Quantity): + self._min_feed = min_feed.getValueAs("mm/min").Value + elif isinstance(min_feed, (int, float)): + self._min_feed = min_feed + else: + self._min_feed = 1.0 + + # Initialize max_feed (mm/min) + if isinstance(max_feed, FreeCAD.Units.Quantity): + self._max_feed = max_feed.getValueAs("mm/min").Value + elif isinstance(max_feed, (int, float)): + self._max_feed = max_feed + else: + self._max_feed = 2000.0 + + # Initialize peak_torque_rpm (1/s) + if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity): + try: + self._peak_torque_rpm = peak_torque_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + self._peak_torque_rpm = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + elif isinstance(peak_torque_rpm, (int, float)): + self._peak_torque_rpm = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"] + else: + self._peak_torque_rpm = self._max_rpm / 3 + + # Initialize max_torque (N*m) + if isinstance(max_torque, FreeCAD.Units.Quantity): + self._max_torque = max_torque.getValueAs("Nm").Value + elif isinstance(max_torque, (int, float)): + self._max_torque = max_torque + else: + # Convert 1/s to rpm + peak_rpm_for_calc = self._peak_torque_rpm * 60 + self._max_torque = ( + self._max_power * 9.5488 / peak_rpm_for_calc if peak_rpm_for_calc else float("inf") + ) + + def get_id(self) -> str: + """Returns the unique identifier for the Machine instance.""" + return self.id + + def to_dict(self) -> dict: + """Returns a dictionary representation of the Machine.""" + return { + "version": self.API_VERSION, + "id": self.id, + "label": self.label, + "max_power": self._max_power, # W + "min_rpm": self._min_rpm, # 1/s + "max_rpm": self._max_rpm, # 1/s + "max_torque": self._max_torque, # Nm + "peak_torque_rpm": self._peak_torque_rpm, # 1/s + "min_feed": self._min_feed, # mm/min + "max_feed": self._max_feed, # mm/min + } + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + """Serializes the Machine object to bytes using to_dict.""" + data_dict = self.to_dict() + json_str = json.dumps(data_dict) + return json_str.encode("utf-8") + + @classmethod + def from_dict(cls, data_dict: dict, id: str) -> "Machine": + """Creates a Machine instance from a dictionary.""" + machine = cls( + label=data_dict.get("label", "Machine"), + max_power=data_dict.get("max_power", 2000.0), # W + min_rpm=data_dict.get("min_rpm", 3000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s + max_rpm=data_dict.get("max_rpm", 60000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s + max_torque=data_dict.get("max_torque", None), # Nm + peak_torque_rpm=data_dict.get("peak_torque_rpm", None), # 1/s + min_feed=data_dict.get("min_feed", 1.0), # mm/min + max_feed=data_dict.get("max_feed", 2000.0), # mm/min + id=id, + ) + return machine + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> "Machine": + """ + Deserializes bytes into a Machine instance using from_dict. + """ + # If dependencies is None, it's fine as Machine doesn't use it. + data_dict = json.loads(data.decode("utf-8")) + return cls.from_dict(data_dict, id) + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + """Returns a list of AssetUri dependencies parsed from the serialized data.""" + return [] # Machine has no dependencies + + @property + def max_power(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_power, "W") + + @property + def min_rpm(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._min_rpm, "1/s") + + @property + def max_rpm(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_rpm, "1/s") + + @property + def max_torque(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_torque, "Nm") + + @property + def peak_torque_rpm(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._peak_torque_rpm, "1/s") + + @property + def min_feed(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._min_feed, "mm/min") + + @property + def max_feed(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_feed, "mm/min") + + @property + def label(self) -> str: + return self._label + + @label.setter + def label(self, label: str) -> None: + self._label = label + + def get_min_rpm_value(self) -> float: + """Helper method to get minimum RPM value for display/testing.""" + return self._min_rpm * 60 + + def get_max_rpm_value(self) -> float: + """Helper method to get maximum RPM value for display/testing.""" + return self._max_rpm * 60 + + def get_peak_torque_rpm_value(self) -> float: + """Helper method to get peak torque RPM value for display/testing.""" + return self._peak_torque_rpm * 60 + + def validate(self) -> None: + """Validates the machine parameters.""" + if not self.label: + raise AttributeError("Machine name is required") + if self._peak_torque_rpm > self._max_rpm: + err = ("Peak Torque RPM {ptrpm:.2f} must be less than max RPM " "{max_rpm:.2f}").format( + ptrpm=self._peak_torque_rpm * 60, max_rpm=self._max_rpm * 60 + ) + raise AttributeError(err) + if self._max_rpm <= self._min_rpm: + raise AttributeError("Max RPM must be larger than min RPM") + if self._max_feed <= self._min_feed: + raise AttributeError("Max feed must be larger than min feed") + + def get_torque_at_rpm(self, rpm: Union[int, float, FreeCAD.Units.Quantity]) -> float: + """ + Calculates the torque at a given RPM. + + Args: + rpm: The RPM value (int, float, or Quantity). + + Returns: + The torque at the given RPM in Nm. + """ + if isinstance(rpm, FreeCAD.Units.Quantity): + try: + rpm_hz = rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + rpm_hz = rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + rpm_hz = rpm * self.UNIT_CONVERSIONS["rpm"] + max_torque_nm = self._max_torque + peak_torque_rpm_hz = self._peak_torque_rpm + peak_rpm_for_calc = peak_torque_rpm_hz * 60 + rpm_for_calc = rpm_hz * 60 + torque_at_current_rpm = ( + self._max_power * 9.5488 / rpm_for_calc if rpm_for_calc else float("inf") + ) + if rpm_for_calc <= peak_rpm_for_calc: + torque_at_current_rpm = ( + max_torque_nm / peak_rpm_for_calc * rpm_for_calc + if peak_rpm_for_calc + else float("inf") + ) + return min(max_torque_nm, torque_at_current_rpm) + + def set_max_power(self, power: Union[int, float], unit: Optional[str] = None) -> None: + """Sets the maximum power of the machine.""" + unit = unit or "kW" + if unit in self.UNIT_CONVERSIONS: + power_value = power * self.UNIT_CONVERSIONS[unit] + else: + power_value = FreeCAD.Units.Quantity(power, unit).getValueAs("W").Value + self._max_power = power_value + if self._max_power <= 0: + raise AttributeError("Max power must be positive") + + def set_min_rpm(self, min_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None: + """Sets the minimum RPM of the machine.""" + if isinstance(min_rpm, FreeCAD.Units.Quantity): + try: + min_rpm_value = min_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + min_rpm_value = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + min_rpm_value = min_rpm * self.UNIT_CONVERSIONS["rpm"] + self._min_rpm = min_rpm_value + if self._min_rpm < 0: + raise AttributeError("Min RPM cannot be negative") + if self._min_rpm >= self._max_rpm: + self._max_rpm = min_rpm_value + 1.0 / 60.0 + + def set_max_rpm(self, max_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None: + """Sets the maximum RPM of the machine.""" + if isinstance(max_rpm, FreeCAD.Units.Quantity): + try: + max_rpm_value = max_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + max_rpm_value = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + max_rpm_value = max_rpm * self.UNIT_CONVERSIONS["rpm"] + self._max_rpm = max_rpm_value + if self._max_rpm <= 0: + raise AttributeError("Max RPM must be positive") + if self._max_rpm <= self._min_rpm: + self._min_rpm = max(0, max_rpm_value - 1.0 / 60.0) + + def set_min_feed( + self, + min_feed: Union[int, float, FreeCAD.Units.Quantity], + unit: Optional[str] = None, + ) -> None: + """Sets the minimum feed rate of the machine.""" + unit = unit or "mm/min" + if unit in self.UNIT_CONVERSIONS: + min_feed_value = min_feed * self.UNIT_CONVERSIONS[unit] + else: + min_feed_value = FreeCAD.Units.Quantity(min_feed, unit).getValueAs("mm/min").Value + self._min_feed = min_feed_value + if self._min_feed < 0: + raise AttributeError("Min feed cannot be negative") + if self._min_feed >= self._max_feed: + self._max_feed = min_feed_value + 1.0 + + def set_max_feed( + self, + max_feed: Union[int, float, FreeCAD.Units.Quantity], + unit: Optional[str] = None, + ) -> None: + """Sets the maximum feed rate of the machine.""" + unit = unit or "mm/min" + if unit in self.UNIT_CONVERSIONS: + max_feed_value = max_feed * self.UNIT_CONVERSIONS[unit] + else: + max_feed_value = FreeCAD.Units.Quantity(max_feed, unit).getValueAs("mm/min").Value + self._max_feed = max_feed_value + if self._max_feed <= 0: + raise AttributeError("Max feed must be positive") + if self._max_feed <= self._min_feed: + self._min_feed = max(0, max_feed_value - 1.0) + + def set_peak_torque_rpm( + self, peak_torque_rpm: Union[int, float, FreeCAD.Units.Quantity] + ) -> None: + """Sets the peak torque RPM of the machine.""" + if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity): + try: + peak_torque_rpm_value = peak_torque_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + peak_torque_rpm_value = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + peak_torque_rpm_value = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"] + self._peak_torque_rpm = peak_torque_rpm_value + if self._peak_torque_rpm < 0: + raise AttributeError("Peak torque RPM cannot be negative") + + def set_max_torque( + self, + max_torque: Union[int, float, FreeCAD.Units.Quantity], + unit: Optional[str] = None, + ) -> None: + """Sets the maximum torque of the machine.""" + unit = unit or "Nm" + if unit in self.UNIT_CONVERSIONS: + max_torque_value = max_torque * self.UNIT_CONVERSIONS[unit] + else: + max_torque_value = FreeCAD.Units.Quantity(max_torque, unit).getValueAs("Nm").Value + self._max_torque = max_torque_value + if self._max_torque <= 0: + raise AttributeError("Max torque must be positive") + + def dump(self, do_print: bool = True) -> Optional[str]: + """ + Dumps machine information to console or returns it as a string. + + Args: + do_print: If True, prints the information to the console. + If False, returns the information as a string. + + Returns: + A formatted string containing machine information if do_print is + False, otherwise None. + """ + min_rpm_value = self._min_rpm * 60 + max_rpm_value = self._max_rpm * 60 + peak_torque_rpm_value = self._peak_torque_rpm * 60 + + output = "" + output += f"Machine {self.label}:\n" + output += f" Max power: {self._max_power:.2f} W\n" + output += f" RPM: {min_rpm_value:.2f} RPM - {max_rpm_value:.2f} RPM\n" + output += f" Feed: {self.min_feed.UserString} - " f"{self.max_feed.UserString}\n" + output += ( + f" Peak torque: {self._max_torque:.2f} Nm at " f"{peak_torque_rpm_value:.2f} RPM\n" + ) + output += f" Max_torque: {self._max_torque} Nm\n" + + if do_print: + print(output) + return output diff --git a/src/Mod/CAM/Path/Tool/shape/__init__.py b/src/Mod/CAM/Path/Tool/shape/__init__.py new file mode 100644 index 0000000000..d70cd5de4a --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/__init__.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# This package aggregates tool bit shape classes. + +# Import the base class and all concrete shape classes +from .models.base import ToolBitShape +from .models.ballend import ToolBitShapeBallend +from .models.chamfer import ToolBitShapeChamfer +from .models.dovetail import ToolBitShapeDovetail +from .models.drill import ToolBitShapeDrill +from .models.endmill import ToolBitShapeEndmill +from .models.fillet import ToolBitShapeFillet +from .models.probe import ToolBitShapeProbe +from .models.reamer import ToolBitShapeReamer +from .models.slittingsaw import ToolBitShapeSlittingSaw +from .models.tap import ToolBitShapeTap +from .models.threadmill import ToolBitShapeThreadMill +from .models.bullnose import ToolBitShapeBullnose +from .models.vbit import ToolBitShapeVBit +from .models.icon import ( + ToolBitShapeIcon, + ToolBitShapePngIcon, + ToolBitShapeSvgIcon, +) + +# A list of the name of each ToolBitShape +TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__()]) + +# Define __all__ for explicit public interface +__all__ = [ + "ToolBitShape", + "ToolBitShapeBallend", + "ToolBitShapeChamfer", + "ToolBitShapeDovetail", + "ToolBitShapeDrill", + "ToolBitShapeEndmill", + "ToolBitShapeFillet", + "ToolBitShapeProbe", + "ToolBitShapeReamer", + "ToolBitShapeSlittingSaw", + "ToolBitShapeTap", + "ToolBitShapeThreadMill", + "ToolBitShapeBullnose", + "ToolBitShapeVBit", + "TOOL_BIT_SHAPE_NAMES", + "ToolBitShapeIcon", + "ToolBitShapeSvgIcon", + "ToolBitShapePngIcon", +] diff --git a/src/Mod/CAM/Path/Tool/shape/doc.py b/src/Mod/CAM/Path/Tool/shape/doc.py new file mode 100644 index 0000000000..1e2d306a02 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/doc.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +import Path.Base.Util as PathUtil +from typing import Dict, List, Any, Optional +import tempfile +import os + + +def find_shape_object(doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]: + """ + Find the primary object representing the shape in a document. + + Looks for PartDesign::Body, then Part::Feature. Falls back to the first + object if no better candidate is found. + + Args: + doc (FreeCAD.Document): The document to search within. + + Returns: + Optional[FreeCAD.DocumentObject]: The found object or None. + """ + obj = None + # Prioritize Body + for o in doc.Objects: + if o.isDerivedFrom("PartDesign::Body"): + return o + # Keep track of the first Part::Feature found as a fallback + if obj is None and o.isDerivedFrom("Part::Feature"): + obj = o + if obj: + return obj + # Fallback to the very first object if nothing else suitable found + return doc.Objects[0] if doc.Objects else None + + +def get_object_properties( + obj: "FreeCAD.DocumentObject", expected_params: List[str] +) -> Dict[str, Any]: + """ + Extract properties matching expected_params from a FreeCAD PropertyBag. + + Issues warnings for missing parameters but does not raise an error. + + Args: + obj: The PropertyBag to extract properties from. + expected_params (List[str]): A list of property names to look for. + + Returns: + Dict[str, Any]: A dictionary mapping property names to their values. + Values are FreeCAD native types. + """ + properties = {} + for name in expected_params: + if hasattr(obj, name): + properties[name] = getattr(obj, name) + else: + # Log a warning if a parameter expected by the shape class is missing + FreeCAD.Console.PrintWarning( + f"Parameter '{name}' not found on object '{obj.Label}' " + f"({obj.Name}). Default value will be used by the shape class.\n" + ) + properties[name] = None # Indicate missing value + return properties + + +def update_shape_object_properties( + obj: "FreeCAD.DocumentObject", parameters: Dict[str, Any] +) -> None: + """ + Update properties of a FreeCAD PropertyBag based on a dictionary of parameters. + + Args: + obj (FreeCAD.DocumentObject): The PropertyBag to update properties on. + parameters (Dict[str, Any]): A dictionary of property names and values. + """ + for name, value in parameters.items(): + if hasattr(obj, name): + try: + PathUtil.setProperty(obj, name, value) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Failed to set property '{name}' on object '{obj.Label}'" + f" ({obj.Name}) with value '{value}': {e}\n" + ) + else: + FreeCAD.Console.PrintWarning( + f"Property '{name}' not found on object '{obj.Label}'" f" ({obj.Name}). Skipping.\n" + ) + + +def get_doc_state() -> Any: + """ + Used to make a "snapshot" of the current state of FreeCAD, to allow + for restoring the ActiveDocument and selection state later. + """ + doc_name = FreeCAD.ActiveDocument.Name if FreeCAD.ActiveDocument else None + if FreeCAD.GuiUp: + import FreeCADGui + + selection = FreeCADGui.Selection.getSelection() + else: + selection = [] + return doc_name, selection + + +def restore_doc_state(state): + doc_name, selection = state + if doc_name: + FreeCAD.setActiveDocument(doc_name) + if FreeCAD.GuiUp: + import FreeCADGui + + for sel in selection: + FreeCADGui.Selection.addSelection(doc_name, sel.Name) + + +class ShapeDocFromBytes: + """ + Context manager to create and manage a temporary FreeCAD document, + loading content from a byte string. + """ + + def __init__(self, content: bytes): + self._content = content + self._doc = None + self._temp_file = None + self._old_state = None + + def __enter__(self) -> "FreeCAD.Document": + """Creates a new temporary FreeCAD document or loads cache if provided.""" + # Create a temporary file and write the cache content to it + with tempfile.NamedTemporaryFile(suffix=".FCStd", delete=False) as tmp_file: + tmp_file.write(self._content) + self._temp_file = tmp_file.name + + # When we open a new document, FreeCAD loses the state, of the active + # document (i.e. current selection), even if the newly opened document + # is a hidden one. + # So we need to restore the active document state at the end. + self._old_state = get_doc_state() + + # Open the document from the temporary file + # Use a specific name to avoid clashes if multiple docs are open + # Open the document from the temporary file + self._doc = FreeCAD.openDocument(self._temp_file, hidden=True) + if not self._doc: + raise RuntimeError(f"Failed to open document from {self._temp_file}") + return self._doc + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Closes the temporary FreeCAD document and cleans up the temp file.""" + if self._doc: + # Note that .closeDocument() is extremely slow; it takes + # almost 400ms per document - much longer than opening! + FreeCAD.closeDocument(self._doc.Name) + self._doc = None + + # Restore the original active document + restore_doc_state(self._old_state) + + # Clean up the temporary file if it was created + if self._temp_file and os.path.exists(self._temp_file): + try: + os.remove(self._temp_file) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Failed to remove temporary file {self._temp_file}: {e}\n" + ) diff --git a/src/Mod/CAM/Path/Tool/shape/models/__init__.py b/src/Mod/CAM/Path/Tool/shape/models/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/src/Mod/CAM/Path/Tool/shape/models/ballend.py b/src/Mod/CAM/Path/Tool/shape/models/ballend.py new file mode 100644 index 0000000000..98088d1e82 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/ballend.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeBallend(ToolBitShape): + name: str = "Ballend" + aliases = ("ballend",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Ballend") diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py new file mode 100644 index 0000000000..301bb7ec30 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -0,0 +1,630 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import pathlib +import FreeCAD +import Path +import os +from typing import Dict, List, Any, Mapping, Optional, Tuple, Type, cast +import zipfile +import xml.etree.ElementTree as ET +import io +import tempfile +from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer +from ...camassets import cam_assets +from ..doc import ( + find_shape_object, + get_object_properties, + update_shape_object_properties, + ShapeDocFromBytes, +) +from .icon import ToolBitShapeIcon + + +class ToolBitShape(Asset): + """Abstract base class for tool bit shapes.""" + + asset_type: str = "toolbitshape" + + # The name is used... + # 1. as a base for the default filename. E.g. if the name is + # "Endmill", then by default the file is "endmill.fcstd". + # 2. to identify the shape class from a shape.fcstd file. + # Upon loading a shape, the name of the body in the shape + # file is read. It MUST match one of the names. + name: str + + # Aliases exist for backward compatibility. If an existing .fctb file + # references a shape such as "v-bit.fctb", and that shape file cannot + # be found, then we can attempt to find a shape class from the string + # "v-bit", "vbit", etc. + aliases: Tuple[str, ...] = tuple() + + def __init__(self, id: str, **kwargs: Any): + """ + Initialize the shape. + + Args: + id (str): The unique identifier for the shape. + **kwargs: Keyword arguments for shape parameters (e.g., Diameter). + Values should be FreeCAD.Units.Quantity where applicable. + """ + # _params will be populated with default values after loading + self._params: Dict[str, Any] = {} + + # Stores default parameter values loaded from the FCStd file + self._defaults: Dict[str, Any] = {} + + # Keeps the loaded FreeCAD document content for this instance + self._data: Optional[bytes] = None + + self.id: str = id + + self.is_builtin: bool = True + + self.icon: Optional[ToolBitShapeIcon] = None + + # Assign parameters + for param, value in kwargs.items(): + self.set_parameter(param, value) + + def __str__(self): + params_str = ", ".join(f"{name}={val}" for name, val in self._params.items()) + return f"{self.name}({params_str})" + + def __repr__(self): + return self.__str__() + + def get_id(self) -> str: + """ + Get the ID of the shape. + + Returns: + str: The ID of the shape. + """ + return self.id + + @classmethod + def _get_shape_class_from_doc(cls, doc: "FreeCAD.Document") -> Type["ToolBitShape"]: + # Find the Body object to identify the shape type + body_obj = find_shape_object(doc) + if not body_obj: + raise ValueError(f"No 'PartDesign::Body' object found in {doc}") + + # Find the correct subclass based on the body label + shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()} + shape_class = shape_classes.get(body_obj.Label) + if not shape_class: + raise ValueError( + f"No ToolBitShape subclass found matching Body label '{body_obj.Label}' in {doc}" + ) + return shape_class + + @classmethod + def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]: + """ + Identifies the ToolBitShape subclass from the raw bytes of an FCStd file + by parsing the XML content to find the Body label. + + Args: + data (bytes): The raw bytes of the .FCStd file. + + Returns: + Type[ToolBitShape]: The appropriate ToolBitShape subclass. + + Raises: + ValueError: If the data is not a valid FCStd file, Document.xml is + missing, no Body object is found, or the Body label + does not match a known shape name. + """ + try: + # FCStd files are zip archives + with zipfile.ZipFile(io.BytesIO(data)) as zf: + # Read Document.xml from the archive + with zf.open("Document.xml") as doc_xml_file: + tree = ET.parse(doc_xml_file) + root = tree.getroot() + + # Extract name of the main Body from XML tree using xpath. + # The body should be a PartDesign::Body, and its label is + # stored in an Property element with a matching name. + body_label = None + xpath = './/Object[@name="Body"]//Property[@name="Label"]/String' + body_label_elem = root.find(xpath) + if body_label_elem is not None: + body_label = body_label_elem.get("value") + + if not body_label: + raise ValueError( + "No 'Label' property found for 'PartDesign::Body' object using XPath" + ) + + # Find the correct subclass based on the body label + shape_class = cls.get_subclass_by_name(body_label) + if not shape_class: + raise ValueError( + f"No ToolBitShape subclass found matching Body label '{body_label}'" + ) + return shape_class + + except zipfile.BadZipFile: + raise ValueError("Invalid FCStd file data (not a valid zip archive)") + except KeyError: + raise ValueError("Invalid FCStd file data (Document.xml not found)") + except ET.ParseError: + raise ValueError("Error parsing Document.xml") + except Exception as e: + # Catch any other unexpected errors during parsing + raise ValueError(f"Error processing FCStd data: {e}") + + @classmethod + def _find_property_object(cls, doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]: + """ + Find the PropertyBag object named "Attributes" in a document. + + Args: + doc (FreeCAD.Document): The document to search within. + + Returns: + Optional[FreeCAD.DocumentObject]: The found object or None. + """ + for o in doc.Objects: + # Check if the object has a Label property and if its value is "Attributes" + # This seems to be the convention in the shape files. + if hasattr(o, "Label") and o.Label == "Attributes": + # We assume this object holds the parameters. + # Further type checking (e.g., for App::FeaturePython or PropertyBag) + # could be added if needed, but Label check might be sufficient. + return o + return None + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: + """ + Extracts URIs of dependencies from the raw bytes of an FCStd file. + For ToolBitShape, this is the associated ToolBitShapeIcon, identified + by the same ID as the shape asset. + """ + Path.Log.debug(f"ToolBitShape.extract_dependencies called for {cls.__name__}") + assert ( + serializer == DummyAssetSerializer + ), f"ToolBitShape supports only native import, not {serializer}" + + # A ToolBitShape asset depends on a ToolBitShapeIcon asset with the same ID. + # We need to extract the shape ID from the FCStd data. + try: + # Open the shape data temporarily to get the Body label, which can + # be used to derive the ID if needed, or assume the ID is available + # in the data somehow (e.g., in a property). + # For now, let's assume the ID is implicitly the asset name derived + # from the Body label. + shape_class = cls.get_shape_class_from_bytes(data) + shape_id = shape_class.name.lower() # Assuming ID is lowercase name + + # Construct the URI for the corresponding icon asset + svg_uri = AssetUri.build( + asset_type="toolbitshapesvg", + asset_id=shape_id + ".svg", + ) + png_uri = AssetUri.build( + asset_type="toolbitshapepng", + asset_id=shape_id + ".png", + ) + return [svg_uri, png_uri] + + except Exception as e: + # If we can't extract the shape ID or something goes wrong, + # assume no dependencies for now. + Path.Log.error(f"Failed to extract dependencies from shape data: {e}") + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], + ) -> "ToolBitShape": + """ + Create a ToolBitShape instance from the raw bytes of an FCStd file. + + Identifies the correct subclass based on the Body label in the file, + loads parameters, and caches the document content. + + Args: + data (bytes): The raw bytes of the .FCStd file. + id (str): The unique identifier for the shape. + dependencies (Optional[Mapping[AssetUri, Any]]): A mapping of + resolved dependencies. If None, shallow load was attempted. + + Returns: + ToolBitShape: An instance of the appropriate ToolBitShape subclass. + + Raises: + ValueError: If the data cannot be opened, no Body or PropertyBag + is found, or the Body label does not match a known + shape name. + Exception: For other potential FreeCAD errors during loading. + """ + assert serializer == DummyAssetSerializer, "ToolBitShape supports only native import" + + # Open the shape data temporarily to get the Body label and parameters + with ShapeDocFromBytes(data) as temp_doc: + if not temp_doc: + # This case might be covered by ShapeDocFromBytes exceptions, + # but keeping for clarity. + raise ValueError("Failed to open shape document from bytes") + + # Determine the specific subclass of ToolBitShape using the new method + shape_class = ToolBitShape.get_shape_class_from_bytes(data) + + # Load properties from the temporary document + props_obj = ToolBitShape._find_property_object(temp_doc) + if not props_obj: + raise ValueError("No 'Attributes' PropertyBag object found in document bytes") + + # Get properties from the properties object + expected_params = shape_class.get_expected_shape_parameters() + loaded_params = get_object_properties(props_obj, expected_params) + + missing_params = [ + name + for name in expected_params + if name not in loaded_params or loaded_params[name] is None + ] + + if missing_params: + raise ValueError( + f"Validation error: Object '{props_obj.Label}' in document bytes " + + f"is missing parameters for {shape_class.__name__}: {', '.join(missing_params)}" + ) + + # Instantiate the specific subclass with the provided ID + instance = shape_class(id=id) + instance._data = data # Cache the byte content + instance._defaults = loaded_params + + if dependencies: # dependencies is None = shallow load + # Assign resolved dependencies (like the icon) to the instance + # The icon has the same ID as the shape, with .png or .svg appended. + icon_uri = AssetUri.build( + asset_type="toolbitshapesvg", + asset_id=id + ".svg", + ) + instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri)) + if not instance.icon: + icon_uri = AssetUri.build( + asset_type="toolbitshapepng", + asset_id=id + ".png", + ) + instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri)) + + # Update instance parameters, prioritizing loaded defaults but not + # overwriting parameters that may already be set during __init__ + instance._params = instance._defaults | instance._params + + return instance + + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: + """ + Serializes a ToolBitShape object to bytes (e.g., an fcstd file). + This is required by the Asset interface. + """ + assert serializer == DummyAssetSerializer, "ToolBitShape supports only native export" + doc = None + try: + # Create a new temporary document + doc = FreeCAD.newDocument("TemporaryShapeDoc", hidden=True) + + # Add the shape's body to the temporary document + self.make_body(doc) + + # Recompute the document to ensure the body is created + doc.recompute() + + # Save the temporary document to a temporary file + # We cannot use NamedTemporaryFile on Windows, because there + # doc.saveAs() may not have permission to access the tempfile + # while the NamedTemporaryFile is open. + # So we use TemporaryDirectory instead, to ensure cleanup while + # still having a the temporary file inside it. + with tempfile.TemporaryDirectory() as thedir: + temp_file_path = pathlib.Path(thedir, "temp.FCStd") + doc.saveAs(str(temp_file_path)) + return temp_file_path.read_bytes() + + finally: + # Clean up the temporary document + if doc: + FreeCAD.closeDocument(doc.Name) + + @classmethod + def from_file(cls, filepath: pathlib.Path, **kwargs: Any) -> "ToolBitShape": + """ + Create a ToolBitShape instance from an FCStd file. + + Reads the file bytes and delegates to from_bytes(). + + Args: + filepath (pathlib.Path): Path to the .FCStd file. + **kwargs: Keyword arguments for shape parameters to override defaults. + + Returns: + ToolBitShape: An instance of the appropriate ToolBitShape subclass. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file cannot be opened, no Body or PropertyBag + is found, or the Body label does not match a known + shape name. + Exception: For other potential FreeCAD errors during loading. + """ + if not filepath.exists(): + raise FileNotFoundError(f"Shape file not found: {filepath}") + + try: + data = filepath.read_bytes() + # Extract the ID from the filename (without extension) + shape_id = filepath.stem + # Pass an empty dictionary for dependencies when loading from a single file + # TODO: pass ToolBitShapeIcon as a dependency + instance = cls.from_bytes(data, shape_id, {}, DummyAssetSerializer) + # Apply kwargs parameters after loading from bytes + if kwargs: + instance.set_parameters(**kwargs) + return instance + except (FileNotFoundError, ValueError) as e: + raise e + except Exception as e: + raise RuntimeError(f"Failed to create shape from {filepath}: {e}") + + @classmethod + def get_subclass_by_name( + cls, name: str, default: Type["ToolBitShape"] | None = None + ) -> Optional[Type["ToolBitShape"]]: + """ + Retrieves a ToolBitShape class by its name or alias. + """ + name = name.lower() + for thecls in cls.__subclasses__(): + if ( + thecls.name.lower() == name + or thecls.__name__.lower() == name + or name in thecls.aliases + ): + return thecls + return default + + @classmethod + def resolve_name(cls, identifier: str) -> AssetUri: + """ + Resolves an identifier (alias, name, filename, or URI) to a Uri object. + """ + # 1. If the input is a url string, return the AssetUri for it. + if AssetUri.is_uri(identifier): + return AssetUri(identifier) + + # 2. If the input is a filename (with extension), assume the asset + # name is the base name. + asset_name = identifier + if identifier.endswith(".fcstd"): + asset_name = os.path.splitext(os.path.basename(identifier))[0] + + # 3. Use get_subclass_by_name to try to resolve alias to a class. + # if one is found, use the class.name. + shape_class = cls.get_subclass_by_name(asset_name.lower()) + if shape_class: + asset_name = shape_class.name.lower() + + # 4. Construct the Uri using AssetUri.build() and return it + return AssetUri.build( + asset_type="toolbitshape", + asset_id=asset_name, + ) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + """ + Subclasses must define the dictionary mapping parameter names to + translations and FreeCAD property type strings (e.g., + 'App::PropertyLength'). + + The schema defines any parameters that MUST be in the shape file. + Any attempt to load a shape file that does not match the schema + will cause an error. + """ + raise NotImplementedError + + @property + def label(self) -> str: + """Return a user friendly, translatable display name.""" + raise NotImplementedError + + def reset_parameters(self): + """Reset parameters to their default values.""" + self._params.update(self._defaults) + + def get_parameter_label(self, param_name: str) -> str: + """ + Get the user-facing label for a given parameter name. + """ + str_param_name = str(param_name) + entry = self.schema().get(param_name) + return entry[0] if entry else str_param_name + + def get_parameter_property_type(self, param_name: str) -> str: + """ + Get the FreeCAD property type string for a given parameter name. + """ + return self.schema()[param_name][1] + + def get_parameters(self) -> Dict[str, Any]: + """ + Get the dictionary of current parameters and their values. + + Returns: + dict: A dictionary mapping parameter names to their values. + """ + return self._params + + def get_parameter(self, name: str) -> Any: + """ + Get the value of a specific parameter. + + Args: + name (str): The name of the parameter. + + Returns: + The value of the parameter (often a FreeCAD.Units.Quantity). + + Raises: + KeyError: If the parameter name is not valid for this shape. + """ + if name not in self.schema(): + raise KeyError(f"Shape '{self.name}' has no parameter '{name}'") + return self._params[name] + + def set_parameter(self, name: str, value: Any): + """ + Set the value of a specific parameter. + + Args: + name (str): The name of the parameter. + value: The new value for the parameter. Should be compatible + with the expected type (e.g., FreeCAD.Units.Quantity). + + Raises: + KeyError: If the parameter name is not valid for this shape. + """ + if name not in self.schema().keys(): + Path.Log.debug( + f"Shape '{self.name}' was given an invalid parameter '{name}'. Has {self._params}\n" + ) + # Log to confirm this path is taken when an invalid parameter is given + Path.Log.debug( + f"Invalid parameter '{name}' for shape " + f"'{self.name}', returning without raising KeyError." + ) + return + + self._params[name] = value + + def set_parameters(self, **kwargs): + """ + Set multiple parameters using keyword arguments. + + Args: + **kwargs: Keyword arguments where keys are parameter names. + """ + for name, value in kwargs.items(): + try: + self.set_parameter(name, value) + except KeyError: + Path.Log.debug(f"Ignoring unknown parameter '{name}' for shape '{self.name}'.\n") + + @classmethod + def get_expected_shape_parameters(cls) -> List[str]: + """ + Get a list of parameter names expected by this shape class based on + its schema. + + Returns: + list[str]: List of parameter names. + """ + return list(cls.schema().keys()) + + def make_body(self, doc: "FreeCAD.Document"): + """ + Generates the body of the ToolBitShape and copies it to the provided + document. + """ + assert self._data is not None + with ShapeDocFromBytes(self._data) as tmp_doc: + shape = find_shape_object(tmp_doc) + if not shape: + FreeCAD.Console.PrintWarning( + "No suitable shape object found in document. " "Cannot create solid shape.\n" + ) + return None + + props = self._find_property_object(tmp_doc) + if not props: + FreeCAD.Console.PrintWarning( + "No suitable shape object found in document. " "Cannot create solid shape.\n" + ) + return None + + update_shape_object_properties(props, self.get_parameters()) + + # Recompute the document to apply property changes + tmp_doc.recompute() + + # Copy the body to the given document without immediate compute. + return doc.copyObject(shape, True) + + """ + Retrieves the thumbnail data for the tool bit shape in PNG format. + """ + + def get_icon(self) -> Optional[ToolBitShapeIcon]: + """ + Get the associated ToolBitShapeIcon instance. Tries to load one from + the asset manager if none was assigned. + + Returns: + Optional[ToolBitShapeIcon]: The icon instance, or None if none found. + """ + if self.icon: + return self.icon + + # Try to get a matching SVG from the asset manager. + self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.id}.svg") + if self.icon: + return self.icon + self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.name.lower()}.svg") + if self.icon: + return self.icon + + # Try to get a matching PNG from the asset manager. + self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.id}.png") + if self.icon: + return self.icon + self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.name.lower()}.png") + if self.icon: + return self.icon + return None + + def get_thumbnail(self) -> Optional[bytes]: + """ + Retrieves the thumbnail data for the tool bit shape in PNG format, + as embedded in the shape file. + """ + if not self._data: + return None + with zipfile.ZipFile(io.BytesIO(self._data)) as zf: + try: + with zf.open("thumbnails/Thumbnail.png", "r") as tn: + return tn.read() + except KeyError: + pass + return None diff --git a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py new file mode 100644 index 0000000000..faeb13dc45 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeBullnose(ToolBitShape): + name = "Bullnose" + aliases = "bullnose", "torus" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "FlatRadius": ( + FreeCAD.Qt.translate("ToolBitShape", "Torus radius"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Torus") diff --git a/src/Mod/CAM/Path/Tool/shape/models/chamfer.py b/src/Mod/CAM/Path/Tool/shape/models/chamfer.py new file mode 100644 index 0000000000..ce880cbdbc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/chamfer.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeChamfer(ToolBitShape): + name = "Chamfer" + aliases = ("chamfer",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"), + "App::PropertyAngle", + ), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Chamfer") diff --git a/src/Mod/CAM/Path/Tool/shape/models/dovetail.py b/src/Mod/CAM/Path/Tool/shape/models/dovetail.py new file mode 100644 index 0000000000..7e52e8ec48 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/dovetail.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeDovetail(ToolBitShape): + name = "Dovetail" + aliases = ("dovetail",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "TipDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Crest height"), + "App::PropertyLength", + ), + "CuttingEdgeAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"), + "App::PropertyAngle", + ), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Dovetail height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Major diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "NeckDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"), + "App::PropertyLength", + ), + "NeckHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Dovetail") diff --git a/src/Mod/CAM/Path/Tool/shape/models/drill.py b/src/Mod/CAM/Path/Tool/shape/models/drill.py new file mode 100644 index 0000000000..3dd7ea68dc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/drill.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeDrill(ToolBitShape): + name = "Drill" + aliases = ("drill",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "TipAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Tip angle"), + "App::PropertyAngle", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("CAM", "Drill") diff --git a/src/Mod/CAM/Path/Tool/shape/models/endmill.py b/src/Mod/CAM/Path/Tool/shape/models/endmill.py new file mode 100644 index 0000000000..bce686aeb7 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/endmill.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeEndmill(ToolBitShape): + name = "Endmill" + aliases = ("endmill",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitToolBitShapeShapeEndMill", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Endmill") diff --git a/src/Mod/CAM/Path/Tool/shape/models/fillet.py b/src/Mod/CAM/Path/Tool/shape/models/fillet.py new file mode 100644 index 0000000000..0156b910ff --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/fillet.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeFillet(ToolBitShape): + name = "Fillet" + aliases = ("fillet",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CrownHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Crown height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "FilletRadius": ( + FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer") diff --git a/src/Mod/CAM/Path/Tool/shape/models/icon.py b/src/Mod/CAM/Path/Tool/shape/models/icon.py new file mode 100644 index 0000000000..702f7e448a --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/icon.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +import xml.etree.ElementTree as ET +from typing import Mapping, Optional +from functools import cached_property +from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer +import Path.Tool.shape.util as util +from PySide import QtCore, QtGui, QtSvg + +_svg_ns = {"s": "http://www.w3.org/2000/svg"} + + +class ToolBitShapeIcon(Asset): + """Abstract base class for tool bit shape icons.""" + + def __init__(self, id: str, data: bytes): + """ + Initialize the icon. + + Args: + id (str): The unique identifier for the icon, including extension. + data (bytes): The raw icon data (e.g., SVG or PNG bytes). + """ + self.id: str = id + self.data: bytes = data + + def get_id(self) -> str: + """ + Get the ID of the icon. + + Returns: + str: The ID of the icon. + """ + return self.id + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: AssetSerializer, + ) -> "ToolBitShapeIcon": + """ + Create a ToolBitShapeIcon instance from raw bytes. + + Args: + data (bytes): The raw bytes of the icon file. + id (str): The ID of the asset, including extension. + dependencies (Optional[Mapping[AssetUri, Asset]]): A mapping of resolved dependencies (not used for icons). + + Returns: + ToolBitShapeIcon: An instance of ToolBitShapeIcon. + """ + assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native import" + return cls(id=id, data=data) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + """ + Serializes a ToolBitShapeIcon object to bytes. + """ + assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native export" + return self.data + + @classmethod + def from_file(cls, filepath: pathlib.Path, id: str) -> "ToolBitShapeIcon": + """ + Create a ToolBitShapeIcon instance from a file. + + Args: + filepath (pathlib.Path): Path to the icon file (.svg or .png). + shape_id_base (str): The base ID of the associated shape. + + Returns: + ToolBitShapeIcon: An instance of ToolBitShapeIcon. + + Raises: + FileNotFoundError: If the file does not exist. + """ + if not filepath.exists(): + raise FileNotFoundError(f"Icon file not found: {filepath}") + + data = filepath.read_bytes() + if filepath.suffix.lower() == ".png": + return ToolBitShapePngIcon(id, data) + elif filepath.suffix.lower() == ".svg": + return ToolBitShapeSvgIcon(id, data) + else: + raise NotImplementedError(f"unsupported icon file: {filepath}") + + @classmethod + def from_shape_data(cls, shape_data: bytes, id: str) -> Optional["ToolBitShapeIcon"]: + """ + Create a thumbnail icon from shape data bytes. + + Args: + shape_data (bytes): The raw bytes of the shape file (.FCStd). + shape_id_base (str): The base ID of the associated shape. + + Returns: + Optional[ToolBitShapeIcon]: An instance of ToolBitShapeIcon (PNG), or None. + """ + image_bytes = util.create_thumbnail_from_data(shape_data) + if not image_bytes: + return None + + # Assuming create_thumbnail_from_data returns PNG data + return ToolBitShapePngIcon(id=id, data=image_bytes) + + def get_size_in_bytes(self) -> int: + """ + Get the size of the icon data in bytes. + """ + return len(self.data) + + @cached_property + def abbreviations(self) -> Mapping[str, str]: + """ + Returns a cached mapping of parameter abbreviations from the icon data. + """ + return {} + + def get_abbr(self, param_name: str) -> Optional[str]: + """ + Retrieves the abbreviation for a given parameter name. + + Args: + param_name: The name of the parameter. + + Returns: + The abbreviation string, or None if not found. + """ + normalized_param_name = param_name.lower().replace(" ", "_") + return self.abbreviations.get(normalized_param_name) + + def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes: + """ + Returns the icon data as PNG bytes. + """ + raise NotImplementedError + + def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap: + """ + Returns the icon data as a QPixmap. + """ + raise NotImplementedError + + +class ToolBitShapeSvgIcon(ToolBitShapeIcon): + asset_type: str = "toolbitshapesvg" + + def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes: + """ + Converts SVG icon data to PNG and returns it using QtSvg. + """ + if icon_size is None: + icon_size = QtCore.QSize(48, 48) + image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32) + image.fill(QtGui.Qt.transparent) + painter = QtGui.QPainter(image) + + buffer = QtCore.QBuffer(QtCore.QByteArray(self.data)) + buffer.open(QtCore.QIODevice.ReadOnly) + svg_renderer = QtSvg.QSvgRenderer(buffer) + svg_renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio) + svg_renderer.render(painter) + painter.end() + + byte_array = QtCore.QByteArray() + buffer = QtCore.QBuffer(byte_array) + buffer.open(QtCore.QIODevice.WriteOnly) + image.save(buffer, "PNG") + + return bytes(byte_array) + + def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap: + """ + Returns the SVG icon data as a QPixmap using QtSvg. + """ + if icon_size is None: + icon_size = QtCore.QSize(48, 48) + icon_ba = QtCore.QByteArray(self.data) + image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32) + image.fill(QtGui.Qt.transparent) + painter = QtGui.QPainter(image) + + buffer = QtCore.QBuffer(icon_ba) # PySide6 + buffer.open(QtCore.QIODevice.ReadOnly) + data = QtCore.QXmlStreamReader(buffer) + renderer = QtSvg.QSvgRenderer(data) + renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio) + renderer.render(painter) + painter.end() + + return QtGui.QPixmap.fromImage(image) + + @cached_property + def abbreviations(self) -> Mapping[str, str]: + """ + Returns a cached mapping of parameter abbreviations from the icon data. + + Only applicable for SVG icons. + """ + if self.data: + return self.get_abbreviations_from_svg(self.data) + return {} + + def get_abbr(self, param_name: str) -> Optional[str]: + """ + Retrieves the abbreviation for a given parameter name. + + Args: + param_name: The name of the parameter. + + Returns: + The abbreviation string, or None if not found. + """ + normalized_param_name = param_name.lower().replace(" ", "_") + return self.abbreviations.get(normalized_param_name) + + @staticmethod + def get_abbreviations_from_svg(svg: bytes) -> Mapping[str, str]: + """ + Extract abbreviations from SVG text elements. + """ + try: + tree = ET.fromstring(svg) + except ET.ParseError: + return {} + + result = {} + for text_elem in tree.findall(".//s:text", _svg_ns): + id = text_elem.attrib.get("id", _svg_ns) + if id is None or not isinstance(id, str): + continue + + abbr = text_elem.text + if abbr is not None: + result[id.lower()] = abbr + + span_elem = text_elem.find(".//s:tspan", _svg_ns) + if span_elem is None: + continue + abbr = span_elem.text + result[id.lower()] = abbr + + return result + + +class ToolBitShapePngIcon(ToolBitShapeIcon): + asset_type: str = "toolbitshapepng" + + def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes: + """ + Returns the PNG icon data. + """ + # For PNG, resizing might be needed if icon_size is different + # from the original size. Simple return for now. + return self.data + + def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap: + """ + Returns the PNG icon data as a QPixmap. + """ + if icon_size is None: + icon_size = QtCore.QSize(48, 48) + pixmap = QtGui.QPixmap() + pixmap.loadFromData(self.data, "PNG") + # Scale the pixmap if the requested size is different + if pixmap.size() != icon_size: + pixmap = pixmap.scaled( + icon_size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) + return pixmap diff --git a/src/Mod/CAM/Path/Tool/shape/models/probe.py b/src/Mod/CAM/Path/Tool/shape/models/probe.py new file mode 100644 index 0000000000..9096308a94 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/probe.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeProbe(ToolBitShape): + name = "Probe" + aliases = ("probe",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Ball diameter"), + "App::PropertyLength", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Length of probe"), + "App::PropertyLength", + ), + "ShaftDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shaft diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Probe") diff --git a/src/Mod/CAM/Path/Tool/shape/models/reamer.py b/src/Mod/CAM/Path/Tool/shape/models/reamer.py new file mode 100644 index 0000000000..af643a0e95 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/reamer.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeReamer(ToolBitShape): + name = "Reamer" + aliases = ("reamer",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Reamer") diff --git a/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py new file mode 100644 index 0000000000..caca9e5fde --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeSlittingSaw(ToolBitShape): + name = "SlittingSaw" + aliases = "slittingsaw", "slitting-saw" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "BladeThickness": ( + FreeCAD.Qt.translate("ToolBitShape", "Blade thickness"), + "App::PropertyLength", + ), + "CapDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Cap diameter"), + "App::PropertyLength", + ), + "CapHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cap height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Slitting Saw") diff --git a/src/Mod/CAM/Path/Tool/shape/models/tap.py b/src/Mod/CAM/Path/Tool/shape/models/tap.py new file mode 100644 index 0000000000..494d0f3164 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/tap.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeTap(ToolBitShape): + name = "Tap" + aliases = ("Tap",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeLength": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge length"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Tap diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall length of tap"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "TipAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Tip angle"), + "App::PropertyAngle", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Tap") diff --git a/src/Mod/CAM/Path/Tool/shape/models/threadmill.py b/src/Mod/CAM/Path/Tool/shape/models/threadmill.py new file mode 100644 index 0000000000..15beccdf6d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/threadmill.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeThreadMill(ToolBitShape): + name = "ThreadMill" + aliases = "threadmill", "thread-mill" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Crest": ( + FreeCAD.Qt.translate("ToolBitShape", "Crest height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Major diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "NeckDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"), + "App::PropertyLength", + ), + "NeckLength": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "cuttingAngle": ( # TODO rename to CuttingAngle + FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"), + "App::PropertyAngle", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Thread Mill") diff --git a/src/Mod/CAM/Path/Tool/shape/models/vbit.py b/src/Mod/CAM/Path/Tool/shape/models/vbit.py new file mode 100644 index 0000000000..2c82b0d18e --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/vbit.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeVBit(ToolBitShape): + name = "VBit" + aliases = "vbit", "v-bit" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"), + "App::PropertyAngle", + ), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "TipDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Tip diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "V-Bit") diff --git a/src/Mod/CAM/Path/Tool/shape/ui/__init__.py b/src/Mod/CAM/Path/Tool/shape/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py b/src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py new file mode 100644 index 0000000000..ca8d168aa6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide.QtCore import * +from PySide.QtGui import * + + +class FlowLayout(QLayout): + widthChanged = Signal(int) + + def __init__(self, parent=None, margin=0, spacing=-1, orientation=Qt.Horizontal): + super(FlowLayout, self).__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + self.itemList = [] + self.orientation = orientation + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self.itemList.append(item) + + def count(self): + return len(self.itemList) + + def itemAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList[index] + + return None + + def takeAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList.pop(index) + + return None + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + if self.orientation == Qt.Horizontal: + return self.doLayoutHorizontal(QRect(0, 0, width, 0), True) + elif self.orientation == Qt.Vertical: + return self.doLayoutVertical(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + if self.orientation == Qt.Horizontal: + self.doLayoutHorizontal(rect, False) + elif self.orientation == Qt.Vertical: + self.doLayoutVertical(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self.itemList: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QSize(2 * margin, 2 * margin) + return size + + def doLayoutHorizontal(self, rect, testOnly): + # Get initial coordinates of the drawing region (should be 0, 0) + x = rect.x() + y = rect.y() + lineHeight = 0 + i = 0 + for item in self.itemList: + wid = item.widget() + # Space X and Y is item spacing horizontally and vertically + spaceX = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + spaceY = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + ) + # Determine the coordinate we want to place the item at + # It should be placed at : initial coordinate of the rect + width of the item + spacing + nextX = x + item.sizeHint().width() + spaceX + # If the calculated nextX is greater than the outer bound... + if nextX - spaceX > rect.right() and lineHeight > 0: + x = rect.x() # Reset X coordinate to origin of drawing region + y = y + lineHeight + spaceY # Move Y coordinate to the next line + nextX = ( + x + item.sizeHint().width() + spaceX + ) # Recalculate nextX based on the new X coordinate + lineHeight = 0 + + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = nextX # Store the next starting X coordinate for next item + lineHeight = max(lineHeight, item.sizeHint().height()) + i = i + 1 + + return y + lineHeight - rect.y() + + def doLayoutVertical(self, rect, testOnly): + # Get initial coordinates of the drawing region (should be 0, 0) + x = rect.x() + y = rect.y() + # Initialize column width and line height + columnWidth = 0 + lineHeight = 0 + + # Space between items + spaceX = 0 + spaceY = 0 + + # Variables that will represent the position of the widgets in a 2D Array + i = 0 + j = 0 + for item in self.itemList: + wid = item.widget() + # Space X and Y is item spacing horizontally and vertically + spaceX = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + spaceY = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + ) + # Determine the coordinate we want to place the item at + # It should be placed at : initial coordinate of the rect + width of the item + spacing + nextY = y + item.sizeHint().height() + spaceY + # If the calculated nextY is greater than the outer bound, move to the next column + if nextY - spaceY > rect.bottom() and columnWidth > 0: + y = rect.y() # Reset y coordinate to origin of drawing region + x = x + columnWidth + spaceX # Move X coordinate to the next column + nextY = ( + y + item.sizeHint().height() + spaceY + ) # Recalculate nextX based on the new X coordinate + # Reset the column width + columnWidth = 0 + + # Set indexes of the item for the 2D array + j += 1 + i = 0 + + # Assign 2D array indexes + item.x_index = i + item.y_index = j + + # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false + # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??) + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + y = nextY # Store the next starting Y coordinate for next item + columnWidth = max( + columnWidth, item.sizeHint().width() + ) # Update the width of the column + lineHeight = max(lineHeight, item.sizeHint().height()) # Update the height of the line + + i += 1 # Increment i + + # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false + # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??) + if not testOnly: + self.calculateMaxWidth(i) + self.widthChanged.emit(self.totalMaxWidth + spaceX * self.itemsOnWidestRow) + return lineHeight + + # Method to calculate the maximum width among each "row" of the flow layout + # This will be useful to let the UI know the total width of the flow layout + def calculateMaxWidth(self, numberOfRows): + # Init variables + self.totalMaxWidth = 0 + self.itemsOnWidestRow = 0 + + # For each "row", calculate the total width by adding the width of each item + # and then update the totalMaxWidth if the calculated width is greater than the current value + # Also update the number of items on the widest row + for i in range(numberOfRows): + rowWidth = 0 + itemsOnWidestRow = 0 + for item in self.itemList: + # Only compare items from the same row + if item.x_index == i: + rowWidth += item.sizeHint().width() + itemsOnWidestRow += 1 + if rowWidth > self.totalMaxWidth: + self.totalMaxWidth = rowWidth + self.itemsOnWidestRow = itemsOnWidestRow diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py b/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py new file mode 100644 index 0000000000..75855ca7fc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide import QtGui, QtCore + + +class ShapeButton(QtGui.QToolButton): + def __init__(self, shape, parent=None): + super(ShapeButton, self).__init__(parent) + self.shape = shape + + self.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon) + self.setText(shape.label) + + self.setFixedSize(128, 128) + self.setBaseSize(128, 128) + self.icon_size = QtCore.QSize(71, 100) + self.setIconSize(self.icon_size) + + self._update_icon() + + def set_text(self, text): + self.label.setText(text) + + def _update_icon(self): + icon = self.shape.get_icon() + if icon: + pixmap = icon.get_qpixmap(self.icon_size) + self.setIcon(QtGui.QIcon(pixmap)) diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py new file mode 100644 index 0000000000..4fa3e1885b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from typing import Optional, cast +import FreeCADGui +from functools import partial +from PySide import QtGui +from ...camassets import cam_assets +from .. import ToolBitShape +from .flowlayout import FlowLayout +from .shapebutton import ShapeButton + + +class ShapeSelector: + def __init__(self): + self.shape = None + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ShapeSelector.ui") + + self.form.buttonBox.clicked.connect(self.form.close) + + self.flows = {} + + self.update_shapes() + self.form.toolBox.setCurrentIndex(0) + + def _add_shape_group(self, toolbox): + if toolbox in self.flows: + return self.flows[toolbox] + flow = FlowLayout(toolbox, orientation=QtGui.Qt.Horizontal) + flow.widthChanged.connect(lambda x: toolbox.setMinimumWidth(x)) + self.flows[toolbox] = flow + return flow + + def _add_shapes(self, toolbox, shapes): + flow = self._add_shape_group(toolbox) + + # Remove all shapes first. + for i in reversed(range(flow.count())): + flow.itemAt(i).widget().setParent(None) + + # Add all shapes. + for shape in sorted(shapes, key=lambda x: x.label): + button = ShapeButton(shape) + flow.addWidget(button) + cb = partial(self.on_shape_button_clicked, shape) + button.clicked.connect(cb) + + def update_shapes(self): + # Retrieve each shape asset + shapes = set(cam_assets.fetch(asset_type="toolbitshape")) + + builtin = set(s for s in shapes if cast(ToolBitShape, s).is_builtin) + self._add_shapes(self.form.standardTools, builtin) + self._add_shapes(self.form.customTools, shapes - builtin) + + def on_shape_button_clicked(self, shape): + self.shape = shape + self.form.close() + + def show(self) -> Optional[ToolBitShape]: + self.form.exec() + return self.shape diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py new file mode 100644 index 0000000000..2bea5969f2 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide import QtGui, QtCore + + +class ShapeWidget(QtGui.QWidget): + def __init__(self, shape, parent=None): + super(ShapeWidget, self).__init__(parent) + self.layout = QtGui.QVBoxLayout(self) + self.layout.setAlignment(QtCore.Qt.AlignHCenter) + + self.shape = shape + ratio = self.devicePixelRatioF() + self.icon_size = QtCore.QSize(200 * ratio, 235 * ratio) + self.icon_widget = QtGui.QLabel() + self.layout.addWidget(self.icon_widget) + + self._update_icon() + + def _update_icon(self): + icon = self.shape.get_icon() + if icon: + pixmap = icon.get_qpixmap(self.icon_size) + self.icon_widget.setPixmap(pixmap) diff --git a/src/Mod/CAM/Path/Tool/shape/util.py b/src/Mod/CAM/Path/Tool/shape/util.py new file mode 100644 index 0000000000..0d6e8aba91 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/util.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +from typing import Optional +import FreeCAD +import tempfile +import os +from .doc import ShapeDocFromBytes + + +_svg_ns = {"s": "http://www.w3.org/2000/svg"} + + +def file_is_newer(reference: pathlib.Path, file: pathlib.Path): + return reference.stat().st_mtime > file.stat().st_mtime + + +def create_thumbnail(filepath: pathlib.Path, w: int = 200, h: int = 200) -> Optional[pathlib.Path]: + if not FreeCAD.GuiUp: + return None + + try: + import FreeCADGui + except ImportError: + raise RuntimeError("Error: Could not load UI - is it up?") + + doc = FreeCAD.openDocument(str(filepath)) + view = FreeCADGui.activeDocument().ActiveView + out_filepath = filepath.with_suffix(".png") + if not view: + print("No view active, cannot make thumbnail for {}".format(filepath)) + return + + view.viewFront() + view.fitAll() + view.setAxisCross(False) + view.saveImage(str(out_filepath), w, h, "Transparent") + + FreeCAD.closeDocument(doc.Name) + return out_filepath + + +def create_thumbnail_from_data(shape_data: bytes, w: int = 200, h: int = 200) -> Optional[bytes]: + """ + Create a thumbnail icon from shape data bytes using a temporary document. + + Args: + shape_data (bytes): The raw bytes of the shape file (.FCStd). + w (int): Width of the thumbnail. + h (int): Height of the thumbnail. + + Returns: + Optional[bytes]: PNG image bytes, or None if generation fails. + """ + if not FreeCAD.GuiUp: + return None + + try: + import FreeCADGui + except ImportError: + raise RuntimeError("Error: Could not load UI - is it up?") + + temp_png_path = None + try: + with ShapeDocFromBytes(shape_data) as doc: + view = FreeCADGui.activeDocument().ActiveView + + if not view: + print("No view active, cannot make thumbnail from data") + return None + + view.viewFront() + view.fitAll() + view.setAxisCross(False) + + # Create a temporary file path for the output PNG + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + temp_png_path = pathlib.Path(temp_file.name) + + view.saveImage(str(temp_png_path), w, h, "Transparent") + + # Read the PNG bytes + with open(temp_png_path, "rb") as f: + png_bytes = f.read() + + return png_bytes + + except Exception as e: + print(f"Error creating thumbnail from data: {e}") + return None + + finally: + # Clean up temporary PNG file + if temp_png_path and temp_png_path.exists(): + os.remove(temp_png_path) diff --git a/src/Mod/CAM/Path/Tool/toolbit/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/__init__.py new file mode 100644 index 0000000000..5b1def1d18 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This package aggregates tool bit classes. + +# Import the base class and all concrete shape classes +from .models.base import ToolBit +from .models.ballend import ToolBitBallend +from .models.chamfer import ToolBitChamfer +from .models.dovetail import ToolBitDovetail +from .models.drill import ToolBitDrill +from .models.endmill import ToolBitEndmill +from .models.fillet import ToolBitFillet +from .models.probe import ToolBitProbe +from .models.reamer import ToolBitReamer +from .models.slittingsaw import ToolBitSlittingSaw +from .models.tap import ToolBitTap +from .models.threadmill import ToolBitThreadMill +from .models.bullnose import ToolBitBullnose +from .models.vbit import ToolBitVBit + +# Define __all__ for explicit public interface +__all__ = [ + "ToolBit", + "ToolBitBallend", + "ToolBitChamfer", + "ToolBitDovetail", + "ToolBitDrill", + "ToolBitEndmill", + "ToolBitFillet", + "ToolBitProbe", + "ToolBitReamer", + "ToolBitSlittingSaw", + "ToolBitTap", + "ToolBitThreadMill", + "ToolBitBullnose", + "ToolBitVBit", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/docobject.py b/src/Mod/CAM/Path/Tool/toolbit/docobject.py new file mode 100644 index 0000000000..5bc8f29662 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/docobject.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from typing import Any, Dict, List, Optional + + +class DetachedDocumentObject: + """ + A lightweight class mimicking the property API of a FreeCAD DocumentObject. + + This class is used by ToolBit instances when they are not associated + with a real FreeCAD DocumentObject, allowing properties to be stored + and accessed in a detached state. + """ + + def __init__(self, label: str = "DetachedObject"): + self.Label: str = label + self.Name: str = label.replace(" ", "_") + self.PropertiesList: List[str] = [] + self._properties: Dict[str, Any] = {} + self._property_groups: Dict[str, Optional[str]] = {} + self._property_types: Dict[str, Optional[str]] = {} + self._property_docs: Dict[str, Optional[str]] = {} + self._editor_modes: Dict[str, int] = {} + self._property_enums: Dict[str, List[str]] = {} + + def addProperty( + self, + thetype: Optional[str], + name: str, + group: Optional[str], + doc: Optional[str], + ) -> None: + """Mimics FreeCAD DocumentObject.addProperty.""" + if name not in self._properties: + self.PropertiesList.append(name) + self._properties[name] = None + self._property_groups[name] = group + self._property_types[name] = thetype + self._property_docs[name] = doc + + def getPropertyByName(self, name: str) -> Any: + """Mimics FreeCAD DocumentObject.getPropertyByName.""" + return self._properties.get(name) + + def setPropertyByName(self, name: str, value: Any) -> None: + """Mimics FreeCAD DocumentObject.setPropertyByName.""" + self._properties[name] = value + + def __setattr__(self, name: str, value: Any) -> None: + """ + Intercept attribute assignment. This is done to behave like + FreeCAD's DocumentObject, which may have any property assigned, + pre-defined or not. + Without this, code linters report an error when trying to set + a property that is not defined in the class. + + Handles assignment of enumeration choices (lists/tuples) and + converts string representations of Quantity types to Quantity objects. + """ + if name in ("PropertiesList", "Label", "Name") or name.startswith("_"): + super().__setattr__(name, value) + return + + # Handle assignment of enumeration choices (list/tuple) + prop_type = self._property_types.get(name) + if prop_type == "App::PropertyEnumeration" and isinstance(value, (list, tuple)): + self._property_enums[name] = list(value) + assert len(value) > 0, f"Enum property '{name}' must have at least one entry" + self._properties.setdefault(name, value[0]) + return + + # Attempt to convert string values to Quantity if the property type is Quantity + elif prop_type in [ + "App::PropertyQuantity", + "App::PropertyLength", + "App::PropertyArea", + "App::PropertyVolume", + "App::PropertyAngle", + ]: + value = FreeCAD.Units.Quantity(value) + + # Store the (potentially converted) value + self._properties[name] = value + Path.Log.debug( + f"DetachedDocumentObject: Set property '{name}' to " + f"value {value} (type: {type(value)})" + ) + + def __getattr__(self, name: str) -> Any: + """Intercept attribute access.""" + if name in self._properties: + return self._properties[name] + # Default behaviour: raise AttributeError + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def setEditorMode(self, name: str, mode: int) -> None: + """Stores editor mode settings in detached state.""" + self._editor_modes[name] = mode + + def getEditorMode(self, name: str) -> int: + """Stores editor mode settings in detached state.""" + return self._editor_modes.get(name, 0) or 0 + + def getGroupOfProperty(self, name: str) -> Optional[str]: + """Returns the stored group for a property in detached state.""" + return self._property_groups.get(name) + + def getTypeIdOfProperty(self, name: str) -> Optional[str]: + """Returns the stored type string for a property in detached state.""" + return self._property_types.get(name) + + def getEnumerationsOfProperty(self, name: str) -> List[str]: + """Returns the stored enumeration list for a property.""" + return self._property_enums.get(name, []) + + @property + def ExpressionEngine(self) -> List[Any]: + """Mimics the ExpressionEngine attribute of a real DocumentObject.""" + return [] # Return an empty list to satisfy iteration + + def copy_to(self, obj: FreeCAD.DocumentObject) -> None: + """ + Copies properties from this detached object to a real DocumentObject. + """ + for prop_name in self.PropertiesList: + if not hasattr(self, prop_name): + continue + + prop_value = self.getPropertyByName(prop_name) + prop_type = self._property_types.get(prop_name) + prop_group = self._property_groups.get(prop_name) + prop_doc = self._property_docs.get(prop_name, "") + prop_editor_mode = self._editor_modes.get(prop_name) + + # If the property doesn't exist in the target object, add it + if not hasattr(obj, prop_name): + # For enums, addProperty expects "App::PropertyEnumeration" + # The list of choices is set afterwards. + obj.addProperty(prop_type, prop_name, prop_group, prop_doc) + + # If it's an enumeration, set its list of choices first + if prop_type == "App::PropertyEnumeration": + enum_choices = self._property_enums.get(prop_name) + assert enum_choices is not None + setattr(obj, prop_name, enum_choices) + + # Set the property value and editor mode + try: + if prop_type == "App::PropertyEnumeration": + first_choice = self._property_enums[prop_name][0] + setattr(obj, prop_name, first_choice) + else: + setattr(obj, prop_name, prop_value) + + except Exception as e: + Path.Log.error( + f"Error setting property {prop_name} to {prop_value} " + f"(type: {type(prop_value)}, expected type: {prop_type}): {e}" + ) + raise + + if prop_editor_mode is not None: + obj.setEditorMode(prop_name, prop_editor_mode) diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py new file mode 100644 index 0000000000..67219906ff --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from .rotary import RotaryToolBitMixin +from .cutting import CuttingToolMixin + +__all__ = [ + "RotaryToolBitMixin", + "CuttingToolMixin", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py new file mode 100644 index 0000000000..73eb9cec37 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +from PySide.QtCore import QT_TRANSLATE_NOOP + + +class CuttingToolMixin: + """ + This is a interface class to indicate that the ToolBit can chip, i.e. + it has a Chipload property. + It is used to determine if the tool bit can be used for chip removal. + """ + + def __init__(self, obj, *args, **kwargs): + obj.addProperty( + "App::PropertyLength", + "Chipload", + "Base", + QT_TRANSLATE_NOOP("App::Property", "Chipload per tooth"), + ) + obj.Chipload = FreeCAD.Units.Quantity("0.0 mm") diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py new file mode 100644 index 0000000000..b5bf795511 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD + + +class RotaryToolBitMixin: + """ + Mixin class for rotary tool bits. + Provides methods for accessing diameter and length from the shape. + """ + + def can_rotate(self) -> bool: + return True + + def get_diameter(self) -> FreeCAD.Units.Quantity: + """ + Get the diameter of the rotary tool bit from the shape. + """ + return self.obj.Diameter + + def set_diameter(self, diameter: FreeCAD.Units.Quantity): + """ + Set the diameter of the rotary tool bit on the shape. + """ + if not isinstance(diameter, FreeCAD.Units.Quantity): + raise ValueError("Diameter must be a FreeCAD Units.Quantity") + self.obj.Diameter = diameter + + def get_length(self) -> FreeCAD.Units.Quantity: + """ + Get the length of the rotary tool bit from the shape. + """ + return self.obj.Length + + def set_length(self, length: FreeCAD.Units.Quantity): + """ + Set the length of the rotary tool bit on the shape. + """ + if not isinstance(length, FreeCAD.Units.Quantity): + raise ValueError("Length must be a FreeCAD Units.Quantity") + self.obj.Length = length diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/models/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py new file mode 100644 index 0000000000..9edaf3516d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeBallend +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitBallend(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeBallend + + def __init__(self, shape: ToolBitShapeBallend, id: str | None = None): + Path.Log.track(f"ToolBitBallend __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {flutes}-flute ballend, {cutting_edge_height} cutting edge" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py new file mode 100644 index 0000000000..1c4d846c03 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -0,0 +1,810 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +import Path +import Path.Base.Util as PathUtil +import json +import uuid +import pathlib +from abc import ABC +from itertools import chain +from lazy_loader.lazy_loader import LazyLoader +from typing import Any, List, Optional, Tuple, Type, Union, Mapping, cast +from PySide.QtCore import QT_TRANSLATE_NOOP +import Part +from Path.Base.Generator import toolchange +from ...assets import Asset +from ...camassets import cam_assets +from ...shape import ToolBitShape, ToolBitShapeEndmill, ToolBitShapeIcon +from ..docobject import DetachedDocumentObject +from ..util import to_json, format_value + + +ToolBitView = LazyLoader("Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view") + + +PropertyGroupShape = "Shape" + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +class ToolBit(Asset, ABC): + asset_type: str = "toolbit" + SHAPE_CLASS: Type[ToolBitShape] # Abstract class attribute + + def __init__(self, tool_bit_shape: ToolBitShape, id: str | None = None): + Path.Log.track("ToolBit __init__ called") + self.id = id if id is not None else str(uuid.uuid4()) + self.obj = DetachedDocumentObject() + self.obj.Proxy = self + self._tool_bit_shape: ToolBitShape = tool_bit_shape + self._in_update = False + + self._create_base_properties() + self.obj.ToolBitID = self.get_id() + self.obj.ShapeID = tool_bit_shape.get_id() + self.obj.ShapeType = tool_bit_shape.name + self.obj.Label = tool_bit_shape.label or f"New {tool_bit_shape.name}" + + # Initialize properties + self._update_tool_properties() + + def __eq__(self, other): + """Compare ToolBit objects based on their unique ID.""" + if not isinstance(other, ToolBit): + return False + return self.id == other.id + + @staticmethod + def _find_subclass_for_shape(shape: ToolBitShape) -> Type["ToolBit"]: + """ + Finds the appropriate ToolBit subclass for a given ToolBitShape instance. + """ + for subclass in ToolBit.__subclasses__(): + if isinstance(shape, subclass.SHAPE_CLASS): + return subclass + raise ValueError(f"No ToolBit subclass found for shape {type(shape).__name__}") + + @classmethod + def from_dict(cls, attrs: Mapping, shallow: bool = False) -> "ToolBit": + """ + Creates and populates a ToolBit instance from a dictionary. + """ + # Find the shape ID. + shape_id = pathlib.Path( + str(attrs.get("shape", "")) + ).stem # backward compatibility. used to be a filename + if not shape_id: + raise ValueError("ToolBit dictionary is missing 'shape' key") + + # Find the shape type. + shape_type = attrs.get("shape-type") + shape_class = None + if shape_type is None: + shape_class = ToolBitShape.get_subclass_by_name(shape_id) + if not shape_class: + Path.Log.error(f'failed to infer shape type from {shape_id}; using "endmill"') + shape_class = ToolBitShapeEndmill + shape_type = shape_class.name + + # Try to load the shape, if the asset exists. + tool_bit_shape = None + if not shallow: # Shallow means: skip loading of child assets + shape_asset_uri = ToolBitShape.resolve_name(shape_id) + try: + tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_asset_uri)) + except FileNotFoundError: + pass # Rely on the fallback below + + # If it does not exist, create a new instance from scratch. + params = attrs.get("parameter", {}) + if tool_bit_shape is None: + if not shape_class: + shape_class = ToolBitShape.get_subclass_by_name(shape_type) + if not shape_class: + raise ValueError(f"failed to get shape class from {shape_id}") + tool_bit_shape = shape_class(shape_id, **params) + + # Now that we have a shape, create the toolbit instance. + return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) + + @classmethod + def from_shape(cls, tool_bit_shape: ToolBitShape, attrs: Mapping, id: str | None) -> "ToolBit": + selected_toolbit_subclass = cls._find_subclass_for_shape(tool_bit_shape) + toolbit = selected_toolbit_subclass(tool_bit_shape, id=id) + toolbit.label = attrs.get("name") or tool_bit_shape.label + + # Get params and attributes. + params = attrs.get("parameter", {}) + attr = attrs.get("attribute", {}) + + # Update parameters; these are stored in the document model object. + for param_name, param_value in params.items(): + if hasattr(toolbit.obj, param_name): + PathUtil.setProperty(toolbit.obj, param_name, param_value) + else: + Path.Log.warning( + f" ToolBit {id} Parameter '{param_name}' not found on" + f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})" + f" '{toolbit.obj.Label}'. Skipping." + ) + + # Update parameters; these are stored in the document model object. + for attr_name, attr_value in attr.items(): + if hasattr(toolbit.obj, attr_name): + PathUtil.setProperty(toolbit.obj, attr_name, attr_value) + else: + Path.Log.warning( + f"ToolBit {id} Attribute '{attr_name}' not found on" + f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})" + f" '{toolbit.obj.Label}'. Skipping." + ) + + return toolbit + + @classmethod + def from_shape_id(cls, shape_id: str, label: Optional[str] = None) -> "ToolBit": + """ + Creates and populates a ToolBit instance from a shape ID. + """ + attrs = {"shape": shape_id, "name": label} + return cls.from_dict(attrs) + + @classmethod + def from_file(cls, path: Union[str, pathlib.Path]) -> "ToolBit": + """ + Creates and populates a ToolBit instance from a .fctb file. + """ + path = pathlib.Path(path) + with path.open("r") as fp: + attrs_map = json.load(fp) + return cls.from_dict(attrs_map) + + @property + def label(self) -> str: + return self.obj.Label + + @label.setter + def label(self, label: str): + self.obj.Label = label + + def get_shape_name(self) -> str: + """Returns the shape name of the tool bit.""" + return self._tool_bit_shape.name + + def set_shape_name(self, name: str): + """Sets the shape name of the tool bit.""" + self._tool_bit_shape.name = name + + @property + def summary(self) -> str: + """ + To be overridden by subclasses to provide a better summary + including parameter values. Used as "subtitle" for the tool + in the UI. + + Example: "3.2 mm endmill, 4-flute, 8 mm cutting edge" + """ + return self.get_shape_name() + + def _create_base_properties(self): + # Create the properties in the Base group. + if not hasattr(self.obj, "ShapeID"): + self.obj.addProperty( + "App::PropertyString", + "ShapeID", + "Base", + QT_TRANSLATE_NOOP( + "App::Property", + "The unique ID of the tool shape (.fcstd)", + ), + ) + if not hasattr(self.obj, "ShapeType"): + self.obj.addProperty( + "App::PropertyEnumeration", + "ShapeType", + "Base", + QT_TRANSLATE_NOOP( + "App::Property", + "The tool shape type", + ), + ) + names = [c.name for c in ToolBitShape.__subclasses__()] + self.obj.ShapeType = names + self.obj.ShapeType = ToolBitShapeEndmill.name + if not hasattr(self.obj, "BitBody"): + self.obj.addProperty( + "App::PropertyLink", + "BitBody", + "Base", + QT_TRANSLATE_NOOP( + "App::Property", + "The parametrized body representing the tool bit", + ), + ) + if not hasattr(self.obj, "ToolBitID"): + self.obj.addProperty( + "App::PropertyString", + "ToolBitID", + "Base", + QT_TRANSLATE_NOOP("App::Property", "The unique ID of the toolbit"), + ) + + # 0 = read/write, 1 = read only, 2 = hide + self.obj.setEditorMode("ShapeID", 1) + self.obj.setEditorMode("ShapeType", 1) + self.obj.setEditorMode("ToolBitID", 1) + self.obj.setEditorMode("BitBody", 2) + self.obj.setEditorMode("Shape", 2) + + # Create the ToolBit properties that are shared by all tool bits + if not hasattr(self.obj, "SpindleDirection"): + self.obj.addProperty( + "App::PropertyEnumeration", + "SpindleDirection", + "Attributes", + QT_TRANSLATE_NOOP("App::Property", "Direction of spindle rotation"), + ) + self.obj.SpindleDirection = ["Forward", "Reverse", "None"] + self.obj.SpindleDirection = "Forward" # Default value + if not hasattr(self.obj, "Material"): + self.obj.addProperty( + "App::PropertyEnumeration", + "Material", + "Attributes", + QT_TRANSLATE_NOOP("App::Property", "Tool material"), + ) + self.obj.Material = ["HSS", "Carbide"] + self.obj.Material = "HSS" # Default value + + def get_id(self) -> str: + """Returns the unique ID of the tool bit.""" + return self.id + + def _promote_toolbit(self): + """ + Updates the toolbit properties for backward compatibility. + Ensure obj.ShapeID and obj.ToolBitID are set, handling legacy cases. + """ + Path.Log.track(f"Promoting tool bit {self.obj.Label}") + + # Ensure ShapeID is set (handling legacy BitShape/ShapeName) + name = None + if hasattr(self.obj, "ShapeID") and self.obj.ShapeID: + name = self.obj.ShapeID + elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile: + name = pathlib.Path(self.obj.ShapeFile).stem + elif hasattr(self.obj, "BitShape") and self.obj.BitShape: + name = pathlib.Path(self.obj.BitShape).stem + elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName: + name = pathlib.Path(self.obj.ShapeName).stem + if name is None: + raise ValueError("ToolBit is missing a shape ID") + + uri = ToolBitShape.resolve_name(name) + if uri is None: + raise ValueError(f"Failed to identify ID of ToolBit from '{name}'") + self.obj.ShapeID = uri.asset_id + + # Ensure ShapeType is set + thetype = None + if hasattr(self.obj, "ShapeType") and self.obj.ShapeType: + thetype = self.obj.ShapeType + elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile: + thetype = pathlib.Path(self.obj.ShapeFile).stem + elif hasattr(self.obj, "BitShape") and self.obj.BitShape: + thetype = pathlib.Path(self.obj.BitShape).stem + elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName: + thetype = pathlib.Path(self.obj.ShapeName).stem + if thetype is None: + raise ValueError("ToolBit is missing a shape type") + + shape_class = ToolBitShape.get_subclass_by_name(thetype) + if shape_class is None: + raise ValueError(f"Failed to identify shape of ToolBit from '{thetype}'") + self.obj.ShapeType = shape_class.name + + # Ensure ToolBitID is set + if hasattr(self.obj, "File"): + self.id = pathlib.Path(self.obj.File).stem + self.obj.ToolBitID = self.id + Path.Log.debug(f"Set ToolBitID to {self.obj.ToolBitID}") + + # Update SpindleDirection: + # Old tools may still have "CCW", "CW", "Off", "None". + # New tools use "None", "Forward", "Reverse". + normalized_direction = old_direction = self.obj.SpindleDirection + + if isinstance(old_direction, str): + lower_direction = old_direction.lower() + if lower_direction in ("none", "off"): + normalized_direction = "None" + elif lower_direction in ("cw", "forward"): + normalized_direction = "Forward" + elif lower_direction in ("ccw", "reverse"): + normalized_direction = "Reverse" + + self.obj.SpindleDirection = ["Forward", "Reverse", "None"] + self.obj.SpindleDirection = normalized_direction + if old_direction != normalized_direction: + Path.Log.info( + f"Promoted tool bit {self.obj.Label}: SpindleDirection from {old_direction} to {self.obj.SpindleDirection}" + ) + + # Drop legacy properties. + legacy = "ShapeFile", "File", "BitShape", "ShapeName" + for name in legacy: + if hasattr(self.obj, name): + value = getattr(self.obj, name) + self.obj.removeProperty(name) + Path.Log.debug(f"Removed obsolete property '{name}' ('{value}').") + + # Get the schema properties from the current shape + shape_cls = ToolBitShape.get_subclass_by_name(self.obj.ShapeType) + if not shape_cls: + raise ValueError(f"Failed to find shape class named '{self.obj.ShapeType}'") + shape_schema_props = shape_cls.schema().keys() + + # Move properties that are part of the shape schema to the "Shape" group + for prop_name in self.obj.PropertiesList: + if ( + self.obj.getGroupOfProperty(prop_name) == PropertyGroupShape + or prop_name not in shape_schema_props + ): + continue + try: + Path.Log.debug(f"Moving property '{prop_name}' to group '{PropertyGroupShape}'") + + # Get property details before removing + prop_type = self.obj.getTypeIdOfProperty(prop_name) + prop_doc = self.obj.getDocumentationOfProperty(prop_name) + prop_value = self.obj.getPropertyByName(prop_name) + + # Remove the property + self.obj.removeProperty(prop_name) + + # Add the property back to the Shape group + self.obj.addProperty(prop_type, prop_name, PropertyGroupShape, prop_doc) + self._in_update = True # Prevent onChanged from running + PathUtil.setProperty(self.obj, prop_name, prop_value) + Path.Log.info(f"Moved property '{prop_name}' to group '{PropertyGroupShape}'") + except Exception as e: + Path.Log.error( + f"Failed to move property '{prop_name}' to group '{PropertyGroupShape}': {e}" + ) + raise + finally: + self._in_update = False + + def onDocumentRestored(self, obj): + Path.Log.track(obj.Label) + + # Assign self.obj to the restored object + self.obj = obj + self.obj.Proxy = self + if not hasattr(self, "id"): + self.id = str(uuid.uuid4()) + Path.Log.debug( + f"Assigned new id {self.id} for ToolBit {obj.Label} during document restore" + ) + + # Our constructor previously created the base properties in the + # DetachedDocumentObject, which was now replaced. + # So here we need to ensure to set them up in the new (real) DocumentObject + # as well. + self._create_base_properties() + self._promote_toolbit() + + # Get the shape instance based on the ShapeType. We try two approaches + # to find the shape and shape class: + # 1. If the asset with the given type exists, use that. + # 2. Otherwise create a new empty instance + shape_uri = ToolBitShape.resolve_name(self.obj.ShapeType) + try: + # Best case: we directly find the shape file in our assets. + self._tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_uri)) + except FileNotFoundError: + # Otherwise, try to at least identify the type of the shape. + shape_class = ToolBitShape.get_subclass_by_name(shape_uri.asset_id) + if not shape_class: + raise ValueError( + "Failed to identify class of ToolBitShape from name " + f"'{self.obj.ShapeType}' (asset id {shape_uri.asset_id})" + ) + self._tool_bit_shape = shape_class(shape_uri.asset_id) + + # If BitBody exists and is in a different document after document restore, + # it means a shallow copy occurred. We need to re-initialize the visual + # representation and properties to ensure a deep copy of the BitBody + # and its properties. + # Only re-initialize properties from shape if not restoring from file + if self.obj.BitBody and self.obj.BitBody.Document != self.obj.Document: + Path.Log.debug( + f"onDocumeformat_valuentRestored: Re-initializing BitBody for {self.obj.Label} after copy" + ) + self._update_visual_representation() + + # Ensure the correct ViewProvider is attached during document restore, + # because some legacy fcstd files may still have references to old view + # providers. + if hasattr(self.obj, "ViewObject") and self.obj.ViewObject: + if hasattr(self.obj.ViewObject, "Proxy") and not isinstance( + self.obj.ViewObject.Proxy, ToolBitView.ViewProvider + ): + Path.Log.debug(f"onDocumentRestored: Attaching ViewProvider for {self.obj.Label}") + ToolBitView.ViewProvider(self.obj.ViewObject, "ToolBit") + + # Copy properties from the restored object to the ToolBitShape. + for name, item in self._tool_bit_shape.schema().items(): + if name in self.obj.PropertiesList: + value = self.obj.getPropertyByName(name) + self._tool_bit_shape.set_parameter(name, value) + + # Ensure property state is correct after restore. + self._update_tool_properties() + + def attach_to_doc( + self, doc: FreeCAD.Document, label: Optional[str] = None + ) -> FreeCAD.DocumentObject: + """ + Creates a new FreeCAD DocumentObject in the given document and attaches + this ToolBit instance to it. + """ + label = label or self.label or self._tool_bit_shape.label + tool_doc_obj = doc.addObject("Part::FeaturePython", label) + self.attach_to_obj(tool_doc_obj, label=label) + return tool_doc_obj + + def attach_to_obj(self, tool_doc_obj: FreeCAD.DocumentObject, label: Optional[str] = None): + """ + Attaches the ToolBit instance to an existing FreeCAD DocumentObject. + + Transfers properties from the internal DetachedDocumentObject to the + tool_doc_obj and updates the visual representation. + """ + if not isinstance(self.obj, DetachedDocumentObject): + Path.Log.warning( + f"ToolBit {self.obj.Label} is already attached to a " + "DocumentObject. Skipping attach_to_obj." + ) + return + + Path.Log.track(f"Attaching ToolBit to {tool_doc_obj.Label}") + + temp_obj = self.obj + self.obj = tool_doc_obj + self.obj.Proxy = self + + self._create_base_properties() + + # Transfer property values from the detached object to the real object + temp_obj.copy_to(self.obj) + + # Ensure label is set + self.obj.Label = label or self.label or self._tool_bit_shape.label + + # Update the visual representation now that it's attached + self._update_tool_properties() + self._update_visual_representation() + + def onChanged(self, obj, prop): + Path.Log.track(obj.Label, prop) + # Avoid acting during document restore or internal updates + if "Restore" in obj.State: + return + + if hasattr(self, "_in_update") and self._in_update: + Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.") + return + + # We only care about updates that affect the Shape + if obj.getGroupOfProperty(prop) != PropertyGroupShape: + return + + self._in_update = True + try: + new_value = obj.getPropertyByName(prop) + Path.Log.debug( + f"Shape parameter '{prop}' changed to {new_value}. " + f"Updating visual representation." + ) + self._tool_bit_shape.set_parameter(prop, new_value) + self._update_visual_representation() + finally: + self._in_update = False + + def onDelete(self, obj, arg2=None): + Path.Log.track(obj.Label) + self._removeBitBody() + obj.Document.removeObject(obj.Name) + + def _removeBitBody(self): + if self.obj.BitBody: + self.obj.BitBody.removeObjectsFromDocument() + self.obj.Document.removeObject(self.obj.BitBody.Name) + self.obj.BitBody = None + + def _setupProperty(self, prop, orig): + # extract property parameters and values so it can be copied + val = orig.getPropertyByName(prop) + typ = orig.getTypeIdOfProperty(prop) + grp = orig.getGroupOfProperty(prop) + dsc = orig.getDocumentationOfProperty(prop) + + self.obj.addProperty(typ, prop, grp, dsc) + if "App::PropertyEnumeration" == typ: + setattr(self.obj, prop, orig.getEnumerationsOfProperty(prop)) + self.obj.setEditorMode(prop, 1) + PathUtil.setProperty(self.obj, prop, val) + + def _get_props(self, group: Optional[Union[str, Tuple[str, ...]]] = None) -> List[str]: + """ + Returns a list of property names from the given group(s) for the object. + Returns all groups if the group argument is None. + """ + props_in_group = [] + # Use PropertiesList to get all property names + for prop in self.obj.PropertiesList: + prop_group = self.obj.getGroupOfProperty(prop) + if group is None: + props_in_group.append(prop) + elif isinstance(group, str) and prop_group == group: + props_in_group.append(prop) + elif isinstance(group, tuple) and prop_group in group: + props_in_group.append(prop) + return props_in_group + + def get_property(self, name: str): + return self.obj.getPropertyByName(name) + + def get_property_str(self, name: str, default: str | None = None) -> str | None: + value = self.get_property(name) + return format_value(value) if value else default + + def set_property(self, name: str, value: Any): + return self.obj.setPropertyByName(name, value) + + def get_property_label_from_name(self, name: str): + return self.obj.getPropertyByName + + def get_icon(self) -> Optional[ToolBitShapeIcon]: + """ + Retrieves the thumbnail data for the tool bit shape, as + taken from the explicit SVG or PNG, if the shape has one. + """ + if self._tool_bit_shape: + return self._tool_bit_shape.get_icon() + return None + + def get_thumbnail(self) -> Optional[bytes]: + """ + Retrieves the thumbnail data for the tool bit shape in PNG format, + as embedded in the shape file. + Fallback to the icon from get_icon() (converted to PNG) + """ + if not self._tool_bit_shape: + return None + png_data = self._tool_bit_shape.get_thumbnail() + if png_data: + return png_data + icon = self.get_icon() + if icon: + return icon.get_png() + return None + + def _remove_properties(self, group, prop_names): + for name in prop_names: + if hasattr(self.obj, name): + if self.obj.getGroupOfProperty(name) == group: + try: + self.obj.removeProperty(name) + Path.Log.debug(f"Removed property: {group}.{name}") + except Exception as e: + Path.Log.error(f"Failed removing property '{group}.{name}': {e}") + else: + Path.Log.warning(f"'{group}.{name}' failed to remove property, not found") + + def _update_tool_properties(self): + """ + Initializes or updates the tool bit's properties based on the current + _tool_bit_shape. Adds/updates shape parameters, removes obsolete shape + parameters, and updates the edit state of them. + Does not handle updating the visual representation. + """ + Path.Log.track(self.obj.Label) + + # 1. Add/Update properties for the new shape + for name, item in self._tool_bit_shape.schema().items(): + docstring = item[0] + prop_type = item[1] + + if not prop_type: + Path.Log.error( + f"No property type for parameter '{name}' in shape " + f"'{self._tool_bit_shape.name}'. Skipping." + ) + continue + + docstring = self._tool_bit_shape.get_parameter_label(name) + + # Add new property + if not hasattr(self.obj, name): + self.obj.addProperty(prop_type, name, "Shape", docstring) + Path.Log.debug(f"Added new shape property: {name}") + + # Ensure editor mode is correct + self.obj.setEditorMode(name, 0) + + try: + value = self._tool_bit_shape.get_parameter(name) + except KeyError: + continue # Retain existing property value. + + # Conditional to avoid unnecessary migration warning when called + # from onDocumentRestored. + if getattr(self.obj, name) != value: + setattr(self.obj, name, value) + + # 2. Remove obsolete shape properties + # These are properties currently listed AND in the Shape group, + # but not required by the new shape. + current_shape_prop_names = set(self._get_props("Shape")) + new_shape_param_names = self._tool_bit_shape.schema().keys() + obsolete = current_shape_prop_names - new_shape_param_names + self._remove_properties("Shape", obsolete) + + def _update_visual_representation(self): + """ + Updates the visual representation of the tool bit based on the current + _tool_bit_shape. Creates or updates the BitBody and copies its shape + to the main object. + """ + if isinstance(self.obj, DetachedDocumentObject): + return + Path.Log.track(self.obj.Label) + + # Remove existing BitBody if it exists + self._removeBitBody() + + try: + # Use the shape's make_body method to create the visual representation + body = self._tool_bit_shape.make_body(self.obj.Document) + + if not body: + Path.Log.error( + f"Failed to create visual representation for shape " + f"'{self._tool_bit_shape.name}'" + ) + return + + # Assign the created object to BitBody and copy its shape + self.obj.BitBody = body + self.obj.Shape = self.obj.BitBody.Shape # Copy the evaluated Solid shape + + # Hide the visual representation and remove from tree + if hasattr(self.obj.BitBody, "ViewObject") and self.obj.BitBody.ViewObject: + self.obj.BitBody.ViewObject.Visibility = False + self.obj.BitBody.ViewObject.ShowInTree = False + + except Exception as e: + Path.Log.error( + f"Failed to create visual representation using make_body for shape" + f" '{self._tool_bit_shape.name}': {e}" + ) + raise + + def to_dict(self): + """ + Returns a dictionary representation of the tool bit. + + Returns: + A dictionary with tool bit properties, JSON-serializable. + """ + Path.Log.track(self.obj.Label) + attrs = {} + attrs["version"] = 2 + attrs["name"] = self.obj.Label + attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd" + attrs["shape-type"] = self._tool_bit_shape.name + attrs["parameter"] = {} + attrs["attribute"] = {} + + # Store all shape parameter names and attribute names + param_names = self._tool_bit_shape.get_parameters() + attr_props = self._get_props("Attributes") + property_names = list(chain(param_names, attr_props)) + for name in property_names: + value = getattr(self.obj, name, None) + if value is None or isinstance(value, FreeCAD.DocumentObject): + Path.Log.warning( + f"Excluding property '{name}' from serialization " + f"(type {type(value).__name__ if value is not None else 'None'}, value {value})" + ) + try: + serialized_value = to_json(value) + attrs["parameter"][name] = serialized_value + except (TypeError, ValueError) as e: + Path.Log.warning( + f"Excluding property '{name}' from serialization " + f"(type {type(value).__name__}, value {value}): {e}" + ) + + Path.Log.debug(f"to_dict output for {self.obj.Label}: {attrs}") + return attrs + + def __getstate__(self): + """ + Prepare the ToolBit for pickling by excluding non-picklable attributes. + + Returns: + A dictionary with picklable and JSON-serializable state. + """ + Path.Log.track("ToolBit.__getstate__") + state = { + "id": getattr(self, "id", str(uuid.uuid4())), # Fallback to new UUID + "_in_update": getattr(self, "_in_update", False), # Fallback to False + "_obj_data": self.to_dict(), + } + + if not getattr(self, "_tool_bit_shape", None): + return state + + # Store minimal shape data to reconstruct _tool_bit_shape + state["_shape_data"] = { + "id": self._tool_bit_shape.get_id(), + "name": self._tool_bit_shape.name, + "parameters": { + name: to_json(getattr(self.obj, name, None)) + for name in self._tool_bit_shape.get_parameters() + if not isinstance(getattr(self.obj, name, None), FreeCAD.DocumentObject) + }, + } + + return state + + def get_spindle_direction(self) -> toolchange.SpindleDirection: + # To be safe, never allow non-rotatable shapes (such as probes) to rotate. + if not self.can_rotate(): + return toolchange.SpindleDirection.OFF + + # Otherwise use power from defined attribute. + if hasattr(self.obj, "SpindleDirection") and self.obj.SpindleDirection is not None: + if self.obj.SpindleDirection.lower() in ("cw", "forward"): + return toolchange.SpindleDirection.CW + else: + return toolchange.SpindleDirection.CCW + + # Default to keeping spindle off. + return toolchange.SpindleDirection.OFF + + def can_rotate(self) -> bool: + """ + Whether the spindle is allowed to rotate for this kind of ToolBit. + This mostly exists as a safe-hold for probes, which should never rotate. + """ + return True diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py new file mode 100644 index 0000000000..caf497d423 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeBullnose +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeBullnose + + def __init__(self, tool_bit_shape: ToolBitShapeBullnose, id: str | None = None): + Path.Log.track(f"ToolBitBullnose __init__ called with id: {id}") + super().__init__(tool_bit_shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + flat_radius = self.get_property_str("FlatRadius", "?") + + return FreeCAD.Qt.translate( + "CAM", + f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {flat_radius} flat radius", + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py new file mode 100644 index 0000000000..da0abce4d6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeChamfer +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitChamfer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeChamfer + + def __init__(self, shape: ToolBitShapeChamfer, id: str | None = None): + Path.Log.track(f"ToolBitChamfer __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {cutting_edge_angle} chamfer bit, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py new file mode 100644 index 0000000000..aac48338b6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeDovetail +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitDovetail(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeDovetail + + def __init__(self, shape: ToolBitShapeDovetail, id: str | None = None): + Path.Log.track(f"ToolBitDovetail __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {cutting_edge_angle} dovetail bit, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py new file mode 100644 index 0000000000..cc5055d372 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeDrill +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitDrill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeDrill + + def __init__(self, shape: ToolBitShapeDrill, id: str | None = None): + Path.Log.track(f"ToolBitDrill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + tip_angle = self.get_property_str("TipAngle", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate("CAM", f"{diameter} drill, {tip_angle} tip, {flutes}-flute") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py new file mode 100644 index 0000000000..6651705540 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeEndmill +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitEndmill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeEndmill + + def __init__(self, shape: ToolBitShapeEndmill, id: str | None = None): + Path.Log.track(f"ToolBitEndmill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {flutes}-flute endmill, {cutting_edge_height} cutting edge" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py new file mode 100644 index 0000000000..a23f82ecf0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeFillet +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeFillet + + def __init__(self, shape: ToolBitShapeFillet, id: str | None = None): + Path.Log.track(f"ToolBitFillet __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + radius = self.get_property_str("FilletRadius", "?") + flutes = self.get_property("Flutes") + diameter = self.get_property_str("ShankDiameter", "?") + + return FreeCAD.Qt.translate( + "CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py new file mode 100644 index 0000000000..f0330084ef --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeProbe +from .base import ToolBit + + +class ToolBitProbe(ToolBit): + SHAPE_CLASS = ToolBitShapeProbe + + def __init__(self, shape: ToolBitShapeProbe, id: str | None = None): + Path.Log.track(f"ToolBitProbe __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + self.obj.SpindleDirection = "None" + self.obj.setEditorMode("SpindleDirection", 2) # Read-only + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + length = self.get_property_str("Length", "?") + shaft_diameter = self.get_property_str("ShaftDiameter", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} probe, {length} length, {shaft_diameter} shaft" + ) + + def can_rotate(self) -> bool: + return False diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py new file mode 100644 index 0000000000..d8b7fbcefb --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeReamer +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitReamer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeReamer + + def __init__(self, shape: ToolBitShapeReamer, id: str | None = None): + Path.Log.track(f"ToolBitReamer __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + + return FreeCAD.Qt.translate("CAM", f"{diameter} reamer, {cutting_edge_height} cutting edge") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py new file mode 100644 index 0000000000..2c779edc33 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeSlittingSaw +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitSlittingSaw(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeSlittingSaw + + def __init__(self, shape: ToolBitShapeSlittingSaw, id: str | None = None): + Path.Log.track(f"ToolBitSlittingSaw __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + blade_thickness = self.get_property_str("BladeThickness", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} slitting saw, {blade_thickness} blade, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py new file mode 100644 index 0000000000..662a2e7376 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeTap +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeTap + + def __init__(self, shape: ToolBitShapeTap, id: str | None = None): + Path.Log.track(f"ToolBitTap __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} tap, {flutes}-flute, {cutting_edge_length} cutting edge" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py new file mode 100644 index 0000000000..131be1abb4 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeThreadMill +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitThreadMill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeThreadMill + + def __init__(self, shape: ToolBitShapeThreadMill, id: str | None = None): + Path.Log.track(f"ToolBitThreadMill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_angle = self.get_property_str("cuttingAngle", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} thread mill, {flutes}-flute, {cutting_angle} cutting angle" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py new file mode 100644 index 0000000000..cfabf0e978 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeVBit +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitVBit(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeVBit + + def __init__(self, shape: ToolBitShapeVBit, id: str | None = None): + Path.Log.track(f"ToolBitVBit __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate("CAM", f"{diameter} {cutting_edge_angle} v-bit, {flutes}-flute") diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py new file mode 100644 index 0000000000..3ef9d0b167 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py @@ -0,0 +1,12 @@ +from .camotics import CamoticsToolBitSerializer +from .fctb import FCTBSerializer + + +all_serializers = CamoticsToolBitSerializer, FCTBSerializer + + +__all__ = [ + "CamoticsToolBitSerializer", + "FCTBSerializer", + "all_serializers", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py new file mode 100644 index 0000000000..aea9787a19 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import json +from typing import Optional, Mapping +import FreeCAD +import Path +from ...camassets import cam_assets +from ..mixins import RotaryToolBitMixin +from ..models.base import ToolBit +from ...assets.serializer import AssetSerializer +from ...assets.uri import AssetUri +from ...assets.asset import Asset + +SHAPEMAP = { + "ballend": "Ballnose", + "endmill": "Cylindrical", + "vbit": "Conical", + "chamfer": "Snubnose", +} +SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items()) + +tooltemplate = { + "units": "metric", + "shape": "Cylindrical", + "length": 10, + "diameter": 3, + "description": "", +} + + +class CamoticsToolBitSerializer(AssetSerializer): + for_class = ToolBit + extensions = tuple() # Camotics does not have tool files; tools are rows in tool tables + mime_type = "application/json" + can_import = False + can_export = False + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "Camotics Tool") + + @classmethod + def extract_dependencies(cls, data: bytes) -> list[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + assert isinstance(asset, ToolBit) + if not isinstance(asset, RotaryToolBitMixin): + lbl = asset.label + name = asset.get_shape_name() + Path.Log.info( + f"Skipping export of toolbit {lbl} ({name}) because it is not a rotary tool." + ) + return b"{}" + toolitem = tooltemplate.copy() + toolitem["diameter"] = asset.get_diameter().Value or 2 + toolitem["description"] = asset.label + toolitem["length"] = asset.get_length().Value or 10 + toolitem["shape"] = SHAPEMAP.get(asset.get_shape_name(), "Cylindrical") + return json.dumps(toolitem).encode("ascii", "ignore") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> ToolBit: + # Create an instance of the ToolBitShape class + attrs: dict = json.loads(data.decode("ascii", "ignore")) + shape = cam_assets.get("toolbitshape://endmill") + + # Create an instance of the ToolBit class + bit = ToolBit.from_shape_id(shape.get_id()) + bit.label = attrs["description"] + + if not isinstance(bit, RotaryToolBitMixin): + raise NotImplementedError( + f"Only export of rotary tools is supported ({bit.label} ({bit.id})" + ) + + bit.set_diameter(FreeCAD.Units.Quantity(float(attrs["diameter"]), "mm")) + bit.set_length(FreeCAD.Units.Quantity(float(attrs["length"]), "mm")) + return bit diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py new file mode 100644 index 0000000000..f6f38af6b6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import json +import Path +from typing import Mapping, List, Optional, cast +import FreeCAD +from ...assets import Asset, AssetUri, AssetSerializer +from ...shape import ToolBitShape +from ..models.base import ToolBit +from Path.Base import Util as PathUtil + + +class FCTBSerializer(AssetSerializer): + for_class = ToolBit + mime_type = "application/x-freecad-toolbit" + extensions = (".fctb",) + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "FreeCAD Tool") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + Path.Log.info(f"FCTBSerializer.extract_dependencies: raw data = {data!r}") + data_dict = json.loads(data.decode("utf-8")) + shape = data_dict["shape"] + return [ToolBitShape.resolve_name(shape)] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + # Ensure the asset is a ToolBit instance before serializing + if not isinstance(asset, ToolBit): + raise TypeError(f"Expected ToolBit instance, got {type(asset).__name__}") + attrs = asset.to_dict() + return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> ToolBit: + """ + Creates a ToolBit instance from serialized data and resolved + dependencies. + """ + attrs = json.loads(data.decode("utf-8", "ignore")) + attrs["id"] = id # Ensure id is available for from_dict + + if dependencies is None: + # Shallow load: dependencies are not resolved. + # Delegate to from_dict with shallow=True. + return ToolBit.from_dict(attrs, shallow=True) + + # Full load: dependencies are resolved. + # Proceed with existing logic to use the resolved shape. + shape_id = attrs.get("shape") + if not shape_id: + Path.Log.warning("ToolBit data is missing 'shape' key, defaulting to 'endmill'") + shape_id = "endmill" + + shape_uri = ToolBitShape.resolve_name(shape_id) + shape = dependencies.get(shape_uri) + + if shape is None: + raise ValueError( + f"Dependency for shape '{shape_id}' not found by uri {shape_uri}" f" {dependencies}" + ) + elif not isinstance(shape, ToolBitShape): + raise ValueError( + f"Dependency for shape '{shape_id}' found by uri {shape_uri} " + f"is not a ToolBitShape instance. {dependencies}" + ) + + # Find the correct ToolBit subclass for the shape + return ToolBit.from_shape(shape, attrs, id) + + @classmethod + def deep_deserialize(cls, data: bytes) -> ToolBit: + attrs_map = json.loads(data) + asset_class = cast(ToolBit, cls.for_class) + return asset_class.from_dict(attrs_map) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py new file mode 100644 index 0000000000..b09ba55eef --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py @@ -0,0 +1,6 @@ +from .editor import ToolBitEditorPanel, ToolBitEditor + +__all__ = [ + "ToolBitEditor", + "ToolBitEditorPanel", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py new file mode 100644 index 0000000000..f776a73dec --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for browsing ToolBit assets with filtering and sorting.""" + +from typing import List, cast +from PySide import QtGui, QtCore +from typing import List, cast +from PySide import QtGui, QtCore +from ...assets import AssetManager, AssetUri +from ...toolbit import ToolBit +from .toollist import ToolBitListWidget, CompactToolBitListWidget, ToolBitUriRole + + +class ToolBitBrowserWidget(QtGui.QWidget): + """ + A widget to browse, filter, and select ToolBit assets from the + AssetManager, with sorting and batch insertion. + """ + + # Signal emitted when a tool is selected in the list + toolSelected = QtCore.Signal(str) # Emits ToolBit URI string + # Signal emitted when a tool is requested for editing (e.g., double-click) + itemDoubleClicked = QtCore.Signal(str) # Emits ToolBit URI string + + # Debounce timer for search input + _search_timer_interval = 300 # milliseconds + _batch_size = 20 # Number of items to insert per batch + + def __init__( + self, + asset_manager: AssetManager, + store: str = "local", + parent=None, + tool_no_factory=None, + compact=False, + ): + super().__init__(parent) + self._asset_manager = asset_manager + self._tool_no_factory = tool_no_factory + self._compact_mode = compact + + self._is_fetching = False + self._store_name = store + self._all_assets: List[ToolBit] = [] # Store all fetched assets + self._current_search = "" # Track current search term + self._scroll_position = 0 # Track scroll position + self._sort_key = "tool_no" if tool_no_factory else "label" + + # UI Elements + self._search_edit = QtGui.QLineEdit() + self._search_edit.setPlaceholderText("Search tools...") + + # Sorting dropdown + self._sort_combo = QtGui.QComboBox() + if self._tool_no_factory: + self._sort_combo.addItem("Sort by Tool Number", "tool_no") + self._sort_combo.addItem("Sort by Label", "label") + self._sort_combo.setCurrentIndex(0) + self._sort_combo.setVisible(self._tool_no_factory is not None) # Hide if no tool_no_factory + + # Top layout for search and sort + self._top_layout = QtGui.QHBoxLayout() + self._top_layout.addWidget(self._search_edit, 3) # Give search more space + self._top_layout.addWidget(self._sort_combo, 1) + + if self._compact_mode: + self._tool_list_widget = CompactToolBitListWidget(tool_no_factory=self._tool_no_factory) + else: + self._tool_list_widget = ToolBitListWidget(tool_no_factory=self._tool_no_factory) + + # Main layout + layout = QtGui.QVBoxLayout(self) + layout.addLayout(self._top_layout) + layout.addWidget(self._tool_list_widget) + + # Connections + self._search_timer = QtCore.QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(self._search_timer_interval) + self._search_timer.timeout.connect(self._trigger_fetch) + self._search_edit.textChanged.connect(self._search_timer.start) + self._sort_combo.currentIndexChanged.connect(self._on_sort_changed) + + scrollbar = self._tool_list_widget.verticalScrollBar() + scrollbar.valueChanged.connect(self._on_scroll) + + self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) + self._tool_list_widget.currentItemChanged.connect(self._on_item_selection_changed) + + # Note that fetching of assets is done at showEvent(), + # because we need to know the widget size to calculate the number + # of items that need to be fetched. + + def showEvent(self, event): + """Handles the widget show event to trigger initial data fetch.""" + super().showEvent(event) + # Fetch all assets the first time the widget is shown + if not self._all_assets and not self._is_fetching: + self._fetch_all_assets() + + def _fetch_all_assets(self): + """Fetches all ToolBit assets and stores them in memory.""" + if self._is_fetching: + return + self._is_fetching = True + try: + self._all_assets = cast( + List[ToolBit], + self._asset_manager.fetch( + asset_type="toolbit", + depth=0, # do not fetch dependencies (e.g. shape, icon) + store=self._store_name, + ), + ) + self._sort_assets() + self._trigger_fetch() + finally: + self._is_fetching = False + + def _sort_assets(self): + """Sorts the in-memory assets based on the current sort key.""" + if self._sort_key == "label": + self._all_assets.sort(key=lambda x: x.label.lower()) + elif self._sort_key == "tool_no" and self._tool_no_factory: + self._all_assets.sort( + key=lambda x: (self._tool_no_factory(x) or 0) if self._tool_no_factory else 0 + ) + + def _trigger_fetch(self): + """Initiates a data fetch, clearing the list only if search term changes.""" + new_search = self._search_edit.text() + if new_search != self._current_search: + self._current_search = new_search + self._tool_list_widget.clear_list() + self._scroll_position = 0 + self._fetch_data() + + def _fetch_batch(self, offset): + """Inserts a batch of filtered assets into the list widget.""" + filtered_assets = [ + asset + for asset in self._all_assets + if not self._current_search or self._matches_search(asset, self._current_search) + ] + end_idx = min(offset + self._batch_size, len(filtered_assets)) + for i in range(offset, end_idx): + self._tool_list_widget.add_toolbit(filtered_assets[i]) + return end_idx < len(filtered_assets) # Return True if more items remain + + def _matches_search(self, toolbit, search_term): + """Checks if a ToolBit matches the search term.""" + search_term = search_term.lower() + return search_term in toolbit.label.lower() or search_term in toolbit.summary.lower() + + def _fetch_data(self): + """Inserts filtered and sorted ToolBit assets into the list widget.""" + if self._is_fetching: + return + self._is_fetching = True + try: + # Save current scroll position and selected item + scrollbar = self._tool_list_widget.verticalScrollBar() + self._scroll_position = scrollbar.value() + selected_uri = self._tool_list_widget.get_selected_toolbit_uri() + + # Insert initial batches to fill the viewport + offset = self._tool_list_widget.count() + more_items = True + while more_items: + more_items = self._fetch_batch(offset) + offset += self._batch_size + if scrollbar.maximum() != 0: + break + + # Apply filter to ensure UI consistency + self._tool_list_widget.apply_filter(self._current_search) + + # Restore scroll position and selection + scrollbar.setValue(self._scroll_position) + if selected_uri: + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + if item.data(ToolBitUriRole) == selected_uri and not item.isHidden(): + self._tool_list_widget.setCurrentItem(item) + break + + finally: + self._is_fetching = False + + def _on_scroll(self, value): + """Handles scroll events for lazy batch insertion.""" + scrollbar = self._tool_list_widget.verticalScrollBar() + is_near_bottom = value >= scrollbar.maximum() - scrollbar.singleStep() + filtered_count = sum( + 1 + for asset in self._all_assets + if not self._current_search or self._matches_search(asset, self._current_search) + ) + more_might_exist = self._tool_list_widget.count() < filtered_count + + if is_near_bottom and more_might_exist and not self._is_fetching: + self._fetch_data() + + def _on_sort_changed(self): + """Handles sort order change from the dropdown.""" + self._sort_key = self._sort_combo.itemData(self._sort_combo.currentIndex()) + self._sort_assets() + self._tool_list_widget.clear_list() + self._scroll_position = 0 + self._fetch_data() + + def _on_item_double_clicked(self, item): + """Emits itemDoubleClicked signal when an item is double-clicked.""" + uri = item.data(ToolBitUriRole) + if uri: + self.itemDoubleClicked.emit(uri) + + def _on_item_selection_changed(self, current_item, previous_item): + """Emits toolSelected signal when the selection changes.""" + uri = None + if current_item: + uri = current_item.data(ToolBitUriRole) + self.toolSelected.emit(uri if current_item else None) + + def get_selected_bit_uris(self) -> List[str]: + """ + Returns a list of URIs for the currently selected ToolBit items. + Delegates to the underlying list widget. + """ + return self._tool_list_widget.get_selected_toolbit_uris() + + def get_selected_bits(self) -> List[ToolBit]: + """ + Returns a list of selected ToolBit objects. + Retrieves the full ToolBit objects using the asset manager. + """ + selected_bits = [] + selected_uris = self.get_selected_bit_uris() + for uri_string in selected_uris: + toolbit = self._asset_manager.get(AssetUri(uri_string)) + if toolbit: + selected_bits.append(toolbit) + return selected_bits diff --git a/src/Mod/CAM/Path/Tool/Gui/BitCmd.py b/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py similarity index 80% rename from src/Mod/CAM/Path/Tool/Gui/BitCmd.py rename to src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py index d8094e5a24..f4c8808ff3 100644 --- a/src/Mod/CAM/Path/Tool/Gui/BitCmd.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -24,9 +25,11 @@ import FreeCAD import FreeCADGui import Path import Path.Tool -import os -from PySide import QtCore from PySide.QtCore import QT_TRANSLATE_NOOP +from ...toolbit import ToolBit +from ...assets.ui import AssetSaveDialog +from ..serializers import all_serializers as toolbit_serializers +from .file import ToolBitOpenDialog if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -54,7 +57,9 @@ class CommandToolBitCreate: return FreeCAD.ActiveDocument is not None def Activated(self): - obj = Path.Tool.Bit.Factory.Create() + # Create a default endmill tool bit and attach it to a new DocumentObject + toolbit = ToolBit.from_shape_id("endmill.fcstd") + obj = toolbit.attach_to_doc(FreeCAD.ActiveDocument) obj.ViewObject.Proxy.setCreate(obj.ViewObject) @@ -81,7 +86,7 @@ class CommandToolBitSave: def selectedTool(self): sel = FreeCADGui.Selection.getSelectionEx() - if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.Bit.ToolBit): + if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.ToolBit): return sel[0].Object return None @@ -94,32 +99,15 @@ class CommandToolBitSave: return False def Activated(self): - from PySide import QtGui + tool_obj = self.selectedTool() + if not tool_obj: + return + toolbit = tool_obj.Proxy - tool = self.selectedTool() - if tool: - path = None - if not tool.File or self.saveAs: - if tool.File: - fname = tool.File - else: - fname = os.path.join( - Path.Preferences.lastPathToolBit(), - tool.Label + ".fctb", - ) - foo = QtGui.QFileDialog.getSaveFileName( - QtGui.QApplication.activeWindow(), "Tool", fname, "*.fctb" - ) - if foo: - path = foo[0] - else: - path = tool.File - - if path: - if not path.endswith(".fctb"): - path += ".fctb" - tool.Proxy.saveToFile(tool, path) - Path.Preferences.setLastPathToolBit(os.path.dirname(path)) + dialog = AssetSaveDialog(ToolBit, toolbit_serializers, FreeCADGui.getMainWindow()) + dialog_result = dialog.exec(toolbit) + if not dialog_result: + return class CommandToolBitLoad: @@ -141,7 +129,7 @@ class CommandToolBitLoad: def selectedTool(self): sel = FreeCADGui.Selection.getSelectionEx() - if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.Bit.ToolBit): + if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.ToolBit): return sel[0].Object return None @@ -149,7 +137,11 @@ class CommandToolBitLoad: return FreeCAD.ActiveDocument is not None def Activated(self): - if Path.Tool.Bit.Gui.LoadTools(): + dialog = ToolBitOpenDialog(toolbit_serializers, FreeCADGui.getMainWindow()) + toolbits = dialog.exec() + for toolbit in toolbits: + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + if toolbits: FreeCAD.ActiveDocument.recompute() @@ -165,5 +157,3 @@ CommandList = [ "CAM_ToolBitSave", "CAM_ToolBitSaveAs", ] - -FreeCAD.Console.PrintLog("Loading PathToolBitCmd... done\n") diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py new file mode 100644 index 0000000000..eb4d77065c --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for editing a ToolBit object.""" + +from functools import partial +import FreeCAD +import FreeCADGui +from PySide import QtGui, QtCore +from ..models.base import ToolBit +from ...shape.ui.shapewidget import ShapeWidget +from ...ui.docobject import DocumentObjectEditorWidget + + +class ToolBitPropertiesWidget(QtGui.QWidget): + """ + A composite widget for editing the properties and shape of a ToolBit. + """ + + # Signal emitted when the toolbit data has been modified + toolBitChanged = QtCore.Signal() + + def __init__(self, toolbit: ToolBit | None = None, parent=None, icon: bool = True): + super().__init__(parent) + self._toolbit = None + self._show_shape = icon + + # UI Elements + self._label_edit = QtGui.QLineEdit() + self._id_label = QtGui.QLabel() # Read-only ID + self._id_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) + + self._property_editor = DocumentObjectEditorWidget() + self._property_editor.setSizePolicy( + QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding + ) + self._shape_widget = None # Will be created in load_toolbit + + # Layout + toolbit_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Tool Bit")) + form_layout = QtGui.QFormLayout(toolbit_group_box) + form_layout.addRow("Label:", self._label_edit) + form_layout.addRow("ID:", self._id_label) + + main_layout = QtGui.QVBoxLayout(self) + main_layout.addWidget(toolbit_group_box) + + properties_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Properties")) + properties_group_box.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) + properties_layout = QtGui.QVBoxLayout(properties_group_box) + properties_layout.setSpacing(5) + properties_layout.addWidget(self._property_editor) + + # Ensure the layout expands horizontally + properties_layout.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + + # Set stretch factor to make property editor expand + properties_layout.setStretchFactor(self._property_editor, 1) + + main_layout.addWidget(properties_group_box) + + # Add stretch before shape widget to push it towards the bottom + main_layout.addStretch(1) + + # Layout for centering the shape widget (created later) + self._shape_display_layout = QtGui.QHBoxLayout() + self._shape_display_layout.addStretch(1) + + # Placeholder for the widget + self._shape_display_layout.addStretch(1) + main_layout.addLayout(self._shape_display_layout) + + # Connections + self._label_edit.editingFinished.connect(self._on_label_changed) + self._property_editor.propertyChanged.connect(self.toolBitChanged) + + if toolbit: + self.load_toolbit(toolbit) + + def _on_label_changed(self): + """Update the toolbit's label when the line edit changes.""" + if self._toolbit and self._toolbit.obj: + new_label = self._label_edit.text() + if self._toolbit.obj.Label != new_label: + self._toolbit.obj.Label = new_label + self.toolBitChanged.emit() + + def load_toolbit(self, toolbit: ToolBit): + """Load a ToolBit object into the editor.""" + self._toolbit = toolbit + if not self._toolbit or not self._toolbit.obj: + # Clear or disable fields if toolbit is invalid + self._label_edit.clear() + self._label_edit.setEnabled(False) + self._id_label.clear() + self._property_editor.setObject(None) + # Clear existing shape widget if any + if self._shape_widget: + self._shape_display_layout.removeWidget(self._shape_widget) + self._shape_widget.deleteLater() + self._shape_widget = None + self.setEnabled(False) + return + + self.setEnabled(True) + self._label_edit.setEnabled(True) + self._label_edit.setText(self._toolbit.obj.Label) + self._id_label.setText(self._toolbit.get_id()) + + # Get properties and suffixes + props_to_show = self._toolbit._get_props(("Shape", "Attributes")) + icon = self._toolbit._tool_bit_shape.get_icon() + suffixes = icon.abbreviations if icon else {} + self._property_editor.setObject(self._toolbit.obj) + self._property_editor.setPropertiesToShow(props_to_show, suffixes) + + # Clear old shape widget and create/add new one if shape exists + if self._shape_widget: + self._shape_display_layout.removeWidget(self._shape_widget) + self._shape_widget.deleteLater() + self._shape_widget = None + + if self._show_shape and self._toolbit._tool_bit_shape: + self._shape_widget = ShapeWidget(shape=self._toolbit._tool_bit_shape, parent=self) + self._shape_widget.setMinimumSize(200, 150) + # Insert into the middle slot of the HBox layout + self._shape_display_layout.insertWidget(1, self._shape_widget) + + def save_toolbit(self): + """ + Applies changes from the editor widgets back to the ToolBit object. + Note: Most changes are applied via signals, but this can be called + for explicit save actions. + """ + # Ensure label is updated if focus is lost without pressing Enter + self._on_label_changed() + + # No need to explicitly save the toolbit object itself here, + # as properties were modified directly on toolbit.obj + + +class ToolBitEditorPanel(QtGui.QWidget): + """ + A widget for editing a ToolBit object, wrapping ToolBitEditorWidget + and providing standard dialog buttons. + """ + + # Signals + accepted = QtCore.Signal(ToolBit) + rejected = QtCore.Signal() + toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget + + def __init__(self, toolbit: ToolBit | None = None, parent=None): + super().__init__(parent) + + # Create the main editor widget + self._editor_widget = ToolBitPropertiesWidget(toolbit, self) + + # Create the button box + buttons = QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel + self._button_box = QtGui.QDialogButtonBox(buttons) + + # Connect button box signals to custom signals + self._button_box.accepted.connect(self._accepted) + self._button_box.rejected.connect(self.rejected.emit) + + # Layout + main_layout = QtGui.QVBoxLayout(self) + main_layout.addWidget(self._editor_widget) + main_layout.addWidget(self._button_box) + + # Connect the toolBitChanged signal from the inner widget + self._editor_widget.toolBitChanged.connect(self.toolBitChanged) + + def _accepted(self): + self.accepted.emit(self._editor_widget._toolbit) + + def load_toolbit(self, toolbit: ToolBit): + """Load a ToolBit object into the editor.""" + self._editor_widget.load_toolbit(toolbit) + + def save_toolbit(self): + """Applies changes from the editor widgets back to the ToolBit object.""" + self._editor_widget.save_toolbit() + + +class ToolBitEditor(QtGui.QWidget): + """ + A widget for editing a ToolBit object, wrapping ToolBitEditorWidget + and providing standard dialog buttons. + """ + + # Signals + toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget + + def __init__(self, toolbit: ToolBit, parent=None): + super().__init__(parent) + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui") + + self.toolbit = toolbit + # self.tool_no = tool_no + self.default_title = self.form.windowTitle() + + # Get first tab from the form, add the shape widget at the top. + tool_tab_layout = self.form.toolTabLayout + widget = ShapeWidget(toolbit._tool_bit_shape) + tool_tab_layout.addWidget(widget) + + # Add tool properties editor to the same tab. + props = ToolBitPropertiesWidget(toolbit, self, icon=False) + props.toolBitChanged.connect(self._update) + # props.toolNoChanged.connect(self._on_tool_no_changed) + tool_tab_layout.addWidget(props) + + self.form.tabWidget.setCurrentIndex(0) + self.form.tabWidget.currentChanged.connect(self._on_tab_switched) + + # Hide second tab (tool notes) for now. + self.form.tabWidget.setTabVisible(1, False) + + # Feeds & Speeds + self.feeds_tab_idx = None + """ + TODO: disabled for now. + if tool.supports_feeds_and_speeds(): + label = translate('CAM', 'Feeds && Speeds') + self.feeds = FeedsAndSpeedsWidget(db, serializer, tool, parent=self) + self.feeds_tab_idx = self.form.tabWidget.insertTab(1, self.feeds, label) + else: + self.feeds = None + self.feeds_tab_idx = None + + self.form.lineEditCoating.setText(toolbit.get_coating()) + self.form.lineEditCoating.textChanged.connect(toolbit.set_coating) + self.form.lineEditHardness.setText(toolbit.get_hardness()) + self.form.lineEditHardness.textChanged.connect(toolbit.set_hardness) + self.form.lineEditMaterials.setText(toolbit.get_materials()) + self.form.lineEditMaterials.textChanged.connect(toolbit.set_materials) + self.form.lineEditSupplier.setText(toolbit.get_supplier()) + self.form.lineEditSupplier.textChanged.connect(toolbit.set_supplier) + self.form.plainTextEditNotes.setPlainText(tool.get_notes()) + self.form.plainTextEditNotes.textChanged.connect(self._on_notes_changed) + """ + + def _update(self): + title = self.default_title + tool_name = self.toolbit.label + if tool_name: + title = "{} - {}".format(tool_name, title) + self.form.setWindowTitle(title) + + def _on_tab_switched(self, index): + if index == self.feeds_tab_idx: + self.feeds.update() + + def _on_notes_changed(self): + self.toolbit.set_notes(self.form.plainTextEditNotes.toPlainText()) + + def _on_tool_no_changed(self, value): + self.tool_no = value + + def show(self): + return self.form.exec_() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/file.py b/src/Mod/CAM/Path/Tool/toolbit/ui/file.py new file mode 100644 index 0000000000..0a7c4cddf2 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/file.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +from typing import Optional, List, Type, Iterable +from PySide.QtWidgets import QFileDialog, QMessageBox +from ...assets import AssetSerializer +from ...assets.ui.util import ( + make_import_filters, + get_serializer_from_extension, +) +from ..models.base import ToolBit +from ..serializers import all_serializers + + +class ToolBitOpenDialog(QFileDialog): + def __init__( + self, + serializers: Iterable[Type[AssetSerializer]] | None, + parent=None, + ): + super().__init__(parent) + self.serializers = list(serializers) if serializers else all_serializers + self.setWindowTitle("Open ToolBit(s)") + self.setFileMode(QFileDialog.ExistingFiles) # Allow multiple files + filters = make_import_filters(self.serializers) + self.setNameFilters(filters) + if filters: + self.selectNameFilter(filters[0]) + + def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[ToolBit]: + """Deserialize the selected file using the appropriate serializer.""" + file_extension = file_path.suffix.lower() + serializer_class = get_serializer_from_extension( + self.serializers, file_extension, for_import=True + ) + if not serializer_class: + QMessageBox.critical( + self, + "Error", + f"No supported serializer found for file extension '{file_extension}'", + ) + return None + try: + raw_data = file_path.read_bytes() + toolbit = serializer_class.deep_deserialize(raw_data) + if not isinstance(toolbit, ToolBit): + raise TypeError("Deserialized asset is not of type ToolBit") + return toolbit + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to import toolbit: {e}") + return None + + def exec(self) -> List[ToolBit]: + toolbits = [] + if super().exec_(): + filenames = self.selectedFiles() + for filename in filenames: + file_path = pathlib.Path(filename) + toolbit = self._deserialize_selected_file(file_path) + if toolbit: + toolbits.append(toolbit) + return toolbits diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/panel.py b/src/Mod/CAM/Path/Tool/toolbit/ui/panel.py new file mode 100644 index 0000000000..98027cfa0d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/panel.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +import FreeCADGui +import Path +from Path.Tool.toolbit.ui import ToolBitEditorPanel + + +class TaskPanel: + """TaskPanel for the SetupSheet - if it is being edited directly.""" + + def __init__(self, vobj, deleteOnReject): + Path.Log.track(vobj.Object.Label) + self.vobj = vobj + self.obj = vobj.Object + self.editor = ToolBitEditorPanel(self.obj, self.editor.form) + self.deleteOnReject = deleteOnReject + FreeCAD.ActiveDocument.openTransaction("Edit ToolBit") + + def reject(self): + FreeCAD.ActiveDocument.abortTransaction() + self.editor.reject() + FreeCADGui.Control.closeDialog() + if self.deleteOnReject: + FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit") + self.editor.reject() + FreeCAD.ActiveDocument.removeObject(self.obj.Name) + FreeCAD.ActiveDocument.commitTransaction() + FreeCAD.ActiveDocument.recompute() + + def accept(self): + self.editor.accept() + + FreeCAD.ActiveDocument.commitTransaction() + FreeCADGui.ActiveDocument.resetEdit() + FreeCADGui.Control.closeDialog() + FreeCAD.ActiveDocument.recompute() + + def updateUI(self): + Path.Log.track() + self.editor.updateUI() + + def updateModel(self): + self.editor.updateTool() + FreeCAD.ActiveDocument.recompute() + + def setupUi(self): + self.editor.setupUI() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py new file mode 100644 index 0000000000..40d73f6a7b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""ToolBit selector dialog.""" + +from PySide import QtWidgets +import FreeCAD +from ...camassets import cam_assets +from ...toolbit import ToolBit +from .browser import ToolBitBrowserWidget + + +class ToolBitSelector(QtWidgets.QDialog): + """ + A dialog for selecting ToolBits using the ToolBitBrowserWidget. + """ + + def __init__( + self, parent=None, compact=False, button_label=FreeCAD.Qt.translate("CAM", "Add Tool") + ): + super().__init__(parent) + + self.setMinimumSize(600, 400) + + self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Tool Bit")) + + self._browser_widget = ToolBitBrowserWidget(cam_assets, compact=compact) + + # Create OK and Cancel buttons + self._ok_button = QtWidgets.QPushButton(button_label) + self._cancel_button = QtWidgets.QPushButton("Cancel") + + # Connect buttons to their actions + self._ok_button.clicked.connect(self.accept) + self._cancel_button.clicked.connect(self.reject) + + # Layout setup + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self._browser_widget) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(self._cancel_button) + button_layout.addWidget(self._ok_button) + + layout.addLayout(button_layout) + + # Disable OK button initially until a tool is selected + self._ok_button.setEnabled(False) + self._browser_widget.toolSelected.connect(self._on_tool_selected) + self._browser_widget.itemDoubleClicked.connect(self.accept) + + self._selected_tool_uri = None + + def _on_tool_selected(self, uri): + """Enables/disables OK button based on selection.""" + self._selected_tool_uri = uri + self._ok_button.setEnabled(uri is not None) + + def get_selected_tool_uri(self): + """Returns the URI of the selected tool bit.""" + return self._selected_tool_uri + + def get_selected_tool(self) -> ToolBit: + """Returns the selected ToolBit object, or None if none selected.""" + uri = self.get_selected_tool_uri() + if uri: + # Assuming ToolBit.from_uri exists and loads the ToolBit object + return cam_assets.get(uri) + return None diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py new file mode 100644 index 0000000000..c604d666bc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import re +from PySide import QtGui, QtCore +import FreeCAD +from ...shape import ToolBitShape + + +def isub(text, old, repl_pattern): + pattern = "|".join(re.escape(o) for o in old) + return re.sub("(" + pattern + ")", repl_pattern, text, flags=re.I) + + +def interpolate_colors(start_color, end_color, ratio): + r = 1.0 - ratio + red = start_color.red() * r + end_color.red() * ratio + green = start_color.green() * r + end_color.green() * ratio + blue = start_color.blue() * r + end_color.blue() * ratio + return QtGui.QColor(int(red), int(green), int(blue)) + + +class TwoLineTableCell(QtGui.QWidget): + def __init__(self, parent=None): + super(TwoLineTableCell, self).__init__(parent) + self.tool_no = "" + self.pocket = "" + self.upper_text = "" + self.lower_text = "" + self.search_highlight = "" + + palette = self.palette() + bg_role = self.backgroundRole() + bg_color = palette.color(bg_role) + fg_role = self.foregroundRole() + fg_color = palette.color(fg_role) + + self.vbox = QtGui.QVBoxLayout() + self.label_upper = QtGui.QLabel() + self.label_upper.setStyleSheet("margin-top: 8px") + + color = interpolate_colors(bg_color, fg_color, 0.8) + style = "margin-bottom: 8px; color: {};".format(color.name()) + self.label_lower = QtGui.QLabel() + self.label_lower.setStyleSheet(style) + self.vbox.addWidget(self.label_upper) + self.vbox.addWidget(self.label_lower) + + style = "color: {}".format(fg_color.name()) + self.label_left = QtGui.QLabel() + self.label_left.setMinimumWidth(40) + self.label_left.setTextFormat(QtCore.Qt.RichText) + self.label_left.setAlignment(QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter) + self.label_left.setStyleSheet(style) + + ratio = self.devicePixelRatioF() + self.icon_size = QtCore.QSize(50 * ratio, 60 * ratio) + self.icon_widget = QtGui.QLabel() + + style = "color: {}".format(fg_color.name()) + self.label_right = QtGui.QLabel() + self.label_right.setMinimumWidth(40) + self.label_right.setTextFormat(QtCore.Qt.RichText) + self.label_right.setAlignment(QtCore.Qt.AlignCenter) + self.label_right.setStyleSheet(style) + + self.hbox = QtGui.QHBoxLayout() + self.hbox.addWidget(self.label_left, 0) + self.hbox.addWidget(self.icon_widget, 0) + self.hbox.addLayout(self.vbox, 1) + self.hbox.addWidget(self.label_right, 0) + + self.setLayout(self.hbox) + + def _highlight(self, text): + if not self.search_highlight: + return text + highlight_fmt = r'\1' + return isub(text, self.search_highlight.split(" "), highlight_fmt) + + def _update(self): + # Handle tool number display + if self.tool_no is not None and self.tool_no != "": + text = self._highlight(str(self.tool_no)) + self.label_left.setText(f"{text}") + self.label_left.setVisible(True) + else: + self.label_left.setVisible(False) + + text = self._highlight(self.pocket) + lbl = FreeCAD.Qt.translate("CAM_Toolbit", "Pocket") + text = f"{lbl}\n

{text}

" if text else "" + self.label_right.setText(text) + + text = self._highlight(self.upper_text) + self.label_upper.setText(f"{text}") + + text = self._highlight(self.lower_text) + self.label_lower.setText(text) + self.label_lower.setText(f"{text}") + + def set_tool_no(self, no): + self.tool_no = no + self._update() + + def set_pocket(self, pocket): + self.pocket = str(pocket) if pocket else "" + self._update() + + def set_upper_text(self, text): + self.upper_text = text + self._update() + + def set_lower_text(self, text): + self.lower_text = text + self._update() + + def set_icon(self, pixmap): + self.hbox.removeWidget(self.icon_widget) + self.icon_widget = QtGui.QLabel() + self.icon_widget.setPixmap(pixmap) + self.hbox.insertWidget(1, self.icon_widget, 0) + + def set_icon_from_shape(self, shape: ToolBitShape): + icon = shape.get_icon() + if not icon: + return + pixmap = icon.get_qpixmap(self.icon_size) + if pixmap: + self.set_icon(pixmap) + + def contains_text(self, text): + for term in text.lower().split(" "): + tool_no_str = str(self.tool_no) if self.tool_no is not None else "" + # Check against the raw text content, not the HTML-formatted text + if ( + term not in tool_no_str.lower() + and term not in self.upper_text.lower() + and term not in self.lower_text.lower() + ): + return False + return True + + def highlight(self, text): + self.search_highlight = text + self._update() + + +class CompactTwoLineTableCell(TwoLineTableCell): + def __init__(self, parent=None): + super(CompactTwoLineTableCell, self).__init__(parent) + + # Reduce icon size + ratio = self.devicePixelRatioF() + self.icon_size = QtCore.QSize(32 * ratio, 32 * ratio) + + # Reduce margins + self.label_upper.setStyleSheet("margin: 2px 0px 0px 0px; font-size: .8em;") + self.label_lower.setStyleSheet("margin: 0px 0px 2px 0px; font-size: .8em;") + self.vbox.setSpacing(0) + self.hbox.setContentsMargins(0, 0, 0, 0) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py new file mode 100644 index 0000000000..fd60d068d4 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for displaying a list of ToolBits using TwoLineTableCell.""" + +from typing import Callable, List +from PySide import QtGui, QtCore +from .tablecell import TwoLineTableCell, CompactTwoLineTableCell +from ..models.base import ToolBit # For type hinting + +# Role for storing the ToolBit URI string +ToolBitUriRole = QtCore.Qt.UserRole + 1 + + +class ToolBitListWidget(QtGui.QListWidget): + """ + A QListWidget specialized for displaying ToolBit items using + TwoLineTableCell widgets. + """ + + def __init__(self, parent=None, tool_no_factory: Callable | None = None): + super().__init__(parent) + self._tool_no_factory = tool_no_factory + # Optimize view for custom widgets + self.setUniformItemSizes(False) # Allow different heights if needed + self.setAutoScroll(True) + self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection) + # Consider setting view mode if needed, default is ListMode + # self.setViewMode(QtGui.QListView.ListMode) + # self.setResizeMode(QtGui.QListView.Adjust) # Adjust items on resize + + def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + """ + Adds a ToolBit to the list. + + Args: + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + # Use the factory function if provided, otherwise use the passed tool_no + final_tool_no = None + if self._tool_no_factory: + final_tool_no = self._tool_no_factory(toolbit) + elif tool_no is not None: + final_tool_no = tool_no + + # Add item to this widget + item = QtGui.QListWidgetItem(self) + cell = TwoLineTableCell(self) + + # Populate the cell widget + cell.set_tool_no(final_tool_no) + cell.set_upper_text(toolbit.label) + cell.set_lower_text(toolbit.summary) + + # Set the custom widget for the list item + item.setSizeHint(cell.sizeHint()) + self.setItemWidget(item, cell) + + # Store the ToolBit URI for later retrieval + item.setData(ToolBitUriRole, str(toolbit.get_uri())) + + def clear_list(self): + """Removes all items from the list.""" + self.clear() + + def apply_filter(self, search_text: str): + """ + Filters the list items based on the search text. + Items are hidden if they don't contain the text in their + tool number, upper text, or lower text. + Also applies highlighting to the visible matching text. + """ + search_text_lower = search_text.lower() + for i in range(self.count()): + item = self.item(i) + cell = self.itemWidget(item) + + if isinstance(cell, TwoLineTableCell): + cell.highlight(search_text) # Apply highlighting + # Determine visibility based on content + contains = cell.contains_text(search_text_lower) + item.setHidden(not contains) + else: + # Fallback for items without the expected widget (shouldn't happen) + item_text = item.text().lower() # Basic text search + item.setHidden(search_text_lower not in item_text) + + def count_visible_items(self) -> int: + """ + Counts and returns the number of visible items in the list. + """ + visible_count = 0 + for i in range(self.count()): + item = self.item(i) + if not item.isHidden(): + visible_count += 1 + return visible_count + + def get_selected_toolbit_uri(self) -> str | None: + """ + Returns the URI string of the currently selected ToolBit item. + Returns None if no item is selected. + """ + currentItem = self.currentItem() + if currentItem: + return currentItem.data(ToolBitUriRole) + return None + + def get_selected_toolbit_uris(self) -> List[str]: + """ + Returns a list of URI strings for the currently selected ToolBit items. + Returns an empty list if no item is selected. + """ + selected_uris = [] + selected_items = self.selectedItems() + for item in selected_items: + uri = item.data(ToolBitUriRole) + if uri: + selected_uris.append(uri) + return selected_uris + + +class CompactToolBitListWidget(ToolBitListWidget): + """ + A QListWidget specialized for displaying ToolBit items using + CompactTwoLineTableCell widgets. + """ + + def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + """ + Adds a ToolBit to the list using CompactTwoLineTableCell. + + Args: + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + # Use the factory function if provided, otherwise use the passed tool_no + final_tool_no = None + if self._tool_no_factory: + final_tool_no = self._tool_no_factory(toolbit) + elif tool_no is not None: + final_tool_no = tool_no + + item = QtGui.QListWidgetItem(self) # Add item to this widget + cell = CompactTwoLineTableCell(self) # Parent the cell to this widget + + # Populate the cell widget + cell.set_tool_no(final_tool_no) + cell.set_upper_text(toolbit.label) + lower_text = toolbit.summary + cell.set_icon_from_shape(toolbit._tool_bit_shape) + cell.set_lower_text(lower_text) + + # Set the custom widget for the list item + item.setSizeHint(cell.sizeHint()) + self.setItemWidget(item, cell) + + # Store the ToolBit URI for later retrieval + item.setData(ToolBitUriRole, str(toolbit.get_uri())) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/view.py b/src/Mod/CAM/Path/Tool/toolbit/ui/view.py new file mode 100644 index 0000000000..2021a01b14 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/view.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +from PySide import QtGui +import FreeCADGui +import Path +from Path.Base.Gui import IconViewProvider +from Path.Tool.toolbit.ui.panel import TaskPanel + + +class ViewProvider(object): + """ + ViewProvider for a ToolBit DocumentObject. + It's sole job is to provide an icon and invoke the TaskPanel + on edit. + """ + + def __init__(self, vobj, name): + Path.Log.track(name, vobj.Object) + self.panel = None + self.icon = name + self.obj = vobj.Object + self.vobj = vobj + vobj.Proxy = self + + def attach(self, vobj): + Path.Log.track(vobj.Object) + self.vobj = vobj + self.obj = vobj.Object + + def getIcon(self): + try: + png_data = self.obj.Proxy.get_thumbnail() + except AttributeError: # Proxy not initialized + png_data = None + if png_data: + pixmap = QtGui.QPixmap() + pixmap.loadFromData(png_data, "PNG") + return QtGui.QIcon(pixmap) + return ":/icons/CAM_ToolBit.svg" + + def dumps(self): + return None + + def loads(self, state): + return None + + def onDelete(self, vobj, arg2=None): + Path.Log.track(vobj.Object.Label) + vobj.Object.Proxy.onDelete(vobj.Object) + + def getDisplayMode(self, mode): + return "Default" + + def _openTaskPanel(self, vobj, deleteOnReject): + Path.Log.track() + self.panel = TaskPanel(vobj, deleteOnReject) + FreeCADGui.Control.closeDialog() + FreeCADGui.Control.showDialog(self.panel) + self.panel.setupUi() + + def setCreate(self, vobj): + Path.Log.track() + self._openTaskPanel(vobj, True) + + def setEdit(self, vobj, mode=0): + self._openTaskPanel(vobj, False) + return True + + def unsetEdit(self, vobj, mode): + FreeCADGui.Control.closeDialog() + self.panel = None + return + + def claimChildren(self): + if self.obj.BitBody: + return [self.obj.BitBody] + return [] + + def doubleClicked(self, vobj): + pass + + def setupContextMenu(self, vobj, menu): + # Override the base class method to prevent adding the "Edit" action + # for ToolBit objects. + pass # TODO: call setEdit here once we have a new editor panel + + +IconViewProvider.RegisterViewProvider("ToolBit", ViewProvider) diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py new file mode 100644 index 0000000000..3a81493ee2 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD + + +def to_json(value): + """Convert a value to JSON format.""" + if isinstance(value, FreeCAD.Units.Quantity): + return str(value) + return value + + +def format_value(value: FreeCAD.Units.Quantity | int | float | None): + if value is None: + return None + elif isinstance(value, FreeCAD.Units.Quantity): + return value.UserString + return str(value) diff --git a/src/Mod/CAM/Path/Tool/ui/__init__.py b/src/Mod/CAM/Path/Tool/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/ui/docobject.py b/src/Mod/CAM/Path/Tool/ui/docobject.py new file mode 100644 index 0000000000..4a57846058 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/ui/docobject.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for editing a list of properties of a DocumentObject.""" + +import re +from PySide import QtGui, QtCore +from .property import BasePropertyEditorWidget + + +def _get_label_text(prop_name): + """Generate a human-readable label from a property name.""" + # Add space before capital letters (CamelCase splitting) + s1 = re.sub(r"([A-Z][a-z]+)", r" \1", prop_name) + # Add space before sequences of capitals (e.g., ID) followed by lowercase + s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", s1) + # Add space before sequences of capitals followed by end of string + s3 = re.sub(r"([A-Z]+)$", r" \1", s2) + # Remove leading/trailing spaces and capitalize + return s3.strip().capitalize() + + +class DocumentObjectEditorWidget(QtGui.QWidget): + """ + A widget that displays a user friendly form for editing properties of a + FreeCAD DocumentObject. + """ + + # Signal emitted when any underlying property value might have changed + propertyChanged = QtCore.Signal() + + def __init__(self, obj=None, properties_to_show=None, property_suffixes=None, parent=None): + """ + Initialize the editor widget. + + Args: + obj (App.DocumentObject, optional): The object to edit. Defaults to None. + properties_to_show (list[str], optional): List of property names to display. + Defaults to None (shows nothing). + property_suffixes (dict[str, str], optional): Dictionary mapping property names + to suffixes for their labels. + Defaults to None. + parent (QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self._obj = obj + self._properties_to_show = properties_to_show if properties_to_show else [] + self._property_suffixes = property_suffixes if property_suffixes else {} + self._property_editors = {} # Store {prop_name: editor_widget} + + self._layout = QtGui.QFormLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setFieldGrowthPolicy(QtGui.QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + + self._populate_form() + + def _clear_form(self): + """Remove all rows from the form layout.""" + while self._layout.rowCount() > 0: + self._layout.removeRow(0) + self._property_editors.clear() + + def _populate_form(self): + """Create and add property editors to the form.""" + self._clear_form() + if not self._obj: + return + + for prop_name in self._properties_to_show: + # Only create an editor if the property exists on the object + if not hasattr(self._obj, prop_name): + continue + + editor_widget = BasePropertyEditorWidget.for_property(self._obj, prop_name, self) + label_text = _get_label_text(prop_name) + suffix = self._property_suffixes.get(prop_name) + if suffix: + label_text = f"{label_text} ({suffix}):" + else: + label_text = f"{label_text}:" + + label = QtGui.QLabel(label_text) + self._layout.addRow(label, editor_widget) + self._property_editors[prop_name] = editor_widget + + # Connect the editor's signal to our own signal + editor_widget.propertyChanged.connect(self.propertyChanged) + + def setObject(self, obj): + """Set or change the DocumentObject being edited.""" + if obj != self._obj: + self._obj = obj + # Re-populate might be too slow if only object changes, + # better to just re-attach existing editors. + # self._populate_form() + for prop_name, editor in self._property_editors.items(): + editor.attachTo(self._obj, prop_name) + + def setPropertiesToShow(self, properties_to_show, property_suffixes=None): + """Set or change the list of properties to display.""" + self._properties_to_show = properties_to_show if properties_to_show else [] + self._property_suffixes = property_suffixes if property_suffixes else {} + self._populate_form() # Rebuild the form completely + + def updateUI(self): + """Update all child editor widgets from the object's properties.""" + for editor in self._property_editors.values(): + editor.updateWidget() + + def updateObject(self): + """Update the object's properties from all child editor widgets.""" + # This might not be strictly necessary if signals are connected, + # but can be useful for explicit save actions. + for editor in self._property_editors.values(): + editor.updateProperty() diff --git a/src/Mod/CAM/Path/Tool/ui/property.py b/src/Mod/CAM/Path/Tool/ui/property.py new file mode 100644 index 0000000000..66876c763b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/ui/property.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +""" +Widgets for editing specific types of DocumentObject properties. +Includes a factory method to create the appropriate widget based on type. +""" + +import FreeCAD +import FreeCADGui +from PySide import QtGui, QtCore +from typing import Optional + + +class BasePropertyEditorWidget(QtGui.QWidget): + """ + Base class for property editor widgets. Includes a factory method + to create specific subclasses based on property type. + """ + + # Signal emitted when the underlying property value might have changed + propertyChanged = QtCore.Signal() + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(parent) + self._obj = obj + self._prop_name = prop_name + self._layout = QtGui.QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._editor_widget: QtGui.QWidget = None # The actual input widget (SpinBox, ComboBox) + self._editor_mode: int = 0 # Default to editable + self._is_read_only: bool = False + self._update_editor_mode() + self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) + + def attachTo(self, obj: FreeCAD.DocumentObject, prop_name: Optional[str] = None): + """Attach the editor to a (potentially different) object/property.""" + self._obj = obj + self._prop_name = prop_name if prop_name else self._prop_name + self._update_editor_mode() + self.updateWidget() # Ensure widget reflects new state + + def _update_editor_mode(self): + """Fetch and store the current editor mode for the property.""" + if self._obj and self._prop_name: + self._editor_mode = self._obj.getEditorMode(self._prop_name) + self._is_read_only = self._editor_mode == 2 + return + self._editor_mode = 0 + self._is_read_only = False + + def updateWidget(self): + """Update the editor widget's display from the object property.""" + # Implementation specific to subclasses + raise NotImplementedError + + def updateProperty(self): + """Update the object property from the editor widget's value.""" + # Implementation specific to subclasses + # Should emit propertyChanged signal if value actually changes + raise NotImplementedError + + @classmethod + def for_property( + cls, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None + ) -> "BasePropertyEditorWidget": + """ + Factory method to create the appropriate editor widget subclass. + """ + if not obj or not hasattr(obj, "getPropertyByName"): + return LabelPropertyEditorWidget(obj, prop_name, parent) + + prop_value = obj.getPropertyByName(prop_name) + prop_type = obj.getTypeIdOfProperty(prop_name) + + if isinstance(prop_value, FreeCAD.Units.Quantity): + return QuantityPropertyEditorWidget(obj, prop_name, parent) + elif isinstance(prop_value, bool): + return BoolPropertyEditorWidget(obj, prop_name, parent) + elif isinstance(prop_value, int): + return IntPropertyEditorWidget(obj, prop_name, parent) + elif prop_type == "App::PropertyEnumeration": + return EnumPropertyEditorWidget(obj, prop_name, parent) + else: + # Default to a read-only label for other types + return LabelPropertyEditorWidget(obj, prop_name, parent) + + +class QuantityPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Quantity properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + ui = FreeCADGui.UiLoader() + self._editor_widget: FreeCADGui.QuantitySpinBox = ui.createWidget("Gui::QuantitySpinBox") + self._layout.addWidget(self._editor_widget) + self.updateWidget() # Set initial value + # Connect signal after setting initial value to avoid premature update + self._editor_widget.editingFinished.connect(self.updateProperty) + + def updateWidget(self): + value: FreeCAD.Units.Quantity = self._obj.getPropertyByName(self._prop_name) + # Block signals temporarily to prevent feedback loops + self._editor_widget.blockSignals(True) + self._editor_widget.setProperty("value", value) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def updateProperty(self): + current_value = self._obj.getPropertyByName(self._prop_name) + new_value_str: str = self._editor_widget.property("value").UserString + new_value = FreeCAD.Units.Quantity(new_value_str) + if new_value_str != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + +class BoolPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Boolean properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QComboBox = QtGui.QComboBox() + self._editor_widget.addItems(["False", "True"]) + self._layout.addWidget(self._editor_widget) + self.updateWidget() + self._editor_widget.currentIndexChanged.connect(self._on_index_changed) + + def updateWidget(self): + value: bool = self._obj.getPropertyByName(self._prop_name) + self._editor_widget.blockSignals(True) + self._editor_widget.setCurrentIndex(1 if value else 0) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def _on_index_changed(self, index: int): + """Slot connected to currentIndexChanged signal.""" + if self._is_read_only: + return + current_value: bool = self._obj.getPropertyByName(self._prop_name) + new_value: bool = bool(index) + if new_value != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + def updateProperty(self): + """Update property based on current widget state (for consistency).""" + self._on_index_changed(self._editor_widget.currentIndex()) + + +class IntPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Integer properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QSpinBox = QtGui.QSpinBox() + self._editor_widget.setMinimum(-2147483648) + self._editor_widget.setMaximum(2147483647) + self._layout.addWidget(self._editor_widget) + self.updateWidget() + self._editor_widget.editingFinished.connect(self.updateProperty) + + def updateWidget(self): + value = self._obj.getPropertyByName(self._prop_name) + self._editor_widget.blockSignals(True) + self._editor_widget.setValue(value or 0) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def updateProperty(self): + current_value: int = self._obj.getPropertyByName(self._prop_name) + new_value: int = self._editor_widget.value() + if new_value != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + +class EnumPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Enumeration properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QComboBox = QtGui.QComboBox() + self._layout.addWidget(self._editor_widget) + self._populate_enum() + self.updateWidget() + self._editor_widget.currentIndexChanged.connect(self._on_index_changed) + + def _populate_enum(self): + self._editor_widget.clear() + enums: list[str] = self._obj.getEnumerationsOfProperty(self._prop_name) + self._editor_widget.addItems(enums) + + def attachTo(self, obj: FreeCAD.DocumentObject, prop_name: Optional[str] = None): + """Override attachTo to repopulate enums if object changes.""" + super().attachTo(obj, prop_name) + self._populate_enum() # Repopulate in case enums are different + + def updateWidget(self): + value: str = self._obj.getPropertyByName(self._prop_name) + self._editor_widget.blockSignals(True) + index: int = self._editor_widget.findText(value) + self._editor_widget.setCurrentIndex(index if index >= 0 else 0) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def _on_index_changed(self, index: int): + """Slot connected to currentIndexChanged signal.""" + if self._is_read_only: + return + current_value: str = self._obj.getPropertyByName(self._prop_name) + new_value: str = self._editor_widget.itemText(index) + if new_value != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + def updateProperty(self): + """Update property based on current widget state (for consistency).""" + self._on_index_changed(self._editor_widget.currentIndex()) + + +class LabelPropertyEditorWidget(BasePropertyEditorWidget): + """Read-only label for unsupported or invalid property types.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QLabel = QtGui.QLabel("N/A") + self._editor_widget.setTextInteractionFlags( + QtGui.Qt.TextSelectableByMouse | QtGui.Qt.TextSelectableByKeyboard + ) + self._layout.addWidget(self._editor_widget) + self.updateWidget() + + def updateWidget(self): + text = "N/A" + text: str = str(self._obj.getPropertyByName(self._prop_name)) + self._editor_widget.setText(text) + + def updateProperty(self): + # Read-only, no action needed + pass diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index a8e993541f..4fb285c6e0 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# flake8: noqa import # *************************************************************************** # * Copyright (c) 2016 sliptonic * # * * @@ -61,9 +62,34 @@ from CAMTests.TestPathStock import TestPathStock from CAMTests.TestPathTapGenerator import TestPathTapGenerator from CAMTests.TestPathThreadMilling import TestPathThreadMilling from CAMTests.TestPathThreadMillingGenerator import TestPathThreadMillingGenerator +from CAMTests.TestPathToolAsset import TestPathToolAsset +from CAMTests.TestPathToolAssetCache import ( + TestPathToolAssetCache, + TestPathToolAssetCacheIntegration, +) +from CAMTests.TestPathToolAssetManager import TestPathToolAssetManager +from CAMTests.TestPathToolAssetStore import TestPathToolFileStore, TestPathToolMemoryStore +from CAMTests.TestPathToolAssetUri import TestPathToolAssetUri from CAMTests.TestPathToolBit import TestPathToolBit +from CAMTests.TestPathToolShapeClasses import TestPathToolShapeClasses +from CAMTests.TestPathToolShapeDoc import TestPathToolShapeDoc +from CAMTests.TestPathToolShapeIcon import ( + TestToolBitShapeIconBase, + TestToolBitShapeSvgIcon, + TestToolBitShapePngIcon, +) +from CAMTests.TestPathToolBitSerializer import ( + TestCamoticsToolBitSerializer, + TestFCTBSerializer, +) +from CAMTests.TestPathToolLibrary import TestPathToolLibrary +from CAMTests.TestPathToolLibrarySerializer import ( + TestCamoticsLibrarySerializer, + TestLinuxCNCLibrarySerializer, +) 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 @@ -82,61 +108,3 @@ from CAMTests.TestRefactoredTestPost import TestRefactoredTestPost from CAMTests.TestRefactoredTestPostGCodes import TestRefactoredTestPostGCodes from CAMTests.TestRefactoredTestPostMCodes import TestRefactoredTestPostMCodes from CAMTests.TestSnapmakerPost import TestSnapmakerPost - -# dummy usage to get flake8 and lgtm quiet -False if TestCAMSanity.__name__ else True -False if depthTestCases.__name__ else True -False if TestApp.__name__ else True -False if TestBuildPostList.__name__ else True -False if TestDressupDogbone.__name__ else True -False if TestDressupDogboneII.__name__ else True -False if TestFileNameGenerator.__name__ else True -False if TestGeneratorDogboneII.__name__ else True -False if TestHoldingTags.__name__ else True -False if TestPathLanguage.__name__ else True -# False if TestOutputNameSubstitution.__name__ else True -False if TestPathAdaptive.__name__ else True -False if TestPathCore.__name__ else True -False if TestPathOpDeburr.__name__ else True -False if TestPathDrillable.__name__ else True -False if TestPathGeom.__name__ else True -False if TestPathHelpers.__name__ else True -False if TestPathHelix.__name__ else True -False if TestPathLog.__name__ else True -False if TestPathOpUtil.__name__ else True -# False if TestPathPost.__name__ else True -False if TestPostProcessorFactory.__name__ else True -False if TestResolvingPostProcessorName.__name__ else True -False if TestPathPostUtils.__name__ else True -False if TestPathPreferences.__name__ else True -False if TestPathProfile.__name__ else True -False if TestPathPropertyBag.__name__ else True -False if TestPathRotationGenerator.__name__ else True -False if TestPathSetupSheet.__name__ else True -False if TestPathStock.__name__ else True -False if TestPathTapGenerator.__name__ else True -False if TestPathThreadMilling.__name__ else True -False if TestPathThreadMillingGenerator.__name__ else True -False if TestPathToolBit.__name__ else True -False if TestPathToolChangeGenerator.__name__ else True -False if TestPathToolController.__name__ else True -False if TestPathUtil.__name__ else True -False if TestPathVcarve.__name__ else True -False if TestPathVoronoi.__name__ else True -False if TestPathDrillGenerator.__name__ else True -False if TestPathHelixGenerator.__name__ else True - -False if TestCentroidPost.__name__ else True -False if TestGrblPost.__name__ else True -False if TestLinuxCNCPost.__name__ else True -False if TestMach3Mach4Post.__name__ else True -False if TestRefactoredCentroidPost.__name__ else True -False if TestRefactoredGrblPost.__name__ else True -False if TestRefactoredLinuxCNCPost.__name__ else True -False if TestRefactoredMassoG3Post.__name__ else True -False if TestRefactoredMach3Mach4Post.__name__ else True -False if TestRefactoredTestDressupPost.__name__ else True -False if TestRefactoredTestPost.__name__ else True -False if TestRefactoredTestPostGCodes.__name__ else True -False if TestRefactoredTestPostMCodes.__name__ else True -False if TestSnapmakerPost.__name__ else True diff --git a/src/Mod/CAM/TestCAMGui.py b/src/Mod/CAM/TestCAMGui.py new file mode 100644 index 0000000000..4cb882d88c --- /dev/null +++ b/src/Mod/CAM/TestCAMGui.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# flake8: noqa import +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE 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 * +# * * +# *************************************************************************** +from CAMTests.TestPathToolBitPropertyEditorWidget import ( + TestBoolPropertyEditorWidget, + TestEnumPropertyEditorWidget, + TestIntPropertyEditorWidget, + TestLabelPropertyEditorWidget, + TestPropertyEditorFactory, + TestQuantityPropertyEditorWidget, +) +from CAMTests.TestPathToolDocumentObjectEditorWidget import TestDocumentObjectEditorWidget +from CAMTests.TestPathToolBitBrowserWidget import TestToolBitBrowserWidget +from CAMTests.TestPathToolBitEditorWidget import TestToolBitPropertiesWidget +from CAMTests.TestPathToolBitListWidget import TestToolBitListWidget diff --git a/src/Mod/CAM/Tools/Shape/ballend.fcstd b/src/Mod/CAM/Tools/Shape/ballend.fcstd index 1060b6983e..d9bf4dc730 100644 Binary files a/src/Mod/CAM/Tools/Shape/ballend.fcstd and b/src/Mod/CAM/Tools/Shape/ballend.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/ballend.svg b/src/Mod/CAM/Tools/Shape/ballend.svg new file mode 100644 index 0000000000..5c0629f787 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/ballend.svg @@ -0,0 +1,345 @@ + + + +SDHL diff --git a/src/Mod/CAM/Tools/Shape/bullnose.fcstd b/src/Mod/CAM/Tools/Shape/bullnose.fcstd index 24a6a18869..121fa63397 100644 Binary files a/src/Mod/CAM/Tools/Shape/bullnose.fcstd and b/src/Mod/CAM/Tools/Shape/bullnose.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/bullnose.svg b/src/Mod/CAM/Tools/Shape/bullnose.svg new file mode 100644 index 0000000000..c8ab80b901 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/bullnose.svg @@ -0,0 +1,374 @@ + + + +SDHRL diff --git a/src/Mod/CAM/Tools/Shape/chamfer.fcstd b/src/Mod/CAM/Tools/Shape/chamfer.fcstd index a904db4cd3..2c8ef596a5 100644 Binary files a/src/Mod/CAM/Tools/Shape/chamfer.fcstd and b/src/Mod/CAM/Tools/Shape/chamfer.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/chamfer.svg b/src/Mod/CAM/Tools/Shape/chamfer.svg new file mode 100644 index 0000000000..3886c44d95 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/chamfer.svg @@ -0,0 +1,448 @@ + +image/svg+xmlSDhLα diff --git a/src/Mod/CAM/Tools/Shape/dovetail.fcstd b/src/Mod/CAM/Tools/Shape/dovetail.fcstd index 1fca39c020..548726c2c3 100644 Binary files a/src/Mod/CAM/Tools/Shape/dovetail.fcstd and b/src/Mod/CAM/Tools/Shape/dovetail.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/dovetail.svg b/src/Mod/CAM/Tools/Shape/dovetail.svg new file mode 100644 index 0000000000..13767f21c9 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/dovetail.svg @@ -0,0 +1,660 @@ + +image/svg+xmlSDαHNCLh diff --git a/src/Mod/CAM/Tools/Shape/drill.fcstd b/src/Mod/CAM/Tools/Shape/drill.fcstd index ec78c92f06..5d8ef15dea 100644 Binary files a/src/Mod/CAM/Tools/Shape/drill.fcstd and b/src/Mod/CAM/Tools/Shape/drill.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/drill.svg b/src/Mod/CAM/Tools/Shape/drill.svg new file mode 100644 index 0000000000..0b98c74701 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/drill.svg @@ -0,0 +1,258 @@ + +image/svg+xmlHDα diff --git a/src/Mod/CAM/Tools/Shape/endmill.fcstd b/src/Mod/CAM/Tools/Shape/endmill.fcstd index 47e748acde..998a635ac2 100644 Binary files a/src/Mod/CAM/Tools/Shape/endmill.fcstd and b/src/Mod/CAM/Tools/Shape/endmill.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/endmill.svg b/src/Mod/CAM/Tools/Shape/endmill.svg new file mode 100644 index 0000000000..4fbce8e969 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/endmill.svg @@ -0,0 +1,433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + S + D + H + + + + + + + + L + + + + + diff --git a/src/Mod/CAM/Tools/Shape/fillet.fcstd b/src/Mod/CAM/Tools/Shape/fillet.fcstd new file mode 100644 index 0000000000..536a57f848 Binary files /dev/null and b/src/Mod/CAM/Tools/Shape/fillet.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/fillet.svg b/src/Mod/CAM/Tools/Shape/fillet.svg new file mode 100644 index 0000000000..090a22fbce --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/fillet.svg @@ -0,0 +1,423 @@ + +image/svg+xmlSDhLr diff --git a/src/Mod/CAM/Tools/Shape/probe.fcstd b/src/Mod/CAM/Tools/Shape/probe.fcstd index bb156dd580..8bcaf27888 100644 Binary files a/src/Mod/CAM/Tools/Shape/probe.fcstd and b/src/Mod/CAM/Tools/Shape/probe.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/probe.svg b/src/Mod/CAM/Tools/Shape/probe.svg new file mode 100644 index 0000000000..3190142c74 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/probe.svg @@ -0,0 +1,468 @@ + +image/svg+xmlSdL diff --git a/src/Mod/CAM/Tools/Shape/reamer.fcstd b/src/Mod/CAM/Tools/Shape/reamer.fcstd new file mode 100644 index 0000000000..56ed8f1cee Binary files /dev/null and b/src/Mod/CAM/Tools/Shape/reamer.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/reamer.svg b/src/Mod/CAM/Tools/Shape/reamer.svg new file mode 100644 index 0000000000..330dab6c0e --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/reamer.svg @@ -0,0 +1,586 @@ + +image/svg+xmlDH diff --git a/src/Mod/CAM/Tools/Shape/slittingsaw.fcstd b/src/Mod/CAM/Tools/Shape/slittingsaw.fcstd index 904cffeaf6..2b33b0136c 100644 Binary files a/src/Mod/CAM/Tools/Shape/slittingsaw.fcstd and b/src/Mod/CAM/Tools/Shape/slittingsaw.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/slittingsaw.svg b/src/Mod/CAM/Tools/Shape/slittingsaw.svg new file mode 100644 index 0000000000..f9718c6c54 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/slittingsaw.svg @@ -0,0 +1,504 @@ + +image/svg+xmlSDdThL diff --git a/src/Mod/CAM/Tools/Shape/tap.fcstd b/src/Mod/CAM/Tools/Shape/tap.fcstd index 32634aeb58..2ee6cbcda3 100644 Binary files a/src/Mod/CAM/Tools/Shape/tap.fcstd and b/src/Mod/CAM/Tools/Shape/tap.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/tap.svg b/src/Mod/CAM/Tools/Shape/tap.svg new file mode 100644 index 0000000000..df05ecb5ee --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/tap.svg @@ -0,0 +1,427 @@ + +image/svg+xmlSDαhL diff --git a/src/Mod/CAM/Tools/Shape/thread-mill.fcstd b/src/Mod/CAM/Tools/Shape/thread-mill.fcstd deleted file mode 100644 index 0e93663ed4..0000000000 Binary files a/src/Mod/CAM/Tools/Shape/thread-mill.fcstd and /dev/null differ diff --git a/src/Mod/CAM/Tools/Shape/threadmill.fcstd b/src/Mod/CAM/Tools/Shape/threadmill.fcstd new file mode 100644 index 0000000000..e0eb96c9ea Binary files /dev/null and b/src/Mod/CAM/Tools/Shape/threadmill.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/threadmill.svg b/src/Mod/CAM/Tools/Shape/threadmill.svg new file mode 100644 index 0000000000..f15357d1cc --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/threadmill.svg @@ -0,0 +1,602 @@ + +image/svg+xmlSDαCNn diff --git a/src/Mod/CAM/Tools/Shape/v-bit.fcstd b/src/Mod/CAM/Tools/Shape/v-bit.fcstd deleted file mode 100644 index c87f4dee99..0000000000 Binary files a/src/Mod/CAM/Tools/Shape/v-bit.fcstd and /dev/null differ diff --git a/src/Mod/CAM/Tools/Shape/vbit.fcstd b/src/Mod/CAM/Tools/Shape/vbit.fcstd new file mode 100644 index 0000000000..16cd0e630a Binary files /dev/null and b/src/Mod/CAM/Tools/Shape/vbit.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/vbit.svg b/src/Mod/CAM/Tools/Shape/vbit.svg new file mode 100644 index 0000000000..3b87534d9f --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/vbit.svg @@ -0,0 +1,436 @@ + +image/svg+xmlSDαdhL