diff --git a/src/Mod/CAM/CAMTests/TestPathPropertyBag.py b/src/Mod/CAM/CAMTests/TestPathPropertyBag.py index 0cf14f129a..3cff57c1e3 100644 --- a/src/Mod/CAM/CAMTests/TestPathPropertyBag.py +++ b/src/Mod/CAM/CAMTests/TestPathPropertyBag.py @@ -25,6 +25,20 @@ import Path.Base.PropertyBag as PathPropertyBag import CAMTests.PathTestUtils as PathTestUtils +def as_group_list(groups): + """Normalize CustomPropertyGroups to a list of strings.""" + if groups is None: + return [] + if isinstance(groups, (list, tuple)): + return list(groups) + if isinstance(groups, str): + return [groups] + try: + return list(groups) + except Exception: + return [str(groups)] + + class TestPathPropertyBag(PathTestUtils.PathTestBase): def setUp(self): self.doc = FreeCAD.newDocument("test-property-bag") @@ -37,7 +51,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase): bag = PathPropertyBag.Create() self.assertTrue(hasattr(bag, "Proxy")) self.assertEqual(bag.Proxy.getCustomProperties(), []) - self.assertEqual(bag.CustomPropertyGroups, []) + self.assertEqual(as_group_list(bag.CustomPropertyGroups), []) def test01(self): """adding properties to a PropertyBag is tracked properly""" @@ -48,7 +62,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase): bag.Title = "Madame" self.assertEqual(bag.Title, "Madame") self.assertEqual(bag.Proxy.getCustomProperties(), ["Title"]) - self.assertEqual(bag.CustomPropertyGroups, ["Address"]) + self.assertEqual(as_group_list(bag.CustomPropertyGroups), ["Address"]) def test02(self): """refreshCustomPropertyGroups deletes empty groups""" @@ -59,7 +73,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase): bag.removeProperty("Title") proxy.refreshCustomPropertyGroups() self.assertEqual(bag.Proxy.getCustomProperties(), []) - self.assertEqual(bag.CustomPropertyGroups, []) + self.assertEqual(as_group_list(bag.CustomPropertyGroups), []) def test03(self): """refreshCustomPropertyGroups does not delete non-empty groups""" @@ -72,4 +86,4 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase): bag.removeProperty("Gender") proxy.refreshCustomPropertyGroups() self.assertEqual(bag.Proxy.getCustomProperties(), ["Title"]) - self.assertEqual(bag.CustomPropertyGroups, ["Address"]) + self.assertEqual(as_group_list(bag.CustomPropertyGroups), ["Address"]) diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py index 3043060f9e..6120110a1c 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py @@ -1,9 +1,13 @@ +""" +AssetManager tests. +""" + import unittest import asyncio from unittest.mock import Mock import pathlib import tempfile -from typing import Any, Mapping, List +from typing import Any, Mapping, List, Type, Optional, cast from Path.Tool.assets import ( AssetManager, FileStore, @@ -24,7 +28,7 @@ class MockAsset(Asset): self._id = id @classmethod - def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: # Mock implementation doesn't use data or format for dependencies return [] @@ -33,18 +37,83 @@ class MockAsset(Asset): cls, data: bytes, id: str, - dependencies: Mapping[AssetUri, Asset] | None, - serializer: AssetSerializer, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], ) -> "MockAsset": # Create instance with provided id return cls(data, id) - def to_bytes(self, serializer: AssetSerializer) -> bytes: + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: return self._data def get_id(self) -> str: return self._id + def get_data(self) -> bytes: + """Returns the raw data stored in the mock asset.""" + return self._data + + +# Mock Asset class with dependencies for testing deepcopy +class MockAssetWithDeps(Asset): + asset_type: str = "mock_asset_with_deps" + + def __init__( + self, + data: Any = None, + id: str = "mock_id", + dependencies: Optional[Mapping[AssetUri, Asset]] = None, + ): + self._data = data + self._id = id + self._dependencies = dependencies or {} + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: + # Assuming data is a simple JSON string like '{"deps": ["uri1", "uri2"]}' + try: + import json + + data_str = data.decode("utf-8") + data_dict = json.loads(data_str) + dep_uris_str = data_dict.get("deps", []) + return [AssetUri(uri_str) for uri_str in dep_uris_str] + except Exception: + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], + ) -> "MockAssetWithDeps": + # Create instance with provided id and resolved dependencies + return cls(data, id, dependencies) + + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: + # Serialize data and dependency URIs into a simple format + try: + import json + + dep_uri_strs = [str(uri) for uri in self._dependencies.keys()] + data_dict = {"data": self._data.decode("utf-8"), "deps": dep_uri_strs} + return json.dumps(data_dict).encode("utf-8") + except Exception: + return self._data # Fallback if serialization fails + + def get_id(self) -> str: + return self._id + + def get_data(self) -> bytes: + """Returns the raw data stored in the mock asset.""" + return self._data + + def get_dependencies(self) -> Mapping[AssetUri, Asset]: + """Returns the resolved dependencies.""" + return self._dependencies + class TestPathToolAssetManager(unittest.TestCase): def test_register_store(self): @@ -85,12 +154,12 @@ class TestPathToolAssetManager(unittest.TestCase): cls, data: bytes, id: str, - dependencies: Mapping[AssetUri, Asset] | None, - serializer: AssetSerializer, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], ) -> "AnotherMockAsset": return cls() - def to_bytes(self, serializer: AssetSerializer) -> bytes: + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: return b"" def get_id(self) -> str: @@ -134,12 +203,11 @@ class TestPathToolAssetManager(unittest.TestCase): ) # Call AssetManager.get - retrieved_object = manager.get(test_uri) + retrieved_object = cast(MockAsset, 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) + self.assertEqual(retrieved_object.get_data(), test_data) # Test error handling for non-existent URI non_existent_uri = AssetUri.build(MockAsset.asset_type, "non_existent", "1") @@ -210,7 +278,7 @@ class TestPathToolAssetManager(unittest.TestCase): # Verify the asset was created retrieved_data = asyncio.run(local_store.get(created_uri)) - self.assertEqual(retrieved_data, test_obj.to_bytes(DummyAssetSerializer)) + self.assertEqual(retrieved_data, test_obj.get_data()) # Test error handling (store not found) with self.assertRaises(ValueError) as cm: @@ -244,9 +312,10 @@ class TestPathToolAssetManager(unittest.TestCase): 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)) + obj = cast(MockAsset, manager.get(updated_uri, store="local")) + self.assertEqual(updated_data, test_obj.get_data()) + self.assertIsInstance(obj, MockAsset) + self.assertEqual(updated_data, obj.get_data()) # Test error handling (store not found) with self.assertRaises(ValueError) as cm: @@ -382,23 +451,17 @@ class TestPathToolAssetManager(unittest.TestCase): uris = [uri1, uri2, uri3] # Call manager.get_bulk - retrieved_assets = manager.get_bulk(uris, store="memory_bulk") + retrieved_assets = cast(List[MockAsset], 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.assertEqual(retrieved_assets[0].get_data(), data1) self.assertIsInstance(retrieved_assets[1], MockAsset) - self.assertEqual( - retrieved_assets[1].to_bytes(DummyAssetSerializer), - data2, - ) + self.assertEqual(retrieved_assets[1].get_data(), data2) # Assert the non-existent asset is None self.assertIsNone(retrieved_assets[2]) @@ -442,8 +505,9 @@ class TestPathToolAssetManager(unittest.TestCase): 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" + retrieved_assets_filtered = cast( + List[MockAsset], + manager_filtered.fetch(asset_type=MockAsset.asset_type, store="memory_fetch_filtered"), ) # Assert the correct number of assets were returned @@ -452,13 +516,13 @@ class TestPathToolAssetManager(unittest.TestCase): # 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"), + retrieved_assets_filtered[0].get_data().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"), + retrieved_assets_filtered[1].get_data().decode("utf-8"), data2.decode("utf-8"), ) @@ -467,6 +531,340 @@ class TestPathToolAssetManager(unittest.TestCase): manager.fetch(store="non_existent_store") self.assertIn("No store registered for name:", str(cm.exception)) + def test_copy(self): + # Setup AssetManager with two MemoryStores and MockAsset class + memory_store_src = MemoryStore("memory_copy_src") + memory_store_dest = MemoryStore("memory_copy_dest") + manager = AssetManager() + manager.register_store(memory_store_src) + manager.register_store(memory_store_dest) + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create a source asset + src_data = b"source asset data" + src_uri = manager.add_raw(MockAsset.asset_type, "source_id", src_data, "memory_copy_src") + + # Test copying to a different store with default destination URI + copied_uri_default_dest = manager.copy( + src_uri, dest_store="memory_copy_dest", store="memory_copy_src" + ) + self.assertEqual(copied_uri_default_dest.asset_type, src_uri.asset_type) + self.assertEqual(copied_uri_default_dest.asset_id, src_uri.asset_id) + self.assertEqual(copied_uri_default_dest.version, "1") # First version in dest + + # Verify the copied asset exists in the destination store + copied_data_default_dest = manager.get_raw( + copied_uri_default_dest, store="memory_copy_dest" + ) + self.assertEqual(copied_data_default_dest, src_data) + + # Test copying to a different store with a specified destination URI + dest_uri_specified = AssetUri.build(MockAsset.asset_type, "specified_dest_id", "1") + copied_uri_specified_dest = manager.copy( + src_uri, + dest_store="memory_copy_dest", + store="memory_copy_src", + dest=dest_uri_specified, + ) + self.assertEqual(copied_uri_specified_dest, dest_uri_specified) + + # Verify the copied asset exists at the specified destination URI + copied_data_specified_dest = manager.get_raw( + copied_uri_specified_dest, store="memory_copy_dest" + ) + self.assertEqual(copied_data_specified_dest, src_data) + + # Test copying to the same store with a different destination URI + dest_uri_same_store = AssetUri.build(MockAsset.asset_type, "same_store_dest", "1") + copied_uri_same_store = manager.copy( + src_uri, dest_store="memory_copy_src", store="memory_copy_src", dest=dest_uri_same_store + ) + self.assertEqual(copied_uri_same_store, dest_uri_same_store) + + # Verify the copied asset exists in the same store at the new URI + copied_data_same_store = manager.get_raw(copied_uri_same_store, store="memory_copy_src") + self.assertEqual(copied_data_same_store, src_data) + + # Test assertion for source and destination being the same + with self.assertRaises(ValueError) as cm: + manager.copy( + src_uri, dest_store="memory_copy_src", store="memory_copy_src", dest=src_uri + ) + self.assertIn( + "Source and destination cannot be the same asset in the same store.", + str(cm.exception), + ) + + # Test overwriting existing destination (add a different asset at specified_dest_id) + overwrite_data = b"data to be overwritten" + overwrite_uri = manager.add_raw( + MockAsset.asset_type, "specified_dest_id", overwrite_data, "memory_copy_dest" + ) + self.assertEqual(overwrite_uri.version, "2") # Should be version 2 now + + # Copy the original src_uri to the existing destination + copied_uri_overwrite = manager.copy( + src_uri, + dest_store="memory_copy_dest", + store="memory_copy_src", + dest=dest_uri_specified, + ) + # The version should be incremented again due to overwrite + self.assertEqual(copied_uri_overwrite.version, "3") + + # Verify the destination now contains the source data + retrieved_overwritten_data = manager.get_raw(copied_uri_overwrite, store="memory_copy_dest") + self.assertEqual(retrieved_overwritten_data, src_data) + + def test_deepcopy(self): + # Setup AssetManager with two MemoryStores and MockAssetWithDeps class + memory_store_src = MemoryStore("memory_deepcopy_src") + memory_store_dest = MemoryStore("memory_deepcopy_dest") + manager = AssetManager() + manager.register_store(memory_store_src) + manager.register_store(memory_store_dest) + manager.register_asset(MockAssetWithDeps, DummyAssetSerializer) + + # Create dependency assets in the source store + dep1_data = b'{"data": "dependency 1 data", "deps": []}' + dep2_data = b'{"data": "dependency 2 data", "deps": []}' + dep1_uri = manager.add_raw( + MockAssetWithDeps.asset_type, "dep1_id", dep1_data, "memory_deepcopy_src" + ) + dep2_uri = manager.add_raw( + MockAssetWithDeps.asset_type, "dep2_id", dep2_data, "memory_deepcopy_src" + ) + + # Create a source asset with dependencies + src_data = ( + b'{"data": "source asset data", "deps": ["' + + str(dep1_uri).encode("utf-8") + + b'", "' + + str(dep2_uri).encode("utf-8") + + b'"]}' + ) + src_uri = manager.add_raw( + MockAssetWithDeps.asset_type, "source_id", src_data, "memory_deepcopy_src" + ) + + # Test deep copying to a different store with default destination URI + copied_uri_default_dest = manager.deepcopy( + src_uri, dest_store="memory_deepcopy_dest", store="memory_deepcopy_src" + ) + self.assertEqual(copied_uri_default_dest.asset_type, src_uri.asset_type) + self.assertEqual(copied_uri_default_dest.asset_id, src_uri.asset_id) + self.assertEqual(copied_uri_default_dest.version, "1") # First version in dest + + # Verify the copied top-level asset exists in the destination store + copied_asset_default_dest = cast( + MockAssetWithDeps, manager.get(copied_uri_default_dest, store="memory_deepcopy_dest") + ) + self.assertIsInstance(copied_asset_default_dest, MockAssetWithDeps) + # The copied asset's data should be the serialized form including dependencies + expected_data = b'{"data": "source asset data", "deps": ["mock_asset_with_deps://dep1_id/1", "mock_asset_with_deps://dep2_id/1"]}' + self.assertEqual(copied_asset_default_dest.get_data(), expected_data) + + # Verify dependencies were also copied and resolved correctly + copied_deps_default_dest = copied_asset_default_dest.get_dependencies() + self.assertEqual(len(copied_deps_default_dest), 2) + self.assertIn(dep1_uri, copied_deps_default_dest) + self.assertIn(dep2_uri, copied_deps_default_dest) + + copied_dep1 = cast(MockAssetWithDeps, copied_deps_default_dest[dep1_uri]) + self.assertIsInstance(copied_dep1, MockAssetWithDeps) + self.assertEqual(copied_dep1.get_data(), b'{"data": "dependency 1 data", "deps": []}') + + copied_dep2 = cast(MockAssetWithDeps, copied_deps_default_dest[dep2_uri]) + self.assertIsInstance(copied_dep2, MockAssetWithDeps) + self.assertEqual(copied_dep2.get_data(), b'{"data": "dependency 2 data", "deps": []}') + + # Test deep copying with a specified destination URI for the top-level asset + dest_uri_specified = AssetUri.build(MockAssetWithDeps.asset_type, "specified_dest_id", "1") + copied_uri_specified_dest = manager.deepcopy( + src_uri, + dest_store="memory_deepcopy_dest", + store="memory_deepcopy_src", + dest=dest_uri_specified, + ) + self.assertEqual(copied_uri_specified_dest, dest_uri_specified) + + # Verify the copied asset exists at the specified destination URI + copied_asset_specified_dest = cast( + MockAssetWithDeps, manager.get(copied_uri_specified_dest, store="memory_deepcopy_dest") + ) + self.assertIsInstance(copied_asset_specified_dest, MockAssetWithDeps) + self.assertEqual( + copied_asset_specified_dest.get_data(), + b'{"data": "source asset data", "deps": ["mock_asset_with_deps://dep1_id/1", "mock_asset_with_deps://dep2_id/1"]}', + ) + + # Verify dependencies were copied and resolved correctly (their URIs should be + # in the destination store, but their asset_type and asset_id should be the same) + copied_deps_specified_dest = copied_asset_specified_dest.get_dependencies() + self.assertEqual(len(copied_deps_specified_dest), 2) + + # The keys in the dependencies mapping should be the *original* URIs, + # but the values should be the *copied* dependency assets. + self.assertIn(dep1_uri, copied_deps_specified_dest) + self.assertIn(dep2_uri, copied_deps_specified_dest) + + copied_dep1_specified = cast(MockAssetWithDeps, copied_deps_specified_dest[dep1_uri]) + self.assertIsInstance(copied_dep1_specified, MockAssetWithDeps) + self.assertEqual( + copied_dep1_specified.get_data(), b'{"data": "dependency 1 data", "deps": []}' + ) + # Check the URI of the copied dependency in the destination store + self.assertIsNotNone( + manager.get_or_none(copied_dep1_specified.get_uri(), store="memory_deepcopy_dest") + ) + self.assertEqual(copied_dep1_specified.get_uri().asset_type, dep1_uri.asset_type) + self.assertEqual(copied_dep1_specified.get_uri().asset_id, dep1_uri.asset_id) + + copied_dep2_specified = cast(MockAssetWithDeps, copied_deps_specified_dest[dep2_uri]) + self.assertIsInstance(copied_dep2_specified, MockAssetWithDeps) + self.assertEqual( + copied_dep2_specified.get_data(), b'{"data": "dependency 2 data", "deps": []}' + ) + # Check the URI of the copied dependency in the destination store + self.assertIsNotNone( + manager.get_or_none(copied_dep2_specified.get_uri(), store="memory_deepcopy_dest") + ) + self.assertEqual(copied_dep2_specified.get_uri().asset_type, dep2_uri.asset_type) + self.assertEqual(copied_dep2_specified.get_uri().asset_id, dep2_uri.asset_id) + + # Test handling of existing dependencies in the destination store (should be skipped) + # Add a dependency with the same URI as dep1_uri to the destination store + existing_dep1_data = b'{"data": "existing dependency 1 data", "deps": []}' + existing_dep1_uri_in_dest = manager.add_raw( + dep1_uri.asset_type, dep1_uri.asset_id, existing_dep1_data, "memory_deepcopy_dest" + ) + self.assertEqual(existing_dep1_uri_in_dest.version, "2") # Should be version 2 now + + # Deep copy the source asset again + copied_uri_existing_dep = manager.deepcopy( + src_uri, dest_store="memory_deepcopy_dest", store="memory_deepcopy_src" + ) + # The top-level asset should be overwritten, incrementing its version + self.assertEqual(copied_uri_existing_dep.version, "2") + + # Verify the top-level asset was overwritten + copied_asset_existing_dep = cast( + MockAssetWithDeps, manager.get(copied_uri_existing_dep, store="memory_deepcopy_dest") + ) + self.assertIsInstance(copied_asset_existing_dep, MockAssetWithDeps) + self.assertEqual( + copied_asset_existing_dep.get_data(), + b'{"data": "source asset data", "deps": ["mock_asset_with_deps://dep1_id/1", "mock_asset_with_deps://dep2_id/1"]}', + ) + + # Verify that the existing dependency was *not* overwritten + retrieved_existing_dep1 = manager.get_raw( + existing_dep1_uri_in_dest, store="memory_deepcopy_dest" + ) + self.assertEqual(retrieved_existing_dep1, existing_dep1_data) + + # Verify the dependencies in the copied asset still point to the correct + # (existing) dependency in the destination store. + copied_deps_existing_dep = copied_asset_existing_dep.get_dependencies() + self.assertEqual(len(copied_deps_existing_dep), 2) + self.assertIn(dep1_uri, copied_deps_existing_dep) + self.assertIn(dep2_uri, copied_deps_existing_dep) + + copied_dep1_existing = cast(MockAssetWithDeps, copied_deps_existing_dep[dep1_uri]) + self.assertIsInstance(copied_dep1_existing, MockAssetWithDeps) + self.assertEqual( + copied_dep1_existing.get_data(), b'{"data": "dependency 1 data", "deps": []}' + ) # Should be the original data from source + + copied_dep2_existing = cast(MockAssetWithDeps, copied_deps_existing_dep[dep2_uri]) + self.assertIsInstance(copied_dep2_existing, MockAssetWithDeps) + self.assertEqual( + copied_dep2_existing.get_data(), b'{"data": "dependency 2 data", "deps": []}' + ) # Should be the newly copied raw data + + # Test handling of existing top-level asset in the destination store (should be overwritten) + # This was implicitly tested in the previous step where the top-level asset's + # version was incremented. Let's add a more explicit test. + overwrite_src_data = b'{"data": "overwrite source data", "deps": []}' + overwrite_src_uri = manager.add_raw( + MockAssetWithDeps.asset_type, + "overwrite_source_id", + overwrite_src_data, + "memory_deepcopy_src", + ) + + # Add an asset to the destination store with the same URI as overwrite_src_uri + existing_dest_data = b'{"data": "existing destination data", "deps": []}' + existing_dest_uri = manager.add_raw( + overwrite_src_uri.asset_type, + overwrite_src_uri.asset_id, + existing_dest_data, + "memory_deepcopy_dest", + ) + self.assertEqual(existing_dest_uri.version, "1") + + # Deep copy overwrite_src_uri to the existing destination URI + copied_uri_overwrite_top = manager.deepcopy( + overwrite_src_uri, + dest_store="memory_deepcopy_dest", + store="memory_deepcopy_src", + dest=existing_dest_uri, + ) + # The version should be incremented + self.assertEqual(copied_uri_overwrite_top.version, "2") + + # Verify the destination now contains the source data + retrieved_overwritten_top = manager.get_raw( + copied_uri_overwrite_top, store="memory_deepcopy_dest" + ) + # Need to parse the data to get the actual content + import json + + retrieved_data_dict = json.loads(retrieved_overwritten_top.decode("utf-8")) + self.assertEqual(retrieved_data_dict.get("data"), b"overwrite source data".decode("utf-8")) + + # Test error handling for non-existent source asset + non_existent_src_uri = AssetUri.build(MockAssetWithDeps.asset_type, "non_existent_src", "1") + with self.assertRaises(FileNotFoundError) as cm: + manager.deepcopy( + non_existent_src_uri, dest_store="memory_deepcopy_dest", store="memory_deepcopy_src" + ) + self.assertIn("Source asset", str(cm.exception)) + self.assertIn("not found", str(cm.exception)) + + # Test error handling for non-existent source store + with self.assertRaises(ValueError) as cm: + manager.deepcopy(src_uri, dest_store="memory_deepcopy_dest", store="non_existent_store") + self.assertIn("Source store", str(cm.exception)) + self.assertIn("not registered", str(cm.exception)) + + # Test error handling for non-existent destination store + with self.assertRaises(ValueError) as cm: + manager.deepcopy(src_uri, dest_store="non_existent_store", store="memory_deepcopy_src") + self.assertIn("Destination store", str(cm.exception)) + self.assertIn("not registered", str(cm.exception)) + + def test_exists(self): + # Setup AssetManager with a MemoryStore + memory_store = MemoryStore("memory_exists") + manager = AssetManager() + manager.register_store(memory_store) + + # Create an asset + test_uri = manager.add_raw("test_type", "test_id", b"data", "memory_exists") + + # Test exists for an existing asset + self.assertTrue(manager.exists(test_uri, store="memory_exists")) + + # Test exists for a non-existent asset + non_existent_uri = AssetUri.build("test_type", "non_existent_id", "1") + self.assertFalse(manager.exists(non_existent_uri, store="memory_exists")) + + # Test exists for a non-existent store (should raise ValueError) + with self.assertRaises(ValueError) as cm: + manager.exists(test_uri, 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/TestPathToolBit.py b/src/Mod/CAM/CAMTests/TestPathToolBit.py index d5b9fbe758..126fe763c5 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBit.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBit.py @@ -62,7 +62,7 @@ class TestPathToolBit(PathTestWithAssets): # 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")) + self.assertEqual(bullnose_bit.obj.CornerRadius, FreeCAD.Units.Quantity("1.5 mm")) def testToolBitPickle(self): """Test if ToolBit is picklable""" diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py index df4dc9ae23..2a839c4317 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py @@ -62,8 +62,8 @@ class TestToolBitBrowserWidget(PathTestWithAssets): search_term = "Endmill" self.widget._search_edit.setText(search_term) - # Directly trigger the fetch and filtering logic - self.widget._trigger_fetch() + # Directly trigger the filtering logic + self.widget._update_list() # Verify that the filter was applied to the list widget # We can check if items are hidden/shown based on the filter term @@ -100,28 +100,6 @@ class TestToolBitBrowserWidget(PathTestWithAssets): 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) diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py index 4b358e9d13..182695b51f 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py @@ -22,7 +22,9 @@ """Unit tests for the ToolBitListWidget.""" +from typing import cast import unittest +from Path.Tool.toolbit import ToolBit 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 @@ -37,7 +39,7 @@ class TestToolBitListWidget(PathTestWithAssets): def test_add_toolbit(self): # Get a real ToolBit asset - toolbit = self.assets.get("toolbit://5mm_Endmill") + toolbit = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) tool_no = 1 self.widget.add_toolbit(toolbit, str(tool_no)) @@ -61,8 +63,8 @@ class TestToolBitListWidget(PathTestWithAssets): def test_clear_list(self): # Add some real items first - toolbit1 = self.assets.get("toolbit://5mm_Endmill") - toolbit2 = self.assets.get("toolbit://slittingsaw") + toolbit1 = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) + toolbit2 = cast(ToolBit, self.assets.get("toolbit://slittingsaw")) self.widget.add_toolbit(toolbit1, 1) self.widget.add_toolbit(toolbit2, 2) self.assertEqual(self.widget.count(), 2) @@ -72,9 +74,9 @@ class TestToolBitListWidget(PathTestWithAssets): 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") + toolbit1 = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) + toolbit2 = cast(ToolBit, self.assets.get("toolbit://slittingsaw")) + toolbit3 = cast(ToolBit, self.assets.get("toolbit://probe")) self.widget.add_toolbit(toolbit1, 1) self.widget.add_toolbit(toolbit2, 2) @@ -117,8 +119,8 @@ class TestToolBitListWidget(PathTestWithAssets): 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") + toolbit1 = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) + toolbit2 = cast(ToolBit, self.assets.get("toolbit://slittingsaw")) self.widget.add_toolbit(toolbit1, 1) self.widget.add_toolbit(toolbit2, 2) diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py index b2d36cb7ca..fbc8cf16fa 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py @@ -1,3 +1,4 @@ +import yaml import json from typing import Type, cast import FreeCAD @@ -6,6 +7,7 @@ from Path.Tool.toolbit import ToolBit, ToolBitEndmill from Path.Tool.toolbit.serializers import ( FCTBSerializer, CamoticsToolBitSerializer, + YamlToolBitSerializer, ) from Path.Tool.assets.asset import Asset from Path.Tool.assets.serializer import AssetSerializer @@ -132,3 +134,72 @@ class TestFCTBSerializer(_BaseToolBitSerializerTestCase): 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") + + +class TestYamlToolBitSerializer(_BaseToolBitSerializerTestCase): + serializer_class = YamlToolBitSerializer + + def test_serialize(self): + super().test_serialize() + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + # YAML specific assertions + data = yaml.safe_load(serialized_data.decode("utf-8")) + self.assertEqual(data.get("id"), "5mm_Endmill") + self.assertEqual(data.get("name"), "Test Tool") + self.assertEqual(data.get("shape"), "endmill.fcstd") + self.assertEqual(data.get("shape-type"), "Endmill") + self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm") + self.assertEqual(data.get("parameter", {}).get("Length"), "15.00 mm") + + def test_extract_dependencies(self): + """Test dependency extraction for YAML.""" + yaml_data = ( + b"name: Test Tool\n" + b"shape: endmill\n" + b"shape-type: Endmill\n" + b"parameter:\n" + b" Diameter: 4.12 mm\n" + b" Length: 15.0 mm\n" + b"attribute: {}\n" + ) + dependencies = self.serializer_class.extract_dependencies(yaml_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 YAML format + yaml_data = ( + b"id: TestID\n" + b"name: Test Tool\n" + b"shape: endmill\n" + b"shape-type: Endmill\n" + b"parameter:\n" + b" Diameter: 4.12 mm\n" + b" Length: 15.0 mm\n" + b"attribute: {}\n" + ) + # 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(yaml_data, "TestID", dependencies=dependencies), + ) + self.assertIsInstance(deserialized_bit, ToolBit) + self.assertEqual(deserialized_bit.id, "TestID") + 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") + + # Test with ID argument. + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(yaml_data, id="test_id", dependencies=dependencies), + ) + self.assertEqual(deserialized_bit.id, "test_id") diff --git a/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py b/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py index ce578ddd27..597175c117 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py +++ b/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py @@ -144,13 +144,13 @@ class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase): # Verify the content format (basic check) lines = serialized_data.decode("ascii", "ignore").strip().split("\n") self.assertEqual(len(lines), 3) - self.assertEqual(lines[0], "T1 P0 D6.000 ;Endmill 6mm") - self.assertEqual(lines[1], "T2 P0 D3.000 ;Endmill 3mm") - self.assertEqual(lines[2], "T3 P0 D5.000 ;Ballend 5mm") + self.assertEqual(lines[0], "T1 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D6.00 I0 J0 Q0 ;Endmill 6mm") + self.assertEqual(lines[1], "T2 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D3.00 I0 J0 Q0 ;Endmill 3mm") + self.assertEqual(lines[2], "T3 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D5.00 I0 J0 Q0 ;Ballend 5mm") def test_linuxcnc_deserialize_not_implemented(self): serializer = LinuxCNCSerializer - dummy_data = b"T1 D6.0 ;Endmill 6mm\n" + dummy_data = b"T1 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D6.00 I0 J0 Q0 ;Endmill 6mm\n" with self.assertRaises(NotImplementedError): serializer.deserialize(dummy_data, "dummy_id", {}) diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py index dc09140ef7..c473c99a4b 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py @@ -153,8 +153,8 @@ class TestPathToolShapeClasses(PathTestWithAssets): self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend") self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "v-bit") self.assertEqual(ToolBitShape.resolve_name("vbit").asset_id, "vbit") - self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "torus") - self.assertEqual(ToolBitShape.resolve_name("torus.fcstd").asset_id, "torus") + self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose") + self.assertEqual(ToolBitShape.resolve_name("bullnose.fcstd").asset_id, "bullnose") 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") @@ -336,12 +336,12 @@ class TestPathToolShapeClasses(PathTestWithAssets): 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") + self.assertEqual(shape["CornerRadius"].Value, 1.5) + self.assertEqual(unit(shape["CornerRadius"]), "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") + self.assertEqual(instance.get_parameter_label("CornerRadius"), "Corner radius") def test_toolbitshapevbit_defaults(self): """Test ToolBitShapeVBit default parameters and labels.""" diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index b2e1e55ad7..5cc41ed1b3 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -183,7 +183,7 @@ SET(PathPythonToolsToolBitModels_SRCS 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/radius.py Path/Tool/toolbit/models/probe.py Path/Tool/toolbit/models/reamer.py Path/Tool/toolbit/models/slittingsaw.py @@ -196,6 +196,7 @@ SET(PathPythonToolsToolBitSerializers_SRCS Path/Tool/toolbit/serializers/__init__.py Path/Tool/toolbit/serializers/camotics.py Path/Tool/toolbit/serializers/fctb.py + Path/Tool/toolbit/serializers/yaml.py ) SET(PathPythonToolsToolBitUi_SRCS @@ -208,6 +209,7 @@ SET(PathPythonToolsToolBitUi_SRCS Path/Tool/toolbit/ui/selector.py Path/Tool/toolbit/ui/tablecell.py Path/Tool/toolbit/ui/toollist.py + Path/Tool/toolbit/ui/util.py Path/Tool/toolbit/ui/view.py ) @@ -230,10 +232,11 @@ SET(PathPythonToolsLibrarySerializers_SRCS SET(PathPythonToolsLibraryUi_SRCS Path/Tool/library/ui/__init__.py + Path/Tool/library/ui/browser.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 + Path/Tool/library/ui/properties.py ) SET(PathPythonToolsMachine_SRCS @@ -261,7 +264,7 @@ SET(PathPythonToolsShapeModels_SRCS 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/radius.py Path/Tool/shape/models/icon.py Path/Tool/shape/models/probe.py Path/Tool/shape/models/reamer.py @@ -452,8 +455,8 @@ SET(Tools_Shape_SRCS Tools/Shape/drill.svg Tools/Shape/endmill.fcstd Tools/Shape/endmill.svg - Tools/Shape/fillet.fcstd - Tools/Shape/fillet.svg + Tools/Shape/radius.fcstd + Tools/Shape/radius.svg Tools/Shape/probe.fcstd Tools/Shape/probe.svg Tools/Shape/reamer.fcstd diff --git a/src/Mod/CAM/Gui/Resources/Path.qrc b/src/Mod/CAM/Gui/Resources/Path.qrc index 071ae97d00..e681f05236 100644 --- a/src/Mod/CAM/Gui/Resources/Path.qrc +++ b/src/Mod/CAM/Gui/Resources/Path.qrc @@ -62,6 +62,8 @@ icons/CAM_ToolDuplicate.svg icons/CAM_Toolpath.svg icons/CAM_ToolTable.svg + icons/CAM_ToolTableAdd.svg + icons/CAM_ToolTableRemove.svg icons/CAM_Vcarve.svg icons/CAM_Waterline.svg icons/arrow-ccw.svg @@ -93,6 +95,7 @@ panels/DragKnifeEdit.ui panels/DressUpLeadInOutEdit.ui panels/HoldingTagsEdit.ui + panels/LibraryProperties.ui panels/PageBaseGeometryEdit.ui panels/PageBaseHoleGeometryEdit.ui panels/PageBaseLocationEdit.ui diff --git a/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg new file mode 100644 index 0000000000..4b7103e0dd --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg @@ -0,0 +1,1084 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + CAM_ToolTable + 2015-07-04 + https://www.freecad.org/wiki/index.php?title=Artwork + + + FreeCAD + + + FreeCAD/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTable.svg + + + FreeCAD LGPL2+ + + + https://www.gnu.org/copyleft/lesser.html + + + [agryson] Alexander Gryson + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg new file mode 100644 index 0000000000..044fb05b24 --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg @@ -0,0 +1,1084 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + CAM_ToolTable + 2015-07-04 + https://www.freecad.org/wiki/index.php?title=Artwork + + + FreeCAD + + + FreeCAD/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTable.svg + + + FreeCAD LGPL2+ + + + https://www.gnu.org/copyleft/lesser.html + + + [agryson] Alexander Gryson + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui new file mode 100644 index 0000000000..d520c74738 --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui @@ -0,0 +1,77 @@ + + + LibraryProperties + + + + 0 + 0 + 400 + 189 + + + + Library Property Editor + + + + QLayout::SetMinimumSize + + + + + QLayout::SetMaximumSize + + + 0 + + + 0 + + + + + Name + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + QLayout::SetMinimumSize + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui index 667bc2755a..b1f31d1dd5 100644 --- a/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/PageOpAdaptiveEdit.ui @@ -50,19 +50,19 @@ - QFrame::Shape::StyledPanel + QFrame::StyledPanel - QFrame::Shadow::Raised + QFrame::Raised - QFrame::Shape::StyledPanel + QFrame::StyledPanel - QFrame::Shadow::Raised + QFrame::Raised @@ -92,7 +92,7 @@ Larger values (further to the right) will calculate faster; smaller values (furt 10 - Qt::Orientation::Horizontal + Qt::Horizontal 1 @@ -318,7 +318,7 @@ This option changes that behavior to cut each discrete area to its full depth be - Qt::Orientation::Vertical + Qt::Vertical diff --git a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui index da9f2dbc17..741e952ec4 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui @@ -11,17 +11,17 @@ - Tool Shape Selection + Toolbit Shape Selection - - - 1 + + + true - + 0 @@ -30,22 +30,6 @@ 487 - - Standard tools - - - - - - 0 - 0 - 880 - 487 - - - - My tools - diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui index eff7e73369..12cc1b8a7a 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui @@ -11,7 +11,7 @@ - Tool Parameter Editor + Toolbit Parameter Editor @@ -62,7 +62,7 @@ - Tool + Toolbit diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui index 5fe79475eb..e353773e8a 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui @@ -13,275 +13,234 @@ Library Manager - + - + - - - Qt::Horizontal + + + QLayout::SetMinimumSize - - - 20 - 20 - - - + + + + Adds a new library + + + + :/icons/CAM_ToolTableAdd.svg:/icons/CAM_ToolTableAdd.svg + + + + + + + Removes the library + + + + :/icons/CAM_ToolTableRemove.svg:/icons/CAM_ToolTableRemove.svg + + + + + + + Renames the library + + + + :/icons/edit-edit.svg:/icons/edit-edit.svg + + + + + + + Imports a library + + + + :/icons/Std_Import.svg:/icons/Std_Import.svg + + + + + + + Exports the library + + + + :/icons/Std_Export.svg:/icons/Std_Export.svg + + + + - - - Create Toolbit - - - - :/icons/CAM_ToolBit.svg:/icons/CAM_ToolBit.svg - - - - - - - - 16777215 - 16777215 - - - - Adds the existing tool bit to the library - - - Add Existing - - - - :/icons/CAM_ToolDuplicate.svg:/icons/CAM_ToolDuplicate.svg - - - - - - - - 16777215 - 16777215 - - - - Deletes the selected tool bits from the library - - - Remove - - - - :/icons/list-remove.svg:/icons/list-remove.svg - - - - - - - - - - - - - - - 16777215 - 16777215 - - - - Add new tool table - - - - - - - :/icons/document-new.svg:/icons/document-new.svg - - - - 24 - 24 - - - - - - - - - Save the selected library with a new name or export to another format - - - - - - - :/icons/Std_Export.svg:/icons/Std_Export.svg - - - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Save the current library - - - - - - - :/icons/document-save.svg:/icons/document-save.svg - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - 0 - 0 - - - - QFrame::Box - - - QAbstractItemView::NoEditTriggers - - - false - - - - - - - true - - - Table of tool bits of the library - - - QFrame::Box - - - QFrame::Sunken - - - 1 - - - 0 - - - false - - - false - - - QAbstractItemView::DragOnly - - - Qt::IgnoreAction - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - true - - - false - - - true - - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 65555555 - 20 - - - - - - + - + 0 0 - + - 16777215 - 16777215 + 2 + 0 + + QFrame::VLine + + + QFrame::Sunken + + + + + + + QLayout::SetMinimumSize + + + + + Adds a toolbit + + + + :/icons/CAM_ToolBit.svg:/icons/CAM_ToolBit.svg + + + + + + + Imports a toolbit + + + + :/icons/Std_Import.svg:/icons/Std_Import.svg + + + + + + + Exports the toolbit + + + + :/icons/Std_Export.svg:/icons/Std_Export.svg + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + true + + + Qt::StrongFocus + + + true + + + QFrame::Box + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::DropOnly + + + Qt::IgnoreAction + + + + + + + true + - Close the library editor + Table of tool bits of the library - - Close + + QFrame::Box - - - :/icons/edit_OK.svg:/icons/edit_OK.svg + + QFrame::Sunken + + 1 + + + 0 + + + false + + + false + + + QAbstractItemView::DragOnly + + + Qt::IgnoreAction + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + false + + + true + diff --git a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui index 30472ceabe..73482d95e4 100644 --- a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui +++ b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui @@ -24,8 +24,8 @@ 0 0 - 681 - 370 + 695 + 308 @@ -38,37 +38,7 @@ Defaults - - - - Path - - - - - - Path to look for templates, post processors, tool tables and other external files. - -If left empty the macro directory is used. - - - - - - - - - - - - - - Template - - - - The default template to be selected when creating a new job. @@ -79,7 +49,14 @@ If left empty no template will be preselected. - + + + + Template + + + + @@ -146,8 +123,8 @@ If left empty no template will be preselected. 0 0 - 681 - 518 + 695 + 480 @@ -362,8 +339,8 @@ See the file save policy below on how to deal with name conflicts. 0 0 - 662 - 755 + 674 + 619 @@ -634,46 +611,6 @@ See the file save policy below on how to deal with name conflicts. - - - - 0 - 0 - 681 - 171 - - - - Tools - - - - - - References to tool bits and their shapes can either be stored with an absolute path or with a relative path to the search path. -Generally it is recommended to use relative paths due to their flexibility and robustness to layout changes. -Should multiple tools or tool shapes with the same name exist in different directories it can be required to use absolute paths. - - - Store Absolute Paths - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - @@ -700,8 +637,6 @@ Should multiple tools or tool shapes with the same name exist in different direc - leDefaultFilePath - tbDefaultFilePath leDefaultJobTemplate tbDefaultJobTemplate geometryTolerance diff --git a/src/Mod/CAM/Path/Base/Gui/PropertyBag.py b/src/Mod/CAM/Path/Base/Gui/PropertyBag.py index f6bd417eae..f25b11e03a 100644 --- a/src/Mod/CAM/Path/Base/Gui/PropertyBag.py +++ b/src/Mod/CAM/Path/Base/Gui/PropertyBag.py @@ -150,7 +150,6 @@ class PropertyCreate(object): self.form.propertyEnum.textChanged.connect(self.updateUI) def updateUI(self): - typeSet = True if self.propertyIsEnumeration(): self.form.labelEnum.setEnabled(True) @@ -239,7 +238,17 @@ class TaskPanel(object): pass def _setupProperty(self, i, name): - typ = PathPropertyBag.getPropertyTypeName(self.obj.getTypeIdOfProperty(name)) + if name not in self.obj.PropertiesList: + Path.Log.warning(f"Property '{name}' not found in object {self.obj.Name}") + return + prop_type_id = self.obj.getTypeIdOfProperty(name) + try: + typ = PathPropertyBag.getPropertyTypeName(prop_type_id) + except IndexError: + Path.Log.error( + f"Unknown property type id '{prop_type_id}' for property '{name}' in object {self.obj.Name}" + ) + return val = PathUtil.getPropertyValueString(self.obj, name) info = self.obj.getDocumentationOfProperty(name) diff --git a/src/Mod/CAM/Path/Base/PropertyBag.py b/src/Mod/CAM/Path/Base/PropertyBag.py index e5f2657112..7cfa299120 100644 --- a/src/Mod/CAM/Path/Base/PropertyBag.py +++ b/src/Mod/CAM/Path/Base/PropertyBag.py @@ -68,12 +68,14 @@ class PropertyBag(object): CustomPropertyGroupDefault = "User" def __init__(self, obj): - obj.addProperty( - "App::PropertyStringList", - self.CustomPropertyGroups, - "Base", - QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"), - ) + # Always add as enumeration + if not hasattr(obj, self.CustomPropertyGroups): + obj.addProperty( + "App::PropertyEnumeration", + self.CustomPropertyGroups, + "Base", + QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"), + ) self.onDocumentRestored(obj) def dumps(self): @@ -96,15 +98,40 @@ class PropertyBag(object): def onDocumentRestored(self, obj): self.obj = obj - obj.setEditorMode(self.CustomPropertyGroups, 2) # hide + cpg = getattr(obj, self.CustomPropertyGroups, None) + # If it's a string list, convert to enum + if isinstance(cpg, list): + vals = cpg + try: + obj.removeProperty(self.CustomPropertyGroups) + except Exception: + # Removing the property may fail if it does not exist; safe to ignore in this context. + pass + obj.addProperty( + "App::PropertyEnumeration", + self.CustomPropertyGroups, + "Base", + QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"), + ) + if hasattr(obj, "setEnumerationsOfProperty"): + obj.setEnumerationsOfProperty(self.CustomPropertyGroups, vals) + else: + # Fallback: set the property value directly (may not work in all FreeCAD versions) + setattr(obj, self.CustomPropertyGroups, vals) + if hasattr(obj, "setEditorMode"): + obj.setEditorMode(self.CustomPropertyGroups, 2) # hide + elif hasattr(obj, "getEnumerationsOfProperty"): + if hasattr(obj, "setEditorMode"): + obj.setEditorMode(self.CustomPropertyGroups, 2) # hide def getCustomProperties(self): - """getCustomProperties() ... Return a list of all custom properties created in this container.""" - return [ - p - for p in self.obj.PropertiesList - if self.obj.getGroupOfProperty(p) in self.obj.CustomPropertyGroups - ] + """Return a list of all custom properties created in this container.""" + groups = [] + if hasattr(self.obj, "getEnumerationsOfProperty"): + groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups)) + else: + groups = list(getattr(self.obj, self.CustomPropertyGroups, [])) + return [p for p in self.obj.PropertiesList if self.obj.getGroupOfProperty(p) in groups] def addCustomProperty(self, propertyType, name, group=None, desc=None): """addCustomProperty(propertyType, name, group=None, desc=None) ... adds a custom property and tracks its group.""" @@ -112,15 +139,23 @@ class PropertyBag(object): desc = "" if group is None: group = self.CustomPropertyGroupDefault - groups = self.obj.CustomPropertyGroups + + # Always use enum for groups + if hasattr(self.obj, "getEnumerationsOfProperty"): + groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups)) + else: + groups = list(getattr(self.obj, self.CustomPropertyGroups, [])) name = self.__sanitizePropertyName(name) if not re.match("^[A-Za-z0-9_]*$", name): raise ValueError("Property Name can only contain letters and numbers") - if not group in groups: + if group not in groups: groups.append(group) - self.obj.CustomPropertyGroups = groups + if hasattr(self.obj, "setEnumerationsOfProperty"): + self.obj.setEnumerationsOfProperty(self.CustomPropertyGroups, groups) + else: + setattr(self.obj, self.CustomPropertyGroups, groups) self.obj.addProperty(propertyType, name, group, desc) return name @@ -129,9 +164,16 @@ class PropertyBag(object): customGroups = [] for p in self.obj.PropertiesList: group = self.obj.getGroupOfProperty(p) - if group in self.obj.CustomPropertyGroups and not group in customGroups: + if hasattr(self.obj, "getEnumerationsOfProperty"): + groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups)) + else: + groups = list(getattr(self.obj, self.CustomPropertyGroups, [])) + if group in groups and group not in customGroups: customGroups.append(group) - self.obj.CustomPropertyGroups = customGroups + if hasattr(self.obj, "setEnumerationsOfProperty"): + self.obj.setEnumerationsOfProperty(self.CustomPropertyGroups, customGroups) + else: + setattr(self.obj, self.CustomPropertyGroups, customGroups) def Create(name="PropertyBag"): diff --git a/src/Mod/CAM/Path/Base/Util.py b/src/Mod/CAM/Path/Base/Util.py index 33beb423e1..4905b8abda 100644 --- a/src/Mod/CAM/Path/Base/Util.py +++ b/src/Mod/CAM/Path/Base/Util.py @@ -78,10 +78,10 @@ def setProperty(obj, prop, value): """setProperty(obj, prop, value) ... set the property value of obj's property defined by its canonical name.""" o, attr, name = _getProperty(obj, prop) if attr is not None and isinstance(value, str): - if isinstance(attr, int): - value = int(value, 0) - elif isinstance(attr, bool): + if isinstance(attr, bool): value = value.lower() in ["true", "1", "yes", "ok"] + elif isinstance(attr, int): + value = int(value, 0) if o and name: setattr(o, name, value) diff --git a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py index 2b98f123b3..2aa5a167e8 100644 --- a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py +++ b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py @@ -45,11 +45,10 @@ class JobPreferencesPage: self.processor = {} def saveSettings(self): - filePath = self.form.leDefaultFilePath.text() jobTemplate = self.form.leDefaultJobTemplate.text() geometryTolerance = Units.Quantity(self.form.geometryTolerance.text()) curveAccuracy = Units.Quantity(self.form.curveAccuracy.text()) - Path.Preferences.setJobDefaults(filePath, jobTemplate, geometryTolerance, curveAccuracy) + Path.Preferences.setJobDefaults(jobTemplate, geometryTolerance, curveAccuracy) if curveAccuracy: Path.Area.setDefaultParams(Accuracy=curveAccuracy) @@ -146,7 +145,6 @@ class JobPreferencesPage: ) def loadSettings(self): - self.form.leDefaultFilePath.setText(Path.Preferences.defaultFilePath()) self.form.leDefaultJobTemplate.setText(Path.Preferences.defaultJobTemplate()) blacklist = Path.Preferences.postProcessorBlacklist() @@ -175,7 +173,6 @@ class JobPreferencesPage: self.form.leOutputFile.setText(Path.Preferences.defaultOutputFile()) self.selectComboEntry(self.form.cboOutputPolicy, Path.Preferences.defaultOutputPolicy()) - self.form.tbDefaultFilePath.clicked.connect(self.browseDefaultFilePath) self.form.tbDefaultJobTemplate.clicked.connect(self.browseDefaultJobTemplate) self.form.postProcessorList.itemEntered.connect(self.setProcessorListTooltip) self.form.postProcessorList.itemChanged.connect(self.verifyAndUpdateDefaultPostProcessor) @@ -311,7 +308,8 @@ class JobPreferencesPage: self.form.defaultPostProcessorArgs.setToolTip(self.postProcessorArgsDefaultTooltip) def bestGuessForFilePath(self): - path = self.form.leDefaultFilePath.text() + + path = Path.Preferences.defaultFilePath() if not path: path = Path.Preferences.filePath() return path @@ -326,14 +324,6 @@ class JobPreferencesPage: if foo: self.form.leDefaultJobTemplate.setText(foo) - def browseDefaultFilePath(self): - path = self.bestGuessForFilePath() - foo = QtGui.QFileDialog.getExistingDirectory( - QtGui.QApplication.activeWindow(), "Path - External File Directory", path - ) - if foo: - self.form.leDefaultFilePath.setText(foo) - def browseOutputFile(self): path = self.form.leOutputFile.text() foo = QtGui.QFileDialog.getExistingDirectory( diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 7f784fa4f6..965b896c3c 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -54,6 +54,7 @@ PostProcessorOutputPolicy = "PostProcessorOutputPolicy" ToolGroup = PreferencesGroup + "/Tools" ToolPath = "ToolPath" LastToolLibrary = "LastToolLibrary" +LastToolLibrarySortKey = "LastToolLibrarySortKey" # Linear tolerance to use when generating Paths, eg when tessellating geometry GeometryTolerance = "GeometryTolerance" @@ -123,22 +124,38 @@ def getDefaultAssetPath() -> Path: def getAssetPath() -> pathlib.Path: pref = tool_preferences() + + # Check if we have a CamAssets path already set + cam_assets_path = pref.GetString(ToolPath, "") + if cam_assets_path: + return pathlib.Path(cam_assets_path) + + # Migration: Check for legacy DefaultFilePath and use it for CamAssets + legacy_path = defaultFilePath() + if legacy_path: + legacy_path_obj = pathlib.Path(legacy_path) + if legacy_path_obj.exists() and legacy_path_obj.is_dir(): + # Migrate: Set the legacy path as the new CamAssets path + setAssetPath(legacy_path_obj) + return legacy_path_obj + + # Fallback to default if no legacy path found default = getDefaultAssetPath() - path = pref.GetString(ToolPath, str(default)) - return pathlib.Path(path or default) + return pathlib.Path(default) def setAssetPath(path: pathlib.Path): assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}" - if str(path) == str(getAssetPath()): - return pref = tool_preferences() + current_path = pref.GetString(ToolPath, "") + if str(path) == current_path: + return pref.SetString(ToolPath, str(path)) _emit_change(ToolGroup, ToolPath, path) def getToolBitPath() -> pathlib.Path: - return getAssetPath() / "Bit" + return getAssetPath() / "Tools" / "Bit" def getLastToolLibrary() -> Optional[str]: @@ -152,6 +169,16 @@ def setLastToolLibrary(name: str): pref.SetString(LastToolLibrary, name) +def getLastToolLibrarySortKey() -> Optional[str]: + pref = tool_preferences() + return pref.GetString(LastToolLibrarySortKey) or None + + +def setLastToolLibrarySortKey(name: str): + pref = tool_preferences() + pref.SetString(LastToolLibrarySortKey, name) + + def allAvailablePostProcessors(): allposts = [] for path in searchPathsPost(): @@ -201,7 +228,7 @@ def defaultFilePath(): def filePath(): path = defaultFilePath() if not path: - path = macroFilePath() + path = getAssetPath() return path @@ -237,13 +264,9 @@ def defaultJobTemplate(): return "" -def setJobDefaults(fileName, jobTemplate, geometryTolerance, curveAccuracy): - Path.Log.track( - "(%s='%s', %s, %s, %s)" - % (DefaultFilePath, fileName, jobTemplate, geometryTolerance, curveAccuracy) - ) +def setJobDefaults(jobTemplate, geometryTolerance, curveAccuracy): + Path.Log.track("(%s, %s, %s)" % (jobTemplate, geometryTolerance, curveAccuracy)) pref = preferences() - pref.SetString(DefaultFilePath, fileName) pref.SetString(DefaultJobTemplate, jobTemplate) pref.SetFloat(GeometryTolerance, geometryTolerance) pref.SetFloat(LibAreaCurveAccuracy, curveAccuracy) diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py index 9095a6ec15..e96cc8e0b0 100644 --- a/src/Mod/CAM/Path/Tool/assets/manager.py +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -46,7 +46,7 @@ from .cache import AssetCache, CacheKey logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.ERROR) @dataclass @@ -107,6 +107,12 @@ class AssetManager: visited_uris: Set[AssetUri], depth: Optional[int] = None, ) -> Optional[_AssetConstructionData]: + # Log library fetch details + if uri.asset_type == "library": + logger.info( + f"LIBRARY FETCH: Loading library '{uri.asset_id}' with depth={depth} from stores {store_names}" + ) + logger.debug( f"_fetch_asset_construction_data_recursive_async called {store_names} {uri} {depth}" ) @@ -126,29 +132,59 @@ class AssetManager: # Fetch the requested asset, trying each store in order raw_data = None found_store_name = None + + # Log toolbit search details + if uri.asset_type == "toolbit": + logger.info( + f"TOOLBIT SEARCH: Looking for toolbit '{uri.asset_id}' in stores: {store_names}" + ) + for current_store_name in store_names: store = self.stores.get(current_store_name) if not store: logger.warning(f"Store '{current_store_name}' not registered. Skipping.") continue + # Log store search path for toolbits + if uri.asset_type == "toolbit": + store_path = getattr(store, "base_path", "unknown") + logger.info( + f"TOOLBIT SEARCH: Checking store '{current_store_name}' at path: {store_path}" + ) + try: raw_data = await store.get(uri) found_store_name = current_store_name + if uri.asset_type == "toolbit": + logger.info( + f"TOOLBIT FOUND: '{uri.asset_id}' found in store '{found_store_name}'" + ) logger.debug( f"_fetch_asset_construction_data_recursive_async: Asset {uri} found in store {found_store_name}" ) break # Asset found, no need to check other stores except FileNotFoundError: + if uri.asset_type == "toolbit": + logger.info( + f"TOOLBIT SEARCH: '{uri.asset_id}' NOT found in store '{current_store_name}'" + ) logger.debug( f"_fetch_asset_construction_data_recursive_async: Asset {uri} not found in store {current_store_name}" ) continue # Try next store - if raw_data is None: + if raw_data is None or not found_store_name: + if uri.asset_type == "toolbit": + logger.warning( + f"TOOLBIT NOT FOUND: '{uri.asset_id}' not found in any of the stores: {store_names}" + ) return None # Asset not found in any store if depth == 0: + if uri.asset_type == "library": + logger.warning( + f"LIBRARY SHALLOW: Library '{uri.asset_id}' loaded with depth=0 - no dependencies will be resolved" + ) return _AssetConstructionData( store=found_store_name, uri=uri, @@ -194,7 +230,6 @@ class AssetManager: 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 @@ -209,7 +244,7 @@ class AssetManager: raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16) return CacheKey( - store_name=store_name_for_cache, + store_name=construction_data.store, asset_uri_str=str(construction_data.uri), raw_data_hash=raw_data_hash, dependency_signature=deps_signature_tuple, @@ -218,8 +253,7 @@ class AssetManager: def _build_asset_tree_from_data_sync( self, construction_data: Optional[_AssetConstructionData], - store_name_for_cache: str, - ) -> Asset | None: + ) -> Optional[Asset]: """ Synchronously and recursively builds an asset instance. Integrates caching logic. @@ -228,10 +262,8 @@ class AssetManager: 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 construction_data.store in self._cacheable_stores: + cache_key = self._calculate_cache_key_from_construction_data(construction_data) if cache_key: cached_asset = self.asset_cache.get(cache_key) if cached_asset is not None: @@ -245,18 +277,42 @@ class AssetManager: resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None if construction_data.dependencies_data is not None: resolved_dependencies = {} + + # Log dependency resolution for libraries + if construction_data.uri.asset_type == "library": + logger.info( + f"LIBRARY DEPS: Resolving {len(construction_data.dependencies_data)} dependencies for library '{construction_data.uri.asset_id}'" + ) + for ( dep_uri, dep_data_node, ) in construction_data.dependencies_data.items(): + # Log toolbit dependency resolution + if dep_uri.asset_type == "toolbit": + logger.info( + f"TOOLBIT DEP: Resolving dependency '{dep_uri.asset_id}' for library '{construction_data.uri.asset_id}'" + ) + # 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) + dep = self._build_asset_tree_from_data_sync(dep_data_node) + if dep_uri.asset_type == "toolbit": + if dep: + logger.info( + f"TOOLBIT DEP: Successfully resolved '{dep_uri.asset_id}' -> {type(dep).__name__}" + ) + else: + logger.warning( + f"TOOLBIT DEP: Dependency '{dep_uri.asset_id}' resolved to None" + ) except Exception as e: + if dep_uri.asset_type == "toolbit": + logger.error(f"TOOLBIT DEP: Error resolving '{dep_uri.asset_id}': {e}") logger.error( f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}", exc_info=True, @@ -264,9 +320,31 @@ class AssetManager: else: resolved_dependencies[dep_uri] = dep + # Log final dependency count for libraries + if construction_data.uri.asset_type == "library": + toolbit_deps = [ + uri for uri in resolved_dependencies.keys() if uri.asset_type == "toolbit" + ] + logger.info( + f"LIBRARY DEPS: Resolved {len(resolved_dependencies)} total dependencies ({len(toolbit_deps)} toolbits) for library '{construction_data.uri.asset_id}'" + ) + else: + # Log when dependencies_data is None + if construction_data.uri.asset_type == "library": + logger.warning( + f"LIBRARY NO DEPS: Library '{construction_data.uri.asset_id}' has dependencies_data=None - was loaded with depth=0" + ) + asset_class = construction_data.asset_class serializer = self.get_serializer_for_class(asset_class) try: + # Log library instantiation with dependency info + if construction_data.uri.asset_type == "library": + dep_count = len(resolved_dependencies) if resolved_dependencies else 0 + logger.info( + f"LIBRARY INSTANTIATE: Creating library '{construction_data.uri.asset_id}' with {dep_count} dependencies" + ) + final_asset = asset_class.from_bytes( construction_data.raw_data, construction_data.uri.asset_id, @@ -311,6 +389,24 @@ class AssetManager: # Log entry with thread info for verification calling_thread_name = threading.current_thread().name stores_list = [store] if isinstance(store, str) else store + + # Log all asset get requests + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + if asset_uri_obj.asset_type == "library": + logger.info( + f"LIBRARY GET: Request for library '{asset_uri_obj.asset_id}' with depth={depth}" + ) + elif asset_uri_obj.asset_type == "toolbit": + logger.info( + f"TOOLBIT GET: Direct request for toolbit '{asset_uri_obj.asset_id}' with depth={depth} from stores {stores_list}" + ) + # Add stack trace to see who's calling this + import traceback + + stack = traceback.format_stack() + caller_info = "".join(stack[-3:-1]) # Get the 2 frames before this one + logger.info(f"TOOLBIT GET CALLER:\n{caller_info}") + logger.debug( f"AssetManager.get(uri='{uri}', stores='{stores_list}', depth='{depth}') called from thread: {calling_thread_name}" ) @@ -365,10 +461,9 @@ class AssetManager: f"and {deps_count} dependencies ({found_deps_count} resolved)." ) # Use the first store from the list for caching purposes - store_name_for_cache = stores_list[0] if stores_list else "local" - final_asset = self._build_asset_tree_from_data_sync( - all_construction_data, store_name_for_cache=store_name_for_cache - ) + final_asset = self._build_asset_tree_from_data_sync(all_construction_data) + if not final_asset: + raise ValueError(f"failed to build asset {uri}") logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.") return final_asset @@ -377,7 +472,7 @@ class AssetManager: uri: Union[AssetUri, str], store: Union[str, Sequence[str]] = "local", depth: Optional[int] = None, - ) -> Asset | None: + ) -> Optional[Asset]: """ Convenience wrapper for get() that does not raise FileNotFoundError; returns None instead @@ -423,9 +518,7 @@ class AssetManager: 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 - ) + return self._build_asset_tree_from_data_sync(all_construction_data) def get_raw( self, @@ -438,31 +531,8 @@ class AssetManager: f"AssetManager.get_raw(uri='{uri}', stores='{stores_list}') from T:{threading.current_thread().name}" ) - async def _fetch_raw_async(stores_list: Sequence[str]): - asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri - logger.debug( - f"GetRawAsync (internal): Trying stores '{stores_list}'. Available stores: {list(self.stores.keys())}" - ) - for current_store_name in stores_list: - store = self.stores.get(current_store_name) - if not store: - logger.warning(f"Store '{current_store_name}' not registered. Skipping.") - continue - try: - raw_data = await store.get(asset_uri_obj) - logger.debug( - f"GetRawAsync: Asset {asset_uri_obj} found in store {current_store_name}" - ) - return raw_data - except FileNotFoundError: - logger.debug( - f"GetRawAsync: Asset {asset_uri_obj} not found in store {current_store_name}" - ) - continue - raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'") - try: - return asyncio.run(_fetch_raw_async(stores_list)) + return asyncio.run(self.get_raw_async(uri, stores_list)) except Exception as e: logger.error( f"GetRaw: Error during asyncio.run for '{uri}': {e}", @@ -483,12 +553,12 @@ class AssetManager: asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri for current_store_name in stores_list: - store = self.stores.get(current_store_name) - if not store: + thestore = self.stores.get(current_store_name) + if not thestore: logger.warning(f"Store '{current_store_name}' not registered. Skipping.") continue try: - raw_data = await store.get(asset_uri_obj) + raw_data = await thestore.get(asset_uri_obj) logger.debug( f"GetRawAsync: Asset {asset_uri_obj} found in store {current_store_name}" ) @@ -551,12 +621,7 @@ class AssetManager: elif isinstance(data_or_exc, _AssetConstructionData): # Build asset instance synchronously. Exceptions during build should propagate. # Use the first store from the list for caching purposes in build_asset_tree - store_name_for_cache = stores_list[0] if stores_list else "local" - assets.append( - self._build_asset_tree_from_data_sync( - data_or_exc, store_name_for_cache=store_name_for_cache - ) - ) + assets.append(self._build_asset_tree_from_data_sync(data_or_exc)) 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) @@ -596,12 +661,8 @@ class AssetManager: for i, data_or_exc in enumerate(all_construction_data_list): if isinstance(data_or_exc, _AssetConstructionData): # Use the first store from the list for caching purposes in build_asset_tree - store_name_for_cache = stores_list[0] if stores_list else "local" - assets.append( - self._build_asset_tree_from_data_sync( - data_or_exc, store_name_for_cache=store_name_for_cache - ) - ) + asset = self._build_asset_tree_from_data_sync(data_or_exc) + assets.append(asset) elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None: assets.append(None) elif isinstance(data_or_exc, Exception): @@ -625,8 +686,8 @@ class AssetManager: for current_store_name in stores_list: store = self.stores.get(current_store_name) if not store: - logger.warning(f"Store '{current_store_name}' not registered. Skipping.") - continue + logger.error(f"Store '{current_store_name}' not registered. Skipping.") + raise ValueError(f"No store registered for name: {store}") try: exists = await store.exists(asset_uri_obj) if exists: @@ -840,12 +901,191 @@ class AssetManager: ) raise + async def copy_async( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Copies an asset from one location to another asynchronously. + + Performs a shallow copy by wrapping get_raw_async and add_raw_async. + If dest is None, it defaults to the uri given in src. + An assertion is raised if src and store are the same as dest and + dest_store. + If the destination already exists it should be silently overwritten. + """ + if dest is None: + dest = src + + if src == dest and store == dest_store: + raise ValueError("Source and destination cannot be the same asset in the same store.") + + raw_data = await self.get_raw_async(src, store) + return await self.add_raw_async(dest.asset_type, dest.asset_id, raw_data, dest_store) + + def copy( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Copies an asset from one location to another synchronously. + + Performs a shallow copy by wrapping get_raw and add_raw. + If dest is None, it defaults to the uri given in src. + An assertion is raised if src and store are the same as dest and + dest_store. + If the destination already exists it should be silently overwritten. + """ + return asyncio.run(self.copy_async(src, dest_store, store, dest)) + + async def deepcopy_async( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Asynchronously deep copies an asset and its dependencies from a source store + to a destination store. + + Args: + src: The AssetUri of the source asset. + dest_store: The name of the destination store. + store: The name of the source store (defaults to "local"). + dest: Optional. The new AssetUri for the top-level asset in the + destination store. If None, the original URI is used. + + Returns: + The AssetUri of the copied top-level asset in the destination store. + + Raises: + ValueError: If the source or destination store is not registered. + FileNotFoundError: If the source asset is not found. + RuntimeError: If a cyclic dependency is detected. + """ + logger.debug( + f"DeepcopyAsync URI '{src}' from store '{store}' to '{dest_store}'" + f" with dest '{dest}'" + ) + if dest is None: + dest = src + + if store not in self.stores: + raise ValueError(f"Source store '{store}' not registered.") + if dest_store not in self.stores: + raise ValueError(f"Destination store '{dest_store}' not registered.") + if store == dest_store and src == dest: + raise ValueError(f"File '{src}' cannot be copied to itself.") + + # Fetch the source asset and its dependencies recursively + # Use a new set for visited_uris for this deepcopy operation + construction_data = await self._fetch_asset_construction_data_recursive_async( + src, [store], set(), depth=None + ) + if construction_data is None: + raise FileNotFoundError(f"Source asset '{src}' not found in store '{store}'.") + + # Collect all assets (including dependencies) in a flat list, + # ensuring dependencies are processed before the assets that depend on them. + assets_to_copy: List[_AssetConstructionData] = [] + + def collect_assets(data: _AssetConstructionData): + if data.dependencies_data is not None: + for dep_data in data.dependencies_data.values(): + if dep_data: # Only collect if dependency data was successfully fetched + collect_assets(dep_data) + assets_to_copy.append(data) + + collect_assets(construction_data) + + # Process assets in the collected order (dependencies first) + dest_store: AssetStore = self.stores[dest_store] + copied_uris: Set[AssetUri] = set() + for asset_data in assets_to_copy: + # Prevent duplicate processing of the same asset + asset_uri = dest if asset_data.uri == src else asset_data.uri + if asset_uri in copied_uris: + logger.debug( + f"Dependency '{asset_uri}' already added to '{dest_store}'," " skipping copy." + ) + continue + copied_uris.add(asset_uri) + + # Check if the dependency already exists in the destination store + # Dependencies should be skipped if they exist, top-level should be overwritten. + exists_in_dest = await dest_store.exists(asset_uri) + if exists_in_dest and asset_uri != src: + logger.debug( + f"Dependency '{asset_uri}' already exists in '{dest_store}'," " skipping copy." + ) + continue + + # Put the asset (or dependency) into the destination store + # Pass the dependency_uri_map to the store's put method. + if exists_in_dest: + # If it was not skipped above, this is the top-level asset. Update it. + logger.debug(f"Updating asset '{asset_uri}' in '{dest_store}'") + dest = await dest_store.update( + asset_uri, + asset_data.raw_data, + ) + else: + # If it doesn't exist, or if it's a dependency that doesn't exist, create it + logger.debug(f"Creating asset '{asset_uri}' in '{dest_store}'") + logger.debug(f"Raw data before writing: {asset_data.raw_data}") # Added log + await dest_store.create( + asset_uri.asset_type, + asset_uri.asset_id, + asset_data.raw_data, + ) + + logger.debug(f"DeepcopyAsync completed for '{src}' to '{dest}'") + return dest + + def deepcopy( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Synchronously deep copies an asset and its dependencies from a source store + to a destination store. + + Args: + src: The AssetUri of the source asset. + dest_store: The name of the destination store. + store: The name of the source store (defaults to "local"). + dest: Optional. The new AssetUri for the top-level asset in the + destination store. If None, the original URI is used. + + Returns: + The AssetUri of the copied top-level asset in the destination store. + + Raises: + ValueError: If the source or destination store is not registered. + FileNotFoundError: If the source asset is not found. + RuntimeError: If a cyclic dependency is detected. + """ + logger.debug( + f"Deepcopy URI '{src}' from store '{store}' to '{dest_store}'" f" with dest '{dest}'" + ) + return asyncio.run(self.deepcopy_async(src, dest_store, store, dest)) + def add_file( self, asset_type: str, path: pathlib.Path, store: str = "local", - asset_id: str | None = None, + asset_id: Optional[str] = None, ) -> AssetUri: """ Convenience wrapper around add_raw(). diff --git a/src/Mod/CAM/Path/Tool/assets/serializer.py b/src/Mod/CAM/Path/Tool/assets/serializer.py index 327cc4680f..dca0ca1ff5 100644 --- a/src/Mod/CAM/Path/Tool/assets/serializer.py +++ b/src/Mod/CAM/Path/Tool/assets/serializer.py @@ -28,7 +28,7 @@ from .asset import Asset class AssetSerializer(ABC): for_class: Type[Asset] - extensions: Tuple[str] = tuple() + extensions: Tuple[str, ...] = tuple() mime_type: str can_import: bool = True can_export: bool = True diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index c597e7430e..32ba281114 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -20,27 +20,34 @@ # * * # *************************************************************************** import pathlib -import FreeCAD -import Path from typing import Optional, Tuple, Type, Iterable from PySide.QtWidgets import QFileDialog, QMessageBox +from ..manager import AssetManager from ..serializer import AssetSerializer, Asset from .util import ( make_import_filters, make_export_filters, get_serializer_from_extension, ) +import Path.Preferences as Preferences class AssetOpenDialog(QFileDialog): def __init__( self, + asset_manager: AssetManager, asset_class: Type[Asset], serializers: Iterable[Type[AssetSerializer]], parent=None, ): super().__init__(parent) + + # Set default directory based on asset type + default_dir = self._get_default_directory(asset_class) + self.setDirectory(default_dir.as_posix()) + self.asset_class = asset_class + self.asset_manager = asset_manager self.serializers = list(serializers) self.setFileMode(QFileDialog.ExistingFile) filters = make_import_filters(self.serializers) @@ -50,6 +57,7 @@ class AssetOpenDialog(QFileDialog): def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]: """Deserialize the selected file using the appropriate serializer.""" + # Find the correct serializer for the file. file_extension = file_path.suffix.lower() serializer_class = get_serializer_from_extension( self.serializers, file_extension, for_import=True @@ -61,8 +69,25 @@ class AssetOpenDialog(QFileDialog): f"No supported serializer found for file extension '{file_extension}'", ) return None + + # Check whether all dependencies for importing the file exist. try: raw_data = file_path.read_bytes() + dependencies = serializer_class.extract_dependencies(raw_data) + for dependency_uri in dependencies: + if not self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): + QMessageBox.critical( + self, + "Error", + f"Failed to import {file_path}: required dependency {dependency_uri} not found", + ) + return None + except Exception as e: + QMessageBox.critical(self, "Error", f"{file_path}: Failed to check dependencies: {e}") + return None + + # Load and return the asset. + try: asset = serializer_class.deep_deserialize(raw_data) if not isinstance(asset, self.asset_class): raise TypeError(f"Deserialized asset is not of type {self.asset_class.__name__}") @@ -81,6 +106,24 @@ class AssetOpenDialog(QFileDialog): return file_path, asset return None + def _get_default_directory(self, asset_class: Type[Asset]) -> pathlib.Path: + """Get the appropriate default directory based on asset type.""" + try: + asset_path = Preferences.getAssetPath() + + # Check asset type to determine subdirectory + asset_type = getattr(asset_class, "asset_type", None) + if asset_type == "toolbit": + return asset_path / "Tool" / "Bit" + elif asset_type == "library" or asset_type == "toolbitlibrary": + return asset_path / "Tool" / "Library" + else: + # Default to asset path root for unknown types + return asset_path + except Exception: + # Fallback to home directory if anything goes wrong + return pathlib.Path.home() + class AssetSaveDialog(QFileDialog): def __init__( @@ -90,6 +133,10 @@ class AssetSaveDialog(QFileDialog): parent=None, ): super().__init__(parent) + + # Set default directory based on asset type + default_dir = self._get_default_directory(asset_class) + self.setDirectory(default_dir.as_posix()) self.asset_class = asset_class self.serializers = list(serializers) self.setFileMode(QFileDialog.AnyFile) @@ -124,6 +171,11 @@ class AssetSaveDialog(QFileDialog): QMessageBox.critical(self, "Error", f"Failed to export asset: {e}") return False + def _get_default_directory(self, asset_class: Type[Asset]) -> pathlib.Path: + """Get the appropriate default directory based on asset type.""" + # For exports, default to home directory instead of CAM assets path + return pathlib.Path.home() + def exec_(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]: self.setWindowTitle(f"Save {asset.label or self.asset_class.asset_type}") if super().exec_(): diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index c17b871234..e74305ad05 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -120,6 +120,7 @@ class AssetPreferencesPage: ) return False Path.Preferences.setAssetPath(asset_path) + Path.Preferences.setLastToolLibrary("") return True def loadSettings(self): diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index 38f1c6f2a6..8bffbeaec3 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -131,13 +131,13 @@ def ensure_toolbitshape_assets_present(asset_manager: AssetManager, store_name: def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): """ - Copies an example shape to the given store if it is currently empty. + Ensures the toolbitshape directory structure exists without adding any files. """ - builtin_shape_path = Preferences.getBuiltinShapePath() + from pathlib import Path - if asset_manager.is_empty("toolbitshape", store=store_name): - path = builtin_shape_path / "endmill.fcstd" - asset_manager.add_file("toolbitshape", path, store=store_name, asset_id="example") + # Get the shape directory path and ensure it exists + shape_path = Preferences.getAssetPath() / "Tools" / "Shape" + shape_path.mkdir(parents=True, exist_ok=True) def ensure_assets_initialized(asset_manager: AssetManager, store="local"): @@ -157,6 +157,16 @@ def _on_asset_path_changed(group, key, value): # Set up the local CAM asset storage. asset_mapping = { + "toolbitlibrary": "Tools/Library/{asset_id}.fctl", + "toolbit": "Tools/Bit/{asset_id}.fctb", + "toolbitshape": "Tools/Shape/{asset_id}.fcstd", + "toolbitshapesvg": "Tools/Shape/{asset_id}", # Asset ID has ".svg" included + "toolbitshapepng": "Tools/Shape/{asset_id}", # Asset ID has ".png" included + "machine": "Machine/{asset_id}.fcm", +} + +# Separate mapping for builtin assets (maintains original structure) +builtin_asset_mapping = { "toolbitlibrary": "Library/{asset_id}.fctl", "toolbit": "Bit/{asset_id}.fctb", "toolbitshape": "Shape/{asset_id}.fcstd", @@ -174,7 +184,7 @@ user_asset_store = FileStore( builtin_asset_store = FileStore( name="builtin", base_dir=Preferences.getBuiltinAssetPath(), - mapping=asset_mapping, + mapping=builtin_asset_mapping, ) diff --git a/src/Mod/CAM/Path/Tool/library/models/__init__.py b/src/Mod/CAM/Path/Tool/library/models/__init__.py index e69de29bb2..d95aa7a1c4 100644 --- a/src/Mod/CAM/Path/Tool/library/models/__init__.py +++ b/src/Mod/CAM/Path/Tool/library/models/__init__.py @@ -0,0 +1,5 @@ +from .library import Library + +__all__ = [ + "Library", +] diff --git a/src/Mod/CAM/Path/Tool/library/models/library.py b/src/Mod/CAM/Path/Tool/library/models/library.py index d2964d0ea8..4f1b9127af 100644 --- a/src/Mod/CAM/Path/Tool/library/models/library.py +++ b/src/Mod/CAM/Path/Tool/library/models/library.py @@ -132,15 +132,15 @@ class Library(Asset): return tool return None - def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> int: if bit not in self._bits: - return + raise ValueError(f"given bit {bit} not in library; cannot assign tool number") # 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 + return bit_no # 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 @@ -154,7 +154,7 @@ class Library(Asset): self.assign_new_bit_no(old_bit) return bit_no - def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> int: if bit not in self._bits: self._bits.append(bit) return self.assign_new_bit_no(bit, bit_no) @@ -172,6 +172,12 @@ class Library(Asset): 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 remove_bit_by_uri(self, uri: AssetUri | str): + if isinstance(uri, str): + uri = AssetUri(uri) + self._bits = [t for t in self._bits if t.get_uri() != uri] + self._bit_nos = {k: v for (k, v) in self._bit_nos.items() if v.get_uri() != uri} + def dump(self, summarize: bool = False): title = 'Library "{}" ({}) (instance {})'.format(self.label, self.id, id(self)) print("-" * len(title)) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index 8b6737bddb..f8fe8555fc 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -66,6 +66,7 @@ class FCTLSerializer(AssetSerializer): 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 @@ -103,9 +104,80 @@ class FCTLSerializer(AssetSerializer): Path.Log.warning( f"Tool with id {tool_id} not found in dependencies during deserialization." ) + # Create a placeholder toolbit with the original ID to preserve library structure + from ...toolbit.models.custom import ToolBitCustom + from ...shape.models.custom import ToolBitShapeCustom + + placeholder_shape = ToolBitShapeCustom(tool_id) + placeholder_toolbit = ToolBitCustom(placeholder_shape, id=tool_id) + placeholder_toolbit.label = f"Missing Tool ({tool_id})" + library.add_bit(placeholder_toolbit, bit_no=tool_no) + Path.Log.info(f"Created placeholder toolbit with original ID {tool_id}") 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()), {}) + """Deep deserialize a library by fetching all toolbit dependencies.""" + import uuid + from ...camassets import cam_assets + + # Generate a unique ID for this library instance + library_id = str(uuid.uuid4()) + + Path.Log.info( + f"FCTL DEEP_DESERIALIZE: Starting deep deserialization for library id='{library_id}'" + ) + + # Extract dependency URIs from the library data + dependency_uris = cls.extract_dependencies(data) + Path.Log.info( + f"FCTL DEEP_DESERIALIZE: Found {len(dependency_uris)} toolbit dependencies: {[uri.asset_id for uri in dependency_uris]}" + ) + + # Fetch all toolbit dependencies + resolved_dependencies = {} + for dep_uri in dependency_uris: + try: + Path.Log.info( + f"FCTL DEEP_DESERIALIZE: Fetching toolbit '{dep_uri.asset_id}' from stores ['local', 'builtin']" + ) + + # Check if toolbit exists in each store individually for debugging + exists_local = cam_assets.exists(dep_uri, store="local") + exists_builtin = cam_assets.exists(dep_uri, store="builtin") + Path.Log.info( + f"FCTL DEEP_DESERIALIZE: Toolbit '{dep_uri.asset_id}' exists - local: {exists_local}, builtin: {exists_builtin}" + ) + + toolbit = cam_assets.get(dep_uri, store=["local", "builtin"], depth=0) + resolved_dependencies[dep_uri] = toolbit + Path.Log.info( + f"FCTL DEEP_DESERIALIZE: Successfully fetched toolbit '{dep_uri.asset_id}'" + ) + except Exception as e: + Path.Log.warning( + f"FCTL DEEP_DESERIALIZE: Failed to fetch toolbit '{dep_uri.asset_id}': {e}" + ) + + # Try to get more detailed error information + try: + # Check what's actually in the stores + local_toolbits = cam_assets.list_assets("toolbit", store="local") + local_ids = [uri.asset_id for uri in local_toolbits] + Path.Log.info( + f"FCTL DEBUG: Local store has {len(local_ids)} toolbits: {local_ids[:10]}{'...' if len(local_ids) > 10 else ''}" + ) + + if dep_uri.asset_id in local_ids: + Path.Log.warning( + f"FCTL DEBUG: Toolbit '{dep_uri.asset_id}' IS in local store list but get() failed!" + ) + except Exception as list_error: + Path.Log.error(f"FCTL DEBUG: Failed to list local toolbits: {list_error}") + + Path.Log.info( + f"FCTL DEEP_DESERIALIZE: Resolved {len(resolved_dependencies)} of {len(dependency_uris)} dependencies" + ) + + # Now deserialize with the resolved dependencies + return cls.deserialize(data, library_id, resolved_dependencies) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py index 70f26f3a46..1b599917fa 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py @@ -50,18 +50,30 @@ class LinuxCNCSerializer(AssetSerializer): 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() + # Connor: 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 + # Commenting this out. Why did we skip because it is not a rotary tool? + diameter = bit.get_diameter().getUserPreferred()[0] pocket = "P0" # 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:.3f} ;{bit.label}\n" - output.write(line.encode("ascii", "ignore")) + # TODO: Strip units by splitting at the first space if diameter is a string + # This is where we need a machine definition so we can export these out correctly + # for a metric or imperial machine + # Using user preferred for now + if hasattr(diameter, "Value"): + diameter_value = diameter.Value + elif isinstance(diameter, str): + diameter_value = diameter.split(" ")[0] + else: + diameter_value = diameter + line = ( + f"T{bit_no} {pocket} X0 Y0 Z0 A0 B0 C0 U0 V0 W0 " + f"D{diameter_value} I0 J0 Q0 ;{bit.label}\n" + ) + output.write(line.encode("utf-8")) return output.getvalue() diff --git a/src/Mod/CAM/Path/Tool/library/ui/__init__.py b/src/Mod/CAM/Path/Tool/library/ui/__init__.py index e69de29bb2..0d30b4bcc2 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/__init__.py +++ b/src/Mod/CAM/Path/Tool/library/ui/__init__.py @@ -0,0 +1,12 @@ +from .browser import LibraryBrowserWidget +from .dock import ToolBitLibraryDock +from .editor import LibraryEditor +from .properties import LibraryPropertyDialog + + +__all__ = [ + "LibraryBrowserWidget", + "ToolBitLibraryDock", + "LibraryEditor", + "LibraryPropertyDialog", +] diff --git a/src/Mod/CAM/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py index 24e0c1559e..15fc49f403 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# flake8: noqa E731 # *************************************************************************** # * Copyright (c) 2025 Samuel Abels * # * * @@ -22,18 +23,581 @@ """Widget for browsing Tool Library assets with filtering and sorting.""" -from typing import cast -from PySide import QtGui +import yaml +from typing import cast, List, Optional +from PySide import QtCore, QtGui +from PySide.QtGui import QMenu, QAction, QKeySequence +import FreeCAD import Path -from ...toolbit.ui.browser import ToolBitBrowserWidget -from ...assets import AssetManager -from ...library import Library +from ...assets import AssetManager, AssetUri +from ...toolbit import ToolBit +from ...toolbit.ui import ToolBitEditor +from ...toolbit.ui.util import natural_sort_key +from ...toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole +from ...toolbit.serializers import YamlToolBitSerializer +from ..models.library import Library + + +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) class LibraryBrowserWidget(ToolBitBrowserWidget): """ A widget to browse, filter, and select Tool Library assets from the - AssetManager, with sorting and batch insertion, including library selection. + AssetManager, with sorting and batch insertion, using a current library. + """ + + current_library_changed = QtCore.Signal() + + def __init__( + self, + asset_manager: AssetManager, + store: str = "local", + parent=None, + compact=True, + ): + super().__init__( + asset_manager=asset_manager, + store=store, + parent=parent, + tool_no_factory=self.get_tool_no_from_current_library, + compact=compact, + ) + self.current_library: Optional[Library] = None + self._selected_tool_type: Optional[str] = None + self.layout().setContentsMargins(0, 0, 0, 0) + + # Add tool type filter combo box to the base widget + self._tool_type_combo = QtGui.QComboBox() + self._tool_type_combo.setSizePolicy( + QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred + ) + self._top_layout.insertWidget(0, self._tool_type_combo, 1) + self._tool_type_combo.currentTextChanged.connect(self._on_tool_type_combo_changed) + + self.restore_last_sort_order() + self.load_last_library() + + def setDragEnabled(self, enabled: bool = True): + """Enable or disable drag-and-drop support for the tool list.""" + self._tool_list_widget.setDragEnabled(enabled) + + def load_last_library(self): + """Loads the last selected library from preferences.""" + library_uri = Path.Preferences.getLastToolLibrary() + if library_uri: + try: + library = self._asset_manager.get(library_uri, store="local", depth=1) + self.set_current_library(library) + except Exception as e: + Path.Log.warning(f"Failed to load last tool library: {e}") + + def restore_last_sort_order(self): + """Sets the sort mode and updates the tool list.""" + last_sort_key = Path.Preferences.getLastToolLibrarySortKey() + if last_sort_key: + self.set_sort_order(last_sort_key) + + def set_sort_order(self, key: str): + super().set_sort_order(key) + Path.Preferences.setLastToolLibrarySortKey(self._sort_key) + + def _get_state(self): + """Gets the current library URI, selected toolbit URI, and scroll + position.""" + current_library_uri_str = ( + str(self.current_library.get_uri()) if self.current_library else None + ) + + selected_toolbit_uris = [] + selected_items = self._tool_list_widget.selectedItems() + if selected_items: + selected_toolbit_uris = [item.data(ToolBitUriRole) for item in selected_items] + + scroll_pos = self._tool_list_widget.verticalScrollBar().value() + + return { + "library_uri": current_library_uri_str, + "toolbit_uris": selected_toolbit_uris, + "scroll_pos": scroll_pos, + } + + def _set_state(self, selection_data): + """Restores the library selection, toolbit selection, and scroll + position.""" + library_uri_str = selection_data.get("library_uri") + toolbit_uris = selection_data.get("toolbit_uris", []) + scroll_pos = selection_data.get("scroll_pos", 0) + + # Restore library selection + if library_uri_str: + try: + library_uri = AssetUri(library_uri_str) + library = self._asset_manager.get(library_uri, store=self._store_name, depth=1) + self.set_current_library(library) + except FileNotFoundError: + Path.Log.error(f"Library {library_uri_str} not found.") + self.set_current_library(None) + else: + self.set_current_library(None) + + # Restore toolbit selection + if toolbit_uris: + for uri in toolbit_uris: + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + if item.data(ToolBitUriRole) == uri: + item.setSelected(True) + + # Restore scroll position + self._tool_list_widget.verticalScrollBar().setValue(scroll_pos) + + def refresh(self): + """Refreshes the toolbits for the current library from disk.""" + Path.Log.debug("refresh(): Fetching and populating toolbits.") + if self.current_library: + library_uri = self.current_library.get_uri() + try: + self.current_library = cast( + Library, self._asset_manager.get(library_uri, store=self._store_name, depth=1) + ) + except FileNotFoundError: + Path.Log.error(f"Library {library_uri} not found.") + self.current_library = None + self._update_tool_list() + + def get_tool_no_from_current_library(self, toolbit): + """ + Retrieves the tool number for a toolbit based on the current library. + """ + if not self.current_library: + return None + tool_no = self.current_library.get_bit_no_from_bit(toolbit) + return tool_no + + def set_current_library(self, library): + """Sets the current library and updates the tool list.""" + self.current_library = library + self._update_tool_list() + self.current_library_changed.emit() + + # Save the selected library to preferences + if library: + Path.Preferences.setLastToolLibrary(str(library.get_uri())) + + def _get_available_tool_types(self): + """Get all available tool types from the current assets.""" + tool_types = set() + # Make sure we have assets to work with + if not hasattr(self, "_all_assets") or not self._all_assets: + return [] + + for asset in self._all_assets: + # Use get_shape_name() method to get the tool type + if hasattr(asset, "get_shape_name"): + tool_type = asset.get_shape_name() + if tool_type: + tool_types.add(tool_type) + + return sorted(tool_types) + + def _get_filtered_assets(self): + """Get assets filtered by tool type if a specific type is selected.""" + if self._tool_type_combo.currentIndex() == 0: # "All Toolbit Types" + return self._all_assets + + filtered_assets = [] + for asset in self._all_assets: + if hasattr(asset, "get_shape_name"): + tool_type = asset.get_shape_name() + if tool_type == self._selected_tool_type: + filtered_assets.append(asset) + return filtered_assets + + def _update_tool_list(self): + """Updates the tool list based on the current library.""" + if self.current_library: + self._all_assets = [t for t in self.current_library] + else: + # Fetch all toolbits + all_toolbits = self._asset_manager.fetch(asset_type="toolbit", depth=0) + self._all_assets = cast(List[ToolBit], all_toolbits) + self._sort_assets() + self._tool_list_widget.clear_list() + # Update tool type combo after assets are loaded + if hasattr(self, "_tool_type_combo"): + self._update_tool_type_combo() + self._update_list() + + def _update_list(self): + """Updates the list widget with filtered assets.""" + self._tool_list_widget.clear_list() + filtered_assets = self._get_filtered_assets() + + # Apply search filter if there is one + search_term = self._search_edit.text().lower() + if search_term: + search_filtered = [] + for asset in filtered_assets: + if search_term in asset.label.lower(): + search_filtered.append(asset) + continue + # Also search in tool type + if hasattr(asset, "get_shape_name"): + tool_type = asset.get_shape_name() + if tool_type and search_term in tool_type.lower(): + search_filtered.append(asset) + filtered_assets = search_filtered + + for asset in filtered_assets: + self._tool_list_widget.add_toolbit(asset) + + def _add_shortcuts(self): + """Adds keyboard shortcuts for common actions.""" + Path.Log.debug("LibraryBrowserWidget._add_shortcuts: Called.") + super()._add_shortcuts() + + cut_action = QAction(self) + cut_action.setShortcuts(QKeySequence.Cut) + cut_action.triggered.connect(self._on_cut_requested) + self.addAction(cut_action) + + duplicate_action = QAction(self) + duplicate_action.setShortcut(QKeySequence("Ctrl+D")) + duplicate_action.triggered.connect(self._on_duplicate_requested) + self.addAction(duplicate_action) + + remove_action = QAction(self) + remove_action.setShortcut(QKeySequence.Delete) + remove_action.triggered.connect(self._on_remove_from_library_requested) + self.addAction(remove_action) + + paste_action = QAction(self) + paste_action.setShortcuts(QKeySequence.Paste) + paste_action.triggered.connect(self._on_paste_requested) + self.addAction(paste_action) + + def _show_context_menu(self, position): + """Shows the context menu at the given position.""" + context_menu = QMenu(self) + + selected_items = self._tool_list_widget.selectedItems() + has_selection = bool(selected_items) + has_library = self.current_library is not None + + # Add actions in the desired order + edit_action = context_menu.addAction("Edit", self._on_edit_requested) + edit_action.setEnabled(has_selection) + + context_menu.addSeparator() + + action = context_menu.addAction("Copy", self._on_copy_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+C")) + + action = context_menu.addAction("Cut", self._on_cut_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+X")) + + action = context_menu.addAction("Paste", self._on_paste_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+V")) + + # Paste is enabled if there is data in the clipboard + clipboard = QtGui.QApplication.clipboard() + mime_type = "application/x-freecad-toolbit-list-yaml" + action.setEnabled(clipboard.mimeData().hasFormat(mime_type)) + + action = context_menu.addAction("Duplicate", self._on_duplicate_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+D")) + + context_menu.addSeparator() + + # Only show "Remove from Library" when viewing a specific library + if has_library: + action = context_menu.addAction( + "Remove from Library", self._on_remove_from_library_requested + ) + action.setShortcut(QtGui.QKeySequence.Delete) + + # Only show "Delete from disk" when viewing 'all tools' (no library selected) + if not has_library: + action = context_menu.addAction("Delete from disk", self._on_delete_requested) + action.setShortcut(QtGui.QKeySequence("Shift+Delete")) + + # Execute the menu + context_menu.exec_(self._tool_list_widget.mapToGlobal(position)) + + def get_current_library(self) -> Library | None: + """Helper to get the current library.""" + return self.current_library + + def _on_edit_requested(self): + """Opens the ToolBitEditor for the selected toolbit.""" + toolbit = self._get_first_selected_bit() + if not toolbit: + return + + # Open the editor for the selected toolbit + tool_no = self.get_tool_no_from_current_library(toolbit) + editor = ToolBitEditor(toolbit, tool_no, parent=self) + result = editor.show() + if result != QtGui.QDialog.Accepted: + return + + # If the editor was closed with "OK", save the changes + self._asset_manager.add(toolbit) + Path.Log.info(f"Toolbit {toolbit.get_id()} saved.") + + # Also save the library because the tool number may have changed. + if self.current_library and tool_no != editor.tool_no: + self.current_library.assign_new_bit_no(toolbit, editor.tool_no) + self._asset_manager.add(self.current_library) + + state = self._get_state() + self.refresh() + self._update_list() + self._set_state(state) + + def _on_cut_requested(self): + """Handles cut request by copying and marking for removal from library.""" + uris = self.get_selected_bit_uris() + library = self.get_current_library() + if not library or not uris: + return + + # Copy to clipboard (handled by base class _to_clipboard) + extra_data = {"source_library_uri": str(library.get_uri())} + self._to_clipboard(uris, mode="cut", extra_data=extra_data) + + def _on_duplicate_requested(self): + """Handles duplicate request by duplicating and adding to library.""" + Path.Log.debug("LibraryBrowserWidget._on_duplicate_requested: Called.\n") + uris = self.get_selected_bit_uris() + library = self.get_current_library() + if not library or not uris: + Path.Log.debug( + "LibraryBrowserWidget._on_duplicate_requested: No library or URIs selected. Returning." + ) + return + + new_uris = set() + for uri_string in uris: + toolbit = cast(ToolBit, self._asset_manager.get(AssetUri(uri_string), depth=0)) + if not toolbit: + Path.Log.warning(f"Toolbit {uri_string} not found.\n") + continue + + # Change the ID of the toolbit and save it to disk + toolbit.set_id() # Generate a new ID + toolbit.label = toolbit.label + " (copy)" + added_uri = self._asset_manager.add(toolbit) + if added_uri: + new_uris.add(str(toolbit.get_uri())) + + # Add the bit to the current library + library.add_bit(toolbit) + + self._asset_manager.add(library) # Save the modified library + self.refresh() + + self.select_by_uri(list(new_uris)) + + def _on_paste_requested(self): + """Handles paste request by adding toolbits to the current library.""" + current_library = self.get_current_library() + if not current_library: + return + + clipboard = QtGui.QApplication.clipboard() + mime_type = "application/x-freecad-toolbit-list-yaml" + mime_data = clipboard.mimeData() + + if not mime_data.hasFormat(mime_type): + return + + try: + clipboard_content_yaml = mime_data.data(mime_type).data().decode("utf-8") + clipboard_data_dict = yaml.safe_load(clipboard_content_yaml) + + if ( + not isinstance(clipboard_data_dict, dict) + or "toolbits" not in clipboard_data_dict + or not isinstance(clipboard_data_dict["toolbits"], list) + ): + return + + serialized_toolbits_data = clipboard_data_dict["toolbits"] + mode = clipboard_data_dict.get("operation", "copy") + source_library_uri_str = clipboard_data_dict.get("source_library_uri") + + if mode == "copy": + self._on_copy_paste(current_library, serialized_toolbits_data) + elif mode == "cut" and source_library_uri_str: + self._on_cut_paste( + current_library, serialized_toolbits_data, source_library_uri_str + ) + + except Exception as e: + Path.Log.warning(f"An unexpected error occurred during paste: {e}") + + def _on_copy_paste(self, current_library: Library, serialized_toolbits_data: list): + """Handles pasting toolbits that were copied.""" + new_uris = set() + for toolbit_yaml_str in serialized_toolbits_data: + if not isinstance(toolbit_yaml_str, str) or not toolbit_yaml_str.strip(): + continue + + toolbit_data_bytes = toolbit_yaml_str.encode("utf-8") + toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None) + + # Get the original toolbit ID from the deserialized data + original_id = toolbit.id + Path.Log.info(f"COPY PASTE: Attempting to paste toolbit with original_id={original_id}") + + # Check if toolbit already exists in asset manager + toolbit_uri = toolbit.get_uri() + existing_toolbit = None + try: + existing_toolbit = self._asset_manager.get( + toolbit_uri, store=["local", "builtin"], depth=0 + ) + Path.Log.info(f"COPY PASTE: Found existing toolbit {original_id}, using reference") + except FileNotFoundError: + # Toolbit doesn't exist, save it as new + Path.Log.info(f"COPY PASTE: Toolbit {original_id} not found, creating new one") + self._asset_manager.add(toolbit) + existing_toolbit = toolbit + + # Add the existing or new toolbit to the current library + added_toolbit = current_library.add_bit(existing_toolbit) + if added_toolbit: + new_uris.add(str(existing_toolbit.get_uri())) + + if new_uris: + self._asset_manager.add(current_library) # Save the modified library + self.refresh() + self.select_by_uri(list(new_uris)) + + def _on_cut_paste( + self, + current_library: Library, + serialized_toolbits_data: list, + source_library_uri_str: str, + ): + """Handles pasting toolbits that were cut.""" + source_library_uri = AssetUri(source_library_uri_str) + if source_library_uri == current_library.get_uri(): + # Cut from the same library, do nothing + return + + try: + source_library = cast( + Library, + self._asset_manager.get(source_library_uri, store=self._store_name, depth=1), + ) + except FileNotFoundError: + Path.Log.warning(f"Source library {source_library_uri_str} not found.\n") + return + + new_uris = set() + for toolbit_yaml_str in serialized_toolbits_data: + if not isinstance(toolbit_yaml_str, str) or not toolbit_yaml_str.strip(): + continue + + toolbit_data_bytes = toolbit_yaml_str.encode("utf-8") + toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None) + + # Get the original toolbit ID and find the existing toolbit + original_id = toolbit.id + Path.Log.info(f"CUT PASTE: Moving toolbit with original_id={original_id}") + + toolbit_uri = toolbit.get_uri() + try: + existing_toolbit = self._asset_manager.get( + toolbit_uri, store=["local", "builtin"], depth=0 + ) + Path.Log.info(f"CUT PASTE: Found existing toolbit {original_id}, using reference") + + # Remove from source library, add to target library + source_library.remove_bit(existing_toolbit) + added_toolbit = current_library.add_bit(existing_toolbit) + if added_toolbit: + new_uris.add(str(existing_toolbit.get_uri())) + except FileNotFoundError: + Path.Log.warning(f"CUT PASTE: Toolbit {original_id} not found in asset manager") + + if new_uris: + # Save the modified libraries + self._asset_manager.add(current_library) + self._asset_manager.add(source_library) + self.refresh() + self.select_by_uri(list(new_uris)) + + def _on_remove_from_library_requested(self): + """Handles request to remove selected toolbits from the current library.""" + Path.Log.debug("_on_remove_from_library_requested: Called.") + uris = self.get_selected_bit_uris() + library = self.get_current_library() + if not library or not uris: + return + + # Ask for confirmation + reply = QtGui.QMessageBox.question( + self, + FreeCAD.Qt.translate("CAM", "Confirm Removal"), + FreeCAD.Qt.translate( + "CAM", "Are you sure you want to remove the selected toolbit(s) from the library?" + ), + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + QtGui.QMessageBox.No, + ) + + if reply == QtGui.QMessageBox.Yes: + self._remove_toolbits_from_library(library, uris) + + def _remove_toolbits_from_library(self, library: Library, uris: List[str]): + """Removes toolbits with the given URIs from the specified library.""" + removed_count = 0 + for uri_string in uris: + try: + # Remove the toolbit from the library + library.remove_bit_by_uri(uri_string) + removed_count += 1 + except Exception as e: + Path.Log.error(f"Failed to remove toolbit {uri_string} from library: {e}\n") + + if removed_count > 0: + self._asset_manager.add(library) + self.refresh() + + def _update_tool_type_combo(self): + """Update the tool type combo box with available types.""" + current_selection = self._tool_type_combo.currentText() + self._tool_type_combo.blockSignals(True) + try: + self._tool_type_combo.clear() + self._tool_type_combo.addItem(FreeCAD.Qt.translate("CAM", "All Toolbit Types")) + + for tool_type in self._get_available_tool_types(): + self._tool_type_combo.addItem(tool_type) + + # Restore selection if it still exists + index = self._tool_type_combo.findText(current_selection) + if index >= 0: + self._tool_type_combo.setCurrentIndex(index) + else: + self._tool_type_combo.setCurrentIndex(0) + finally: + self._tool_type_combo.blockSignals(False) + + def _on_tool_type_combo_changed(self, tool_type): + """Handle tool type filter selection change.""" + self._selected_tool_type = tool_type + self._update_list() + + +class LibraryBrowserWithCombo(LibraryBrowserWidget): + """ + A widget extending LibraryBrowserWidget with a combo box for library selection. """ def __init__( @@ -43,74 +607,88 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): 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) + # Move search box into dedicated row to make space for the + # library selection combo box + layout = self.layout() + self._top_layout.removeWidget(self._search_edit) + layout.insertWidget(1, self._search_edit, 20) + + # Library selection combo box + self._library_combo = QtGui.QComboBox() + self._library_combo.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) + self._top_layout.insertWidget(0, self._library_combo, 1) + self._library_combo.currentIndexChanged.connect(self._on_library_combo_changed) + + self._top_layout.removeWidget(self._tool_type_combo) + self._top_layout.insertWidget(1, self._tool_type_combo, 1) + + self.current_library_changed.connect(self._on_current_library_changed) + + self._in_refresh = False + self.refresh() + + def _on_library_combo_changed(self, index): + """Handles library selection change from the combo box.""" + if self._in_refresh: + return + + selected_library = cast(Library, self._library_combo.itemData(index)) + if not selected_library: + return + + # Have to refetch the non-shallow library. + uri = selected_library.get_uri() + library = self._asset_manager.get(uri, store=self._store_name, depth=1) + self.set_current_library(library) + + def _on_current_library_changed(self): + """Updates the combo box when the current library changes externally.""" + if self.current_library: + for i in range(self._library_combo.count()): + lib = self._library_combo.itemData(i) + if lib.get_uri() == self.current_library.get_uri(): + self._library_combo.setCurrentIndex(i) + return + Path.Log.warning( + f"Current library {self.current_library.get_uri()} not found in combo box." + ) 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 + """Reads available libraries and refreshes the combo box and toolbits.""" + Path.Log.debug("refresh(): Fetching and populating libraries and toolbits.") 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() + self._in_refresh = True 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 = [] + self._library_combo.clear() + for library in sorted(libraries, key=lambda x: natural_sort_key(x.label)): + self._library_combo.addItem(library.label, userData=library) + finally: + self._in_refresh = False + + super().refresh() + + if not libraries: + return + if not self.current_library: + first_library = self._library_combo.itemData(0) + if first_library: + uri = first_library.get_uri() + library = self._asset_manager.get(uri, store=self._store_name, depth=1) + self.set_current_library(library) + self._library_combo.setCurrentIndex(0) 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 + for i in range(self._library_combo.count()): + lib = self._library_combo.itemData(i) + if lib.get_uri() == self.current_library.get_uri(): + self._library_combo.setCurrentIndex(i) + break + else: + self._library_combo.setCurrentIndex(0) diff --git a/src/Mod/CAM/Path/Tool/library/ui/cmd.py b/src/Mod/CAM/Path/Tool/library/ui/cmd.py index c1dc9d0798..d70303ac90 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/cmd.py +++ b/src/Mod/CAM/Path/Tool/library/ui/cmd.py @@ -75,7 +75,7 @@ class CommandLibraryEditorOpen: def GetResources(self): return { "Pixmap": "CAM_ToolTable", - "MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitLibraryOpen", "Toolbit Library Editor"), + "MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitLibraryOpen", "Toolbit Library Manager"), "ToolTip": QT_TRANSLATE_NOOP( "CAM_ToolBitLibraryOpen", "Opens an editor to manage toolbit libraries" ), @@ -86,7 +86,7 @@ class CommandLibraryEditorOpen: return True def Activated(self): - library = LibraryEditor() + library = LibraryEditor(parent=FreeCADGui.getMainWindow()) library.open() diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py index 6606bfeb44..6e9a930250 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/dock.py +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -34,8 +34,7 @@ from typing import List, Tuple from ...camassets import cam_assets, ensure_assets_initialized from ...toolbit import ToolBit from .editor import LibraryEditor -from .browser import LibraryBrowserWidget - +from .browser import LibraryBrowserWithCombo if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -57,7 +56,7 @@ class ToolBitLibraryDock(object): self.autoClose = autoClose self.form = QtWidgets.QDialog() self.form.setObjectName("ToolSelector") - self.form.setWindowTitle(translate("CAM_ToolBit", "Tool Selector")) + self.form.setWindowTitle(translate("CAM_ToolBit", "Toolbit Selector")) self.form.setMinimumSize(600, 400) self.form.resize(800, 600) self.form.adjustSize() @@ -66,7 +65,7 @@ class ToolBitLibraryDock(object): self.form_layout.setSpacing(4) # Create the browser widget - self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets) + self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets) self._setup_ui() @@ -80,7 +79,6 @@ class ToolBitLibraryDock(object): main_layout.setContentsMargins(4, 4, 4, 4) main_layout.setSpacing(4) - # Add the browser widget to the layout main_layout.addWidget(self.browser_widget) # Create buttons @@ -88,11 +86,19 @@ class ToolBitLibraryDock(object): translate("CAM_ToolBit", "Open Library Editor") ) self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job")) + self.closeButton = QtGui.QPushButton(translate("CAM_ToolBit", "Close")) - # Add buttons to a horizontal layout + button_width = 120 + self.libraryEditorOpenButton.setMinimumWidth(button_width) + self.addToolControllerButton.setMinimumWidth(button_width) + self.closeButton.setMinimumWidth(button_width) + + # Add buttons to a horizontal layout, right-align Close button_layout = QtGui.QHBoxLayout() button_layout.addWidget(self.libraryEditorOpenButton) button_layout.addWidget(self.addToolControllerButton) + button_layout.addStretch(1) + button_layout.addWidget(self.closeButton) # Add the button layout to the main layout main_layout.addLayout(button_layout) @@ -101,26 +107,32 @@ class ToolBitLibraryDock(object): self.form.layout().addWidget(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.browser_widget.toolSelected.connect(lambda x: self._update_state()) + self.browser_widget.itemDoubleClicked.connect(self._on_doubleclick) self.libraryEditorOpenButton.clicked.connect(self._open_editor) - self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc)) + self.addToolControllerButton.clicked.connect(self._add_tool_controller_to_doc) + self.closeButton.clicked.connect(self.form.reject) - # Initial state of buttons + # Update the initial state of the UI self._update_state() + def _count_jobs(self): + if not FreeCAD.ActiveDocument: + return 0 + return len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 + 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) + selected = bool(self.browser_widget.get_selected_bit_uris()) + has_job = selected and self._count_jobs() > 0 + self.addToolControllerButton.setEnabled(selected and has_job) + + def _on_doubleclick(self, toolbit: ToolBit): + """Opens the ToolBitEditor for the selected toolbit.""" + self._add_tool_controller_to_doc() def _open_editor(self): - library = LibraryEditor() + library = LibraryEditor(parent=FreeCADGui.getMainWindow()) library.open() # After editing, we might need to refresh the libraries in the browser widget # Assuming _populate_libraries is the correct method to call @@ -148,7 +160,7 @@ class ToolBitLibraryDock(object): return tools - def _add_tool_controller_to_doc(self, index=None): + def _add_tool_controller_to_doc(self): """ if no jobs, don't do anything, otherwise all TCs for all selected toolbit assets diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py index 95090336f2..0bb1f7cf55 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -21,17 +21,21 @@ # * USA * # * * # *************************************************************************** - - +import yaml +import pathlib 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 PySide.QtGui import ( + QStandardItem, + QStandardItemModel, + QPixmap, + QDialog, + QMessageBox, + QWidget, +) +from PySide.QtCore import Qt, QEvent +from typing import List, cast, Tuple, Optional from ...assets import AssetUri from ...assets.ui import AssetOpenDialog, AssetSaveDialog from ...camassets import cam_assets, ensure_assets_initialized @@ -39,8 +43,12 @@ 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 +from ...toolbit.ui.toollist import ToolBitUriListMimeType +from ...toolbit.ui.util import natural_sort_key +from ..serializers import all_serializers as library_serializers +from ..models import Library +from .browser import LibraryBrowserWidget +from .properties import LibraryPropertyDialog if False: @@ -50,99 +58,244 @@ 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 - - +_LibraryRole = Qt.UserRole + 1 translate = FreeCAD.Qt.translate -class _TableView(PySide.QtGui.QTableView): - """Subclass of QTableView to support rearrange and copying of ToolBits""" +class LibraryEditor(QWidget): + """LibraryEditor is the controller for + displaying/selecting/creating/editing a collection 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""" + def __init__(self, parent=None): + super().__init__(parent=parent) 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) + ensure_assets_initialized(cam_assets) + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") + self.form.installEventFilter(self) # to forward keypress events + self._base_title = self.form.windowTitle() - # get the uuids of all srcRows - model = self.toolModel() - srcUuids = [self._uuidOfRow(row) for row in set(srcRows)] - destRow = self.rowAt(event.pos().y()) + # Create the library list. + self.listModel = QStandardItemModel() + self.form.TableList.setModel(self.listModel) + self.form.TableList.clicked.connect(self._on_library_selected) - self._copyTools(srcUuids, destRow) - if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction(): - for uuid in srcUuids: - model.removeRow(self._rowWithUuid(uuid)) + # Enable drop support for the library list + self.form.TableList.viewport().installEventFilter(self) # Also on viewport + # Create the LibraryBrowserWidget + self.browser = LibraryBrowserWidget( + asset_manager=cam_assets, + parent=self, + ) + self.browser.setDragEnabled(True) + self.form.verticalLayout_2.layout().replaceWidget(self.form.toolTable, self.browser) + self.form.toolTable.hide() -class ModelFactory: - """Helper class to generate qtdata models for toolbit libraries""" + # Connect signals. + self.browser.itemDoubleClicked.connect(self.browser._on_edit_requested) - @staticmethod - def find_libraries(model) -> QStandardItemModel: - """ - Finds all the fctl files in a location. - Returns a QStandardItemModel. - """ + self.form.addLibraryButton.clicked.connect(self._on_add_library_requested) + self.form.removeLibraryButton.clicked.connect(self._on_remove_library_requested) + self.form.renameLibraryButton.clicked.connect(self._on_rename_library_requested) + self.form.importLibraryButton.clicked.connect(self._on_import_library_requested) + self.form.exportLibraryButton.clicked.connect(self._on_export_library_requested) + + self.form.addToolBitButton.clicked.connect(self._on_add_toolbit_requested) + self.form.importToolBitButton.clicked.connect(self._on_import_toolbit_requested) + self.form.exportToolBitButton.clicked.connect(self._on_export_toolbit_requested) + + # Populate the UI. + self._refresh_library_list() + self._select_last_library() + self._update_button_states() + + def _highlight_row(self, index): + """Highlights the row at the given index using the selection model.""" + if not index.isValid(): + return + self.form.TableList.setCurrentIndex(index) + + def _clear_highlight(self): + """Clears the highlighting from the previously highlighted row.""" + self.form.TableList.selectionModel().clear() + + def eventFilter(self, obj, event): + if event.type() == QEvent.KeyPress and self.form.TableList.hasFocus(): + if event.key() == Qt.Key_F2: + Path.Log.debug("F2 pressed on library list.") + self._on_rename_library_requested() + return True + elif event.key() == Qt.Key_Delete: + Path.Log.debug("Del pressed on library list.") + self._on_remove_library_requested() + return True + if obj == self.form.TableList.viewport(): + if event.type() == QEvent.DragEnter or event.type() == QEvent.DragMove: + return self._handle_drag_enter(event) + elif event.type() == QEvent.DragLeave: + self._handle_drag_leave(event) + return True + elif event.type() == QEvent.Drop: + return self._handle_drop(event) + return super().eventFilter(obj, event) + + def _handle_drag_enter(self, event): + """Handle drag enter and move events for the library list.""" + mime_data = event.mimeData() + Path.Log.debug(f"_handle_drag_enter: MIME formats: {mime_data.formats()}") + if not mime_data.hasFormat(ToolBitUriListMimeType): + Path.Log.debug("_handle_drag_enter: Invalid MIME type, ignoring") + return True + + # Get the row being hovered. + pos = event.pos() + event.acceptProposedAction() + index = self.form.TableList.indexAt(pos) + if not index.isValid(): + self._clear_highlight() + return True + + # Prevent drop into "All Toolbits" + item = self.listModel.itemFromIndex(index) + if not item or item.data(_LibraryRole) == "all_tools": + self._clear_highlight() + return True + + self._highlight_row(index) + return True + + def _handle_drag_leave(self, event): + """Handle drag leave event for the library list.""" + self._clear_highlight() + + def _handle_drop(self, event): + """Handle drop events to move or copy toolbits to the target library.""" + mime_data = event.mimeData() + if not (mime_data.hasFormat(ToolBitUriListMimeType)): + event.ignore() + return True + + self._clear_highlight() + pos = event.pos() + index = self.form.TableList.indexAt(pos) + if not index.isValid(): + event.ignore() + return True + + item = self.listModel.itemFromIndex(index) + if not item or item.data(_LibraryRole) == "all_tools": + event.ignore() + return True + + target_library_id = item.data(_LibraryRole) + target_library_uri = f"toolbitlibrary://{target_library_id}" + target_library = cast(Library, cam_assets.get(target_library_uri, depth=1)) + + try: + clipboard_content_yaml = mime_data.data(ToolBitUriListMimeType).data().decode("utf-8") + clipboard_data_dict = yaml.safe_load(clipboard_content_yaml) + + if not isinstance(clipboard_data_dict, dict) or "toolbits" not in clipboard_data_dict: + event.ignore() + return True + + uris = clipboard_data_dict["toolbits"] + new_uris = set() + + # Get the current library from the browser + current_library = self.browser.get_current_library() + + for uri in uris: + try: + toolbit = cast(ToolBit, cam_assets.get(AssetUri(uri), depth=0)) + if toolbit: + added_toolbit = target_library.add_bit(toolbit) + if added_toolbit: + new_uris.add(str(toolbit.get_uri())) + + # Remove the toolbit from the current library if it exists and + # it's not "all_tools" + if current_library and current_library.get_id() != "all_tools": + current_library.remove_bit(toolbit) + except Exception as e: + Path.Log.error(f"Failed to load toolbit from URI {uri}: {e}") + continue + + if new_uris: + cam_assets.add(target_library) + # Save the current library if it was modified + if current_library and current_library.get_id() != "all_tools": + cam_assets.add(current_library) + self.browser.refresh() + self.browser.select_by_uri(list(new_uris)) + self._update_button_states() + + event.acceptProposedAction() + except Exception as e: + Path.Log.error(f"Failed to process drop event: {e}") + event.ignore() + return True + + def get_selected_library_id(self) -> Optional[str]: + index = self.form.TableList.currentIndex() + if not index.isValid(): + return None + item = self.listModel.itemFromIndex(index) + if not item: + return None + return item.data(_LibraryRole) + + def get_selected_library(self, depth=1) -> Optional[Library]: + library_id = self.get_selected_library_id() + if not library_id: + return None + uri = f"toolbitlibrary://{library_id}" + return cast(Library, cam_assets.get(uri, depth=depth)) + + def select_library_by_uri(self, uri: AssetUri): + # 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) == uri.asset_id: + index = i + break + else: + return + + # Select it. + if index <= self.listModel.rowCount(): + item = self.listModel.item(index) + if item: # Should always be true, but... + self.form.TableList.setCurrentIndex(self.listModel.index(index, 0)) + self._on_library_selected() + + def _select_last_library(self): + # Find the last used library. + last_used_lib_identifier = Path.Preferences.getLastToolLibrary() + if last_used_lib_identifier: + uri = Library.resolve_name(last_used_lib_identifier) + self.select_library_by_uri(uri) + + def open(self): Path.Log.track() - model.clear() + return self.form.exec_() + + def _refresh_library_list(self): + """Clears and repopulates the self.listModel with available libraries.""" + Path.Log.track() + self.listModel.clear() + + # Add "All Toolbits" item + all_tools_item = QStandardItem(translate("CAM", "All Toolbits")) + all_tools_item.setData("all_tools", _LibraryRole) + # all_tools_item.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) + # Make the "All Toolbits" item bold and italic + font = all_tools_item.font() + font.setBold(True) + font.setItalic(True) + all_tools_item.setFont(font) + self.listModel.appendRow(all_tools_item) # Use AssetManager to fetch library assets (depth=0 for shallow fetch) try: @@ -152,141 +305,187 @@ class ModelFactory: 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 + return # 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): + for library in sorted( + libraries, + key=lambda library: natural_sort_key(library.label or library.get_id()), + ): 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.setData(library.get_id(), _LibraryRole) # Store the library ID libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) - model.appendRow(libItem) + self.listModel.appendRow(libItem) - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model + Path.Log.debug("model rows: {}".format(self.listModel.rowCount())) - @staticmethod - def __library_load(library_uri: str, data_model: QStandardItemModel): - Path.Log.track(library_uri) + self.listModel.setHorizontalHeaderLabels(["Library"]) - if library_uri: - # Store the AssetUri string, not just the name - Path.Preferences.setLastToolLibrary(library_uri) + def _on_library_selected(self): + """Sets the current library in the browser when a library is selected.""" + Path.Log.debug("_on_library_selected: Called.") + index = self.form.TableList.currentIndex() + item = self.listModel.itemFromIndex(index) + if not item: + return + if item.data(_LibraryRole) == "all_tools": + selected_library = None + else: + selected_library = self.get_selected_library() + self.browser.set_current_library(selected_library) + self._update_window_title() + self._update_button_states() + def _update_window_title(self): + """Updates the window title with the current library name.""" + current_library = self.browser.get_current_library() + if current_library: + title = f"{self._base_title} - {current_library.label}" + else: + title = self._base_title + self.form.setWindowTitle(title) + + def _update_button_states(self): + """Updates the enabled state of library management buttons.""" + library_selected = self.browser.get_current_library() is not None + self.form.addLibraryButton.setEnabled(True) + self.form.removeLibraryButton.setEnabled(library_selected) + self.form.renameLibraryButton.setEnabled(library_selected) + self.form.exportLibraryButton.setEnabled(library_selected) + self.form.importLibraryButton.setEnabled(True) + self.form.addToolBitButton.setEnabled( + True + ) # Always enabled - can create standalone toolbits + # TODO: self.form.exportToolBitButton.setEnabled(toolbit_selected) + + def _save_library(self): + """Internal method to save the current tool library asset""" + Path.Log.track() + library = self.browser.get_current_library() + if not library: + return + + # Save the modified library asset. try: - # Load the library asset using AssetManager - loaded_library = cam_assets.get(AssetUri(library_uri), depth=1) + cam_assets.add(library) + Path.Log.debug(f"Library {library.get_uri()} saved") except Exception as e: - Path.Log.error(f"Failed to load library from {library_uri}: {e}") + Path.Log.error(f"Failed to save library {library.get_uri()}: {e}") + QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Saving Library"), + str(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."), - ) + def _on_add_library_requested(self): + Path.Log.debug("_on_add_library_requested: Called.") + new_library = Library(FreeCAD.Qt.translate("CAM", "New Library")) + dialog = LibraryPropertyDialog(new_library, new=True, parent=self) + if dialog.exec_() != QDialog.Accepted: return + uri = cam_assets.add(new_library) + Path.Log.debug(f"_on_add_library_requested: New library URI = {uri}") + self._refresh_library_list() + self.select_library_by_uri(uri) + self._update_button_states() + + def _on_remove_library_requested(self): + """Handles request to remove the selected library.""" + Path.Log.debug("_on_remove_library_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + return + + reply = QMessageBox.question( + self, + FreeCAD.Qt.translate("CAM", "Confirm Library Removal"), + FreeCAD.Qt.translate( + "CAM", + "Are you sure you want to remove the library '{0}'?\n" + "This will not delete the toolbits contained within it.", + ).format(current_library.label), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply != QMessageBox.Yes: + return + + try: + library_uri = current_library.get_uri() + cam_assets.delete(library_uri) + Path.Log.info(f"Library {current_library.label} deleted.") + self._refresh_library_list() + self.browser.refresh() + self._update_button_states() + except FileNotFoundError as e: + Path.Log.error(f"Failed to delete library {current_library.label}: {e}") + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error"), + FreeCAD.Qt.translate("CAM", "Failed to delete library '{0}': {1}").format( + current_library.label, str(e) + ), + ) + + def _on_rename_library_requested(self): + """Handles request to rename the selected library.""" + Path.Log.debug("_on_rename_library_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + return + + dialog = LibraryPropertyDialog(current_library, new=False, parent=self) + if dialog.exec_() != QDialog.Accepted: + return + + cam_assets.add(current_library) + self._refresh_library_list() + self._update_button_states() + + def _on_import_library_requested(self): + """Handles request to import a library.""" + Path.Log.debug("_on_import_library_requested: Called.") + dialog = AssetOpenDialog( + cam_assets, asset_class=Library, serializers=library_serializers, parent=self + ) + response = dialog.exec_() + if not response: + return + file_path, library = cast(Tuple[pathlib.Path, Library], response) + + try: + cam_assets.add(library) + self._refresh_library_list() + self._update_button_states() + except Exception as e: + Path.Log.error(f"Failed to import library: {file_path} {e}") + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error"), + FreeCAD.Qt.translate("CAM", f"Failed to import library: {file_path} {e}"), + ) + + def _on_export_library_requested(self): + """Handles request to export the selected library.""" + Path.Log.debug("_on_export_library_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + return + + dialog = AssetSaveDialog(asset_class=Library, serializers=library_serializers, parent=self) + dialog.exec_(current_library) + self._update_button_states() + + def _on_add_toolbit_requested(self): + """Handles request to add a new toolbit to the current library or create standalone.""" + Path.Log.debug("_on_add_toolbit_requested: Called.") + current_library = self.browser.get_current_library() + # Select the shape for the new toolbit selector = ShapeSelector() shape = selector.show() @@ -297,380 +496,142 @@ class LibraryEditor(object): # 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) + # Create a new ToolBit instance + new_toolbit = tool_bit_class(shape) + new_toolbit.label = FreeCAD.Qt.translate("CAM", "New Toolbit") - # 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}") + # Save the individual toolbit asset first + tool_asset_uri = cam_assets.add(new_toolbit) + Path.Log.debug(f"_on_add_toolbit_requested: 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.saveLibrary() + # Add the toolbit to the current library if one is selected + if current_library: + toolno = current_library.add_bit(new_toolbit) + Path.Log.debug( + f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) " + f"to current_library with number {toolno}." + ) + # Save the library + cam_assets.add(current_library) + else: + Path.Log.debug( + f"_on_add_toolbit_requested: Created standalone toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()})" + ) 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"), + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error Creating Toolbit"), str(e), ) raise - def toolBitExisting(self): - """Add an existing toolbit asset to the current library""" - Path.Log.track() + self.browser.refresh() + self.browser.select_by_uri([str(new_toolbit.get_uri())]) + self._update_button_states() - 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."), + def _on_import_toolbit_requested(self): + """Handles request to import a toolbit.""" + Path.Log.debug("_on_import_toolbit_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + Path.Log.warning("Cannot import toolbit: No library selected.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select a 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.saveLibrary() - - 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.saveLibrary() - - 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 = cast(ToolBit, 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.saveLibrary() - - 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:"), + dialog = AssetOpenDialog( + cam_assets, asset_class=ToolBit, serializers=toolbit_serializers, parent=self ) - if not ok or not library_label: + response = dialog.exec_() + if not response: return + file_path, toolbit = cast(Tuple[pathlib.Path, ToolBit], response) - # 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 saveLibrary(self): - """Internal method to save the current tool library asset""" - Path.Log.track() - if not self.current_library: - Path.Log.warning("saveLibrary: No library asset loaded to save.") - return - - # Create a new dictionary to hold the updated tool numbers and bits - for row in range(self.toolModel.rowCount()): - tool_nr_item = self.toolModel.item(row, 0) - tool_uri_item = self.toolModel.item( - row, 0 - ) # Tool URI is stored in column 0 with _PathRole - - tool_nr = tool_nr_item.data(Qt.EditRole) - tool_uri_string = tool_uri_item.data(_PathRole) - - if tool_nr is not None and tool_uri_string: - try: - tool_uri = AssetUri(tool_uri_string) - # Retrieve the toolbit using the public method - found_bit = self.current_library.get_tool_by_uri(tool_uri) - - if found_bit: - # Use assign_new_bit_no to update the tool number - # This method modifies the library in place - self.current_library.assign_new_bit_no(found_bit, int(tool_nr)) - Path.Log.debug(f"Assigned tool number {tool_nr} to {tool_uri_string}") - else: - Path.Log.warning( - f"Toolbit with URI {tool_uri_string} not found in current library." - ) - except Exception as e: - Path.Log.error( - f"Error processing row {row} (tool_nr: {tool_nr}, uri: {tool_uri_string}): {e}" - ) - # Continue processing other rows even if one fails - continue - else: - Path.Log.warning(f"Skipping row {row}: Invalid tool number or URI.") - - # The current_library object has been modified in the loop by assign_new_bit_no - # Now save the modified library asset - try: - cam_assets.add(self.current_library) - Path.Log.debug(f"saveLibrary: Library " f"{self.current_library.get_uri()} saved.") - except Exception as e: - Path.Log.error( - f"saveLibrary: 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(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 - + # Debug logging for imported toolbit Path.Log.info( - f"Exported library {self.current_library.label} " - f"to {file_path} using serializer {serializer_class.__name__}" + f"IMPORT TOOLBIT: file_path={file_path}, toolbit.id={toolbit.id}, toolbit.label={toolbit.label}" ) + import traceback - def columnNames(self): - return [ - "Tn", - translate("CAM_ToolBit", "Tool"), - translate("CAM_ToolBit", "Shape"), - ] + stack = traceback.format_stack() + caller_info = "".join(stack[-3:-1]) + Path.Log.info(f"IMPORT TOOLBIT CALLER:\n{caller_info}") - 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 + # Check if toolbit already exists in asset manager + toolbit_uri = toolbit.get_uri() + Path.Log.info(f"IMPORT CHECK: toolbit_uri={toolbit_uri}") + existing_toolbit = None 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 + existing_toolbit = cam_assets.get(toolbit_uri, store=["local", "builtin"], depth=0) + Path.Log.info( + f"IMPORT CHECK: Toolbit {toolbit.id} already exists, using existing reference" + ) + Path.Log.info( + f"IMPORT CHECK: existing_toolbit.id={existing_toolbit.id}, existing_toolbit.label={existing_toolbit.label}" + ) + except FileNotFoundError: + # Toolbit doesn't exist, save it as new + Path.Log.info(f"IMPORT CHECK: Toolbit {toolbit.id} is new, saving to disk") + new_uri = cam_assets.add(toolbit) + Path.Log.info(f"IMPORT CHECK: Toolbit saved with new URI: {new_uri}") + existing_toolbit = toolbit - # 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())) + # Add the toolbit (existing or new) to the current library + Path.Log.info( + f"IMPORT ADD: Adding toolbit {existing_toolbit.id} to library {current_library.label}" + ) + added_toolbit = current_library.add_bit(existing_toolbit) + if added_toolbit: + Path.Log.info(f"IMPORT ADD: Successfully added toolbit to library") + cam_assets.add(current_library) # Save the modified library + self.browser.refresh() + self.browser.select_by_uri([str(existing_toolbit.get_uri())]) + self._update_button_states() + else: + Path.Log.warning(f"IMPORT ADD: Failed to add toolbit {existing_toolbit.id} to library") + Path.Log.warning( + f"IMPORT FAILED: Failed to import toolbit from {file_path} to library {current_library.label}." + ) + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate( + "CAM", + f"Failed to import toolbit from '{file_path}' to library '{current_library.label}'.", + ), ) - self.toolModel.setHorizontalHeaderLabels(self.columnNames()) - self.toolTableView.setUpdatesEnabled(True) + def _on_export_toolbit_requested(self): + """Handles request to export the selected toolbit.""" + Path.Log.debug("_on_export_toolbit_requested: Called.") + selected_toolbits = self.browser.get_selected_bits() + if not selected_toolbits: + Path.Log.warning("Cannot export toolbit: No toolbit selected.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select a toolbit to export."), + ) + return - def setupUI(self): - """Setup the form and load the tool library data""" - Path.Log.track() + if len(selected_toolbits) > 1: + Path.Log.warning("Cannot export multiple toolbits: Please select only one.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select only one toolbit to export."), + ) + return - 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.saveLibrary.clicked.connect(self.saveLibrary) - - self.form.okButton.clicked.connect(self.form.close) - - self.toolSelect([], []) + toolbit_to_export = selected_toolbits[0] + dialog = AssetSaveDialog(asset_class=ToolBit, serializers=toolbit_serializers, parent=self) + dialog.exec_(toolbit_to_export) # This will open the save dialog and handle the export + self._update_button_states() diff --git a/src/Mod/CAM/Path/Tool/library/ui/properties.py b/src/Mod/CAM/Path/Tool/library/ui/properties.py new file mode 100644 index 0000000000..42202eea43 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/properties.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + + +from PySide import QtWidgets +import FreeCADGui +import FreeCAD +from ..models.library import Library + + +class LibraryPropertyDialog(QtWidgets.QDialog): + def __init__(self, library: Library, new=False, parent=None): + super(LibraryPropertyDialog, self).__init__(parent) + self.library = library + + # Load the UI file into a QWidget + self.form = FreeCADGui.PySideUic.loadUi(":/panels/LibraryProperties.ui") + + # Create a layout for the dialog and add the loaded form widget + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.form) + self.setLayout(layout) + + # Connect signals and set initial values using the loaded form + self.form.lineEditLibraryName.setText(self.library.label) + self.update_window_title() + + self.form.buttonBox.accepted.connect(self.save_properties) + self.form.buttonBox.rejected.connect(self.reject) + + # Connect text changed signal to update window title + self.form.lineEditLibraryName.textChanged.connect(self.update_window_title) + + # Make the OK button the default so Enter key works + ok_button = self.form.buttonBox.button(QtWidgets.QDialogButtonBox.Ok) + cancel_button = self.form.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel) + + if cancel_button: + cancel_button.setDefault(False) + cancel_button.setAutoDefault(False) + + if ok_button: + ok_button.setDefault(True) + ok_button.setAutoDefault(True) + ok_button.setFocus() # Also set focus to the OK button + + # Set minimum width for the dialog + self.setMinimumWidth(450) + + # Set focus to the text input so user can start typing immediately + self.form.lineEditLibraryName.setFocus() + self.form.lineEditLibraryName.selectAll() # Select all text for easy replacement + + def update_window_title(self): + # Update title based on current text in the line edit + current_name = self.form.lineEditLibraryName.text() + title = FreeCAD.Qt.translate( + "LibraryPropertyDialog", f"Library Properties - {current_name or self.library.label}" + ) + self.setWindowTitle(title) + + def save_properties(self): + new_name = self.form.lineEditLibraryName.text() + if new_name != self.library.label: + self.library._label = new_name + # Additional logic to save other properties if added later + self.accept() diff --git a/src/Mod/CAM/Path/Tool/shape/__init__.py b/src/Mod/CAM/Path/Tool/shape/__init__.py index 70aa088931..1a19b62139 100644 --- a/src/Mod/CAM/Path/Tool/shape/__init__.py +++ b/src/Mod/CAM/Path/Tool/shape/__init__.py @@ -10,7 +10,7 @@ from .models.custom import ToolBitShapeCustom from .models.dovetail import ToolBitShapeDovetail from .models.drill import ToolBitShapeDrill from .models.endmill import ToolBitShapeEndmill -from .models.fillet import ToolBitShapeFillet +from .models.radius import ToolBitShapeRadius from .models.probe import ToolBitShapeProbe from .models.reamer import ToolBitShapeReamer from .models.slittingsaw import ToolBitShapeSlittingSaw @@ -36,7 +36,7 @@ __all__ = [ "ToolBitShapeDovetail", "ToolBitShapeDrill", "ToolBitShapeEndmill", - "ToolBitShapeFillet", + "ToolBitShapeRadius", "ToolBitShapeProbe", "ToolBitShapeReamer", "ToolBitShapeSlittingSaw", diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py index 312109318b..2ecc850f55 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/base.py +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -133,7 +133,9 @@ class ToolBitShape(Asset): shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()} shape_class = shape_classes.get(body_obj.Label) if not shape_class: - return ToolBitShape.get_subclass_by_name("Custom") + custom = ToolBitShape.get_subclass_by_name("Custom") + assert custom is not None, "BUG: Custom tool class not found" + return custom return shape_class @classmethod @@ -228,7 +230,13 @@ class ToolBitShape(Asset): # Find the correct subclass based on the body label shape_class = cls.get_subclass_by_name(body_label) - return shape_class or ToolBitShape.get_subclass_by_name("Custom") + if shape_class: + return shape_class + + # All else fails, treat the shape as a custom shape. + custom = ToolBitShape.get_subclass_by_name("Custom") + assert custom is not None, "BUG: Custom tool class not found" + return custom except zipfile.BadZipFile: raise ValueError("Invalid FCStd file data (not a valid zip archive)") diff --git a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py index faeb13dc45..2e78f4eb4e 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py @@ -52,12 +52,12 @@ class ToolBitShapeBullnose(ToolBitShape): FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), "App::PropertyLength", ), - "FlatRadius": ( - FreeCAD.Qt.translate("ToolBitShape", "Torus radius"), + "CornerRadius": ( + FreeCAD.Qt.translate("ToolBitShape", "Corner radius"), "App::PropertyLength", ), } @property def label(self) -> str: - return FreeCAD.Qt.translate("ToolBitShape", "Torus") + return FreeCAD.Qt.translate("ToolBitShape", "Bullnose") diff --git a/src/Mod/CAM/Path/Tool/shape/models/custom.py b/src/Mod/CAM/Path/Tool/shape/models/custom.py index bdd1c12e8b..a64069068d 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/custom.py +++ b/src/Mod/CAM/Path/Tool/shape/models/custom.py @@ -34,9 +34,31 @@ class ToolBitShapeCustom(ToolBitShape): name: str = "Custom" aliases = ("custom",) + # Connor: We're going to treat custom tools as normal endmills @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: - return {} + 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: diff --git a/src/Mod/CAM/Path/Tool/shape/models/fillet.py b/src/Mod/CAM/Path/Tool/shape/models/radius.py similarity index 86% rename from src/Mod/CAM/Path/Tool/shape/models/fillet.py rename to src/Mod/CAM/Path/Tool/shape/models/radius.py index 0156b910ff..be99fa6024 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/fillet.py +++ b/src/Mod/CAM/Path/Tool/shape/models/radius.py @@ -25,23 +25,26 @@ from typing import Tuple, Mapping from .base import ToolBitShape -class ToolBitShapeFillet(ToolBitShape): - name = "Fillet" - aliases = ("fillet",) +class ToolBitShapeRadius(ToolBitShape): + name = "Radius" + aliases = ( + "radius", + "fillet", + ) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: return { - "CrownHeight": ( - FreeCAD.Qt.translate("ToolBitShape", "Crown height"), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), "App::PropertyLength", ), "Diameter": ( FreeCAD.Qt.translate("ToolBitShape", "Diameter"), "App::PropertyLength", ), - "FilletRadius": ( - FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"), + "CuttingRadius": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting radius"), "App::PropertyLength", ), "Flutes": ( @@ -60,4 +63,4 @@ class ToolBitShapeFillet(ToolBitShape): @property def label(self) -> str: - return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer") + return FreeCAD.Qt.translate("ToolBitShape", "Radius Mill") diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py index 5e4e7ce8a6..542c847bad 100644 --- a/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py @@ -39,7 +39,6 @@ class ShapeSelector: self.flows = {} self.update_shapes() - self.form.toolBox.setCurrentIndex(0) def _add_shape_group(self, toolbox): if toolbox in self.flows: @@ -70,8 +69,10 @@ class ShapeSelector: custom = cam_assets.fetch(asset_type="toolbitshape", store="local") for shape in custom: builtin.pop(shape.id, None) - self._add_shapes(self.form.standardTools, builtin.values()) - self._add_shapes(self.form.customTools, custom) + + # Combine all shapes into a single list + all_shapes = list(builtin.values()) + list(custom) + self._add_shapes(self.form.toolsContainer, all_shapes) def on_shape_button_clicked(self, shape): self.shape = shape diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py index bcbc505178..e6f4a1ff00 100644 --- a/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py @@ -44,7 +44,7 @@ class ShapeWidget(QtGui.QWidget): self.layout.setAlignment(QtCore.Qt.AlignHCenter) self.shape = shape - self.icon_size = icon_size or QtCore.QSize(200, 235) + self.icon_size = icon_size or QtCore.QSize(140, 165) # 200 x 235 self.icon_widget = QtGui.QLabel() self.layout.addWidget(self.icon_widget) diff --git a/src/Mod/CAM/Path/Tool/toolbit/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/__init__.py index 9ae1e129ad..37fa8aa694 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/__init__.py +++ b/src/Mod/CAM/Path/Tool/toolbit/__init__.py @@ -10,7 +10,7 @@ from .models.custom import ToolBitCustom from .models.dovetail import ToolBitDovetail from .models.drill import ToolBitDrill from .models.endmill import ToolBitEndmill -from .models.fillet import ToolBitFillet +from .models.radius import ToolBitRadius from .models.probe import ToolBitProbe from .models.reamer import ToolBitReamer from .models.slittingsaw import ToolBitSlittingSaw @@ -28,7 +28,7 @@ __all__ = [ "ToolBitDovetail", "ToolBitDrill", "ToolBitEndmill", - "ToolBitFillet", + "ToolBitRadius", "ToolBitProbe", "ToolBitReamer", "ToolBitSlittingSaw", diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py index 9edaf3516d..d894254a1a 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py @@ -36,9 +36,9 @@ class ToolBitBallend(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) 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 index 3f3c7155a4..9cfff72974 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -291,6 +291,9 @@ class ToolBit(Asset, ABC): """Returns the unique ID of the tool bit.""" return self.id + def set_id(self, id: str = None): + self.id = id if id is not None else str(uuid.uuid4()) + def _promote_toolbit(self): """ Updates the toolbit properties for backward compatibility. @@ -510,6 +513,7 @@ class ToolBit(Asset, ABC): self._create_base_properties() # Transfer property values from the detached object to the real object + self._suppress_visual_update = True temp_obj.copy_to(self.obj) # Ensure label is set @@ -517,6 +521,7 @@ class ToolBit(Asset, ABC): # Update the visual representation now that it's attached self._update_tool_properties() + self._suppress_visual_update = False self._update_visual_representation() def onChanged(self, obj, prop): @@ -525,6 +530,9 @@ class ToolBit(Asset, ABC): if "Restore" in obj.State: return + if getattr(self, "_suppress_visual_update", False): + return + if hasattr(self, "_in_update") and self._in_update: Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.") return @@ -589,9 +597,11 @@ class ToolBit(Asset, ABC): def get_property(self, name: str): return self.obj.getPropertyByName(name) - def get_property_str(self, name: str, default: Optional[str] = None) -> Optional[str]: + def get_property_str( + self, name: str, default: str | None = None, precision: int | None = None + ) -> str | None: value = self.get_property(name) - return format_value(value) if value else default + return format_value(value, precision=precision) if value else default def set_property(self, name: str, value: Any): return self.obj.setPropertyByName(name, value) @@ -751,6 +761,7 @@ class ToolBit(Asset, ABC): Path.Log.track(self.obj.Label) attrs = {} attrs["version"] = 2 + attrs["id"] = self.id attrs["name"] = self.obj.Label attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd" attrs["shape-type"] = self._tool_bit_shape.name diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py index caf497d423..aa7c7bbdbf 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -36,12 +36,13 @@ class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") - flat_radius = self.get_property_str("FlatRadius", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) + # flat_radius = self.get_property_str("FlatRadius", "?", precision=3) + corner_radius = self.get_property_str("CornerRadius", "?", precision=3) return FreeCAD.Qt.translate( "CAM", - f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {flat_radius} flat radius", + f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {corner_radius} corner radius", ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py index da0abce4d6..29683c6ec7 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py @@ -36,9 +36,9 @@ class ToolBitChamfer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} {cutting_edge_angle} chamfer bit, {flutes}-flute" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/custom.py b/src/Mod/CAM/Path/Tool/toolbit/models/custom.py index b32004a796..b969e515e0 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/custom.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/custom.py @@ -35,3 +35,32 @@ class ToolBitCustom(ToolBit): @property def summary(self) -> str: return FreeCAD.Qt.translate("CAM", "Unknown custom toolbit type") + + # Connor: Adding in getters and setters for diameter and length + 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/dovetail.py b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py index aac48338b6..64bb161143 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py @@ -36,8 +36,8 @@ class ToolBitDovetail(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?", precision=3) flutes = self.get_property("Flutes") return FreeCAD.Qt.translate( diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py index cc5055d372..105e9a3586 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py @@ -36,8 +36,8 @@ class ToolBitDrill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - tip_angle = self.get_property_str("TipAngle", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + tip_angle = self.get_property_str("TipAngle", "?", precision=3) 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 index 6651705540..2b3b3dff8d 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py @@ -36,9 +36,9 @@ class ToolBitEndmill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) 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/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py index f0330084ef..c07c6b879b 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py @@ -36,9 +36,9 @@ class ToolBitProbe(ToolBit): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - length = self.get_property_str("Length", "?") - shaft_diameter = self.get_property_str("ShaftDiameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + length = self.get_property_str("Length", "?", precision=3) + shaft_diameter = self.get_property_str("ShaftDiameter", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} probe, {length} length, {shaft_diameter} shaft" @@ -46,3 +46,32 @@ class ToolBitProbe(ToolBit): def can_rotate(self) -> bool: return False + + # Connor: Add getters and setters for Diameter and Length + 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/fillet.py b/src/Mod/CAM/Path/Tool/toolbit/models/radius.py similarity index 79% rename from src/Mod/CAM/Path/Tool/toolbit/models/fillet.py rename to src/Mod/CAM/Path/Tool/toolbit/models/radius.py index a23f82ecf0..b289e2bad5 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/radius.py @@ -21,25 +21,25 @@ # *************************************************************************** import FreeCAD import Path -from ...shape import ToolBitShapeFillet +from ...shape import ToolBitShapeRadius from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit -class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin): - SHAPE_CLASS = ToolBitShapeFillet +class ToolBitRadius(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeRadius - def __init__(self, shape: ToolBitShapeFillet, id: str | None = None): - Path.Log.track(f"ToolBitFillet __init__ called with shape: {shape}, id: {id}") + def __init__(self, shape: ToolBitShapeRadius, id: str | None = None): + Path.Log.track(f"ToolBitRadius __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", "?") + radius = self.get_property_str("CuttingRadius", "?", precision=3) flutes = self.get_property("Flutes") - diameter = self.get_property_str("ShankDiameter", "?") + diameter = self.get_property_str("ShankDiameter", "?", precision=3) return FreeCAD.Qt.translate( - "CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute" + "CAM", f"R{radius} radius mill, {diameter} shank, {flutes}-flute" ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py index d8b7fbcefb..5e9d624afe 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py @@ -36,7 +36,7 @@ class ToolBitReamer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) 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 index 2c779edc33..af7a585afd 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py @@ -36,8 +36,8 @@ class ToolBitSlittingSaw(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - blade_thickness = self.get_property_str("BladeThickness", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + blade_thickness = self.get_property_str("BladeThickness", "?", precision=3) flutes = self.get_property("Flutes") return FreeCAD.Qt.translate( diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py index 662a2e7376..4a83a59822 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py @@ -36,9 +36,9 @@ class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?") + cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?", precision=3) 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 index 131be1abb4..771f35a7b3 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py @@ -36,9 +36,9 @@ class ToolBitThreadMill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_angle = self.get_property_str("cuttingAngle", "?") + cutting_angle = self.get_property_str("cuttingAngle", "?", precision=3) 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 index cfabf0e978..159cffb20b 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py @@ -36,8 +36,8 @@ class ToolBitVBit(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?", precision=3) 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 index 3ef9d0b167..bba618aa12 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py @@ -1,12 +1,18 @@ from .camotics import CamoticsToolBitSerializer from .fctb import FCTBSerializer +from .yaml import YamlToolBitSerializer -all_serializers = CamoticsToolBitSerializer, FCTBSerializer +all_serializers = ( + CamoticsToolBitSerializer, + FCTBSerializer, + YamlToolBitSerializer, +) __all__ = [ "CamoticsToolBitSerializer", "FCTBSerializer", + "YamlToolBitSerializer", "all_serializers", ] diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py index 0722d770a2..498a0b31ce 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py @@ -100,15 +100,19 @@ class FCTBSerializer(AssetSerializer): f"is not a ToolBitShape instance. {dependencies}" ) - # Find the correct ToolBit subclass for the shape - Path.Log.debug( - f"FCTBSerializer.deserialize: shape = {shape!r}, id = {id!r}," - f" params = {shape.get_parameters()}, attrs = {attrs!r}" - ) return ToolBit.from_shape(shape, attrs, id) @classmethod def deep_deserialize(cls, data: bytes) -> ToolBit: + """Deep deserialize preserving the original toolbit ID.""" + attrs_map = json.loads(data) + original_id = attrs_map.get("id") + asset_class = cast(ToolBit, cls.for_class) - return asset_class.from_dict(attrs_map) + toolbit = asset_class.from_dict(attrs_map) + + if original_id: + toolbit.id = original_id # Preserve the original ID + + return toolbit diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py new file mode 100644 index 0000000000..85f062b9ba --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +import yaml +from typing import List, Optional, Mapping, Type +from ...assets.serializer import AssetSerializer +from ...assets.uri import AssetUri +from ...shape import ToolBitShape +from ..models.base import ToolBit + + +class YamlToolBitSerializer(AssetSerializer): + """ + Serializes and deserializes ToolBit instances to and from YAML. + """ + + for_class: Type[ToolBit] = ToolBit + extensions: tuple[str, ...] = (".yaml", ".yml") + mime_type: str = "application/x-yaml" + can_import: bool = True + can_export: bool = True + + @classmethod + def get_label(cls) -> str: + return "YAML ToolBit" + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + data_dict = yaml.safe_load(data) + if isinstance(data_dict, dict): + shape_id = data_dict.get("shape") + if shape_id: + # Assuming shape is identified by its ID/name + return [ToolBitShape.resolve_name(str(shape_id))] + return [] + + @classmethod + def serialize(cls, asset: ToolBit) -> bytes: + """Serializes a ToolBit instance to bytes (shallow).""" + # Shallow serialization: only serialize direct attributes and shape ID + data = asset.to_dict() + return yaml.dump(data, default_flow_style=False).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str | None = None, + dependencies: Optional[Mapping[AssetUri, ToolBitShape]] = None, + ) -> ToolBit: + """ + Creates a ToolBit instance from serialized data and resolved + dependencies (shallow). + """ + data_dict = yaml.safe_load(data) + if not isinstance(data_dict, dict): + raise ValueError("Invalid YAML data for ToolBit") + toolbit = ToolBit.from_dict(data_dict) + if id: + toolbit.id = id + return toolbit + + @classmethod + def deep_deserialize(cls, data: bytes) -> ToolBit: + """Deep deserialize preserving the original toolbit ID.""" + data_dict = yaml.safe_load(data) + if not isinstance(data_dict, dict): + raise ValueError("Invalid YAML data for ToolBit") + + original_id = data_dict.get("id") # Extract the original ID + toolbit = ToolBit.from_dict(data_dict) + if original_id: + toolbit.id = original_id # Preserve the original ID + return toolbit diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py index b09ba55eef..744ba43704 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py @@ -1,6 +1,8 @@ from .editor import ToolBitEditorPanel, ToolBitEditor +from .browser import ToolBitBrowserWidget __all__ = [ + "ToolBitBrowserWidget", "ToolBitEditor", "ToolBitEditorPanel", ] diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 6a6fc6face..e6f6991c6a 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# flake8: noqa E731 # *************************************************************************** # * Copyright (c) 2025 Samuel Abels * # * * @@ -22,13 +23,23 @@ """Widget for browsing ToolBit assets with filtering and sorting.""" -from typing import List, cast -from PySide import QtGui, QtCore -from typing import List, cast +import yaml +from typing import List, Optional, cast, Sequence from PySide import QtGui, QtCore +from PySide.QtGui import QApplication, QMessageBox, QMenu, QAction, QKeySequence, QDialog +from PySide.QtCore import QMimeData +import FreeCAD +import Path from ...assets import AssetManager, AssetUri -from ...toolbit import ToolBit +from ..models.base import ToolBit +from ..serializers.yaml import YamlToolBitSerializer from .toollist import ToolBitListWidget, CompactToolBitListWidget, ToolBitUriRole +from .editor import ToolBitEditor +from .util import natural_sort_key + + +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) class ToolBitBrowserWidget(QtGui.QWidget): @@ -40,11 +51,10 @@ class ToolBitBrowserWidget(QtGui.QWidget): # 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 + itemDoubleClicked = QtCore.Signal(ToolBit) # 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, @@ -52,6 +62,7 @@ class ToolBitBrowserWidget(QtGui.QWidget): store: str = "local", parent=None, tool_no_factory=None, + tool_fetcher=None, compact=False, ): super().__init__(parent) @@ -61,19 +72,19 @@ class ToolBitBrowserWidget(QtGui.QWidget): self._is_fetching = False self._store_name = store - self._all_assets: List[ToolBit] = [] # Store all fetched assets + self._all_assets: Sequence[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" + self._selected_uris: List[str] = [] # Track selected toolbit URIs # UI Elements self._search_edit = QtGui.QLineEdit() - self._search_edit.setPlaceholderText("Search tools...") + self._search_edit.setPlaceholderText("Search toolbits...") # 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 Toolbit 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 @@ -97,150 +108,375 @@ class ToolBitBrowserWidget(QtGui.QWidget): 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_timer.timeout.connect(self._update_list) 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) - + # Connect signals from the list widget self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) - self._tool_list_widget.currentItemChanged.connect(self._on_item_selection_changed) + self._tool_list_widget.itemSelectionChanged.connect(self._on_item_selection_changed) + + # Connect list widget context menu request to browser handler + self._tool_list_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self._tool_list_widget.customContextMenuRequested.connect(self._show_context_menu) + + # Add keyboard shortcuts + self._add_shortcuts() # 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. + self.tool_fetcher = tool_fetcher or self._tool_fetcher 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() + self.refresh() + # Set focus to the search field + self._search_edit.setFocus() - def _fetch_all_assets(self): - """Fetches all ToolBit assets and stores them in memory.""" + def _tool_fetcher(self) -> Sequence[ToolBit]: + return cast( + List[ToolBit], + self._asset_manager.fetch( + asset_type="toolbit", + depth=0, # do not fetch dependencies (e.g. shape, icon) + store=self._store_name, + ), + ) + + def select_by_uri(self, uris: List[str]): + if not uris: + return + + # Select and scroll to the first toolbit + is_first = True + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + if item.data(ToolBitUriRole) in uris: + self._tool_list_widget.setCurrentItem(item) + if is_first: + # Scroll to the first selected item + is_first = False + self._tool_list_widget.scrollToItem(item) + + def refresh(self): + """Fetches all ToolBit assets and stores them in memory, then updates the UI.""" 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._all_assets = self.tool_fetcher() finally: self._is_fetching = False - self._trigger_fetch() + Path.Log.debug(f"Loaded {len(self._all_assets)} ToolBits.") + + self._sort_assets() + self._update_list() 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()) + self._all_assets.sort(key=lambda x: natural_sort_key(x.label)) elif self._sort_key == "tool_no" and self._tool_no_factory: self._all_assets.sort( - key=lambda x: (int(self._tool_no_factory(x)) or 0) if self._tool_no_factory else 0 + key=lambda x: int(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.""" + def _update_list(self): + """Updates the list widget based on current search and sort.""" 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 + self._current_search = self._search_edit.text() + filtered_assets = [ + asset 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() + # Collect current items in the list widget + current_items = {} + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + uri = item.data(ToolBitUriRole) + if uri: + current_items[uri] = item + + # Iterate through filtered assets and update the list widget + for i, asset in enumerate(filtered_assets): + uri = str(asset.get_uri()) + if uri in current_items: + # Item exists, remove the old one and insert the new one + item = current_items[uri] + row = self._tool_list_widget.row(item) + self._tool_list_widget.takeItem(row) + self._tool_list_widget.insert_toolbit(i, asset) + del current_items[uri] + else: + # Insert new item + self._tool_list_widget.insert_toolbit(i, asset) + + # Remove items that are no longer in filtered_assets + for uri, item in current_items.items(): + row = self._tool_list_widget.row(item) + self._tool_list_widget.takeItem(row) + + # Restore selection and scroll to the selected item + if self._selected_uris: + first_selected_item = None + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + uri = item.data(ToolBitUriRole) + if uri in self._selected_uris: + item.setSelected(True) + if first_selected_item is None: + first_selected_item = item + if first_selected_item: + self._tool_list_widget.scrollToItem(first_selected_item) + + # Apply the filter to trigger highlighting in the list widget + self._tool_list_widget.apply_filter(self._current_search) + + def set_sort_order(self, key: str): + for i in range(self._sort_combo.count()): + if self._sort_combo.itemData(i) == key: + if self._sort_combo.currentIndex() != i: + self._sort_combo.setCurrentIndex(i) + break + else: + return + self._sort_key = key + self._sort_assets() + self._update_list() 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() + key = self._sort_combo.itemData(self._sort_combo.currentIndex()) + self.set_sort_order(key) 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) + """Handles double-click on a list item to request editing.""" + uri_string = item.data(ToolBitUriRole) + if not uri_string: + return + toolbit = self._asset_manager.get(AssetUri(uri_string)) + if toolbit: + self.itemDoubleClicked.emit(toolbit) - 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 _on_item_selection_changed(self): + """Emits toolSelected signal and tracks selected URIs.""" + selected_uris = self._tool_list_widget.get_selected_toolbit_uris() + self._selected_uris = selected_uris + if not selected_uris: + return + self.toolSelected.emit(selected_uris[0]) + + def _get_first_selected_bit(self) -> Optional[ToolBit]: + uris = self.get_selected_bit_uris() + if not uris: + return None + uri_string = uris[0] + return cast(ToolBit, self._asset_manager.get(AssetUri(uri_string))) + + def _on_edit_requested(self): + """Opens the ToolBitEditor for the selected toolbit.""" + toolbit = self._get_first_selected_bit() + if not toolbit: + return + + # Open the editor for the selected toolbit + editor = ToolBitEditor(toolbit) + result = editor.show() + if result != QDialog.Accepted: + return + + # If the editor was closed with "OK", save the changes + self._asset_manager.add(toolbit) + Path.Log.info(f"Toolbit {toolbit.get_id()} saved.") + self.refresh() + self._update_list() + + def _add_shortcuts(self): + """Adds keyboard shortcuts for common actions.""" + copy_action = QAction(self) + copy_action.setShortcut(QKeySequence.Copy) + copy_action.triggered.connect(self._on_copy_requested) + self.addAction(copy_action) + + delete_action = QAction(self) + delete_action.setShortcut(QKeySequence("Shift+Delete")) + delete_action.triggered.connect(self._on_delete_requested) + self.addAction(delete_action) + + edit_action = QAction(self) + edit_action.setShortcut(QKeySequence("F2")) + edit_action.triggered.connect(self._on_edit_requested) + self.addAction(edit_action) + + def _create_base_context_menu(self): + """Creates the base context menu with Edit, Copy, and Delete actions.""" + selected_items = self._tool_list_widget.selectedItems() + has_selection = bool(selected_items) + + context_menu = QMenu(self) + + edit_action = context_menu.addAction("Edit", self._on_edit_requested) + edit_action.setEnabled(has_selection) + context_menu.addSeparator() + action = context_menu.addAction("Copy", self._on_copy_requested) + action.setShortcut(QKeySequence.Copy) + action = context_menu.addAction("Delete from disk", self._on_delete_requested) + action.setShortcut(QKeySequence("Shift+Delete")) + + return context_menu + + def _show_context_menu(self, position): + """Shows the context menu at the given position.""" + context_menu = self._create_base_context_menu() + context_menu.exec_(self._tool_list_widget.mapToGlobal(position)) + + def _to_clipboard( + self, + uris: List[str], + mode: str = "copy", + extra_data: Optional[dict] = None, + ): + """Copies selected toolbits to the clipboard as YAML.""" + if not uris: + return + + selected_bits = [cast(ToolBit, self._asset_manager.get(AssetUri(uri))) for uri in uris] + selected_bits = [bit for bit in selected_bits if bit] # Filter out None + if not selected_bits: + return + + # Serialize selected toolbits individually + serialized_toolbits_data = [] + for toolbit in selected_bits: + yaml_data = YamlToolBitSerializer.serialize(toolbit) + serialized_toolbits_data.append(yaml_data.decode("utf-8")) + + # Create a dictionary to hold the operation type and serialized data + clipboard_data_dict = { + "operation": mode, + "toolbits": serialized_toolbits_data, + } + + # Include extra data if provided + if extra_data: + clipboard_data_dict.update(extra_data) + + # Serialize the dictionary to YAML + clipboard_content_yaml = yaml.dump(clipboard_data_dict, default_flow_style=False) + + # Put the YAML data on the clipboard with a custom MIME type + mime_data = QMimeData() + mime_type = "application/x-freecad-toolbit-list-yaml" + mime_data.setData(mime_type, clipboard_content_yaml.encode("utf-8")) + + # Put it in text format for pasting to text editors + toolbit_list = [yaml.safe_load(d) for d in serialized_toolbits_data] + mime_data.setText(yaml.dump(toolbit_list, default_flow_style=False)) + + clipboard = QApplication.clipboard() + clipboard.setMimeData(mime_data) + + def _on_copy_requested(self): + """Copies selected toolbits to the clipboard as YAML.""" + uris = self.get_selected_bit_uris() + self._to_clipboard(uris, mode="copy") + + def _on_delete_requested(self): + """Deletes selected toolbits and removes them from all libraries.""" + Path.Log.debug("ToolBitBrowserWidget._on_delete_requested: Function entered.") + uris = self.get_selected_bit_uris() + if not uris: + Path.Log.debug("_on_delete_requested: No URIs selected. Returning.") + return + + # Ask for confirmation + reply = QMessageBox.question( + self, + FreeCAD.Qt.translate("CAM", "Confirm Deletion"), + FreeCAD.Qt.translate( + "CAM", + "Are you sure you want to delete the selected toolbit(s)? This is not reversible. The toolbits will be removed from disk and from all libraries that contain them.", + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply != QMessageBox.Yes: + return + + deleted_count = 0 + libraries_modified = [] # Use list instead of set since Library objects aren't hashable + + for uri_string in uris: + try: + toolbit_uri = AssetUri(uri_string) + + # First, remove the toolbit from all libraries that contain it + libraries_to_update = self._find_libraries_containing_toolbit(toolbit_uri) + for library in libraries_to_update: + library.remove_bit_by_uri(uri_string) + if library not in libraries_modified: # Avoid duplicates + libraries_modified.append(library) + Path.Log.info( + f"Removed toolbit {toolbit_uri.asset_id} from library {library.label}" + ) + + # Then delete the toolbit file from disk + self._asset_manager.delete(toolbit_uri) + deleted_count += 1 + Path.Log.info(f"Deleted toolbit file {uri_string}") + + except Exception as e: + Path.Log.error(f"Failed to delete toolbit {uri_string}: {e}") + + # Save all modified libraries + for library in libraries_modified: + try: + self._asset_manager.add(library) + Path.Log.info(f"Saved updated library {library.label}") + except Exception as e: + Path.Log.error(f"Failed to save library {library.label}: {e}") + + if deleted_count > 0: + Path.Log.info( + f"Deleted {deleted_count} toolbit(s) and updated {len(libraries_modified)} libraries." + ) + self.refresh() + + def _find_libraries_containing_toolbit(self, toolbit_uri: AssetUri) -> List: + """Find all libraries that contain the specified toolbit.""" + from ...library.models.library import Library + + libraries_with_toolbit = [] + try: + # Get all libraries from the asset manager + all_libraries = self._asset_manager.fetch("toolbitlibrary", store="local", depth=1) + + for library in all_libraries: + if isinstance(library, Library): + # Check if this library contains the toolbit + for toolbit in library: + if toolbit.get_uri() == toolbit_uri: + libraries_with_toolbit.append(library) + break + + except Exception as e: + Path.Log.error(f"Error finding libraries containing toolbit {toolbit_uri}: {e}") + + return libraries_with_toolbit def get_selected_bit_uris(self) -> List[str]: """ diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py b/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py index e417120e0a..061b5eef3c 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py @@ -49,7 +49,7 @@ class CommandToolBitCreate: def GetResources(self): return { "Pixmap": "CAM_ToolBit", - "MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitCreate", "New Tool"), + "MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitCreate", "New Toolbit"), "ToolTip": QT_TRANSLATE_NOOP("CAM_ToolBitCreate", "Creates a new toolbit object"), } diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py index f9c7c22847..9353ff373d 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -30,6 +30,8 @@ from ...shape.ui.shapewidget import ShapeWidget from ...docobject.ui import DocumentObjectEditorWidget from ..models.base import ToolBit +translate = FreeCAD.Qt.translate + class ToolBitPropertiesWidget(QtGui.QWidget): """ @@ -38,11 +40,19 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # Signal emitted when the toolbit data has been modified toolBitChanged = QtCore.Signal() + toolNoChanged = QtCore.Signal(int) - def __init__(self, toolbit: Optional[ToolBit] = None, parent=None, icon: bool = True): + def __init__( + self, + toolbit: Optional[ToolBit] = None, + tool_no: Optional[int] = None, + parent=None, + icon: bool = True, + ): super().__init__(parent) self._toolbit = None self._show_shape = icon + self._tool_no = tool_no # UI Elements self._label_edit = QtGui.QLineEdit() @@ -58,10 +68,17 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._shape_widget = None # Will be created in load_toolbit # Layout - toolbit_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Tool Bit")) + toolbit_group_box = QtGui.QGroupBox(translate("CAM", "Toolbit")) form_layout = QtGui.QFormLayout(toolbit_group_box) - form_layout.addRow("Label:", self._label_edit) - form_layout.addRow("ID:", self._id_label) + form_layout.addRow(translate("CAM", "Label:"), self._label_edit) + form_layout.addRow(translate("CAM", "ID:"), self._id_label) + + # Optional tool number edit field. + self._tool_no_edit = QtGui.QSpinBox() + self._tool_no_edit.setMinimum(1) + self._tool_no_edit.setMaximum(99999999) + if tool_no is not None: + form_layout.addRow(translate("CAM", "Tool Number:"), self._tool_no_edit) main_layout = QtGui.QVBoxLayout(self) main_layout.addWidget(toolbit_group_box) @@ -93,6 +110,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # Connections self._label_edit.editingFinished.connect(self._on_label_changed) + self._tool_no_edit.valueChanged.connect(self._on_tool_no_changed) self._property_editor.propertyChanged.connect(self.toolBitChanged) if toolbit: @@ -106,6 +124,12 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._toolbit.obj.Label = new_label self.toolBitChanged.emit() + def _on_tool_no_changed(self, value): + """Update the tool number when the line edit changes.""" + if self._tool_no != value: + self._tool_no = value + self.toolNoChanged.emit(value) + def load_toolbit(self, toolbit: ToolBit): """Load a ToolBit object into the editor.""" self._toolbit = toolbit @@ -114,12 +138,14 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._label_edit.clear() self._label_edit.setEnabled(False) self._id_label.clear() + self._tool_no_edit.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._tool_no_edit.setValue(1) self.setEnabled(False) return @@ -127,6 +153,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._label_edit.setEnabled(True) self._label_edit.setText(self._toolbit.obj.Label) self._id_label.setText(self._toolbit.get_id()) + self._tool_no_edit.setValue(int(self._tool_no or 1)) # Get properties and suffixes props_to_show = self._toolbit._get_props(("Shape", "Attributes")) @@ -214,12 +241,18 @@ class ToolBitEditor(QtGui.QWidget): # Signals toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget - def __init__(self, toolbit: ToolBit, parent=None): + def __init__( + self, + toolbit: ToolBit, + tool_no: Optional[int] = None, + parent=None, + icon: bool = False, + ): super().__init__(parent) self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui") self.toolbit = toolbit - # self.tool_no = tool_no + self.tool_no = tool_no self.default_title = self.form.windowTitle() # Get first tab from the form, add the shape widget at the top. @@ -228,9 +261,9 @@ class ToolBitEditor(QtGui.QWidget): tool_tab_layout.addWidget(widget) # Add tool properties editor to the same tab. - props = ToolBitPropertiesWidget(toolbit, self, icon=False) + props = ToolBitPropertiesWidget(toolbit, tool_no, self, icon=icon) props.toolBitChanged.connect(self._update) - # props.toolNoChanged.connect(self._on_tool_no_changed) + props.toolNoChanged.connect(self._on_tool_no_changed) tool_tab_layout.addWidget(props) self.form.tabWidget.setCurrentIndex(0) @@ -280,5 +313,8 @@ class ToolBitEditor(QtGui.QWidget): def _on_tool_no_changed(self, value): self.tool_no = value + def get_tool_no(self): + return self.tool_no + def show(self): return self.form.exec_() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py index 40d73f6a7b..27d8d5e94c 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py @@ -41,7 +41,7 @@ class ToolBitSelector(QtWidgets.QDialog): self.setMinimumSize(600, 400) - self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Tool Bit")) + self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Toolbit")) self._browser_widget = ToolBitBrowserWidget(cam_assets, compact=compact) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py index c604d666bc..e0bed94b43 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py @@ -56,31 +56,31 @@ class TwoLineTableCell(QtGui.QWidget): self.vbox = QtGui.QVBoxLayout() self.label_upper = QtGui.QLabel() self.label_upper.setStyleSheet("margin-top: 8px") + self.label_upper.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) 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.label_lower.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) 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) + self.label_left.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) 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.label_right.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) self.hbox = QtGui.QHBoxLayout() self.hbox.addWidget(self.label_left, 0) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py index fd60d068d4..edc6a08ee6 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py @@ -22,13 +22,19 @@ """Widget for displaying a list of ToolBits using TwoLineTableCell.""" +import yaml +import Path from typing import Callable, List from PySide import QtGui, QtCore +from PySide.QtGui import QDrag +from PySide.QtCore import QMimeData +from ..models.base import ToolBit 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 +ToolBitUriListMimeType = "application/x-freecad-toolbit-uri-list-yaml" class ToolBitListWidget(QtGui.QListWidget): @@ -40,22 +46,45 @@ class ToolBitListWidget(QtGui.QListWidget): 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): + def setDragEnabled(self, enabled: bool = True): + """Enable or disable drag-and-drop support for toolbits.""" + super().setDragEnabled(enabled) + + def startDrag(self, supportedActions): + """Initiate drag with selected toolbits serialized as mime data if drag is enabled.""" + Path.Log.debug("startDrag: Drag initiated.") + selected_items = self.selectedItems() + if not selected_items: + Path.Log.debug("startDrag: No items selected for drag.") + return + + uris = [item.data(ToolBitUriRole) for item in selected_items] + if not uris: + Path.Log.debug("startDrag: No valid URIs found for selected items.") + return + + # Create clipboard data + clipboard_data = { + "toolbits": uris, + } + yaml_data = yaml.safe_dump(clipboard_data).encode("utf-8") + + # Set mime data + mime_data = QMimeData() + mime_data.setData(ToolBitUriListMimeType, yaml_data) + + # Start drag + drag = QDrag(self) + drag.setMimeData(mime_data) + drag.exec_(QtCore.Qt.CopyAction | QtCore.Qt.MoveAction) + Path.Log.debug("startDrag: Drag executed.") + + def _create_toolbit_item(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. + Creates a QListWidgetItem and populates it with ToolBit data. """ # Use the factory function if provided, otherwise use the passed tool_no final_tool_no = None @@ -72,6 +101,7 @@ class ToolBitListWidget(QtGui.QListWidget): cell.set_tool_no(final_tool_no) cell.set_upper_text(toolbit.label) cell.set_lower_text(toolbit.summary) + cell.set_icon_from_shape(toolbit._tool_bit_shape) # Set the custom widget for the list item item.setSizeHint(cell.sizeHint()) @@ -80,6 +110,33 @@ class ToolBitListWidget(QtGui.QListWidget): # Store the ToolBit URI for later retrieval item.setData(ToolBitUriRole, str(toolbit.get_uri())) + return item + + 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. + """ + item = self._create_toolbit_item(toolbit, tool_no) + self.addItem(item) + + def insert_toolbit(self, row: int, toolbit: ToolBit, tool_no: int | None = None): + """ + Inserts a ToolBit to the list at the specified row. + + Args: + row (int): The row index where the item should be inserted. + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + item = self._create_toolbit_item(toolbit, tool_no) + self.insertItem(row, item) + def clear_list(self): """Removes all items from the list.""" self.clear() @@ -147,14 +204,10 @@ class CompactToolBitListWidget(ToolBitListWidget): CompactTwoLineTableCell widgets. """ - def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + def _create_toolbit_item(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. + Creates a QListWidgetItem and populates it with ToolBit data + using CompactTwoLineTableCell. """ # Use the factory function if provided, otherwise use the passed tool_no final_tool_no = None @@ -163,15 +216,14 @@ class CompactToolBitListWidget(ToolBitListWidget): 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 + item = QtGui.QListWidgetItem(self) + cell = CompactTwoLineTableCell(self) # Populate the cell widget cell.set_tool_no(final_tool_no) cell.set_upper_text(toolbit.label) - lower_text = toolbit.summary + cell.set_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()) @@ -179,3 +231,5 @@ class CompactToolBitListWidget(ToolBitListWidget): # Store the ToolBit URI for later retrieval item.setData(ToolBitUriRole, str(toolbit.get_uri())) + + return item diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/util.py b/src/Mod/CAM/Path/Tool/toolbit/ui/util.py new file mode 100644 index 0000000000..4957e05995 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/util.py @@ -0,0 +1,32 @@ +# -*- 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 + + +def natural_sort_key(s, _nsre=re.compile(r"(\d+[\.,]?\d*)")): + def try_convert(text): + try: + return float(text.replace(",", ".")) + except ValueError: + return text.lower() + + return [try_convert(text) for text in _nsre.split(s)] diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py index 03976184de..bc21722814 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/util.py +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -29,9 +29,21 @@ def to_json(value): return value -def format_value(value: FreeCAD.Units.Quantity | int | float | None): +def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: int | None = None): if value is None: return None elif isinstance(value, FreeCAD.Units.Quantity): + if precision is not None: + user_val, _, user_unit = value.getUserPreferred() + if user_unit in ("deg", "°", "degree", "degrees"): + # Remove the last character (degree symbol) and convert to float + try: + deg_val = float(str(user_val)[:-1]) + except Exception: + return value.getUserPreferred()[0] + formatted_value = f"{deg_val:.1f}".rstrip("0").rstrip(".") + return f"{formatted_value}°" + # Format the value with the specified number of precision and strip trailing zeros + return value.getUserPreferred()[0] return value.UserString return str(value) diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index 4fb285c6e0..8bcafcd3c9 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -81,6 +81,7 @@ from CAMTests.TestPathToolShapeIcon import ( from CAMTests.TestPathToolBitSerializer import ( TestCamoticsToolBitSerializer, TestFCTBSerializer, + TestYamlToolBitSerializer, ) from CAMTests.TestPathToolLibrary import TestPathToolLibrary from CAMTests.TestPathToolLibrarySerializer import ( diff --git a/src/Mod/CAM/Tools/Bit/6mm_Bullnose.fctb b/src/Mod/CAM/Tools/Bit/6mm_Bullnose.fctb index b2830fd38c..fb6dbfab13 100644 --- a/src/Mod/CAM/Tools/Bit/6mm_Bullnose.fctb +++ b/src/Mod/CAM/Tools/Bit/6mm_Bullnose.fctb @@ -6,7 +6,7 @@ "parameter": { "CuttingEdgeHeight": "40.0000 mm", "Diameter": "6.0000 mm", - "FlatRadius": "1.5000 mm", + "CornerRadius": "1.5000 mm", "Length": "50.0000 mm", "ShankDiameter": "3.0000 mm" }, diff --git a/src/Mod/CAM/Tools/Shape/bullnose.fcstd b/src/Mod/CAM/Tools/Shape/bullnose.fcstd index 121fa63397..c2a7053993 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/fillet.fcstd b/src/Mod/CAM/Tools/Shape/fillet.fcstd deleted file mode 100644 index 536a57f848..0000000000 Binary files a/src/Mod/CAM/Tools/Shape/fillet.fcstd and /dev/null differ diff --git a/src/Mod/CAM/Tools/Shape/radius.fcstd b/src/Mod/CAM/Tools/Shape/radius.fcstd new file mode 100644 index 0000000000..b5276922fd Binary files /dev/null and b/src/Mod/CAM/Tools/Shape/radius.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/fillet.svg b/src/Mod/CAM/Tools/Shape/radius.svg similarity index 82% rename from src/Mod/CAM/Tools/Shape/fillet.svg rename to src/Mod/CAM/Tools/Shape/radius.svg index 03caccb09a..bb3f8e7e11 100644 --- a/src/Mod/CAM/Tools/Shape/fillet.svg +++ b/src/Mod/CAM/Tools/Shape/radius.svg @@ -5,9 +5,9 @@ viewBox="0 0 210 297" height="297mm" width="210mm" - sodipodi:docname="fillet.svg" + sodipodi:docname="radius.svg" xml:space="preserve" - inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" + inkscape:version="1.3.2 (091e20e, 2023-11-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" @@ -26,13 +26,13 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" showgrid="false" - inkscape:zoom="0.50398562" - inkscape:cx="712.32191" - inkscape:cy="915.70073" - inkscape:window-width="2311" - inkscape:window-height="1509" - inkscape:window-x="1529" - inkscape:window-y="377" + inkscape:zoom="0.41628253" + inkscape:cx="136.92624" + inkscape:cy="599.35256" + inkscape:window-width="1512" + inkscape:window-height="916" + inkscape:window-x="0" + inkscape:window-y="38" inkscape:window-maximized="0" inkscape:current-layer="svg8" />image/svg+xmlhr + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">rd diff --git a/src/Mod/CAM/Tools/Shape/thread-mill.fcstd b/src/Mod/CAM/Tools/Shape/thread-mill.fcstd index e0eb96c9ea..575076c4f2 100644 Binary files a/src/Mod/CAM/Tools/Shape/thread-mill.fcstd and b/src/Mod/CAM/Tools/Shape/thread-mill.fcstd differ diff --git a/src/Mod/CAM/Tools/Shape/v-bit.fcstd b/src/Mod/CAM/Tools/Shape/v-bit.fcstd index 16cd0e630a..be25111e2e 100644 Binary files a/src/Mod/CAM/Tools/Shape/v-bit.fcstd and b/src/Mod/CAM/Tools/Shape/v-bit.fcstd differ