Merge pull request #23856 from Connor9220/library-editor

CAM: Replace the main library editor dialog and add copy & paste & drag & drop support
This commit is contained in:
sliptonic
2025-09-15 10:37:19 -05:00
committed by GitHub
81 changed files with 5807 additions and 1464 deletions

View File

@@ -25,6 +25,20 @@ import Path.Base.PropertyBag as PathPropertyBag
import CAMTests.PathTestUtils as PathTestUtils
def as_group_list(groups):
"""Normalize CustomPropertyGroups to a list of strings."""
if groups is None:
return []
if isinstance(groups, (list, tuple)):
return list(groups)
if isinstance(groups, str):
return [groups]
try:
return list(groups)
except Exception:
return [str(groups)]
class TestPathPropertyBag(PathTestUtils.PathTestBase):
def setUp(self):
self.doc = FreeCAD.newDocument("test-property-bag")
@@ -37,7 +51,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag = PathPropertyBag.Create()
self.assertTrue(hasattr(bag, "Proxy"))
self.assertEqual(bag.Proxy.getCustomProperties(), [])
self.assertEqual(bag.CustomPropertyGroups, [])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), [])
def test01(self):
"""adding properties to a PropertyBag is tracked properly"""
@@ -48,7 +62,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag.Title = "Madame"
self.assertEqual(bag.Title, "Madame")
self.assertEqual(bag.Proxy.getCustomProperties(), ["Title"])
self.assertEqual(bag.CustomPropertyGroups, ["Address"])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), ["Address"])
def test02(self):
"""refreshCustomPropertyGroups deletes empty groups"""
@@ -59,7 +73,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag.removeProperty("Title")
proxy.refreshCustomPropertyGroups()
self.assertEqual(bag.Proxy.getCustomProperties(), [])
self.assertEqual(bag.CustomPropertyGroups, [])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), [])
def test03(self):
"""refreshCustomPropertyGroups does not delete non-empty groups"""
@@ -72,4 +86,4 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag.removeProperty("Gender")
proxy.refreshCustomPropertyGroups()
self.assertEqual(bag.Proxy.getCustomProperties(), ["Title"])
self.assertEqual(bag.CustomPropertyGroups, ["Address"])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), ["Address"])

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,7 +62,7 @@ class TestPathToolBit(PathTestWithAssets):
# Parameters should be loaded from the shape file and set on the tool bit's object
self.assertEqual(bullnose_bit.obj.Diameter, FreeCAD.Units.Quantity("5.0 mm"))
self.assertEqual(bullnose_bit.obj.FlatRadius, FreeCAD.Units.Quantity("1.5 mm"))
self.assertEqual(bullnose_bit.obj.CornerRadius, FreeCAD.Units.Quantity("1.5 mm"))
def testToolBitPickle(self):
"""Test if ToolBit is picklable"""

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))
@@ -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.00 mm")
def test_extract_dependencies(self):
"""Test dependency extraction for YAML."""
yaml_data = (
b"name: Test Tool\n"
b"shape: endmill\n"
b"shape-type: Endmill\n"
b"parameter:\n"
b" Diameter: 4.12 mm\n"
b" Length: 15.0 mm\n"
b"attribute: {}\n"
)
dependencies = self.serializer_class.extract_dependencies(yaml_data)
self.assertIsInstance(dependencies, list)
self.assertEqual(len(dependencies), 1)
self.assertEqual(dependencies[0], AssetUri.build("toolbitshape", "endmill"))
def test_deserialize(self):
# Create a known serialized data string based on the YAML format
yaml_data = (
b"id: TestID\n"
b"name: Test Tool\n"
b"shape: endmill\n"
b"shape-type: Endmill\n"
b"parameter:\n"
b" Diameter: 4.12 mm\n"
b" Length: 15.0 mm\n"
b"attribute: {}\n"
)
# Create a ToolBitShapeEndmill instance for 'endmill'
shape = ToolBitShapeEndmill("endmill")
# Create the dependencies dictionary with the shape instance
dependencies: Mapping[AssetUri, Asset] = {AssetUri.build("toolbitshape", "endmill"): shape}
# Provide dummy id and dependencies for deserialization test
deserialized_bit = cast(
ToolBitEndmill,
self.serializer_class.deserialize(yaml_data, "TestID", dependencies=dependencies),
)
self.assertIsInstance(deserialized_bit, ToolBit)
self.assertEqual(deserialized_bit.id, "TestID")
self.assertEqual(deserialized_bit.label, "Test Tool")
self.assertEqual(deserialized_bit.get_shape_name(), "Endmill")
self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm")
self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm")
# Test with ID argument.
deserialized_bit = cast(
ToolBitEndmill,
self.serializer_class.deserialize(yaml_data, id="test_id", dependencies=dependencies),
)
self.assertEqual(deserialized_bit.id, "test_id")

View File

@@ -144,13 +144,13 @@ class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase):
# Verify the content format (basic check)
lines = serialized_data.decode("ascii", "ignore").strip().split("\n")
self.assertEqual(len(lines), 3)
self.assertEqual(lines[0], "T1 P0 D6.000 ;Endmill 6mm")
self.assertEqual(lines[1], "T2 P0 D3.000 ;Endmill 3mm")
self.assertEqual(lines[2], "T3 P0 D5.000 ;Ballend 5mm")
self.assertEqual(lines[0], "T1 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D6.00 I0 J0 Q0 ;Endmill 6mm")
self.assertEqual(lines[1], "T2 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D3.00 I0 J0 Q0 ;Endmill 3mm")
self.assertEqual(lines[2], "T3 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D5.00 I0 J0 Q0 ;Ballend 5mm")
def test_linuxcnc_deserialize_not_implemented(self):
serializer = LinuxCNCSerializer
dummy_data = b"T1 D6.0 ;Endmill 6mm\n"
dummy_data = b"T1 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D6.00 I0 J0 Q0 ;Endmill 6mm\n"
with self.assertRaises(NotImplementedError):
serializer.deserialize(dummy_data, "dummy_id", {})

View File

@@ -153,8 +153,8 @@ class TestPathToolShapeClasses(PathTestWithAssets):
self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "v-bit")
self.assertEqual(ToolBitShape.resolve_name("vbit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "torus")
self.assertEqual(ToolBitShape.resolve_name("torus.fcstd").asset_id, "torus")
self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("bullnose.fcstd").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("SlittingSaw").asset_id, "SlittingSaw")
# Test unknown name - should return the input name
self.assertEqual(ToolBitShape.resolve_name("nonexistent").asset_id, "nonexistent")
@@ -336,12 +336,12 @@ class TestPathToolShapeClasses(PathTestWithAssets):
shape = self._test_shape_common("bullnose")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["FlatRadius"].Value, 1.5)
self.assertEqual(unit(shape["FlatRadius"]), "mm")
self.assertEqual(shape["CornerRadius"].Value, 1.5)
self.assertEqual(unit(shape["CornerRadius"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("bullnose")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("FlatRadius"), "Torus radius")
self.assertEqual(instance.get_parameter_label("CornerRadius"), "Corner radius")
def test_toolbitshapevbit_defaults(self):
"""Test ToolBitShapeVBit default parameters and labels."""

View File

@@ -183,7 +183,7 @@ SET(PathPythonToolsToolBitModels_SRCS
Path/Tool/toolbit/models/dovetail.py
Path/Tool/toolbit/models/drill.py
Path/Tool/toolbit/models/endmill.py
Path/Tool/toolbit/models/fillet.py
Path/Tool/toolbit/models/radius.py
Path/Tool/toolbit/models/probe.py
Path/Tool/toolbit/models/reamer.py
Path/Tool/toolbit/models/slittingsaw.py
@@ -196,6 +196,7 @@ SET(PathPythonToolsToolBitSerializers_SRCS
Path/Tool/toolbit/serializers/__init__.py
Path/Tool/toolbit/serializers/camotics.py
Path/Tool/toolbit/serializers/fctb.py
Path/Tool/toolbit/serializers/yaml.py
)
SET(PathPythonToolsToolBitUi_SRCS
@@ -208,6 +209,7 @@ SET(PathPythonToolsToolBitUi_SRCS
Path/Tool/toolbit/ui/selector.py
Path/Tool/toolbit/ui/tablecell.py
Path/Tool/toolbit/ui/toollist.py
Path/Tool/toolbit/ui/util.py
Path/Tool/toolbit/ui/view.py
)
@@ -230,10 +232,11 @@ SET(PathPythonToolsLibrarySerializers_SRCS
SET(PathPythonToolsLibraryUi_SRCS
Path/Tool/library/ui/__init__.py
Path/Tool/library/ui/browser.py
Path/Tool/library/ui/cmd.py
Path/Tool/library/ui/dock.py
Path/Tool/library/ui/editor.py
Path/Tool/library/ui/browser.py
Path/Tool/library/ui/properties.py
)
SET(PathPythonToolsMachine_SRCS
@@ -261,7 +264,7 @@ SET(PathPythonToolsShapeModels_SRCS
Path/Tool/shape/models/dovetail.py
Path/Tool/shape/models/drill.py
Path/Tool/shape/models/endmill.py
Path/Tool/shape/models/fillet.py
Path/Tool/shape/models/radius.py
Path/Tool/shape/models/icon.py
Path/Tool/shape/models/probe.py
Path/Tool/shape/models/reamer.py
@@ -452,8 +455,8 @@ SET(Tools_Shape_SRCS
Tools/Shape/drill.svg
Tools/Shape/endmill.fcstd
Tools/Shape/endmill.svg
Tools/Shape/fillet.fcstd
Tools/Shape/fillet.svg
Tools/Shape/radius.fcstd
Tools/Shape/radius.svg
Tools/Shape/probe.fcstd
Tools/Shape/probe.svg
Tools/Shape/reamer.fcstd

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,77 @@
<?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">
<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|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -50,19 +50,19 @@
<item>
<widget class="QFrame" name="frame">
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
<enum>QFrame::Raised</enum>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="9" column="0" colspan="2">
<widget class="QFrame" name="frame_3">
<property name="frameShape">
<enum>QFrame::Shape::StyledPanel</enum>
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
<enum>QFrame::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
@@ -92,7 +92,7 @@ Larger values (further to the right) will calculate faster; smaller values (furt
<number>10</number>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
<enum>Qt::Horizontal</enum>
</property>
<property name="tickInterval">
<number>1</number>
@@ -318,7 +318,7 @@ This option changes that behavior to cut each discrete area to its full depth be
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>

View File

@@ -11,17 +11,17 @@
</rect>
</property>
<property name="windowTitle">
<string>Tool Shape Selection</string>
<string>Toolbit Shape Selection</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,0">
<item>
<layout class="QVBoxLayout" name="gridLayout">
<item>
<widget class="QToolBox" name="toolBox">
<property name="currentIndex">
<number>1</number>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="standardTools">
<widget class="QWidget" name="toolsContainer">
<property name="geometry">
<rect>
<x>0</x>
@@ -30,22 +30,6 @@
<height>487</height>
</rect>
</property>
<attribute name="label">
<string>Standard tools</string>
</attribute>
</widget>
<widget class="QWidget" name="customTools">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>880</width>
<height>487</height>
</rect>
</property>
<attribute name="label">
<string>My tools</string>
</attribute>
</widget>
</widget>
</item>

View File

@@ -11,7 +11,7 @@
</rect>
</property>
<property name="windowTitle">
<string>Tool Parameter Editor</string>
<string>Toolbit Parameter Editor</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
@@ -62,7 +62,7 @@
</sizepolicy>
</property>
<attribute name="title">
<string>Tool</string>
<string>Toolbit</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="0,1">
<item>

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>Adds a 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>Removes the 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>Renames the 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>Imports a 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>Exports the 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>Adds a 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>Imports a 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>Exports the 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

@@ -24,8 +24,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>370</height>
<width>695</width>
<height>308</height>
</rect>
</property>
<attribute name="label">
@@ -38,37 +38,7 @@
<string>Defaults</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Path</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="leDefaultFilePath">
<property name="toolTip">
<string>Path to look for templates, post processors, tool tables and other external files.
If left empty the macro directory is used.</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QToolButton" name="tbDefaultFilePath">
<property name="text">
<string notr="true">…</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Template</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leDefaultJobTemplate">
<property name="toolTip">
<string>The default template to be selected when creating a new job.
@@ -79,7 +49,14 @@ If left empty no template will be preselected.</string>
</property>
</widget>
</item>
<item row="1" column="3">
<item row="0" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Template</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QToolButton" name="tbDefaultJobTemplate">
<property name="text">
<string notr="true">…</string>
@@ -146,8 +123,8 @@ If left empty no template will be preselected.</string>
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>518</height>
<width>695</width>
<height>480</height>
</rect>
</property>
<attribute name="label">
@@ -362,8 +339,8 @@ See the file save policy below on how to deal with name conflicts.</string>
<rect>
<x>0</x>
<y>0</y>
<width>662</width>
<height>755</height>
<width>674</width>
<height>619</height>
</rect>
</property>
<attribute name="label">
@@ -634,46 +611,6 @@ See the file save policy below on how to deal with name conflicts.</string>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_4">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>171</height>
</rect>
</property>
<attribute name="label">
<string>Tools</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QCheckBox" name="toolsAbsolutePaths">
<property name="toolTip">
<string>References to tool bits and their shapes can either be stored with an absolute path or with a relative path to the search path.
Generally it is recommended to use relative paths due to their flexibility and robustness to layout changes.
Should multiple tools or tool shapes with the same name exist in different directories it can be required to use absolute paths.</string>
</property>
<property name="text">
<string>Store Absolute Paths</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_4">
<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>
</widget>
</widget>
</item>
<item>
@@ -700,8 +637,6 @@ Should multiple tools or tool shapes with the same name exist in different direc
</customwidget>
</customwidgets>
<tabstops>
<tabstop>leDefaultFilePath</tabstop>
<tabstop>tbDefaultFilePath</tabstop>
<tabstop>leDefaultJobTemplate</tabstop>
<tabstop>tbDefaultJobTemplate</tabstop>
<tabstop>geometryTolerance</tabstop>

View File

@@ -150,7 +150,6 @@ class PropertyCreate(object):
self.form.propertyEnum.textChanged.connect(self.updateUI)
def updateUI(self):
typeSet = True
if self.propertyIsEnumeration():
self.form.labelEnum.setEnabled(True)
@@ -239,7 +238,17 @@ class TaskPanel(object):
pass
def _setupProperty(self, i, name):
typ = PathPropertyBag.getPropertyTypeName(self.obj.getTypeIdOfProperty(name))
if name not in self.obj.PropertiesList:
Path.Log.warning(f"Property '{name}' not found in object {self.obj.Name}")
return
prop_type_id = self.obj.getTypeIdOfProperty(name)
try:
typ = PathPropertyBag.getPropertyTypeName(prop_type_id)
except IndexError:
Path.Log.error(
f"Unknown property type id '{prop_type_id}' for property '{name}' in object {self.obj.Name}"
)
return
val = PathUtil.getPropertyValueString(self.obj, name)
info = self.obj.getDocumentationOfProperty(name)

View File

@@ -68,12 +68,14 @@ class PropertyBag(object):
CustomPropertyGroupDefault = "User"
def __init__(self, obj):
obj.addProperty(
"App::PropertyStringList",
self.CustomPropertyGroups,
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"),
)
# Always add as enumeration
if not hasattr(obj, self.CustomPropertyGroups):
obj.addProperty(
"App::PropertyEnumeration",
self.CustomPropertyGroups,
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"),
)
self.onDocumentRestored(obj)
def dumps(self):
@@ -96,15 +98,40 @@ class PropertyBag(object):
def onDocumentRestored(self, obj):
self.obj = obj
obj.setEditorMode(self.CustomPropertyGroups, 2) # hide
cpg = getattr(obj, self.CustomPropertyGroups, None)
# If it's a string list, convert to enum
if isinstance(cpg, list):
vals = cpg
try:
obj.removeProperty(self.CustomPropertyGroups)
except Exception:
# Removing the property may fail if it does not exist; safe to ignore in this context.
pass
obj.addProperty(
"App::PropertyEnumeration",
self.CustomPropertyGroups,
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"),
)
if hasattr(obj, "setEnumerationsOfProperty"):
obj.setEnumerationsOfProperty(self.CustomPropertyGroups, vals)
else:
# Fallback: set the property value directly (may not work in all FreeCAD versions)
setattr(obj, self.CustomPropertyGroups, vals)
if hasattr(obj, "setEditorMode"):
obj.setEditorMode(self.CustomPropertyGroups, 2) # hide
elif hasattr(obj, "getEnumerationsOfProperty"):
if hasattr(obj, "setEditorMode"):
obj.setEditorMode(self.CustomPropertyGroups, 2) # hide
def getCustomProperties(self):
"""getCustomProperties() ... Return a list of all custom properties created in this container."""
return [
p
for p in self.obj.PropertiesList
if self.obj.getGroupOfProperty(p) in self.obj.CustomPropertyGroups
]
"""Return a list of all custom properties created in this container."""
groups = []
if hasattr(self.obj, "getEnumerationsOfProperty"):
groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups))
else:
groups = list(getattr(self.obj, self.CustomPropertyGroups, []))
return [p for p in self.obj.PropertiesList if self.obj.getGroupOfProperty(p) in groups]
def addCustomProperty(self, propertyType, name, group=None, desc=None):
"""addCustomProperty(propertyType, name, group=None, desc=None) ... adds a custom property and tracks its group."""
@@ -112,15 +139,23 @@ class PropertyBag(object):
desc = ""
if group is None:
group = self.CustomPropertyGroupDefault
groups = self.obj.CustomPropertyGroups
# Always use enum for groups
if hasattr(self.obj, "getEnumerationsOfProperty"):
groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups))
else:
groups = list(getattr(self.obj, self.CustomPropertyGroups, []))
name = self.__sanitizePropertyName(name)
if not re.match("^[A-Za-z0-9_]*$", name):
raise ValueError("Property Name can only contain letters and numbers")
if not group in groups:
if group not in groups:
groups.append(group)
self.obj.CustomPropertyGroups = groups
if hasattr(self.obj, "setEnumerationsOfProperty"):
self.obj.setEnumerationsOfProperty(self.CustomPropertyGroups, groups)
else:
setattr(self.obj, self.CustomPropertyGroups, groups)
self.obj.addProperty(propertyType, name, group, desc)
return name
@@ -129,9 +164,16 @@ class PropertyBag(object):
customGroups = []
for p in self.obj.PropertiesList:
group = self.obj.getGroupOfProperty(p)
if group in self.obj.CustomPropertyGroups and not group in customGroups:
if hasattr(self.obj, "getEnumerationsOfProperty"):
groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups))
else:
groups = list(getattr(self.obj, self.CustomPropertyGroups, []))
if group in groups and group not in customGroups:
customGroups.append(group)
self.obj.CustomPropertyGroups = customGroups
if hasattr(self.obj, "setEnumerationsOfProperty"):
self.obj.setEnumerationsOfProperty(self.CustomPropertyGroups, customGroups)
else:
setattr(self.obj, self.CustomPropertyGroups, customGroups)
def Create(name="PropertyBag"):

View File

@@ -78,10 +78,10 @@ def setProperty(obj, prop, value):
"""setProperty(obj, prop, value) ... set the property value of obj's property defined by its canonical name."""
o, attr, name = _getProperty(obj, prop)
if attr is not None and isinstance(value, str):
if isinstance(attr, int):
value = int(value, 0)
elif isinstance(attr, bool):
if isinstance(attr, bool):
value = value.lower() in ["true", "1", "yes", "ok"]
elif isinstance(attr, int):
value = int(value, 0)
if o and name:
setattr(o, name, value)

View File

@@ -45,11 +45,10 @@ class JobPreferencesPage:
self.processor = {}
def saveSettings(self):
filePath = self.form.leDefaultFilePath.text()
jobTemplate = self.form.leDefaultJobTemplate.text()
geometryTolerance = Units.Quantity(self.form.geometryTolerance.text())
curveAccuracy = Units.Quantity(self.form.curveAccuracy.text())
Path.Preferences.setJobDefaults(filePath, jobTemplate, geometryTolerance, curveAccuracy)
Path.Preferences.setJobDefaults(jobTemplate, geometryTolerance, curveAccuracy)
if curveAccuracy:
Path.Area.setDefaultParams(Accuracy=curveAccuracy)
@@ -146,7 +145,6 @@ class JobPreferencesPage:
)
def loadSettings(self):
self.form.leDefaultFilePath.setText(Path.Preferences.defaultFilePath())
self.form.leDefaultJobTemplate.setText(Path.Preferences.defaultJobTemplate())
blacklist = Path.Preferences.postProcessorBlacklist()
@@ -175,7 +173,6 @@ class JobPreferencesPage:
self.form.leOutputFile.setText(Path.Preferences.defaultOutputFile())
self.selectComboEntry(self.form.cboOutputPolicy, Path.Preferences.defaultOutputPolicy())
self.form.tbDefaultFilePath.clicked.connect(self.browseDefaultFilePath)
self.form.tbDefaultJobTemplate.clicked.connect(self.browseDefaultJobTemplate)
self.form.postProcessorList.itemEntered.connect(self.setProcessorListTooltip)
self.form.postProcessorList.itemChanged.connect(self.verifyAndUpdateDefaultPostProcessor)
@@ -311,7 +308,8 @@ class JobPreferencesPage:
self.form.defaultPostProcessorArgs.setToolTip(self.postProcessorArgsDefaultTooltip)
def bestGuessForFilePath(self):
path = self.form.leDefaultFilePath.text()
path = Path.Preferences.defaultFilePath()
if not path:
path = Path.Preferences.filePath()
return path
@@ -326,14 +324,6 @@ class JobPreferencesPage:
if foo:
self.form.leDefaultJobTemplate.setText(foo)
def browseDefaultFilePath(self):
path = self.bestGuessForFilePath()
foo = QtGui.QFileDialog.getExistingDirectory(
QtGui.QApplication.activeWindow(), "Path - External File Directory", path
)
if foo:
self.form.leDefaultFilePath.setText(foo)
def browseOutputFile(self):
path = self.form.leOutputFile.text()
foo = QtGui.QFileDialog.getExistingDirectory(

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"
@@ -123,22 +124,38 @@ def getDefaultAssetPath() -> Path:
def getAssetPath() -> pathlib.Path:
pref = tool_preferences()
# Check if we have a CamAssets path already set
cam_assets_path = pref.GetString(ToolPath, "")
if cam_assets_path:
return pathlib.Path(cam_assets_path)
# Migration: Check for legacy DefaultFilePath and use it for CamAssets
legacy_path = defaultFilePath()
if legacy_path:
legacy_path_obj = pathlib.Path(legacy_path)
if legacy_path_obj.exists() and legacy_path_obj.is_dir():
# Migrate: Set the legacy path as the new CamAssets path
setAssetPath(legacy_path_obj)
return legacy_path_obj
# Fallback to default if no legacy path found
default = getDefaultAssetPath()
path = pref.GetString(ToolPath, str(default))
return pathlib.Path(path or default)
return pathlib.Path(default)
def setAssetPath(path: pathlib.Path):
assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}"
if str(path) == str(getAssetPath()):
return
pref = tool_preferences()
current_path = pref.GetString(ToolPath, "")
if str(path) == current_path:
return
pref.SetString(ToolPath, str(path))
_emit_change(ToolGroup, ToolPath, path)
def getToolBitPath() -> pathlib.Path:
return getAssetPath() / "Bit"
return getAssetPath() / "Tools" / "Bit"
def getLastToolLibrary() -> Optional[str]:
@@ -152,6 +169,16 @@ def setLastToolLibrary(name: str):
pref.SetString(LastToolLibrary, name)
def getLastToolLibrarySortKey() -> Optional[str]:
pref = tool_preferences()
return pref.GetString(LastToolLibrarySortKey) or None
def setLastToolLibrarySortKey(name: str):
pref = tool_preferences()
pref.SetString(LastToolLibrarySortKey, name)
def allAvailablePostProcessors():
allposts = []
for path in searchPathsPost():
@@ -201,7 +228,7 @@ def defaultFilePath():
def filePath():
path = defaultFilePath()
if not path:
path = macroFilePath()
path = getAssetPath()
return path
@@ -237,13 +264,9 @@ def defaultJobTemplate():
return ""
def setJobDefaults(fileName, jobTemplate, geometryTolerance, curveAccuracy):
Path.Log.track(
"(%s='%s', %s, %s, %s)"
% (DefaultFilePath, fileName, jobTemplate, geometryTolerance, curveAccuracy)
)
def setJobDefaults(jobTemplate, geometryTolerance, curveAccuracy):
Path.Log.track("(%s, %s, %s)" % (jobTemplate, geometryTolerance, curveAccuracy))
pref = preferences()
pref.SetString(DefaultFilePath, fileName)
pref.SetString(DefaultJobTemplate, jobTemplate)
pref.SetFloat(GeometryTolerance, geometryTolerance)
pref.SetFloat(LibAreaCurveAccuracy, curveAccuracy)

View File

@@ -46,7 +46,7 @@ from .cache import AssetCache, CacheKey
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=logging.ERROR)
@dataclass
@@ -107,6 +107,12 @@ class AssetManager:
visited_uris: Set[AssetUri],
depth: Optional[int] = None,
) -> Optional[_AssetConstructionData]:
# Log library fetch details
if uri.asset_type == "library":
logger.info(
f"LIBRARY FETCH: Loading library '{uri.asset_id}' with depth={depth} from stores {store_names}"
)
logger.debug(
f"_fetch_asset_construction_data_recursive_async called {store_names} {uri} {depth}"
)
@@ -126,29 +132,59 @@ class AssetManager:
# Fetch the requested asset, trying each store in order
raw_data = None
found_store_name = None
# Log toolbit search details
if uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT SEARCH: Looking for toolbit '{uri.asset_id}' in stores: {store_names}"
)
for current_store_name in store_names:
store = self.stores.get(current_store_name)
if not store:
logger.warning(f"Store '{current_store_name}' not registered. Skipping.")
continue
# Log store search path for toolbits
if uri.asset_type == "toolbit":
store_path = getattr(store, "base_path", "unknown")
logger.info(
f"TOOLBIT SEARCH: Checking store '{current_store_name}' at path: {store_path}"
)
try:
raw_data = await store.get(uri)
found_store_name = current_store_name
if uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT FOUND: '{uri.asset_id}' found in store '{found_store_name}'"
)
logger.debug(
f"_fetch_asset_construction_data_recursive_async: Asset {uri} found in store {found_store_name}"
)
break # Asset found, no need to check other stores
except FileNotFoundError:
if uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT SEARCH: '{uri.asset_id}' NOT found in store '{current_store_name}'"
)
logger.debug(
f"_fetch_asset_construction_data_recursive_async: Asset {uri} not found in store {current_store_name}"
)
continue # Try next store
if raw_data is None:
if raw_data is None or not found_store_name:
if uri.asset_type == "toolbit":
logger.warning(
f"TOOLBIT NOT FOUND: '{uri.asset_id}' not found in any of the stores: {store_names}"
)
return None # Asset not found in any store
if depth == 0:
if uri.asset_type == "library":
logger.warning(
f"LIBRARY SHALLOW: Library '{uri.asset_id}' loaded with depth=0 - no dependencies will be resolved"
)
return _AssetConstructionData(
store=found_store_name,
uri=uri,
@@ -194,7 +230,6 @@ class AssetManager:
def _calculate_cache_key_from_construction_data(
self,
construction_data: _AssetConstructionData,
store_name_for_cache: str,
) -> Optional[CacheKey]:
if not construction_data or not construction_data.raw_data:
return None
@@ -209,7 +244,7 @@ class AssetManager:
raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16)
return CacheKey(
store_name=store_name_for_cache,
store_name=construction_data.store,
asset_uri_str=str(construction_data.uri),
raw_data_hash=raw_data_hash,
dependency_signature=deps_signature_tuple,
@@ -218,8 +253,7 @@ class AssetManager:
def _build_asset_tree_from_data_sync(
self,
construction_data: Optional[_AssetConstructionData],
store_name_for_cache: str,
) -> Asset | None:
) -> Optional[Asset]:
"""
Synchronously and recursively builds an asset instance.
Integrates caching logic.
@@ -228,10 +262,8 @@ class AssetManager:
return None
cache_key: Optional[CacheKey] = None
if store_name_for_cache in self._cacheable_stores:
cache_key = self._calculate_cache_key_from_construction_data(
construction_data, store_name_for_cache
)
if construction_data.store in self._cacheable_stores:
cache_key = self._calculate_cache_key_from_construction_data(construction_data)
if cache_key:
cached_asset = self.asset_cache.get(cache_key)
if cached_asset is not None:
@@ -245,18 +277,42 @@ class AssetManager:
resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None
if construction_data.dependencies_data is not None:
resolved_dependencies = {}
# Log dependency resolution for libraries
if construction_data.uri.asset_type == "library":
logger.info(
f"LIBRARY DEPS: Resolving {len(construction_data.dependencies_data)} dependencies for library '{construction_data.uri.asset_id}'"
)
for (
dep_uri,
dep_data_node,
) in construction_data.dependencies_data.items():
# Log toolbit dependency resolution
if dep_uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT DEP: Resolving dependency '{dep_uri.asset_id}' for library '{construction_data.uri.asset_id}'"
)
# Assuming dependencies are fetched from the same store context
# for caching purposes. If a dependency *could* be from a
# different store and that store has different cacheability,
# this would need more complex store_name propagation.
# For now, use the parent's store_name_for_cache.
try:
dep = self._build_asset_tree_from_data_sync(dep_data_node, store_name_for_cache)
dep = self._build_asset_tree_from_data_sync(dep_data_node)
if dep_uri.asset_type == "toolbit":
if dep:
logger.info(
f"TOOLBIT DEP: Successfully resolved '{dep_uri.asset_id}' -> {type(dep).__name__}"
)
else:
logger.warning(
f"TOOLBIT DEP: Dependency '{dep_uri.asset_id}' resolved to None"
)
except Exception as e:
if dep_uri.asset_type == "toolbit":
logger.error(f"TOOLBIT DEP: Error resolving '{dep_uri.asset_id}': {e}")
logger.error(
f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}",
exc_info=True,
@@ -264,9 +320,31 @@ class AssetManager:
else:
resolved_dependencies[dep_uri] = dep
# Log final dependency count for libraries
if construction_data.uri.asset_type == "library":
toolbit_deps = [
uri for uri in resolved_dependencies.keys() if uri.asset_type == "toolbit"
]
logger.info(
f"LIBRARY DEPS: Resolved {len(resolved_dependencies)} total dependencies ({len(toolbit_deps)} toolbits) for library '{construction_data.uri.asset_id}'"
)
else:
# Log when dependencies_data is None
if construction_data.uri.asset_type == "library":
logger.warning(
f"LIBRARY NO DEPS: Library '{construction_data.uri.asset_id}' has dependencies_data=None - was loaded with depth=0"
)
asset_class = construction_data.asset_class
serializer = self.get_serializer_for_class(asset_class)
try:
# Log library instantiation with dependency info
if construction_data.uri.asset_type == "library":
dep_count = len(resolved_dependencies) if resolved_dependencies else 0
logger.info(
f"LIBRARY INSTANTIATE: Creating library '{construction_data.uri.asset_id}' with {dep_count} dependencies"
)
final_asset = asset_class.from_bytes(
construction_data.raw_data,
construction_data.uri.asset_id,
@@ -311,6 +389,24 @@ class AssetManager:
# Log entry with thread info for verification
calling_thread_name = threading.current_thread().name
stores_list = [store] if isinstance(store, str) else store
# Log all asset get requests
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
if asset_uri_obj.asset_type == "library":
logger.info(
f"LIBRARY GET: Request for library '{asset_uri_obj.asset_id}' with depth={depth}"
)
elif asset_uri_obj.asset_type == "toolbit":
logger.info(
f"TOOLBIT GET: Direct request for toolbit '{asset_uri_obj.asset_id}' with depth={depth} from stores {stores_list}"
)
# Add stack trace to see who's calling this
import traceback
stack = traceback.format_stack()
caller_info = "".join(stack[-3:-1]) # Get the 2 frames before this one
logger.info(f"TOOLBIT GET CALLER:\n{caller_info}")
logger.debug(
f"AssetManager.get(uri='{uri}', stores='{stores_list}', depth='{depth}') called from thread: {calling_thread_name}"
)
@@ -365,10 +461,9 @@ class AssetManager:
f"and {deps_count} dependencies ({found_deps_count} resolved)."
)
# Use the first store from the list for caching purposes
store_name_for_cache = stores_list[0] if stores_list else "local"
final_asset = self._build_asset_tree_from_data_sync(
all_construction_data, store_name_for_cache=store_name_for_cache
)
final_asset = self._build_asset_tree_from_data_sync(all_construction_data)
if not final_asset:
raise ValueError(f"failed to build asset {uri}")
logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.")
return final_asset
@@ -377,7 +472,7 @@ class AssetManager:
uri: Union[AssetUri, str],
store: Union[str, Sequence[str]] = "local",
depth: Optional[int] = None,
) -> Asset | None:
) -> Optional[Asset]:
"""
Convenience wrapper for get() that does not raise FileNotFoundError; returns
None instead
@@ -423,9 +518,7 @@ class AssetManager:
logger.debug(
f"get_async: Building asset tree for '{asset_uri_obj}', depth {depth} in current async context."
)
return self._build_asset_tree_from_data_sync(
all_construction_data, store_name_for_cache=store
)
return self._build_asset_tree_from_data_sync(all_construction_data)
def get_raw(
self,
@@ -438,31 +531,8 @@ class AssetManager:
f"AssetManager.get_raw(uri='{uri}', stores='{stores_list}') from T:{threading.current_thread().name}"
)
async def _fetch_raw_async(stores_list: Sequence[str]):
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
logger.debug(
f"GetRawAsync (internal): Trying stores '{stores_list}'. Available stores: {list(self.stores.keys())}"
)
for current_store_name in stores_list:
store = self.stores.get(current_store_name)
if not store:
logger.warning(f"Store '{current_store_name}' not registered. Skipping.")
continue
try:
raw_data = await store.get(asset_uri_obj)
logger.debug(
f"GetRawAsync: Asset {asset_uri_obj} found in store {current_store_name}"
)
return raw_data
except FileNotFoundError:
logger.debug(
f"GetRawAsync: Asset {asset_uri_obj} not found in store {current_store_name}"
)
continue
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'")
try:
return asyncio.run(_fetch_raw_async(stores_list))
return asyncio.run(self.get_raw_async(uri, stores_list))
except Exception as e:
logger.error(
f"GetRaw: Error during asyncio.run for '{uri}': {e}",
@@ -483,12 +553,12 @@ class AssetManager:
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
for current_store_name in stores_list:
store = self.stores.get(current_store_name)
if not store:
thestore = self.stores.get(current_store_name)
if not thestore:
logger.warning(f"Store '{current_store_name}' not registered. Skipping.")
continue
try:
raw_data = await store.get(asset_uri_obj)
raw_data = await thestore.get(asset_uri_obj)
logger.debug(
f"GetRawAsync: Asset {asset_uri_obj} found in store {current_store_name}"
)
@@ -551,12 +621,7 @@ class AssetManager:
elif isinstance(data_or_exc, _AssetConstructionData):
# Build asset instance synchronously. Exceptions during build should propagate.
# Use the first store from the list for caching purposes in build_asset_tree
store_name_for_cache = stores_list[0] if stores_list else "local"
assets.append(
self._build_asset_tree_from_data_sync(
data_or_exc, store_name_for_cache=store_name_for_cache
)
)
assets.append(self._build_asset_tree_from_data_sync(data_or_exc))
elif data_or_exc is None: # From _fetch_... returning None for not found
logger.debug(f"GetBulk: Asset '{original_uri_input}' not found")
assets.append(None)
@@ -596,12 +661,8 @@ class AssetManager:
for i, data_or_exc in enumerate(all_construction_data_list):
if isinstance(data_or_exc, _AssetConstructionData):
# Use the first store from the list for caching purposes in build_asset_tree
store_name_for_cache = stores_list[0] if stores_list else "local"
assets.append(
self._build_asset_tree_from_data_sync(
data_or_exc, store_name_for_cache=store_name_for_cache
)
)
asset = self._build_asset_tree_from_data_sync(data_or_exc)
assets.append(asset)
elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None:
assets.append(None)
elif isinstance(data_or_exc, Exception):
@@ -625,8 +686,8 @@ class AssetManager:
for current_store_name in stores_list:
store = self.stores.get(current_store_name)
if not store:
logger.warning(f"Store '{current_store_name}' not registered. Skipping.")
continue
logger.error(f"Store '{current_store_name}' not registered. Skipping.")
raise ValueError(f"No store registered for name: {store}")
try:
exists = await store.exists(asset_uri_obj)
if exists:
@@ -840,12 +901,191 @@ class AssetManager:
)
raise
async def copy_async(
self,
src: AssetUri,
dest_store: str,
store: str = "local",
dest: Optional[AssetUri] = None,
) -> AssetUri:
"""
Copies an asset from one location to another asynchronously.
Performs a shallow copy by wrapping get_raw_async and add_raw_async.
If dest is None, it defaults to the uri given in src.
An assertion is raised if src and store are the same as dest and
dest_store.
If the destination already exists it should be silently overwritten.
"""
if dest is None:
dest = src
if src == dest and store == dest_store:
raise ValueError("Source and destination cannot be the same asset in the same store.")
raw_data = await self.get_raw_async(src, store)
return await self.add_raw_async(dest.asset_type, dest.asset_id, raw_data, dest_store)
def copy(
self,
src: AssetUri,
dest_store: str,
store: str = "local",
dest: Optional[AssetUri] = None,
) -> AssetUri:
"""
Copies an asset from one location to another synchronously.
Performs a shallow copy by wrapping get_raw and add_raw.
If dest is None, it defaults to the uri given in src.
An assertion is raised if src and store are the same as dest and
dest_store.
If the destination already exists it should be silently overwritten.
"""
return asyncio.run(self.copy_async(src, dest_store, store, dest))
async def deepcopy_async(
self,
src: AssetUri,
dest_store: str,
store: str = "local",
dest: Optional[AssetUri] = None,
) -> AssetUri:
"""
Asynchronously deep copies an asset and its dependencies from a source store
to a destination store.
Args:
src: The AssetUri of the source asset.
dest_store: The name of the destination store.
store: The name of the source store (defaults to "local").
dest: Optional. The new AssetUri for the top-level asset in the
destination store. If None, the original URI is used.
Returns:
The AssetUri of the copied top-level asset in the destination store.
Raises:
ValueError: If the source or destination store is not registered.
FileNotFoundError: If the source asset is not found.
RuntimeError: If a cyclic dependency is detected.
"""
logger.debug(
f"DeepcopyAsync URI '{src}' from store '{store}' to '{dest_store}'"
f" with dest '{dest}'"
)
if dest is None:
dest = src
if store not in self.stores:
raise ValueError(f"Source store '{store}' not registered.")
if dest_store not in self.stores:
raise ValueError(f"Destination store '{dest_store}' not registered.")
if store == dest_store and src == dest:
raise ValueError(f"File '{src}' cannot be copied to itself.")
# Fetch the source asset and its dependencies recursively
# Use a new set for visited_uris for this deepcopy operation
construction_data = await self._fetch_asset_construction_data_recursive_async(
src, [store], set(), depth=None
)
if construction_data is None:
raise FileNotFoundError(f"Source asset '{src}' not found in store '{store}'.")
# Collect all assets (including dependencies) in a flat list,
# ensuring dependencies are processed before the assets that depend on them.
assets_to_copy: List[_AssetConstructionData] = []
def collect_assets(data: _AssetConstructionData):
if data.dependencies_data is not None:
for dep_data in data.dependencies_data.values():
if dep_data: # Only collect if dependency data was successfully fetched
collect_assets(dep_data)
assets_to_copy.append(data)
collect_assets(construction_data)
# Process assets in the collected order (dependencies first)
dest_store: AssetStore = self.stores[dest_store]
copied_uris: Set[AssetUri] = set()
for asset_data in assets_to_copy:
# Prevent duplicate processing of the same asset
asset_uri = dest if asset_data.uri == src else asset_data.uri
if asset_uri in copied_uris:
logger.debug(
f"Dependency '{asset_uri}' already added to '{dest_store}'," " skipping copy."
)
continue
copied_uris.add(asset_uri)
# Check if the dependency already exists in the destination store
# Dependencies should be skipped if they exist, top-level should be overwritten.
exists_in_dest = await dest_store.exists(asset_uri)
if exists_in_dest and asset_uri != src:
logger.debug(
f"Dependency '{asset_uri}' already exists in '{dest_store}'," " skipping copy."
)
continue
# Put the asset (or dependency) into the destination store
# Pass the dependency_uri_map to the store's put method.
if exists_in_dest:
# If it was not skipped above, this is the top-level asset. Update it.
logger.debug(f"Updating asset '{asset_uri}' in '{dest_store}'")
dest = await dest_store.update(
asset_uri,
asset_data.raw_data,
)
else:
# If it doesn't exist, or if it's a dependency that doesn't exist, create it
logger.debug(f"Creating asset '{asset_uri}' in '{dest_store}'")
logger.debug(f"Raw data before writing: {asset_data.raw_data}") # Added log
await dest_store.create(
asset_uri.asset_type,
asset_uri.asset_id,
asset_data.raw_data,
)
logger.debug(f"DeepcopyAsync completed for '{src}' to '{dest}'")
return dest
def deepcopy(
self,
src: AssetUri,
dest_store: str,
store: str = "local",
dest: Optional[AssetUri] = None,
) -> AssetUri:
"""
Synchronously deep copies an asset and its dependencies from a source store
to a destination store.
Args:
src: The AssetUri of the source asset.
dest_store: The name of the destination store.
store: The name of the source store (defaults to "local").
dest: Optional. The new AssetUri for the top-level asset in the
destination store. If None, the original URI is used.
Returns:
The AssetUri of the copied top-level asset in the destination store.
Raises:
ValueError: If the source or destination store is not registered.
FileNotFoundError: If the source asset is not found.
RuntimeError: If a cyclic dependency is detected.
"""
logger.debug(
f"Deepcopy URI '{src}' from store '{store}' to '{dest_store}'" f" with dest '{dest}'"
)
return asyncio.run(self.deepcopy_async(src, dest_store, store, dest))
def add_file(
self,
asset_type: str,
path: pathlib.Path,
store: str = "local",
asset_id: str | None = None,
asset_id: Optional[str] = None,
) -> AssetUri:
"""
Convenience wrapper around add_raw().

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,27 +20,34 @@
# * *
# ***************************************************************************
import pathlib
import FreeCAD
import Path
from typing import Optional, Tuple, Type, Iterable
from PySide.QtWidgets import QFileDialog, QMessageBox
from ..manager import AssetManager
from ..serializer import AssetSerializer, Asset
from .util import (
make_import_filters,
make_export_filters,
get_serializer_from_extension,
)
import Path.Preferences as Preferences
class AssetOpenDialog(QFileDialog):
def __init__(
self,
asset_manager: AssetManager,
asset_class: Type[Asset],
serializers: Iterable[Type[AssetSerializer]],
parent=None,
):
super().__init__(parent)
# Set default directory based on asset type
default_dir = self._get_default_directory(asset_class)
self.setDirectory(default_dir.as_posix())
self.asset_class = asset_class
self.asset_manager = asset_manager
self.serializers = list(serializers)
self.setFileMode(QFileDialog.ExistingFile)
filters = make_import_filters(self.serializers)
@@ -50,6 +57,7 @@ class AssetOpenDialog(QFileDialog):
def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]:
"""Deserialize the selected file using the appropriate serializer."""
# Find the correct serializer for the file.
file_extension = file_path.suffix.lower()
serializer_class = get_serializer_from_extension(
self.serializers, file_extension, for_import=True
@@ -61,8 +69,25 @@ class AssetOpenDialog(QFileDialog):
f"No supported serializer found for file extension '{file_extension}'",
)
return None
# Check whether all dependencies for importing the file exist.
try:
raw_data = file_path.read_bytes()
dependencies = serializer_class.extract_dependencies(raw_data)
for dependency_uri in dependencies:
if not self.asset_manager.exists(dependency_uri, store=["local", "builtin"]):
QMessageBox.critical(
self,
"Error",
f"Failed to import {file_path}: required dependency {dependency_uri} not found",
)
return None
except Exception as e:
QMessageBox.critical(self, "Error", f"{file_path}: Failed to check dependencies: {e}")
return None
# Load and return the asset.
try:
asset = serializer_class.deep_deserialize(raw_data)
if not isinstance(asset, self.asset_class):
raise TypeError(f"Deserialized asset is not of type {self.asset_class.__name__}")
@@ -81,6 +106,24 @@ class AssetOpenDialog(QFileDialog):
return file_path, asset
return None
def _get_default_directory(self, asset_class: Type[Asset]) -> pathlib.Path:
"""Get the appropriate default directory based on asset type."""
try:
asset_path = Preferences.getAssetPath()
# Check asset type to determine subdirectory
asset_type = getattr(asset_class, "asset_type", None)
if asset_type == "toolbit":
return asset_path / "Tool" / "Bit"
elif asset_type == "library" or asset_type == "toolbitlibrary":
return asset_path / "Tool" / "Library"
else:
# Default to asset path root for unknown types
return asset_path
except Exception:
# Fallback to home directory if anything goes wrong
return pathlib.Path.home()
class AssetSaveDialog(QFileDialog):
def __init__(
@@ -90,6 +133,10 @@ class AssetSaveDialog(QFileDialog):
parent=None,
):
super().__init__(parent)
# Set default directory based on asset type
default_dir = self._get_default_directory(asset_class)
self.setDirectory(default_dir.as_posix())
self.asset_class = asset_class
self.serializers = list(serializers)
self.setFileMode(QFileDialog.AnyFile)
@@ -124,6 +171,11 @@ class AssetSaveDialog(QFileDialog):
QMessageBox.critical(self, "Error", f"Failed to export asset: {e}")
return False
def _get_default_directory(self, asset_class: Type[Asset]) -> pathlib.Path:
"""Get the appropriate default directory based on asset type."""
# For exports, default to home directory instead of CAM assets path
return pathlib.Path.home()
def exec_(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]:
self.setWindowTitle(f"Save {asset.label or self.asset_class.asset_type}")
if super().exec_():

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

@@ -131,13 +131,13 @@ def ensure_toolbitshape_assets_present(asset_manager: AssetManager, store_name:
def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Copies an example shape to the given store if it is currently empty.
Ensures the toolbitshape directory structure exists without adding any files.
"""
builtin_shape_path = Preferences.getBuiltinShapePath()
from pathlib import Path
if asset_manager.is_empty("toolbitshape", store=store_name):
path = builtin_shape_path / "endmill.fcstd"
asset_manager.add_file("toolbitshape", path, store=store_name, asset_id="example")
# Get the shape directory path and ensure it exists
shape_path = Preferences.getAssetPath() / "Tools" / "Shape"
shape_path.mkdir(parents=True, exist_ok=True)
def ensure_assets_initialized(asset_manager: AssetManager, store="local"):
@@ -157,6 +157,16 @@ def _on_asset_path_changed(group, key, value):
# Set up the local CAM asset storage.
asset_mapping = {
"toolbitlibrary": "Tools/Library/{asset_id}.fctl",
"toolbit": "Tools/Bit/{asset_id}.fctb",
"toolbitshape": "Tools/Shape/{asset_id}.fcstd",
"toolbitshapesvg": "Tools/Shape/{asset_id}", # Asset ID has ".svg" included
"toolbitshapepng": "Tools/Shape/{asset_id}", # Asset ID has ".png" included
"machine": "Machine/{asset_id}.fcm",
}
# Separate mapping for builtin assets (maintains original structure)
builtin_asset_mapping = {
"toolbitlibrary": "Library/{asset_id}.fctl",
"toolbit": "Bit/{asset_id}.fctb",
"toolbitshape": "Shape/{asset_id}.fcstd",
@@ -174,7 +184,7 @@ user_asset_store = FileStore(
builtin_asset_store = FileStore(
name="builtin",
base_dir=Preferences.getBuiltinAssetPath(),
mapping=asset_mapping,
mapping=builtin_asset_mapping,
)

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

@@ -66,6 +66,7 @@ class FCTLSerializer(AssetSerializer):
Creates a Library instance from serialized data and resolved
dependencies.
"""
data_dict = json.loads(data.decode("utf-8"))
# The id parameter from the Asset.from_bytes method is the canonical ID
# for the asset being deserialized. We should use this ID for the library
@@ -103,9 +104,80 @@ class FCTLSerializer(AssetSerializer):
Path.Log.warning(
f"Tool with id {tool_id} not found in dependencies during deserialization."
)
# Create a placeholder toolbit with the original ID to preserve library structure
from ...toolbit.models.custom import ToolBitCustom
from ...shape.models.custom import ToolBitShapeCustom
placeholder_shape = ToolBitShapeCustom(tool_id)
placeholder_toolbit = ToolBitCustom(placeholder_shape, id=tool_id)
placeholder_toolbit.label = f"Missing Tool ({tool_id})"
library.add_bit(placeholder_toolbit, bit_no=tool_no)
Path.Log.info(f"Created placeholder toolbit with original ID {tool_id}")
return library
@classmethod
def deep_deserialize(cls, data: bytes) -> Library:
# TODO: attempt to fetch tools from the asset manager here
return cls.deserialize(data, str(uuid.uuid4()), {})
"""Deep deserialize a library by fetching all toolbit dependencies."""
import uuid
from ...camassets import cam_assets
# Generate a unique ID for this library instance
library_id = str(uuid.uuid4())
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Starting deep deserialization for library id='{library_id}'"
)
# Extract dependency URIs from the library data
dependency_uris = cls.extract_dependencies(data)
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Found {len(dependency_uris)} toolbit dependencies: {[uri.asset_id for uri in dependency_uris]}"
)
# Fetch all toolbit dependencies
resolved_dependencies = {}
for dep_uri in dependency_uris:
try:
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Fetching toolbit '{dep_uri.asset_id}' from stores ['local', 'builtin']"
)
# Check if toolbit exists in each store individually for debugging
exists_local = cam_assets.exists(dep_uri, store="local")
exists_builtin = cam_assets.exists(dep_uri, store="builtin")
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Toolbit '{dep_uri.asset_id}' exists - local: {exists_local}, builtin: {exists_builtin}"
)
toolbit = cam_assets.get(dep_uri, store=["local", "builtin"], depth=0)
resolved_dependencies[dep_uri] = toolbit
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Successfully fetched toolbit '{dep_uri.asset_id}'"
)
except Exception as e:
Path.Log.warning(
f"FCTL DEEP_DESERIALIZE: Failed to fetch toolbit '{dep_uri.asset_id}': {e}"
)
# Try to get more detailed error information
try:
# Check what's actually in the stores
local_toolbits = cam_assets.list_assets("toolbit", store="local")
local_ids = [uri.asset_id for uri in local_toolbits]
Path.Log.info(
f"FCTL DEBUG: Local store has {len(local_ids)} toolbits: {local_ids[:10]}{'...' if len(local_ids) > 10 else ''}"
)
if dep_uri.asset_id in local_ids:
Path.Log.warning(
f"FCTL DEBUG: Toolbit '{dep_uri.asset_id}' IS in local store list but get() failed!"
)
except Exception as list_error:
Path.Log.error(f"FCTL DEBUG: Failed to list local toolbits: {list_error}")
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Resolved {len(resolved_dependencies)} of {len(dependency_uris)} dependencies"
)
# Now deserialize with the resolved dependencies
return cls.deserialize(data, library_id, resolved_dependencies)

View File

@@ -50,18 +50,30 @@ class LinuxCNCSerializer(AssetSerializer):
output = io.BytesIO()
for bit_no, bit in sorted(asset._bit_nos.items()):
assert isinstance(bit, ToolBit)
if not isinstance(bit, RotaryToolBitMixin):
Path.Log.warning(
f"Skipping too {bit.label} (bit.id) because it is not a rotary tool"
)
continue
diameter = bit.get_diameter()
# Connor: assert isinstance(bit, ToolBit)
# if not isinstance(bit, RotaryToolBitMixin):
# Path.Log.warning(
# f"Skipping too {bit.label} (bit.id) because it is not a rotary tool"
# )
# continue
# Commenting this out. Why did we skip because it is not a rotary tool?
diameter = bit.get_diameter().getUserPreferred()[0]
pocket = "P0" # TODO: is there a better way?
# Format diameter to one decimal place and remove units
diameter_value = diameter.Value if hasattr(diameter, "Value") else diameter
line = f"T{bit_no} {pocket} D{diameter_value:.3f} ;{bit.label}\n"
output.write(line.encode("ascii", "ignore"))
# TODO: Strip units by splitting at the first space if diameter is a string
# This is where we need a machine definition so we can export these out correctly
# for a metric or imperial machine
# Using user preferred for now
if hasattr(diameter, "Value"):
diameter_value = diameter.Value
elif isinstance(diameter, str):
diameter_value = diameter.split(" ")[0]
else:
diameter_value = diameter
line = (
f"T{bit_no} {pocket} X0 Y0 Z0 A0 B0 C0 U0 V0 W0 "
f"D{diameter_value} I0 J0 Q0 ;{bit.label}\n"
)
output.write(line.encode("utf-8"))
return output.getvalue()

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,581 @@
"""Widget for browsing Tool Library assets with filtering and sorting."""
from typing import cast
from PySide import QtGui
import yaml
from typing import cast, List, Optional
from PySide import QtCore, QtGui
from PySide.QtGui import QMenu, QAction, QKeySequence
import FreeCAD
import Path
from ...toolbit.ui.browser import ToolBitBrowserWidget
from ...assets import AssetManager
from ...library import Library
from ...assets import AssetManager, AssetUri
from ...toolbit import ToolBit
from ...toolbit.ui import ToolBitEditor
from ...toolbit.ui.util import natural_sort_key
from ...toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole
from ...toolbit.serializers import YamlToolBitSerializer
from ..models.library import Library
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
class LibraryBrowserWidget(ToolBitBrowserWidget):
"""
A widget to browse, filter, and select Tool Library assets from the
AssetManager, with sorting and batch insertion, including library selection.
AssetManager, with sorting and batch insertion, using a current library.
"""
current_library_changed = QtCore.Signal()
def __init__(
self,
asset_manager: AssetManager,
store: str = "local",
parent=None,
compact=True,
):
super().__init__(
asset_manager=asset_manager,
store=store,
parent=parent,
tool_no_factory=self.get_tool_no_from_current_library,
compact=compact,
)
self.current_library: Optional[Library] = None
self._selected_tool_type: Optional[str] = None
self.layout().setContentsMargins(0, 0, 0, 0)
# Add tool type filter combo box to the base widget
self._tool_type_combo = QtGui.QComboBox()
self._tool_type_combo.setSizePolicy(
QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred
)
self._top_layout.insertWidget(0, self._tool_type_combo, 1)
self._tool_type_combo.currentTextChanged.connect(self._on_tool_type_combo_changed)
self.restore_last_sort_order()
self.load_last_library()
def setDragEnabled(self, enabled: bool = True):
"""Enable or disable drag-and-drop support for the tool list."""
self._tool_list_widget.setDragEnabled(enabled)
def load_last_library(self):
"""Loads the last selected library from preferences."""
library_uri = Path.Preferences.getLastToolLibrary()
if library_uri:
try:
library = self._asset_manager.get(library_uri, store="local", depth=1)
self.set_current_library(library)
except Exception as e:
Path.Log.warning(f"Failed to load last tool library: {e}")
def restore_last_sort_order(self):
"""Sets the sort mode and updates the tool list."""
last_sort_key = Path.Preferences.getLastToolLibrarySortKey()
if last_sort_key:
self.set_sort_order(last_sort_key)
def set_sort_order(self, key: str):
super().set_sort_order(key)
Path.Preferences.setLastToolLibrarySortKey(self._sort_key)
def _get_state(self):
"""Gets the current library URI, selected toolbit URI, and scroll
position."""
current_library_uri_str = (
str(self.current_library.get_uri()) if self.current_library else None
)
selected_toolbit_uris = []
selected_items = self._tool_list_widget.selectedItems()
if selected_items:
selected_toolbit_uris = [item.data(ToolBitUriRole) for item in selected_items]
scroll_pos = self._tool_list_widget.verticalScrollBar().value()
return {
"library_uri": current_library_uri_str,
"toolbit_uris": selected_toolbit_uris,
"scroll_pos": scroll_pos,
}
def _set_state(self, selection_data):
"""Restores the library selection, toolbit selection, and scroll
position."""
library_uri_str = selection_data.get("library_uri")
toolbit_uris = selection_data.get("toolbit_uris", [])
scroll_pos = selection_data.get("scroll_pos", 0)
# Restore library selection
if library_uri_str:
try:
library_uri = AssetUri(library_uri_str)
library = self._asset_manager.get(library_uri, store=self._store_name, depth=1)
self.set_current_library(library)
except FileNotFoundError:
Path.Log.error(f"Library {library_uri_str} not found.")
self.set_current_library(None)
else:
self.set_current_library(None)
# Restore toolbit selection
if toolbit_uris:
for uri in toolbit_uris:
for i in range(self._tool_list_widget.count()):
item = self._tool_list_widget.item(i)
if item.data(ToolBitUriRole) == uri:
item.setSelected(True)
# Restore scroll position
self._tool_list_widget.verticalScrollBar().setValue(scroll_pos)
def refresh(self):
"""Refreshes the toolbits for the current library from disk."""
Path.Log.debug("refresh(): Fetching and populating toolbits.")
if self.current_library:
library_uri = self.current_library.get_uri()
try:
self.current_library = cast(
Library, self._asset_manager.get(library_uri, store=self._store_name, depth=1)
)
except FileNotFoundError:
Path.Log.error(f"Library {library_uri} not found.")
self.current_library = None
self._update_tool_list()
def get_tool_no_from_current_library(self, toolbit):
"""
Retrieves the tool number for a toolbit based on the current library.
"""
if not self.current_library:
return None
tool_no = self.current_library.get_bit_no_from_bit(toolbit)
return tool_no
def set_current_library(self, library):
"""Sets the current library and updates the tool list."""
self.current_library = library
self._update_tool_list()
self.current_library_changed.emit()
# Save the selected library to preferences
if library:
Path.Preferences.setLastToolLibrary(str(library.get_uri()))
def _get_available_tool_types(self):
"""Get all available tool types from the current assets."""
tool_types = set()
# Make sure we have assets to work with
if not hasattr(self, "_all_assets") or not self._all_assets:
return []
for asset in self._all_assets:
# Use get_shape_name() method to get the tool type
if hasattr(asset, "get_shape_name"):
tool_type = asset.get_shape_name()
if tool_type:
tool_types.add(tool_type)
return sorted(tool_types)
def _get_filtered_assets(self):
"""Get assets filtered by tool type if a specific type is selected."""
if self._tool_type_combo.currentIndex() == 0: # "All Toolbit Types"
return self._all_assets
filtered_assets = []
for asset in self._all_assets:
if hasattr(asset, "get_shape_name"):
tool_type = asset.get_shape_name()
if tool_type == self._selected_tool_type:
filtered_assets.append(asset)
return filtered_assets
def _update_tool_list(self):
"""Updates the tool list based on the current library."""
if self.current_library:
self._all_assets = [t for t in self.current_library]
else:
# Fetch all toolbits
all_toolbits = self._asset_manager.fetch(asset_type="toolbit", depth=0)
self._all_assets = cast(List[ToolBit], all_toolbits)
self._sort_assets()
self._tool_list_widget.clear_list()
# Update tool type combo after assets are loaded
if hasattr(self, "_tool_type_combo"):
self._update_tool_type_combo()
self._update_list()
def _update_list(self):
"""Updates the list widget with filtered assets."""
self._tool_list_widget.clear_list()
filtered_assets = self._get_filtered_assets()
# Apply search filter if there is one
search_term = self._search_edit.text().lower()
if search_term:
search_filtered = []
for asset in filtered_assets:
if search_term in asset.label.lower():
search_filtered.append(asset)
continue
# Also search in tool type
if hasattr(asset, "get_shape_name"):
tool_type = asset.get_shape_name()
if tool_type and search_term in tool_type.lower():
search_filtered.append(asset)
filtered_assets = search_filtered
for asset in filtered_assets:
self._tool_list_widget.add_toolbit(asset)
def _add_shortcuts(self):
"""Adds keyboard shortcuts for common actions."""
Path.Log.debug("LibraryBrowserWidget._add_shortcuts: Called.")
super()._add_shortcuts()
cut_action = QAction(self)
cut_action.setShortcuts(QKeySequence.Cut)
cut_action.triggered.connect(self._on_cut_requested)
self.addAction(cut_action)
duplicate_action = QAction(self)
duplicate_action.setShortcut(QKeySequence("Ctrl+D"))
duplicate_action.triggered.connect(self._on_duplicate_requested)
self.addAction(duplicate_action)
remove_action = QAction(self)
remove_action.setShortcut(QKeySequence.Delete)
remove_action.triggered.connect(self._on_remove_from_library_requested)
self.addAction(remove_action)
paste_action = QAction(self)
paste_action.setShortcuts(QKeySequence.Paste)
paste_action.triggered.connect(self._on_paste_requested)
self.addAction(paste_action)
def _show_context_menu(self, position):
"""Shows the context menu at the given position."""
context_menu = QMenu(self)
selected_items = self._tool_list_widget.selectedItems()
has_selection = bool(selected_items)
has_library = self.current_library is not None
# Add actions in the desired order
edit_action = context_menu.addAction("Edit", self._on_edit_requested)
edit_action.setEnabled(has_selection)
context_menu.addSeparator()
action = context_menu.addAction("Copy", self._on_copy_requested)
action.setShortcut(QtGui.QKeySequence("Ctrl+C"))
action = context_menu.addAction("Cut", self._on_cut_requested)
action.setShortcut(QtGui.QKeySequence("Ctrl+X"))
action = context_menu.addAction("Paste", self._on_paste_requested)
action.setShortcut(QtGui.QKeySequence("Ctrl+V"))
# Paste is enabled if there is data in the clipboard
clipboard = QtGui.QApplication.clipboard()
mime_type = "application/x-freecad-toolbit-list-yaml"
action.setEnabled(clipboard.mimeData().hasFormat(mime_type))
action = context_menu.addAction("Duplicate", self._on_duplicate_requested)
action.setShortcut(QtGui.QKeySequence("Ctrl+D"))
context_menu.addSeparator()
# Only show "Remove from Library" when viewing a specific library
if has_library:
action = context_menu.addAction(
"Remove from Library", self._on_remove_from_library_requested
)
action.setShortcut(QtGui.QKeySequence.Delete)
# Only show "Delete from disk" when viewing 'all tools' (no library selected)
if not has_library:
action = context_menu.addAction("Delete from disk", self._on_delete_requested)
action.setShortcut(QtGui.QKeySequence("Shift+Delete"))
# Execute the menu
context_menu.exec_(self._tool_list_widget.mapToGlobal(position))
def get_current_library(self) -> Library | None:
"""Helper to get the current library."""
return self.current_library
def _on_edit_requested(self):
"""Opens the ToolBitEditor for the selected toolbit."""
toolbit = self._get_first_selected_bit()
if not toolbit:
return
# Open the editor for the selected toolbit
tool_no = self.get_tool_no_from_current_library(toolbit)
editor = ToolBitEditor(toolbit, tool_no, parent=self)
result = editor.show()
if result != QtGui.QDialog.Accepted:
return
# If the editor was closed with "OK", save the changes
self._asset_manager.add(toolbit)
Path.Log.info(f"Toolbit {toolbit.get_id()} saved.")
# Also save the library because the tool number may have changed.
if self.current_library and tool_no != editor.tool_no:
self.current_library.assign_new_bit_no(toolbit, editor.tool_no)
self._asset_manager.add(self.current_library)
state = self._get_state()
self.refresh()
self._update_list()
self._set_state(state)
def _on_cut_requested(self):
"""Handles cut request by copying and marking for removal from library."""
uris = self.get_selected_bit_uris()
library = self.get_current_library()
if not library or not uris:
return
# Copy to clipboard (handled by base class _to_clipboard)
extra_data = {"source_library_uri": str(library.get_uri())}
self._to_clipboard(uris, mode="cut", extra_data=extra_data)
def _on_duplicate_requested(self):
"""Handles duplicate request by duplicating and adding to library."""
Path.Log.debug("LibraryBrowserWidget._on_duplicate_requested: Called.\n")
uris = self.get_selected_bit_uris()
library = self.get_current_library()
if not library or not uris:
Path.Log.debug(
"LibraryBrowserWidget._on_duplicate_requested: No library or URIs selected. Returning."
)
return
new_uris = set()
for uri_string in uris:
toolbit = cast(ToolBit, self._asset_manager.get(AssetUri(uri_string), depth=0))
if not toolbit:
Path.Log.warning(f"Toolbit {uri_string} not found.\n")
continue
# Change the ID of the toolbit and save it to disk
toolbit.set_id() # Generate a new ID
toolbit.label = toolbit.label + " (copy)"
added_uri = self._asset_manager.add(toolbit)
if added_uri:
new_uris.add(str(toolbit.get_uri()))
# Add the bit to the current library
library.add_bit(toolbit)
self._asset_manager.add(library) # Save the modified library
self.refresh()
self.select_by_uri(list(new_uris))
def _on_paste_requested(self):
"""Handles paste request by adding toolbits to the current library."""
current_library = self.get_current_library()
if not current_library:
return
clipboard = QtGui.QApplication.clipboard()
mime_type = "application/x-freecad-toolbit-list-yaml"
mime_data = clipboard.mimeData()
if not mime_data.hasFormat(mime_type):
return
try:
clipboard_content_yaml = mime_data.data(mime_type).data().decode("utf-8")
clipboard_data_dict = yaml.safe_load(clipboard_content_yaml)
if (
not isinstance(clipboard_data_dict, dict)
or "toolbits" not in clipboard_data_dict
or not isinstance(clipboard_data_dict["toolbits"], list)
):
return
serialized_toolbits_data = clipboard_data_dict["toolbits"]
mode = clipboard_data_dict.get("operation", "copy")
source_library_uri_str = clipboard_data_dict.get("source_library_uri")
if mode == "copy":
self._on_copy_paste(current_library, serialized_toolbits_data)
elif mode == "cut" and source_library_uri_str:
self._on_cut_paste(
current_library, serialized_toolbits_data, source_library_uri_str
)
except Exception as e:
Path.Log.warning(f"An unexpected error occurred during paste: {e}")
def _on_copy_paste(self, current_library: Library, serialized_toolbits_data: list):
"""Handles pasting toolbits that were copied."""
new_uris = set()
for toolbit_yaml_str in serialized_toolbits_data:
if not isinstance(toolbit_yaml_str, str) or not toolbit_yaml_str.strip():
continue
toolbit_data_bytes = toolbit_yaml_str.encode("utf-8")
toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None)
# Get the original toolbit ID from the deserialized data
original_id = toolbit.id
Path.Log.info(f"COPY PASTE: Attempting to paste toolbit with original_id={original_id}")
# Check if toolbit already exists in asset manager
toolbit_uri = toolbit.get_uri()
existing_toolbit = None
try:
existing_toolbit = self._asset_manager.get(
toolbit_uri, store=["local", "builtin"], depth=0
)
Path.Log.info(f"COPY PASTE: Found existing toolbit {original_id}, using reference")
except FileNotFoundError:
# Toolbit doesn't exist, save it as new
Path.Log.info(f"COPY PASTE: Toolbit {original_id} not found, creating new one")
self._asset_manager.add(toolbit)
existing_toolbit = toolbit
# Add the existing or new toolbit to the current library
added_toolbit = current_library.add_bit(existing_toolbit)
if added_toolbit:
new_uris.add(str(existing_toolbit.get_uri()))
if new_uris:
self._asset_manager.add(current_library) # Save the modified library
self.refresh()
self.select_by_uri(list(new_uris))
def _on_cut_paste(
self,
current_library: Library,
serialized_toolbits_data: list,
source_library_uri_str: str,
):
"""Handles pasting toolbits that were cut."""
source_library_uri = AssetUri(source_library_uri_str)
if source_library_uri == current_library.get_uri():
# Cut from the same library, do nothing
return
try:
source_library = cast(
Library,
self._asset_manager.get(source_library_uri, store=self._store_name, depth=1),
)
except FileNotFoundError:
Path.Log.warning(f"Source library {source_library_uri_str} not found.\n")
return
new_uris = set()
for toolbit_yaml_str in serialized_toolbits_data:
if not isinstance(toolbit_yaml_str, str) or not toolbit_yaml_str.strip():
continue
toolbit_data_bytes = toolbit_yaml_str.encode("utf-8")
toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None)
# Get the original toolbit ID and find the existing toolbit
original_id = toolbit.id
Path.Log.info(f"CUT PASTE: Moving toolbit with original_id={original_id}")
toolbit_uri = toolbit.get_uri()
try:
existing_toolbit = self._asset_manager.get(
toolbit_uri, store=["local", "builtin"], depth=0
)
Path.Log.info(f"CUT PASTE: Found existing toolbit {original_id}, using reference")
# Remove from source library, add to target library
source_library.remove_bit(existing_toolbit)
added_toolbit = current_library.add_bit(existing_toolbit)
if added_toolbit:
new_uris.add(str(existing_toolbit.get_uri()))
except FileNotFoundError:
Path.Log.warning(f"CUT PASTE: Toolbit {original_id} not found in asset manager")
if new_uris:
# Save the modified libraries
self._asset_manager.add(current_library)
self._asset_manager.add(source_library)
self.refresh()
self.select_by_uri(list(new_uris))
def _on_remove_from_library_requested(self):
"""Handles request to remove selected toolbits from the current library."""
Path.Log.debug("_on_remove_from_library_requested: Called.")
uris = self.get_selected_bit_uris()
library = self.get_current_library()
if not library or not uris:
return
# Ask for confirmation
reply = QtGui.QMessageBox.question(
self,
FreeCAD.Qt.translate("CAM", "Confirm Removal"),
FreeCAD.Qt.translate(
"CAM", "Are you sure you want to remove the selected toolbit(s) from the library?"
),
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
QtGui.QMessageBox.No,
)
if reply == QtGui.QMessageBox.Yes:
self._remove_toolbits_from_library(library, uris)
def _remove_toolbits_from_library(self, library: Library, uris: List[str]):
"""Removes toolbits with the given URIs from the specified library."""
removed_count = 0
for uri_string in uris:
try:
# Remove the toolbit from the library
library.remove_bit_by_uri(uri_string)
removed_count += 1
except Exception as e:
Path.Log.error(f"Failed to remove toolbit {uri_string} from library: {e}\n")
if removed_count > 0:
self._asset_manager.add(library)
self.refresh()
def _update_tool_type_combo(self):
"""Update the tool type combo box with available types."""
current_selection = self._tool_type_combo.currentText()
self._tool_type_combo.blockSignals(True)
try:
self._tool_type_combo.clear()
self._tool_type_combo.addItem(FreeCAD.Qt.translate("CAM", "All Toolbit Types"))
for tool_type in self._get_available_tool_types():
self._tool_type_combo.addItem(tool_type)
# Restore selection if it still exists
index = self._tool_type_combo.findText(current_selection)
if index >= 0:
self._tool_type_combo.setCurrentIndex(index)
else:
self._tool_type_combo.setCurrentIndex(0)
finally:
self._tool_type_combo.blockSignals(False)
def _on_tool_type_combo_changed(self, tool_type):
"""Handle tool type filter selection change."""
self._selected_tool_type = tool_type
self._update_list()
class LibraryBrowserWithCombo(LibraryBrowserWidget):
"""
A widget extending LibraryBrowserWidget with a combo box for library selection.
"""
def __init__(
@@ -43,74 +607,88 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
parent=None,
compact=True,
):
self._library_combo = QtGui.QComboBox()
super().__init__(
asset_manager=asset_manager,
store=store,
parent=parent,
tool_no_factory=self.get_tool_no_from_current_library,
compact=compact,
)
# Create the library dropdown and insert it into the top layout
self._top_layout.insertWidget(0, self._library_combo)
self._library_combo.currentIndexChanged.connect(self._on_library_changed)
# Move search box into dedicated row to make space for the
# library selection combo box
layout = self.layout()
self._top_layout.removeWidget(self._search_edit)
layout.insertWidget(1, self._search_edit, 20)
# Library selection combo box
self._library_combo = QtGui.QComboBox()
self._library_combo.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred)
self._top_layout.insertWidget(0, self._library_combo, 1)
self._library_combo.currentIndexChanged.connect(self._on_library_combo_changed)
self._top_layout.removeWidget(self._tool_type_combo)
self._top_layout.insertWidget(1, self._tool_type_combo, 1)
self.current_library_changed.connect(self._on_current_library_changed)
self._in_refresh = False
self.refresh()
def _on_library_combo_changed(self, index):
"""Handles library selection change from the combo box."""
if self._in_refresh:
return
selected_library = cast(Library, self._library_combo.itemData(index))
if not selected_library:
return
# Have to refetch the non-shallow library.
uri = selected_library.get_uri()
library = self._asset_manager.get(uri, store=self._store_name, depth=1)
self.set_current_library(library)
def _on_current_library_changed(self):
"""Updates the combo box when the current library changes externally."""
if self.current_library:
for i in range(self._library_combo.count()):
lib = self._library_combo.itemData(i)
if lib.get_uri() == self.current_library.get_uri():
self._library_combo.setCurrentIndex(i)
return
Path.Log.warning(
f"Current library {self.current_library.get_uri()} not found in combo box."
)
def refresh(self):
"""Refreshes the library dropdown and fetches all assets."""
self._library_combo.clear()
self._fetch_all_assets()
def _fetch_all_assets(self):
"""Populates the library dropdown with available libraries."""
# Use list_assets("toolbitlibrary") to get URIs
"""Reads available libraries and refreshes the combo box and toolbits."""
Path.Log.debug("refresh(): Fetching and populating libraries and toolbits.")
libraries = self._asset_manager.fetch("toolbitlibrary", store=self._store_name, depth=0)
for library in sorted(libraries, key=lambda x: x.label):
self._library_combo.addItem(library.label, userData=library)
if not self._library_combo.count():
return
# Trigger initial load after populating libraries
self._on_library_changed(0)
def get_tool_no_from_current_library(self, toolbit):
"""
Retrieves the tool number for a toolbit based on the currently
selected library.
"""
selected_library = self._library_combo.currentData()
if selected_library is None:
return None
# Use the get_bit_no_from_bit method of the Library object
# This method returns the tool number or None
tool_no = selected_library.get_bit_no_from_bit(toolbit)
return tool_no
def _on_library_changed(self, index):
"""Handles library selection change."""
# Get the selected library from the combo box
selected_library = self._library_combo.currentData()
if not isinstance(selected_library, Library):
self._all_assets = []
return
# Fetch the library from the asset manager
library_uri = selected_library.get_uri()
self._in_refresh = True
try:
library = self._asset_manager.get(library_uri, store=self._store_name, depth=1)
# Update the combo box item's user data with the fully fetched library
self._library_combo.setItemData(index, library)
except FileNotFoundError:
Path.Log.error(f"Library {library_uri} not found in store {self._store_name}.")
self._all_assets = []
self._library_combo.clear()
for library in sorted(libraries, key=lambda x: natural_sort_key(x.label)):
self._library_combo.addItem(library.label, userData=library)
finally:
self._in_refresh = False
super().refresh()
if not libraries:
return
if not self.current_library:
first_library = self._library_combo.itemData(0)
if first_library:
uri = first_library.get_uri()
library = self._asset_manager.get(uri, store=self._store_name, depth=1)
self.set_current_library(library)
self._library_combo.setCurrentIndex(0)
return
# Update _all_assets based on the selected library
library = cast(Library, library)
self._all_assets = [t for t in library]
self._sort_assets()
self._tool_list_widget.clear_list()
self._scroll_position = 0
self._trigger_fetch() # Display data for the selected library
for i in range(self._library_combo.count()):
lib = self._library_combo.itemData(i)
if lib.get_uri() == self.current_library.get_uri():
self._library_combo.setCurrentIndex(i)
break
else:
self._library_combo.setCurrentIndex(0)

View File

@@ -75,7 +75,7 @@ class CommandLibraryEditorOpen:
def GetResources(self):
return {
"Pixmap": "CAM_ToolTable",
"MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitLibraryOpen", "Toolbit Library Editor"),
"MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitLibraryOpen", "Toolbit Library Manager"),
"ToolTip": QT_TRANSLATE_NOOP(
"CAM_ToolBitLibraryOpen", "Opens an editor to manage toolbit libraries"
),
@@ -86,7 +86,7 @@ class CommandLibraryEditorOpen:
return True
def Activated(self):
library = LibraryEditor()
library = LibraryEditor(parent=FreeCADGui.getMainWindow())
library.open()

View File

@@ -34,8 +34,7 @@ from typing import List, Tuple
from ...camassets import cam_assets, ensure_assets_initialized
from ...toolbit import ToolBit
from .editor import LibraryEditor
from .browser import LibraryBrowserWidget
from .browser import LibraryBrowserWithCombo
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
@@ -57,7 +56,7 @@ class ToolBitLibraryDock(object):
self.autoClose = autoClose
self.form = QtWidgets.QDialog()
self.form.setObjectName("ToolSelector")
self.form.setWindowTitle(translate("CAM_ToolBit", "Tool Selector"))
self.form.setWindowTitle(translate("CAM_ToolBit", "Toolbit Selector"))
self.form.setMinimumSize(600, 400)
self.form.resize(800, 600)
self.form.adjustSize()
@@ -66,7 +65,7 @@ class ToolBitLibraryDock(object):
self.form_layout.setSpacing(4)
# Create the browser widget
self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets)
self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets)
self._setup_ui()
@@ -80,7 +79,6 @@ class ToolBitLibraryDock(object):
main_layout.setContentsMargins(4, 4, 4, 4)
main_layout.setSpacing(4)
# Add the browser widget to the layout
main_layout.addWidget(self.browser_widget)
# Create buttons
@@ -88,11 +86,19 @@ class ToolBitLibraryDock(object):
translate("CAM_ToolBit", "Open Library Editor")
)
self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job"))
self.closeButton = QtGui.QPushButton(translate("CAM_ToolBit", "Close"))
# Add buttons to a horizontal layout
button_width = 120
self.libraryEditorOpenButton.setMinimumWidth(button_width)
self.addToolControllerButton.setMinimumWidth(button_width)
self.closeButton.setMinimumWidth(button_width)
# Add buttons to a horizontal layout, right-align Close
button_layout = QtGui.QHBoxLayout()
button_layout.addWidget(self.libraryEditorOpenButton)
button_layout.addWidget(self.addToolControllerButton)
button_layout.addStretch(1)
button_layout.addWidget(self.closeButton)
# Add the button layout to the main layout
main_layout.addLayout(button_layout)
@@ -101,26 +107,32 @@ class ToolBitLibraryDock(object):
self.form.layout().addWidget(main_widget)
# Connect signals from the browser widget and buttons
self.browser_widget.toolSelected.connect(self._update_state)
self.browser_widget.itemDoubleClicked.connect(partial(self._add_tool_controller_to_doc))
self.browser_widget.toolSelected.connect(lambda x: self._update_state())
self.browser_widget.itemDoubleClicked.connect(self._on_doubleclick)
self.libraryEditorOpenButton.clicked.connect(self._open_editor)
self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc))
self.addToolControllerButton.clicked.connect(self._add_tool_controller_to_doc)
self.closeButton.clicked.connect(self.form.reject)
# Initial state of buttons
# Update the initial state of the UI
self._update_state()
def _count_jobs(self):
if not FreeCAD.ActiveDocument:
return 0
return len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1
def _update_state(self):
"""Enable button to add tool controller when a tool is selected"""
# Set buttons inactive
self.addToolControllerButton.setEnabled(False)
# Check if any tool is selected in the browser widget
selected = self.browser_widget._tool_list_widget.selectedItems()
if selected and FreeCAD.ActiveDocument:
jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1
self.addToolControllerButton.setEnabled(len(selected) >= 1 and jobs)
selected = bool(self.browser_widget.get_selected_bit_uris())
has_job = selected and self._count_jobs() > 0
self.addToolControllerButton.setEnabled(selected and has_job)
def _on_doubleclick(self, toolbit: ToolBit):
"""Opens the ToolBitEditor for the selected toolbit."""
self._add_tool_controller_to_doc()
def _open_editor(self):
library = LibraryEditor()
library = LibraryEditor(parent=FreeCADGui.getMainWindow())
library.open()
# After editing, we might need to refresh the libraries in the browser widget
# Assuming _populate_libraries is the correct method to call
@@ -148,7 +160,7 @@ class ToolBitLibraryDock(object):
return tools
def _add_tool_controller_to_doc(self, index=None):
def _add_tool_controller_to_doc(self):
"""
if no jobs, don't do anything, otherwise all TCs for all
selected toolbit assets

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
from PySide import QtWidgets
import FreeCADGui
import FreeCAD
from ..models.library import Library
class LibraryPropertyDialog(QtWidgets.QDialog):
def __init__(self, library: Library, new=False, parent=None):
super(LibraryPropertyDialog, self).__init__(parent)
self.library = library
# Load the UI file into a QWidget
self.form = FreeCADGui.PySideUic.loadUi(":/panels/LibraryProperties.ui")
# Create a layout for the dialog and add the loaded form widget
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.form)
self.setLayout(layout)
# Connect signals and set initial values using the loaded form
self.form.lineEditLibraryName.setText(self.library.label)
self.update_window_title()
self.form.buttonBox.accepted.connect(self.save_properties)
self.form.buttonBox.rejected.connect(self.reject)
# Connect text changed signal to update window title
self.form.lineEditLibraryName.textChanged.connect(self.update_window_title)
# Make the OK button the default so Enter key works
ok_button = self.form.buttonBox.button(QtWidgets.QDialogButtonBox.Ok)
cancel_button = self.form.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
if cancel_button:
cancel_button.setDefault(False)
cancel_button.setAutoDefault(False)
if ok_button:
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
ok_button.setFocus() # Also set focus to the OK button
# Set minimum width for the dialog
self.setMinimumWidth(450)
# Set focus to the text input so user can start typing immediately
self.form.lineEditLibraryName.setFocus()
self.form.lineEditLibraryName.selectAll() # Select all text for easy replacement
def update_window_title(self):
# Update title based on current text in the line edit
current_name = self.form.lineEditLibraryName.text()
title = FreeCAD.Qt.translate(
"LibraryPropertyDialog", f"Library Properties - {current_name or self.library.label}"
)
self.setWindowTitle(title)
def save_properties(self):
new_name = self.form.lineEditLibraryName.text()
if new_name != self.library.label:
self.library._label = new_name
# Additional logic to save other properties if added later
self.accept()

View File

@@ -10,7 +10,7 @@ from .models.custom import ToolBitShapeCustom
from .models.dovetail import ToolBitShapeDovetail
from .models.drill import ToolBitShapeDrill
from .models.endmill import ToolBitShapeEndmill
from .models.fillet import ToolBitShapeFillet
from .models.radius import ToolBitShapeRadius
from .models.probe import ToolBitShapeProbe
from .models.reamer import ToolBitShapeReamer
from .models.slittingsaw import ToolBitShapeSlittingSaw
@@ -36,7 +36,7 @@ __all__ = [
"ToolBitShapeDovetail",
"ToolBitShapeDrill",
"ToolBitShapeEndmill",
"ToolBitShapeFillet",
"ToolBitShapeRadius",
"ToolBitShapeProbe",
"ToolBitShapeReamer",
"ToolBitShapeSlittingSaw",

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

@@ -52,12 +52,12 @@ class ToolBitShapeBullnose(ToolBitShape):
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"FlatRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Torus radius"),
"CornerRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Corner radius"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Torus")
return FreeCAD.Qt.translate("ToolBitShape", "Bullnose")

View File

@@ -34,9 +34,31 @@ class ToolBitShapeCustom(ToolBitShape):
name: str = "Custom"
aliases = ("custom",)
# Connor: We're going to treat custom tools as normal endmills
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {}
return {
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitToolBitShapeShapeEndMill", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:

View File

@@ -25,23 +25,26 @@ from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeFillet(ToolBitShape):
name = "Fillet"
aliases = ("fillet",)
class ToolBitShapeRadius(ToolBitShape):
name = "Radius"
aliases = (
"radius",
"fillet",
)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CrownHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Crown height"),
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"FilletRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"),
"CuttingRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting radius"),
"App::PropertyLength",
),
"Flutes": (
@@ -60,4 +63,4 @@ class ToolBitShapeFillet(ToolBitShape):
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer")
return FreeCAD.Qt.translate("ToolBitShape", "Radius Mill")

View File

@@ -39,7 +39,6 @@ class ShapeSelector:
self.flows = {}
self.update_shapes()
self.form.toolBox.setCurrentIndex(0)
def _add_shape_group(self, toolbox):
if toolbox in self.flows:
@@ -70,8 +69,10 @@ class ShapeSelector:
custom = cam_assets.fetch(asset_type="toolbitshape", store="local")
for shape in custom:
builtin.pop(shape.id, None)
self._add_shapes(self.form.standardTools, builtin.values())
self._add_shapes(self.form.customTools, custom)
# Combine all shapes into a single list
all_shapes = list(builtin.values()) + list(custom)
self._add_shapes(self.form.toolsContainer, all_shapes)
def on_shape_button_clicked(self, shape):
self.shape = shape

View File

@@ -44,7 +44,7 @@ class ShapeWidget(QtGui.QWidget):
self.layout.setAlignment(QtCore.Qt.AlignHCenter)
self.shape = shape
self.icon_size = icon_size or QtCore.QSize(200, 235)
self.icon_size = icon_size or QtCore.QSize(140, 165) # 200 x 235
self.icon_widget = QtGui.QLabel()
self.layout.addWidget(self.icon_widget)

View File

@@ -10,7 +10,7 @@ from .models.custom import ToolBitCustom
from .models.dovetail import ToolBitDovetail
from .models.drill import ToolBitDrill
from .models.endmill import ToolBitEndmill
from .models.fillet import ToolBitFillet
from .models.radius import ToolBitRadius
from .models.probe import ToolBitProbe
from .models.reamer import ToolBitReamer
from .models.slittingsaw import ToolBitSlittingSaw
@@ -28,7 +28,7 @@ __all__ = [
"ToolBitDovetail",
"ToolBitDrill",
"ToolBitEndmill",
"ToolBitFillet",
"ToolBitRadius",
"ToolBitProbe",
"ToolBitReamer",
"ToolBitSlittingSaw",

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.
@@ -510,6 +513,7 @@ class ToolBit(Asset, ABC):
self._create_base_properties()
# Transfer property values from the detached object to the real object
self._suppress_visual_update = True
temp_obj.copy_to(self.obj)
# Ensure label is set
@@ -517,6 +521,7 @@ class ToolBit(Asset, ABC):
# Update the visual representation now that it's attached
self._update_tool_properties()
self._suppress_visual_update = False
self._update_visual_representation()
def onChanged(self, obj, prop):
@@ -525,6 +530,9 @@ class ToolBit(Asset, ABC):
if "Restore" in obj.State:
return
if getattr(self, "_suppress_visual_update", False):
return
if hasattr(self, "_in_update") and self._in_update:
Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.")
return
@@ -589,9 +597,11 @@ class ToolBit(Asset, ABC):
def get_property(self, name: str):
return self.obj.getPropertyByName(name)
def get_property_str(self, name: str, default: Optional[str] = None) -> Optional[str]:
def get_property_str(
self, name: str, default: str | None = None, precision: int | None = None
) -> str | None:
value = self.get_property(name)
return format_value(value) if value else default
return format_value(value, precision=precision) if value else default
def set_property(self, name: str, value: Any):
return self.obj.setPropertyByName(name, value)
@@ -751,6 +761,7 @@ class ToolBit(Asset, ABC):
Path.Log.track(self.obj.Label)
attrs = {}
attrs["version"] = 2
attrs["id"] = self.id
attrs["name"] = self.obj.Label
attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd"
attrs["shape-type"] = self._tool_bit_shape.name

View File

@@ -36,12 +36,13 @@ class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
diameter = self.get_property_str("Diameter", "?", precision=3)
flutes = self.get_property("Flutes")
cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?")
flat_radius = self.get_property_str("FlatRadius", "?")
cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?", precision=3)
# flat_radius = self.get_property_str("FlatRadius", "?", precision=3)
corner_radius = self.get_property_str("CornerRadius", "?", precision=3)
return FreeCAD.Qt.translate(
"CAM",
f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {flat_radius} flat radius",
f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {corner_radius} corner radius",
)

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

@@ -35,3 +35,32 @@ class ToolBitCustom(ToolBit):
@property
def summary(self) -> str:
return FreeCAD.Qt.translate("CAM", "Unknown custom toolbit type")
# Connor: Adding in getters and setters for diameter and length
def get_diameter(self) -> FreeCAD.Units.Quantity:
"""
Get the diameter of the rotary tool bit from the shape.
"""
return self.obj.Diameter
def set_diameter(self, diameter: FreeCAD.Units.Quantity):
"""
Set the diameter of the rotary tool bit on the shape.
"""
if not isinstance(diameter, FreeCAD.Units.Quantity):
raise ValueError("Diameter must be a FreeCAD Units.Quantity")
self.obj.Diameter = diameter
def get_length(self) -> FreeCAD.Units.Quantity:
"""
Get the length of the rotary tool bit from the shape.
"""
return self.obj.Length
def set_length(self, length: FreeCAD.Units.Quantity):
"""
Set the length of the rotary tool bit on the shape.
"""
if not isinstance(length, FreeCAD.Units.Quantity):
raise ValueError("Length must be a FreeCAD Units.Quantity")
self.obj.Length = length

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 ToolBitProbe(ToolBit):
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
length = self.get_property_str("Length", "?")
shaft_diameter = self.get_property_str("ShaftDiameter", "?")
diameter = self.get_property_str("Diameter", "?", precision=3)
length = self.get_property_str("Length", "?", precision=3)
shaft_diameter = self.get_property_str("ShaftDiameter", "?", precision=3)
return FreeCAD.Qt.translate(
"CAM", f"{diameter} probe, {length} length, {shaft_diameter} shaft"
@@ -46,3 +46,32 @@ class ToolBitProbe(ToolBit):
def can_rotate(self) -> bool:
return False
# Connor: Add getters and setters for Diameter and Length
def get_diameter(self) -> FreeCAD.Units.Quantity:
"""
Get the diameter of the rotary tool bit from the shape.
"""
return self.obj.Diameter
def set_diameter(self, diameter: FreeCAD.Units.Quantity):
"""
Set the diameter of the rotary tool bit on the shape.
"""
if not isinstance(diameter, FreeCAD.Units.Quantity):
raise ValueError("Diameter must be a FreeCAD Units.Quantity")
self.obj.Diameter = diameter
def get_length(self) -> FreeCAD.Units.Quantity:
"""
Get the length of the rotary tool bit from the shape.
"""
return self.obj.Length
def set_length(self, length: FreeCAD.Units.Quantity):
"""
Set the length of the rotary tool bit on the shape.
"""
if not isinstance(length, FreeCAD.Units.Quantity):
raise ValueError("Length must be a FreeCAD Units.Quantity")
self.obj.Length = length

View File

@@ -21,25 +21,25 @@
# ***************************************************************************
import FreeCAD
import Path
from ...shape import ToolBitShapeFillet
from ...shape import ToolBitShapeRadius
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeFillet
class ToolBitRadius(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeRadius
def __init__(self, shape: ToolBitShapeFillet, id: str | None = None):
Path.Log.track(f"ToolBitFillet __init__ called with shape: {shape}, id: {id}")
def __init__(self, shape: ToolBitShapeRadius, id: str | None = None):
Path.Log.track(f"ToolBitRadius __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
radius = self.get_property_str("FilletRadius", "?")
radius = self.get_property_str("CuttingRadius", "?", precision=3)
flutes = self.get_property("Flutes")
diameter = self.get_property_str("ShankDiameter", "?")
diameter = self.get_property_str("ShankDiameter", "?", precision=3)
return FreeCAD.Qt.translate(
"CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute"
"CAM", f"R{radius} radius mill, {diameter} shank, {flutes}-flute"
)

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

@@ -100,15 +100,19 @@ class FCTBSerializer(AssetSerializer):
f"is not a ToolBitShape instance. {dependencies}"
)
# Find the correct ToolBit subclass for the shape
Path.Log.debug(
f"FCTBSerializer.deserialize: shape = {shape!r}, id = {id!r},"
f" params = {shape.get_parameters()}, attrs = {attrs!r}"
)
return ToolBit.from_shape(shape, attrs, id)
@classmethod
def deep_deserialize(cls, data: bytes) -> ToolBit:
"""Deep deserialize preserving the original toolbit ID."""
attrs_map = json.loads(data)
original_id = attrs_map.get("id")
asset_class = cast(ToolBit, cls.for_class)
return asset_class.from_dict(attrs_map)
toolbit = asset_class.from_dict(attrs_map)
if original_id:
toolbit.id = original_id # Preserve the original ID
return toolbit

View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
import yaml
from typing import List, Optional, Mapping, Type
from ...assets.serializer import AssetSerializer
from ...assets.uri import AssetUri
from ...shape import ToolBitShape
from ..models.base import ToolBit
class YamlToolBitSerializer(AssetSerializer):
"""
Serializes and deserializes ToolBit instances to and from YAML.
"""
for_class: Type[ToolBit] = ToolBit
extensions: tuple[str, ...] = (".yaml", ".yml")
mime_type: str = "application/x-yaml"
can_import: bool = True
can_export: bool = True
@classmethod
def get_label(cls) -> str:
return "YAML ToolBit"
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
data_dict = yaml.safe_load(data)
if isinstance(data_dict, dict):
shape_id = data_dict.get("shape")
if shape_id:
# Assuming shape is identified by its ID/name
return [ToolBitShape.resolve_name(str(shape_id))]
return []
@classmethod
def serialize(cls, asset: ToolBit) -> bytes:
"""Serializes a ToolBit instance to bytes (shallow)."""
# Shallow serialization: only serialize direct attributes and shape ID
data = asset.to_dict()
return yaml.dump(data, default_flow_style=False).encode("utf-8")
@classmethod
def deserialize(
cls,
data: bytes,
id: str | None = None,
dependencies: Optional[Mapping[AssetUri, ToolBitShape]] = None,
) -> ToolBit:
"""
Creates a ToolBit instance from serialized data and resolved
dependencies (shallow).
"""
data_dict = yaml.safe_load(data)
if not isinstance(data_dict, dict):
raise ValueError("Invalid YAML data for ToolBit")
toolbit = ToolBit.from_dict(data_dict)
if id:
toolbit.id = id
return toolbit
@classmethod
def deep_deserialize(cls, data: bytes) -> ToolBit:
"""Deep deserialize preserving the original toolbit ID."""
data_dict = yaml.safe_load(data)
if not isinstance(data_dict, dict):
raise ValueError("Invalid YAML data for ToolBit")
original_id = data_dict.get("id") # Extract the original ID
toolbit = ToolBit.from_dict(data_dict)
if original_id:
toolbit.id = original_id # Preserve the original ID
return toolbit

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,19 +72,19 @@ class ToolBitBrowserWidget(QtGui.QWidget):
self._is_fetching = False
self._store_name = store
self._all_assets: List[ToolBit] = [] # Store all fetched assets
self._all_assets: Sequence[ToolBit] = [] # Store all fetched assets
self._current_search = "" # Track current search term
self._scroll_position = 0 # Track scroll position
self._sort_key = "tool_no" if tool_no_factory else "label"
self._selected_uris: List[str] = [] # Track selected toolbit URIs
# UI Elements
self._search_edit = QtGui.QLineEdit()
self._search_edit.setPlaceholderText("Search tools...")
self._search_edit.setPlaceholderText("Search toolbits...")
# Sorting dropdown
self._sort_combo = QtGui.QComboBox()
if self._tool_no_factory:
self._sort_combo.addItem("Sort by Tool Number", "tool_no")
self._sort_combo.addItem("Sort by Toolbit Number", "tool_no")
self._sort_combo.addItem("Sort by Label", "label")
self._sort_combo.setCurrentIndex(0)
self._sort_combo.setVisible(self._tool_no_factory is not None) # Hide if no tool_no_factory
@@ -97,150 +108,375 @@ class ToolBitBrowserWidget(QtGui.QWidget):
self._search_timer = QtCore.QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(self._search_timer_interval)
self._search_timer.timeout.connect(self._trigger_fetch)
self._search_timer.timeout.connect(self._update_list)
self._search_edit.textChanged.connect(self._search_timer.start)
self._sort_combo.currentIndexChanged.connect(self._on_sort_changed)
scrollbar = self._tool_list_widget.verticalScrollBar()
scrollbar.valueChanged.connect(self._on_scroll)
# Connect signals from the list widget
self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
self._tool_list_widget.currentItemChanged.connect(self._on_item_selection_changed)
self._tool_list_widget.itemSelectionChanged.connect(self._on_item_selection_changed)
# Connect list widget context menu request to browser handler
self._tool_list_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self._tool_list_widget.customContextMenuRequested.connect(self._show_context_menu)
# Add keyboard shortcuts
self._add_shortcuts()
# Note that fetching of assets is done at showEvent(),
# because we need to know the widget size to calculate the number
# of items that need to be fetched.
self.tool_fetcher = tool_fetcher or self._tool_fetcher
def showEvent(self, event):
"""Handles the widget show event to trigger initial data fetch."""
super().showEvent(event)
# Fetch all assets the first time the widget is shown
if not self._all_assets and not self._is_fetching:
self._fetch_all_assets()
self.refresh()
# Set focus to the search field
self._search_edit.setFocus()
def _fetch_all_assets(self):
"""Fetches all ToolBit assets and stores them in memory."""
def _tool_fetcher(self) -> Sequence[ToolBit]:
return cast(
List[ToolBit],
self._asset_manager.fetch(
asset_type="toolbit",
depth=0, # do not fetch dependencies (e.g. shape, icon)
store=self._store_name,
),
)
def select_by_uri(self, uris: List[str]):
if not uris:
return
# Select and scroll to the first toolbit
is_first = True
for i in range(self._tool_list_widget.count()):
item = self._tool_list_widget.item(i)
if item.data(ToolBitUriRole) in uris:
self._tool_list_widget.setCurrentItem(item)
if is_first:
# Scroll to the first selected item
is_first = False
self._tool_list_widget.scrollToItem(item)
def refresh(self):
"""Fetches all ToolBit assets and stores them in memory, then updates the UI."""
if self._is_fetching:
return
self._is_fetching = True
try:
self._all_assets = cast(
List[ToolBit],
self._asset_manager.fetch(
asset_type="toolbit",
depth=0, # do not fetch dependencies (e.g. shape, icon)
store=self._store_name,
),
)
self._sort_assets()
self._all_assets = self.tool_fetcher()
finally:
self._is_fetching = False
self._trigger_fetch()
Path.Log.debug(f"Loaded {len(self._all_assets)} ToolBits.")
self._sort_assets()
self._update_list()
def _sort_assets(self):
"""Sorts the in-memory assets based on the current sort key."""
if self._sort_key == "label":
self._all_assets.sort(key=lambda x: x.label.lower())
self._all_assets.sort(key=lambda x: natural_sort_key(x.label))
elif self._sort_key == "tool_no" and self._tool_no_factory:
self._all_assets.sort(
key=lambda x: (int(self._tool_no_factory(x)) or 0) if self._tool_no_factory else 0
key=lambda x: int(self._tool_no_factory(x) or 0) if self._tool_no_factory else 0
)
def _trigger_fetch(self):
"""Initiates a data fetch, clearing the list only if search term changes."""
new_search = self._search_edit.text()
if new_search != self._current_search:
self._current_search = new_search
self._tool_list_widget.clear_list()
self._scroll_position = 0
self._fetch_data()
def _fetch_batch(self, offset):
"""Inserts a batch of filtered assets into the list widget."""
filtered_assets = [
asset
for asset in self._all_assets
if not self._current_search or self._matches_search(asset, self._current_search)
]
end_idx = min(offset + self._batch_size, len(filtered_assets))
for i in range(offset, end_idx):
self._tool_list_widget.add_toolbit(filtered_assets[i])
return end_idx < len(filtered_assets) # Return True if more items remain
def _matches_search(self, toolbit, search_term):
"""Checks if a ToolBit matches the search term."""
search_term = search_term.lower()
return search_term in toolbit.label.lower() or search_term in toolbit.summary.lower()
def _fetch_data(self):
"""Inserts filtered and sorted ToolBit assets into the list widget."""
def _update_list(self):
"""Updates the list widget based on current search and sort."""
if self._is_fetching:
return
self._is_fetching = True
try:
# Save current scroll position and selected item
scrollbar = self._tool_list_widget.verticalScrollBar()
self._scroll_position = scrollbar.value()
selected_uri = self._tool_list_widget.get_selected_toolbit_uri()
# Insert initial batches to fill the viewport
offset = self._tool_list_widget.count()
more_items = True
while more_items:
more_items = self._fetch_batch(offset)
offset += self._batch_size
if scrollbar.maximum() != 0:
break
# Apply filter to ensure UI consistency
self._tool_list_widget.apply_filter(self._current_search)
# Restore scroll position and selection
scrollbar.setValue(self._scroll_position)
if selected_uri:
for i in range(self._tool_list_widget.count()):
item = self._tool_list_widget.item(i)
if item.data(ToolBitUriRole) == selected_uri and not item.isHidden():
self._tool_list_widget.setCurrentItem(item)
break
finally:
self._is_fetching = False
def _on_scroll(self, value):
"""Handles scroll events for lazy batch insertion."""
scrollbar = self._tool_list_widget.verticalScrollBar()
is_near_bottom = value >= scrollbar.maximum() - scrollbar.singleStep()
filtered_count = sum(
1
self._current_search = self._search_edit.text()
filtered_assets = [
asset
for asset in self._all_assets
if not self._current_search or self._matches_search(asset, self._current_search)
)
more_might_exist = self._tool_list_widget.count() < filtered_count
]
if is_near_bottom and more_might_exist and not self._is_fetching:
self._fetch_data()
# Collect current items in the list widget
current_items = {}
for i in range(self._tool_list_widget.count()):
item = self._tool_list_widget.item(i)
uri = item.data(ToolBitUriRole)
if uri:
current_items[uri] = item
# Iterate through filtered assets and update the list widget
for i, asset in enumerate(filtered_assets):
uri = str(asset.get_uri())
if uri in current_items:
# Item exists, remove the old one and insert the new one
item = current_items[uri]
row = self._tool_list_widget.row(item)
self._tool_list_widget.takeItem(row)
self._tool_list_widget.insert_toolbit(i, asset)
del current_items[uri]
else:
# Insert new item
self._tool_list_widget.insert_toolbit(i, asset)
# Remove items that are no longer in filtered_assets
for uri, item in current_items.items():
row = self._tool_list_widget.row(item)
self._tool_list_widget.takeItem(row)
# Restore selection and scroll to the selected item
if self._selected_uris:
first_selected_item = None
for i in range(self._tool_list_widget.count()):
item = self._tool_list_widget.item(i)
uri = item.data(ToolBitUriRole)
if uri in self._selected_uris:
item.setSelected(True)
if first_selected_item is None:
first_selected_item = item
if first_selected_item:
self._tool_list_widget.scrollToItem(first_selected_item)
# Apply the filter to trigger highlighting in the list widget
self._tool_list_widget.apply_filter(self._current_search)
def set_sort_order(self, key: str):
for i in range(self._sort_combo.count()):
if self._sort_combo.itemData(i) == key:
if self._sort_combo.currentIndex() != i:
self._sort_combo.setCurrentIndex(i)
break
else:
return
self._sort_key = key
self._sort_assets()
self._update_list()
def _on_sort_changed(self):
"""Handles sort order change from the dropdown."""
self._sort_key = self._sort_combo.itemData(self._sort_combo.currentIndex())
self._sort_assets()
self._tool_list_widget.clear_list()
self._scroll_position = 0
self._fetch_data()
key = self._sort_combo.itemData(self._sort_combo.currentIndex())
self.set_sort_order(key)
def _on_item_double_clicked(self, item):
"""Emits itemDoubleClicked signal when an item is double-clicked."""
uri = item.data(ToolBitUriRole)
if uri:
self.itemDoubleClicked.emit(uri)
"""Handles double-click on a list item to request editing."""
uri_string = item.data(ToolBitUriRole)
if not uri_string:
return
toolbit = self._asset_manager.get(AssetUri(uri_string))
if toolbit:
self.itemDoubleClicked.emit(toolbit)
def _on_item_selection_changed(self, current_item, previous_item):
"""Emits toolSelected signal when the selection changes."""
uri = None
if current_item:
uri = current_item.data(ToolBitUriRole)
self.toolSelected.emit(uri if current_item else None)
def _on_item_selection_changed(self):
"""Emits toolSelected signal and tracks selected URIs."""
selected_uris = self._tool_list_widget.get_selected_toolbit_uris()
self._selected_uris = selected_uris
if not selected_uris:
return
self.toolSelected.emit(selected_uris[0])
def _get_first_selected_bit(self) -> Optional[ToolBit]:
uris = self.get_selected_bit_uris()
if not uris:
return None
uri_string = uris[0]
return cast(ToolBit, self._asset_manager.get(AssetUri(uri_string)))
def _on_edit_requested(self):
"""Opens the ToolBitEditor for the selected toolbit."""
toolbit = self._get_first_selected_bit()
if not toolbit:
return
# Open the editor for the selected toolbit
editor = ToolBitEditor(toolbit)
result = editor.show()
if result != QDialog.Accepted:
return
# If the editor was closed with "OK", save the changes
self._asset_manager.add(toolbit)
Path.Log.info(f"Toolbit {toolbit.get_id()} saved.")
self.refresh()
self._update_list()
def _add_shortcuts(self):
"""Adds keyboard shortcuts for common actions."""
copy_action = QAction(self)
copy_action.setShortcut(QKeySequence.Copy)
copy_action.triggered.connect(self._on_copy_requested)
self.addAction(copy_action)
delete_action = QAction(self)
delete_action.setShortcut(QKeySequence("Shift+Delete"))
delete_action.triggered.connect(self._on_delete_requested)
self.addAction(delete_action)
edit_action = QAction(self)
edit_action.setShortcut(QKeySequence("F2"))
edit_action.triggered.connect(self._on_edit_requested)
self.addAction(edit_action)
def _create_base_context_menu(self):
"""Creates the base context menu with Edit, Copy, and Delete actions."""
selected_items = self._tool_list_widget.selectedItems()
has_selection = bool(selected_items)
context_menu = QMenu(self)
edit_action = context_menu.addAction("Edit", self._on_edit_requested)
edit_action.setEnabled(has_selection)
context_menu.addSeparator()
action = context_menu.addAction("Copy", self._on_copy_requested)
action.setShortcut(QKeySequence.Copy)
action = context_menu.addAction("Delete from disk", self._on_delete_requested)
action.setShortcut(QKeySequence("Shift+Delete"))
return context_menu
def _show_context_menu(self, position):
"""Shows the context menu at the given position."""
context_menu = self._create_base_context_menu()
context_menu.exec_(self._tool_list_widget.mapToGlobal(position))
def _to_clipboard(
self,
uris: List[str],
mode: str = "copy",
extra_data: Optional[dict] = None,
):
"""Copies selected toolbits to the clipboard as YAML."""
if not uris:
return
selected_bits = [cast(ToolBit, self._asset_manager.get(AssetUri(uri))) for uri in uris]
selected_bits = [bit for bit in selected_bits if bit] # Filter out None
if not selected_bits:
return
# Serialize selected toolbits individually
serialized_toolbits_data = []
for toolbit in selected_bits:
yaml_data = YamlToolBitSerializer.serialize(toolbit)
serialized_toolbits_data.append(yaml_data.decode("utf-8"))
# Create a dictionary to hold the operation type and serialized data
clipboard_data_dict = {
"operation": mode,
"toolbits": serialized_toolbits_data,
}
# Include extra data if provided
if extra_data:
clipboard_data_dict.update(extra_data)
# Serialize the dictionary to YAML
clipboard_content_yaml = yaml.dump(clipboard_data_dict, default_flow_style=False)
# Put the YAML data on the clipboard with a custom MIME type
mime_data = QMimeData()
mime_type = "application/x-freecad-toolbit-list-yaml"
mime_data.setData(mime_type, clipboard_content_yaml.encode("utf-8"))
# Put it in text format for pasting to text editors
toolbit_list = [yaml.safe_load(d) for d in serialized_toolbits_data]
mime_data.setText(yaml.dump(toolbit_list, default_flow_style=False))
clipboard = QApplication.clipboard()
clipboard.setMimeData(mime_data)
def _on_copy_requested(self):
"""Copies selected toolbits to the clipboard as YAML."""
uris = self.get_selected_bit_uris()
self._to_clipboard(uris, mode="copy")
def _on_delete_requested(self):
"""Deletes selected toolbits and removes them from all libraries."""
Path.Log.debug("ToolBitBrowserWidget._on_delete_requested: Function entered.")
uris = self.get_selected_bit_uris()
if not uris:
Path.Log.debug("_on_delete_requested: No URIs selected. Returning.")
return
# Ask for confirmation
reply = QMessageBox.question(
self,
FreeCAD.Qt.translate("CAM", "Confirm Deletion"),
FreeCAD.Qt.translate(
"CAM",
"Are you sure you want to delete the selected toolbit(s)? This is not reversible. The toolbits will be removed from disk and from all libraries that contain them.",
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
deleted_count = 0
libraries_modified = [] # Use list instead of set since Library objects aren't hashable
for uri_string in uris:
try:
toolbit_uri = AssetUri(uri_string)
# First, remove the toolbit from all libraries that contain it
libraries_to_update = self._find_libraries_containing_toolbit(toolbit_uri)
for library in libraries_to_update:
library.remove_bit_by_uri(uri_string)
if library not in libraries_modified: # Avoid duplicates
libraries_modified.append(library)
Path.Log.info(
f"Removed toolbit {toolbit_uri.asset_id} from library {library.label}"
)
# Then delete the toolbit file from disk
self._asset_manager.delete(toolbit_uri)
deleted_count += 1
Path.Log.info(f"Deleted toolbit file {uri_string}")
except Exception as e:
Path.Log.error(f"Failed to delete toolbit {uri_string}: {e}")
# Save all modified libraries
for library in libraries_modified:
try:
self._asset_manager.add(library)
Path.Log.info(f"Saved updated library {library.label}")
except Exception as e:
Path.Log.error(f"Failed to save library {library.label}: {e}")
if deleted_count > 0:
Path.Log.info(
f"Deleted {deleted_count} toolbit(s) and updated {len(libraries_modified)} libraries."
)
self.refresh()
def _find_libraries_containing_toolbit(self, toolbit_uri: AssetUri) -> List:
"""Find all libraries that contain the specified toolbit."""
from ...library.models.library import Library
libraries_with_toolbit = []
try:
# Get all libraries from the asset manager
all_libraries = self._asset_manager.fetch("toolbitlibrary", store="local", depth=1)
for library in all_libraries:
if isinstance(library, Library):
# Check if this library contains the toolbit
for toolbit in library:
if toolbit.get_uri() == toolbit_uri:
libraries_with_toolbit.append(library)
break
except Exception as e:
Path.Log.error(f"Error finding libraries containing toolbit {toolbit_uri}: {e}")
return libraries_with_toolbit
def get_selected_bit_uris(self) -> List[str]:
"""

View File

@@ -49,7 +49,7 @@ class CommandToolBitCreate:
def GetResources(self):
return {
"Pixmap": "CAM_ToolBit",
"MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitCreate", "New Tool"),
"MenuText": QT_TRANSLATE_NOOP("CAM_ToolBitCreate", "New Toolbit"),
"ToolTip": QT_TRANSLATE_NOOP("CAM_ToolBitCreate", "Creates a new toolbit object"),
}

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", "Toolbit"))
form_layout = QtGui.QFormLayout(toolbit_group_box)
form_layout.addRow("Label:", self._label_edit)
form_layout.addRow("ID:", self._id_label)
form_layout.addRow(translate("CAM", "Label:"), self._label_edit)
form_layout.addRow(translate("CAM", "ID:"), self._id_label)
# Optional tool number edit field.
self._tool_no_edit = QtGui.QSpinBox()
self._tool_no_edit.setMinimum(1)
self._tool_no_edit.setMaximum(99999999)
if tool_no is not None:
form_layout.addRow(translate("CAM", "Tool Number:"), self._tool_no_edit)
main_layout = QtGui.QVBoxLayout(self)
main_layout.addWidget(toolbit_group_box)
@@ -93,6 +110,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget):
# Connections
self._label_edit.editingFinished.connect(self._on_label_changed)
self._tool_no_edit.valueChanged.connect(self._on_tool_no_changed)
self._property_editor.propertyChanged.connect(self.toolBitChanged)
if toolbit:
@@ -106,6 +124,12 @@ class ToolBitPropertiesWidget(QtGui.QWidget):
self._toolbit.obj.Label = new_label
self.toolBitChanged.emit()
def _on_tool_no_changed(self, value):
"""Update the tool number when the line edit changes."""
if self._tool_no != value:
self._tool_no = value
self.toolNoChanged.emit(value)
def load_toolbit(self, toolbit: ToolBit):
"""Load a ToolBit object into the editor."""
self._toolbit = toolbit
@@ -114,12 +138,14 @@ class ToolBitPropertiesWidget(QtGui.QWidget):
self._label_edit.clear()
self._label_edit.setEnabled(False)
self._id_label.clear()
self._tool_no_edit.clear()
self._property_editor.setObject(None)
# Clear existing shape widget if any
if self._shape_widget:
self._shape_display_layout.removeWidget(self._shape_widget)
self._shape_widget.deleteLater()
self._shape_widget = None
self._tool_no_edit.setValue(1)
self.setEnabled(False)
return
@@ -127,6 +153,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget):
self._label_edit.setEnabled(True)
self._label_edit.setText(self._toolbit.obj.Label)
self._id_label.setText(self._toolbit.get_id())
self._tool_no_edit.setValue(int(self._tool_no or 1))
# Get properties and suffixes
props_to_show = self._toolbit._get_props(("Shape", "Attributes"))
@@ -214,12 +241,18 @@ class ToolBitEditor(QtGui.QWidget):
# Signals
toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget
def __init__(self, toolbit: ToolBit, parent=None):
def __init__(
self,
toolbit: ToolBit,
tool_no: Optional[int] = None,
parent=None,
icon: bool = False,
):
super().__init__(parent)
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui")
self.toolbit = toolbit
# self.tool_no = tool_no
self.tool_no = tool_no
self.default_title = self.form.windowTitle()
# Get first tab from the form, add the shape widget at the top.
@@ -228,9 +261,9 @@ class ToolBitEditor(QtGui.QWidget):
tool_tab_layout.addWidget(widget)
# Add tool properties editor to the same tab.
props = ToolBitPropertiesWidget(toolbit, self, icon=False)
props = ToolBitPropertiesWidget(toolbit, tool_no, self, icon=icon)
props.toolBitChanged.connect(self._update)
# props.toolNoChanged.connect(self._on_tool_no_changed)
props.toolNoChanged.connect(self._on_tool_no_changed)
tool_tab_layout.addWidget(props)
self.form.tabWidget.setCurrentIndex(0)
@@ -280,5 +313,8 @@ class ToolBitEditor(QtGui.QWidget):
def _on_tool_no_changed(self, value):
self.tool_no = value
def get_tool_no(self):
return self.tool_no
def show(self):
return self.form.exec_()

View File

@@ -41,7 +41,7 @@ class ToolBitSelector(QtWidgets.QDialog):
self.setMinimumSize(600, 400)
self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Tool Bit"))
self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Toolbit"))
self._browser_widget = ToolBitBrowserWidget(cam_assets, compact=compact)

View File

@@ -56,31 +56,31 @@ class TwoLineTableCell(QtGui.QWidget):
self.vbox = QtGui.QVBoxLayout()
self.label_upper = QtGui.QLabel()
self.label_upper.setStyleSheet("margin-top: 8px")
self.label_upper.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
color = interpolate_colors(bg_color, fg_color, 0.8)
style = "margin-bottom: 8px; color: {};".format(color.name())
self.label_lower = QtGui.QLabel()
self.label_lower.setStyleSheet(style)
self.label_lower.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
self.vbox.addWidget(self.label_upper)
self.vbox.addWidget(self.label_lower)
style = "color: {}".format(fg_color.name())
self.label_left = QtGui.QLabel()
self.label_left.setMinimumWidth(40)
self.label_left.setTextFormat(QtCore.Qt.RichText)
self.label_left.setAlignment(QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter)
self.label_left.setStyleSheet(style)
self.label_left.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
ratio = self.devicePixelRatioF()
self.icon_size = QtCore.QSize(50 * ratio, 60 * ratio)
self.icon_widget = QtGui.QLabel()
style = "color: {}".format(fg_color.name())
self.label_right = QtGui.QLabel()
self.label_right.setMinimumWidth(40)
self.label_right.setTextFormat(QtCore.Qt.RichText)
self.label_right.setAlignment(QtCore.Qt.AlignCenter)
self.label_right.setStyleSheet(style)
self.label_right.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
self.hbox = QtGui.QHBoxLayout()
self.hbox.addWidget(self.label_left, 0)

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,21 @@ def to_json(value):
return value
def format_value(value: FreeCAD.Units.Quantity | int | float | None):
def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: int | None = None):
if value is None:
return None
elif isinstance(value, FreeCAD.Units.Quantity):
if precision is not None:
user_val, _, user_unit = value.getUserPreferred()
if user_unit in ("deg", "°", "degree", "degrees"):
# Remove the last character (degree symbol) and convert to float
try:
deg_val = float(str(user_val)[:-1])
except Exception:
return value.getUserPreferred()[0]
formatted_value = f"{deg_val:.1f}".rstrip("0").rstrip(".")
return f"{formatted_value}°"
# Format the value with the specified number of precision and strip trailing zeros
return value.getUserPreferred()[0]
return value.UserString
return str(value)

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 (

View File

@@ -6,7 +6,7 @@
"parameter": {
"CuttingEdgeHeight": "40.0000 mm",
"Diameter": "6.0000 mm",
"FlatRadius": "1.5000 mm",
"CornerRadius": "1.5000 mm",
"Length": "50.0000 mm",
"ShankDiameter": "3.0000 mm"
},

Binary file not shown.

Binary file not shown.

View File

@@ -5,9 +5,9 @@
viewBox="0 0 210 297"
height="297mm"
width="210mm"
sodipodi:docname="fillet.svg"
sodipodi:docname="radius.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
@@ -26,13 +26,13 @@
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.50398562"
inkscape:cx="712.32191"
inkscape:cy="915.70073"
inkscape:window-width="2311"
inkscape:window-height="1509"
inkscape:window-x="1529"
inkscape:window-y="377"
inkscape:zoom="0.41628253"
inkscape:cx="136.92624"
inkscape:cy="599.35256"
inkscape:window-width="1512"
inkscape:window-height="916"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="svg8" /><defs
id="defs2"><linearGradient
@@ -97,7 +97,11 @@
refY="0"
refX="0"
id="marker4584"
style="overflow:visible"><path
style="overflow:visible"
viewBox="0 0 12.70584107 9.5264135"
markerWidth="12.70584106"
markerHeight="9.5264135"
preserveAspectRatio="xMidYMid"><path
id="path4582"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1"
d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
@@ -128,7 +132,11 @@
id="marker3948"
refX="0"
refY="0"
orient="auto"><path
orient="auto"
viewBox="0 0 12.70584107 9.5264135"
markerWidth="12.70584107"
markerHeight="9.5264135"
preserveAspectRatio="xMidYMid"><path
transform="matrix(1.1,0,0,1.1,1.1,0)"
d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1"
@@ -249,7 +257,11 @@
refY="0"
refX="0"
id="marker4856"
style="overflow:visible"><path
style="overflow:visible"
viewBox="0 0 17.77385393 10.15648796"
markerWidth="17.77385393"
markerHeight="10.15648796"
preserveAspectRatio="xMidYMid"><path
id="path4854"
d="M 0,0 5,-5 -12.5,0 5,5 Z"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1"
@@ -329,7 +341,25 @@
y1="117.03271"
x2="136.77219"
y2="117.03271"
gradientUnits="userSpaceOnUse" /></defs><metadata
gradientUnits="userSpaceOnUse" /><marker
orient="auto"
refY="0"
refX="0"
id="marker7593-1"
style="overflow:visible"><path
id="path7591-7"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1"
d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
transform="matrix(1.1,0,0,1.1,1.1,0)" /></marker><marker
orient="auto"
refY="0"
refX="0"
id="marker5072-2"
style="overflow:visible"><path
id="path5070-3"
d="M 0,0 5,-5 -12.5,0 5,5 Z"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1pt;stroke-opacity:1"
transform="matrix(-0.8,0,0,-0.8,-10,0)" /></marker></defs><metadata
id="metadata5"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><path
@@ -362,7 +392,7 @@
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
id="path4542"
d="M 77.321236,58.014043 H 133.53382"
style="fill:none;stroke:#000000;stroke-width:0.682912;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186);marker-end:url(#marker4584)" /><path
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186);marker-end:url(#marker4584)" /><path
id="path4548"
d="M 41.274623,258.30918 H 166.88345"
style="fill:none;stroke:#000000;stroke-width:0.721845;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker3948);marker-end:url(#marker4328)" /><text
@@ -391,35 +421,46 @@
id="path4538-8"
d="M 38.74436,270.09124 V 164.05264"
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
id="path4538-8-9"
d="m 93.265958,250.35549 0,-23.30956"
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path4538-8-9-3"
d="M 114.22859,250.35549 V 227.04593"
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path4548-6"
d="m 175.2226,160.40007 h 18.38097"
style="fill:none;stroke:#000000;stroke-width:0.592962;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
d="m 118.72725,223.34176 74.87632,0"
style="fill:none;stroke:#000000;stroke-width:0.592962;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path4548-6-8"
d="m 132.26064,181.33547 39.1751,40.80318"
style="fill:none;stroke:#000000;stroke-width:0.757536;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186)"
d="m 132.26064,182.39381 39.1751,40.80318"
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186)"
sodipodi:nodetypes="cc" /><path
id="sesr3u8"
d="M 184.26391,158.38661 V 136.96645"
style="fill:none;stroke:#000000;stroke-width:0.66145833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker5072)" /><text
d="m 184.26391,221.22509 0,-84.25864"
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker4584)"
sodipodi:nodetypes="cc" /><text
transform="scale(0.97096033,1.0299082)"
id="cutting_edge_height"
y="123.2775"
x="180.79047"
y="180.48601"
x="191.77032"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;line-height:1.25;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.28362"
xml:space="preserve"><tspan
y="123.2775"
x="180.79047"
y="180.48601"
x="191.77032"
id="tspan7855"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">h</tspan></text><path
id="path936"
d="M 21.764217,82.083367 H 72.731009"
style="fill:none;stroke:#000000;stroke-width:0.987384;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
id="path938"
d="m 19.64521,223.34176 h 83.99998"
style="fill:none;stroke:#000000;stroke-width:0.757536;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
d="m 21.764217,223.34176 66.827826,0"
style="fill:none;stroke:#000000;stroke-width:0.757536;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path940"
d="M 28.028504,219.65219 V 84.84232"
style="fill:none;stroke:#000000;stroke-width:1.17078;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker5072)" /><text
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker4584)" /><text
transform="scale(0.97096033,1.0299082)"
id="length"
y="153.26979"
@@ -435,11 +476,25 @@
style="fill:none;stroke:#000000;stroke-width:0.592962;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><text
transform="scale(0.97096033,1.0299082)"
id="cutting_edge_height-7"
y="217.33482"
x="144.21545"
y="195.87868"
x="156.38969"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;line-height:1.25;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.28362"
xml:space="preserve"><tspan
y="217.33482"
x="144.21545"
y="195.87868"
x="156.38969"
id="tspan7855-5"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">r</tspan></text></svg>
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">r</tspan></text><text
transform="scale(0.97096033,1.0299082)"
id="diameter-9"
y="244.40749"
x="70.96611"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32.286px;line-height:1.25;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.02681"
xml:space="preserve"><tspan
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32.286px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:3.02681"
y="244.40749"
x="70.96611"
id="tspan5690-9">d</tspan></text><path
id="sesr3u8-6"
d="m 112.39408,239.97586 -17.090908,0"
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker3948);marker-end:url(#marker4584)"
sodipodi:nodetypes="cc" /></svg>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.