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