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:
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
1084
src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg
Normal file
1084
src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableAdd.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 36 KiB |
1084
src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg
Normal file
1084
src/Mod/CAM/Gui/Resources/icons/CAM_ToolTableRemove.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 36 KiB |
77
src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui
Normal file
77
src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_():
|
||||
|
||||
@@ -120,6 +120,7 @@ class AssetPreferencesPage:
|
||||
)
|
||||
return False
|
||||
Path.Preferences.setAssetPath(asset_path)
|
||||
Path.Preferences.setLastToolLibrary("")
|
||||
return True
|
||||
|
||||
def loadSettings(self):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from .library import Library
|
||||
|
||||
__all__ = [
|
||||
"Library",
|
||||
]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
88
src/Mod/CAM/Path/Tool/library/ui/properties.py
Normal file
88
src/Mod/CAM/Path/Tool/library/ui/properties.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
96
src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
Normal file
96
src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
Normal 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
|
||||
@@ -1,6 +1,8 @@
|
||||
from .editor import ToolBitEditorPanel, ToolBitEditor
|
||||
from .browser import ToolBitBrowserWidget
|
||||
|
||||
__all__ = [
|
||||
"ToolBitBrowserWidget",
|
||||
"ToolBitEditor",
|
||||
"ToolBitEditorPanel",
|
||||
]
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -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_()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
32
src/Mod/CAM/Path/Tool/toolbit/ui/util.py
Normal file
32
src/Mod/CAM/Path/Tool/toolbit/ui/util.py
Normal 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)]
|
||||
@@ -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)
|
||||
|
||||
@@ -81,6 +81,7 @@ from CAMTests.TestPathToolShapeIcon import (
|
||||
from CAMTests.TestPathToolBitSerializer import (
|
||||
TestCamoticsToolBitSerializer,
|
||||
TestFCTBSerializer,
|
||||
TestYamlToolBitSerializer,
|
||||
)
|
||||
from CAMTests.TestPathToolLibrary import TestPathToolLibrary
|
||||
from CAMTests.TestPathToolLibrarySerializer import (
|
||||
|
||||
@@ -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.
BIN
src/Mod/CAM/Tools/Shape/radius.fcstd
Normal file
BIN
src/Mod/CAM/Tools/Shape/radius.fcstd
Normal file
Binary file not shown.
@@ -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.
Binary file not shown.
Reference in New Issue
Block a user