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
This commit is contained in:
Samuel Abels
2025-05-21 16:23:33 +02:00
committed by sliptonic
parent f2643925b6
commit 6150eac59f
49 changed files with 4963 additions and 1182 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -62,6 +62,8 @@
<file>icons/CAM_ToolDuplicate.svg</file>
<file>icons/CAM_Toolpath.svg</file>
<file>icons/CAM_ToolTable.svg</file>
<file>icons/CAM_ToolTableAdd.svg</file>
<file>icons/CAM_ToolTableRemove.svg</file>
<file>icons/CAM_Vcarve.svg</file>
<file>icons/CAM_Waterline.svg</file>
<file>icons/arrow-ccw.svg</file>
@@ -93,6 +95,7 @@
<file>panels/DragKnifeEdit.ui</file>
<file>panels/DressUpLeadInOutEdit.ui</file>
<file>panels/HoldingTagsEdit.ui</file>
<file>panels/LibraryProperties.ui</file>
<file>panels/PageBaseGeometryEdit.ui</file>
<file>panels/PageBaseHoleGeometryEdit.ui</file>
<file>panels/PageBaseLocationEdit.ui</file>

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>LibraryProperties</class>
<widget class="QWidget" name="LibraryProperties">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>189</height>
</rect>
</property>
<property name="windowTitle">
<string>Library Property Editor</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,1,0">
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<layout class="QGridLayout" name="gridLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMaximumSize</enum>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="labelLibraryName">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="lineEditLibraryName"/>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="1,0">
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButtonSave">
<property name="text">
<string>Edit Library</string>
</property>
<property name="icon">
<iconset>
<normaloff>../resources/icons/add-library.svg</normaloff>../resources/icons/add-library.svg</iconset>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -13,275 +13,234 @@
<property name="windowTitle">
<string>Library Manager</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="0,1">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<layout class="QHBoxLayout" name="button_bar" stretch="0,0,0,1">
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<layout class="QHBoxLayout" name="library_buttons">
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
<item>
<widget class="QPushButton" name="addLibraryButton">
<property name="toolTip">
<string>Add New Library</string>
</property>
<property name="icon">
<iconset resource="../Path.qrc">
<normaloff>:/icons/CAM_ToolTableAdd.svg</normaloff>:/icons/CAM_ToolTableAdd.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeLibraryButton">
<property name="toolTip">
<string>Remove Library</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/CAM_ToolTableRemove.svg</normaloff>:/icons/CAM_ToolTableRemove.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="renameLibraryButton">
<property name="toolTip">
<string>Rename Library</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/edit-edit.svg</normaloff>:/icons/edit-edit.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="importLibraryButton">
<property name="toolTip">
<string>Import Library</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/Std_Import.svg</normaloff>:/icons/Std_Import.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="exportLibraryButton">
<property name="toolTip">
<string>Export Library</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/Std_Export.svg</normaloff>:/icons/Std_Export.svg</iconset>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="toolCreate">
<property name="text">
<string>Create Toolbit</string>
</property>
<property name="icon">
<iconset resource="../Path.qrc">
<normaloff>:/icons/CAM_ToolBit.svg</normaloff>:/icons/CAM_ToolBit.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="toolAdd">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Adds the existing tool bit to the library</string>
</property>
<property name="text">
<string>Add Existing</string>
</property>
<property name="icon">
<iconset resource="../Path.qrc">
<normaloff>:/icons/CAM_ToolDuplicate.svg</normaloff>:/icons/CAM_ToolDuplicate.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="toolDelete">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Deletes the selected tool bits from the library</string>
</property>
<property name="text">
<string>Remove</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/list-remove.svg</normaloff>:/icons/list-remove.svg</iconset>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QWidget" name="toolTableGroup">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="addLibrary">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Add new tool table</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/document-new.svg</normaloff>:/icons/document-new.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
</widget>
</item>
<!-- The libraryOpen button item was here -->
<item>
<widget class="QPushButton" name="exportLibrary">
<property name="toolTip">
<string>Save the selected library with a new name or export to another format</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/Std_Export.svg</normaloff>:/icons/Std_Export.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="saveLibrary">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Save the current library</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/document-save.svg</normaloff>:/icons/document-save.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QTreeView" name="TableList">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::Box</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QTableView" name="toolTable">
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="toolTip">
<string>Table of tool bits of the library</string>
</property>
<property name="frameShape">
<enum>QFrame::Box</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Sunken</enum>
</property>
<property name="lineWidth">
<number>1</number>
</property>
<property name="midLineWidth">
<number>0</number>
</property>
<property name="dragEnabled">
<bool>false</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragOnly</enum>
</property>
<property name="defaultDropAction">
<enum>Qt::IgnoreAction</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<attribute name="verticalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_8">
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Preferred</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>65555555</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="okButton">
<widget class="QFrame" name="line">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<property name="minimumSize">
<size>
<width>16777215</width>
<height>16777215</height>
<width>2</width>
<height>0</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::VLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Sunken</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="tool_buttons">
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item>
<widget class="QPushButton" name="addToolBitButton">
<property name="toolTip">
<string>Add Toolbit</string>
</property>
<property name="icon">
<iconset resource="../Path.qrc">
<normaloff>:/icons/CAM_ToolBit.svg</normaloff>:/icons/CAM_ToolBit.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="importToolBitButton">
<property name="toolTip">
<string>Import Toolbit</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/Std_Import.svg</normaloff>:/icons/Std_Import.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="exportToolBitButton">
<property name="toolTip">
<string>Export Toolbit</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/Std_Export.svg</normaloff>:/icons/Std_Export.svg</iconset>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Expanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QTreeView" name="TableList">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="mouseTracking">
<bool>true</bool>
</property>
<property name="focusPolicy">
<enum>Qt::StrongFocus</enum>
</property>
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="frameShape">
<enum>QFrame::Box</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>true</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DropOnly</enum>
</property>
<property name="defaultDropAction">
<enum>Qt::IgnoreAction</enum>
</property>
</widget>
</item>
<item>
<widget class="QTableView" name="toolTable">
<property name="acceptDrops">
<bool>true</bool>
</property>
<property name="toolTip">
<string>Close the library editor</string>
<string>Table of Tool Bits of the library.</string>
</property>
<property name="text">
<string>Close</string>
<property name="frameShape">
<enum>QFrame::Box</enum>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/edit_OK.svg</normaloff>:/icons/edit_OK.svg</iconset>
<property name="frameShadow">
<enum>QFrame::Sunken</enum>
</property>
<property name="lineWidth">
<number>1</number>
</property>
<property name="midLineWidth">
<number>0</number>
</property>
<property name="dragEnabled">
<bool>false</bool>
</property>
<property name="dragDropOverwriteMode">
<bool>false</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragOnly</enum>
</property>
<property name="defaultDropAction">
<enum>Qt::IgnoreAction</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
<attribute name="verticalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
</widget>
</item>
</layout>

View File

@@ -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():

View File

@@ -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().

View File

@@ -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

View File

@@ -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)

View File

@@ -120,6 +120,7 @@ class AssetPreferencesPage:
)
return False
Path.Preferences.setAssetPath(asset_path)
Path.Preferences.setLastToolLibrary("")
return True
def loadSettings(self):

View File

@@ -0,0 +1,5 @@
from .library import Library
__all__ = [
"Library",
]

View File

@@ -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))

View File

@@ -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()

View File

@@ -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",
]

View File

@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# flake8: noqa E731
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
@@ -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)

View File

@@ -86,7 +86,7 @@ class CommandLibraryEditorOpen:
return True
def Activated(self):
library = LibraryEditor()
library = LibraryEditor(parent=FreeCADGui.getMainWindow())
library.open()

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * 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()

View File

@@ -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)")

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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"

View File

@@ -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(

View File

@@ -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")

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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")

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * 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

View File

@@ -1,6 +1,8 @@
from .editor import ToolBitEditorPanel, ToolBitEditor
from .browser import ToolBitBrowserWidget
__all__ = [
"ToolBitBrowserWidget",
"ToolBitEditor",
"ToolBitEditorPanel",
]

View File

@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# flake8: noqa E731
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
@@ -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]:
"""

View File

@@ -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_()

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * 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)]

View File

@@ -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)

View File

@@ -81,6 +81,7 @@ from CAMTests.TestPathToolShapeIcon import (
from CAMTests.TestPathToolBitSerializer import (
TestCamoticsToolBitSerializer,
TestFCTBSerializer,
TestYamlToolBitSerializer,
)
from CAMTests.TestPathToolLibrary import TestPathToolLibrary
from CAMTests.TestPathToolLibrarySerializer import (