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/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..2c233856ca 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))
@@ -53,7 +55,7 @@ class TestToolBitListWidget(PathTestWithAssets):
self.assertEqual(cell_widget.tool_no, str(tool_no))
self.assertEqual(cell_widget.upper_text, toolbit.label)
# Assuming the 5mm_Endmill asset has a shape named 'Endmill'
- self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge")
+ self.assertEqual(cell_widget.lower_text, "5 mm 4-flute endmill, 30 mm cutting edge")
# Verify URI is stored in item data
stored_uri = item.data(ToolBitUriRole)
@@ -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..7a9ea41995 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.0 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/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt
index b2e1e55ad7..88a6f3643e 100644
--- a/src/Mod/CAM/CMakeLists.txt
+++ b/src/Mod/CAM/CMakeLists.txt
@@ -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
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 @@
+
+
+
+
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 @@
+
+
+
+
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..ff76a97264
--- /dev/null
+++ b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui
@@ -0,0 +1,121 @@
+
+
+ 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
+
+
+
+ -
+
+
+ Edit Library
+
+
+
+ ../resources/icons/add-library.svg../resources/icons/add-library.svg
+
+
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ Dialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ Dialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui
index 5fe79475eb..4b9177c369 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
-
-
-
+
-
+
+
+ Add New Library
+
+
+
+ :/icons/CAM_ToolTableAdd.svg:/icons/CAM_ToolTableAdd.svg
+
+
+
+ -
+
+
+ Remove Library
+
+
+
+ :/icons/CAM_ToolTableRemove.svg:/icons/CAM_ToolTableRemove.svg
+
+
+
+ -
+
+
+ Rename Library
+
+
+
+ :/icons/edit-edit.svg:/icons/edit-edit.svg
+
+
+
+ -
+
+
+ Import Library
+
+
+
+ :/icons/Std_Import.svg:/icons/Std_Import.svg
+
+
+
+ -
+
+
+ Export 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
+
+
-
+
+
+ Add Toolbit
+
+
+
+ :/icons/CAM_ToolBit.svg:/icons/CAM_ToolBit.svg
+
+
+
+ -
+
+
+ Import Toolbit
+
+
+
+ :/icons/Std_Import.svg:/icons/Std_Import.svg
+
+
+
+ -
+
+
+ Export 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/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py
index 7f784fa4f6..107fd76fa6 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"
@@ -152,6 +153,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():
diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py
index 9095a6ec15..5a05fa9e17 100644
--- a/src/Mod/CAM/Path/Tool/assets/manager.py
+++ b/src/Mod/CAM/Path/Tool/assets/manager.py
@@ -145,7 +145,7 @@ class AssetManager:
)
continue # Try next store
- if raw_data is None:
+ if raw_data is None or not found_store_name:
return None # Asset not found in any store
if depth == 0:
@@ -194,7 +194,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 +208,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 +217,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 +226,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:
@@ -255,7 +251,7 @@ class AssetManager:
# 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)
except Exception as e:
logger.error(
f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}",
@@ -365,10 +361,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 +372,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 +418,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 +431,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 +453,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 +521,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 +561,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 +586,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 +801,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..3c8505c4a6 100644
--- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
+++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
@@ -20,10 +20,9 @@
# * *
# ***************************************************************************
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,
@@ -35,12 +34,15 @@ from .util import (
class AssetOpenDialog(QFileDialog):
def __init__(
self,
+ asset_manager: AssetManager,
asset_class: Type[Asset],
serializers: Iterable[Type[AssetSerializer]],
parent=None,
):
super().__init__(parent)
+ self.setDirectory(pathlib.Path.home().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 +52,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 +64,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):
+ 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__}")
@@ -90,6 +110,7 @@ class AssetSaveDialog(QFileDialog):
parent=None,
):
super().__init__(parent)
+ self.setDirectory(pathlib.Path.home().as_posix())
self.asset_class = asset_class
self.serializers = list(serializers)
self.setFileMode(QFileDialog.AnyFile)
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/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/linuxcnc.py b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py
index 70f26f3a46..992dfe6760 100644
--- a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py
+++ b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py
@@ -58,10 +58,11 @@ class LinuxCNCSerializer(AssetSerializer):
continue
diameter = bit.get_diameter()
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"))
+ 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..c5527772a1 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,463 @@
"""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.layout().setContentsMargins(0, 0, 0, 0)
+
+ 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 _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()
+ self._update_list()
+
+ 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)
+
+ # 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()
+
+ action = context_menu.addAction(
+ "Remove from Library", self._on_remove_from_library_requested
+ )
+ action.setShortcut(QtGui.QKeySequence.Delete)
+
+ 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)
+ # Assign a new tool id and a label
+ toolbit.set_id()
+ self._asset_manager.add(toolbit) # Save the new toolbit to disk
+
+ # Add the bit to the current library
+ added_toolbit = current_library.add_bit(toolbit)
+ if added_toolbit:
+ new_uris.add(str(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)
+ source_library.remove_bit(toolbit)
+
+ # Remove it from the old library, add it to the new library
+ source_library.remove_bit(toolbit)
+ added_toolbit = current_library.add_bit(toolbit)
+ if added_toolbit:
+ new_uris.add(str(toolbit.get_uri()))
+
+ # The toolbit itself does not change, so we don't need to save it.
+ # It is only the reference in the library that changes.
+
+ 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()
+
+
+class LibraryBrowserWithCombo(LibraryBrowserWidget):
+ """
+ A widget extending LibraryBrowserWidget with a combo box for library selection.
"""
def __init__(
@@ -43,74 +489,78 @@ 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)
+
+ 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.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:
+ 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..c845af7846 100644
--- a/src/Mod/CAM/Path/Tool/library/ui/cmd.py
+++ b/src/Mod/CAM/Path/Tool/library/ui/cmd.py
@@ -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..215444ebf7 100644
--- a/src/Mod/CAM/Path/Tool/library/ui/dock.py
+++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py
@@ -34,7 +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:
@@ -80,7 +80,8 @@ class ToolBitLibraryDock(object):
main_layout.setContentsMargins(4, 4, 4, 4)
main_layout.setSpacing(4)
- # Add the browser widget to the layout
+ # Create the browser widget
+ self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets)
main_layout.addWidget(self.browser_widget)
# Create buttons
@@ -101,26 +102,31 @@ 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)
- # 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 +154,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..bcfd175203 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,239 @@ 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 Tools"
+ 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 Tools" item
+ all_tools_item = QStandardItem(translate("CAM", "All Tools"))
+ all_tools_item.setData("all_tools", _LibraryRole)
+ all_tools_item.setIcon(QPixmap(":/icons/CAM_ToolTable.svg"))
+ self.listModel.appendRow(all_tools_item)
# Use AssetManager to fetch library assets (depth=0 for shallow fetch)
try:
@@ -152,138 +300,190 @@ 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(library_selected)
+ # 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()))
+ 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)
+ ),
)
- @staticmethod
- def _generate_tooltip(toolbit: dict) -> str:
- """
- Generate an HTML tooltip for a given toolbit dictionary.
+ 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
- Args:
- toolbit (dict): A dictionary containing toolbit information.
+ dialog = LibraryPropertyDialog(current_library, new=False, parent=self)
+ if dialog.exec_() != QDialog.Accepted:
+ return
- 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.
"
+ cam_assets.add(current_library)
+ self._refresh_library_list()
+ self._update_button_states()
- attributes = toolbit.get("attribute", {})
- if attributes:
- tooltip += "Attributes:
"
- for key, value in attributes.items():
- tooltip += f" {key}: {value}
"
+ 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)
- return tooltip
+ 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}"),
+ )
- @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)
+ 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
- tool_nr = QStandardItem()
- tool_nr.setData(nr, Qt.EditRole)
- tool_nr.setData(path, _PathRole)
- tool_nr.setData(UUID.uuid4(), _UuidRole)
- tool_nr.setToolTip(tooltip)
+ dialog = AssetSaveDialog(asset_class=Library, serializers=library_serializers, parent=self)
+ dialog.exec_(current_library)
+ self._update_button_states()
- 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_toolbit_requested(self):
+ """Handles request to add a new toolbit to the current library."""
+ Path.Log.debug("_on_add_toolbit_requested: Called.")
+ current_library = self.browser.get_current_library()
+ if not current_library:
+ Path.Log.warning("Cannot add toolbit: No library selected.")
+ QMessageBox.warning(
+ self,
+ FreeCAD.Qt.translate("CAM", "Warning"),
+ FreeCAD.Qt.translate("CAM", "Please select a library first."),
)
return
@@ -297,380 +497,105 @@ 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)
+ # Add the toolbit to the current library
+ toolno = current_library.add_bit(new_toolbit)
Path.Log.debug(
- f"toolBitNew: Added toolbit {toolbit.get_id()} (URI: {toolbit.get_uri()}) "
- f"to current_library with tool number {tool_no}."
+ f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) "
+ f"to current_library with number {toolno}."
)
- # 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()
+ # Save the library
+ cam_assets.add(current_library)
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}"
+ # Add the imported toolbit to the current library
+ added_toolbit = current_library.add_bit(toolbit)
+ if added_toolbit:
+ cam_assets.add(toolbit) # Save the imported toolbit to disk
+ cam_assets.add(current_library) # Save the modified library
+ self.browser.refresh()
+ self.browser.select_by_uri([str(toolbit.get_uri())])
+ self._update_button_states()
+ else:
+ Path.Log.warning(
+ f"Failed to import toolbit from {file_path} to library {current_library.label}."
)
- PySide.QtGui.QMessageBox.critical(
- self.form,
- translate("CAM_ToolBit", "Error Saving Library"),
- str(e),
+ 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}'.",
+ ),
)
- 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."),
+ 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
- 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
-
- Path.Log.info(
- f"Exported library {self.current_library.label} "
- f"to {file_path} using serializer {serializer_class.__name__}"
- )
-
- def columnNames(self):
- return [
- "Tn",
- translate("CAM_ToolBit", "Tool"),
- translate("CAM_ToolBit", "Shape"),
- ]
-
- def _loadSelectedLibraryTools(self, library_uri: AssetUri | str | None = None):
- """Loads tools for the given library_uri into self.toolModel and selects it in the list."""
- Path.Log.track(library_uri)
- self.toolModel.clear()
- # library_uri is now expected to be a string URI or None when called from setupUI/tableSelected.
- # AssetUri object conversion is handled by cam_assets.get() if needed.
-
- self.current_library = None # Reset current_library before loading
-
- if not library_uri:
- self.form.setWindowTitle("Tool Library Editor - No Library Selected")
- return
-
- # Fetch the library from the asset manager
- try:
- self.current_library = cam_assets.get(library_uri, depth=1)
- except Exception as e:
- Path.Log.error(f"Failed to load library asset {library_uri}: {e}")
- self.form.setWindowTitle("Tool Library Editor - Error")
- return
-
- # Success! Add the tools to the toolModel.
- self.toolTableView.setUpdatesEnabled(False)
- self.form.setWindowTitle(f"Tool Library Editor - {self.current_library.label}")
- for tool_no, tool_bit in sorted(self.current_library._bit_nos.items()):
- self.toolModel.appendRow(
- ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri()))
+ 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.toolModel.setHorizontalHeaderLabels(self.columnNames())
- self.toolTableView.setUpdatesEnabled(True)
-
- def setupUI(self):
- """Setup the form and load the tool library data"""
- Path.Log.track()
-
- self.form.TableList.setModel(self.listModel)
- self._refreshLibraryListModel()
-
- self.toolTableView.setModel(self.toolModel)
-
- # Find the last used library.
- last_used_lib_identifier = Path.Preferences.getLastToolLibrary()
- Path.Log.debug(
- f"setupUI: Last used library identifier from prefs: '{last_used_lib_identifier}'"
- )
- last_used_lib_uri = None
- if last_used_lib_identifier:
- last_used_lib_uri = Library.resolve_name(last_used_lib_identifier)
-
- # Find it in the list.
- index = 0
- for i in range(self.listModel.rowCount()):
- item = self.listModel.item(i)
- if item and item.data(_LibraryRole) == str(last_used_lib_uri):
- index = i
- break
-
- # Select it.
- if index <= self.listModel.rowCount():
- item = self.listModel.item(index)
- if item: # Should always be true, but...
- library_uri_str = item.data(_LibraryRole)
- self.form.TableList.setCurrentIndex(self.listModel.index(index, 0))
-
- # Load tools for the selected library.
- self._loadSelectedLibraryTools(library_uri_str)
-
- self.toolTableView.resizeColumnsToContents()
- self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect)
-
- self.form.TableList.clicked.connect(self.tableSelected)
-
- self.form.toolAdd.clicked.connect(self.toolBitExisting)
- self.form.toolDelete.clicked.connect(self.toolDelete)
- self.form.toolCreate.clicked.connect(self.toolBitNew)
-
- self.form.addLibrary.clicked.connect(self.libraryNew)
- self.form.exportLibrary.clicked.connect(self.exportLibrary)
- self.form.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..6bab65b4fa
--- /dev/null
+++ b/src/Mod/CAM/Path/Tool/library/ui/properties.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# ***************************************************************************
+# * Copyright (c) 2025 Samuel Abels *
+# * *
+# * This program is free software; you can redistribute it and/or modify *
+# * it under the terms of the GNU Lesser General Public License (LGPL) *
+# * as published by the Free Software Foundation; either version 2 of *
+# * the License, or (at your option) any later version. *
+# * for detail see the LICENCE text file. *
+# * *
+# * This program is distributed in the hope that it will be useful, *
+# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+# * GNU Library General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Library General Public *
+# * License along with this program; if not, write to the Free Software *
+# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
+# * USA *
+# * *
+# ***************************************************************************
+from PySide import 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()
+
+ if new:
+ label = FreeCAD.Qt.translate("CAM", "Create Library")
+ self.form.pushButtonSave.setText(label)
+
+ self.form.buttonBox.accepted.connect(self.accept)
+ self.form.buttonBox.rejected.connect(self.reject)
+ self.form.pushButtonSave.clicked.connect(self.save_properties)
+
+ # Connect text changed signal to update window title
+ self.form.lineEditLibraryName.textChanged.connect(self.update_window_title)
+
+ # Set minimum width for the dialog
+ self.setMinimumWidth(450)
+
+ 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/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/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..692ebe71e2 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.
@@ -589,9 +592,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 +756,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..e86c180965 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py
@@ -36,10 +36,10 @@ 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)
return FreeCAD.Qt.translate(
"CAM",
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/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/fillet.py b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py
index a23f82ecf0..05063a710c 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py
@@ -36,9 +36,9 @@ class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
@property
def summary(self) -> str:
- radius = self.get_property_str("FilletRadius", "?")
+ radius = self.get_property_str("FilletRadius", "?", 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"
diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py
index f0330084ef..838667ea28 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"
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/yaml.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
new file mode 100644
index 0000000000..2fbbdcef0f
--- /dev/null
+++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
@@ -0,0 +1,88 @@
+# -*- 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 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:
+ """
+ Like deserialize(), but builds dependencies itself if they are
+ sufficiently defined in the data.
+ """
+ raise NotImplementedError
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..6273f4cb81 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,10 +72,10 @@ 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()
@@ -97,150 +108,325 @@ 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."""
+ 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)?"),
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No,
+ )
+
+ if reply != QMessageBox.Yes:
+ return
+
+ deleted_count = 0
+ for uri_string in uris:
+ try:
+ # Delete the toolbit using the asset manager
+ self._asset_manager.delete(AssetUri(uri_string))
+ deleted_count += 1
+ except Exception as e:
+ Path.Log.error(f"Failed to delete toolbit {uri_string}: {e}")
+ # Optionally show a message box to the user
+
+ if deleted_count > 0:
+ Path.Log.info(f"Deleted {deleted_count} toolbit(s).")
+ self.refresh()
def get_selected_bit_uris(self) -> List[str]:
"""
diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py
index f9c7c22847..26c60a10f8 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", "Tool Bit"))
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/tablecell.py b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py
index c604d666bc..7248bc56c6 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py
@@ -56,11 +56,13 @@ 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)
@@ -70,6 +72,7 @@ class TwoLineTableCell(QtGui.QWidget):
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)
@@ -81,6 +84,7 @@ class TwoLineTableCell(QtGui.QWidget):
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..705ef05783 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/util.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/util.py
@@ -29,9 +29,14 @@ 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:
+ # Format the value with the specified number of precision and strip trailing zeros
+ formatted_value = f"{value.Value:.{precision}f}".rstrip("0").rstrip(".")
+ unit = value.getUserPreferred()[2]
+ return f"{formatted_value} {unit}"
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 (