From 2168e3cd9900117a9c447c23b930f2b5f7e773a9 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Wed, 21 May 2025 16:23:33 +0200 Subject: [PATCH 1/6] CAM: Add AssetManager.copy() and .deepcopy() CAM: Add copy/paste support for the ToolBitBrowser CAM: Move library dropdown and sort order combo to dedicated row to give them more space CAM: Fix: PathAssetManagerTest failed CAM: Add YamlSerializer [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci CAM: Fix CodeQL complaints CAM: add LibraryProperties dialog CAM: Replace the LibraryEditor CAM: allow for editing tool number in the tool editor dialog CAM: Remember last selected library and sort order in preferences CAM: support natural sort order in tool and library lists CAM: Fix CodeQL complaints CAM: Fix: not all attributes included in YAML serialization CAM: Fix: UTF8 chars not included in LinuxCNC export Fix: tool library not displayed when loading it for the first time CAM: Fix: custom shape class not found CAM: Check dependencies on import for friendlier error messages CAM: Open file dialogs in home by default CAM: Show "All Tools" entry in library list in the library editor CAM: fix: error on sorting tools with no tool number CAM: Fix: traceback if library contained tool number as string CAM: Fix: Linter errors in manager.py CAM: Fix: separator between library and tool buttons CAM: Add drag & drop support to the library editor CAM: Fix numerous linter errors on the AssetManager CAM: Show current library in library editor window title CAM: Add dedicated icons for library add + remove CAM: Support F2 key in library editor CAM: library editor handles delete key when library list is in focus; focus search field by default CAM: fix: tool list in dock initially not loading CAM: Fix: library editor did not open from "all tools" list CAM: Increase precision of parameters in tool summary to 3 digits fix TestToolBitListWidget --- .../CAM/CAMTests/TestPathToolAssetManager.py | 454 ++++++- .../CAMTests/TestPathToolBitBrowserWidget.py | 26 +- .../CAM/CAMTests/TestPathToolBitListWidget.py | 20 +- .../CAM/CAMTests/TestPathToolBitSerializer.py | 71 ++ src/Mod/CAM/CMakeLists.txt | 5 +- src/Mod/CAM/Gui/Resources/Path.qrc | 3 + .../Gui/Resources/icons/CAM_ToolTableAdd.svg | 1084 +++++++++++++++++ .../Resources/icons/CAM_ToolTableRemove.svg | 1084 +++++++++++++++++ .../Gui/Resources/panels/LibraryProperties.ui | 121 ++ .../Resources/panels/ToolBitLibraryEdit.ui | 463 ++++--- src/Mod/CAM/Path/Preferences.py | 11 + src/Mod/CAM/Path/Tool/assets/manager.py | 260 +++- src/Mod/CAM/Path/Tool/assets/serializer.py | 2 +- src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 25 +- .../CAM/Path/Tool/assets/ui/preferences.py | 1 + .../CAM/Path/Tool/library/models/__init__.py | 5 + .../CAM/Path/Tool/library/models/library.py | 14 +- .../Path/Tool/library/serializers/linuxcnc.py | 3 +- src/Mod/CAM/Path/Tool/library/ui/__init__.py | 12 + src/Mod/CAM/Path/Tool/library/ui/browser.py | 578 ++++++++- src/Mod/CAM/Path/Tool/library/ui/cmd.py | 2 +- src/Mod/CAM/Path/Tool/library/ui/dock.py | 36 +- src/Mod/CAM/Path/Tool/library/ui/editor.py | 1021 +++++++--------- .../CAM/Path/Tool/library/ui/properties.py | 72 ++ src/Mod/CAM/Path/Tool/shape/models/base.py | 12 +- .../CAM/Path/Tool/toolbit/models/ballend.py | 4 +- src/Mod/CAM/Path/Tool/toolbit/models/base.py | 10 +- .../CAM/Path/Tool/toolbit/models/bullnose.py | 6 +- .../CAM/Path/Tool/toolbit/models/chamfer.py | 4 +- .../CAM/Path/Tool/toolbit/models/dovetail.py | 4 +- src/Mod/CAM/Path/Tool/toolbit/models/drill.py | 4 +- .../CAM/Path/Tool/toolbit/models/endmill.py | 4 +- .../CAM/Path/Tool/toolbit/models/fillet.py | 4 +- src/Mod/CAM/Path/Tool/toolbit/models/probe.py | 6 +- .../CAM/Path/Tool/toolbit/models/reamer.py | 4 +- .../Path/Tool/toolbit/models/slittingsaw.py | 4 +- src/Mod/CAM/Path/Tool/toolbit/models/tap.py | 4 +- .../Path/Tool/toolbit/models/threadmill.py | 4 +- src/Mod/CAM/Path/Tool/toolbit/models/vbit.py | 4 +- .../Path/Tool/toolbit/serializers/__init__.py | 8 +- .../CAM/Path/Tool/toolbit/serializers/yaml.py | 88 ++ src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py | 2 + src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 398 ++++-- src/Mod/CAM/Path/Tool/toolbit/ui/editor.py | 52 +- src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py | 4 + src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py | 102 +- src/Mod/CAM/Path/Tool/toolbit/ui/util.py | 32 + src/Mod/CAM/Path/Tool/toolbit/util.py | 7 +- src/Mod/CAM/TestCAMApp.py | 1 + 49 files changed, 4963 insertions(+), 1182 deletions(-) create mode 100644 src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg create mode 100644 src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg create mode 100644 src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui create mode 100644 src/Mod/CAM/Path/Tool/library/ui/properties.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/util.py diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py index 3043060f9e..6120110a1c 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py @@ -1,9 +1,13 @@ +""" +AssetManager tests. +""" + import unittest import asyncio from unittest.mock import Mock import pathlib import tempfile -from typing import Any, Mapping, List +from typing import Any, Mapping, List, Type, Optional, cast from Path.Tool.assets import ( AssetManager, FileStore, @@ -24,7 +28,7 @@ class MockAsset(Asset): self._id = id @classmethod - def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: # Mock implementation doesn't use data or format for dependencies return [] @@ -33,18 +37,83 @@ class MockAsset(Asset): cls, data: bytes, id: str, - dependencies: Mapping[AssetUri, Asset] | None, - serializer: AssetSerializer, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], ) -> "MockAsset": # Create instance with provided id return cls(data, id) - def to_bytes(self, serializer: AssetSerializer) -> bytes: + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: return self._data def get_id(self) -> str: return self._id + def get_data(self) -> bytes: + """Returns the raw data stored in the mock asset.""" + return self._data + + +# Mock Asset class with dependencies for testing deepcopy +class MockAssetWithDeps(Asset): + asset_type: str = "mock_asset_with_deps" + + def __init__( + self, + data: Any = None, + id: str = "mock_id", + dependencies: Optional[Mapping[AssetUri, Asset]] = None, + ): + self._data = data + self._id = id + self._dependencies = dependencies or {} + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: + # Assuming data is a simple JSON string like '{"deps": ["uri1", "uri2"]}' + try: + import json + + data_str = data.decode("utf-8") + data_dict = json.loads(data_str) + dep_uris_str = data_dict.get("deps", []) + return [AssetUri(uri_str) for uri_str in dep_uris_str] + except Exception: + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], + ) -> "MockAssetWithDeps": + # Create instance with provided id and resolved dependencies + return cls(data, id, dependencies) + + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: + # Serialize data and dependency URIs into a simple format + try: + import json + + dep_uri_strs = [str(uri) for uri in self._dependencies.keys()] + data_dict = {"data": self._data.decode("utf-8"), "deps": dep_uri_strs} + return json.dumps(data_dict).encode("utf-8") + except Exception: + return self._data # Fallback if serialization fails + + def get_id(self) -> str: + return self._id + + def get_data(self) -> bytes: + """Returns the raw data stored in the mock asset.""" + return self._data + + def get_dependencies(self) -> Mapping[AssetUri, Asset]: + """Returns the resolved dependencies.""" + return self._dependencies + class TestPathToolAssetManager(unittest.TestCase): def test_register_store(self): @@ -85,12 +154,12 @@ class TestPathToolAssetManager(unittest.TestCase): cls, data: bytes, id: str, - dependencies: Mapping[AssetUri, Asset] | None, - serializer: AssetSerializer, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], ) -> "AnotherMockAsset": return cls() - def to_bytes(self, serializer: AssetSerializer) -> bytes: + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: return b"" def get_id(self) -> str: @@ -134,12 +203,11 @@ class TestPathToolAssetManager(unittest.TestCase): ) # Call AssetManager.get - retrieved_object = manager.get(test_uri) + retrieved_object = cast(MockAsset, manager.get(test_uri)) # Assert the retrieved object is an instance of MockAsset self.assertIsInstance(retrieved_object, MockAsset) - # Assert the data was passed to from_bytes - self.assertEqual(retrieved_object._data, test_data) + self.assertEqual(retrieved_object.get_data(), test_data) # Test error handling for non-existent URI non_existent_uri = AssetUri.build(MockAsset.asset_type, "non_existent", "1") @@ -210,7 +278,7 @@ class TestPathToolAssetManager(unittest.TestCase): # Verify the asset was created retrieved_data = asyncio.run(local_store.get(created_uri)) - self.assertEqual(retrieved_data, test_obj.to_bytes(DummyAssetSerializer)) + self.assertEqual(retrieved_data, test_obj.get_data()) # Test error handling (store not found) with self.assertRaises(ValueError) as cm: @@ -244,9 +312,10 @@ class TestPathToolAssetManager(unittest.TestCase): self.assertEqual(updated_uri.version, "2") # Verify the asset was updated - obj = manager.get(updated_uri, store="local") - self.assertEqual(updated_data, test_obj.to_bytes(DummyAssetSerializer)) - self.assertEqual(updated_data, obj.to_bytes(DummyAssetSerializer)) + obj = cast(MockAsset, manager.get(updated_uri, store="local")) + self.assertEqual(updated_data, test_obj.get_data()) + self.assertIsInstance(obj, MockAsset) + self.assertEqual(updated_data, obj.get_data()) # Test error handling (store not found) with self.assertRaises(ValueError) as cm: @@ -382,23 +451,17 @@ class TestPathToolAssetManager(unittest.TestCase): uris = [uri1, uri2, uri3] # Call manager.get_bulk - retrieved_assets = manager.get_bulk(uris, store="memory_bulk") + retrieved_assets = cast(List[MockAsset], manager.get_bulk(uris, store="memory_bulk")) # Assert the correct number of assets were returned self.assertEqual(len(retrieved_assets), 3) # Assert the retrieved assets are MockAsset instances with correct data self.assertIsInstance(retrieved_assets[0], MockAsset) - self.assertEqual( - retrieved_assets[0].to_bytes(DummyAssetSerializer), - data1, - ) + self.assertEqual(retrieved_assets[0].get_data(), data1) self.assertIsInstance(retrieved_assets[1], MockAsset) - self.assertEqual( - retrieved_assets[1].to_bytes(DummyAssetSerializer), - data2, - ) + self.assertEqual(retrieved_assets[1].get_data(), data2) # Assert the non-existent asset is None self.assertIsNone(retrieved_assets[2]) @@ -442,8 +505,9 @@ class TestPathToolAssetManager(unittest.TestCase): manager_filtered.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch_filtered") manager_filtered.add_raw("another_type", "id3", b"data for id3", "memory_fetch_filtered") - retrieved_assets_filtered = manager_filtered.fetch( - asset_type=MockAsset.asset_type, store="memory_fetch_filtered" + retrieved_assets_filtered = cast( + List[MockAsset], + manager_filtered.fetch(asset_type=MockAsset.asset_type, store="memory_fetch_filtered"), ) # Assert the correct number of assets were returned @@ -452,13 +516,13 @@ class TestPathToolAssetManager(unittest.TestCase): # Assert the retrieved assets are MockAsset instances with correct data self.assertIsInstance(retrieved_assets_filtered[0], MockAsset) self.assertEqual( - retrieved_assets_filtered[0].to_bytes(DummyAssetSerializer).decode("utf-8"), + retrieved_assets_filtered[0].get_data().decode("utf-8"), data1.decode("utf-8"), ) self.assertIsInstance(retrieved_assets_filtered[1], MockAsset) self.assertEqual( - retrieved_assets_filtered[1].to_bytes(DummyAssetSerializer).decode("utf-8"), + retrieved_assets_filtered[1].get_data().decode("utf-8"), data2.decode("utf-8"), ) @@ -467,6 +531,340 @@ class TestPathToolAssetManager(unittest.TestCase): manager.fetch(store="non_existent_store") self.assertIn("No store registered for name:", str(cm.exception)) + def test_copy(self): + # Setup AssetManager with two MemoryStores and MockAsset class + memory_store_src = MemoryStore("memory_copy_src") + memory_store_dest = MemoryStore("memory_copy_dest") + manager = AssetManager() + manager.register_store(memory_store_src) + manager.register_store(memory_store_dest) + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create a source asset + src_data = b"source asset data" + src_uri = manager.add_raw(MockAsset.asset_type, "source_id", src_data, "memory_copy_src") + + # Test copying to a different store with default destination URI + copied_uri_default_dest = manager.copy( + src_uri, dest_store="memory_copy_dest", store="memory_copy_src" + ) + self.assertEqual(copied_uri_default_dest.asset_type, src_uri.asset_type) + self.assertEqual(copied_uri_default_dest.asset_id, src_uri.asset_id) + self.assertEqual(copied_uri_default_dest.version, "1") # First version in dest + + # Verify the copied asset exists in the destination store + copied_data_default_dest = manager.get_raw( + copied_uri_default_dest, store="memory_copy_dest" + ) + self.assertEqual(copied_data_default_dest, src_data) + + # Test copying to a different store with a specified destination URI + dest_uri_specified = AssetUri.build(MockAsset.asset_type, "specified_dest_id", "1") + copied_uri_specified_dest = manager.copy( + src_uri, + dest_store="memory_copy_dest", + store="memory_copy_src", + dest=dest_uri_specified, + ) + self.assertEqual(copied_uri_specified_dest, dest_uri_specified) + + # Verify the copied asset exists at the specified destination URI + copied_data_specified_dest = manager.get_raw( + copied_uri_specified_dest, store="memory_copy_dest" + ) + self.assertEqual(copied_data_specified_dest, src_data) + + # Test copying to the same store with a different destination URI + dest_uri_same_store = AssetUri.build(MockAsset.asset_type, "same_store_dest", "1") + copied_uri_same_store = manager.copy( + src_uri, dest_store="memory_copy_src", store="memory_copy_src", dest=dest_uri_same_store + ) + self.assertEqual(copied_uri_same_store, dest_uri_same_store) + + # Verify the copied asset exists in the same store at the new URI + copied_data_same_store = manager.get_raw(copied_uri_same_store, store="memory_copy_src") + self.assertEqual(copied_data_same_store, src_data) + + # Test assertion for source and destination being the same + with self.assertRaises(ValueError) as cm: + manager.copy( + src_uri, dest_store="memory_copy_src", store="memory_copy_src", dest=src_uri + ) + self.assertIn( + "Source and destination cannot be the same asset in the same store.", + str(cm.exception), + ) + + # Test overwriting existing destination (add a different asset at specified_dest_id) + overwrite_data = b"data to be overwritten" + overwrite_uri = manager.add_raw( + MockAsset.asset_type, "specified_dest_id", overwrite_data, "memory_copy_dest" + ) + self.assertEqual(overwrite_uri.version, "2") # Should be version 2 now + + # Copy the original src_uri to the existing destination + copied_uri_overwrite = manager.copy( + src_uri, + dest_store="memory_copy_dest", + store="memory_copy_src", + dest=dest_uri_specified, + ) + # The version should be incremented again due to overwrite + self.assertEqual(copied_uri_overwrite.version, "3") + + # Verify the destination now contains the source data + retrieved_overwritten_data = manager.get_raw(copied_uri_overwrite, store="memory_copy_dest") + self.assertEqual(retrieved_overwritten_data, src_data) + + def test_deepcopy(self): + # Setup AssetManager with two MemoryStores and MockAssetWithDeps class + memory_store_src = MemoryStore("memory_deepcopy_src") + memory_store_dest = MemoryStore("memory_deepcopy_dest") + manager = AssetManager() + manager.register_store(memory_store_src) + manager.register_store(memory_store_dest) + manager.register_asset(MockAssetWithDeps, DummyAssetSerializer) + + # Create dependency assets in the source store + dep1_data = b'{"data": "dependency 1 data", "deps": []}' + dep2_data = b'{"data": "dependency 2 data", "deps": []}' + dep1_uri = manager.add_raw( + MockAssetWithDeps.asset_type, "dep1_id", dep1_data, "memory_deepcopy_src" + ) + dep2_uri = manager.add_raw( + MockAssetWithDeps.asset_type, "dep2_id", dep2_data, "memory_deepcopy_src" + ) + + # Create a source asset with dependencies + src_data = ( + b'{"data": "source asset data", "deps": ["' + + str(dep1_uri).encode("utf-8") + + b'", "' + + str(dep2_uri).encode("utf-8") + + b'"]}' + ) + src_uri = manager.add_raw( + MockAssetWithDeps.asset_type, "source_id", src_data, "memory_deepcopy_src" + ) + + # Test deep copying to a different store with default destination URI + copied_uri_default_dest = manager.deepcopy( + src_uri, dest_store="memory_deepcopy_dest", store="memory_deepcopy_src" + ) + self.assertEqual(copied_uri_default_dest.asset_type, src_uri.asset_type) + self.assertEqual(copied_uri_default_dest.asset_id, src_uri.asset_id) + self.assertEqual(copied_uri_default_dest.version, "1") # First version in dest + + # Verify the copied top-level asset exists in the destination store + copied_asset_default_dest = cast( + MockAssetWithDeps, manager.get(copied_uri_default_dest, store="memory_deepcopy_dest") + ) + self.assertIsInstance(copied_asset_default_dest, MockAssetWithDeps) + # The copied asset's data should be the serialized form including dependencies + expected_data = b'{"data": "source asset data", "deps": ["mock_asset_with_deps://dep1_id/1", "mock_asset_with_deps://dep2_id/1"]}' + self.assertEqual(copied_asset_default_dest.get_data(), expected_data) + + # Verify dependencies were also copied and resolved correctly + copied_deps_default_dest = copied_asset_default_dest.get_dependencies() + self.assertEqual(len(copied_deps_default_dest), 2) + self.assertIn(dep1_uri, copied_deps_default_dest) + self.assertIn(dep2_uri, copied_deps_default_dest) + + copied_dep1 = cast(MockAssetWithDeps, copied_deps_default_dest[dep1_uri]) + self.assertIsInstance(copied_dep1, MockAssetWithDeps) + self.assertEqual(copied_dep1.get_data(), b'{"data": "dependency 1 data", "deps": []}') + + copied_dep2 = cast(MockAssetWithDeps, copied_deps_default_dest[dep2_uri]) + self.assertIsInstance(copied_dep2, MockAssetWithDeps) + self.assertEqual(copied_dep2.get_data(), b'{"data": "dependency 2 data", "deps": []}') + + # Test deep copying with a specified destination URI for the top-level asset + dest_uri_specified = AssetUri.build(MockAssetWithDeps.asset_type, "specified_dest_id", "1") + copied_uri_specified_dest = manager.deepcopy( + src_uri, + dest_store="memory_deepcopy_dest", + store="memory_deepcopy_src", + dest=dest_uri_specified, + ) + self.assertEqual(copied_uri_specified_dest, dest_uri_specified) + + # Verify the copied asset exists at the specified destination URI + copied_asset_specified_dest = cast( + MockAssetWithDeps, manager.get(copied_uri_specified_dest, store="memory_deepcopy_dest") + ) + self.assertIsInstance(copied_asset_specified_dest, MockAssetWithDeps) + self.assertEqual( + copied_asset_specified_dest.get_data(), + b'{"data": "source asset data", "deps": ["mock_asset_with_deps://dep1_id/1", "mock_asset_with_deps://dep2_id/1"]}', + ) + + # Verify dependencies were copied and resolved correctly (their URIs should be + # in the destination store, but their asset_type and asset_id should be the same) + copied_deps_specified_dest = copied_asset_specified_dest.get_dependencies() + self.assertEqual(len(copied_deps_specified_dest), 2) + + # The keys in the dependencies mapping should be the *original* URIs, + # but the values should be the *copied* dependency assets. + self.assertIn(dep1_uri, copied_deps_specified_dest) + self.assertIn(dep2_uri, copied_deps_specified_dest) + + copied_dep1_specified = cast(MockAssetWithDeps, copied_deps_specified_dest[dep1_uri]) + self.assertIsInstance(copied_dep1_specified, MockAssetWithDeps) + self.assertEqual( + copied_dep1_specified.get_data(), b'{"data": "dependency 1 data", "deps": []}' + ) + # Check the URI of the copied dependency in the destination store + self.assertIsNotNone( + manager.get_or_none(copied_dep1_specified.get_uri(), store="memory_deepcopy_dest") + ) + self.assertEqual(copied_dep1_specified.get_uri().asset_type, dep1_uri.asset_type) + self.assertEqual(copied_dep1_specified.get_uri().asset_id, dep1_uri.asset_id) + + copied_dep2_specified = cast(MockAssetWithDeps, copied_deps_specified_dest[dep2_uri]) + self.assertIsInstance(copied_dep2_specified, MockAssetWithDeps) + self.assertEqual( + copied_dep2_specified.get_data(), b'{"data": "dependency 2 data", "deps": []}' + ) + # Check the URI of the copied dependency in the destination store + self.assertIsNotNone( + manager.get_or_none(copied_dep2_specified.get_uri(), store="memory_deepcopy_dest") + ) + self.assertEqual(copied_dep2_specified.get_uri().asset_type, dep2_uri.asset_type) + self.assertEqual(copied_dep2_specified.get_uri().asset_id, dep2_uri.asset_id) + + # Test handling of existing dependencies in the destination store (should be skipped) + # Add a dependency with the same URI as dep1_uri to the destination store + existing_dep1_data = b'{"data": "existing dependency 1 data", "deps": []}' + existing_dep1_uri_in_dest = manager.add_raw( + dep1_uri.asset_type, dep1_uri.asset_id, existing_dep1_data, "memory_deepcopy_dest" + ) + self.assertEqual(existing_dep1_uri_in_dest.version, "2") # Should be version 2 now + + # Deep copy the source asset again + copied_uri_existing_dep = manager.deepcopy( + src_uri, dest_store="memory_deepcopy_dest", store="memory_deepcopy_src" + ) + # The top-level asset should be overwritten, incrementing its version + self.assertEqual(copied_uri_existing_dep.version, "2") + + # Verify the top-level asset was overwritten + copied_asset_existing_dep = cast( + MockAssetWithDeps, manager.get(copied_uri_existing_dep, store="memory_deepcopy_dest") + ) + self.assertIsInstance(copied_asset_existing_dep, MockAssetWithDeps) + self.assertEqual( + copied_asset_existing_dep.get_data(), + b'{"data": "source asset data", "deps": ["mock_asset_with_deps://dep1_id/1", "mock_asset_with_deps://dep2_id/1"]}', + ) + + # Verify that the existing dependency was *not* overwritten + retrieved_existing_dep1 = manager.get_raw( + existing_dep1_uri_in_dest, store="memory_deepcopy_dest" + ) + self.assertEqual(retrieved_existing_dep1, existing_dep1_data) + + # Verify the dependencies in the copied asset still point to the correct + # (existing) dependency in the destination store. + copied_deps_existing_dep = copied_asset_existing_dep.get_dependencies() + self.assertEqual(len(copied_deps_existing_dep), 2) + self.assertIn(dep1_uri, copied_deps_existing_dep) + self.assertIn(dep2_uri, copied_deps_existing_dep) + + copied_dep1_existing = cast(MockAssetWithDeps, copied_deps_existing_dep[dep1_uri]) + self.assertIsInstance(copied_dep1_existing, MockAssetWithDeps) + self.assertEqual( + copied_dep1_existing.get_data(), b'{"data": "dependency 1 data", "deps": []}' + ) # Should be the original data from source + + copied_dep2_existing = cast(MockAssetWithDeps, copied_deps_existing_dep[dep2_uri]) + self.assertIsInstance(copied_dep2_existing, MockAssetWithDeps) + self.assertEqual( + copied_dep2_existing.get_data(), b'{"data": "dependency 2 data", "deps": []}' + ) # Should be the newly copied raw data + + # Test handling of existing top-level asset in the destination store (should be overwritten) + # This was implicitly tested in the previous step where the top-level asset's + # version was incremented. Let's add a more explicit test. + overwrite_src_data = b'{"data": "overwrite source data", "deps": []}' + overwrite_src_uri = manager.add_raw( + MockAssetWithDeps.asset_type, + "overwrite_source_id", + overwrite_src_data, + "memory_deepcopy_src", + ) + + # Add an asset to the destination store with the same URI as overwrite_src_uri + existing_dest_data = b'{"data": "existing destination data", "deps": []}' + existing_dest_uri = manager.add_raw( + overwrite_src_uri.asset_type, + overwrite_src_uri.asset_id, + existing_dest_data, + "memory_deepcopy_dest", + ) + self.assertEqual(existing_dest_uri.version, "1") + + # Deep copy overwrite_src_uri to the existing destination URI + copied_uri_overwrite_top = manager.deepcopy( + overwrite_src_uri, + dest_store="memory_deepcopy_dest", + store="memory_deepcopy_src", + dest=existing_dest_uri, + ) + # The version should be incremented + self.assertEqual(copied_uri_overwrite_top.version, "2") + + # Verify the destination now contains the source data + retrieved_overwritten_top = manager.get_raw( + copied_uri_overwrite_top, store="memory_deepcopy_dest" + ) + # Need to parse the data to get the actual content + import json + + retrieved_data_dict = json.loads(retrieved_overwritten_top.decode("utf-8")) + self.assertEqual(retrieved_data_dict.get("data"), b"overwrite source data".decode("utf-8")) + + # Test error handling for non-existent source asset + non_existent_src_uri = AssetUri.build(MockAssetWithDeps.asset_type, "non_existent_src", "1") + with self.assertRaises(FileNotFoundError) as cm: + manager.deepcopy( + non_existent_src_uri, dest_store="memory_deepcopy_dest", store="memory_deepcopy_src" + ) + self.assertIn("Source asset", str(cm.exception)) + self.assertIn("not found", str(cm.exception)) + + # Test error handling for non-existent source store + with self.assertRaises(ValueError) as cm: + manager.deepcopy(src_uri, dest_store="memory_deepcopy_dest", store="non_existent_store") + self.assertIn("Source store", str(cm.exception)) + self.assertIn("not registered", str(cm.exception)) + + # Test error handling for non-existent destination store + with self.assertRaises(ValueError) as cm: + manager.deepcopy(src_uri, dest_store="non_existent_store", store="memory_deepcopy_src") + self.assertIn("Destination store", str(cm.exception)) + self.assertIn("not registered", str(cm.exception)) + + def test_exists(self): + # Setup AssetManager with a MemoryStore + memory_store = MemoryStore("memory_exists") + manager = AssetManager() + manager.register_store(memory_store) + + # Create an asset + test_uri = manager.add_raw("test_type", "test_id", b"data", "memory_exists") + + # Test exists for an existing asset + self.assertTrue(manager.exists(test_uri, store="memory_exists")) + + # Test exists for a non-existent asset + non_existent_uri = AssetUri.build("test_type", "non_existent_id", "1") + self.assertFalse(manager.exists(non_existent_uri, store="memory_exists")) + + # Test exists for a non-existent store (should raise ValueError) + with self.assertRaises(ValueError) as cm: + manager.exists(test_uri, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + if __name__ == "__main__": unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py index df4dc9ae23..2a839c4317 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py @@ -62,8 +62,8 @@ class TestToolBitBrowserWidget(PathTestWithAssets): search_term = "Endmill" self.widget._search_edit.setText(search_term) - # Directly trigger the fetch and filtering logic - self.widget._trigger_fetch() + # Directly trigger the filtering logic + self.widget._update_list() # Verify that the filter was applied to the list widget # We can check if items are hidden/shown based on the filter term @@ -100,28 +100,6 @@ class TestToolBitBrowserWidget(PathTestWithAssets): self.assertEqual(actual_visible_uris, expected_visible_uris) - def test_lazy_loading_on_scroll(self): - # This test requires more than self._batch_size toolbits to be effective. - # The default test assets might not have enough. - # We'll assume there are enough for the test structure. - - initial_count = self.widget._tool_list_widget.count() - if initial_count < self.widget._batch_size: - self.skipTest("Not enough toolbits for lazy loading test.") - - # Simulate scrolling to the bottom by emitting the signal - scrollbar = self.widget._tool_list_widget.verticalScrollBar() - # Set the scrollbar value to its maximum to simulate reaching the end - scrollbar.valueChanged.emit(scrollbar.maximum()) - - # Verify that more items were loaded - new_count = self.widget._tool_list_widget.count() - self.assertGreater(new_count, initial_count) - # Verify that the number of new items is approximately the batch size - self.assertAlmostEqual( - new_count - initial_count, self.widget._batch_size, delta=5 - ) # Allow small delta - def test_tool_selected_signal(self): mock_slot = MagicMock() self.widget.toolSelected.connect(mock_slot) diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py index 4b358e9d13..2c233856ca 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py @@ -22,7 +22,9 @@ """Unit tests for the ToolBitListWidget.""" +from typing import cast import unittest +from Path.Tool.toolbit import ToolBit from Path.Tool.toolbit.ui.toollist import ToolBitListWidget, ToolBitUriRole from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell from .PathTestUtils import PathTestWithAssets # Import the base test class @@ -37,7 +39,7 @@ class TestToolBitListWidget(PathTestWithAssets): def test_add_toolbit(self): # Get a real ToolBit asset - toolbit = self.assets.get("toolbit://5mm_Endmill") + toolbit = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) tool_no = 1 self.widget.add_toolbit(toolbit, str(tool_no)) @@ -53,7 +55,7 @@ class TestToolBitListWidget(PathTestWithAssets): self.assertEqual(cell_widget.tool_no, str(tool_no)) self.assertEqual(cell_widget.upper_text, toolbit.label) # Assuming the 5mm_Endmill asset has a shape named 'Endmill' - self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge") + self.assertEqual(cell_widget.lower_text, "5 mm 4-flute endmill, 30 mm cutting edge") # Verify URI is stored in item data stored_uri = item.data(ToolBitUriRole) @@ -61,8 +63,8 @@ class TestToolBitListWidget(PathTestWithAssets): def test_clear_list(self): # Add some real items first - toolbit1 = self.assets.get("toolbit://5mm_Endmill") - toolbit2 = self.assets.get("toolbit://slittingsaw") + toolbit1 = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) + toolbit2 = cast(ToolBit, self.assets.get("toolbit://slittingsaw")) self.widget.add_toolbit(toolbit1, 1) self.widget.add_toolbit(toolbit2, 2) self.assertEqual(self.widget.count(), 2) @@ -72,9 +74,9 @@ class TestToolBitListWidget(PathTestWithAssets): def test_apply_filter(self): # Add items with distinct text for filtering - toolbit1 = self.assets.get("toolbit://5mm_Endmill") - toolbit2 = self.assets.get("toolbit://slittingsaw") - toolbit3 = self.assets.get("toolbit://probe") + toolbit1 = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) + toolbit2 = cast(ToolBit, self.assets.get("toolbit://slittingsaw")) + toolbit3 = cast(ToolBit, self.assets.get("toolbit://probe")) self.widget.add_toolbit(toolbit1, 1) self.widget.add_toolbit(toolbit2, 2) @@ -117,8 +119,8 @@ class TestToolBitListWidget(PathTestWithAssets): self.assertEqual(cell.search_highlight, "3mm") def test_get_selected_toolbit_uri(self): - toolbit1 = self.assets.get("toolbit://5mm_Endmill") - toolbit2 = self.assets.get("toolbit://slittingsaw") + toolbit1 = cast(ToolBit, self.assets.get("toolbit://5mm_Endmill")) + toolbit2 = cast(ToolBit, self.assets.get("toolbit://slittingsaw")) self.widget.add_toolbit(toolbit1, 1) self.widget.add_toolbit(toolbit2, 2) diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py index b2d36cb7ca..7a9ea41995 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py @@ -1,3 +1,4 @@ +import yaml import json from typing import Type, cast import FreeCAD @@ -6,6 +7,7 @@ from Path.Tool.toolbit import ToolBit, ToolBitEndmill from Path.Tool.toolbit.serializers import ( FCTBSerializer, CamoticsToolBitSerializer, + YamlToolBitSerializer, ) from Path.Tool.assets.asset import Asset from Path.Tool.assets.serializer import AssetSerializer @@ -132,3 +134,72 @@ class TestFCTBSerializer(_BaseToolBitSerializerTestCase): self.assertEqual(deserialized_bit.get_shape_name(), "Endmill") self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm") self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm") + + +class TestYamlToolBitSerializer(_BaseToolBitSerializerTestCase): + serializer_class = YamlToolBitSerializer + + def test_serialize(self): + super().test_serialize() + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + # YAML specific assertions + data = yaml.safe_load(serialized_data.decode("utf-8")) + self.assertEqual(data.get("id"), "5mm_Endmill") + self.assertEqual(data.get("name"), "Test Tool") + self.assertEqual(data.get("shape"), "endmill.fcstd") + self.assertEqual(data.get("shape-type"), "Endmill") + self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm") + self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm") + + def test_extract_dependencies(self): + """Test dependency extraction for YAML.""" + yaml_data = ( + b"name: Test Tool\n" + b"shape: endmill\n" + b"shape-type: Endmill\n" + b"parameter:\n" + b" Diameter: 4.12 mm\n" + b" Length: 15.0 mm\n" + b"attribute: {}\n" + ) + dependencies = self.serializer_class.extract_dependencies(yaml_data) + self.assertIsInstance(dependencies, list) + self.assertEqual(len(dependencies), 1) + self.assertEqual(dependencies[0], AssetUri.build("toolbitshape", "endmill")) + + def test_deserialize(self): + # Create a known serialized data string based on the YAML format + yaml_data = ( + b"id: TestID\n" + b"name: Test Tool\n" + b"shape: endmill\n" + b"shape-type: Endmill\n" + b"parameter:\n" + b" Diameter: 4.12 mm\n" + b" Length: 15.0 mm\n" + b"attribute: {}\n" + ) + # Create a ToolBitShapeEndmill instance for 'endmill' + shape = ToolBitShapeEndmill("endmill") + + # Create the dependencies dictionary with the shape instance + dependencies: Mapping[AssetUri, Asset] = {AssetUri.build("toolbitshape", "endmill"): shape} + + # Provide dummy id and dependencies for deserialization test + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(yaml_data, "TestID", dependencies=dependencies), + ) + self.assertIsInstance(deserialized_bit, ToolBit) + self.assertEqual(deserialized_bit.id, "TestID") + self.assertEqual(deserialized_bit.label, "Test Tool") + self.assertEqual(deserialized_bit.get_shape_name(), "Endmill") + self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm") + self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm") + + # Test with ID argument. + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(yaml_data, id="test_id", dependencies=dependencies), + ) + self.assertEqual(deserialized_bit.id, "test_id") diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index b2e1e55ad7..88a6f3643e 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -196,6 +196,7 @@ SET(PathPythonToolsToolBitSerializers_SRCS Path/Tool/toolbit/serializers/__init__.py Path/Tool/toolbit/serializers/camotics.py Path/Tool/toolbit/serializers/fctb.py + Path/Tool/toolbit/serializers/yaml.py ) SET(PathPythonToolsToolBitUi_SRCS @@ -208,6 +209,7 @@ SET(PathPythonToolsToolBitUi_SRCS Path/Tool/toolbit/ui/selector.py Path/Tool/toolbit/ui/tablecell.py Path/Tool/toolbit/ui/toollist.py + Path/Tool/toolbit/ui/util.py Path/Tool/toolbit/ui/view.py ) @@ -230,10 +232,11 @@ SET(PathPythonToolsLibrarySerializers_SRCS SET(PathPythonToolsLibraryUi_SRCS Path/Tool/library/ui/__init__.py + Path/Tool/library/ui/browser.py Path/Tool/library/ui/cmd.py Path/Tool/library/ui/dock.py Path/Tool/library/ui/editor.py - Path/Tool/library/ui/browser.py + Path/Tool/library/ui/properties.py ) SET(PathPythonToolsMachine_SRCS diff --git a/src/Mod/CAM/Gui/Resources/Path.qrc b/src/Mod/CAM/Gui/Resources/Path.qrc index 071ae97d00..e681f05236 100644 --- a/src/Mod/CAM/Gui/Resources/Path.qrc +++ b/src/Mod/CAM/Gui/Resources/Path.qrc @@ -62,6 +62,8 @@ icons/CAM_ToolDuplicate.svg icons/CAM_Toolpath.svg icons/CAM_ToolTable.svg + icons/CAM_ToolTableAdd.svg + icons/CAM_ToolTableRemove.svg icons/CAM_Vcarve.svg icons/CAM_Waterline.svg icons/arrow-ccw.svg @@ -93,6 +95,7 @@ panels/DragKnifeEdit.ui panels/DressUpLeadInOutEdit.ui panels/HoldingTagsEdit.ui + panels/LibraryProperties.ui panels/PageBaseGeometryEdit.ui panels/PageBaseHoleGeometryEdit.ui panels/PageBaseLocationEdit.ui diff --git a/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg new file mode 100644 index 0000000000..4b7103e0dd --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg @@ -0,0 +1,1084 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + CAM_ToolTable + 2015-07-04 + https://www.freecad.org/wiki/index.php?title=Artwork + + + FreeCAD + + + FreeCAD/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTable.svg + + + FreeCAD LGPL2+ + + + https://www.gnu.org/copyleft/lesser.html + + + [agryson] Alexander Gryson + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg new file mode 100644 index 0000000000..044fb05b24 --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg @@ -0,0 +1,1084 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + CAM_ToolTable + 2015-07-04 + https://www.freecad.org/wiki/index.php?title=Artwork + + + FreeCAD + + + FreeCAD/src/Mod/CAM/Gui/Resources/icons/CAM_ToolTable.svg + + + FreeCAD LGPL2+ + + + https://www.gnu.org/copyleft/lesser.html + + + [agryson] Alexander Gryson + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui new file mode 100644 index 0000000000..ff76a97264 --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui @@ -0,0 +1,121 @@ + + + LibraryProperties + + + + 0 + 0 + 400 + 189 + + + + Library Property Editor + + + + QLayout::SetMinimumSize + + + + + QLayout::SetMaximumSize + + + 0 + + + 0 + + + + + Name: + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + QLayout::SetMinimumSize + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel + + + + + + + Edit Library + + + + ../resources/icons/add-library.svg../resources/icons/add-library.svg + + + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui index 5fe79475eb..4b9177c369 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui @@ -13,275 +13,234 @@ Library Manager - + - + - - - Qt::Horizontal + + + QLayout::SetMinimumSize - - - 20 - 20 - - - + + + + Add New Library + + + + :/icons/CAM_ToolTableAdd.svg:/icons/CAM_ToolTableAdd.svg + + + + + + + Remove Library + + + + :/icons/CAM_ToolTableRemove.svg:/icons/CAM_ToolTableRemove.svg + + + + + + + Rename Library + + + + :/icons/edit-edit.svg:/icons/edit-edit.svg + + + + + + + Import Library + + + + :/icons/Std_Import.svg:/icons/Std_Import.svg + + + + + + + Export Library + + + + :/icons/Std_Export.svg:/icons/Std_Export.svg + + + + - - - Create Toolbit - - - - :/icons/CAM_ToolBit.svg:/icons/CAM_ToolBit.svg - - - - - - - - 16777215 - 16777215 - - - - Adds the existing tool bit to the library - - - Add Existing - - - - :/icons/CAM_ToolDuplicate.svg:/icons/CAM_ToolDuplicate.svg - - - - - - - - 16777215 - 16777215 - - - - Deletes the selected tool bits from the library - - - Remove - - - - :/icons/list-remove.svg:/icons/list-remove.svg - - - - - - - - - - - - - - - 16777215 - 16777215 - - - - Add new tool table - - - - - - - :/icons/document-new.svg:/icons/document-new.svg - - - - 24 - 24 - - - - - - - - - Save the selected library with a new name or export to another format - - - - - - - :/icons/Std_Export.svg:/icons/Std_Export.svg - - - - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Save the current library - - - - - - - :/icons/document-save.svg:/icons/document-save.svg - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - 0 - 0 - - - - QFrame::Box - - - QAbstractItemView::NoEditTriggers - - - false - - - - - - - true - - - Table of tool bits of the library - - - QFrame::Box - - - QFrame::Sunken - - - 1 - - - 0 - - - false - - - false - - - QAbstractItemView::DragOnly - - - Qt::IgnoreAction - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - true - - - false - - - true - - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 65555555 - 20 - - - - - - + - + 0 0 - + - 16777215 - 16777215 + 2 + 0 + + QFrame::VLine + + + QFrame::Sunken + + + + + + + QLayout::SetMinimumSize + + + + + Add Toolbit + + + + :/icons/CAM_ToolBit.svg:/icons/CAM_ToolBit.svg + + + + + + + Import Toolbit + + + + :/icons/Std_Import.svg:/icons/Std_Import.svg + + + + + + + Export Toolbit + + + + :/icons/Std_Export.svg:/icons/Std_Export.svg + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 40 + 20 + + + + + + + + + + + + + 0 + 0 + + + + true + + + Qt::StrongFocus + + + true + + + QFrame::Box + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::DropOnly + + + Qt::IgnoreAction + + + + + + + true + - Close the library editor + Table of Tool Bits of the library. - - Close + + QFrame::Box - - - :/icons/edit_OK.svg:/icons/edit_OK.svg + + QFrame::Sunken + + 1 + + + 0 + + + false + + + false + + + QAbstractItemView::DragOnly + + + Qt::IgnoreAction + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + false + + + true + diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 7f784fa4f6..107fd76fa6 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -54,6 +54,7 @@ PostProcessorOutputPolicy = "PostProcessorOutputPolicy" ToolGroup = PreferencesGroup + "/Tools" ToolPath = "ToolPath" LastToolLibrary = "LastToolLibrary" +LastToolLibrarySortKey = "LastToolLibrarySortKey" # Linear tolerance to use when generating Paths, eg when tessellating geometry GeometryTolerance = "GeometryTolerance" @@ -152,6 +153,16 @@ def setLastToolLibrary(name: str): pref.SetString(LastToolLibrary, name) +def getLastToolLibrarySortKey() -> Optional[str]: + pref = tool_preferences() + return pref.GetString(LastToolLibrarySortKey) or None + + +def setLastToolLibrarySortKey(name: str): + pref = tool_preferences() + pref.SetString(LastToolLibrarySortKey, name) + + def allAvailablePostProcessors(): allposts = [] for path in searchPathsPost(): diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py index 9095a6ec15..5a05fa9e17 100644 --- a/src/Mod/CAM/Path/Tool/assets/manager.py +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -145,7 +145,7 @@ class AssetManager: ) continue # Try next store - if raw_data is None: + if raw_data is None or not found_store_name: return None # Asset not found in any store if depth == 0: @@ -194,7 +194,6 @@ class AssetManager: def _calculate_cache_key_from_construction_data( self, construction_data: _AssetConstructionData, - store_name_for_cache: str, ) -> Optional[CacheKey]: if not construction_data or not construction_data.raw_data: return None @@ -209,7 +208,7 @@ class AssetManager: raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16) return CacheKey( - store_name=store_name_for_cache, + store_name=construction_data.store, asset_uri_str=str(construction_data.uri), raw_data_hash=raw_data_hash, dependency_signature=deps_signature_tuple, @@ -218,8 +217,7 @@ class AssetManager: def _build_asset_tree_from_data_sync( self, construction_data: Optional[_AssetConstructionData], - store_name_for_cache: str, - ) -> Asset | None: + ) -> Optional[Asset]: """ Synchronously and recursively builds an asset instance. Integrates caching logic. @@ -228,10 +226,8 @@ class AssetManager: return None cache_key: Optional[CacheKey] = None - if store_name_for_cache in self._cacheable_stores: - cache_key = self._calculate_cache_key_from_construction_data( - construction_data, store_name_for_cache - ) + if construction_data.store in self._cacheable_stores: + cache_key = self._calculate_cache_key_from_construction_data(construction_data) if cache_key: cached_asset = self.asset_cache.get(cache_key) if cached_asset is not None: @@ -255,7 +251,7 @@ class AssetManager: # this would need more complex store_name propagation. # For now, use the parent's store_name_for_cache. try: - dep = self._build_asset_tree_from_data_sync(dep_data_node, store_name_for_cache) + dep = self._build_asset_tree_from_data_sync(dep_data_node) except Exception as e: logger.error( f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}", @@ -365,10 +361,9 @@ class AssetManager: f"and {deps_count} dependencies ({found_deps_count} resolved)." ) # Use the first store from the list for caching purposes - store_name_for_cache = stores_list[0] if stores_list else "local" - final_asset = self._build_asset_tree_from_data_sync( - all_construction_data, store_name_for_cache=store_name_for_cache - ) + final_asset = self._build_asset_tree_from_data_sync(all_construction_data) + if not final_asset: + raise ValueError(f"failed to build asset {uri}") logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.") return final_asset @@ -377,7 +372,7 @@ class AssetManager: uri: Union[AssetUri, str], store: Union[str, Sequence[str]] = "local", depth: Optional[int] = None, - ) -> Asset | None: + ) -> Optional[Asset]: """ Convenience wrapper for get() that does not raise FileNotFoundError; returns None instead @@ -423,9 +418,7 @@ class AssetManager: logger.debug( f"get_async: Building asset tree for '{asset_uri_obj}', depth {depth} in current async context." ) - return self._build_asset_tree_from_data_sync( - all_construction_data, store_name_for_cache=store - ) + return self._build_asset_tree_from_data_sync(all_construction_data) def get_raw( self, @@ -438,31 +431,8 @@ class AssetManager: f"AssetManager.get_raw(uri='{uri}', stores='{stores_list}') from T:{threading.current_thread().name}" ) - async def _fetch_raw_async(stores_list: Sequence[str]): - asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri - logger.debug( - f"GetRawAsync (internal): Trying stores '{stores_list}'. Available stores: {list(self.stores.keys())}" - ) - for current_store_name in stores_list: - store = self.stores.get(current_store_name) - if not store: - logger.warning(f"Store '{current_store_name}' not registered. Skipping.") - continue - try: - raw_data = await store.get(asset_uri_obj) - logger.debug( - f"GetRawAsync: Asset {asset_uri_obj} found in store {current_store_name}" - ) - return raw_data - except FileNotFoundError: - logger.debug( - f"GetRawAsync: Asset {asset_uri_obj} not found in store {current_store_name}" - ) - continue - raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'") - try: - return asyncio.run(_fetch_raw_async(stores_list)) + return asyncio.run(self.get_raw_async(uri, stores_list)) except Exception as e: logger.error( f"GetRaw: Error during asyncio.run for '{uri}': {e}", @@ -483,12 +453,12 @@ class AssetManager: asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri for current_store_name in stores_list: - store = self.stores.get(current_store_name) - if not store: + thestore = self.stores.get(current_store_name) + if not thestore: logger.warning(f"Store '{current_store_name}' not registered. Skipping.") continue try: - raw_data = await store.get(asset_uri_obj) + raw_data = await thestore.get(asset_uri_obj) logger.debug( f"GetRawAsync: Asset {asset_uri_obj} found in store {current_store_name}" ) @@ -551,12 +521,7 @@ class AssetManager: elif isinstance(data_or_exc, _AssetConstructionData): # Build asset instance synchronously. Exceptions during build should propagate. # Use the first store from the list for caching purposes in build_asset_tree - store_name_for_cache = stores_list[0] if stores_list else "local" - assets.append( - self._build_asset_tree_from_data_sync( - data_or_exc, store_name_for_cache=store_name_for_cache - ) - ) + assets.append(self._build_asset_tree_from_data_sync(data_or_exc)) elif data_or_exc is None: # From _fetch_... returning None for not found logger.debug(f"GetBulk: Asset '{original_uri_input}' not found") assets.append(None) @@ -596,12 +561,8 @@ class AssetManager: for i, data_or_exc in enumerate(all_construction_data_list): if isinstance(data_or_exc, _AssetConstructionData): # Use the first store from the list for caching purposes in build_asset_tree - store_name_for_cache = stores_list[0] if stores_list else "local" - assets.append( - self._build_asset_tree_from_data_sync( - data_or_exc, store_name_for_cache=store_name_for_cache - ) - ) + asset = self._build_asset_tree_from_data_sync(data_or_exc) + assets.append(asset) elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None: assets.append(None) elif isinstance(data_or_exc, Exception): @@ -625,8 +586,8 @@ class AssetManager: for current_store_name in stores_list: store = self.stores.get(current_store_name) if not store: - logger.warning(f"Store '{current_store_name}' not registered. Skipping.") - continue + logger.error(f"Store '{current_store_name}' not registered. Skipping.") + raise ValueError(f"No store registered for name: {store}") try: exists = await store.exists(asset_uri_obj) if exists: @@ -840,12 +801,191 @@ class AssetManager: ) raise + async def copy_async( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Copies an asset from one location to another asynchronously. + + Performs a shallow copy by wrapping get_raw_async and add_raw_async. + If dest is None, it defaults to the uri given in src. + An assertion is raised if src and store are the same as dest and + dest_store. + If the destination already exists it should be silently overwritten. + """ + if dest is None: + dest = src + + if src == dest and store == dest_store: + raise ValueError("Source and destination cannot be the same asset in the same store.") + + raw_data = await self.get_raw_async(src, store) + return await self.add_raw_async(dest.asset_type, dest.asset_id, raw_data, dest_store) + + def copy( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Copies an asset from one location to another synchronously. + + Performs a shallow copy by wrapping get_raw and add_raw. + If dest is None, it defaults to the uri given in src. + An assertion is raised if src and store are the same as dest and + dest_store. + If the destination already exists it should be silently overwritten. + """ + return asyncio.run(self.copy_async(src, dest_store, store, dest)) + + async def deepcopy_async( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Asynchronously deep copies an asset and its dependencies from a source store + to a destination store. + + Args: + src: The AssetUri of the source asset. + dest_store: The name of the destination store. + store: The name of the source store (defaults to "local"). + dest: Optional. The new AssetUri for the top-level asset in the + destination store. If None, the original URI is used. + + Returns: + The AssetUri of the copied top-level asset in the destination store. + + Raises: + ValueError: If the source or destination store is not registered. + FileNotFoundError: If the source asset is not found. + RuntimeError: If a cyclic dependency is detected. + """ + logger.debug( + f"DeepcopyAsync URI '{src}' from store '{store}' to '{dest_store}'" + f" with dest '{dest}'" + ) + if dest is None: + dest = src + + if store not in self.stores: + raise ValueError(f"Source store '{store}' not registered.") + if dest_store not in self.stores: + raise ValueError(f"Destination store '{dest_store}' not registered.") + if store == dest_store and src == dest: + raise ValueError(f"File '{src}' cannot be copied to itself.") + + # Fetch the source asset and its dependencies recursively + # Use a new set for visited_uris for this deepcopy operation + construction_data = await self._fetch_asset_construction_data_recursive_async( + src, [store], set(), depth=None + ) + if construction_data is None: + raise FileNotFoundError(f"Source asset '{src}' not found in store '{store}'.") + + # Collect all assets (including dependencies) in a flat list, + # ensuring dependencies are processed before the assets that depend on them. + assets_to_copy: List[_AssetConstructionData] = [] + + def collect_assets(data: _AssetConstructionData): + if data.dependencies_data is not None: + for dep_data in data.dependencies_data.values(): + if dep_data: # Only collect if dependency data was successfully fetched + collect_assets(dep_data) + assets_to_copy.append(data) + + collect_assets(construction_data) + + # Process assets in the collected order (dependencies first) + dest_store: AssetStore = self.stores[dest_store] + copied_uris: Set[AssetUri] = set() + for asset_data in assets_to_copy: + # Prevent duplicate processing of the same asset + asset_uri = dest if asset_data.uri == src else asset_data.uri + if asset_uri in copied_uris: + logger.debug( + f"Dependency '{asset_uri}' already added to '{dest_store}'," " skipping copy." + ) + continue + copied_uris.add(asset_uri) + + # Check if the dependency already exists in the destination store + # Dependencies should be skipped if they exist, top-level should be overwritten. + exists_in_dest = await dest_store.exists(asset_uri) + if exists_in_dest and asset_uri != src: + logger.debug( + f"Dependency '{asset_uri}' already exists in '{dest_store}'," " skipping copy." + ) + continue + + # Put the asset (or dependency) into the destination store + # Pass the dependency_uri_map to the store's put method. + if exists_in_dest: + # If it was not skipped above, this is the top-level asset. Update it. + logger.debug(f"Updating asset '{asset_uri}' in '{dest_store}'") + dest = await dest_store.update( + asset_uri, + asset_data.raw_data, + ) + else: + # If it doesn't exist, or if it's a dependency that doesn't exist, create it + logger.debug(f"Creating asset '{asset_uri}' in '{dest_store}'") + logger.debug(f"Raw data before writing: {asset_data.raw_data}") # Added log + await dest_store.create( + asset_uri.asset_type, + asset_uri.asset_id, + asset_data.raw_data, + ) + + logger.debug(f"DeepcopyAsync completed for '{src}' to '{dest}'") + return dest + + def deepcopy( + self, + src: AssetUri, + dest_store: str, + store: str = "local", + dest: Optional[AssetUri] = None, + ) -> AssetUri: + """ + Synchronously deep copies an asset and its dependencies from a source store + to a destination store. + + Args: + src: The AssetUri of the source asset. + dest_store: The name of the destination store. + store: The name of the source store (defaults to "local"). + dest: Optional. The new AssetUri for the top-level asset in the + destination store. If None, the original URI is used. + + Returns: + The AssetUri of the copied top-level asset in the destination store. + + Raises: + ValueError: If the source or destination store is not registered. + FileNotFoundError: If the source asset is not found. + RuntimeError: If a cyclic dependency is detected. + """ + logger.debug( + f"Deepcopy URI '{src}' from store '{store}' to '{dest_store}'" f" with dest '{dest}'" + ) + return asyncio.run(self.deepcopy_async(src, dest_store, store, dest)) + def add_file( self, asset_type: str, path: pathlib.Path, store: str = "local", - asset_id: str | None = None, + asset_id: Optional[str] = None, ) -> AssetUri: """ Convenience wrapper around add_raw(). diff --git a/src/Mod/CAM/Path/Tool/assets/serializer.py b/src/Mod/CAM/Path/Tool/assets/serializer.py index 327cc4680f..dca0ca1ff5 100644 --- a/src/Mod/CAM/Path/Tool/assets/serializer.py +++ b/src/Mod/CAM/Path/Tool/assets/serializer.py @@ -28,7 +28,7 @@ from .asset import Asset class AssetSerializer(ABC): for_class: Type[Asset] - extensions: Tuple[str] = tuple() + extensions: Tuple[str, ...] = tuple() mime_type: str can_import: bool = True can_export: bool = True diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index c597e7430e..3c8505c4a6 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -20,10 +20,9 @@ # * * # *************************************************************************** import pathlib -import FreeCAD -import Path from typing import Optional, Tuple, Type, Iterable from PySide.QtWidgets import QFileDialog, QMessageBox +from ..manager import AssetManager from ..serializer import AssetSerializer, Asset from .util import ( make_import_filters, @@ -35,12 +34,15 @@ from .util import ( class AssetOpenDialog(QFileDialog): def __init__( self, + asset_manager: AssetManager, asset_class: Type[Asset], serializers: Iterable[Type[AssetSerializer]], parent=None, ): super().__init__(parent) + self.setDirectory(pathlib.Path.home().as_posix()) self.asset_class = asset_class + self.asset_manager = asset_manager self.serializers = list(serializers) self.setFileMode(QFileDialog.ExistingFile) filters = make_import_filters(self.serializers) @@ -50,6 +52,7 @@ class AssetOpenDialog(QFileDialog): def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]: """Deserialize the selected file using the appropriate serializer.""" + # Find the correct serializer for the file. file_extension = file_path.suffix.lower() serializer_class = get_serializer_from_extension( self.serializers, file_extension, for_import=True @@ -61,8 +64,25 @@ class AssetOpenDialog(QFileDialog): f"No supported serializer found for file extension '{file_extension}'", ) return None + + # Check whether all dependencies for importing the file exist. try: raw_data = file_path.read_bytes() + dependencies = serializer_class.extract_dependencies(raw_data) + for dependency_uri in dependencies: + if not self.asset_manager.exists(dependency_uri): + QMessageBox.critical( + self, + "Error", + f"Failed to import {file_path}: required dependency {dependency_uri} not found", + ) + return None + except Exception as e: + QMessageBox.critical(self, "Error", f"{file_path}: Failed to check dependencies: {e}") + return None + + # Load and return the asset. + try: asset = serializer_class.deep_deserialize(raw_data) if not isinstance(asset, self.asset_class): raise TypeError(f"Deserialized asset is not of type {self.asset_class.__name__}") @@ -90,6 +110,7 @@ class AssetSaveDialog(QFileDialog): parent=None, ): super().__init__(parent) + self.setDirectory(pathlib.Path.home().as_posix()) self.asset_class = asset_class self.serializers = list(serializers) self.setFileMode(QFileDialog.AnyFile) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py index c17b871234..e74305ad05 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -120,6 +120,7 @@ class AssetPreferencesPage: ) return False Path.Preferences.setAssetPath(asset_path) + Path.Preferences.setLastToolLibrary("") return True def loadSettings(self): diff --git a/src/Mod/CAM/Path/Tool/library/models/__init__.py b/src/Mod/CAM/Path/Tool/library/models/__init__.py index e69de29bb2..d95aa7a1c4 100644 --- a/src/Mod/CAM/Path/Tool/library/models/__init__.py +++ b/src/Mod/CAM/Path/Tool/library/models/__init__.py @@ -0,0 +1,5 @@ +from .library import Library + +__all__ = [ + "Library", +] diff --git a/src/Mod/CAM/Path/Tool/library/models/library.py b/src/Mod/CAM/Path/Tool/library/models/library.py index d2964d0ea8..4f1b9127af 100644 --- a/src/Mod/CAM/Path/Tool/library/models/library.py +++ b/src/Mod/CAM/Path/Tool/library/models/library.py @@ -132,15 +132,15 @@ class Library(Asset): return tool return None - def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> int: if bit not in self._bits: - return + raise ValueError(f"given bit {bit} not in library; cannot assign tool number") # If no specific bit_no was requested, assign a new one. if bit_no is None: bit_no = self.get_next_bit_no() elif self._bit_nos.get(bit_no) == bit: - return + return bit_no # Otherwise, add the bit. Since the requested bit_no may already # be in use, we need to account for that. In this case, we will @@ -154,7 +154,7 @@ class Library(Asset): self.assign_new_bit_no(old_bit) return bit_no - def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> int: if bit not in self._bits: self._bits.append(bit) return self.assign_new_bit_no(bit, bit_no) @@ -172,6 +172,12 @@ class Library(Asset): self._bits = [t for t in self._bits if t.id != bit.id] self._bit_nos = {k: v for (k, v) in self._bit_nos.items() if v.id != bit.id} + def remove_bit_by_uri(self, uri: AssetUri | str): + if isinstance(uri, str): + uri = AssetUri(uri) + self._bits = [t for t in self._bits if t.get_uri() != uri] + self._bit_nos = {k: v for (k, v) in self._bit_nos.items() if v.get_uri() != uri} + def dump(self, summarize: bool = False): title = 'Library "{}" ({}) (instance {})'.format(self.label, self.id, id(self)) print("-" * len(title)) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py index 70f26f3a46..992dfe6760 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py @@ -58,10 +58,11 @@ class LinuxCNCSerializer(AssetSerializer): continue diameter = bit.get_diameter() pocket = "P0" # TODO: is there a better way? + # Format diameter to one decimal place and remove units diameter_value = diameter.Value if hasattr(diameter, "Value") else diameter line = f"T{bit_no} {pocket} D{diameter_value:.3f} ;{bit.label}\n" - output.write(line.encode("ascii", "ignore")) + output.write(line.encode("utf-8")) return output.getvalue() diff --git a/src/Mod/CAM/Path/Tool/library/ui/__init__.py b/src/Mod/CAM/Path/Tool/library/ui/__init__.py index e69de29bb2..0d30b4bcc2 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/__init__.py +++ b/src/Mod/CAM/Path/Tool/library/ui/__init__.py @@ -0,0 +1,12 @@ +from .browser import LibraryBrowserWidget +from .dock import ToolBitLibraryDock +from .editor import LibraryEditor +from .properties import LibraryPropertyDialog + + +__all__ = [ + "LibraryBrowserWidget", + "ToolBitLibraryDock", + "LibraryEditor", + "LibraryPropertyDialog", +] diff --git a/src/Mod/CAM/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py index 24e0c1559e..c5527772a1 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# flake8: noqa E731 # *************************************************************************** # * Copyright (c) 2025 Samuel Abels * # * * @@ -22,18 +23,463 @@ """Widget for browsing Tool Library assets with filtering and sorting.""" -from typing import cast -from PySide import QtGui +import yaml +from typing import cast, List, Optional +from PySide import QtCore, QtGui +from PySide.QtGui import QMenu, QAction, QKeySequence +import FreeCAD import Path -from ...toolbit.ui.browser import ToolBitBrowserWidget -from ...assets import AssetManager -from ...library import Library +from ...assets import AssetManager, AssetUri +from ...toolbit import ToolBit +from ...toolbit.ui import ToolBitEditor +from ...toolbit.ui.util import natural_sort_key +from ...toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole +from ...toolbit.serializers import YamlToolBitSerializer +from ..models.library import Library + + +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) class LibraryBrowserWidget(ToolBitBrowserWidget): """ A widget to browse, filter, and select Tool Library assets from the - AssetManager, with sorting and batch insertion, including library selection. + AssetManager, with sorting and batch insertion, using a current library. + """ + + current_library_changed = QtCore.Signal() + + def __init__( + self, + asset_manager: AssetManager, + store: str = "local", + parent=None, + compact=True, + ): + super().__init__( + asset_manager=asset_manager, + store=store, + parent=parent, + tool_no_factory=self.get_tool_no_from_current_library, + compact=compact, + ) + self.current_library: Optional[Library] = None + self.layout().setContentsMargins(0, 0, 0, 0) + + self.restore_last_sort_order() + self.load_last_library() + + def setDragEnabled(self, enabled: bool = True): + """Enable or disable drag-and-drop support for the tool list.""" + self._tool_list_widget.setDragEnabled(enabled) + + def load_last_library(self): + """Loads the last selected library from preferences.""" + library_uri = Path.Preferences.getLastToolLibrary() + if library_uri: + try: + library = self._asset_manager.get(library_uri, store="local", depth=1) + self.set_current_library(library) + except Exception as e: + Path.Log.warning(f"Failed to load last tool library: {e}") + + def restore_last_sort_order(self): + """Sets the sort mode and updates the tool list.""" + last_sort_key = Path.Preferences.getLastToolLibrarySortKey() + if last_sort_key: + self.set_sort_order(last_sort_key) + + def set_sort_order(self, key: str): + super().set_sort_order(key) + Path.Preferences.setLastToolLibrarySortKey(self._sort_key) + + def _get_state(self): + """Gets the current library URI, selected toolbit URI, and scroll + position.""" + current_library_uri_str = ( + str(self.current_library.get_uri()) if self.current_library else None + ) + + selected_toolbit_uris = [] + selected_items = self._tool_list_widget.selectedItems() + if selected_items: + selected_toolbit_uris = [item.data(ToolBitUriRole) for item in selected_items] + + scroll_pos = self._tool_list_widget.verticalScrollBar().value() + + return { + "library_uri": current_library_uri_str, + "toolbit_uris": selected_toolbit_uris, + "scroll_pos": scroll_pos, + } + + def _set_state(self, selection_data): + """Restores the library selection, toolbit selection, and scroll + position.""" + library_uri_str = selection_data.get("library_uri") + toolbit_uris = selection_data.get("toolbit_uris", []) + scroll_pos = selection_data.get("scroll_pos", 0) + + # Restore library selection + if library_uri_str: + try: + library_uri = AssetUri(library_uri_str) + library = self._asset_manager.get(library_uri, store=self._store_name, depth=1) + self.set_current_library(library) + except FileNotFoundError: + Path.Log.error(f"Library {library_uri_str} not found.") + self.set_current_library(None) + else: + self.set_current_library(None) + + # Restore toolbit selection + if toolbit_uris: + for uri in toolbit_uris: + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + if item.data(ToolBitUriRole) == uri: + item.setSelected(True) + + # Restore scroll position + self._tool_list_widget.verticalScrollBar().setValue(scroll_pos) + + def refresh(self): + """Refreshes the toolbits for the current library from disk.""" + Path.Log.debug("refresh(): Fetching and populating toolbits.") + if self.current_library: + library_uri = self.current_library.get_uri() + try: + self.current_library = cast( + Library, self._asset_manager.get(library_uri, store=self._store_name, depth=1) + ) + except FileNotFoundError: + Path.Log.error(f"Library {library_uri} not found.") + self.current_library = None + self._update_tool_list() + + def get_tool_no_from_current_library(self, toolbit): + """ + Retrieves the tool number for a toolbit based on the current library. + """ + if not self.current_library: + return None + tool_no = self.current_library.get_bit_no_from_bit(toolbit) + return tool_no + + def set_current_library(self, library): + """Sets the current library and updates the tool list.""" + self.current_library = library + self._update_tool_list() + self.current_library_changed.emit() + + # Save the selected library to preferences + if library: + Path.Preferences.setLastToolLibrary(str(library.get_uri())) + + def _update_tool_list(self): + """Updates the tool list based on the current library.""" + if self.current_library: + self._all_assets = [t for t in self.current_library] + else: + # Fetch all toolbits + all_toolbits = self._asset_manager.fetch(asset_type="toolbit", depth=0) + self._all_assets = cast(List[ToolBit], all_toolbits) + self._sort_assets() + self._tool_list_widget.clear_list() + self._update_list() + + def _add_shortcuts(self): + """Adds keyboard shortcuts for common actions.""" + Path.Log.debug("LibraryBrowserWidget._add_shortcuts: Called.") + super()._add_shortcuts() + + cut_action = QAction(self) + cut_action.setShortcuts(QKeySequence.Cut) + cut_action.triggered.connect(self._on_cut_requested) + self.addAction(cut_action) + + duplicate_action = QAction(self) + duplicate_action.setShortcut(QKeySequence("Ctrl+D")) + duplicate_action.triggered.connect(self._on_duplicate_requested) + self.addAction(duplicate_action) + + remove_action = QAction(self) + remove_action.setShortcut(QKeySequence.Delete) + remove_action.triggered.connect(self._on_remove_from_library_requested) + self.addAction(remove_action) + + paste_action = QAction(self) + paste_action.setShortcuts(QKeySequence.Paste) + paste_action.triggered.connect(self._on_paste_requested) + self.addAction(paste_action) + + def _show_context_menu(self, position): + """Shows the context menu at the given position.""" + context_menu = QMenu(self) + + selected_items = self._tool_list_widget.selectedItems() + has_selection = bool(selected_items) + + # Add actions in the desired order + edit_action = context_menu.addAction("Edit", self._on_edit_requested) + edit_action.setEnabled(has_selection) + + context_menu.addSeparator() + + action = context_menu.addAction("Copy", self._on_copy_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+C")) + + action = context_menu.addAction("Cut", self._on_cut_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+X")) + + action = context_menu.addAction("Paste", self._on_paste_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+V")) + + # Paste is enabled if there is data in the clipboard + clipboard = QtGui.QApplication.clipboard() + mime_type = "application/x-freecad-toolbit-list-yaml" + action.setEnabled(clipboard.mimeData().hasFormat(mime_type)) + + action = context_menu.addAction("Duplicate", self._on_duplicate_requested) + action.setShortcut(QtGui.QKeySequence("Ctrl+D")) + + context_menu.addSeparator() + + action = context_menu.addAction( + "Remove from Library", self._on_remove_from_library_requested + ) + action.setShortcut(QtGui.QKeySequence.Delete) + + action = context_menu.addAction("Delete from disk", self._on_delete_requested) + action.setShortcut(QtGui.QKeySequence("Shift+Delete")) + + # Execute the menu + context_menu.exec_(self._tool_list_widget.mapToGlobal(position)) + + def get_current_library(self) -> Library | None: + """Helper to get the current library.""" + return self.current_library + + def _on_edit_requested(self): + """Opens the ToolBitEditor for the selected toolbit.""" + toolbit = self._get_first_selected_bit() + if not toolbit: + return + + # Open the editor for the selected toolbit + tool_no = self.get_tool_no_from_current_library(toolbit) + editor = ToolBitEditor(toolbit, tool_no, parent=self) + result = editor.show() + if result != QtGui.QDialog.Accepted: + return + + # If the editor was closed with "OK", save the changes + self._asset_manager.add(toolbit) + Path.Log.info(f"Toolbit {toolbit.get_id()} saved.") + + # Also save the library because the tool number may have changed. + if self.current_library and tool_no != editor.tool_no: + self.current_library.assign_new_bit_no(toolbit, editor.tool_no) + self._asset_manager.add(self.current_library) + + state = self._get_state() + self.refresh() + self._update_list() + self._set_state(state) + + def _on_cut_requested(self): + """Handles cut request by copying and marking for removal from library.""" + uris = self.get_selected_bit_uris() + library = self.get_current_library() + if not library or not uris: + return + + # Copy to clipboard (handled by base class _to_clipboard) + extra_data = {"source_library_uri": str(library.get_uri())} + self._to_clipboard(uris, mode="cut", extra_data=extra_data) + + def _on_duplicate_requested(self): + """Handles duplicate request by duplicating and adding to library.""" + Path.Log.debug("LibraryBrowserWidget._on_duplicate_requested: Called.\n") + uris = self.get_selected_bit_uris() + library = self.get_current_library() + if not library or not uris: + Path.Log.debug( + "LibraryBrowserWidget._on_duplicate_requested: No library or URIs selected. Returning." + ) + return + + new_uris = set() + for uri_string in uris: + toolbit = cast(ToolBit, self._asset_manager.get(AssetUri(uri_string), depth=0)) + if not toolbit: + Path.Log.warning(f"Toolbit {uri_string} not found.\n") + continue + + # Change the ID of the toolbit and save it to disk + toolbit.set_id() # Generate a new ID + toolbit.label = toolbit.label + " (copy)" + added_uri = self._asset_manager.add(toolbit) + if added_uri: + new_uris.add(str(toolbit.get_uri())) + + # Add the bit to the current library + library.add_bit(toolbit) + + self._asset_manager.add(library) # Save the modified library + self.refresh() + + self.select_by_uri(list(new_uris)) + + def _on_paste_requested(self): + """Handles paste request by adding toolbits to the current library.""" + current_library = self.get_current_library() + if not current_library: + return + + clipboard = QtGui.QApplication.clipboard() + mime_type = "application/x-freecad-toolbit-list-yaml" + mime_data = clipboard.mimeData() + + if not mime_data.hasFormat(mime_type): + return + + try: + clipboard_content_yaml = mime_data.data(mime_type).data().decode("utf-8") + clipboard_data_dict = yaml.safe_load(clipboard_content_yaml) + + if ( + not isinstance(clipboard_data_dict, dict) + or "toolbits" not in clipboard_data_dict + or not isinstance(clipboard_data_dict["toolbits"], list) + ): + return + + serialized_toolbits_data = clipboard_data_dict["toolbits"] + mode = clipboard_data_dict.get("operation", "copy") + source_library_uri_str = clipboard_data_dict.get("source_library_uri") + + if mode == "copy": + self._on_copy_paste(current_library, serialized_toolbits_data) + elif mode == "cut" and source_library_uri_str: + self._on_cut_paste( + current_library, serialized_toolbits_data, source_library_uri_str + ) + + except Exception as e: + Path.Log.warning(f"An unexpected error occurred during paste: {e}") + + def _on_copy_paste(self, current_library: Library, serialized_toolbits_data: list): + """Handles pasting toolbits that were copied.""" + new_uris = set() + for toolbit_yaml_str in serialized_toolbits_data: + if not isinstance(toolbit_yaml_str, str) or not toolbit_yaml_str.strip(): + continue + + toolbit_data_bytes = toolbit_yaml_str.encode("utf-8") + toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None) + # Assign a new tool id and a label + toolbit.set_id() + self._asset_manager.add(toolbit) # Save the new toolbit to disk + + # Add the bit to the current library + added_toolbit = current_library.add_bit(toolbit) + if added_toolbit: + new_uris.add(str(toolbit.get_uri())) + + if new_uris: + self._asset_manager.add(current_library) # Save the modified library + self.refresh() + self.select_by_uri(list(new_uris)) + + def _on_cut_paste( + self, + current_library: Library, + serialized_toolbits_data: list, + source_library_uri_str: str, + ): + """Handles pasting toolbits that were cut.""" + source_library_uri = AssetUri(source_library_uri_str) + if source_library_uri == current_library.get_uri(): + # Cut from the same library, do nothing + return + + try: + source_library = cast( + Library, + self._asset_manager.get(source_library_uri, store=self._store_name, depth=1), + ) + except FileNotFoundError: + Path.Log.warning(f"Source library {source_library_uri_str} not found.\n") + return + + new_uris = set() + for toolbit_yaml_str in serialized_toolbits_data: + if not isinstance(toolbit_yaml_str, str) or not toolbit_yaml_str.strip(): + continue + + toolbit_data_bytes = toolbit_yaml_str.encode("utf-8") + toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None) + source_library.remove_bit(toolbit) + + # Remove it from the old library, add it to the new library + source_library.remove_bit(toolbit) + added_toolbit = current_library.add_bit(toolbit) + if added_toolbit: + new_uris.add(str(toolbit.get_uri())) + + # The toolbit itself does not change, so we don't need to save it. + # It is only the reference in the library that changes. + + if new_uris: + # Save the modified libraries + self._asset_manager.add(current_library) + self._asset_manager.add(source_library) + self.refresh() + self.select_by_uri(list(new_uris)) + + def _on_remove_from_library_requested(self): + """Handles request to remove selected toolbits from the current library.""" + Path.Log.debug("_on_remove_from_library_requested: Called.") + uris = self.get_selected_bit_uris() + library = self.get_current_library() + if not library or not uris: + return + + # Ask for confirmation + reply = QtGui.QMessageBox.question( + self, + FreeCAD.Qt.translate("CAM", "Confirm Removal"), + FreeCAD.Qt.translate( + "CAM", "Are you sure you want to remove the selected toolbit(s) from the library?" + ), + QtGui.QMessageBox.Yes | QtGui.QMessageBox.No, + QtGui.QMessageBox.No, + ) + + if reply == QtGui.QMessageBox.Yes: + self._remove_toolbits_from_library(library, uris) + + def _remove_toolbits_from_library(self, library: Library, uris: List[str]): + """Removes toolbits with the given URIs from the specified library.""" + removed_count = 0 + for uri_string in uris: + try: + # Remove the toolbit from the library + library.remove_bit_by_uri(uri_string) + removed_count += 1 + except Exception as e: + Path.Log.error(f"Failed to remove toolbit {uri_string} from library: {e}\n") + + if removed_count > 0: + self._asset_manager.add(library) + self.refresh() + + +class LibraryBrowserWithCombo(LibraryBrowserWidget): + """ + A widget extending LibraryBrowserWidget with a combo box for library selection. """ def __init__( @@ -43,74 +489,78 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): parent=None, compact=True, ): - self._library_combo = QtGui.QComboBox() - super().__init__( asset_manager=asset_manager, store=store, parent=parent, - tool_no_factory=self.get_tool_no_from_current_library, compact=compact, ) - # Create the library dropdown and insert it into the top layout - self._top_layout.insertWidget(0, self._library_combo) - self._library_combo.currentIndexChanged.connect(self._on_library_changed) + # Move search box into dedicated row to make space for the + # library selection combo box + layout = self.layout() + self._top_layout.removeWidget(self._search_edit) + layout.insertWidget(1, self._search_edit, 20) + + self._library_combo = QtGui.QComboBox() + self._library_combo.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) + self._top_layout.insertWidget(0, self._library_combo, 1) + self._library_combo.currentIndexChanged.connect(self._on_library_combo_changed) + self.current_library_changed.connect(self._on_current_library_changed) + + self._in_refresh = False + self.refresh() + + def _on_library_combo_changed(self, index): + """Handles library selection change from the combo box.""" + if self._in_refresh: + return + + selected_library = cast(Library, self._library_combo.itemData(index)) + if not selected_library: + return + + # Have to refetch the non-shallow library. + uri = selected_library.get_uri() + library = self._asset_manager.get(uri, store=self._store_name, depth=1) + self.set_current_library(library) + + def _on_current_library_changed(self): + """Updates the combo box when the current library changes externally.""" + if self.current_library: + for i in range(self._library_combo.count()): + lib = self._library_combo.itemData(i) + if lib.get_uri() == self.current_library.get_uri(): + self._library_combo.setCurrentIndex(i) + return + Path.Log.warning( + f"Current library {self.current_library.get_uri()} not found in combo box." + ) def refresh(self): - """Refreshes the library dropdown and fetches all assets.""" - self._library_combo.clear() - self._fetch_all_assets() - - def _fetch_all_assets(self): - """Populates the library dropdown with available libraries.""" - # Use list_assets("toolbitlibrary") to get URIs + """Reads available libraries and refreshes the combo box and toolbits.""" + Path.Log.debug("refresh(): Fetching and populating libraries and toolbits.") libraries = self._asset_manager.fetch("toolbitlibrary", store=self._store_name, depth=0) - for library in sorted(libraries, key=lambda x: x.label): - self._library_combo.addItem(library.label, userData=library) - - if not self._library_combo.count(): - return - - # Trigger initial load after populating libraries - self._on_library_changed(0) - - def get_tool_no_from_current_library(self, toolbit): - """ - Retrieves the tool number for a toolbit based on the currently - selected library. - """ - selected_library = self._library_combo.currentData() - if selected_library is None: - return None - # Use the get_bit_no_from_bit method of the Library object - # This method returns the tool number or None - tool_no = selected_library.get_bit_no_from_bit(toolbit) - return tool_no - - def _on_library_changed(self, index): - """Handles library selection change.""" - # Get the selected library from the combo box - selected_library = self._library_combo.currentData() - if not isinstance(selected_library, Library): - self._all_assets = [] - return - - # Fetch the library from the asset manager - library_uri = selected_library.get_uri() + self._in_refresh = True try: - library = self._asset_manager.get(library_uri, store=self._store_name, depth=1) - # Update the combo box item's user data with the fully fetched library - self._library_combo.setItemData(index, library) - except FileNotFoundError: - Path.Log.error(f"Library {library_uri} not found in store {self._store_name}.") - self._all_assets = [] + self._library_combo.clear() + for library in sorted(libraries, key=lambda x: natural_sort_key(x.label)): + self._library_combo.addItem(library.label, userData=library) + finally: + self._in_refresh = False + + super().refresh() + + if not libraries: + return + if not self.current_library: + self._library_combo.setCurrentIndex(0) return - # Update _all_assets based on the selected library - library = cast(Library, library) - self._all_assets = [t for t in library] - self._sort_assets() - self._tool_list_widget.clear_list() - self._scroll_position = 0 - self._trigger_fetch() # Display data for the selected library + for i in range(self._library_combo.count()): + lib = self._library_combo.itemData(i) + if lib.get_uri() == self.current_library.get_uri(): + self._library_combo.setCurrentIndex(i) + break + else: + self._library_combo.setCurrentIndex(0) diff --git a/src/Mod/CAM/Path/Tool/library/ui/cmd.py b/src/Mod/CAM/Path/Tool/library/ui/cmd.py index c1dc9d0798..c845af7846 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/cmd.py +++ b/src/Mod/CAM/Path/Tool/library/ui/cmd.py @@ -86,7 +86,7 @@ class CommandLibraryEditorOpen: return True def Activated(self): - library = LibraryEditor() + library = LibraryEditor(parent=FreeCADGui.getMainWindow()) library.open() diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py index 6606bfeb44..215444ebf7 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/dock.py +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -34,7 +34,7 @@ from typing import List, Tuple from ...camassets import cam_assets, ensure_assets_initialized from ...toolbit import ToolBit from .editor import LibraryEditor -from .browser import LibraryBrowserWidget +from .browser import LibraryBrowserWithCombo if False: @@ -80,7 +80,8 @@ class ToolBitLibraryDock(object): main_layout.setContentsMargins(4, 4, 4, 4) main_layout.setSpacing(4) - # Add the browser widget to the layout + # Create the browser widget + self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets) main_layout.addWidget(self.browser_widget) # Create buttons @@ -101,26 +102,31 @@ class ToolBitLibraryDock(object): self.form.layout().addWidget(main_widget) # Connect signals from the browser widget and buttons - self.browser_widget.toolSelected.connect(self._update_state) - self.browser_widget.itemDoubleClicked.connect(partial(self._add_tool_controller_to_doc)) + self.browser_widget.toolSelected.connect(lambda x: self._update_state()) + self.browser_widget.itemDoubleClicked.connect(self._on_doubleclick) self.libraryEditorOpenButton.clicked.connect(self._open_editor) - self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc)) + self.addToolControllerButton.clicked.connect(self._add_tool_controller_to_doc) - # Initial state of buttons + # Update the initial state of the UI self._update_state() + def _count_jobs(self): + if not FreeCAD.ActiveDocument: + return 0 + return len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 + def _update_state(self): """Enable button to add tool controller when a tool is selected""" - # Set buttons inactive - self.addToolControllerButton.setEnabled(False) - # Check if any tool is selected in the browser widget - selected = self.browser_widget._tool_list_widget.selectedItems() - if selected and FreeCAD.ActiveDocument: - jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 - self.addToolControllerButton.setEnabled(len(selected) >= 1 and jobs) + selected = bool(self.browser_widget.get_selected_bit_uris()) + has_job = selected and self._count_jobs() > 0 + self.addToolControllerButton.setEnabled(selected and has_job) + + def _on_doubleclick(self, toolbit: ToolBit): + """Opens the ToolBitEditor for the selected toolbit.""" + self._add_tool_controller_to_doc() def _open_editor(self): - library = LibraryEditor() + library = LibraryEditor(parent=FreeCADGui.getMainWindow()) library.open() # After editing, we might need to refresh the libraries in the browser widget # Assuming _populate_libraries is the correct method to call @@ -148,7 +154,7 @@ class ToolBitLibraryDock(object): return tools - def _add_tool_controller_to_doc(self, index=None): + def _add_tool_controller_to_doc(self): """ if no jobs, don't do anything, otherwise all TCs for all selected toolbit assets diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py index 95090336f2..bcfd175203 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -21,17 +21,21 @@ # * USA * # * * # *************************************************************************** - - +import yaml +import pathlib import FreeCAD import FreeCADGui import Path -import PySide -from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap -from PySide.QtCore import Qt -import os -import uuid as UUID -from typing import List, cast +from PySide.QtGui import ( + QStandardItem, + QStandardItemModel, + QPixmap, + QDialog, + QMessageBox, + QWidget, +) +from PySide.QtCore import Qt, QEvent +from typing import List, cast, Tuple, Optional from ...assets import AssetUri from ...assets.ui import AssetOpenDialog, AssetSaveDialog from ...camassets import cam_assets, ensure_assets_initialized @@ -39,8 +43,12 @@ from ...shape.ui.shapeselector import ShapeSelector from ...toolbit import ToolBit from ...toolbit.serializers import all_serializers as toolbit_serializers from ...toolbit.ui import ToolBitEditor -from ...library import Library -from ...library.serializers import all_serializers as library_serializers +from ...toolbit.ui.toollist import ToolBitUriListMimeType +from ...toolbit.ui.util import natural_sort_key +from ..serializers import all_serializers as library_serializers +from ..models import Library +from .browser import LibraryBrowserWidget +from .properties import LibraryPropertyDialog if False: @@ -50,99 +58,239 @@ else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) -_UuidRole = PySide.QtCore.Qt.UserRole + 1 -_PathRole = PySide.QtCore.Qt.UserRole + 2 -_LibraryRole = PySide.QtCore.Qt.UserRole + 3 - - +_LibraryRole = Qt.UserRole + 1 translate = FreeCAD.Qt.translate -class _TableView(PySide.QtGui.QTableView): - """Subclass of QTableView to support rearrange and copying of ToolBits""" +class LibraryEditor(QWidget): + """LibraryEditor is the controller for + displaying/selecting/creating/editing a collection of ToolBits.""" - def __init__(self, parent): - PySide.QtGui.QTableView.__init__(self, parent) - self.setDragEnabled(False) - self.setAcceptDrops(False) - self.setDropIndicatorShown(False) - self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly) - self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction) - self.setSortingEnabled(True) - self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows) - self.verticalHeader().hide() - - def supportedDropActions(self): - return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction] - - def _uuidOfRow(self, row): - model = self.toolModel() - return model.data(model.index(row, 0), _UuidRole) - - def _rowWithUuid(self, uuid): - model = self.toolModel() - for row in range(model.rowCount()): - if self._uuidOfRow(row) == uuid: - return row - return None - - def _copyTool(self, uuid_, dstRow): - model = self.toolModel() - model.insertRow(dstRow) - srcRow = self._rowWithUuid(uuid_) - for col in range(model.columnCount()): - srcItem = model.item(srcRow, col) - - model.setData( - model.index(dstRow, col), - srcItem.data(PySide.QtCore.Qt.EditRole), - PySide.QtCore.Qt.EditRole, - ) - if col == 0: - model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole) - # Even a clone of a tool gets its own uuid so it can be identified when - # rearranging the order or inserting/deleting rows - model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole) - else: - model.item(dstRow, col).setEditable(False) - - def _copyTools(self, uuids, dst): - for i, uuid in enumerate(uuids): - self._copyTool(uuid, dst + i) - - def dropEvent(self, event): - """Handle drop events on the tool table""" + def __init__(self, parent=None): + super().__init__(parent=parent) Path.Log.track() - mime = event.mimeData() - data = mime.data("application/x-qstandarditemmodeldatalist") - stream = PySide.QtCore.QDataStream(data) - srcRows = [] - while not stream.atEnd(): - row = stream.readInt32() - srcRows.append(row) + ensure_assets_initialized(cam_assets) + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") + self.form.installEventFilter(self) # to forward keypress events + self._base_title = self.form.windowTitle() - # get the uuids of all srcRows - model = self.toolModel() - srcUuids = [self._uuidOfRow(row) for row in set(srcRows)] - destRow = self.rowAt(event.pos().y()) + # Create the library list. + self.listModel = QStandardItemModel() + self.form.TableList.setModel(self.listModel) + self.form.TableList.clicked.connect(self._on_library_selected) - self._copyTools(srcUuids, destRow) - if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction(): - for uuid in srcUuids: - model.removeRow(self._rowWithUuid(uuid)) + # Enable drop support for the library list + self.form.TableList.viewport().installEventFilter(self) # Also on viewport + # Create the LibraryBrowserWidget + self.browser = LibraryBrowserWidget( + asset_manager=cam_assets, + parent=self, + ) + self.browser.setDragEnabled(True) + self.form.verticalLayout_2.layout().replaceWidget(self.form.toolTable, self.browser) + self.form.toolTable.hide() -class ModelFactory: - """Helper class to generate qtdata models for toolbit libraries""" + # Connect signals. + self.browser.itemDoubleClicked.connect(self.browser._on_edit_requested) - @staticmethod - def find_libraries(model) -> QStandardItemModel: - """ - Finds all the fctl files in a location. - Returns a QStandardItemModel. - """ + self.form.addLibraryButton.clicked.connect(self._on_add_library_requested) + self.form.removeLibraryButton.clicked.connect(self._on_remove_library_requested) + self.form.renameLibraryButton.clicked.connect(self._on_rename_library_requested) + self.form.importLibraryButton.clicked.connect(self._on_import_library_requested) + self.form.exportLibraryButton.clicked.connect(self._on_export_library_requested) + + self.form.addToolBitButton.clicked.connect(self._on_add_toolbit_requested) + self.form.importToolBitButton.clicked.connect(self._on_import_toolbit_requested) + self.form.exportToolBitButton.clicked.connect(self._on_export_toolbit_requested) + + # Populate the UI. + self._refresh_library_list() + self._select_last_library() + self._update_button_states() + + def _highlight_row(self, index): + """Highlights the row at the given index using the selection model.""" + if not index.isValid(): + return + self.form.TableList.setCurrentIndex(index) + + def _clear_highlight(self): + """Clears the highlighting from the previously highlighted row.""" + self.form.TableList.selectionModel().clear() + + def eventFilter(self, obj, event): + if event.type() == QEvent.KeyPress and self.form.TableList.hasFocus(): + if event.key() == Qt.Key_F2: + Path.Log.debug("F2 pressed on library list.") + self._on_rename_library_requested() + return True + elif event.key() == Qt.Key_Delete: + Path.Log.debug("Del pressed on library list.") + self._on_remove_library_requested() + return True + if obj == self.form.TableList.viewport(): + if event.type() == QEvent.DragEnter or event.type() == QEvent.DragMove: + return self._handle_drag_enter(event) + elif event.type() == QEvent.DragLeave: + self._handle_drag_leave(event) + return True + elif event.type() == QEvent.Drop: + return self._handle_drop(event) + return super().eventFilter(obj, event) + + def _handle_drag_enter(self, event): + """Handle drag enter and move events for the library list.""" + mime_data = event.mimeData() + Path.Log.debug(f"_handle_drag_enter: MIME formats: {mime_data.formats()}") + if not mime_data.hasFormat(ToolBitUriListMimeType): + Path.Log.debug("_handle_drag_enter: Invalid MIME type, ignoring") + return True + + # Get the row being hovered. + pos = event.pos() + event.acceptProposedAction() + index = self.form.TableList.indexAt(pos) + if not index.isValid(): + self._clear_highlight() + return True + + # Prevent drop into "All Tools" + item = self.listModel.itemFromIndex(index) + if not item or item.data(_LibraryRole) == "all_tools": + self._clear_highlight() + return True + + self._highlight_row(index) + return True + + def _handle_drag_leave(self, event): + """Handle drag leave event for the library list.""" + self._clear_highlight() + + def _handle_drop(self, event): + """Handle drop events to move or copy toolbits to the target library.""" + mime_data = event.mimeData() + if not (mime_data.hasFormat(ToolBitUriListMimeType)): + event.ignore() + return True + + self._clear_highlight() + pos = event.pos() + index = self.form.TableList.indexAt(pos) + if not index.isValid(): + event.ignore() + return True + + item = self.listModel.itemFromIndex(index) + if not item or item.data(_LibraryRole) == "all_tools": + event.ignore() + return True + + target_library_id = item.data(_LibraryRole) + target_library_uri = f"toolbitlibrary://{target_library_id}" + target_library = cast(Library, cam_assets.get(target_library_uri, depth=1)) + + try: + clipboard_content_yaml = mime_data.data(ToolBitUriListMimeType).data().decode("utf-8") + clipboard_data_dict = yaml.safe_load(clipboard_content_yaml) + + if not isinstance(clipboard_data_dict, dict) or "toolbits" not in clipboard_data_dict: + event.ignore() + return True + + uris = clipboard_data_dict["toolbits"] + new_uris = set() + + # Get the current library from the browser + current_library = self.browser.get_current_library() + + for uri in uris: + try: + toolbit = cast(ToolBit, cam_assets.get(AssetUri(uri), depth=0)) + if toolbit: + added_toolbit = target_library.add_bit(toolbit) + if added_toolbit: + new_uris.add(str(toolbit.get_uri())) + + # Remove the toolbit from the current library if it exists and + # it's not "all_tools" + if current_library and current_library.get_id() != "all_tools": + current_library.remove_bit(toolbit) + except Exception as e: + Path.Log.error(f"Failed to load toolbit from URI {uri}: {e}") + continue + + if new_uris: + cam_assets.add(target_library) + # Save the current library if it was modified + if current_library and current_library.get_id() != "all_tools": + cam_assets.add(current_library) + self.browser.refresh() + self.browser.select_by_uri(list(new_uris)) + self._update_button_states() + + event.acceptProposedAction() + except Exception as e: + Path.Log.error(f"Failed to process drop event: {e}") + event.ignore() + return True + + def get_selected_library_id(self) -> Optional[str]: + index = self.form.TableList.currentIndex() + if not index.isValid(): + return None + item = self.listModel.itemFromIndex(index) + if not item: + return None + return item.data(_LibraryRole) + + def get_selected_library(self, depth=1) -> Optional[Library]: + library_id = self.get_selected_library_id() + if not library_id: + return None + uri = f"toolbitlibrary://{library_id}" + return cast(Library, cam_assets.get(uri, depth=depth)) + + def select_library_by_uri(self, uri: AssetUri): + # Find it in the list. + index = 0 + for i in range(self.listModel.rowCount()): + item = self.listModel.item(i) + if item and item.data(_LibraryRole) == uri.asset_id: + index = i + break + else: + return + + # Select it. + if index <= self.listModel.rowCount(): + item = self.listModel.item(index) + if item: # Should always be true, but... + self.form.TableList.setCurrentIndex(self.listModel.index(index, 0)) + self._on_library_selected() + + def _select_last_library(self): + # Find the last used library. + last_used_lib_identifier = Path.Preferences.getLastToolLibrary() + if last_used_lib_identifier: + uri = Library.resolve_name(last_used_lib_identifier) + self.select_library_by_uri(uri) + + def open(self): Path.Log.track() - model.clear() + return self.form.exec_() + + def _refresh_library_list(self): + """Clears and repopulates the self.listModel with available libraries.""" + Path.Log.track() + self.listModel.clear() + + # Add "All Tools" item + all_tools_item = QStandardItem(translate("CAM", "All Tools")) + all_tools_item.setData("all_tools", _LibraryRole) + all_tools_item.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) + self.listModel.appendRow(all_tools_item) # Use AssetManager to fetch library assets (depth=0 for shallow fetch) try: @@ -152,138 +300,190 @@ class ModelFactory: libraries = cast(List[Library], cam_assets.fetch(asset_type="toolbitlibrary", depth=0)) except Exception as e: Path.Log.error(f"Failed to fetch toolbit libraries: {e}") - return model # Return empty model on error + return # Sort by label for consistent ordering, falling back to asset_id if label is missing - def get_sort_key(library): - label = getattr(library, "label", None) - return label if label else library.get_id() - - for library in sorted(libraries, key=get_sort_key): + for library in sorted( + libraries, + key=lambda library: natural_sort_key(library.label or library.get_id()), + ): lib_uri_str = str(library.get_uri()) libItem = QStandardItem(library.label or library.get_id()) libItem.setToolTip(f"ID: {library.get_id()}\nURI: {lib_uri_str}") - libItem.setData(lib_uri_str, _LibraryRole) # Store the URI string + libItem.setData(library.get_id(), _LibraryRole) # Store the library ID libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) - model.appendRow(libItem) + self.listModel.appendRow(libItem) - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model + Path.Log.debug("model rows: {}".format(self.listModel.rowCount())) - @staticmethod - def __library_load(library_uri: str, data_model: QStandardItemModel): - Path.Log.track(library_uri) + self.listModel.setHorizontalHeaderLabels(["Library"]) - if library_uri: - # Store the AssetUri string, not just the name - Path.Preferences.setLastToolLibrary(library_uri) + def _on_library_selected(self): + """Sets the current library in the browser when a library is selected.""" + Path.Log.debug("_on_library_selected: Called.") + index = self.form.TableList.currentIndex() + item = self.listModel.itemFromIndex(index) + if not item: + return + if item.data(_LibraryRole) == "all_tools": + selected_library = None + else: + selected_library = self.get_selected_library() + self.browser.set_current_library(selected_library) + self._update_window_title() + self._update_button_states() + def _update_window_title(self): + """Updates the window title with the current library name.""" + current_library = self.browser.get_current_library() + if current_library: + title = f"{self._base_title} - {current_library.label}" + else: + title = self._base_title + self.form.setWindowTitle(title) + + def _update_button_states(self): + """Updates the enabled state of library management buttons.""" + library_selected = self.browser.get_current_library() is not None + self.form.addLibraryButton.setEnabled(True) + self.form.removeLibraryButton.setEnabled(library_selected) + self.form.renameLibraryButton.setEnabled(library_selected) + self.form.exportLibraryButton.setEnabled(library_selected) + self.form.importLibraryButton.setEnabled(True) + self.form.addToolBitButton.setEnabled(library_selected) + # TODO: self.form.exportToolBitButton.setEnabled(toolbit_selected) + + def _save_library(self): + """Internal method to save the current tool library asset""" + Path.Log.track() + library = self.browser.get_current_library() + if not library: + return + + # Save the modified library asset. try: - # Load the library asset using AssetManager - loaded_library = cam_assets.get(AssetUri(library_uri), depth=1) + cam_assets.add(library) + Path.Log.debug(f"Library {library.get_uri()} saved") except Exception as e: - Path.Log.error(f"Failed to load library from {library_uri}: {e}") + Path.Log.error(f"Failed to save library {library.get_uri()}: {e}") + QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Saving Library"), + str(e), + ) raise - # Iterate over the loaded ToolBit asset instances - for tool_no, tool_bit in sorted(loaded_library._bit_nos.items()): - data_model.appendRow( - ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri())) + def _on_add_library_requested(self): + Path.Log.debug("_on_add_library_requested: Called.") + new_library = Library(FreeCAD.Qt.translate("CAM", "New Library")) + dialog = LibraryPropertyDialog(new_library, new=True, parent=self) + if dialog.exec_() != QDialog.Accepted: + return + + uri = cam_assets.add(new_library) + Path.Log.debug(f"_on_add_library_requested: New library URI = {uri}") + self._refresh_library_list() + self.select_library_by_uri(uri) + self._update_button_states() + + def _on_remove_library_requested(self): + """Handles request to remove the selected library.""" + Path.Log.debug("_on_remove_library_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + return + + reply = QMessageBox.question( + self, + FreeCAD.Qt.translate("CAM", "Confirm Library Removal"), + FreeCAD.Qt.translate( + "CAM", + "Are you sure you want to remove the library '{0}'?\n" + "This will not delete the toolbits contained within it.", + ).format(current_library.label), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply != QMessageBox.Yes: + return + + try: + library_uri = current_library.get_uri() + cam_assets.delete(library_uri) + Path.Log.info(f"Library {current_library.label} deleted.") + self._refresh_library_list() + self.browser.refresh() + self._update_button_states() + except FileNotFoundError as e: + Path.Log.error(f"Failed to delete library {current_library.label}: {e}") + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error"), + FreeCAD.Qt.translate("CAM", "Failed to delete library '{0}': {1}").format( + current_library.label, str(e) + ), ) - @staticmethod - def _generate_tooltip(toolbit: dict) -> str: - """ - Generate an HTML tooltip for a given toolbit dictionary. + def _on_rename_library_requested(self): + """Handles request to rename the selected library.""" + Path.Log.debug("_on_rename_library_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + return - Args: - toolbit (dict): A dictionary containing toolbit information. + dialog = LibraryPropertyDialog(current_library, new=False, parent=self) + if dialog.exec_() != QDialog.Accepted: + return - Returns: - str: An HTML string representing the tooltip. - """ - tooltip = f"Name: {toolbit['name']}
" - tooltip += f"Shape File: {toolbit['shape']}
" - tooltip += "Parameters:
" - parameters = toolbit.get("parameter", {}) - if parameters: - for key, value in parameters.items(): - tooltip += f" {key}: {value}
" - else: - tooltip += " No parameters provided.
" + cam_assets.add(current_library) + self._refresh_library_list() + self._update_button_states() - attributes = toolbit.get("attribute", {}) - if attributes: - tooltip += "Attributes:
" - for key, value in attributes.items(): - tooltip += f" {key}: {value}
" + def _on_import_library_requested(self): + """Handles request to import a library.""" + Path.Log.debug("_on_import_library_requested: Called.") + dialog = AssetOpenDialog( + cam_assets, asset_class=Library, serializers=library_serializers, parent=self + ) + response = dialog.exec_() + if not response: + return + file_path, library = cast(Tuple[pathlib.Path, Library], response) - return tooltip + try: + cam_assets.add(library) + self._refresh_library_list() + self._update_button_states() + except Exception as e: + Path.Log.error(f"Failed to import library: {file_path} {e}") + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error"), + FreeCAD.Qt.translate("CAM", f"Failed to import library: {file_path} {e}"), + ) - @staticmethod - def _tool_add(nr: int, tool: dict, path: str): - str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0] - tooltip = ModelFactory._generate_tooltip(tool) + def _on_export_library_requested(self): + """Handles request to export the selected library.""" + Path.Log.debug("_on_export_library_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + return - tool_nr = QStandardItem() - tool_nr.setData(nr, Qt.EditRole) - tool_nr.setData(path, _PathRole) - tool_nr.setData(UUID.uuid4(), _UuidRole) - tool_nr.setToolTip(tooltip) + dialog = AssetSaveDialog(asset_class=Library, serializers=library_serializers, parent=self) + dialog.exec_(current_library) + self._update_button_states() - tool_name = QStandardItem() - tool_name.setData(tool["name"], Qt.EditRole) - tool_name.setEditable(False) - tool_name.setToolTip(tooltip) - - tool_shape = QStandardItem() - tool_shape.setData(str_shape, Qt.EditRole) - tool_shape.setEditable(False) - - return [tool_nr, tool_name, tool_shape] - - @staticmethod - def library_open(model: QStandardItemModel, library_uri: str) -> QStandardItemModel: - """ - Opens the tools in a library using its AssetUri. - Returns a QStandardItemModel. - """ - Path.Log.track(library_uri) - ModelFactory.__library_load(library_uri, model) - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model - - -class LibraryEditor(object): - """LibraryEditor is the controller for - displaying/selecting/creating/editing a collection of ToolBits.""" - - def __init__(self): - Path.Log.track() - ensure_assets_initialized(cam_assets) - self.factory = ModelFactory() - self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - self.listModel = PySide.QtGui.QStandardItemModel() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") - self.toolTableView = _TableView(self.form.toolTableGroup) - self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView) - self.form.toolTable.hide() - - self.setupUI() - self.title = self.form.windowTitle() - - # Connect signals for tool editing - self.toolTableView.doubleClicked.connect(self.toolEdit) - - def toolBitNew(self): - """Create a new toolbit asset and add it to the current library""" - Path.Log.track() - - if not self.current_library: - PySide.QtGui.QMessageBox.warning( - self.form, - translate("CAM_ToolBit", "No Library Loaded"), - translate("CAM_ToolBit", "Load or create a tool library first."), + def _on_add_toolbit_requested(self): + """Handles request to add a new toolbit to the current library.""" + Path.Log.debug("_on_add_toolbit_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + Path.Log.warning("Cannot add toolbit: No library selected.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select a library first."), ) return @@ -297,380 +497,105 @@ class LibraryEditor(object): # Find the appropriate ToolBit subclass based on the shape name tool_bit_classes = {b.SHAPE_CLASS.name: b for b in ToolBit.__subclasses__()} tool_bit_class = tool_bit_classes.get(shape.name) - if not tool_bit_class: raise ValueError(f"No ToolBit subclass found for shape '{shape.name}'") - # Create a new ToolBit instance using the subclass constructor - # The constructor will generate a UUID - toolbit = tool_bit_class(shape) + # Create a new ToolBit instance + new_toolbit = tool_bit_class(shape) + new_toolbit.label = FreeCAD.Qt.translate("CAM", "New Toolbit") - # 1. Save the individual toolbit asset first. - tool_asset_uri = cam_assets.add(toolbit) - Path.Log.debug(f"toolBitNew: Saved tool with URI: {tool_asset_uri}") + # Save the individual toolbit asset first + tool_asset_uri = cam_assets.add(new_toolbit) + Path.Log.debug(f"_on_add_toolbit_requested: Saved tool with URI: {tool_asset_uri}") - # 2. Add the toolbit (which now has a persisted URI) to the current library's model - tool_no = self.current_library.add_bit(toolbit) + # Add the toolbit to the current library + toolno = current_library.add_bit(new_toolbit) Path.Log.debug( - f"toolBitNew: Added toolbit {toolbit.get_id()} (URI: {toolbit.get_uri()}) " - f"to current_library with tool number {tool_no}." + f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) " + f"to current_library with number {toolno}." ) - # 3. Add the new tool directly to the UI model - new_row_items = ModelFactory._tool_add( - tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit - ) - self.toolModel.appendRow(new_row_items) - - # 4. Save the library (which now references the saved toolbit) - self.saveLibrary() + # Save the library + cam_assets.add(current_library) except Exception as e: Path.Log.error(f"Failed to create or add new toolbit: {e}") - PySide.QtGui.QMessageBox.critical( - self.form, - translate("CAM_ToolBit", "Error Creating Toolbit"), + QMessageBox.critical( + self, + FreeCAD.Qt.translate("CAM", "Error Creating Toolbit"), str(e), ) raise - def toolBitExisting(self): - """Add an existing toolbit asset to the current library""" - Path.Log.track() + self.browser.refresh() + self.browser.select_by_uri([str(new_toolbit.get_uri())]) + self._update_button_states() - if not self.current_library: - PySide.QtGui.QMessageBox.warning( - self.form, - translate("CAM_ToolBit", "No Library Loaded"), - translate("CAM_ToolBit", "Load or create a tool library first."), + def _on_import_toolbit_requested(self): + """Handles request to import a toolbit.""" + Path.Log.debug("_on_import_toolbit_requested: Called.") + current_library = self.browser.get_current_library() + if not current_library: + Path.Log.warning("Cannot import toolbit: No library selected.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select a library first."), ) return - # Open the file dialog - dialog = AssetOpenDialog(ToolBit, toolbit_serializers, self.form) - dialog_result = dialog.exec_() - if not dialog_result: - return # User canceled or error - file_path, toolbit = dialog_result - toolbit = cast(ToolBit, toolbit) - - try: - # Add the existing toolbit to the current library's model - # The add_bit method handles assigning a tool number and returns it. - cam_assets.add(toolbit) - tool_no = self.current_library.add_bit(toolbit) - - # Add the new tool directly to the UI model - new_row_items = ModelFactory._tool_add( - tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit - ) - self.toolModel.appendRow(new_row_items) - - # Save the library (which now references the added toolbit) - # Use cam_assets.add directly for internal save on existing toolbit - self.saveLibrary() - - except Exception as e: - Path.Log.error( - f"Failed to add imported toolbit {toolbit.get_id()} " - f"from {file_path} to library: {e}" - ) - PySide.QtGui.QMessageBox.critical( - self.form, - translate("CAM_ToolBit", "Error Adding Imported Toolbit"), - str(e), - ) - raise - - def toolDelete(self): - """Delete a tool""" - Path.Log.track() - selected_indices = self.toolTableView.selectedIndexes() - if not selected_indices: - return - - if not self.current_library: - Path.Log.error("toolDelete: No current_library loaded. Cannot delete tools.") - return - - # Collect unique rows to process, as selectedIndexes can return multiple indices per row - selected_rows = sorted(list(set(index.row() for index in selected_indices)), reverse=True) - - # Remove the rows from the library model. - for row in selected_rows: - item_tool_nr_or_uri = self.toolModel.item(row, 0) # Column 0 stores _PathRole - tool_uri_string = item_tool_nr_or_uri.data(_PathRole) - tool_uri = AssetUri(tool_uri_string) - bit = self.current_library.get_tool_by_uri(tool_uri) - self.current_library.remove_bit(bit) - self.toolModel.removeRows(row, 1) - - Path.Log.info(f"toolDelete: Removed {len(selected_rows)} rows from UI model.") - - # Save the library after deleting a tool - self.saveLibrary() - - def toolSelect(self, selected, deselected): - sel = len(self.toolTableView.selectedIndexes()) > 0 - self.form.toolDelete.setEnabled(sel) - - def tableSelected(self, index): - """loads the tools for the selected tool table""" - Path.Log.track() - item = index.model().itemFromIndex(index) - library_uri_string = item.data(_LibraryRole) - self._loadSelectedLibraryTools(library_uri_string) - - def open(self): - Path.Log.track() - return self.form.exec_() - - def toolEdit(self, selected): - """Edit the selected tool bit asset""" - Path.Log.track() - item = self.toolModel.item(selected.row(), 0) - - if selected.column() == 0: - return # Assuming tool number editing is handled directly in the table model - - toolbit_uri_string = item.data(_PathRole) - if not toolbit_uri_string: - Path.Log.error("No toolbit URI found for selected item.") - return - toolbit_uri = AssetUri(toolbit_uri_string) - - # Load the toolbit asset for editing - try: - bit = cast(ToolBit, cam_assets.get(toolbit_uri)) - editor_dialog = ToolBitEditor(bit, self.form) # Create dialog instance - result = editor_dialog.show() # Show as modal dialog - - if result == PySide.QtGui.QDialog.Accepted: - # The editor updates the toolbit directly, so we just need to save - cam_assets.add(bit) - Path.Log.info(f"Toolbit {bit.get_id()} saved.") - # Refresh the display and save the library - self._loadSelectedLibraryTools( - self.current_library.get_uri() if self.current_library else None - ) - # Save the library after editing a toolbit - self.saveLibrary() - - except Exception as e: - Path.Log.error(f"Failed to load or edit toolbit asset {toolbit_uri_string}: {e}") - PySide.QtGui.QMessageBox.critical( - self.form, - translate("CAM_ToolBit", "Error Editing Toolbit"), - str(e), - ) - raise - - def libraryNew(self): - """Create a new tool library asset""" - Path.Log.track() - - # Get the desired library name (label) from the user - library_label, ok = PySide.QtGui.QInputDialog.getText( - self.form, - translate("CAM_ToolBit", "New Tool Library"), - translate("CAM_ToolBit", "Enter a name for the new library:"), + dialog = AssetOpenDialog( + cam_assets, asset_class=ToolBit, serializers=toolbit_serializers, parent=self ) - if not ok or not library_label: + response = dialog.exec_() + if not response: return + file_path, toolbit = cast(Tuple[pathlib.Path, ToolBit], response) - # Create a new Library asset instance, UUID will be auto-generated - new_library = Library(library_label) - uri = cam_assets.add(new_library) - Path.Log.info(f"New library created: {uri}") - - # Refresh the list of libraries in the UI - self._refreshLibraryListModel() - self._loadSelectedLibraryTools(uri) - - # Attempt to select the newly added library in the list - for i in range(self.listModel.rowCount()): - item = self.listModel.item(i) - if item and item.data(_LibraryRole) == str(uri): - curIndex = self.listModel.indexFromItem(item) - self.form.TableList.setCurrentIndex(curIndex) - Path.Log.debug(f"libraryNew: Selected new library '{str(uri)}' in TableList.") - break - - def _refreshLibraryListModel(self): - """Clears and repopulates the self.listModel with available libraries.""" - Path.Log.track() - self.listModel.clear() - self.factory.find_libraries(self.listModel) - self.listModel.setHorizontalHeaderLabels(["Library"]) - - def saveLibrary(self): - """Internal method to save the current tool library asset""" - Path.Log.track() - if not self.current_library: - Path.Log.warning("saveLibrary: No library asset loaded to save.") - return - - # Create a new dictionary to hold the updated tool numbers and bits - for row in range(self.toolModel.rowCount()): - tool_nr_item = self.toolModel.item(row, 0) - tool_uri_item = self.toolModel.item( - row, 0 - ) # Tool URI is stored in column 0 with _PathRole - - tool_nr = tool_nr_item.data(Qt.EditRole) - tool_uri_string = tool_uri_item.data(_PathRole) - - if tool_nr is not None and tool_uri_string: - try: - tool_uri = AssetUri(tool_uri_string) - # Retrieve the toolbit using the public method - found_bit = self.current_library.get_tool_by_uri(tool_uri) - - if found_bit: - # Use assign_new_bit_no to update the tool number - # This method modifies the library in place - self.current_library.assign_new_bit_no(found_bit, int(tool_nr)) - Path.Log.debug(f"Assigned tool number {tool_nr} to {tool_uri_string}") - else: - Path.Log.warning( - f"Toolbit with URI {tool_uri_string} not found in current library." - ) - except Exception as e: - Path.Log.error( - f"Error processing row {row} (tool_nr: {tool_nr}, uri: {tool_uri_string}): {e}" - ) - # Continue processing other rows even if one fails - continue - else: - Path.Log.warning(f"Skipping row {row}: Invalid tool number or URI.") - - # The current_library object has been modified in the loop by assign_new_bit_no - # Now save the modified library asset - try: - cam_assets.add(self.current_library) - Path.Log.debug(f"saveLibrary: Library " f"{self.current_library.get_uri()} saved.") - except Exception as e: - Path.Log.error( - f"saveLibrary: Failed to save library " f"{self.current_library.get_uri()}: {e}" + # Add the imported toolbit to the current library + added_toolbit = current_library.add_bit(toolbit) + if added_toolbit: + cam_assets.add(toolbit) # Save the imported toolbit to disk + cam_assets.add(current_library) # Save the modified library + self.browser.refresh() + self.browser.select_by_uri([str(toolbit.get_uri())]) + self._update_button_states() + else: + Path.Log.warning( + f"Failed to import toolbit from {file_path} to library {current_library.label}." ) - PySide.QtGui.QMessageBox.critical( - self.form, - translate("CAM_ToolBit", "Error Saving Library"), - str(e), + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate( + "CAM", + f"Failed to import toolbit from '{file_path}' to library '{current_library.label}'.", + ), ) - raise - def exportLibrary(self): - """Export the current tool library asset to a file""" - Path.Log.track() - if not self.current_library: - PySide.QtGui.QMessageBox.warning( - self.form, - translate("CAM_ToolBit", "No Library Loaded"), - translate("CAM_ToolBit", "Load or create a tool library first."), + def _on_export_toolbit_requested(self): + """Handles request to export the selected toolbit.""" + Path.Log.debug("_on_export_toolbit_requested: Called.") + selected_toolbits = self.browser.get_selected_bits() + if not selected_toolbits: + Path.Log.warning("Cannot export toolbit: No toolbit selected.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select a toolbit to export."), ) return - dialog = AssetSaveDialog(Library, library_serializers, self.form) - dialog_result = dialog.exec_(self.current_library) - if not dialog_result: - return # User canceled or error - - file_path, serializer_class = dialog_result - - Path.Log.info( - f"Exported library {self.current_library.label} " - f"to {file_path} using serializer {serializer_class.__name__}" - ) - - def columnNames(self): - return [ - "Tn", - translate("CAM_ToolBit", "Tool"), - translate("CAM_ToolBit", "Shape"), - ] - - def _loadSelectedLibraryTools(self, library_uri: AssetUri | str | None = None): - """Loads tools for the given library_uri into self.toolModel and selects it in the list.""" - Path.Log.track(library_uri) - self.toolModel.clear() - # library_uri is now expected to be a string URI or None when called from setupUI/tableSelected. - # AssetUri object conversion is handled by cam_assets.get() if needed. - - self.current_library = None # Reset current_library before loading - - if not library_uri: - self.form.setWindowTitle("Tool Library Editor - No Library Selected") - return - - # Fetch the library from the asset manager - try: - self.current_library = cam_assets.get(library_uri, depth=1) - except Exception as e: - Path.Log.error(f"Failed to load library asset {library_uri}: {e}") - self.form.setWindowTitle("Tool Library Editor - Error") - return - - # Success! Add the tools to the toolModel. - self.toolTableView.setUpdatesEnabled(False) - self.form.setWindowTitle(f"Tool Library Editor - {self.current_library.label}") - for tool_no, tool_bit in sorted(self.current_library._bit_nos.items()): - self.toolModel.appendRow( - ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri())) + if len(selected_toolbits) > 1: + Path.Log.warning("Cannot export multiple toolbits: Please select only one.") + QMessageBox.warning( + self, + FreeCAD.Qt.translate("CAM", "Warning"), + FreeCAD.Qt.translate("CAM", "Please select only one toolbit to export."), ) + return - self.toolModel.setHorizontalHeaderLabels(self.columnNames()) - self.toolTableView.setUpdatesEnabled(True) - - def setupUI(self): - """Setup the form and load the tool library data""" - Path.Log.track() - - self.form.TableList.setModel(self.listModel) - self._refreshLibraryListModel() - - self.toolTableView.setModel(self.toolModel) - - # Find the last used library. - last_used_lib_identifier = Path.Preferences.getLastToolLibrary() - Path.Log.debug( - f"setupUI: Last used library identifier from prefs: '{last_used_lib_identifier}'" - ) - last_used_lib_uri = None - if last_used_lib_identifier: - last_used_lib_uri = Library.resolve_name(last_used_lib_identifier) - - # Find it in the list. - index = 0 - for i in range(self.listModel.rowCount()): - item = self.listModel.item(i) - if item and item.data(_LibraryRole) == str(last_used_lib_uri): - index = i - break - - # Select it. - if index <= self.listModel.rowCount(): - item = self.listModel.item(index) - if item: # Should always be true, but... - library_uri_str = item.data(_LibraryRole) - self.form.TableList.setCurrentIndex(self.listModel.index(index, 0)) - - # Load tools for the selected library. - self._loadSelectedLibraryTools(library_uri_str) - - self.toolTableView.resizeColumnsToContents() - self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect) - - self.form.TableList.clicked.connect(self.tableSelected) - - self.form.toolAdd.clicked.connect(self.toolBitExisting) - self.form.toolDelete.clicked.connect(self.toolDelete) - self.form.toolCreate.clicked.connect(self.toolBitNew) - - self.form.addLibrary.clicked.connect(self.libraryNew) - self.form.exportLibrary.clicked.connect(self.exportLibrary) - self.form.saveLibrary.clicked.connect(self.saveLibrary) - - self.form.okButton.clicked.connect(self.form.close) - - self.toolSelect([], []) + toolbit_to_export = selected_toolbits[0] + dialog = AssetSaveDialog(asset_class=ToolBit, serializers=toolbit_serializers, parent=self) + dialog.exec_(toolbit_to_export) # This will open the save dialog and handle the export + self._update_button_states() diff --git a/src/Mod/CAM/Path/Tool/library/ui/properties.py b/src/Mod/CAM/Path/Tool/library/ui/properties.py new file mode 100644 index 0000000000..6bab65b4fa --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/properties.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide import QtWidgets +import FreeCADGui +import FreeCAD +from ..models.library import Library + + +class LibraryPropertyDialog(QtWidgets.QDialog): + def __init__(self, library: Library, new=False, parent=None): + super(LibraryPropertyDialog, self).__init__(parent) + self.library = library + + # Load the UI file into a QWidget + self.form = FreeCADGui.PySideUic.loadUi(":/panels/LibraryProperties.ui") + + # Create a layout for the dialog and add the loaded form widget + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.form) + self.setLayout(layout) + + # Connect signals and set initial values using the loaded form + self.form.lineEditLibraryName.setText(self.library.label) + self.update_window_title() + + if new: + label = FreeCAD.Qt.translate("CAM", "Create Library") + self.form.pushButtonSave.setText(label) + + self.form.buttonBox.accepted.connect(self.accept) + self.form.buttonBox.rejected.connect(self.reject) + self.form.pushButtonSave.clicked.connect(self.save_properties) + + # Connect text changed signal to update window title + self.form.lineEditLibraryName.textChanged.connect(self.update_window_title) + + # Set minimum width for the dialog + self.setMinimumWidth(450) + + def update_window_title(self): + # Update title based on current text in the line edit + current_name = self.form.lineEditLibraryName.text() + title = FreeCAD.Qt.translate( + "LibraryPropertyDialog", f"Library Properties - {current_name or self.library.label}" + ) + self.setWindowTitle(title) + + def save_properties(self): + new_name = self.form.lineEditLibraryName.text() + if new_name != self.library.label: + self.library._label = new_name + # Additional logic to save other properties if added later + self.accept() diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py index 312109318b..2ecc850f55 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/base.py +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -133,7 +133,9 @@ class ToolBitShape(Asset): shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()} shape_class = shape_classes.get(body_obj.Label) if not shape_class: - return ToolBitShape.get_subclass_by_name("Custom") + custom = ToolBitShape.get_subclass_by_name("Custom") + assert custom is not None, "BUG: Custom tool class not found" + return custom return shape_class @classmethod @@ -228,7 +230,13 @@ class ToolBitShape(Asset): # Find the correct subclass based on the body label shape_class = cls.get_subclass_by_name(body_label) - return shape_class or ToolBitShape.get_subclass_by_name("Custom") + if shape_class: + return shape_class + + # All else fails, treat the shape as a custom shape. + custom = ToolBitShape.get_subclass_by_name("Custom") + assert custom is not None, "BUG: Custom tool class not found" + return custom except zipfile.BadZipFile: raise ValueError("Invalid FCStd file data (not a valid zip archive)") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py index 9edaf3516d..d894254a1a 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py @@ -36,9 +36,9 @@ class ToolBitBallend(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} {flutes}-flute ballend, {cutting_edge_height} cutting edge" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index 3f3c7155a4..692ebe71e2 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -291,6 +291,9 @@ class ToolBit(Asset, ABC): """Returns the unique ID of the tool bit.""" return self.id + def set_id(self, id: str = None): + self.id = id if id is not None else str(uuid.uuid4()) + def _promote_toolbit(self): """ Updates the toolbit properties for backward compatibility. @@ -589,9 +592,11 @@ class ToolBit(Asset, ABC): def get_property(self, name: str): return self.obj.getPropertyByName(name) - def get_property_str(self, name: str, default: Optional[str] = None) -> Optional[str]: + def get_property_str( + self, name: str, default: str | None = None, precision: int | None = None + ) -> str | None: value = self.get_property(name) - return format_value(value) if value else default + return format_value(value, precision=precision) if value else default def set_property(self, name: str, value: Any): return self.obj.setPropertyByName(name, value) @@ -751,6 +756,7 @@ class ToolBit(Asset, ABC): Path.Log.track(self.obj.Label) attrs = {} attrs["version"] = 2 + attrs["id"] = self.id attrs["name"] = self.obj.Label attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd" attrs["shape-type"] = self._tool_bit_shape.name diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py index caf497d423..e86c180965 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -36,10 +36,10 @@ class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") - flat_radius = self.get_property_str("FlatRadius", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) + flat_radius = self.get_property_str("FlatRadius", "?", precision=3) return FreeCAD.Qt.translate( "CAM", diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py index da0abce4d6..29683c6ec7 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py @@ -36,9 +36,9 @@ class ToolBitChamfer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} {cutting_edge_angle} chamfer bit, {flutes}-flute" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py index aac48338b6..64bb161143 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py @@ -36,8 +36,8 @@ class ToolBitDovetail(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?", precision=3) flutes = self.get_property("Flutes") return FreeCAD.Qt.translate( diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py index cc5055d372..105e9a3586 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py @@ -36,8 +36,8 @@ class ToolBitDrill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - tip_angle = self.get_property_str("TipAngle", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + tip_angle = self.get_property_str("TipAngle", "?", precision=3) flutes = self.get_property("Flutes") return FreeCAD.Qt.translate("CAM", f"{diameter} drill, {tip_angle} tip, {flutes}-flute") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py index 6651705540..2b3b3dff8d 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py @@ -36,9 +36,9 @@ class ToolBitEndmill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} {flutes}-flute endmill, {cutting_edge_height} cutting edge" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py index a23f82ecf0..05063a710c 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py @@ -36,9 +36,9 @@ class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - radius = self.get_property_str("FilletRadius", "?") + radius = self.get_property_str("FilletRadius", "?", precision=3) flutes = self.get_property("Flutes") - diameter = self.get_property_str("ShankDiameter", "?") + diameter = self.get_property_str("ShankDiameter", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py index f0330084ef..838667ea28 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py @@ -36,9 +36,9 @@ class ToolBitProbe(ToolBit): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - length = self.get_property_str("Length", "?") - shaft_diameter = self.get_property_str("ShaftDiameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + length = self.get_property_str("Length", "?", precision=3) + shaft_diameter = self.get_property_str("ShaftDiameter", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} probe, {length} length, {shaft_diameter} shaft" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py index d8b7fbcefb..5e9d624afe 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py @@ -36,7 +36,7 @@ class ToolBitReamer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) return FreeCAD.Qt.translate("CAM", f"{diameter} reamer, {cutting_edge_height} cutting edge") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py index 2c779edc33..af7a585afd 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py @@ -36,8 +36,8 @@ class ToolBitSlittingSaw(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - blade_thickness = self.get_property_str("BladeThickness", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + blade_thickness = self.get_property_str("BladeThickness", "?", precision=3) flutes = self.get_property("Flutes") return FreeCAD.Qt.translate( diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py index 662a2e7376..4a83a59822 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py @@ -36,9 +36,9 @@ class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?") + cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} tap, {flutes}-flute, {cutting_edge_length} cutting edge" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py index 131be1abb4..771f35a7b3 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py @@ -36,9 +36,9 @@ class ToolBitThreadMill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") - cutting_angle = self.get_property_str("cuttingAngle", "?") + cutting_angle = self.get_property_str("cuttingAngle", "?", precision=3) return FreeCAD.Qt.translate( "CAM", f"{diameter} thread mill, {flutes}-flute, {cutting_angle} cutting angle" diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py index cfabf0e978..159cffb20b 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py @@ -36,8 +36,8 @@ class ToolBitVBit(ToolBit, CuttingToolMixin, RotaryToolBitMixin): @property def summary(self) -> str: - diameter = self.get_property_str("Diameter", "?") - cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + diameter = self.get_property_str("Diameter", "?", precision=3) + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?", precision=3) flutes = self.get_property("Flutes") return FreeCAD.Qt.translate("CAM", f"{diameter} {cutting_edge_angle} v-bit, {flutes}-flute") diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py index 3ef9d0b167..bba618aa12 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py @@ -1,12 +1,18 @@ from .camotics import CamoticsToolBitSerializer from .fctb import FCTBSerializer +from .yaml import YamlToolBitSerializer -all_serializers = CamoticsToolBitSerializer, FCTBSerializer +all_serializers = ( + CamoticsToolBitSerializer, + FCTBSerializer, + YamlToolBitSerializer, +) __all__ = [ "CamoticsToolBitSerializer", "FCTBSerializer", + "YamlToolBitSerializer", "all_serializers", ] diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py new file mode 100644 index 0000000000..2fbbdcef0f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import yaml +from typing import List, Optional, Mapping, Type +from ...assets.serializer import AssetSerializer +from ...assets.uri import AssetUri +from ...shape import ToolBitShape +from ..models.base import ToolBit + + +class YamlToolBitSerializer(AssetSerializer): + """ + Serializes and deserializes ToolBit instances to and from YAML. + """ + + for_class: Type[ToolBit] = ToolBit + extensions: tuple[str, ...] = (".yaml", ".yml") + mime_type: str = "application/x-yaml" + can_import: bool = True + can_export: bool = True + + @classmethod + def get_label(cls) -> str: + return "YAML ToolBit" + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + data_dict = yaml.safe_load(data) + if isinstance(data_dict, dict): + shape_id = data_dict.get("shape") + if shape_id: + # Assuming shape is identified by its ID/name + return [ToolBitShape.resolve_name(str(shape_id))] + return [] + + @classmethod + def serialize(cls, asset: ToolBit) -> bytes: + """Serializes a ToolBit instance to bytes (shallow).""" + # Shallow serialization: only serialize direct attributes and shape ID + data = asset.to_dict() + return yaml.dump(data, default_flow_style=False).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str | None = None, + dependencies: Optional[Mapping[AssetUri, ToolBitShape]] = None, + ) -> ToolBit: + """ + Creates a ToolBit instance from serialized data and resolved + dependencies (shallow). + """ + data_dict = yaml.safe_load(data) + if not isinstance(data_dict, dict): + raise ValueError("Invalid YAML data for ToolBit") + toolbit = ToolBit.from_dict(data_dict) + if id: + toolbit.id = id + return toolbit + + @classmethod + def deep_deserialize(cls, data: bytes) -> ToolBit: + """ + Like deserialize(), but builds dependencies itself if they are + sufficiently defined in the data. + """ + raise NotImplementedError diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py index b09ba55eef..744ba43704 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py @@ -1,6 +1,8 @@ from .editor import ToolBitEditorPanel, ToolBitEditor +from .browser import ToolBitBrowserWidget __all__ = [ + "ToolBitBrowserWidget", "ToolBitEditor", "ToolBitEditorPanel", ] diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 6a6fc6face..6273f4cb81 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# flake8: noqa E731 # *************************************************************************** # * Copyright (c) 2025 Samuel Abels * # * * @@ -22,13 +23,23 @@ """Widget for browsing ToolBit assets with filtering and sorting.""" -from typing import List, cast -from PySide import QtGui, QtCore -from typing import List, cast +import yaml +from typing import List, Optional, cast, Sequence from PySide import QtGui, QtCore +from PySide.QtGui import QApplication, QMessageBox, QMenu, QAction, QKeySequence, QDialog +from PySide.QtCore import QMimeData +import FreeCAD +import Path from ...assets import AssetManager, AssetUri -from ...toolbit import ToolBit +from ..models.base import ToolBit +from ..serializers.yaml import YamlToolBitSerializer from .toollist import ToolBitListWidget, CompactToolBitListWidget, ToolBitUriRole +from .editor import ToolBitEditor +from .util import natural_sort_key + + +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +Path.Log.trackModule(Path.Log.thisModule()) class ToolBitBrowserWidget(QtGui.QWidget): @@ -40,11 +51,10 @@ class ToolBitBrowserWidget(QtGui.QWidget): # Signal emitted when a tool is selected in the list toolSelected = QtCore.Signal(str) # Emits ToolBit URI string # Signal emitted when a tool is requested for editing (e.g., double-click) - itemDoubleClicked = QtCore.Signal(str) # Emits ToolBit URI string + itemDoubleClicked = QtCore.Signal(ToolBit) # Emits ToolBit URI string # Debounce timer for search input _search_timer_interval = 300 # milliseconds - _batch_size = 20 # Number of items to insert per batch def __init__( self, @@ -52,6 +62,7 @@ class ToolBitBrowserWidget(QtGui.QWidget): store: str = "local", parent=None, tool_no_factory=None, + tool_fetcher=None, compact=False, ): super().__init__(parent) @@ -61,10 +72,10 @@ class ToolBitBrowserWidget(QtGui.QWidget): self._is_fetching = False self._store_name = store - self._all_assets: List[ToolBit] = [] # Store all fetched assets + self._all_assets: Sequence[ToolBit] = [] # Store all fetched assets self._current_search = "" # Track current search term - self._scroll_position = 0 # Track scroll position self._sort_key = "tool_no" if tool_no_factory else "label" + self._selected_uris: List[str] = [] # Track selected toolbit URIs # UI Elements self._search_edit = QtGui.QLineEdit() @@ -97,150 +108,325 @@ class ToolBitBrowserWidget(QtGui.QWidget): self._search_timer = QtCore.QTimer(self) self._search_timer.setSingleShot(True) self._search_timer.setInterval(self._search_timer_interval) - self._search_timer.timeout.connect(self._trigger_fetch) + self._search_timer.timeout.connect(self._update_list) self._search_edit.textChanged.connect(self._search_timer.start) self._sort_combo.currentIndexChanged.connect(self._on_sort_changed) - scrollbar = self._tool_list_widget.verticalScrollBar() - scrollbar.valueChanged.connect(self._on_scroll) - + # Connect signals from the list widget self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) - self._tool_list_widget.currentItemChanged.connect(self._on_item_selection_changed) + self._tool_list_widget.itemSelectionChanged.connect(self._on_item_selection_changed) + + # Connect list widget context menu request to browser handler + self._tool_list_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self._tool_list_widget.customContextMenuRequested.connect(self._show_context_menu) + + # Add keyboard shortcuts + self._add_shortcuts() # Note that fetching of assets is done at showEvent(), # because we need to know the widget size to calculate the number # of items that need to be fetched. + self.tool_fetcher = tool_fetcher or self._tool_fetcher def showEvent(self, event): """Handles the widget show event to trigger initial data fetch.""" super().showEvent(event) # Fetch all assets the first time the widget is shown if not self._all_assets and not self._is_fetching: - self._fetch_all_assets() + self.refresh() + # Set focus to the search field + self._search_edit.setFocus() - def _fetch_all_assets(self): - """Fetches all ToolBit assets and stores them in memory.""" + def _tool_fetcher(self) -> Sequence[ToolBit]: + return cast( + List[ToolBit], + self._asset_manager.fetch( + asset_type="toolbit", + depth=0, # do not fetch dependencies (e.g. shape, icon) + store=self._store_name, + ), + ) + + def select_by_uri(self, uris: List[str]): + if not uris: + return + + # Select and scroll to the first toolbit + is_first = True + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + if item.data(ToolBitUriRole) in uris: + self._tool_list_widget.setCurrentItem(item) + if is_first: + # Scroll to the first selected item + is_first = False + self._tool_list_widget.scrollToItem(item) + + def refresh(self): + """Fetches all ToolBit assets and stores them in memory, then updates the UI.""" if self._is_fetching: return self._is_fetching = True try: - self._all_assets = cast( - List[ToolBit], - self._asset_manager.fetch( - asset_type="toolbit", - depth=0, # do not fetch dependencies (e.g. shape, icon) - store=self._store_name, - ), - ) - self._sort_assets() + self._all_assets = self.tool_fetcher() finally: self._is_fetching = False - self._trigger_fetch() + Path.Log.debug(f"Loaded {len(self._all_assets)} ToolBits.") + + self._sort_assets() + self._update_list() def _sort_assets(self): """Sorts the in-memory assets based on the current sort key.""" if self._sort_key == "label": - self._all_assets.sort(key=lambda x: x.label.lower()) + self._all_assets.sort(key=lambda x: natural_sort_key(x.label)) elif self._sort_key == "tool_no" and self._tool_no_factory: self._all_assets.sort( - key=lambda x: (int(self._tool_no_factory(x)) or 0) if self._tool_no_factory else 0 + key=lambda x: int(self._tool_no_factory(x) or 0) if self._tool_no_factory else 0 ) - def _trigger_fetch(self): - """Initiates a data fetch, clearing the list only if search term changes.""" - new_search = self._search_edit.text() - if new_search != self._current_search: - self._current_search = new_search - self._tool_list_widget.clear_list() - self._scroll_position = 0 - self._fetch_data() - - def _fetch_batch(self, offset): - """Inserts a batch of filtered assets into the list widget.""" - filtered_assets = [ - asset - for asset in self._all_assets - if not self._current_search or self._matches_search(asset, self._current_search) - ] - end_idx = min(offset + self._batch_size, len(filtered_assets)) - for i in range(offset, end_idx): - self._tool_list_widget.add_toolbit(filtered_assets[i]) - return end_idx < len(filtered_assets) # Return True if more items remain - def _matches_search(self, toolbit, search_term): """Checks if a ToolBit matches the search term.""" search_term = search_term.lower() return search_term in toolbit.label.lower() or search_term in toolbit.summary.lower() - def _fetch_data(self): - """Inserts filtered and sorted ToolBit assets into the list widget.""" + def _update_list(self): + """Updates the list widget based on current search and sort.""" if self._is_fetching: return - self._is_fetching = True - try: - # Save current scroll position and selected item - scrollbar = self._tool_list_widget.verticalScrollBar() - self._scroll_position = scrollbar.value() - selected_uri = self._tool_list_widget.get_selected_toolbit_uri() - # Insert initial batches to fill the viewport - offset = self._tool_list_widget.count() - more_items = True - while more_items: - more_items = self._fetch_batch(offset) - offset += self._batch_size - if scrollbar.maximum() != 0: - break - - # Apply filter to ensure UI consistency - self._tool_list_widget.apply_filter(self._current_search) - - # Restore scroll position and selection - scrollbar.setValue(self._scroll_position) - if selected_uri: - for i in range(self._tool_list_widget.count()): - item = self._tool_list_widget.item(i) - if item.data(ToolBitUriRole) == selected_uri and not item.isHidden(): - self._tool_list_widget.setCurrentItem(item) - break - - finally: - self._is_fetching = False - - def _on_scroll(self, value): - """Handles scroll events for lazy batch insertion.""" - scrollbar = self._tool_list_widget.verticalScrollBar() - is_near_bottom = value >= scrollbar.maximum() - scrollbar.singleStep() - filtered_count = sum( - 1 + self._current_search = self._search_edit.text() + filtered_assets = [ + asset for asset in self._all_assets if not self._current_search or self._matches_search(asset, self._current_search) - ) - more_might_exist = self._tool_list_widget.count() < filtered_count + ] - if is_near_bottom and more_might_exist and not self._is_fetching: - self._fetch_data() + # Collect current items in the list widget + current_items = {} + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + uri = item.data(ToolBitUriRole) + if uri: + current_items[uri] = item + + # Iterate through filtered assets and update the list widget + for i, asset in enumerate(filtered_assets): + uri = str(asset.get_uri()) + if uri in current_items: + # Item exists, remove the old one and insert the new one + item = current_items[uri] + row = self._tool_list_widget.row(item) + self._tool_list_widget.takeItem(row) + self._tool_list_widget.insert_toolbit(i, asset) + del current_items[uri] + else: + # Insert new item + self._tool_list_widget.insert_toolbit(i, asset) + + # Remove items that are no longer in filtered_assets + for uri, item in current_items.items(): + row = self._tool_list_widget.row(item) + self._tool_list_widget.takeItem(row) + + # Restore selection and scroll to the selected item + if self._selected_uris: + first_selected_item = None + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + uri = item.data(ToolBitUriRole) + if uri in self._selected_uris: + item.setSelected(True) + if first_selected_item is None: + first_selected_item = item + if first_selected_item: + self._tool_list_widget.scrollToItem(first_selected_item) + + # Apply the filter to trigger highlighting in the list widget + self._tool_list_widget.apply_filter(self._current_search) + + def set_sort_order(self, key: str): + for i in range(self._sort_combo.count()): + if self._sort_combo.itemData(i) == key: + if self._sort_combo.currentIndex() != i: + self._sort_combo.setCurrentIndex(i) + break + else: + return + self._sort_key = key + self._sort_assets() + self._update_list() def _on_sort_changed(self): """Handles sort order change from the dropdown.""" - self._sort_key = self._sort_combo.itemData(self._sort_combo.currentIndex()) - self._sort_assets() - self._tool_list_widget.clear_list() - self._scroll_position = 0 - self._fetch_data() + key = self._sort_combo.itemData(self._sort_combo.currentIndex()) + self.set_sort_order(key) def _on_item_double_clicked(self, item): - """Emits itemDoubleClicked signal when an item is double-clicked.""" - uri = item.data(ToolBitUriRole) - if uri: - self.itemDoubleClicked.emit(uri) + """Handles double-click on a list item to request editing.""" + uri_string = item.data(ToolBitUriRole) + if not uri_string: + return + toolbit = self._asset_manager.get(AssetUri(uri_string)) + if toolbit: + self.itemDoubleClicked.emit(toolbit) - def _on_item_selection_changed(self, current_item, previous_item): - """Emits toolSelected signal when the selection changes.""" - uri = None - if current_item: - uri = current_item.data(ToolBitUriRole) - self.toolSelected.emit(uri if current_item else None) + def _on_item_selection_changed(self): + """Emits toolSelected signal and tracks selected URIs.""" + selected_uris = self._tool_list_widget.get_selected_toolbit_uris() + self._selected_uris = selected_uris + if not selected_uris: + return + self.toolSelected.emit(selected_uris[0]) + + def _get_first_selected_bit(self) -> Optional[ToolBit]: + uris = self.get_selected_bit_uris() + if not uris: + return None + uri_string = uris[0] + return cast(ToolBit, self._asset_manager.get(AssetUri(uri_string))) + + def _on_edit_requested(self): + """Opens the ToolBitEditor for the selected toolbit.""" + toolbit = self._get_first_selected_bit() + if not toolbit: + return + + # Open the editor for the selected toolbit + editor = ToolBitEditor(toolbit) + result = editor.show() + if result != QDialog.Accepted: + return + + # If the editor was closed with "OK", save the changes + self._asset_manager.add(toolbit) + Path.Log.info(f"Toolbit {toolbit.get_id()} saved.") + self.refresh() + self._update_list() + + def _add_shortcuts(self): + """Adds keyboard shortcuts for common actions.""" + copy_action = QAction(self) + copy_action.setShortcut(QKeySequence.Copy) + copy_action.triggered.connect(self._on_copy_requested) + self.addAction(copy_action) + + delete_action = QAction(self) + delete_action.setShortcut(QKeySequence("Shift+Delete")) + delete_action.triggered.connect(self._on_delete_requested) + self.addAction(delete_action) + + edit_action = QAction(self) + edit_action.setShortcut(QKeySequence("F2")) + edit_action.triggered.connect(self._on_edit_requested) + self.addAction(edit_action) + + def _create_base_context_menu(self): + """Creates the base context menu with Edit, Copy, and Delete actions.""" + selected_items = self._tool_list_widget.selectedItems() + has_selection = bool(selected_items) + + context_menu = QMenu(self) + + edit_action = context_menu.addAction("Edit", self._on_edit_requested) + edit_action.setEnabled(has_selection) + context_menu.addSeparator() + action = context_menu.addAction("Copy", self._on_copy_requested) + action.setShortcut(QKeySequence.Copy) + action = context_menu.addAction("Delete from disk", self._on_delete_requested) + action.setShortcut(QKeySequence("Shift+Delete")) + + return context_menu + + def _show_context_menu(self, position): + """Shows the context menu at the given position.""" + context_menu = self._create_base_context_menu() + context_menu.exec_(self._tool_list_widget.mapToGlobal(position)) + + def _to_clipboard( + self, + uris: List[str], + mode: str = "copy", + extra_data: Optional[dict] = None, + ): + """Copies selected toolbits to the clipboard as YAML.""" + if not uris: + return + + selected_bits = [cast(ToolBit, self._asset_manager.get(AssetUri(uri))) for uri in uris] + selected_bits = [bit for bit in selected_bits if bit] # Filter out None + if not selected_bits: + return + + # Serialize selected toolbits individually + serialized_toolbits_data = [] + for toolbit in selected_bits: + yaml_data = YamlToolBitSerializer.serialize(toolbit) + serialized_toolbits_data.append(yaml_data.decode("utf-8")) + + # Create a dictionary to hold the operation type and serialized data + clipboard_data_dict = { + "operation": mode, + "toolbits": serialized_toolbits_data, + } + + # Include extra data if provided + if extra_data: + clipboard_data_dict.update(extra_data) + + # Serialize the dictionary to YAML + clipboard_content_yaml = yaml.dump(clipboard_data_dict, default_flow_style=False) + + # Put the YAML data on the clipboard with a custom MIME type + mime_data = QMimeData() + mime_type = "application/x-freecad-toolbit-list-yaml" + mime_data.setData(mime_type, clipboard_content_yaml.encode("utf-8")) + + # Put it in text format for pasting to text editors + toolbit_list = [yaml.safe_load(d) for d in serialized_toolbits_data] + mime_data.setText(yaml.dump(toolbit_list, default_flow_style=False)) + + clipboard = QApplication.clipboard() + clipboard.setMimeData(mime_data) + + def _on_copy_requested(self): + """Copies selected toolbits to the clipboard as YAML.""" + uris = self.get_selected_bit_uris() + self._to_clipboard(uris, mode="copy") + + def _on_delete_requested(self): + """Deletes selected toolbits.""" + Path.Log.debug("ToolBitBrowserWidget._on_delete_requested: Function entered.") + uris = self.get_selected_bit_uris() + if not uris: + Path.Log.debug("_on_delete_requested: No URIs selected. Returning.") + return + + # Ask for confirmation + reply = QMessageBox.question( + self, + FreeCAD.Qt.translate("CAM", "Confirm Deletion"), + FreeCAD.Qt.translate("CAM", "Are you sure you want to delete the selected toolbit(s)?"), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply != QMessageBox.Yes: + return + + deleted_count = 0 + for uri_string in uris: + try: + # Delete the toolbit using the asset manager + self._asset_manager.delete(AssetUri(uri_string)) + deleted_count += 1 + except Exception as e: + Path.Log.error(f"Failed to delete toolbit {uri_string}: {e}") + # Optionally show a message box to the user + + if deleted_count > 0: + Path.Log.info(f"Deleted {deleted_count} toolbit(s).") + self.refresh() def get_selected_bit_uris(self) -> List[str]: """ diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py index f9c7c22847..26c60a10f8 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -30,6 +30,8 @@ from ...shape.ui.shapewidget import ShapeWidget from ...docobject.ui import DocumentObjectEditorWidget from ..models.base import ToolBit +translate = FreeCAD.Qt.translate + class ToolBitPropertiesWidget(QtGui.QWidget): """ @@ -38,11 +40,19 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # Signal emitted when the toolbit data has been modified toolBitChanged = QtCore.Signal() + toolNoChanged = QtCore.Signal(int) - def __init__(self, toolbit: Optional[ToolBit] = None, parent=None, icon: bool = True): + def __init__( + self, + toolbit: Optional[ToolBit] = None, + tool_no: Optional[int] = None, + parent=None, + icon: bool = True, + ): super().__init__(parent) self._toolbit = None self._show_shape = icon + self._tool_no = tool_no # UI Elements self._label_edit = QtGui.QLineEdit() @@ -58,10 +68,17 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._shape_widget = None # Will be created in load_toolbit # Layout - toolbit_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Tool Bit")) + toolbit_group_box = QtGui.QGroupBox(translate("CAM", "Tool Bit")) form_layout = QtGui.QFormLayout(toolbit_group_box) - form_layout.addRow("Label:", self._label_edit) - form_layout.addRow("ID:", self._id_label) + form_layout.addRow(translate("CAM", "Label:"), self._label_edit) + form_layout.addRow(translate("CAM", "ID:"), self._id_label) + + # Optional tool number edit field. + self._tool_no_edit = QtGui.QSpinBox() + self._tool_no_edit.setMinimum(1) + self._tool_no_edit.setMaximum(99999999) + if tool_no is not None: + form_layout.addRow(translate("CAM", "Tool Number:"), self._tool_no_edit) main_layout = QtGui.QVBoxLayout(self) main_layout.addWidget(toolbit_group_box) @@ -93,6 +110,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # Connections self._label_edit.editingFinished.connect(self._on_label_changed) + self._tool_no_edit.valueChanged.connect(self._on_tool_no_changed) self._property_editor.propertyChanged.connect(self.toolBitChanged) if toolbit: @@ -106,6 +124,12 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._toolbit.obj.Label = new_label self.toolBitChanged.emit() + def _on_tool_no_changed(self, value): + """Update the tool number when the line edit changes.""" + if self._tool_no != value: + self._tool_no = value + self.toolNoChanged.emit(value) + def load_toolbit(self, toolbit: ToolBit): """Load a ToolBit object into the editor.""" self._toolbit = toolbit @@ -114,12 +138,14 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._label_edit.clear() self._label_edit.setEnabled(False) self._id_label.clear() + self._tool_no_edit.clear() self._property_editor.setObject(None) # Clear existing shape widget if any if self._shape_widget: self._shape_display_layout.removeWidget(self._shape_widget) self._shape_widget.deleteLater() self._shape_widget = None + self._tool_no_edit.setValue(1) self.setEnabled(False) return @@ -127,6 +153,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._label_edit.setEnabled(True) self._label_edit.setText(self._toolbit.obj.Label) self._id_label.setText(self._toolbit.get_id()) + self._tool_no_edit.setValue(int(self._tool_no or 1)) # Get properties and suffixes props_to_show = self._toolbit._get_props(("Shape", "Attributes")) @@ -214,12 +241,18 @@ class ToolBitEditor(QtGui.QWidget): # Signals toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget - def __init__(self, toolbit: ToolBit, parent=None): + def __init__( + self, + toolbit: ToolBit, + tool_no: Optional[int] = None, + parent=None, + icon: bool = False, + ): super().__init__(parent) self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui") self.toolbit = toolbit - # self.tool_no = tool_no + self.tool_no = tool_no self.default_title = self.form.windowTitle() # Get first tab from the form, add the shape widget at the top. @@ -228,9 +261,9 @@ class ToolBitEditor(QtGui.QWidget): tool_tab_layout.addWidget(widget) # Add tool properties editor to the same tab. - props = ToolBitPropertiesWidget(toolbit, self, icon=False) + props = ToolBitPropertiesWidget(toolbit, tool_no, self, icon=icon) props.toolBitChanged.connect(self._update) - # props.toolNoChanged.connect(self._on_tool_no_changed) + props.toolNoChanged.connect(self._on_tool_no_changed) tool_tab_layout.addWidget(props) self.form.tabWidget.setCurrentIndex(0) @@ -280,5 +313,8 @@ class ToolBitEditor(QtGui.QWidget): def _on_tool_no_changed(self, value): self.tool_no = value + def get_tool_no(self): + return self.tool_no + def show(self): return self.form.exec_() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py index c604d666bc..7248bc56c6 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py @@ -56,11 +56,13 @@ class TwoLineTableCell(QtGui.QWidget): self.vbox = QtGui.QVBoxLayout() self.label_upper = QtGui.QLabel() self.label_upper.setStyleSheet("margin-top: 8px") + self.label_upper.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) color = interpolate_colors(bg_color, fg_color, 0.8) style = "margin-bottom: 8px; color: {};".format(color.name()) self.label_lower = QtGui.QLabel() self.label_lower.setStyleSheet(style) + self.label_lower.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) self.vbox.addWidget(self.label_upper) self.vbox.addWidget(self.label_lower) @@ -70,6 +72,7 @@ class TwoLineTableCell(QtGui.QWidget): self.label_left.setTextFormat(QtCore.Qt.RichText) self.label_left.setAlignment(QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter) self.label_left.setStyleSheet(style) + self.label_left.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) ratio = self.devicePixelRatioF() self.icon_size = QtCore.QSize(50 * ratio, 60 * ratio) @@ -81,6 +84,7 @@ class TwoLineTableCell(QtGui.QWidget): self.label_right.setTextFormat(QtCore.Qt.RichText) self.label_right.setAlignment(QtCore.Qt.AlignCenter) self.label_right.setStyleSheet(style) + self.label_right.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) self.hbox = QtGui.QHBoxLayout() self.hbox.addWidget(self.label_left, 0) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py index fd60d068d4..edc6a08ee6 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py @@ -22,13 +22,19 @@ """Widget for displaying a list of ToolBits using TwoLineTableCell.""" +import yaml +import Path from typing import Callable, List from PySide import QtGui, QtCore +from PySide.QtGui import QDrag +from PySide.QtCore import QMimeData +from ..models.base import ToolBit from .tablecell import TwoLineTableCell, CompactTwoLineTableCell -from ..models.base import ToolBit # For type hinting + # Role for storing the ToolBit URI string ToolBitUriRole = QtCore.Qt.UserRole + 1 +ToolBitUriListMimeType = "application/x-freecad-toolbit-uri-list-yaml" class ToolBitListWidget(QtGui.QListWidget): @@ -40,22 +46,45 @@ class ToolBitListWidget(QtGui.QListWidget): def __init__(self, parent=None, tool_no_factory: Callable | None = None): super().__init__(parent) self._tool_no_factory = tool_no_factory - # Optimize view for custom widgets - self.setUniformItemSizes(False) # Allow different heights if needed self.setAutoScroll(True) self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection) - # Consider setting view mode if needed, default is ListMode - # self.setViewMode(QtGui.QListView.ListMode) - # self.setResizeMode(QtGui.QListView.Adjust) # Adjust items on resize - def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + def setDragEnabled(self, enabled: bool = True): + """Enable or disable drag-and-drop support for toolbits.""" + super().setDragEnabled(enabled) + + def startDrag(self, supportedActions): + """Initiate drag with selected toolbits serialized as mime data if drag is enabled.""" + Path.Log.debug("startDrag: Drag initiated.") + selected_items = self.selectedItems() + if not selected_items: + Path.Log.debug("startDrag: No items selected for drag.") + return + + uris = [item.data(ToolBitUriRole) for item in selected_items] + if not uris: + Path.Log.debug("startDrag: No valid URIs found for selected items.") + return + + # Create clipboard data + clipboard_data = { + "toolbits": uris, + } + yaml_data = yaml.safe_dump(clipboard_data).encode("utf-8") + + # Set mime data + mime_data = QMimeData() + mime_data.setData(ToolBitUriListMimeType, yaml_data) + + # Start drag + drag = QDrag(self) + drag.setMimeData(mime_data) + drag.exec_(QtCore.Qt.CopyAction | QtCore.Qt.MoveAction) + Path.Log.debug("startDrag: Drag executed.") + + def _create_toolbit_item(self, toolbit: ToolBit, tool_no: int | None = None): """ - Adds a ToolBit to the list. - - Args: - toolbit (ToolBit): The ToolBit object to add. - tool_no (int | None): The tool number associated with the ToolBit, - or None if not applicable. + Creates a QListWidgetItem and populates it with ToolBit data. """ # Use the factory function if provided, otherwise use the passed tool_no final_tool_no = None @@ -72,6 +101,7 @@ class ToolBitListWidget(QtGui.QListWidget): cell.set_tool_no(final_tool_no) cell.set_upper_text(toolbit.label) cell.set_lower_text(toolbit.summary) + cell.set_icon_from_shape(toolbit._tool_bit_shape) # Set the custom widget for the list item item.setSizeHint(cell.sizeHint()) @@ -80,6 +110,33 @@ class ToolBitListWidget(QtGui.QListWidget): # Store the ToolBit URI for later retrieval item.setData(ToolBitUriRole, str(toolbit.get_uri())) + return item + + def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + """ + Adds a ToolBit to the list. + + Args: + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + item = self._create_toolbit_item(toolbit, tool_no) + self.addItem(item) + + def insert_toolbit(self, row: int, toolbit: ToolBit, tool_no: int | None = None): + """ + Inserts a ToolBit to the list at the specified row. + + Args: + row (int): The row index where the item should be inserted. + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + item = self._create_toolbit_item(toolbit, tool_no) + self.insertItem(row, item) + def clear_list(self): """Removes all items from the list.""" self.clear() @@ -147,14 +204,10 @@ class CompactToolBitListWidget(ToolBitListWidget): CompactTwoLineTableCell widgets. """ - def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + def _create_toolbit_item(self, toolbit: ToolBit, tool_no: int | None = None): """ - Adds a ToolBit to the list using CompactTwoLineTableCell. - - Args: - toolbit (ToolBit): The ToolBit object to add. - tool_no (int | None): The tool number associated with the ToolBit, - or None if not applicable. + Creates a QListWidgetItem and populates it with ToolBit data + using CompactTwoLineTableCell. """ # Use the factory function if provided, otherwise use the passed tool_no final_tool_no = None @@ -163,15 +216,14 @@ class CompactToolBitListWidget(ToolBitListWidget): elif tool_no is not None: final_tool_no = tool_no - item = QtGui.QListWidgetItem(self) # Add item to this widget - cell = CompactTwoLineTableCell(self) # Parent the cell to this widget + item = QtGui.QListWidgetItem(self) + cell = CompactTwoLineTableCell(self) # Populate the cell widget cell.set_tool_no(final_tool_no) cell.set_upper_text(toolbit.label) - lower_text = toolbit.summary + cell.set_lower_text(toolbit.summary) cell.set_icon_from_shape(toolbit._tool_bit_shape) - cell.set_lower_text(lower_text) # Set the custom widget for the list item item.setSizeHint(cell.sizeHint()) @@ -179,3 +231,5 @@ class CompactToolBitListWidget(ToolBitListWidget): # Store the ToolBit URI for later retrieval item.setData(ToolBitUriRole, str(toolbit.get_uri())) + + return item diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/util.py b/src/Mod/CAM/Path/Tool/toolbit/ui/util.py new file mode 100644 index 0000000000..4957e05995 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/util.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * This program is free software; you can redistribute it and/or modify * +# * it under the terms of the GNU Lesser General Public License (LGPL) * +# * as published by the Free Software Foundation; either version 2 of * +# * the License, or (at your option) any later version. * +# * for detail see the LICENCE text file. * +# * * +# * This program is distributed in the hope that it will be useful, * +# * but WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * +# * GNU Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import re + + +def natural_sort_key(s, _nsre=re.compile(r"(\d+[\.,]?\d*)")): + def try_convert(text): + try: + return float(text.replace(",", ".")) + except ValueError: + return text.lower() + + return [try_convert(text) for text in _nsre.split(s)] diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py index 03976184de..705ef05783 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/util.py +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -29,9 +29,14 @@ def to_json(value): return value -def format_value(value: FreeCAD.Units.Quantity | int | float | None): +def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: int | None = None): if value is None: return None elif isinstance(value, FreeCAD.Units.Quantity): + if precision is not None: + # Format the value with the specified number of precision and strip trailing zeros + formatted_value = f"{value.Value:.{precision}f}".rstrip("0").rstrip(".") + unit = value.getUserPreferred()[2] + return f"{formatted_value} {unit}" return value.UserString return str(value) diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index 4fb285c6e0..8bcafcd3c9 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -81,6 +81,7 @@ from CAMTests.TestPathToolShapeIcon import ( from CAMTests.TestPathToolBitSerializer import ( TestCamoticsToolBitSerializer, TestFCTBSerializer, + TestYamlToolBitSerializer, ) from CAMTests.TestPathToolLibrary import TestPathToolLibrary from CAMTests.TestPathToolLibrarySerializer import ( From 81faf7727c7fbb837dcfee2de5dd7d36b02c5667 Mon Sep 17 00:00:00 2001 From: Billy Date: Sun, 24 Aug 2025 15:42:56 -0400 Subject: [PATCH 2/6] CAM: Remove hardcoded style for Tool Number, Fix TestPathToolBitSerializer Fix issue with toolshapes Renamed fillet to radius Added Tool Type Filter to library Fix units so that they honor user preference Remove the QToolBox widget from the Shape Selector page and combine into a single page. Fix issue with PropertyBag so that CustomPropertyGroups as a string is converted to enum and enums are handled correctly. Update TestPathPropertyBag test for enum changes. Update TestPathToolBitListWidget Update TestPathToolLibrarySerializer to match new LinuxCNC output Fix LinuxCNC export too handle ALL tool types, use user preferences for units, and include all lcnc fields --- src/Mod/CAM/CAMTests/TestPathPropertyBag.py | 22 +++- .../CAM/CAMTests/TestPathToolBitListWidget.py | 2 +- .../CAM/CAMTests/TestPathToolBitSerializer.py | 2 +- .../CAMTests/TestPathToolLibrarySerializer.py | 8 +- src/Mod/CAM/CMakeLists.txt | 8 +- .../CAM/Gui/Resources/panels/ShapeSelector.ui | 24 +--- src/Mod/CAM/Path/Base/Gui/PropertyBag.py | 13 +- src/Mod/CAM/Path/Base/PropertyBag.py | 77 +++++++++--- src/Mod/CAM/Path/Base/Util.py | 6 +- .../Path/Tool/library/serializers/linuxcnc.py | 33 +++-- src/Mod/CAM/Path/Tool/library/ui/browser.py | 100 +++++++++++++++ src/Mod/CAM/Path/Tool/library/ui/dock.py | 16 ++- src/Mod/CAM/Path/Tool/shape/__init__.py | 4 +- src/Mod/CAM/Path/Tool/shape/models/custom.py | 24 +++- .../shape/models/{fillet.py => radius.py} | 19 +-- .../CAM/Path/Tool/shape/ui/shapeselector.py | 7 +- src/Mod/CAM/Path/Tool/toolbit/__init__.py | 4 +- src/Mod/CAM/Path/Tool/toolbit/models/base.py | 5 + .../CAM/Path/Tool/toolbit/models/custom.py | 29 +++++ src/Mod/CAM/Path/Tool/toolbit/models/probe.py | 29 +++++ .../toolbit/models/{fillet.py => radius.py} | 14 +-- src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py | 4 - src/Mod/CAM/Path/Tool/toolbit/util.py | 13 +- src/Mod/CAM/Tools/Shape/fillet.fcstd | Bin 17383 -> 0 bytes src/Mod/CAM/Tools/Shape/radius.fcstd | Bin 0 -> 14627 bytes .../Tools/Shape/{fillet.svg => radius.svg} | 119 +++++++++++++----- src/Mod/CAM/Tools/Shape/thread-mill.fcstd | Bin 16823 -> 15032 bytes src/Mod/CAM/Tools/Shape/v-bit.fcstd | Bin 18822 -> 13977 bytes 28 files changed, 447 insertions(+), 135 deletions(-) rename src/Mod/CAM/Path/Tool/shape/models/{fillet.py => radius.py} (86%) rename src/Mod/CAM/Path/Tool/toolbit/models/{fillet.py => radius.py} (82%) delete mode 100644 src/Mod/CAM/Tools/Shape/fillet.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/radius.fcstd rename src/Mod/CAM/Tools/Shape/{fillet.svg => radius.svg} (82%) 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/TestPathToolBitListWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py index 2c233856ca..182695b51f 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py @@ -55,7 +55,7 @@ class TestToolBitListWidget(PathTestWithAssets): self.assertEqual(cell_widget.tool_no, str(tool_no)) self.assertEqual(cell_widget.upper_text, toolbit.label) # Assuming the 5mm_Endmill asset has a shape named 'Endmill' - self.assertEqual(cell_widget.lower_text, "5 mm 4-flute endmill, 30 mm cutting edge") + self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge") # Verify URI is stored in item data stored_uri = item.data(ToolBitUriRole) diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py index 7a9ea41995..fbc8cf16fa 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py @@ -149,7 +149,7 @@ class TestYamlToolBitSerializer(_BaseToolBitSerializerTestCase): self.assertEqual(data.get("shape"), "endmill.fcstd") self.assertEqual(data.get("shape-type"), "Endmill") self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm") - self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm") + self.assertEqual(data.get("parameter", {}).get("Length"), "15.00 mm") def test_extract_dependencies(self): """Test dependency extraction for YAML.""" 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/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 88a6f3643e..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 @@ -264,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 @@ -455,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/panels/ShapeSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui index da9f2dbc17..86103f5d06 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui @@ -17,11 +17,11 @@ - - - 1 + + + true - + 0 @@ -30,22 +30,6 @@ 487 - - Standard tools - - - - - - 0 - 0 - 880 - 487 - - - - My tools - 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..2b0fd946ab 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,39 @@ 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: + 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 +138,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 +163,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/Tool/library/serializers/linuxcnc.py b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py index 992dfe6760..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,29 @@ 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" + # 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/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py index c5527772a1..ff1f5f15ce 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -65,8 +65,17 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): 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() @@ -177,6 +186,35 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): 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 not self._selected_tool_type or self._selected_tool_type == "All Tool 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: @@ -187,8 +225,34 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): 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.") @@ -476,6 +540,32 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): 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("All Tool 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) + self._selected_tool_type = "All Tool Types" + 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): """ @@ -502,10 +592,15 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): 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 @@ -554,6 +649,11 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): 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 diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py index 215444ebf7..ff92275ed2 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/dock.py +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -36,7 +36,6 @@ from ...toolbit import ToolBit from .editor import LibraryEditor from .browser import LibraryBrowserWithCombo - if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) @@ -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,8 +79,6 @@ class ToolBitLibraryDock(object): main_layout.setContentsMargins(4, 4, 4, 4) main_layout.setSpacing(4) - # Create the browser widget - self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets) main_layout.addWidget(self.browser_widget) # Create buttons @@ -89,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) @@ -106,6 +111,7 @@ class ToolBitLibraryDock(object): self.browser_widget.itemDoubleClicked.connect(self._on_doubleclick) self.libraryEditorOpenButton.clicked.connect(self._open_editor) self.addToolControllerButton.clicked.connect(self._add_tool_controller_to_doc) + self.closeButton.clicked.connect(self.form.reject) # Update the initial state of the UI self._update_state() 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/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/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/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index 692ebe71e2..9cfff72974 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -513,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 @@ -520,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): @@ -528,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 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/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py index 838667ea28..c07c6b879b 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py @@ -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 82% rename from src/Mod/CAM/Path/Tool/toolbit/models/fillet.py rename to src/Mod/CAM/Path/Tool/toolbit/models/radius.py index 05063a710c..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", "?", precision=3) + radius = self.get_property_str("CuttingRadius", "?", precision=3) flutes = self.get_property("Flutes") 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/ui/tablecell.py b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py index 7248bc56c6..e0bed94b43 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py @@ -66,24 +66,20 @@ class TwoLineTableCell(QtGui.QWidget): 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() diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py index 705ef05783..bc21722814 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/util.py +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -34,9 +34,16 @@ def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: 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 - formatted_value = f"{value.Value:.{precision}f}".rstrip("0").rstrip(".") - unit = value.getUserPreferred()[2] - return f"{formatted_value} {unit}" + return value.getUserPreferred()[0] return value.UserString return str(value) diff --git a/src/Mod/CAM/Tools/Shape/fillet.fcstd b/src/Mod/CAM/Tools/Shape/fillet.fcstd deleted file mode 100644 index 536a57f848c404fe0ddb506e9b807b77e2bbe826..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17383 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 SDjm+tPs__+vM@bc1 literal 0 HcmV?d00001 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 e0eb96c9eaebeaf04007502fc60e64f367f4b815..575076c4f2812f949d3e51257fd18f5ef532683f 100644 GIT binary patch delta 12549 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 literal 16823 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 L1ccxHG35Op5};;> diff --git a/src/Mod/CAM/Tools/Shape/v-bit.fcstd b/src/Mod/CAM/Tools/Shape/v-bit.fcstd index 16cd0e630a995741888fed1be1444a4a2f104c16..be25111e2e4cb5022ecc1ce4b38f4ad8b40dc655 100644 GIT binary patch delta 11557 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 Xw=d}k|AiWH2NM$!AyMLg9O(Z556|qW From 78bd46f8158dbfea6e8f9118a63e82bc8400473b Mon Sep 17 00:00:00 2001 From: sliptonic Date: Wed, 10 Sep 2025 11:05:55 -0500 Subject: [PATCH 3/6] fixes fix duplicate toolbits add tools to 'all tools' context menus and deletion /CamAssets/Tool/ directory structure Assets and preferences --- .../Gui/Resources/panels/LibraryProperties.ui | 50 +------- .../CAM/Gui/Resources/preferences/PathJob.ui | 115 ++++-------------- src/Mod/CAM/Path/Main/Gui/PreferencesJob.py | 16 +-- src/Mod/CAM/Path/Preferences.py | 36 ++++-- src/Mod/CAM/Path/Tool/assets/manager.py | 100 +++++++++++++++ src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 37 +++++- src/Mod/CAM/Path/Tool/camassets.py | 22 +++- .../CAM/Path/Tool/library/serializers/fctl.py | 79 +++++++++++- src/Mod/CAM/Path/Tool/library/ui/browser.py | 69 ++++++++--- src/Mod/CAM/Path/Tool/library/ui/editor.py | 79 ++++++++---- .../CAM/Path/Tool/library/ui/properties.py | 24 +++- .../CAM/Path/Tool/toolbit/serializers/fctb.py | 18 +-- .../CAM/Path/Tool/toolbit/serializers/yaml.py | 17 ++- src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 62 +++++++++- 14 files changed, 482 insertions(+), 242 deletions(-) diff --git a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui index ff76a97264..b68741d6ee 100644 --- a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui +++ b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui @@ -54,7 +54,7 @@ - + QLayout::SetMinimumSize @@ -64,18 +64,7 @@ Qt::Horizontal - QDialogButtonBox::Cancel - - - - - - - Edit Library - - - - ../resources/icons/add-library.svg../resources/icons/add-library.svg + QDialogButtonBox::Cancel|QDialogButtonBox::Ok @@ -84,38 +73,5 @@
- - - buttonBox - accepted() - Dialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - Dialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - + diff --git a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui index 30472ceabe..c53822ac8b 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 + + + + … @@ -129,7 +106,7 @@ If left empty no template will be preselected. - Qt::Vertical + Qt::Orientation::Vertical @@ -146,8 +123,8 @@ If left empty no template will be preselected. 0 0 - 681 - 518 + 695 + 480 @@ -167,7 +144,7 @@ If left empty no template will be preselected. - QFormLayout::AllNonFixedFieldsGrow + QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow @@ -280,7 +257,7 @@ See the file save policy below on how to deal with name conflicts. - QFormLayout::AllNonFixedFieldsGrow + QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow @@ -345,7 +322,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Vertical + Qt::Orientation::Vertical @@ -362,8 +339,8 @@ See the file save policy below on how to deal with name conflicts. 0 0 - 662 - 755 + 674 + 619 @@ -410,7 +387,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Horizontal + Qt::Orientation::Horizontal @@ -537,7 +514,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Horizontal + Qt::Orientation::Horizontal @@ -579,21 +556,21 @@ See the file save policy below on how to deal with name conflicts. - QAbstractSpinBox::NoButtons + QAbstractSpinBox::ButtonSymbols::NoButtons - QAbstractSpinBox::NoButtons + QAbstractSpinBox::ButtonSymbols::NoButtons - QAbstractSpinBox::NoButtons + QAbstractSpinBox::ButtonSymbols::NoButtons @@ -622,7 +599,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Vertical + Qt::Orientation::Vertical @@ -634,52 +611,12 @@ 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 - - - - - - - Qt::Vertical + Qt::Orientation::Vertical @@ -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/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 107fd76fa6..965b896c3c 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -124,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]: @@ -212,7 +228,7 @@ def defaultFilePath(): def filePath(): path = defaultFilePath() if not path: - path = macroFilePath() + path = getAssetPath() return path @@ -248,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 5a05fa9e17..072ec3a100 100644 --- a/src/Mod/CAM/Path/Tool/assets/manager.py +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -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 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, @@ -241,10 +277,23 @@ 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, @@ -252,7 +301,18 @@ class AssetManager: # For now, use the parent's store_name_for_cache. try: 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, @@ -260,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, @@ -307,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}" ) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py index 3c8505c4a6..32ba281114 100644 --- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -29,6 +29,7 @@ from .util import ( make_export_filters, get_serializer_from_extension, ) +import Path.Preferences as Preferences class AssetOpenDialog(QFileDialog): @@ -40,7 +41,11 @@ class AssetOpenDialog(QFileDialog): parent=None, ): super().__init__(parent) - self.setDirectory(pathlib.Path.home().as_posix()) + + # 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) @@ -70,7 +75,7 @@ class AssetOpenDialog(QFileDialog): 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): + if not self.asset_manager.exists(dependency_uri, store=["local", "builtin"]): QMessageBox.critical( self, "Error", @@ -101,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__( @@ -110,7 +133,10 @@ class AssetSaveDialog(QFileDialog): parent=None, ): super().__init__(parent) - self.setDirectory(pathlib.Path.home().as_posix()) + + # 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) @@ -145,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/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/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index 8b6737bddb..275bfea0fe 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -35,6 +35,9 @@ class FCTLSerializer(AssetSerializer): extensions = (".fctl",) mime_type = "application/x-freecad-toolbit-library" + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) + @classmethod def get_label(cls) -> str: return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library") @@ -66,6 +69,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 +107,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/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py index ff1f5f15ce..13d5019eb2 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -284,6 +284,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): 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) @@ -310,13 +311,17 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): context_menu.addSeparator() - action = context_menu.addAction( - "Remove from Library", self._on_remove_from_library_requested - ) - action.setShortcut(QtGui.QKeySequence.Delete) + # 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) - action = context_menu.addAction("Delete from disk", self._on_delete_requested) - action.setShortcut(QtGui.QKeySequence("Shift+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)) @@ -443,14 +448,29 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): toolbit_data_bytes = toolbit_yaml_str.encode("utf-8") toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None) - # Assign a new tool id and a label - toolbit.set_id() - self._asset_manager.add(toolbit) # Save the new toolbit to disk - # Add the bit to the current library - added_toolbit = current_library.add_bit(toolbit) + # 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(toolbit.get_uri())) + new_uris.add(str(existing_toolbit.get_uri())) if new_uris: self._asset_manager.add(current_library) # Save the modified library @@ -485,16 +505,25 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): toolbit_data_bytes = toolbit_yaml_str.encode("utf-8") toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None) - source_library.remove_bit(toolbit) - # Remove it from the old library, add it to the new library - source_library.remove_bit(toolbit) - added_toolbit = current_library.add_bit(toolbit) - if added_toolbit: - new_uris.add(str(toolbit.get_uri())) + # 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}") - # The toolbit itself does not change, so we don't need to save it. - # It is only the reference in the library that changes. + 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 diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py index bcfd175203..2572a65bc2 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -350,7 +350,9 @@ class LibraryEditor(QWidget): self.form.renameLibraryButton.setEnabled(library_selected) self.form.exportLibraryButton.setEnabled(library_selected) self.form.importLibraryButton.setEnabled(True) - self.form.addToolBitButton.setEnabled(library_selected) + self.form.addToolBitButton.setEnabled( + True + ) # Always enabled - can create standalone toolbits # TODO: self.form.exportToolBitButton.setEnabled(toolbit_selected) def _save_library(self): @@ -475,17 +477,9 @@ class LibraryEditor(QWidget): self._update_button_states() def _on_add_toolbit_requested(self): - """Handles request to add a new toolbit to the current library.""" + """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() - if not current_library: - Path.Log.warning("Cannot add toolbit: No library selected.") - QMessageBox.warning( - self, - FreeCAD.Qt.translate("CAM", "Warning"), - FreeCAD.Qt.translate("CAM", "Please select a library first."), - ) - return # Select the shape for the new toolbit selector = ShapeSelector() @@ -508,15 +502,19 @@ class LibraryEditor(QWidget): tool_asset_uri = cam_assets.add(new_toolbit) Path.Log.debug(f"_on_add_toolbit_requested: Saved tool with URI: {tool_asset_uri}") - # Add the toolbit to the 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) + # 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}") @@ -552,17 +550,50 @@ class LibraryEditor(QWidget): return file_path, toolbit = cast(Tuple[pathlib.Path, ToolBit], response) - # Add the imported toolbit to the current library - added_toolbit = current_library.add_bit(toolbit) + # Debug logging for imported toolbit + Path.Log.info( + f"IMPORT TOOLBIT: file_path={file_path}, toolbit.id={toolbit.id}, toolbit.label={toolbit.label}" + ) + import traceback + + stack = traceback.format_stack() + caller_info = "".join(stack[-3:-1]) + Path.Log.info(f"IMPORT TOOLBIT CALLER:\n{caller_info}") + + # 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: + 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 + + # 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: - cam_assets.add(toolbit) # Save the imported toolbit to disk + 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(toolbit.get_uri())]) + 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"Failed to import toolbit from {file_path} to library {current_library.label}." + f"IMPORT FAILED: Failed to import toolbit from {file_path} to library {current_library.label}." ) QMessageBox.warning( self, diff --git a/src/Mod/CAM/Path/Tool/library/ui/properties.py b/src/Mod/CAM/Path/Tool/library/ui/properties.py index 6bab65b4fa..20d1dc4f59 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/properties.py +++ b/src/Mod/CAM/Path/Tool/library/ui/properties.py @@ -42,20 +42,32 @@ class LibraryPropertyDialog(QtWidgets.QDialog): self.form.lineEditLibraryName.setText(self.library.label) self.update_window_title() - if new: - label = FreeCAD.Qt.translate("CAM", "Create Library") - self.form.pushButtonSave.setText(label) - - self.form.buttonBox.accepted.connect(self.accept) + self.form.buttonBox.accepted.connect(self.save_properties) self.form.buttonBox.rejected.connect(self.reject) - self.form.pushButtonSave.clicked.connect(self.save_properties) # Connect text changed signal to update window title self.form.lineEditLibraryName.textChanged.connect(self.update_window_title) + # 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() diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py index 0722d770a2..ee1efa6959 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py @@ -28,7 +28,7 @@ from ...shape import ToolBitShape from ..models.base import ToolBit -if False: +if True: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: @@ -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 index 2fbbdcef0f..95d679dc4c 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py @@ -20,7 +20,7 @@ # * * # *************************************************************************** import yaml -from typing import List, Optional, Mapping, Type +from typing import List, Optional, Mapping, Type, cast from ...assets.serializer import AssetSerializer from ...assets.uri import AssetUri from ...shape import ToolBitShape @@ -81,8 +81,13 @@ class YamlToolBitSerializer(AssetSerializer): @classmethod def deep_deserialize(cls, data: bytes) -> ToolBit: - """ - Like deserialize(), but builds dependencies itself if they are - sufficiently defined in the data. - """ - raise NotImplementedError + """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/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 6273f4cb81..4a8f756b00 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -395,7 +395,7 @@ class ToolBitBrowserWidget(QtGui.QWidget): self._to_clipboard(uris, mode="copy") def _on_delete_requested(self): - """Deletes selected toolbits.""" + """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: @@ -406,7 +406,10 @@ class ToolBitBrowserWidget(QtGui.QWidget): 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)?"), + 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, ) @@ -415,19 +418,66 @@ class ToolBitBrowserWidget(QtGui.QWidget): return deleted_count = 0 + libraries_modified = [] # Use list instead of set since Library objects aren't hashable + for uri_string in uris: try: - # Delete the toolbit using the asset manager - self._asset_manager.delete(AssetUri(uri_string)) + 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}") - # Optionally show a message box to the user + + # 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).") + 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]: """ Returns a list of URIs for the currently selected ToolBit items. From db5117e1ae00aaf29fd68691f5531c90e60bb603 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Fri, 12 Sep 2025 12:32:25 -0500 Subject: [PATCH 4/6] Update src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Update src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> --- .../Gui/Resources/panels/LibraryProperties.ui | 2 +- .../Gui/Resources/panels/ToolBitLibraryEdit.ui | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui index b68741d6ee..d520c74738 100644 --- a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui +++ b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui @@ -31,7 +31,7 @@ - Name: + Name diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui index 4b9177c369..e353773e8a 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui @@ -24,7 +24,7 @@ - Add New Library + Adds a new library @@ -35,7 +35,7 @@ - Remove Library + Removes the library @@ -46,7 +46,7 @@ - Rename Library + Renames the library @@ -57,7 +57,7 @@ - Import Library + Imports a library @@ -68,7 +68,7 @@ - Export Library + Exports the library @@ -108,7 +108,7 @@ - Add Toolbit + Adds a toolbit @@ -119,7 +119,7 @@ - Import Toolbit + Imports a toolbit @@ -130,7 +130,7 @@ - Export Toolbit + Exports the toolbit @@ -200,7 +200,7 @@ true - Table of Tool Bits of the library. + Table of tool bits of the library QFrame::Box From 4ae36283d0d08660cc07d84721fe5243027bda89 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Fri, 12 Sep 2025 12:42:42 -0500 Subject: [PATCH 5/6] Ensure Qt5 compatibility with enums Correct LGPL headers rework bullnose Fixes #19050 --- src/Mod/CAM/CAMTests/TestPathToolBit.py | 2 +- .../CAM/CAMTests/TestPathToolShapeClasses.py | 10 +++--- .../Resources/panels/PageOpAdaptiveEdit.ui | 12 +++---- .../CAM/Gui/Resources/preferences/PathJob.ui | 22 ++++++------- src/Mod/CAM/Path/Base/PropertyBag.py | 1 + src/Mod/CAM/Path/Tool/assets/manager.py | 2 +- .../CAM/Path/Tool/library/serializers/fctl.py | 3 -- .../CAM/Path/Tool/library/ui/properties.py | 30 +++++++++-------- .../CAM/Path/Tool/shape/models/bullnose.py | 6 ++-- .../CAM/Path/Tool/toolbit/models/bullnose.py | 5 +-- .../CAM/Path/Tool/toolbit/serializers/fctb.py | 2 +- .../CAM/Path/Tool/toolbit/serializers/yaml.py | 31 ++++++++++-------- src/Mod/CAM/Tools/Bit/6mm_Bullnose.fctb | 2 +- src/Mod/CAM/Tools/Shape/bullnose.fcstd | Bin 15503 -> 16188 bytes 14 files changed, 67 insertions(+), 61 deletions(-) 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/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/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/preferences/PathJob.ui b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui index c53822ac8b..73482d95e4 100644 --- a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui +++ b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui @@ -106,7 +106,7 @@ If left empty no template will be preselected. - Qt::Orientation::Vertical + Qt::Vertical @@ -144,7 +144,7 @@ If left empty no template will be preselected. - QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow + QFormLayout::AllNonFixedFieldsGrow @@ -257,7 +257,7 @@ See the file save policy below on how to deal with name conflicts. - QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow + QFormLayout::AllNonFixedFieldsGrow @@ -322,7 +322,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Orientation::Vertical + Qt::Vertical @@ -387,7 +387,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Orientation::Horizontal + Qt::Horizontal @@ -514,7 +514,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Orientation::Horizontal + Qt::Horizontal @@ -556,21 +556,21 @@ See the file save policy below on how to deal with name conflicts. - QAbstractSpinBox::ButtonSymbols::NoButtons + QAbstractSpinBox::NoButtons - QAbstractSpinBox::ButtonSymbols::NoButtons + QAbstractSpinBox::NoButtons - QAbstractSpinBox::ButtonSymbols::NoButtons + QAbstractSpinBox::NoButtons @@ -599,7 +599,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Orientation::Vertical + Qt::Vertical @@ -616,7 +616,7 @@ See the file save policy below on how to deal with name conflicts. - Qt::Orientation::Vertical + Qt::Vertical diff --git a/src/Mod/CAM/Path/Base/PropertyBag.py b/src/Mod/CAM/Path/Base/PropertyBag.py index 2b0fd946ab..7cfa299120 100644 --- a/src/Mod/CAM/Path/Base/PropertyBag.py +++ b/src/Mod/CAM/Path/Base/PropertyBag.py @@ -105,6 +105,7 @@ class PropertyBag(object): 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", diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py index 072ec3a100..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 diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py index 275bfea0fe..f8fe8555fc 100644 --- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -35,9 +35,6 @@ class FCTLSerializer(AssetSerializer): extensions = (".fctl",) mime_type = "application/x-freecad-toolbit-library" - Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) - Path.Log.trackModule(Path.Log.thisModule()) - @classmethod def get_label(cls) -> str: return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library") diff --git a/src/Mod/CAM/Path/Tool/library/ui/properties.py b/src/Mod/CAM/Path/Tool/library/ui/properties.py index 20d1dc4f59..42202eea43 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/properties.py +++ b/src/Mod/CAM/Path/Tool/library/ui/properties.py @@ -1,24 +1,28 @@ # -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** +# * * # * 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 file is part of FreeCAD. * # * * -# * 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. * +# * 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. * # * * -# * 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 * +# * 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 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/toolbit/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py index e86c180965..3033c02ebf 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -39,9 +39,10 @@ class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) - flat_radius = self.get_property_str("FlatRadius", "?", 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/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py index ee1efa6959..498a0b31ce 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py @@ -28,7 +28,7 @@ from ...shape import ToolBitShape from ..models.base import ToolBit -if True: +if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py index 95d679dc4c..85f062b9ba 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py @@ -1,26 +1,29 @@ # -*- coding: utf-8 -*- +# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** +# * * # * 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 file is part of FreeCAD. * # * * -# * 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. * +# * 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. * # * * -# * 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 * +# * 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, cast +from typing import List, Optional, Mapping, Type from ...assets.serializer import AssetSerializer from ...assets.uri import AssetUri from ...shape import ToolBitShape 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 121fa6339757fc68540f06b99db859324b6ab469..c2a7053993578f495b7aa0f660db75704a4fea62 100644 GIT binary patch literal 16188 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 Date: Fri, 12 Sep 2025 17:45:36 -0400 Subject: [PATCH 6/6] CAM: Consistently rename "Tool" to "Toolbit" in UI and code - Updated all user-facing strings from "Tool" to "Toolbit" for clarity and consistency. - Changed combo box filtering logic to use index for "All Toolbit Types" (localization-safe). - Improved visual distinction for "All Toolbits" in library editor (bold/italic). - Reduced default SVG icon size in ShapeWidget for a more compact display. - Updated window titles, labels, tooltips, and placeholder texts to use "Toolbit". - Removed obsolete string comparisons in filtering logic. - Change the Menu item from "Toolbit Library Editor" to "Toolbit Library Manager" --- src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui | 2 +- src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui | 4 ++-- src/Mod/CAM/Path/Tool/library/ui/browser.py | 5 ++--- src/Mod/CAM/Path/Tool/library/ui/cmd.py | 2 +- src/Mod/CAM/Path/Tool/library/ui/dock.py | 2 +- src/Mod/CAM/Path/Tool/library/ui/editor.py | 13 +++++++++---- src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py | 2 +- src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py | 2 +- src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 4 ++-- src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py | 2 +- src/Mod/CAM/Path/Tool/toolbit/ui/editor.py | 2 +- src/Mod/CAM/Path/Tool/toolbit/ui/selector.py | 2 +- 12 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui index 86103f5d06..741e952ec4 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui @@ -11,7 +11,7 @@ - Tool Shape Selection + Toolbit Shape Selection 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/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py index 13d5019eb2..15fc49f403 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -204,7 +204,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): def _get_filtered_assets(self): """Get assets filtered by tool type if a specific type is selected.""" - if not self._selected_tool_type or self._selected_tool_type == "All Tool Types": + if self._tool_type_combo.currentIndex() == 0: # "All Toolbit Types" return self._all_assets filtered_assets = [] @@ -575,7 +575,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): self._tool_type_combo.blockSignals(True) try: self._tool_type_combo.clear() - self._tool_type_combo.addItem("All Tool Types") + 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) @@ -586,7 +586,6 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): self._tool_type_combo.setCurrentIndex(index) else: self._tool_type_combo.setCurrentIndex(0) - self._selected_tool_type = "All Tool Types" finally: self._tool_type_combo.blockSignals(False) diff --git a/src/Mod/CAM/Path/Tool/library/ui/cmd.py b/src/Mod/CAM/Path/Tool/library/ui/cmd.py index c845af7846..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" ), diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py index ff92275ed2..6e9a930250 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/dock.py +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -56,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() diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py index 2572a65bc2..0bb1f7cf55 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -155,7 +155,7 @@ class LibraryEditor(QWidget): self._clear_highlight() return True - # Prevent drop into "All Tools" + # Prevent drop into "All Toolbits" item = self.listModel.itemFromIndex(index) if not item or item.data(_LibraryRole) == "all_tools": self._clear_highlight() @@ -286,10 +286,15 @@ class LibraryEditor(QWidget): Path.Log.track() self.listModel.clear() - # Add "All Tools" item - all_tools_item = QStandardItem(translate("CAM", "All Tools")) + # 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")) + # 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) 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/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py index 3033c02ebf..aa7c7bbdbf 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -39,7 +39,7 @@ class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): diameter = self.get_property_str("Diameter", "?", precision=3) flutes = self.get_property("Flutes") cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3) - #flat_radius = self.get_property_str("FlatRadius", "?", precision=3) + # flat_radius = self.get_property_str("FlatRadius", "?", precision=3) corner_radius = self.get_property_str("CornerRadius", "?", precision=3) return FreeCAD.Qt.translate( diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 4a8f756b00..e6f6991c6a 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -79,12 +79,12 @@ class ToolBitBrowserWidget(QtGui.QWidget): # 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 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 26c60a10f8..9353ff373d 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -68,7 +68,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._shape_widget = None # Will be created in load_toolbit # Layout - toolbit_group_box = QtGui.QGroupBox(translate("CAM", "Tool Bit")) + toolbit_group_box = QtGui.QGroupBox(translate("CAM", "Toolbit")) form_layout = QtGui.QFormLayout(toolbit_group_box) form_layout.addRow(translate("CAM", "Label:"), self._label_edit) form_layout.addRow(translate("CAM", "ID:"), self._id_label) 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)