CAM: Replace complete tool management (PR 21425)

This commit is contained in:
Samuel Abels
2025-05-19 20:25:00 +02:00
parent 1cfb85a71f
commit d749098dcb
169 changed files with 22274 additions and 2905 deletions

View File

@@ -21,10 +21,20 @@
# ***************************************************************************
import FreeCAD
import os
import Part
import Path
import math
import pathlib
import unittest
from Path.Tool.assets import AssetManager, MemoryStore, DummyAssetSerializer
from Path.Tool.library.serializers import FCTLSerializer
from Path.Tool.toolbit.serializers import FCTBSerializer
from Path.Tool.camassets import ensure_assets_initialized
from Path.Tool.library import Library
from Path.Tool.toolbit import ToolBit
from Path.Tool.shape import ToolBitShape
from Path.Tool.shape.models.icon import ToolBitShapeSvgIcon, ToolBitShapePngIcon
from FreeCAD import Vector
@@ -196,3 +206,43 @@ class PathTestBase(unittest.TestCase):
failed_objects = [o.Name for o in objs if "Invalid" in o.State]
if len(failed_objects) > 0:
self.fail(msg or f"Recompute failed for {failed_objects}")
class PathTestWithAssets(PathTestBase):
"""
A base class that creates an AssetManager, so tests can easily fetch
test data. Examples:
toolbit = self.assets.get("toolbit://ballend")
toolbit = self.assets.get("toolbitshape://chamfer")
"""
__tool_dir = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools"
def setUp(self):
# Set up the manager with an in-memory store.
self.assets: AssetManager = AssetManager()
self.asset_store: MemoryStore = MemoryStore("local")
self.assets.register_store(self.asset_store)
# Register some asset classes.
self.assets.register_asset(Library, FCTLSerializer)
self.assets.register_asset(ToolBit, FCTBSerializer)
self.assets.register_asset(ToolBitShape, DummyAssetSerializer)
self.assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer)
self.assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer)
# Include the built-in assets from src/Mod/CAM/Tools.
# These functions only copy if there are no assets, so this
# must be done BEFORE adding the additional test assets below.
ensure_assets_initialized(self.assets, self.asset_store.name)
# Additional test assets.
for path in pathlib.Path(self.__tool_dir / "Bit").glob("*.fctb"):
self.assets.add_file("toolbit", path)
for path in pathlib.Path(self.__tool_dir / "Shape").glob("*.fcstd"):
self.assets.add_file("toolbitshape", path)
def tearDown(self):
del self.assets
del self.asset_store

View File

@@ -25,7 +25,7 @@ import Part
import Path
import Path.Base.FeedRate as PathFeedRate
import Path.Base.MachineState as PathMachineState
import Path.Tool.Bit as PathToolBit
from Path.Tool.toolbit import ToolBit
import Path.Tool.Controller as PathToolController
import PathScripts.PathUtils as PathUtils
@@ -34,12 +34,13 @@ from CAMTests.PathTestUtils import PathTestBase
def createTool(name="t1", diameter=1.75):
attrs = {
"shape": None,
"name": name,
"name": name or "t1",
"shape": "endmill.fcstd",
"parameter": {"Diameter": diameter},
"attribute": [],
"attribute": {},
}
return PathToolBit.Factory.CreateFromAttrs(attrs, name)
toolbit = ToolBit.from_dict(attrs)
return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument)
class TestPathHelpers(PathTestBase):

View File

@@ -22,7 +22,6 @@
import Path
import Path.Op.Deburr as PathDeburr
import Path.Tool.Bit as PathToolBit
import CAMTests.PathTestUtils as PathTestUtils
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())

View File

@@ -51,11 +51,19 @@ class TestPathPreferences(PathTestUtils.PathTestBase):
def test10(self):
"""Default paths for tools are resolved correctly"""
self.assertTrue(Path.Preferences.pathDefaultToolsPath().endswith("/CAM/Tools/"))
self.assertTrue(Path.Preferences.pathDefaultToolsPath("Bit").endswith("/CAM/Tools/Bit"))
self.assertTrue(
Path.Preferences.pathDefaultToolsPath("Library").endswith("/CAM/Tools/Library")
self.assertEqual(
Path.Preferences.getDefaultAssetPath().parts[-2:],
("CAM", "Tools"),
str(Path.Preferences.getDefaultAssetPath()),
)
self.assertTrue(
Path.Preferences.pathDefaultToolsPath("Template").endswith("/CAM/Tools/Template")
self.assertEqual(
Path.Preferences.getBuiltinToolPath().parts[-2:],
("CAM", "Tools"),
str(Path.Preferences.getBuiltinToolPath()),
)
self.assertEqual(
Path.Preferences.getBuiltinShapePath().parts[-3:],
("CAM", "Tools", "Shape"),
str(Path.Preferences.getBuiltinShapePath()),
)
self.assertEqual(Path.Preferences.getToolBitPath().name, "Bit")

View File

@@ -0,0 +1,41 @@
import unittest
from typing import Any, List, Mapping
from Path.Tool.assets import Asset, AssetUri
class TestAsset(Asset):
asset_type: str = "test_asset"
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def from_bytes(cls, data: bytes, id: str, dependencies: Mapping[AssetUri, Any]) -> Any:
return "dummy_object"
def to_bytes(self) -> bytes:
return b"dummy_serialized_data"
def get_id(self) -> str:
# Dummy implementation for testing purposes
return "dummy_id"
class TestPathToolAsset(unittest.TestCase):
def test_asset_cannot_be_instantiated(self):
with self.assertRaises(TypeError):
Asset() # type: ignore
def test_asset_can_be_instantiated_and_has_members(self):
asset = TestAsset()
self.assertIsInstance(asset, Asset)
self.assertEqual(asset.asset_type, "test_asset")
self.assertEqual(asset.to_bytes(), b"dummy_serialized_data")
self.assertEqual(TestAsset.dependencies(b"some_data"), [])
self.assertEqual(TestAsset.from_bytes(b"some_data", "some_id", {}), "dummy_object")
self.assertEqual(asset.get_id(), "dummy_id")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
import unittest
import asyncio
import hashlib
from typing import Any, Type, Optional, List, Mapping
from Path.Tool.assets.cache import AssetCache, CacheKey
from Path.Tool.assets import (
AssetManager,
Asset,
AssetUri,
AssetSerializer,
DummyAssetSerializer,
MemoryStore,
)
class MockAsset(Asset):
asset_type: str = "mock_asset"
_build_counter = 0
def __init__(
self,
asset_id: str,
raw_data: bytes,
dependencies: Optional[Mapping[AssetUri, Any]] = None,
):
super().__init__() # Initialize Asset ABC
self._asset_id = asset_id # Store id internally
self.raw_data_content = raw_data
self.resolved_dependencies = dependencies or {}
MockAsset._build_counter += 1
self.build_id = MockAsset._build_counter
def get_id(self) -> str: # Implement abstract method
return self._asset_id
# get_uri() is inherited from Asset and uses self.asset_type and self.get_id()
@classmethod
def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
# This mock implementation handles the simple "dep:" format
data_str = data.decode()
if data_str.startswith("dep:"):
try:
# Get content after the first "dep:"
dep_content = data_str.split(":", 1)[1]
except IndexError:
# This case should ideally not be reached if startswith("dep:") is true
# and there's content after "dep:", but good for robustness.
return []
dep_uri_strings = dep_content.split(",")
uris = []
for uri_string in dep_uri_strings:
uri_string = uri_string.strip() # Remove leading/trailing whitespace
if not uri_string:
continue
try:
uris.append(AssetUri(uri_string))
except ValueError:
# This print will now show the full problematic uri_string
print(f"Warning: Could not parse mock dependency URI: '{uri_string}'")
return uris
return []
@classmethod
def from_bytes(
cls: Type["MockAsset"],
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Any]],
serializer: AssetSerializer,
) -> "MockAsset":
return cls(asset_id=id, raw_data=data, dependencies=dependencies)
def to_bytes(self, serializer: AssetSerializer) -> bytes: # Implement abstract method
return self.raw_data_content
class MockAssetB(Asset): # New mock asset class for type 'mock_asset_b'
asset_type: str = "mock_asset_b"
_build_counter = 0 # Separate counter if needed, or share MockAsset's
def __init__(
self,
asset_id: str,
raw_data: bytes,
dependencies: Optional[Mapping[AssetUri, Any]] = None,
):
super().__init__()
self._asset_id = asset_id
self.raw_data_content = raw_data
self.resolved_dependencies = dependencies or {}
MockAssetB._build_counter += 1
self.build_id = MockAssetB._build_counter
def get_id(self) -> str:
return self._asset_id
@classmethod
def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]:
# Keep simple, or adapt if MockAssetB has different dep logic
return []
@classmethod
def from_bytes(
cls: Type["MockAssetB"],
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Any]],
serializer: AssetSerializer,
) -> "MockAssetB":
return cls(asset_id=id, raw_data=data, dependencies=dependencies)
def to_bytes(self, serializer: AssetSerializer) -> bytes:
return self.raw_data_content
def _get_raw_data_hash(raw_data: bytes) -> int:
return int(hashlib.sha256(raw_data).hexdigest(), 16)
class TestPathToolAssetCache(unittest.TestCase):
def setUp(self):
self.cache = AssetCache(max_size_bytes=1000)
def test_put_and_get_simple(self):
key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), ("dep1",))
asset_obj = MockAsset(asset_id="id1", raw_data=b"data1")
self.cache.put(key, asset_obj, len(b"data1"), {"dep_uri_str"})
retrieved = self.cache.get(key)
self.assertIsNotNone(retrieved)
# Assuming retrieved is MockAsset, it will have get_id()
self.assertEqual(retrieved.get_id(), "id1")
def test_get_miss(self):
key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), tuple())
self.assertIsNone(self.cache.get(key))
def test_lru_eviction(self):
asset_data_size = 300
asset1_data = b"a" * asset_data_size
asset2_data = b"b" * asset_data_size
asset3_data = b"c" * asset_data_size
asset4_data = b"d" * asset_data_size
key1 = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(asset1_data), tuple())
key2 = CacheKey("s", "mock_asset://id2", _get_raw_data_hash(asset2_data), tuple())
key3 = CacheKey("s", "mock_asset://id3", _get_raw_data_hash(asset3_data), tuple())
key4 = CacheKey("s", "mock_asset://id4", _get_raw_data_hash(asset4_data), tuple())
self.cache.put(key1, MockAsset("id1", asset1_data), asset_data_size, set())
self.cache.put(key2, MockAsset("id2", asset2_data), asset_data_size, set())
self.cache.put(key3, MockAsset("id3", asset3_data), asset_data_size, set())
self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size)
self.assertIsNotNone(self.cache.get(key1)) # Access key1 to make it MRU
# Adding key4 should evict key2 (oldest after key1 accessed)
self.cache.put(key4, MockAsset("id4", asset4_data), asset_data_size, set())
self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size)
self.assertIsNotNone(self.cache.get(key1))
self.assertIsNone(self.cache.get(key2)) # Evicted
self.assertIsNotNone(self.cache.get(key3))
self.assertIsNotNone(self.cache.get(key4))
def test_invalidate_direct(self):
key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple())
self.cache.put(key, MockAsset("id1", b"data"), 4, set())
retrieved = self.cache.get(key) # Ensure it's there
self.assertIsNotNone(retrieved)
self.cache.invalidate_for_uri("mock_asset://id1")
self.assertIsNone(self.cache.get(key))
self.assertEqual(self.cache.current_size_bytes, 0)
def test_invalidate_recursive(self):
data_a = b"data_a_dep:mock_asset_b://idB" # A depends on B
data_b = b"data_b"
data_c = b"data_c_dep:mock_asset_a://idA" # C depends on A
uri_a_str = "mock_asset_a://idA"
uri_b_str = "mock_asset_b://idB"
uri_c_str = "mock_asset_c://idC"
key_b = CacheKey("s", uri_b_str, _get_raw_data_hash(data_b), tuple())
key_a = CacheKey("s", uri_a_str, _get_raw_data_hash(data_a), (uri_b_str,))
key_c = CacheKey("s", uri_c_str, _get_raw_data_hash(data_c), (uri_a_str,))
self.cache.put(key_b, MockAsset("idB", data_b), len(data_b), set())
self.cache.put(key_a, MockAsset("idA", data_a), len(data_a), {uri_b_str})
self.cache.put(key_c, MockAsset("idC", data_c), len(data_c), {uri_a_str})
self.assertIsNotNone(self.cache.get(key_a))
self.assertIsNotNone(self.cache.get(key_b))
self.assertIsNotNone(self.cache.get(key_c))
self.cache.invalidate_for_uri(uri_b_str) # Invalidate B
self.assertIsNone(self.cache.get(key_a), "Asset A should be invalidated")
self.assertIsNone(self.cache.get(key_b), "Asset B should be invalidated")
self.assertIsNone(self.cache.get(key_c), "Asset C should be invalidated")
self.assertEqual(self.cache.current_size_bytes, 0)
def test_clear_cache(self):
key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple())
self.cache.put(key, MockAsset("id1", b"data"), 4, set())
self.assertNotEqual(self.cache.current_size_bytes, 0)
self.cache.clear()
self.assertIsNone(self.cache.get(key))
self.assertEqual(self.cache.current_size_bytes, 0)
self.assertEqual(len(self.cache._cache_dependencies_map), 0)
self.assertEqual(len(self.cache._cache_dependents_map), 0)
class TestPathToolAssetCacheIntegration(unittest.TestCase):
def setUp(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.manager = AssetManager(cache_max_size_bytes=10 * 1024) # 10KB cache
self.store_name = "test_store"
self.store = MemoryStore(name=self.store_name)
self.manager.register_store(self.store, cacheable=True)
self.manager.register_asset(MockAsset, DummyAssetSerializer)
self.manager.register_asset(MockAssetB, DummyAssetSerializer) # Register the new mock type
MockAsset._build_counter = 0
MockAssetB._build_counter = 0
def tearDown(self):
self.loop.close()
def _run_async(self, coro):
return self.loop.run_until_complete(coro)
def test_get_caches_asset(self):
uri_str = "mock_asset://asset1"
raw_data = b"asset1_data"
self._run_async(self.store.create("mock_asset", "asset1", raw_data))
# First get - should build and cache
asset1 = self.manager.get(uri_str, store=self.store_name)
self.assertIsInstance(asset1, MockAsset)
self.assertEqual(asset1.get_id(), "asset1")
self.assertEqual(MockAsset._build_counter, 1) # Built once
# Second get - should hit cache
asset2 = self.manager.get(uri_str, store=self.store_name)
self.assertIsInstance(asset2, MockAsset)
self.assertEqual(asset2.get_id(), "asset1")
self.assertEqual(MockAsset._build_counter, 1) # Still 1, not rebuilt
self.assertIs(asset1, asset2) # Should be the same instance from cache
def test_get_respects_depth_in_cache_key(self):
uri_str = "mock_asset://asset_depth"
# A depends on B (mock_asset_b://dep_b)
raw_data_a = b"dep:mock_asset_b://dep_b"
raw_data_b = b"dep_b_data"
self._run_async(self.store.create("mock_asset", "asset_depth", raw_data_a))
self._run_async(self.store.create("mock_asset_b", "dep_b", raw_data_b))
# Get with depth=0 (shallow)
asset_shallow = self.manager.get(uri_str, store=self.store_name, depth=0)
self.assertIsInstance(asset_shallow, MockAsset)
self.assertEqual(len(asset_shallow.resolved_dependencies), 0)
self.assertEqual(MockAsset._build_counter, 1) # asset_depth built
# Get with depth=None (full)
asset_full = self.manager.get(uri_str, store=self.store_name, depth=None)
self.assertIsInstance(asset_full, MockAsset)
self.assertEqual(len(asset_full.resolved_dependencies), 1)
# asset_depth (MockAsset) built twice (once shallow, once full)
self.assertEqual(MockAsset._build_counter, 2)
# dep_b (MockAssetB) built once as a dependency of the full asset_depth
self.assertEqual(MockAssetB._build_counter, 1)
# Get shallow again - should hit shallow cache
asset_shallow_2 = self.manager.get(uri_str, store=self.store_name, depth=0)
self.assertIs(asset_shallow, asset_shallow_2)
self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds
self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds
# Get full again - should hit full cache
asset_full_2 = self.manager.get(uri_str, store=self.store_name, depth=None)
self.assertIs(asset_full, asset_full_2)
self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds
self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds
def test_update_invalidates_cache(self):
uri_str = "mock_asset://asset_upd"
raw_data_v1 = b"version1"
raw_data_v2 = b"version2"
asset_uri = AssetUri(uri_str) # Use real AssetUri
self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data_v1))
asset_v1 = self.manager.get(asset_uri, store=self.store_name)
self.assertEqual(asset_v1.raw_data_content, raw_data_v1)
self.assertEqual(MockAsset._build_counter, 1)
# Update the asset in the store (MemoryStore creates new version)
# For this test, let's simulate an update by re-adding with add_raw
# which should trigger invalidation.
# Note: MemoryStore's update creates a new version, so get() would get latest.
# To test invalidation of the *exact* cached object, we'd need to ensure
# the cache key changes (e.g. raw_data_hash).
# Let's use add_raw which calls invalidate.
self.manager.add_raw(
asset_uri.asset_type, asset_uri.asset_id, raw_data_v2, store=self.store_name
)
# Get again - should rebuild because v1 was invalidated
# And MemoryStore's get() for a URI without version gets latest.
# The add_raw invalidates based on URI (type+id), so all versions of it.
asset_v2 = self.manager.get(asset_uri, store=self.store_name)
self.assertEqual(asset_v2.raw_data_content, raw_data_v2)
self.assertEqual(MockAsset._build_counter, 2) # Rebuilt
def test_delete_invalidates_cache(self):
uri_str = "mock_asset://asset_del"
raw_data = b"delete_me"
asset_uri = AssetUri(uri_str)
self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data))
asset1 = self.manager.get(asset_uri, store=self.store_name)
self.assertIsNotNone(asset1)
self.assertEqual(MockAsset._build_counter, 1)
self.manager.delete(asset_uri, store=self.store_name)
with self.assertRaises(FileNotFoundError):
self.manager.get(asset_uri, store=self.store_name)
# Check build counter didn't increase due to trying to get deleted asset
self.assertEqual(MockAsset._build_counter, 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,468 @@
import unittest
import asyncio
from unittest.mock import Mock
import pathlib
import tempfile
from typing import Any, Mapping, List
from Path.Tool.assets import (
AssetManager,
FileStore,
Asset,
AssetUri,
MemoryStore,
AssetSerializer,
DummyAssetSerializer,
)
# Mock Asset class for testing
class MockAsset(Asset):
asset_type: str = "mock_asset"
def __init__(self, data: Any = None, id: str = "mock_id"):
self._data = data
self._id = id
@classmethod
def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]:
# Mock implementation doesn't use data or format for dependencies
return []
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Mapping[AssetUri, Asset] | None,
serializer: AssetSerializer,
) -> "MockAsset":
# Create instance with provided id
return cls(data, id)
def to_bytes(self, serializer: AssetSerializer) -> bytes:
return self._data
def get_id(self) -> str:
return self._id
class TestPathToolAssetManager(unittest.TestCase):
def test_register_store(self):
manager = AssetManager()
mock_store_local = Mock()
mock_store_local.name = "local"
mock_store_remote = Mock()
mock_store_remote.name = "remote"
manager.register_store(mock_store_local)
self.assertEqual(manager.stores["local"], mock_store_local)
manager.register_store(mock_store_remote)
self.assertEqual(manager.stores["remote"], mock_store_remote)
# Test overwriting
mock_store_local_new = Mock()
mock_store_local_new.name = "local"
manager.register_store(mock_store_local_new)
self.assertEqual(manager.stores["local"], mock_store_local_new)
def test_register_asset(self):
manager = AssetManager()
# Register the actual MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset)
# Test registering a different actual Asset class
class AnotherMockAsset(Asset):
asset_type: str = "another_mock_asset"
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Mapping[AssetUri, Asset] | None,
serializer: AssetSerializer,
) -> "AnotherMockAsset":
return cls()
def to_bytes(self, serializer: AssetSerializer) -> bytes:
return b""
def get_id(self) -> str:
return "another_mock_id"
manager.register_asset(AnotherMockAsset, DummyAssetSerializer)
self.assertEqual(manager._asset_classes[AnotherMockAsset.asset_type], AnotherMockAsset)
# Test overwriting
manager.register_asset(
MockAsset, DummyAssetSerializer
) # Registering again should overwrite
self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset)
# Test registering non-Asset class
with self.assertRaises(TypeError):
class NotAnAsset(Asset): # Inherit from Asset
pass
manager.register_asset(NotAnAsset, DummyAssetSerializer)
def test_get(self):
# Setup AssetManager with a real LocalStore and the MockAsset class
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = pathlib.Path(tmpdir)
local_store = FileStore("local", base_dir)
manager = AssetManager()
manager.register_store(local_store)
# Register the MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create a test asset file via AssetManager
test_data = b"test asset data"
test_uri = manager.add_raw(
asset_type=MockAsset.asset_type,
asset_id="dummy_id_get",
data=test_data,
store="local",
)
# Call AssetManager.get
retrieved_object = 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)
# Test error handling for non-existent URI
non_existent_uri = AssetUri.build(MockAsset.asset_type, "non_existent", "1")
with self.assertRaises(FileNotFoundError):
manager.get(non_existent_uri)
# Test error handling for no asset class registered
non_registered_uri = AssetUri.build("non_existent_type", "dummy_id", "1")
# Need to create a dummy file for the store to find
dummy_data = b"dummy"
manager.add_raw(
asset_type="non_existent_type", asset_id="dummy_id", data=dummy_data, store="local"
)
with self.assertRaises(ValueError) as cm:
manager.get(non_registered_uri)
self.assertIn("No asset class registered for asset type:", str(cm.exception))
def test_delete(self):
# Setup AssetManager with a real LocalStore
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = pathlib.Path(tmpdir)
local_store = FileStore("local", base_dir)
manager = AssetManager()
manager.register_store(local_store)
# Create a test asset file
test_data = b"test asset data to delete"
test_uri = manager.add_raw(
asset_type="temp_asset", asset_id="dummy_id_delete", data=test_data, store="local"
)
test_path = base_dir / "temp_asset" / str(test_uri.asset_id) / str(test_uri.version)
self.assertTrue(test_path.exists())
# Call AssetManager.delete
manager.delete(test_uri)
# Verify file deletion
self.assertFalse(test_path.exists())
# Test error handling for non-existent URI (should not raise error
# as LocalStore.delete handles this)
non_existent_uri = AssetUri.build(
"temp_asset", "non_existent", "1" # Keep original for logging
)
manager.delete(non_existent_uri) # Should not raise
def test_create(self):
# Setup AssetManager with LocalStore and MockAsset class
with tempfile.TemporaryDirectory() as tmpdir:
base_dir = pathlib.Path(tmpdir)
local_store = FileStore("local", base_dir)
manager = AssetManager()
manager.register_store(local_store)
# Register the MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create a MockAsset instance with a specific id
test_obj = MockAsset(b"object data", id="mocked_asset_id")
# Call manager.add to create
created_uri = manager.add(test_obj, store="local")
# Assert returned URI is as expected
expected_uri = AssetUri.build(MockAsset.asset_type, "mocked_asset_id", "1")
self.assertEqual(created_uri, expected_uri)
# Verify the asset was created
retrieved_data = asyncio.run(local_store.get(created_uri))
self.assertEqual(retrieved_data, test_obj.to_bytes(DummyAssetSerializer))
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.add(test_obj, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
with tempfile.TemporaryDirectory() as tmpdir:
local_store = MemoryStore("local")
manager = AssetManager()
manager.register_store(local_store)
# Register the MockAsset class
manager.register_asset(MockAsset, DummyAssetSerializer)
# First, create an asset
initial_data = b"initial data"
asset_id = "some_asset_id"
test_uri = manager.add_raw(MockAsset.asset_type, asset_id, initial_data, "local")
self.assertEqual(test_uri.version, "1")
# Create a MockAsset instance with the same id for update
updated_data = b"updated object data"
test_obj = MockAsset(updated_data, id=asset_id)
# Call manager.add to update
updated_uri = manager.add(test_obj, store="local")
# Assert returned URI matches the original except for version
self.assertEqual(updated_uri.asset_type, test_uri.asset_type)
self.assertEqual(updated_uri.asset_id, test_uri.asset_id)
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))
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.add(test_obj, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_create_raw(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_raw")
manager = AssetManager()
manager.register_store(memory_store)
asset_type = "raw_test_type"
asset_id = "raw_test_id"
data = b"raw test data"
# Expected URI with version 1 (assuming MemoryStore uses integer versions)
expected_uri = AssetUri.build(asset_type, asset_id, "1")
# Call manager.add_raw
created_uri = manager.add_raw(
asset_type=asset_type, asset_id=asset_id, data=data, store="memory_raw"
)
# Assert returned URI is correct (check asset_type and asset_id)
self.assertEqual(created_uri.asset_type, asset_type)
self.assertEqual(created_uri.asset_id, asset_id)
self.assertEqual(created_uri, expected_uri)
# Verify data was stored using the actual created_uri
# Await the async get method using asyncio.run
retrieved_data = asyncio.run(memory_store.get(created_uri))
self.assertEqual(retrieved_data, data)
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.add_raw(
asset_type=asset_type, asset_id=asset_id, data=data, store="non_existent_store"
)
self.assertIn("No store registered for name:", str(cm.exception))
def test_get_raw(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_raw_get")
manager = AssetManager()
manager.register_store(memory_store)
test_uri_str = "test_type://test_id/1"
test_uri = AssetUri(test_uri_str)
expected_data = b"retrieved raw data"
# Manually put data into the memory store
manager.add_raw("test_type", "test_id", expected_data, "memory_raw_get")
# Call manager.get_raw using the URI returned by add_raw
retrieved_data = manager.get_raw(test_uri, store="memory_raw_get")
# Assert returned data matches store's result
self.assertEqual(retrieved_data, expected_data)
# Test error handling (store not found)
non_existent_uri = AssetUri("type://id/1")
with self.assertRaises(ValueError) as cm:
manager.get_raw(non_existent_uri, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_is_empty(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_empty")
manager = AssetManager()
manager.register_store(memory_store)
# Test when store is empty
self.assertTrue(manager.is_empty(store="memory_empty"))
# Add an asset and test again
manager.add_raw("test_type", "test_id", b"data", "memory_empty")
self.assertFalse(manager.is_empty(store="memory_empty"))
# Test with asset type
self.assertTrue(manager.is_empty(store="memory_empty", asset_type="another_type"))
self.assertFalse(manager.is_empty(store="memory_empty", asset_type="test_type"))
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.is_empty(store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_count_assets(self):
# Setup AssetManager with a real MemoryStore
memory_store = MemoryStore("memory_count")
manager = AssetManager()
manager.register_store(memory_store)
# Test when store is empty
self.assertEqual(manager.count_assets(store="memory_count"), 0)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 0)
# Add assets and test counts
manager.add_raw("type1", "asset1", b"data1", "memory_count")
self.assertEqual(manager.count_assets(store="memory_count"), 1)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 1)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 0)
manager.add_raw("type2", "asset2", b"data2", "memory_count")
manager.add_raw("type1", "asset3", b"data3", "memory_count")
self.assertEqual(manager.count_assets(store="memory_count"), 3)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 2)
self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 1)
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.count_assets(store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_get_bulk(self):
# Setup AssetManager with a real MemoryStore and MockAsset class
memory_store = MemoryStore("memory_bulk")
manager = AssetManager()
manager.register_store(memory_store)
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create some assets in the memory store
data1 = b"data for id1"
data2 = b"data for id2"
uri1 = manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_bulk")
uri2 = manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_bulk")
uri3 = AssetUri.build(MockAsset.asset_type, "non_existent", "1")
uris = [uri1, uri2, uri3]
# Call manager.get_bulk
retrieved_assets = 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.assertIsInstance(retrieved_assets[1], MockAsset)
self.assertEqual(
retrieved_assets[1].to_bytes(DummyAssetSerializer),
data2,
)
# Assert the non-existent asset is None
self.assertIsNone(retrieved_assets[2])
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.get_bulk(uris, store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
def test_fetch(self):
# Setup AssetManager with a real MemoryStore and MockAsset class
memory_store = MemoryStore("memory_fetch")
manager = AssetManager()
manager.register_store(memory_store)
manager.register_asset(MockAsset, DummyAssetSerializer)
# Create some assets in the memory store
data1 = b"data for id1"
data2 = b"data for id2"
manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch")
manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch")
# Create an asset of a different type
manager.add_raw("another_type", "id3", b"data for id3", "memory_fetch")
AssetUri.build(MockAsset.asset_type, "non_existent", "1")
# Call manager.fetch without filters
# This should raise ValueError because uri3 has an unregistered type
with self.assertRaises(ValueError) as cm:
manager.fetch(store="memory_fetch")
self.assertIn("No asset class registered for asset type:", str(cm.exception))
# Now test fetching with a registered asset type filter
# Setup a new manager and store to avoid state from previous test
memory_store_filtered = MemoryStore("memory_fetch_filtered")
manager_filtered = AssetManager()
manager_filtered.register_store(memory_store_filtered)
manager_filtered.register_asset(MockAsset, DummyAssetSerializer)
# Create assets again
manager_filtered.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch_filtered")
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"
)
# Assert the correct number of assets were returned
self.assertEqual(len(retrieved_assets_filtered), 2)
# 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"),
data1.decode("utf-8"),
)
self.assertIsInstance(retrieved_assets_filtered[1], MockAsset)
self.assertEqual(
retrieved_assets_filtered[1].to_bytes(DummyAssetSerializer).decode("utf-8"),
data2.decode("utf-8"),
)
# Test error handling (store not found)
with self.assertRaises(ValueError) as cm:
manager.fetch(store="non_existent_store")
self.assertIn("No store registered for name:", str(cm.exception))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,389 @@
import unittest
import pathlib
import asyncio
import tempfile
from uuid import uuid4
from Path.Tool.assets import (
AssetUri,
AssetStore,
MemoryStore,
FileStore,
)
class BaseTestPathToolAssetStore(unittest.TestCase):
"""
Base test suite for Path Tool Asset Stores assuming full versioning support.
Store-agnostic tests without direct file system access.
"""
store: AssetStore
def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.tmp_path = pathlib.Path(self.tmp_dir.name)
def tearDown(self):
self.tmp_dir.cleanup()
def test_name(self):
self.assertIsNotNone(self.store)
self.assertIsInstance(self.store.name, str)
self.assertTrue(len(self.store.name) > 0)
def test_create_and_get(self):
async def async_test():
data = b"test data"
asset_type = f"type_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri = await self.store.create(asset_type, asset_id, data)
self.assertIsInstance(uri, AssetUri)
self.assertEqual(uri.asset_type, asset_type)
self.assertEqual(uri.asset_id, asset_id)
self.assertIsNotNone(uri.version)
retrieved_data = await self.store.get(uri)
self.assertEqual(retrieved_data, data)
# Test non-existent URI
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
with self.assertRaises(FileNotFoundError):
await self.store.get(non_existent_uri)
asyncio.run(async_test())
def test_delete(self):
async def async_test():
data = b"data to delete"
asset_type = "delete_type"
asset_id = f"asset_{uuid4()}"
uri = await self.store.create(asset_type, asset_id, data)
await self.store.delete(uri)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri)
# Deleting non-existent URI should not raise
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
await self.store.delete(non_existent_uri)
asyncio.run(async_test())
def test_is_empty(self):
async def async_test():
self.assertTrue(await self.store.is_empty())
self.assertTrue(await self.store.is_empty("type1"))
uri1 = await self.store.create("type1", f"asset_{uuid4()}", b"data")
self.assertFalse(await self.store.is_empty())
self.assertFalse(await self.store.is_empty("type1"))
self.assertTrue(await self.store.is_empty("type2"))
uri2 = await self.store.create("type2", f"asset_{uuid4()}", b"data")
await self.store.delete(uri1)
self.assertFalse(await self.store.is_empty())
self.assertTrue(await self.store.is_empty("type1"))
self.assertFalse(await self.store.is_empty("type2"))
await self.store.delete(uri2)
self.assertTrue(await self.store.is_empty())
asyncio.run(async_test())
def test_count_assets(self):
async def async_test():
self.assertEqual(await self.store.count_assets(), 0)
self.assertEqual(await self.store.count_assets("type1"), 0)
uri1 = await self.store.create("type1", f"asset1_{uuid4()}", b"data1")
self.assertEqual(await self.store.count_assets(), 1)
self.assertEqual(await self.store.count_assets("type1"), 1)
self.assertEqual(await self.store.count_assets("type2"), 0)
uri2 = await self.store.create("type2", f"asset2_{uuid4()}", b"data2")
uri3 = await self.store.create("type1", f"asset3_{uuid4()}", b"data3")
self.assertEqual(await self.store.count_assets(), 3)
self.assertEqual(await self.store.count_assets("type1"), 2)
self.assertEqual(await self.store.count_assets("type2"), 1)
await self.store.delete(uri1)
self.assertEqual(await self.store.count_assets(), 2)
self.assertEqual(await self.store.count_assets("type1"), 1)
self.assertEqual(await self.store.count_assets("type2"), 1)
await self.store.delete(uri2)
await self.store.delete(uri3)
self.assertEqual(await self.store.count_assets(), 0)
self.assertEqual(await self.store.count_assets("type1"), 0)
self.assertEqual(await self.store.count_assets("type2"), 0)
asyncio.run(async_test())
def test_list_assets(self):
async def async_test():
asset_typedata = [
("type1", f"asset1_{uuid4()}", b"data1"),
("type1", f"asset2_{uuid4()}", b"data2"),
("type2", f"asset3_{uuid4()}", b"data3"),
]
uris = []
for asset_type, asset_id, data in asset_typedata:
uri = await self.store.create(asset_type, asset_id, data)
uris.append(uri)
all_assets = await self.store.list_assets()
self.assertEqual(len(all_assets), 3)
for uri in uris:
self.assertTrue(any(u.asset_id == uri.asset_id for u in all_assets))
type1_assets = await self.store.list_assets(asset_type="type1")
self.assertEqual(len(type1_assets), 2)
self.assertTrue(any(u.asset_id == uris[0].asset_id for u in type1_assets))
self.assertTrue(any(u.asset_id == uris[1].asset_id for u in type1_assets))
paginated = await self.store.list_assets(limit=2)
self.assertEqual(len(paginated), 2)
asyncio.run(async_test())
def test_update_versioning(self):
async def async_test():
initial_data = b"initial data"
updated_data = b"updated data"
asset_type = f"update_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri1 = await self.store.create(asset_type, asset_id, initial_data)
uri2 = await self.store.update(uri1, updated_data)
self.assertEqual(uri1.asset_type, uri2.asset_type)
self.assertEqual(uri1.asset_id, uri2.asset_id)
self.assertEqual(uri1.version, "1")
self.assertEqual(uri2.version, "2")
self.assertNotEqual(uri1.version, uri2.version)
self.assertEqual(await self.store.get(uri1), initial_data)
self.assertEqual(await self.store.get(uri2), updated_data)
with self.assertRaises(FileNotFoundError):
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
await self.store.update(non_existent_uri, b"data")
asyncio.run(async_test())
def test_list_versions(self):
async def async_test():
asset_type = f"version_{uuid4()}"
asset_id = f"asset_{uuid4()}"
data1 = b"version1"
data2 = b"version2"
uri1 = await self.store.create(asset_type, asset_id, data1)
uri2 = await self.store.update(uri1, data2)
versions = await self.store.list_versions(uri1)
self.assertEqual(len(versions), 2)
version_ids = {v.version for v in versions if v.version}
self.assertEqual(version_ids, {uri1.version, uri2.version})
non_existent_uri = AssetUri.build(
asset_type="non_existent", asset_id="missing", version="1"
)
self.assertEqual(await self.store.list_versions(non_existent_uri), [])
asyncio.run(async_test())
def test_create_with_empty_data(self):
async def async_test():
data = b""
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
uri = await self.store.create(asset_type, asset_id, data)
self.assertIsInstance(uri, AssetUri)
retrieved_data = await self.store.get(uri)
self.assertEqual(retrieved_data, data)
asyncio.run(async_test())
def test_list_assets_non_existent_type(self):
async def async_test():
assets = await self.store.list_assets(asset_type=f"non_existent_type_{uuid4()}")
self.assertEqual(len(assets), 0)
asyncio.run(async_test())
def test_list_assets_pagination_offset_too_high(self):
async def async_test():
await self.store.create("shape", f"asset1_{uuid4()}", b"data")
assets = await self.store.list_assets(offset=100) # Assuming less than 100 assets
self.assertEqual(len(assets), 0)
asyncio.run(async_test())
def test_list_assets_pagination_limit_zero(self):
async def async_test():
await self.store.create("shape", f"asset1_{uuid4()}", b"data")
assets = await self.store.list_assets(limit=0)
self.assertEqual(len(assets), 0)
asyncio.run(async_test())
def test_create_delete_recreate(self):
async def async_test():
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
data1 = b"first data"
data2 = b"second data"
uri1 = await self.store.create(asset_type, asset_id, data1)
self.assertEqual(await self.store.get(uri1), data1)
# For versioned stores, this would be version "1"
# For stores that don't deeply track versions, it's important what happens on recreate
await self.store.delete(uri1)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri1)
uri2 = await self.store.create(asset_type, asset_id, data2)
self.assertEqual(await self.store.get(uri2), data2)
# Behavior of uri1.version vs uri2.version depends on store implementation
# For a fully versioned store that starts fresh:
self.assertEqual(
uri2.version, "1", "Recreating should yield version 1 for a fresh start"
)
# Ensure only the new asset exists if the store fully removes old versions
versions = await self.store.list_versions(
AssetUri.build(asset_type=asset_type, asset_id=asset_id)
)
self.assertEqual(len(versions), 1)
self.assertEqual(versions[0].version, "1")
asyncio.run(async_test())
def test_get_non_existent_specific_version(self):
async def async_test():
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
await self.store.create(asset_type, asset_id, b"data_v1")
non_existent_version_uri = AssetUri.build(
asset_type=asset_type,
asset_id=asset_id,
version="99", # Assuming version 99 won't exist
)
with self.assertRaises(FileNotFoundError):
await self.store.get(non_existent_version_uri)
asyncio.run(async_test())
def test_delete_last_version(self):
async def async_test():
asset_type = "shape"
asset_id = f"asset_{uuid4()}"
uri_v1 = await self.store.create(asset_type, asset_id, b"v1_data")
uri_v2 = await self.store.update(uri_v1, b"v2_data")
await self.store.delete(uri_v2) # Delete latest version
with self.assertRaises(FileNotFoundError):
await self.store.get(uri_v2)
# v1 should still exist
self.assertEqual(await self.store.get(uri_v1), b"v1_data")
versions_after_v2_delete = await self.store.list_versions(uri_v1)
self.assertEqual(len(versions_after_v2_delete), 1)
self.assertEqual(versions_after_v2_delete[0].version, "1")
await self.store.delete(uri_v1) # Delete the now last version (v1)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri_v1)
versions_after_all_delete = await self.store.list_versions(uri_v1)
self.assertEqual(len(versions_after_all_delete), 0)
# Asset should not appear in list_assets
listed_assets = await self.store.list_assets(asset_type=asset_type)
self.assertFalse(any(a.asset_id == asset_id for a in listed_assets))
self.assertTrue(await self.store.is_empty(asset_type))
asyncio.run(async_test())
def test_get_latest_on_non_existent_asset(self):
async def async_test():
latest_uri = AssetUri.build(
asset_type="shape",
asset_id=f"non_existent_id_for_latest_{uuid4()}",
version="latest",
)
with self.assertRaises(
FileNotFoundError
): # Or custom NoVersionsFoundError if that's how store behaves
await self.store.get(latest_uri)
asyncio.run(async_test())
class TestPathToolFileStore(BaseTestPathToolAssetStore):
"""Test suite for FileStore with full versioning support."""
def setUp(self):
super().setUp()
asset_type_map = {
"*": "{asset_type}/{asset_id}/{version}",
"special1": "Especial/{asset_id}/{version}",
"special2": "my/super/{asset_id}.spcl",
}
self.store = FileStore("versioned", self.tmp_path, asset_type_map)
def test_get_latest_version(self):
async def async_test():
asset_type = f"latest_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri1 = await self.store.create(asset_type, asset_id, b"v1")
await self.store.update(uri1, b"v2")
latest_uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="latest")
self.assertEqual(await self.store.get(latest_uri), b"v2")
asyncio.run(async_test())
def test_delete_all_versions(self):
async def async_test():
asset_type = f"delete_{uuid4()}"
asset_id = f"asset_{uuid4()}"
uri1 = await self.store.create(asset_type, asset_id, b"v1")
await self.store.update(uri1, b"v2")
uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id)
await self.store.delete(uri)
with self.assertRaises(FileNotFoundError):
await self.store.get(uri1)
asyncio.run(async_test())
class TestPathToolMemoryStore(BaseTestPathToolAssetStore):
"""Test suite for MemoryStore."""
def setUp(self):
super().setUp()
self.store = MemoryStore("memory_test")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,101 @@
import unittest
from Path.Tool.assets.uri import AssetUri
class TestPathToolAssetUri(unittest.TestCase):
"""
Test suite for the AssetUri utility class.
"""
def test_uri_parsing_full(self):
uri_string = "remote://asset_id/version?" "param1=value1&param2=value2"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "remote")
self.assertEqual(uri.asset_id, "asset_id")
self.assertEqual(uri.version, "version")
self.assertEqual(uri.params, {"param1": ["value1"], "param2": ["value2"]})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_parsing_local(self):
uri_string = "local://id/2?param=value"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "local")
self.assertEqual(uri.asset_id, "id")
self.assertEqual(uri.version, "2")
self.assertEqual(uri.params, {"param": ["value"]})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_parsing_no_params(self):
uri_string = "file://asset_id/1"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "file")
self.assertEqual(uri.asset_id, "asset_id")
self.assertEqual(uri.version, "1")
self.assertEqual(uri.params, {})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_version_missing(self):
uri_string = "foo://asset"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "foo")
self.assertEqual(uri.asset_id, "asset")
self.assertIsNone(uri.version)
self.assertEqual(uri.params, {})
self.assertEqual(str(uri), uri_string)
def test_uri_parsing_with_version(self):
"""
Test parsing a URI string with asset_type, asset_id, and version.
"""
uri_string = "test_type://test_id/1"
uri = AssetUri(uri_string)
self.assertEqual(uri.asset_type, "test_type")
self.assertEqual(uri.asset_id, "test_id")
self.assertEqual(uri.version, "1")
self.assertEqual(uri.params, {})
self.assertEqual(str(uri), uri_string)
self.assertEqual(repr(uri), f"AssetUri('{uri_string}')")
def test_uri_build_full(self):
expected_uri_string = "local://asset_id/version?param1=value1"
uri = AssetUri.build(
asset_type="local", asset_id="asset_id", version="version", params={"param1": "value1"}
)
self.assertEqual(str(uri), expected_uri_string)
self.assertEqual(uri.asset_type, "local")
self.assertEqual(uri.asset_id, "asset_id")
self.assertEqual(uri.version, "version")
self.assertEqual(uri.params, {"param1": ["value1"]}) # parse_qs always returns list
def test_uri_build_latest_version_no_params(self):
expected_uri_string = "remote://id/latest"
uri = AssetUri.build(asset_type="remote", asset_id="id", version="latest")
self.assertEqual(str(uri), expected_uri_string)
self.assertEqual(uri.asset_type, "remote")
self.assertEqual(uri.asset_id, "id")
self.assertEqual(uri.version, "latest")
self.assertEqual(uri.params, {})
def test_uri_equality(self):
uri1 = AssetUri("local://asset/version")
uri2 = AssetUri("local://asset/version")
uri3 = AssetUri("local://asset/another_version")
self.assertEqual(uri1, uri2)
self.assertNotEqual(uri1, uri3)
self.assertNotEqual(uri1, "not a uri")
def test_uri_parsing_invalid_path_structure(self):
"""
Test that parsing a URI string with an invalid path structure
(more than one component) raises a ValueError.
"""
uri_string = "local://foo/bar/1"
with self.assertRaisesRegex(ValueError, "Invalid URI path structure:"):
AssetUri(uri_string)
if __name__ == "__main__":
unittest.main()

View File

@@ -20,134 +20,62 @@
# * *
# ***************************************************************************
import Path.Tool.Bit as PathToolBit
import CAMTests.PathTestUtils as PathTestUtils
import glob
from typing import cast
import os
TestToolDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "Tools")
TestInvalidDir = os.path.join(
TestToolDir, "some", "silly", "path", "that", "should", "not", "exist"
)
TestToolBitName = "test-path-tool-bit-bit-00.fctb"
TestToolShapeName = "test-path-tool-bit-shape-00.fcstd"
TestToolLibraryName = "test-path-tool-bit-library-00.fctl"
import uuid
import pathlib
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.library import Library
from Path.Tool.shape import ToolBitShapeBullnose
from Path.Tool.toolbit import ToolBitEndmill, ToolBitBullnose
def testToolShape(path=TestToolDir, name=TestToolShapeName):
return os.path.join(path, "Shape", name)
TOOL_DIR = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools"
SHAPE_DIR = TOOL_DIR / "Shape"
BIT_DIR = TOOL_DIR / "Bit"
def testToolBit(path=TestToolDir, name=TestToolBitName):
return os.path.join(path, "Bit", name)
def testToolLibrary(path=TestToolDir, name=TestToolLibraryName):
return os.path.join(path, "Library", name)
def printTree(path, indent):
print("{} {}".format(indent, os.path.basename(path)))
if os.path.isdir(path):
if os.path.basename(path).startswith("__"):
print("{} ...".format(indent))
else:
for foo in sorted(glob.glob(os.path.join(path, "*"))):
printTree(foo, "{} ".format(indent))
class TestPathToolBit(PathTestUtils.PathTestBase):
def test(self):
"""Log test setup directory structure"""
# Enable this test if there are errors showing up in the build system with the
# paths that work OK locally. It'll print out the directory tree, and if it
# doesn't look right you know where to look for it
print()
print("realpath : {}".format(os.path.realpath(__file__)))
print(" Tools : {}".format(TestToolDir))
print(" dir : {}".format(os.path.dirname(os.path.realpath(__file__))))
printTree(os.path.dirname(os.path.realpath(__file__)), " :")
def test00(self):
"""Find a tool shape from file name"""
path = PathToolBit.findToolShape("endmill.fcstd")
self.assertIsNot(path, None)
self.assertNotEqual(path, "endmill.fcstd")
def test01(self):
"""Not find a relative path shape if not stored in default location"""
path = PathToolBit.findToolShape(TestToolShapeName)
self.assertIsNone(path)
def test02(self):
"""Find a relative path shape if it's local to a bit path"""
path = PathToolBit.findToolShape(TestToolShapeName, testToolBit())
self.assertIsNot(path, None)
self.assertEqual(path, testToolShape())
def test03(self):
"""Not find a tool shape from an invalid absolute path."""
path = PathToolBit.findToolShape(testToolShape(TestInvalidDir))
self.assertIsNone(path)
def test04(self):
"""Find a tool shape from a valid absolute path."""
path = PathToolBit.findToolShape(testToolShape())
self.assertIsNot(path, None)
self.assertEqual(path, testToolShape())
def test10(self):
class TestPathToolBit(PathTestWithAssets):
def testGetToolBit(self):
"""Find a tool bit from file name"""
path = PathToolBit.findToolBit("5mm_Endmill.fctb")
self.assertIsNot(path, None)
self.assertNotEqual(path, "5mm_Endmill.fctb")
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.assertIsInstance(toolbit, ToolBitEndmill)
self.assertEqual(toolbit.id, "5mm_Endmill")
def test11(self):
"""Not find a relative path bit if not stored in default location"""
path = PathToolBit.findToolBit(TestToolBitName)
self.assertIsNone(path)
def test12(self):
"""Find a relative path bit if it's local to a library path"""
path = PathToolBit.findToolBit(TestToolBitName, testToolLibrary())
self.assertIsNot(path, None)
self.assertEqual(path, testToolBit())
def test13(self):
"""Not find a tool bit from an invalid absolute path."""
path = PathToolBit.findToolBit(testToolBit(TestInvalidDir))
self.assertIsNone(path)
def test14(self):
"""Find a tool bit from a valid absolute path."""
path = PathToolBit.findToolBit(testToolBit())
self.assertIsNot(path, None)
self.assertEqual(path, testToolBit())
def test20(self):
def testGetLibrary(self):
"""Find a tool library from file name"""
path = PathToolBit.findToolLibrary("Default.fctl")
self.assertIsNot(path, None)
self.assertNotEqual(path, "Default.fctl")
library = self.assets.get("toolbitlibrary://Default")
self.assertIsInstance(library, Library)
self.assertEqual(library.id, "Default")
def test21(self):
"""Not find a relative path library if not stored in default location"""
path = PathToolBit.findToolLibrary(TestToolLibraryName)
self.assertIsNone(path)
def testBullnose(self):
"""Test ToolBitBullnose basic parameters"""
shape = self.assets.get("toolbitshape://bullnose")
shape = cast(ToolBitShapeBullnose, shape)
def test22(self):
"""[skipped] Find a relative path library if it's local to <what?>"""
# this is not a valid test for libraries because t
self.assertTrue(True)
bullnose_bit = ToolBitBullnose(shape, id="mybullnose")
self.assertEqual(bullnose_bit.get_id(), "mybullnose")
def test23(self):
"""Not find a tool library from an invalid absolute path."""
path = PathToolBit.findToolLibrary(testToolLibrary(TestInvalidDir))
self.assertIsNone(path)
bullnose_bit = ToolBitBullnose(shape)
uuid.UUID(bullnose_bit.get_id()) # will raise if not valid UUID
def test24(self):
"""Find a tool library from a valid absolute path."""
path = PathToolBit.findToolBit(testToolBit())
self.assertIsNot(path, None)
self.assertEqual(path, testToolBit())
# 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"))
def testToolBitPickle(self):
"""Test if ToolBit is picklable"""
import pickle
shape = self.assets.get("toolbitshape://bullnose")
shape = cast(ToolBitShapeBullnose, shape)
bullnose_bit = ToolBitBullnose(shape, id="mybullnose")
try:
pickled_bit = pickle.dumps(bullnose_bit)
unpickled_bit = pickle.loads(pickled_bit)
self.assertIsInstance(unpickled_bit, ToolBitBullnose)
self.assertEqual(unpickled_bit.get_id(), "mybullnose")
# Add more assertions here to check if other attributes are preserved
except Exception as e:
self.fail(f"ToolBit is not picklable: {e}")

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * 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 LICENSE 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 FreeCAD *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the ToolBitBrowserWidget."""
import unittest
from unittest.mock import MagicMock
from typing import cast
from Path.Tool.toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole
from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell
from Path.Tool.toolbit.models.base import ToolBit
from .PathTestUtils import PathTestWithAssets
class TestToolBitBrowserWidget(PathTestWithAssets):
"""Tests for ToolBitBrowserWidget using real assets and widgets."""
def setUp(self):
super().setUp() # Call the base class setUp to initialize assets
# The browser widget uses the global cam_assets, which is set up
# by PathTestWithAssets.
self.widget = ToolBitBrowserWidget(self.assets)
def test_initial_fetch(self):
# Verify that the list widget is populated after initialization
# The default test assets include some toolbits.
self.assertGreater(self.widget._tool_list_widget.count(), 0)
# Verify apply_filter was called on the list widget with empty string
# We can check the search_highlight property on the cell widgets
# as apply_filter sets this.
for i in range(self.widget._tool_list_widget.count()):
item = self.widget._tool_list_widget.item(i)
cell = self.widget._tool_list_widget.itemWidget(item)
self.assertIsInstance(cell, TwoLineTableCell)
self.assertEqual(cell.search_highlight, "")
def test_search_filtering(self):
# Simulate typing in the search box
search_term = "Endmill"
self.widget._search_edit.setText(search_term)
# Directly trigger the fetch and filtering logic
self.widget._trigger_fetch()
# Verify that the filter was applied to the list widget
# We can check if items are hidden/shown based on the filter term
# This requires knowing the content of the test assets.
# Assuming '5mm_Endmill' and '10mm_Endmill' contain "Endmill"
# and 'BallEndmill_3mm' does not.
# Re-fetch assets to know their labels/names for verification
all_assets = self.assets.fetch(asset_type="toolbit", depth=0)
expected_visible_uris = set()
for asset in all_assets:
tb = cast(ToolBit, asset)
is_expected = (
search_term.lower() in tb.label.lower() or search_term.lower() in tb.summary.lower()
)
if is_expected:
expected_visible_uris.add(str(tb.get_uri()))
actual_visible_uris = set()
for i in range(self.widget._tool_list_widget.count()):
item = self.widget._tool_list_widget.item(i)
cell = self.widget._tool_list_widget.itemWidget(item)
self.assertIsInstance(cell, TwoLineTableCell)
item_uri = item.data(ToolBitUriRole)
# Verify highlight was called on all cells
self.assertEqual(cell.search_highlight, search_term)
if not item.isHidden():
actual_visible_uris.add(item_uri)
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)
# Select the first item in the list widget
if self.widget._tool_list_widget.count() == 0:
self.skipTest("Not enough toolbits for selection test.")
first_item = self.widget._tool_list_widget.item(0)
self.widget._tool_list_widget.setCurrentItem(first_item)
# Verify signal was emitted with the correct URI
expected_uri = first_item.data(ToolBitUriRole)
mock_slot.assert_called_once_with(expected_uri)
def test_tool_edit_requested_signal(self):
mock_slot = MagicMock()
self.widget.itemDoubleClicked.connect(mock_slot)
# Double-click the first item in the list widget
if self.widget._tool_list_widget.count() == 0:
self.skipTest("Not enough toolbits for double-click test.")
first_item = self.widget._tool_list_widget.item(0)
# Simulate double-click signal emission from the list widget
self.widget._tool_list_widget.itemDoubleClicked.emit(first_item)
# Verify signal was emitted with the correct URI
expected_uri = first_item.data(ToolBitUriRole)
mock_slot.assert_called_once_with(expected_uri)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * 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 LICENSE 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 FreeCAD *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
"""Unit tests for the ToolBitEditorWidget."""
import unittest
from unittest.mock import MagicMock
from Path.Tool.toolbit.ui.editor import ToolBitPropertiesWidget
from Path.Tool.toolbit.models.base import ToolBit
from Path.Tool.shape.ui.shapewidget import ShapeWidget
from Path.Tool.ui.property import BasePropertyEditorWidget
from .PathTestUtils import PathTestWithAssets
class TestToolBitPropertiesWidget(PathTestWithAssets):
"""Tests for ToolBitEditorWidget using real assets and widgets."""
def setUp(self):
super().setUp() # Call the base class setUp to initialize assets
self.widget = ToolBitPropertiesWidget()
def test_load_toolbit(self):
# Get a real ToolBit asset
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.assertIsInstance(toolbit, ToolBit)
self.widget.load_toolbit(toolbit)
# Verify label and ID are populated
self.assertEqual(self.widget._label_edit.text(), toolbit.obj.Label)
self.assertEqual(self.widget._id_label.text(), toolbit.get_id())
# Verify DocumentObjectEditorWidget is populated
self.assertEqual(self.widget._property_editor._obj, toolbit.obj)
# Check if properties were passed to the property editor
self.assertGreater(len(self.widget._property_editor._properties_to_show), 0)
# Verify ShapeWidget is created and populated
self.assertIsNotNone(self.widget._shape_widget)
self.assertIsInstance(self.widget._shape_widget, ShapeWidget)
# We can't easily check the internal shape of ShapeWidget without mocks,
# but we can verify it was created.
def test_label_changed_updates_object(self):
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.widget.load_toolbit(toolbit)
new_label = "Updated Endmill"
self.widget._label_edit.setText(new_label)
# Simulate editing finished signal
self.widget._label_edit.editingFinished.emit()
# Verify the toolbit object's label is updated
self.assertEqual(toolbit.obj.Label, new_label)
def test_property_changed_signal_emitted(self):
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.widget.load_toolbit(toolbit)
mock_slot = MagicMock()
self.widget.toolBitChanged.connect(mock_slot)
# Simulate a property change in the DocumentObjectEditorWidget
# We need to trigger the signal from the real property editor.
# This requires accessing a child editor and emitting its signal.
# Find a child editor (e.g., the first one)
if self.widget._property_editor._property_editors:
first_prop_name = list(self.widget._property_editor._property_editors.keys())[0]
child_editor = self.widget._property_editor._property_editors[first_prop_name]
self.assertIsInstance(child_editor, BasePropertyEditorWidget)
# Emit the propertyChanged signal from the child editor
child_editor.propertyChanged.emit()
# Verify the ToolBitEditorWidget's signal was emitted
mock_slot.assert_called_once()
else:
self.skipTest("DocumentObjectEditorWidget has no property editors.")
def test_save_toolbit(self):
# The save_toolbit method primarily ensures the label is updated
# and potentially calls updateObject on the property editor.
# Since property changes are signal-driven, this method is more
# for explicit save actions.
toolbit = self.assets.get("toolbit://5mm_Endmill")
self.widget.load_toolbit(toolbit)
initial_label = toolbit.obj.Label
new_label = "Another Label"
self.widget._label_edit.setText(new_label)
# Call save_toolbit
self.widget.save_toolbit()
# Verify the label was updated
self.assertEqual(toolbit.obj.Label, new_label)
# We can't easily verify if updateObject was called on the property
# editor without mocks, but we trust the implementation based on
# the DocumentObjectEditorWidget's own tests.
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * 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 LICENSE 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 *
# * *
# ***************************************************************************
"""Unit tests for the ToolBitListWidget."""
import unittest
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
class TestToolBitListWidget(PathTestWithAssets):
"""Tests for ToolBitListWidget using real assets."""
def setUp(self):
super().setUp() # Call the base class setUp to initialize assets
self.widget = ToolBitListWidget()
def test_add_toolbit(self):
# Get a real ToolBit asset
toolbit = self.assets.get("toolbit://5mm_Endmill")
tool_no = 1
self.widget.add_toolbit(toolbit, str(tool_no))
self.assertEqual(self.widget.count(), 1)
item = self.widget.item(0)
self.assertIsNotNone(item)
cell_widget = self.widget.itemWidget(item)
self.assertIsInstance(cell_widget, TwoLineTableCell) # Check against real class
# Verify cell widget properties are set correctly
self.assertEqual(cell_widget.tool_no, str(tool_no))
self.assertEqual(cell_widget.upper_text, toolbit.label)
# Assuming the 5mm_Endmill asset has a shape named 'Endmill'
self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge")
# Verify URI is stored in item data
stored_uri = item.data(ToolBitUriRole)
self.assertEqual(stored_uri, str(toolbit.get_uri()))
def test_clear_list(self):
# Add some real items first
toolbit1 = self.assets.get("toolbit://5mm_Endmill")
toolbit2 = self.assets.get("toolbit://slittingsaw")
self.widget.add_toolbit(toolbit1, 1)
self.widget.add_toolbit(toolbit2, 2)
self.assertEqual(self.widget.count(), 2)
self.widget.clear_list()
self.assertEqual(self.widget.count(), 0)
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")
self.widget.add_toolbit(toolbit1, 1)
self.widget.add_toolbit(toolbit2, 2)
self.widget.add_toolbit(toolbit3, 3)
items = [self.widget.item(i) for i in range(self.widget.count())]
cells = [self.widget.itemWidget(item) for item in items]
# Test filter "Endmill"
self.widget.apply_filter("Endmill")
self.assertFalse(items[0].isHidden()) # 5mm Endmill
self.assertTrue(items[1].isHidden()) # slittingsaw
self.assertTrue(items[2].isHidden()) # probe
# Verify highlight was called on all cells
for cell in cells:
self.assertEqual(cell.search_highlight, "Endmill")
# Test filter "Ballnose"
self.widget.apply_filter("Ballnose")
self.assertTrue(items[0].isHidden()) # 5mm Endmill
self.assertTrue(items[1].isHidden()) # slittingsaw
self.assertTrue(items[2].isHidden()) # probe
# Verify highlight was called again
for cell in cells:
self.assertEqual(cell.search_highlight, "Ballnose")
# Test filter "3mm"
self.widget.apply_filter("3mm")
self.assertTrue(items[0].isHidden()) # 5mm Endmill
self.assertTrue(items[1].isHidden()) # slittingsaw
self.assertTrue(items[2].isHidden()) # probe
# Verify highlight was called again
for cell in cells:
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")
self.widget.add_toolbit(toolbit1, 1)
self.widget.add_toolbit(toolbit2, 2)
# No selection initially
self.assertIsNone(self.widget.get_selected_toolbit_uri())
# Select the first item
self.widget.setCurrentItem(self.widget.item(0))
self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit1.get_uri()))
# Select the second item
self.widget.setCurrentItem(self.widget.item(1))
self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit2.get_uri()))
# Clear selection (simulate by setting current item to None)
self.widget.setCurrentItem(None)
self.assertIsNone(self.widget.get_selected_toolbit_uri())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * 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 LICENSE 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 *
# * *
# ***************************************************************************
"""Unit tests for the Property Editor Widgets."""
import unittest
import FreeCAD
from Path.Tool.ui.property import (
BasePropertyEditorWidget,
QuantityPropertyEditorWidget,
BoolPropertyEditorWidget,
IntPropertyEditorWidget,
EnumPropertyEditorWidget,
LabelPropertyEditorWidget,
)
from Path.Tool.toolbit.docobject import DetachedDocumentObject
class TestPropertyEditorFactory(unittest.TestCase):
"""Tests the BasePropertyEditorWidget.for_property factory method."""
def setUp(self):
# Use the real DetachedDocumentObject
self.obj = DetachedDocumentObject()
# Add properties using the DetachedDocumentObject API with correct signature
self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property")
self.obj.Length = FreeCAD.Units.Quantity(10.0) # Set value separately
self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property")
self.obj.IsEnabled = True # Set value separately
self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property")
self.obj.Count = 5 # Set value separately
self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property")
# Set enums and initial value separately
self.obj.Mode = ["Auto", "Manual"]
self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property")
self.obj.Comment = "Test" # Set value separately
def test_quantity_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Length")
self.assertIsInstance(widget, QuantityPropertyEditorWidget)
def test_bool_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "IsEnabled")
self.assertIsInstance(widget, BoolPropertyEditorWidget)
def test_int_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Count")
self.assertIsInstance(widget, IntPropertyEditorWidget)
def test_enum_creation(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Mode")
self.assertIsInstance(widget, EnumPropertyEditorWidget)
def test_label_creation_for_string(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "Comment")
self.assertIsInstance(widget, LabelPropertyEditorWidget)
def test_label_creation_for_invalid_prop(self):
widget = BasePropertyEditorWidget.for_property(self.obj, "NonExistent")
self.assertIsInstance(widget, LabelPropertyEditorWidget)
class TestQuantityPropertyEditorWidget(unittest.TestCase):
"""Tests for QuantityPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property")
self.obj.Length = FreeCAD.Units.Quantity("10.0 mm")
self.widget = QuantityPropertyEditorWidget(self.obj, "Length")
# Access the real editor widget
self.editor = self.widget._editor_widget
self.widget.updateWidget()
def test_update_property(self):
# Check if the real widget's value is updated
self.assertEqual(self.editor.property("rawValue"), 10.0)
# Check if the real widget's value and unit are updated
self.assertEqual(self.editor.property("value").UserString, "10.00 mm")
# Simulate changing the raw value and check if the object's value updates
self.editor.lineEdit().setText("12.0")
self.widget.updateProperty()
self.assertEqual(self.obj.Length.Value, 12.0)
self.assertEqual(self.obj.Length.UserString, "12.00 mm")
# Try assignment with unit.
self.editor.lineEdit().setText("15.5 in")
self.widget.updateProperty()
updated_value = self.obj.getPropertyByName("Length")
self.assertIsInstance(updated_value, FreeCAD.Units.Quantity)
self.assertEqual(updated_value, FreeCAD.Units.Quantity("15.5 in"))
class TestBoolPropertyEditorWidget(unittest.TestCase):
"""Tests for BoolPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property")
self.obj.IsEnabled = True
self.widget = BoolPropertyEditorWidget(self.obj, "IsEnabled")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.currentIndex(), 1) # True is index 1
self.obj.setPropertyByName("IsEnabled", False)
self.widget.updateWidget()
self.assertEqual(self.editor.currentIndex(), 0) # False is index 0
def test_update_property(self):
# Simulate user changing value in the combobox
self.editor.setCurrentIndex(0) # Select False
self.widget._on_index_changed(0)
self.assertEqual(self.obj.getPropertyByName("IsEnabled"), False)
self.editor.setCurrentIndex(1) # Select True
self.widget._on_index_changed(1)
self.assertEqual(self.obj.getPropertyByName("IsEnabled"), True)
class TestIntPropertyEditorWidget(unittest.TestCase):
"""Tests for IntPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property")
self.obj.Count = 5
self.widget = IntPropertyEditorWidget(self.obj, "Count")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.value(), 5)
self.obj.setPropertyByName("Count", 100)
self.widget.updateWidget()
self.assertEqual(self.editor.value(), 100)
def test_update_property(self):
# Simulate user changing value in the spinbox
self.editor.setValue(42)
self.widget.updateProperty()
self.assertEqual(self.obj.getPropertyByName("Count"), 42)
class TestEnumPropertyEditorWidget(unittest.TestCase):
"""Tests for EnumPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property")
self.obj.Mode = ["Auto", "Manual", "Semi"] # Set enums and initial value
self.widget = EnumPropertyEditorWidget(self.obj, "Mode")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_populate_enum(self):
# Check if the real widget is populated
self.assertEqual(self.editor.count(), 3)
self.assertEqual(self.editor.itemText(0), "Auto")
self.assertEqual(self.editor.itemText(1), "Manual")
self.assertEqual(self.editor.itemText(2), "Semi")
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.currentText(), "Auto")
self.obj.setPropertyByName("Mode", "Manual")
self.widget.updateWidget()
self.assertEqual(self.editor.currentText(), "Manual")
def test_update_property(self):
# Simulate user changing value in the combobox
self.editor.setCurrentIndex(1) # Select Manual
self.widget._on_index_changed(1)
self.assertEqual(self.obj.getPropertyByName("Mode"), "Manual")
self.editor.setCurrentIndex(2) # Select Semi
self.widget._on_index_changed(2)
self.assertEqual(self.obj.getPropertyByName("Mode"), "Semi")
class TestLabelPropertyEditorWidget(unittest.TestCase):
"""Tests for LabelPropertyEditorWidget."""
def setUp(self):
self.obj = DetachedDocumentObject()
self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property")
self.obj.Comment = "Test Comment"
self.widget = LabelPropertyEditorWidget(self.obj, "Comment")
# Access the real editor widget
self.editor = self.widget._editor_widget
def test_update_widget(self):
self.widget.updateWidget()
# Check if the real widget's value is updated
self.assertEqual(self.editor.text(), "Test Comment")
self.obj.setPropertyByName("Comment", "New Comment")
self.widget.updateWidget()
self.assertEqual(self.editor.text(), "New Comment")
def test_update_property_is_noop(self):
# updateProperty should do nothing for a read-only label
self.widget.updateProperty()
# No assertions needed, just ensure it doesn't raise errors
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,134 @@
import json
from typing import Type, cast
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.toolbit import ToolBit, ToolBitEndmill
from Path.Tool.toolbit.serializers import (
FCTBSerializer,
CamoticsToolBitSerializer,
)
from Path.Tool.assets.asset import Asset
from Path.Tool.assets.serializer import AssetSerializer
from Path.Tool.assets.uri import AssetUri
from Path.Tool.shape import ToolBitShapeEndmill
from typing import Mapping
class _BaseToolBitSerializerTestCase(PathTestWithAssets):
"""Base test case for ToolBit Serializers."""
__test__ = False
serializer_class: Type[AssetSerializer]
test_tool_bit: ToolBit
def setUp(self):
"""Create a tool bit for each test."""
super().setUp()
if self.serializer_class is None or not issubclass(self.serializer_class, AssetSerializer):
raise NotImplementedError("Subclasses must define a valid serializer_class")
self.test_tool_bit = cast(ToolBitEndmill, self.assets.get("toolbit://5mm_Endmill"))
self.test_tool_bit.label = "Test Tool"
self.test_tool_bit.set_diameter(FreeCAD.Units.Quantity("4.12 mm"))
self.test_tool_bit.set_length(FreeCAD.Units.Quantity("15.0 mm"))
def test_serialize(self):
"""Test serialization of a toolbit."""
if self.test_tool_bit is None:
raise NotImplementedError("Subclasses must define a test_tool_bit")
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
self.assertIsInstance(serialized_data, bytes)
def test_extract_dependencies(self):
"""Test dependency extraction."""
# This test assumes that the serializers don't have dependencies
# and can be overridden in subclasses if needed.
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
dependencies = self.serializer_class.extract_dependencies(serialized_data)
self.assertIsInstance(dependencies, list)
self.assertEqual(len(dependencies), 0)
class TestCamoticsToolBitSerializer(_BaseToolBitSerializerTestCase):
serializer_class = CamoticsToolBitSerializer
def test_serialize(self):
super().test_serialize()
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
# Camotics specific assertions
expected_substrings = [
b'"units": "metric"',
b'"shape": "Cylindrical"',
b'"length": 15',
b'"diameter": 4.12',
b'"description": "Test Tool"',
]
for substring in expected_substrings:
self.assertIn(substring, serialized_data)
def test_deserialize(self):
# Create a known serialized data string based on the Camotics format
camotics_data = (
b'{"units": "metric", "shape": "Cylindrical", "length": 15, '
b'"diameter": 4.12, "description": "Test Tool"}'
)
deserialized_bit = cast(
ToolBitEndmill,
self.serializer_class.deserialize(camotics_data, id="test_id", dependencies=None),
)
self.assertIsInstance(deserialized_bit, ToolBit)
self.assertEqual(deserialized_bit.label, "Test Tool")
self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm")
self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm")
self.assertEqual(deserialized_bit.get_shape_name(), "Endmill")
class TestFCTBSerializer(_BaseToolBitSerializerTestCase):
serializer_class = FCTBSerializer
def test_serialize(self):
super().test_serialize()
serialized_data = self.serializer_class.serialize(self.test_tool_bit)
# FCTB specific assertions (JSON format)
data = json.loads(serialized_data.decode("utf-8"))
self.assertEqual(data.get("name"), "Test Tool")
self.assertEqual(data.get("shape"), "endmill.fcstd")
self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm")
self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm", data)
def test_extract_dependencies(self):
"""Test dependency extraction for FCTB."""
fctb_data = (
b'{"name": "Test Tool", "pocket": null, "shape": "endmill", '
b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}'
)
dependencies = self.serializer_class.extract_dependencies(fctb_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 FCTB format
fctb_data = (
b'{"name": "Test Tool", "pocket": null, "shape": "endmill", '
b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}'
)
# 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(fctb_data, id="test_id", dependencies=dependencies),
)
self.assertIsInstance(deserialized_bit, ToolBit)
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")

View File

@@ -21,10 +21,8 @@
# ***************************************************************************
import FreeCAD
import Path
import Path.Tool.Bit as PathToolBit
from Path.Tool.toolbit import ToolBit
import Path.Tool.Controller as PathToolController
from CAMTests.PathTestUtils import PathTestBase
@@ -39,12 +37,14 @@ class TestPathToolController(PathTestBase):
def createTool(self, name="t1", diameter=1.75):
attrs = {
"shape": None,
"name": name,
"name": name or "t1",
"shape": "endmill.fcstd",
"parameter": {"Diameter": diameter},
"attribute": [],
"attribute": {},
}
return PathToolBit.Factory.CreateFromAttrs(attrs, name)
print(f"Debug: attrs['attribute'] is {attrs['attribute']}")
toolbit = ToolBit.from_dict(attrs)
return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument)
def test00(self):
"""Verify ToolController templateAttrs"""
@@ -72,7 +72,7 @@ class TestPathToolController(PathTestBase):
self.assertEqual(attrs["hrapid"], "28.0 mm/s")
self.assertEqual(attrs["dir"], "Reverse")
self.assertEqual(attrs["speed"], 12000)
self.assertEqual(attrs["tool"], t.Proxy.templateAttrs(t))
self.assertEqual(attrs["tool"], t.Proxy.to_dict())
return tc

View File

@@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2024 FreeCAD Team *
# * *
# * 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 LICENSE 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 *
# * *
# ***************************************************************************
"""Unit tests for the DocumentObjectEditorWidget."""
import unittest
from unittest.mock import MagicMock
import FreeCAD
from PySide import QtGui
from Path.Tool.ui.property import (
BasePropertyEditorWidget,
QuantityPropertyEditorWidget,
BoolPropertyEditorWidget,
IntPropertyEditorWidget,
EnumPropertyEditorWidget,
LabelPropertyEditorWidget,
)
from Path.Tool.ui.docobject import DocumentObjectEditorWidget, _get_label_text
from Path.Tool.toolbit.docobject import DetachedDocumentObject
class TestDocumentObjectEditorWidget(unittest.TestCase):
"""Tests for DocumentObjectEditorWidget."""
def test_populate_form(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
obj.addProperty("App::PropertyLength", "Prop3", "Group1", "Doc3")
obj.Prop3 = FreeCAD.Units.Quantity(5.0, "mm")
obj.addProperty("App::PropertyBool", "Prop4", "Group1", "Doc4")
obj.Prop4 = False
obj.addProperty("App::PropertyEnumeration", "Prop5", "Group1", "Doc5")
obj.Prop5 = ["OptionA", "OptionB"]
properties_to_show = ["Prop1", "Prop2", "Prop3", "Prop4", "Prop5", "NonExistent"]
property_suffixes = {"Prop1": "Suffix1", "Prop3": "Len"}
widget = DocumentObjectEditorWidget(
obj=obj, properties_to_show=properties_to_show, property_suffixes=property_suffixes
)
# Verify the layout contains the correct number of rows (excluding non-existent)
expected_row_count = len([p for p in properties_to_show if hasattr(obj, p)])
self.assertEqual(widget._layout.rowCount(), expected_row_count)
# Verify labels and widgets are added correctly and are of the expected types
prop_names_in_layout = []
for i in range(widget._layout.rowCount()):
label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole)
field_item = widget._layout.itemAt(i, QtGui.QFormLayout.FieldRole)
self.assertIsNotNone(label_item)
self.assertIsNotNone(field_item)
label_widget = label_item.widget()
field_widget = field_item.widget()
self.assertIsInstance(label_widget, QtGui.QLabel)
self.assertIsInstance(
field_widget, BasePropertyEditorWidget
) # Check against base class
# Determine the property name from the label text (reverse of _get_label_text)
# This is a bit fragile, but necessary without storing prop_name in the label widget
label_text = label_widget.text()
prop_name = None
for original_prop_name in properties_to_show:
expected_label = _get_label_text(original_prop_name)
suffix = property_suffixes.get(original_prop_name)
if suffix:
expected_label = f"{expected_label} ({suffix}):"
else:
expected_label = f"{expected_label}:"
if label_text == expected_label:
prop_name = original_prop_name
break
self.assertIsNotNone(
prop_name, f"Could not determine property name for label: {label_text}"
)
prop_names_in_layout.append(prop_name)
# Verify widget type based on property type
if prop_name == "Prop1":
self.assertIsInstance(field_widget, LabelPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop1 (Suffix1):")
elif prop_name == "Prop2":
self.assertIsInstance(field_widget, IntPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop2:")
elif prop_name == "Prop3":
self.assertIsInstance(field_widget, QuantityPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop3 (Len):")
elif prop_name == "Prop4":
self.assertIsInstance(field_widget, BoolPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop4:")
elif prop_name == "Prop5":
self.assertIsInstance(field_widget, EnumPropertyEditorWidget)
self.assertEqual(label_widget.text(), "Prop5:")
# Verify property editors are stored
self.assertEqual(len(widget._property_editors), expected_row_count)
for prop_name in prop_names_in_layout:
self.assertIn(prop_name, widget._property_editors)
self.assertIsInstance(widget._property_editors[prop_name], BasePropertyEditorWidget)
def test_set_object(self):
obj1 = DetachedDocumentObject()
obj1.addProperty("App::PropertyString", "PropA", "GroupA", "DocA")
obj1.PropA = "ValueA"
obj2 = DetachedDocumentObject()
obj2.addProperty("App::PropertyString", "PropA", "GroupA", "DocA")
obj2.PropA = "ValueB"
properties_to_show = ["PropA"]
widget = DocumentObjectEditorWidget(obj=obj1, properties_to_show=properties_to_show)
# Get the initial editor widget instance
initial_editor = widget._property_editors["PropA"]
self.assertIsInstance(initial_editor, BasePropertyEditorWidget)
# Set a new object
widget.setObject(obj2)
# Verify that the editor widget instance is the same
self.assertEqual(widget._property_editors["PropA"], initial_editor)
# Verify that attachTo was called on the existing editor widget
# This requires the real attachTo method to be implemented correctly
# and the editor widget to update its internal object reference.
# We can't easily assert the internal state change without mocks,
# but we can trust the implementation of attachTo in PropertyEditorWidget.
# We can verify updateWidget was called.
# Note: This test relies on the side effect of attachTo calling updateWidget
# in the real implementation.
# We can't directly assert method calls without mocks, so we'll rely on
# the fact that setting the object and then updating the UI should
# reflect the new object's values if attachTo worked.
widget.updateUI()
# Check if the child widget's display reflects obj2's value
# This requires accessing the child widget's internal editor widget
# which might be fragile. A better approach is to trust the unit tests
# for the individual PropertyEditorWidgets and focus on the
# DocumentObjectEditorWidget's logic of calling attachTo and updateUI.
def test_set_properties_to_show(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"])
# Store the initial editor instance for Prop1
initial_prop1_editor = widget._property_editors["Prop1"]
# Set new properties to show
new_properties_to_show = ["Prop1", "Prop2"]
new_suffixes = {"Prop2": "Suffix2"}
widget.setPropertiesToShow(new_properties_to_show, new_suffixes)
# Verify that the form was repopulated with the new properties
self.assertEqual(widget._layout.rowCount(), len(new_properties_to_show))
# Verify property editors are updated and new ones created
self.assertEqual(len(widget._property_editors), len(new_properties_to_show))
self.assertIn("Prop1", widget._property_editors)
self.assertIn("Prop2", widget._property_editors)
# Verify that the editor for Prop1 is a *new* instance after repopulation
self.assertIsNot(widget._property_editors["Prop1"], initial_prop1_editor)
self.assertIsInstance(widget._property_editors["Prop2"], IntPropertyEditorWidget)
# Verify labels including suffixes
prop_names_in_layout = []
for i in range(widget._layout.rowCount()):
label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole)
label_widget = label_item.widget()
label_text = label_widget.text()
prop_name = None
for original_prop_name in new_properties_to_show:
expected_label = _get_label_text(original_prop_name)
suffix = new_suffixes.get(original_prop_name)
if suffix:
expected_label = f"{expected_label} ({suffix}):"
else:
expected_label = f"{expected_label}:"
if label_text == expected_label:
prop_name = original_prop_name
break
prop_names_in_layout.append(prop_name)
self.assertIn("Prop1", prop_names_in_layout)
self.assertIn("Prop2", prop_names_in_layout)
self.assertEqual(
widget._layout.itemAt(0, QtGui.QFormLayout.LabelRole).widget().text(), "Prop1:"
)
self.assertEqual(
widget._layout.itemAt(1, QtGui.QFormLayout.LabelRole).widget().text(),
"Prop2 (Suffix2):",
)
def test_property_changed_signal(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"])
# Connect to the widget's propertyChanged signal
mock_slot = MagicMock()
widget.propertyChanged.connect(mock_slot)
# Get the real child editor widget
child_editor = widget._property_editors["Prop1"]
self.assertIsInstance(child_editor, BasePropertyEditorWidget)
# Emit the signal from the real child editor
child_editor.propertyChanged.emit()
# Verify that the widget's signal was emitted
mock_slot.assert_called_once()
def test_update_ui(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
properties_to_show = ["Prop1", "Prop2"]
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show)
# Get the real child editor widgets
editor1 = widget._property_editors["Prop1"]
editor2 = widget._property_editors["Prop2"]
# Mock their updateWidget methods to check if they are called
editor1.updateWidget = MagicMock()
editor2.updateWidget = MagicMock()
# Call updateUI
widget.updateUI()
# Verify that updateWidget was called on all child editors
editor1.updateWidget.assert_called_once()
editor2.updateWidget.assert_called_once()
def test_update_object(self):
obj = DetachedDocumentObject()
obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1")
obj.Prop1 = "Value1"
obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2")
obj.Prop2 = 123
properties_to_show = ["Prop1", "Prop2"]
widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show)
# Get the real child editor widgets
editor1 = widget._property_editors["Prop1"]
editor2 = widget._property_editors["Prop2"]
# Mock their updateProperty methods to check if they are called
editor1.updateProperty = MagicMock()
editor2.updateProperty = MagicMock()
# Call updateObject
widget.updateObject()
# Verify that updateProperty was called on all child editors
editor1.updateProperty.assert_called_once()
editor2.updateProperty.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,381 @@
import unittest
from Path.Tool.library import Library
from Path.Tool.toolbit import ToolBitEndmill, ToolBitDrill, ToolBitVBit
from Path.Tool.shape import (
ToolBitShapeEndmill,
ToolBitShapeDrill,
ToolBitShapeVBit,
)
class TestPathToolLibrary(unittest.TestCase):
def test_init(self):
library = Library("Test Library")
self.assertEqual(library.label, "Test Library")
self.assertIsNotNone(library.id)
self.assertEqual(len(library._bits), 0)
self.assertEqual(len(library._bit_nos), 0)
def test_str(self):
library = Library("My Library", "123-abc")
self.assertEqual(str(library), '123-abc "My Library"')
def test_eq(self):
library1 = Library("Lib1", "same-id")
library2 = Library("Lib2", "same-id")
library3 = Library("Lib3", "different-id")
self.assertEqual(library1, library2)
self.assertNotEqual(library1, library3)
def test_iter(self):
library = Library("Test Library")
# Create ToolBitShape instances with required parameters
shape1 = ToolBitShapeEndmill(
id="dummy_endmill",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1)
library.add_bit(bit2)
_bits_list = list(library)
self.assertEqual(len(_bits_list), 2)
self.assertIn(bit1, _bits_list)
self.assertIn(bit2, _bits_list)
def test_get_next_bit_no(self):
library = Library("Test Library")
self.assertEqual(library.get_next_bit_no(), 1)
# Using ToolBit instances in _bit_nos with ToolBitShape
shape_a = ToolBitShapeEndmill(
id="dummy_a",
CuttingEdgeHeight=1.0,
Diameter=1.0,
Flutes=1,
Length=10.0,
ShankDiameter=1.0,
)
shape_b = ToolBitShapeDrill(
id="dummy_b",
Diameter=2.0,
Length=20.0,
ShankDiameter=2.0,
Flutes=2,
TipAngle=118.0,
)
shape_c = ToolBitShapeVBit(
id="dummy_c",
Diameter=3.0,
Angle=90.0,
Length=30.0,
ShankDiameter=3.0,
CuttingEdgeAngle=30.0,
CuttingEdgeHeight=10.0,
Flutes=2,
TipDiameter=1.0,
)
library._bit_nos = {
1: ToolBitEndmill(shape_a),
5: ToolBitDrill(shape_b),
2: ToolBitVBit(shape_c),
}
self.assertEqual(library.get_next_bit_no(), 6)
library._bit_nos = {}
self.assertEqual(library.get_next_bit_no(), 1)
def test_get_bit_no_from_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_1",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_1",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1, 1)
library.add_bit(bit2, 2)
self.assertEqual(library.get_bit_no_from_bit(bit1), 1)
self.assertEqual(library.get_bit_no_from_bit(bit2), 2)
shape_cutter = ToolBitShapeVBit(
id="dummy_cutter_1",
Diameter=3.0,
Angle=90.0,
Length=30.0,
ShankDiameter=3.0,
CuttingEdgeAngle=30.0,
CuttingEdgeHeight=10.0,
Flutes=2,
TipDiameter=1.0,
)
self.assertIsNone(library.get_bit_no_from_bit(ToolBitVBit(shape_cutter)))
def test_assign_new_bit_no(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_2",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_2",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
# Assign bit1 without specifying number (should get 1)
library.add_bit(bit1)
self.assertEqual(len(library._bits), 1)
self.assertEqual(len(library._bit_nos), 1)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit1)
# Assign bit2 to number 1 (should reassign bit1)
library.add_bit(bit2, 1)
self.assertEqual(len(library._bits), 2)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit2)
# Check if bit1 was reassigned to a new bit number (should be 2)
self.assertIn(2, library._bit_nos)
self.assertEqual(library._bit_nos[2], bit1)
# Assign bit2 to number 10
library.assign_new_bit_no(bit2, 10)
self.assertEqual(len(library._bits), 2)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(10, library._bit_nos)
self.assertEqual(library._bit_nos[10], bit2)
self.assertNotIn(1, library._bit_nos) # bit2 should no longer be at 1
self.assertIn(2, library._bit_nos)
self.assertEqual(library._bit_nos[2], bit1)
# Assign bit1 to number 5
library.assign_new_bit_no(bit1, 5)
self.assertEqual(len(library._bits), 2)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(5, library._bit_nos)
self.assertEqual(library._bit_nos[5], bit1)
self.assertNotIn(2, library._bit_nos) # bit1 should no longer be at 2
self.assertIn(10, library._bit_nos)
self.assertEqual(library._bit_nos[10], bit2)
def test_add_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_3",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_3",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1)
self.assertEqual(len(library._bits), 1)
self.assertIn(bit1, library._bits)
self.assertEqual(len(library._bit_nos), 1)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit1)
library.add_bit(bit2, 5)
self.assertEqual(len(library._bits), 2)
self.assertIn(bit1, library._bits)
self.assertIn(bit2, library._bits)
self.assertEqual(len(library._bit_nos), 2)
self.assertIn(1, library._bit_nos)
self.assertEqual(library._bit_nos[1], bit1)
self.assertIn(5, library._bit_nos)
self.assertEqual(library._bit_nos[5], bit2)
# Add bit1 again (should not increase bit count in _bits list)
library.add_bit(bit1, 10)
self.assertEqual(len(library._bits), 2)
self.assertIn(bit1, library._bits)
self.assertIn(bit2, library._bits)
self.assertEqual(len(library._bit_nos), 2) # _bit_nos count remains 2
self.assertIn(10, library._bit_nos)
self.assertEqual(library._bit_nos[10], bit1)
self.assertIn(5, library._bit_nos)
self.assertEqual(library._bit_nos[5], bit2)
self.assertNotIn(1, library._bit_nos) # bit1 should no longer be at 1
def test_get_bits(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_4",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_4",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
self.assertEqual(library.get_bits(), [])
library.add_bit(bit1)
library.add_bit(bit2)
_bits_list = library.get_bits()
self.assertEqual(len(_bits_list), 2)
self.assertIn(bit1, _bits_list)
self.assertIn(bit2, _bits_list)
def test_has_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_5",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_5",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
library.add_bit(bit1)
self.assertTrue(library.has_bit(bit1))
self.assertFalse(library.has_bit(bit2))
# Create a new ToolBit with the same properties but different instance
shape1_copy = ToolBitShapeEndmill(
id="dummy_endmill_5_copy", # Use a different ID for the copy
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
bit1_copy = ToolBitEndmill(shape1_copy)
self.assertFalse(library.has_bit(bit1_copy))
def test_remove_bit(self):
library = Library("Test Library")
shape1 = ToolBitShapeEndmill(
id="dummy_endmill_6",
CuttingEdgeHeight=10.0,
Diameter=10.0,
Flutes=2,
Length=50.0,
ShankDiameter=10.0,
)
shape2 = ToolBitShapeDrill(
id="dummy_drill_6",
Diameter=5.0,
Length=40.0,
ShankDiameter=5.0,
Flutes=2,
TipAngle=118.0,
)
shape3 = ToolBitShapeVBit(
id="dummy_cutter_6",
Diameter=3.0,
Angle=90.0,
Length=30.0,
ShankDiameter=3.0,
CuttingEdgeAngle=30.0,
CuttingEdgeHeight=10.0,
Flutes=2,
TipDiameter=1.0,
)
bit1 = ToolBitEndmill(shape1)
bit2 = ToolBitDrill(shape2)
bit3 = ToolBitVBit(shape3)
library.add_bit(bit1, 1)
library.add_bit(bit2, 2)
library.add_bit(bit3, 3)
self.assertEqual(len(library._bits), 3)
self.assertEqual(len(library._bit_nos), 3)
library.remove_bit(bit2)
self.assertEqual(len(library._bits), 2)
self.assertNotIn(bit2, library._bits)
self.assertEqual(len(library._bit_nos), 2)
self.assertNotIn(2, library._bit_nos)
self.assertNotIn(bit2, library._bit_nos.values())
library.remove_bit(bit1)
self.assertEqual(len(library._bits), 1)
self.assertNotIn(bit1, library._bits)
self.assertEqual(len(library._bit_nos), 1)
self.assertNotIn(1, library._bit_nos)
self.assertNotIn(bit1, library._bit_nos.values())
library.remove_bit(bit3)
self.assertEqual(len(library._bits), 0)
self.assertNotIn(bit3, library._bits)
self.assertEqual(len(library._bit_nos), 0)
self.assertNotIn(3, library._bit_nos)
self.assertNotIn(bit3, library._bit_nos.values())
# Removing a non-existent bit should not raise an error
shape_nonexistent = ToolBitShapeEndmill(
id="dummy_nonexistent_6",
CuttingEdgeHeight=99.0,
Diameter=99.0,
Flutes=1,
Length=99.0,
ShankDiameter=99.0,
)
library.remove_bit(ToolBitEndmill(shape_nonexistent))
self.assertEqual(len(library._bits), 0)
self.assertEqual(len(library._bit_nos), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,128 @@
import unittest
import json
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.library import Library
from Path.Tool.toolbit import ToolBitEndmill
from Path.Tool.shape import ToolBitShapeEndmill
from Path.Tool.library.serializers import CamoticsLibrarySerializer, LinuxCNCSerializer
class TestPathToolLibrarySerializerBase(PathTestWithAssets):
"""Base class for Library serializer tests."""
def setUp(self):
super().setUp()
self.test_library_id = "test_library"
self.test_library_label = "Test Library"
self.test_library = Library(self.test_library_label, id=self.test_library_id)
# Create some dummy tool bits
shape1 = ToolBitShapeEndmill("endmill_1")
shape1.set_parameter("Diameter", FreeCAD.Units.Quantity("6.0 mm"))
shape1.set_parameter("Length", FreeCAD.Units.Quantity("20.0 mm"))
tool1 = ToolBitEndmill(shape1, id="tool_1")
tool1.label = "Endmill 6mm"
shape2 = ToolBitShapeEndmill("endmill_2")
shape2.set_parameter("Diameter", FreeCAD.Units.Quantity("3.0 mm"))
shape2.set_parameter("Length", FreeCAD.Units.Quantity("15.0 mm"))
tool2 = ToolBitEndmill(shape2, id="tool_2")
tool2.label = "Endmill 3mm"
self.test_library.add_bit(tool1, 1)
self.test_library.add_bit(tool2, 2)
class TestCamoticsLibrarySerializer(TestPathToolLibrarySerializerBase):
"""Tests for the CamoticsLibrarySerializer."""
def test_camotics_serialize(self):
serializer = CamoticsLibrarySerializer
serialized_data = serializer.serialize(self.test_library)
self.assertIsInstance(serialized_data, bytes)
# Verify the content structure (basic check)
data_dict = json.loads(serialized_data.decode("utf-8"))
self.assertIn("1", data_dict)
self.assertIn("2", data_dict)
self.assertEqual(data_dict["1"]["description"], self.test_library._bit_nos[1].label)
self.assertEqual(
data_dict["2"]["diameter"],
self.test_library._bit_nos[2]._tool_bit_shape.get_parameter("Diameter"),
)
def test_camotics_deserialize(self):
serializer = CamoticsLibrarySerializer
# Create a dummy serialized data matching the expected format
dummy_data = {
"10": {
"units": "metric",
"shape": "Ballnose",
"length": 25,
"diameter": 8,
"description": "Ballnose 8mm",
},
"20": {
"units": "metric",
"shape": "Cylindrical",
"length": 30,
"diameter": 10,
"description": "Endmill 10mm",
},
}
dummy_bytes = json.dumps(dummy_data, indent=2).encode("utf-8")
# Deserialize the data
deserialized_library = serializer.deserialize(dummy_bytes, "deserialized_lib", {})
self.assertIsInstance(deserialized_library, Library)
self.assertEqual(deserialized_library.get_id(), "deserialized_lib")
self.assertEqual(len(deserialized_library._bit_nos), 2)
tool_10 = deserialized_library._bit_nos.get(10)
assert tool_10 is not None, "tool not in the library"
self.assertEqual(tool_10.label, "Ballnose 8mm")
self.assertEqual(tool_10._tool_bit_shape.name, "Ballend")
self.assertEqual(
tool_10._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("8 mm")
)
self.assertEqual(
tool_10._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("25 mm")
)
tool_20 = deserialized_library._bit_nos.get(20)
assert tool_20 is not None, "tool not in the library"
self.assertEqual(tool_20.label, "Endmill 10mm")
self.assertEqual(tool_20._tool_bit_shape.name, "Endmill")
self.assertEqual(
tool_20._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("10 mm")
)
self.assertEqual(
tool_20._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("30 mm")
)
class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase):
"""Tests for the LinuxCNCLibrarySerializer."""
def test_linuxcnc_serialize(self):
serializer = LinuxCNCSerializer
serialized_data = serializer.serialize(self.test_library)
self.assertIsInstance(serialized_data, bytes)
# Verify the content format (basic check)
lines = serialized_data.decode("ascii", "ignore").strip().split("\n")
self.assertEqual(len(lines), 2)
self.assertTrue(lines[0].startswith("T1 P D6.0 ;Endmill 6mm"))
self.assertTrue(lines[1].startswith("T2 P D3.0 ;Endmill 3mm"))
def test_linuxcnc_deserialize_not_implemented(self):
serializer = LinuxCNCSerializer
dummy_data = b"T1 D6.0 ;Endmill 6mm\n"
with self.assertRaises(NotImplementedError):
serializer.deserialize(dummy_data, "dummy_id", {})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,206 @@
import unittest
import FreeCAD
from Path.Tool.machine.models.machine import Machine
class TestPathToolMachine(unittest.TestCase):
def setUp(self):
self.default_machine = Machine()
def test_initialization_defaults(self):
self.assertEqual(self.default_machine.label, "Machine")
self.assertAlmostEqual(self.default_machine.max_power.getValueAs("W").Value, 2000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 3000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 60000)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 1)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 2000)
expected_peak_torque_rpm = 60000 / 3
self.assertAlmostEqual(
self.default_machine.get_peak_torque_rpm_value(),
expected_peak_torque_rpm,
)
expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm
self.assertAlmostEqual(
self.default_machine.max_torque.getValueAs("Nm").Value,
expected_max_torque_nm,
)
self.assertIsNotNone(self.default_machine.id)
def test_initialization_custom_values(self):
custom_machine = Machine(
label="Custom Machine",
max_power=5,
min_rpm=1000,
max_rpm=20000,
max_torque=50,
peak_torque_rpm=15000,
min_feed=10,
max_feed=5000,
id="custom-id",
)
self.assertEqual(custom_machine.label, "Custom Machine")
self.assertAlmostEqual(custom_machine.max_power.getValueAs("W").Value, 5000)
self.assertAlmostEqual(custom_machine.get_min_rpm_value(), 1000)
self.assertAlmostEqual(custom_machine.get_max_rpm_value(), 20000)
self.assertAlmostEqual(custom_machine.max_torque.getValueAs("Nm").Value, 50)
self.assertAlmostEqual(custom_machine.get_peak_torque_rpm_value(), 15000)
self.assertAlmostEqual(custom_machine.min_feed.getValueAs("mm/min").Value, 10)
self.assertAlmostEqual(custom_machine.max_feed.getValueAs("mm/min").Value, 5000)
self.assertEqual(custom_machine.id, "custom-id")
def test_initialization_custom_torque_quantity(self):
custom_torque_machine = Machine(max_torque=FreeCAD.Units.Quantity(100, "Nm"))
self.assertAlmostEqual(custom_torque_machine.max_torque.getValueAs("Nm").Value, 100)
def test_validate_valid(self):
try:
self.default_machine.validate()
except AttributeError as e:
self.fail(f"Validation failed unexpectedly: {e}")
def test_validate_missing_label(self):
self.default_machine.label = ""
with self.assertRaisesRegex(AttributeError, "Machine name is required"):
self.default_machine.validate()
def test_validate_peak_torque_rpm_greater_than_max_rpm(self):
self.default_machine.set_peak_torque_rpm(70000)
with self.assertRaisesRegex(AttributeError, "Peak Torque RPM.*must be less than max RPM"):
self.default_machine.validate()
def test_validate_max_rpm_less_than_min_rpm(self):
self.default_machine = Machine()
self.default_machine.set_min_rpm(4000) # min_rpm = 4000 RPM
self.default_machine.set_peak_torque_rpm(1000) # peak_torque_rpm = 1000 RPM
self.default_machine._max_rpm = 2000 / 60.0 # max_rpm = 2000 RPM (33.33 1/s)
self.assertLess(
self.default_machine.get_max_rpm_value(),
self.default_machine.get_min_rpm_value(),
)
with self.assertRaisesRegex(AttributeError, "Max RPM must be larger than min RPM"):
self.default_machine.validate()
def test_validate_max_feed_less_than_min_feed(self):
self.default_machine.set_min_feed(1000)
self.default_machine._max_feed = 500
with self.assertRaisesRegex(AttributeError, "Max feed must be larger than min feed"):
self.default_machine.validate()
def test_get_torque_at_rpm(self):
torque_below_peak = self.default_machine.get_torque_at_rpm(10000)
expected_peak_torque_rpm = 60000 / 3
expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm
expected_torque_below_peak = expected_max_torque_nm / expected_peak_torque_rpm * 10000
self.assertAlmostEqual(torque_below_peak, expected_torque_below_peak)
torque_at_peak = self.default_machine.get_torque_at_rpm(
self.default_machine.get_peak_torque_rpm_value()
)
self.assertAlmostEqual(
torque_at_peak,
self.default_machine.max_torque.getValueAs("Nm").Value,
)
torque_above_peak = self.default_machine.get_torque_at_rpm(50000)
expected_torque_above_peak = 2000 * 9.5488 / 50000
self.assertAlmostEqual(torque_above_peak, expected_torque_above_peak)
def test_set_label(self):
self.default_machine.label = "New Label"
self.assertEqual(self.default_machine.label, "New Label")
def test_set_max_power(self):
self.default_machine = Machine()
self.default_machine.set_max_power(5, "hp")
self.assertAlmostEqual(
self.default_machine.max_power.getValueAs("W").Value,
5 * 745.7,
places=4,
)
with self.assertRaisesRegex(AttributeError, "Max power must be positive"):
self.default_machine.set_max_power(0)
def test_set_min_rpm(self):
self.default_machine = Machine()
self.default_machine.set_min_rpm(5000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 5000)
with self.assertRaisesRegex(AttributeError, "Min RPM cannot be negative"):
self.default_machine.set_min_rpm(-100)
self.default_machine = Machine()
self.default_machine.set_min_rpm(70000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 70000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 70001)
def test_set_max_rpm(self):
self.default_machine = Machine()
self.default_machine.set_max_rpm(50000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 50000)
with self.assertRaisesRegex(AttributeError, "Max RPM must be positive"):
self.default_machine.set_max_rpm(0)
self.default_machine = Machine()
self.default_machine.set_max_rpm(2000)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 2000)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 1999)
self.default_machine = Machine()
self.default_machine.set_max_rpm(0.5)
self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 0.5)
self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 0)
def test_set_min_feed(self):
self.default_machine = Machine()
self.default_machine.set_min_feed(500, "inch/min")
self.assertAlmostEqual(
self.default_machine.min_feed.getValueAs("mm/min").Value,
500 * 25.4,
places=4,
)
with self.assertRaisesRegex(AttributeError, "Min feed cannot be negative"):
self.default_machine.set_min_feed(-10)
self.default_machine = Machine()
self.default_machine.set_min_feed(3000)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 3000)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3001)
def test_set_max_feed(self):
self.default_machine = Machine()
self.default_machine.set_max_feed(3000)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3000)
with self.assertRaisesRegex(AttributeError, "Max feed must be positive"):
self.default_machine.set_max_feed(0)
self.default_machine = Machine()
self.default_machine.set_min_feed(600)
self.default_machine.set_max_feed(500)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 500)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 499)
self.default_machine = Machine()
self.default_machine.set_max_feed(0.5)
self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 0.5)
self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 0)
def test_set_peak_torque_rpm(self):
self.default_machine = Machine()
self.default_machine.set_peak_torque_rpm(40000)
self.assertAlmostEqual(self.default_machine.get_peak_torque_rpm_value(), 40000)
with self.assertRaisesRegex(AttributeError, "Peak torque RPM cannot be negative"):
self.default_machine.set_peak_torque_rpm(-100)
def test_set_max_torque(self):
self.default_machine = Machine()
self.default_machine.set_max_torque(200, "in-lbf")
self.assertAlmostEqual(
self.default_machine.max_torque.getValueAs("Nm").Value,
200 * 0.112985,
places=4,
)
with self.assertRaisesRegex(AttributeError, "Max torque must be positive"):
self.default_machine.set_max_torque(0)
def test_dump(self):
try:
self.default_machine.dump(False)
except Exception as e:
self.fail(f"dump() method failed unexpectedly: {e}")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
# Unit tests for the Path.Tool.Shape module and its utilities.
from pathlib import Path
from typing import Mapping, Tuple
import FreeCAD
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.assets import DummyAssetSerializer
from Path.Tool.shape import (
ToolBitShape,
ToolBitShapeBallend,
ToolBitShapeVBit,
ToolBitShapeBullnose,
ToolBitShapeSlittingSaw,
)
# Helper dummy class for testing abstract methods
class DummyShape(ToolBitShape):
name = "dummy"
def __init__(self, id, **kwargs):
super().__init__(id=id, **kwargs)
# Always define defaults in the subclass
self._defaults = {
"Param1": FreeCAD.Units.Quantity("10 mm"),
"Param2": FreeCAD.Units.Quantity("5 deg"),
}
# Merge defaults into _params, allowing kwargs to override
self._params = self._defaults | self._params
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"Param1": (
FreeCAD.Qt.translate("Param1", "Parameter 1"),
"App::PropertyLength",
),
"Param2": (
FreeCAD.Qt.translate("Param2", "Parameter 2"),
"App::PropertyAngle",
),
}
@property
def label(self):
return "Dummy Shape"
def unit(param):
return param.getUserPreferred()[2]
class TestPathToolShapeClasses(PathTestWithAssets):
"""Tests for the concrete ToolBitShape subclasses."""
def _test_shape_common(self, alias):
uri = ToolBitShape.resolve_name(alias)
shape = self.assets.get(uri)
return shape.get_parameters()
def test_base_init_with_defaults(self):
"""Test base class initialization uses default parameters."""
# Provide a dummy filepath and id for instantiation
shape = DummyShape(id="dummy_shape_1", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(str(shape.get_parameter("Param1")), "10.0 mm")
self.assertEqual(str(shape.get_parameter("Param2")), "5.0 deg")
def test_base_init_with_kwargs(self):
"""Test base class initialization overrides defaults with kwargs."""
# Provide a dummy filepath for instantiation
shape = DummyShape(
id="dummy_shape_2",
filepath=Path("/fake/dummy.fcstd"),
Param1=FreeCAD.Units.Quantity("20 mm"),
Param3="Ignored",
)
self.assertEqual(shape.get_parameter("Param1").Value, 20.0)
self.assertEqual(shape.get_parameter("Param1").Value, 20.0)
self.assertEqual(
str(shape.get_parameter("Param1").Unit),
"Unit: mm (1,0,0,0,0,0,0,0) [Length]",
)
self.assertEqual(shape.get_parameter("Param2").Value, 5.0)
self.assertEqual(
str(shape.get_parameter("Param2").Unit),
"Unit: deg (0,0,0,0,0,0,0,1) [Angle]",
) # Should remain default
def test_base_get_set_parameter(self):
"""Test getting and setting individual parameters."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_3", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.get_parameter("Param1").Value, 10.0)
self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm"))
shape.set_parameter("Param1", FreeCAD.Units.Quantity("15 mm"))
self.assertEqual(shape.get_parameter("Param1").Value, 15.0)
self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm"))
with self.assertRaisesRegex(KeyError, "Shape 'dummy' has no parameter 'InvalidParam'"):
shape.get_parameter("InvalidParam")
def test_base_get_parameters(self):
"""Test getting the full parameter dictionary."""
# Provide a dummy filepath for instantiation
shape = DummyShape(
id="dummy_shape_4",
filepath=Path("/fake/dummy.fcstd"),
Param1=FreeCAD.Units.Quantity("12 mm"),
)
# Create mock quantity instances using the configured mock class
expected_param1 = FreeCAD.Units.Quantity("12.0 mm")
expected_param2 = FreeCAD.Units.Quantity("5.0 deg")
expected = {"Param1": expected_param1, "Param2": expected_param2}
params = shape.get_parameters()
self.assertEqual(params["Param1"].Value, expected["Param1"].Value)
self.assertEqual(str(params["Param1"].Unit), str(expected["Param1"].Unit))
self.assertEqual(params["Param2"].Value, expected["Param2"].Value)
self.assertEqual(str(params["Param2"].Unit), str(expected["Param2"].Unit))
def test_base_name_property(self):
"""Test the name property returns the primary alias."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_5", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.name, "dummy")
def test_base_get_parameter_label(self):
"""Test retrieving parameter labels."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_6", filepath=Path("/fake/dummy.fcstd"))
self.assertEqual(shape.get_parameter_label("Param1"), "Parameter 1")
self.assertEqual(shape.get_parameter_label("Param2"), "Parameter 2")
# Test fallback for unknown parameter
self.assertEqual(shape.get_parameter_label("UnknownParam"), "UnknownParam")
def test_base_get_expected_shape_parameters(self):
"""Test retrieving the list of expected parameter names."""
expected = ["Param1", "Param2"]
self.assertCountEqual(DummyShape.get_expected_shape_parameters(), expected)
def test_base_str_repr(self):
"""Test string representation."""
# Provide a dummy filepath for instantiation
shape = DummyShape(id="dummy_shape_7", filepath=Path("/fake/dummy.fcstd"))
# Dynamically construct the expected string using the actual parameter string representations
params_str = ", ".join(f"{name}={str(val)}" for name, val in shape.get_parameters().items())
expected_str = f"dummy({params_str})"
self.assertEqual(str(shape), expected_str)
self.assertEqual(repr(shape), expected_str)
def test_base_resolve_name(self):
"""Test resolving shape aliases to canonical names."""
self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("Ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("VBit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("slitting-saw").asset_id, "slittingsaw")
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")
self.assertEqual(ToolBitShape.resolve_name("UnknownShape").asset_id, "UnknownShape")
def test_concrete_classes_instantiation(self):
"""Test that all concrete classes can be instantiated."""
# No patching of FreeCAD document operations here.
# The test relies on the actual FreeCAD environment.
shape_uris = self.assets.list_assets(asset_type="toolbitshape")
for uri in shape_uris:
# Skip the DummyShape asset if it exists
if uri.asset_id == "dummy":
continue
with self.subTest(uri=uri):
instance = self.assets.get(uri)
self.assertIsInstance(instance, ToolBitShape)
# Check if default params were set by checking if the
# parameters dictionary is not empty.
self.assertTrue(instance.get_parameters())
def test_get_shape_class(self):
"""Test the get_shape_class function."""
uri = ToolBitShape.resolve_name("ballend")
self.assets.get(uri) # Ensure it's loadable
self.assertEqual(ToolBitShape.get_subclass_by_name("ballend"), ToolBitShapeBallend)
self.assertEqual(ToolBitShape.get_subclass_by_name("v-bit"), ToolBitShapeVBit)
self.assertEqual(ToolBitShape.get_subclass_by_name("VBit"), ToolBitShapeVBit)
self.assertEqual(ToolBitShape.get_subclass_by_name("torus"), ToolBitShapeBullnose)
self.assertEqual(ToolBitShape.get_subclass_by_name("slitting-saw"), ToolBitShapeSlittingSaw)
self.assertIsNone(ToolBitShape.get_subclass_by_name("nonexistent"))
# The following tests for default parameters and labels
# should also not use mocks for FreeCAD document operations or Units.
# They should rely on the actual FreeCAD environment and the
# load_file method of the base class.
def test_toolbitshapeballend_defaults(self):
"""Test ToolBitShapeBallend default parameters and labels."""
# Provide a dummy filepath for instantiation.
# The actual file content is not loaded in this test,
# only the default parameters are checked.
shape = self._test_shape_common("ballend")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["Length"].Value, 50.0)
self.assertEqual(unit(shape["Length"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("ballend")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
self.assertEqual(instance.get_parameter_label("Length"), "Overall tool length")
def test_toolbitshapedrill_defaults(self):
"""Test ToolBitShapeDrill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("drill")
self.assertEqual(shape["Diameter"].Value, 3.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["TipAngle"].Value, 119.0)
self.assertEqual(unit(shape["TipAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("drill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle")
def test_toolbitshapechamfer_defaults(self):
"""Test ToolBitShapeChamfer default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("chamfer")
self.assertEqual(shape["Diameter"].Value, 12.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0)
self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("chamfer")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
def test_toolbitshapedovetail_defaults(self):
"""Test ToolBitShapeDovetail default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("dovetail")
self.assertEqual(shape["Diameter"].Value, 20.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0)
self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("dovetail")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting angle")
def test_toolbitshapeendmill_defaults(self):
"""Test ToolBitShapeEndmill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("endmill")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeHeight"].Value, 30.0)
self.assertEqual(unit(shape["CuttingEdgeHeight"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("endmill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeHeight"), "Cutting edge height")
def test_toolbitshapeprobe_defaults(self):
"""Test ToolBitShapeProbe default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("probe")
self.assertEqual(shape["Diameter"].Value, 6.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["ShaftDiameter"].Value, 4.0)
self.assertEqual(unit(shape["ShaftDiameter"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("probe")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Ball diameter")
self.assertEqual(instance.get_parameter_label("ShaftDiameter"), "Shaft diameter")
def test_toolbitshapereamer_defaults(self):
"""Test ToolBitShapeReamer default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("reamer")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["Length"].Value, 50.0)
self.assertEqual(unit(shape["Length"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("reamer")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter")
def test_toolbitshapeslittingsaw_defaults(self):
"""Test ToolBitShapeSlittingSaw default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("slittingsaw")
self.assertEqual(shape["Diameter"].Value, 100.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["BladeThickness"].Value, 3.0)
self.assertEqual(unit(shape["BladeThickness"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("slittingsaw")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("BladeThickness"), "Blade thickness")
def test_toolbitshapetap_defaults(self):
"""Test ToolBitShapeTap default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("tap")
self.assertAlmostEqual(shape["Diameter"].Value, 8, 4)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["TipAngle"].Value, 90.0)
self.assertEqual(unit(shape["TipAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("tap")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle")
def test_toolbitshapethreadmill_defaults(self):
"""Test ToolBitShapeThreadMill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("threadmill")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["cuttingAngle"].Value, 60.0)
self.assertEqual(unit(shape["cuttingAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("threadmill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("cuttingAngle"), "Cutting angle")
def test_toolbitshapebullnose_defaults(self):
"""Test ToolBitShapeBullnose default parameters and labels."""
# Provide a dummy filepath for instantiation.
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")
# 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")
def test_toolbitshapevbit_defaults(self):
"""Test ToolBitShapeVBit default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("vbit")
self.assertEqual(shape["Diameter"].Value, 10.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 90.0)
self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°")
self.assertEqual(shape["TipDiameter"].Value, 1.0)
self.assertEqual(unit(shape["TipDiameter"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("vbit")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting edge angle")
def test_serialize_deserialize(self):
"""
Tests serialization and deserialization of a ToolBitShape object
using the Asset interface methods.
"""
# Load a shape instance from a fixture file
fixture_path = (
Path(__file__).parent / "Tools" / "Shape" / "test-path-tool-bit-shape-00.fcstd"
)
original_shape = ToolBitShape.from_file(fixture_path)
# Serialize the shape using the to_bytes method
serialized_data = original_shape.to_bytes(DummyAssetSerializer)
# Assert that the serialized data is bytes and not empty
self.assertIsInstance(serialized_data, bytes)
self.assertTrue(len(serialized_data) > 0)
# Deserialize the data using the from_bytes classmethod
# Provide an empty dependencies mapping for this test
deserialized_shape = ToolBitShape.from_bytes(
serialized_data, original_shape.get_id(), {}, DummyAssetSerializer
)
# Assert that the deserialized object is a ToolBitShape instance
self.assertIsInstance(deserialized_shape, ToolBitShape)
# Assert that the deserialized shape has the same parameters as the original
self.assertEqual(original_shape.get_parameters(), deserialized_shape.get_parameters())
self.assertEqual(original_shape.name, deserialized_shape.name)

View File

@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
# Unit tests for the Path.Tool.Shape module and its document utilities.
import unittest
from unittest.mock import patch, MagicMock, call
from Path.Tool.shape import doc
import os
mock_freecad = MagicMock(Name="FreeCAD_Mock")
mock_freecad.Console = MagicMock()
mock_freecad.Console.PrintWarning = MagicMock()
mock_freecad.Console.PrintError = MagicMock()
mock_obj = MagicMock(Name="Object_Mock")
mock_obj.Label = "MockObjectLabel"
mock_obj.Name = "MockObjectName"
mock_doc = MagicMock(Name="Document_Mock")
mock_doc.Objects = [mock_obj]
class TestPathToolShapeDoc(unittest.TestCase):
def setUp(self):
"""Reset mocks before each test."""
# Resetting the top-level mock recursively resets its children
# (newDocument, getDocument, openDocument, closeDocument, Console, etc.)
# and their call counts, return_values, side_effects.
mock_freecad.reset_mock()
mock_doc.reset_mock()
mock_obj.reset_mock()
# Re-establish default state/attributes potentially cleared by reset_mock
# or needed for tests.
mock_doc.Objects = [mock_obj]
mock_obj.Label = "MockObjectLabel"
mock_obj.Name = "MockObjectName"
# Ensure mock_doc also has a Name attribute used in tests/code
mock_doc.Name = "Document_Mock" # Used in closeDocument calls
# Clear attributes potentially added by setattr in previous tests.
# reset_mock() doesn't remove attributes added this way.
# Focus on attributes known to be added by tests in this file.
for attr_name in ["Diameter", "Length", "Height"]:
if hasattr(mock_obj, attr_name):
try:
delattr(mock_obj, attr_name)
except AttributeError:
pass # Ignore if already gone
"""Tests for the document utility functions in Path/Tool/Shape/doc.py"""
def test_doc_find_shape_object_body_priority(self):
"""Test find_shape_object prioritizes PartDesign::Body."""
body_obj = MagicMock(Name="Body_Mock")
body_obj.isDerivedFrom = lambda typeName: typeName == "PartDesign::Body"
part_obj = MagicMock(Name="Part_Mock")
part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature"
mock_doc.Objects = [part_obj, body_obj]
found = doc.find_shape_object(mock_doc)
self.assertEqual(found, body_obj)
def test_doc_find_shape_object_part_fallback(self):
"""Test find_shape_object falls back to Part::Feature."""
part_obj = MagicMock(Name="Part_Mock")
part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature"
other_obj = MagicMock(Name="Other_Mock")
other_obj.isDerivedFrom = lambda typeName: False
mock_doc.Objects = [other_obj, part_obj]
found = doc.find_shape_object(mock_doc)
self.assertEqual(found, part_obj)
def test_doc_find_shape_object_first_obj_fallback(self):
"""Test find_shape_object falls back to the first object."""
other_obj1 = MagicMock(Name="Other1_Mock")
other_obj1.isDerivedFrom = lambda typeName: False
other_obj2 = MagicMock(Name="Other2_Mock")
other_obj2.isDerivedFrom = lambda typeName: False
mock_doc.Objects = [other_obj1, other_obj2]
found = doc.find_shape_object(mock_doc)
self.assertEqual(found, other_obj1)
def test_doc_find_shape_object_no_objects(self):
"""Test find_shape_object returns None if document has no objects."""
mock_doc.Objects = []
found = doc.find_shape_object(mock_doc)
self.assertIsNone(found)
def test_doc_get_object_properties_found(self):
"""Test get_object_properties extracts existing properties."""
setattr(mock_obj, "Diameter", "10 mm")
setattr(mock_obj, "Length", "50 mm")
params = doc.get_object_properties(mock_obj, ["Diameter", "Length"])
# Expecting just the values, not tuples
self.assertEqual(params, {"Diameter": "10 mm", "Length": "50 mm"})
mock_freecad.Console.PrintWarning.assert_not_called()
@patch("Path.Tool.shape.doc.FreeCAD", new=mock_freecad)
def test_doc_get_object_properties_missing(self):
"""Test get_object_properties handles missing properties with warning."""
# Re-import doc within the patch context to use the mocked FreeCAD
import Path.Tool.shape.doc as doc_patched
setattr(mock_obj, "Diameter", "10 mm")
# Explicitly delete Height to ensure hasattr returns False for MagicMock
if hasattr(mock_obj, "Height"):
delattr(mock_obj, "Height")
params = doc_patched.get_object_properties(mock_obj, ["Diameter", "Height"])
# Expecting just the values, not tuples
self.assertEqual(params, {"Diameter": "10 mm", "Height": None}) # Height is missing
expected_calls = [
# The 'Could not get type' warning is from base.py's set_parameter,
# not get_object_properties. Removing it from expected calls here.
call(
"Parameter 'Height' not found on object 'MockObjectLabel' "
"(MockObjectName). Default value will be used by the shape "
"class.\n"
)
]
mock_freecad.Console.PrintWarning.assert_has_calls(expected_calls, any_order=True)
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")
@patch("FreeCAD.closeDocument")
def test_ShapeDocFromBytes(self, mock_close_doc, mock_get_doc, mock_open_doc):
"""Test ShapeDocFromBytes loads doc from a byte string."""
content = b"fake_content"
mock_opened_doc = MagicMock(Name="OpenedDoc_Mock")
mock_get_doc.return_value = mock_opened_doc
temp_file_path = None
try:
with doc.ShapeDocFromBytes(content=content) as temp_doc:
# Verify temp file creation and content
mock_open_doc.assert_called_once()
temp_file_path = mock_open_doc.call_args[0][0]
self.assertTrue(os.path.exists(temp_file_path))
with open(temp_file_path, "rb") as f:
self.assertEqual(f.read(), content)
self.assertEqual(temp_doc, mock_open_doc.return_value)
# Verify cleanup after exiting the context
mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name)
self.assertFalse(os.path.exists(temp_file_path))
finally:
# Ensure cleanup even if test fails before assertion
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")
@patch("FreeCAD.closeDocument")
def test_ShapeDocFromBytes_open_exception(self, mock_close_doc, mock_get_doc, mock_open_doc):
"""Test ShapeDocFromBytes propagates exceptions and cleans up."""
content = b"fake_content_exception"
load_error = Exception("Fake load error")
mock_open_doc.side_effect = load_error
temp_file_path = None
try:
with self.assertRaises(Exception) as cm:
with doc.ShapeDocFromBytes(content=content):
pass
self.assertEqual(cm.exception, load_error)
# Verify temp file was created before the exception
mock_open_doc.assert_called_once()
temp_file_path = mock_open_doc.call_args[0][0]
self.assertTrue(os.path.exists(temp_file_path))
with open(temp_file_path, "rb") as f:
self.assertEqual(f.read(), content)
mock_get_doc.assert_not_called()
# closeDocument is called in __exit__ only if _doc is not None,
# which it will be if openDocument failed.
mock_close_doc.assert_not_called()
finally:
# Verify cleanup after exiting the context (even with exception)
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
# Assert removal only if temp_file_path was set
if temp_file_path:
self.assertFalse(os.path.exists(temp_file_path))
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")
@patch("FreeCAD.closeDocument")
def test_ShapeDocFromBytes_exit_cleans_up(self, mock_close_doc, mock_get_doc, mock_open_doc):
"""Test ShapeDocFromBytes __exit__ cleans up temp file."""
content = b"fake_content_cleanup"
mock_opened_doc = MagicMock(Name="OpenedDoc_Cleanup_Mock")
mock_get_doc.return_value = mock_opened_doc
temp_file_path = None
try:
with doc.ShapeDocFromBytes(content=content):
mock_open_doc.assert_called_once()
temp_file_path = mock_open_doc.call_args[0][0]
self.assertTrue(os.path.exists(temp_file_path))
with open(temp_file_path, "rb") as f:
self.assertEqual(f.read(), content)
# No assertions on the returned doc here, focus is on cleanup
pass # Exit the context
# Verify cleanup after exiting the context
mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name)
self.assertFalse(os.path.exists(temp_file_path))
finally:
# Ensure cleanup even if test fails before assertion
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
# Test execution
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
import unittest
import unittest.mock
import pathlib
from tempfile import TemporaryDirectory
from PySide import QtCore, QtGui
from CAMTests.PathTestUtils import PathTestWithAssets
from Path.Tool.assets import DummyAssetSerializer
from Path.Tool.shape.models.icon import (
ToolBitShapeIcon,
ToolBitShapeSvgIcon,
ToolBitShapePngIcon,
)
class TestToolBitShapeIconBase(PathTestWithAssets):
"""Base class for ToolBitShapeIcon tests."""
ICON_CLASS = ToolBitShapeIcon
def setUp(self):
super().setUp()
# Ensure a QApplication exists for QPixmap tests
self.app = QtGui.QApplication.instance()
# Create a test shape and a test SVG icon.
self.test_shape = self.assets.get("toolbitshape://ballend")
self.test_svg = self.test_shape.icon
assert self.test_svg is not None
self.icon = self.ICON_CLASS("test_icon_base", b"")
def tearDown(self):
self.app = None
return super().tearDown()
def test_create_instance(self):
# Test basic instance creation
icon_id = "test_icon_123.dat"
icon = self.ICON_CLASS(icon_id, b"")
self.assertEqual(icon.get_id(), icon_id)
self.assertEqual(icon.data, b"")
self.assertIsInstance(icon.abbreviations, dict)
def test_to_bytes(self):
# Test serializing to bytes
icon_id = "test_to_bytes.bin"
icon_data = b"some_binary_data"
icon = ToolBitShapeIcon(icon_id, icon_data)
self.assertEqual(icon.to_bytes(DummyAssetSerializer), icon_data)
def test_get_size_in_bytes(self):
# Test getting icon data length
icon_with_data = ToolBitShapeIcon("with_data.bin", b"abc")
self.assertEqual(icon_with_data.get_size_in_bytes(), 3)
icon_no_data = ToolBitShapeIcon("no_data.dat", b"")
self.assertEqual(icon_no_data.get_size_in_bytes(), 0)
@unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data")
def test_from_shape_data_success(self, mock_create_thumbnail):
# Test creating instance from shape data - success case
shape_id = "test_shape"
thumbnail_data = b"png thumbnail data"
mock_create_thumbnail.return_value = thumbnail_data
icon = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id)
mock_create_thumbnail.assert_called_once_with(self.test_svg.data)
self.assertIsNotNone(icon)
self.assertIsInstance(icon, ToolBitShapePngIcon)
self.assertEqual(icon.get_id(), shape_id)
self.assertEqual(icon.data, thumbnail_data)
self.assertEqual(icon.abbreviations, {})
@unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data")
def test_from_shape_data_failure(self, mock_create_thumbnail):
# Test creating instance from shape data - failure case
shape_id = "test_shape"
mock_create_thumbnail.return_value = None
icon_failed = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id)
mock_create_thumbnail.assert_called_once_with(self.test_svg.data)
self.assertIsNone(icon_failed)
def test_get_png(self):
if not self.app:
self.skipTest("QApplication not available, skipping test_get_png")
if type(self) is TestToolBitShapeIconBase:
self.skipTest("Skipping test on abstract base class")
# Test getting PNG data from the icon
icon_size = QtCore.QSize(16, 16)
png_data = self.icon.get_png(icon_size)
self.assertIsInstance(png_data, bytes)
self.assertTrue(len(png_data) > 0)
def test_get_qpixmap(self):
if not self.app:
self.skipTest("QApplication not available, skipping test_get_qpixmap")
if type(self) is TestToolBitShapeIconBase:
self.skipTest("Skipping test on abstract base class")
# Test getting QPixmap from the icon
icon_size = QtCore.QSize(31, 32)
pixmap = self.icon.get_qpixmap(icon_size)
self.assertIsInstance(pixmap, QtGui.QPixmap)
self.assertFalse(pixmap.isNull())
self.assertEqual(pixmap.size().width(), 31)
self.assertEqual(pixmap.size().height(), 32)
class TestToolBitShapeSvgIcon(TestToolBitShapeIconBase):
"""Tests specifically for ToolBitShapeSvgIcon."""
ICON_CLASS = ToolBitShapeSvgIcon
def setUp(self):
super().setUp()
self.icon = ToolBitShapeSvgIcon("test_icon_svg", self.test_svg.data)
def test_from_bytes_svg(self):
# Test creating instance from bytes with SVG
icon_svg = ToolBitShapeSvgIcon.from_bytes(
self.test_svg.data, "test_from_bytes.svg", {}, DummyAssetSerializer
)
self.assertEqual(icon_svg.get_id(), "test_from_bytes.svg")
self.assertEqual(icon_svg.data, self.test_svg.data)
self.assertIsInstance(icon_svg.abbreviations, dict)
def test_round_trip_serialization_svg(self):
# Test serialization and deserialization round trip for SVG
svg_id = "round_trip_svg.svg"
icon_svg = ToolBitShapeSvgIcon(svg_id, self.test_svg.data)
serialized_svg = icon_svg.to_bytes(DummyAssetSerializer)
deserialized_svg = ToolBitShapeSvgIcon.from_bytes(
serialized_svg, svg_id, {}, DummyAssetSerializer
)
self.assertEqual(deserialized_svg.get_id(), svg_id)
self.assertEqual(deserialized_svg.data, self.test_svg.data)
# Abbreviations are extracted on access, so we don't check the dict directly
self.assertIsInstance(deserialized_svg.abbreviations, dict)
def test_from_file_svg(self):
# We cannot use NamedTemporaryFile on Windows, because there
# we may not have permission to read the tempfile while it is
# still open.
# So we use TemporaryDirectory instead, to ensure cleanup while
# still having a the temporary file inside it.
with TemporaryDirectory() as thedir:
tempfile = pathlib.Path(thedir, "test.svg")
tempfile.write_bytes(self.test_svg.data)
icon_id = "dummy_icon"
icon = ToolBitShapeIcon.from_file(tempfile, icon_id)
self.assertIsInstance(icon, ToolBitShapeSvgIcon)
self.assertEqual(icon.get_id(), icon_id)
self.assertEqual(icon.data, self.test_svg.data)
self.assertIsInstance(icon.abbreviations, dict)
def test_abbreviations_cached_property_svg(self):
# Test abbreviations property and caching for SVG
icon_svg = ToolBitShapeSvgIcon("cached_abbr.svg", self.test_svg.data)
# Accessing the property should call the static method
with unittest.mock.patch.object(
ToolBitShapeSvgIcon, "get_abbreviations_from_svg"
) as mock_get_abbr:
mock_get_abbr.return_value = {"param1": "A1"}
abbr1 = icon_svg.abbreviations
abbr2 = icon_svg.abbreviations
mock_get_abbr.assert_called_once_with(self.test_svg.data)
self.assertEqual(abbr1, {"param1": "A1"})
self.assertEqual(abbr2, {"param1": "A1"})
def test_get_abbr_svg(self):
# Test getting abbreviations for SVG
icon_data = self.test_svg.data
icon = ToolBitShapeSvgIcon("abbr_test.svg", icon_data)
# Assuming the test_svg data has 'diameter' and 'length' ids
self.assertIsNotNone(icon.get_abbr("Diameter"))
self.assertIsNotNone(icon.get_abbr("Length"))
self.assertIsNone(icon.get_abbr("NonExistent"))
def test_get_abbreviations_from_svg_static(self):
# Test static method get_abbreviations_from_svg
svg_content = self.test_svg.data
abbr = ToolBitShapeSvgIcon.get_abbreviations_from_svg(svg_content)
# Assuming the test_svg data has 'diameter' and 'length' ids
self.assertIn("diameter", abbr)
self.assertIn("length", abbr)
# Test with invalid SVG
invalid_svg = b"<svg><text id='param1'>A1</text>" # Missing closing tag
abbr_invalid = ToolBitShapeSvgIcon.get_abbreviations_from_svg(invalid_svg)
self.assertEqual(abbr_invalid, {})
# Test with no text elements
no_text_svg = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
abbr_no_text = ToolBitShapeSvgIcon.get_abbreviations_from_svg(no_text_svg)
self.assertEqual(abbr_no_text, {})
class TestToolBitShapePngIcon(TestToolBitShapeIconBase):
"""Tests specifically for ToolBitShapePngIcon."""
ICON_CLASS = ToolBitShapePngIcon
def setUp(self):
super().setUp()
self.png_data = b"\x89PNG\r\n\x1a\n" # Basic PNG signature
self.icon = ToolBitShapePngIcon("test_icon_png", self.png_data)
def test_from_bytes_png(self):
# Test creating instance from bytes with PNG
icon_png = ToolBitShapePngIcon.from_bytes(
self.png_data, "test_from_bytes.png", {}, DummyAssetSerializer
)
self.assertEqual(icon_png.get_id(), "test_from_bytes.png")
self.assertEqual(icon_png.data, self.png_data)
self.assertEqual(icon_png.abbreviations, {}) # No abbreviations for PNG
def test_round_trip_serialization_png(self):
# Test serialization and deserialization round trip for PNG
png_id = "round_trip_png.png"
serialized_png = self.icon.to_bytes(DummyAssetSerializer)
deserialized_png = ToolBitShapePngIcon.from_bytes(
serialized_png, png_id, {}, DummyAssetSerializer
)
self.assertEqual(deserialized_png.get_id(), png_id)
self.assertEqual(deserialized_png.data, self.png_data)
self.assertEqual(deserialized_png.abbreviations, {})
def test_from_file_png(self):
png_data = b"\\x89PNG\\r\\n\\x1a\\n"
# We cannot use NamedTemporaryFile on Windows, because there
# we may not have permission to read the tempfile while it is
# still open.
# So we use TemporaryDirectory instead, to ensure cleanup while
# still having a the temporary file inside it.
with TemporaryDirectory() as thedir:
tempfile = pathlib.Path(thedir, "test.png")
tempfile.write_bytes(png_data)
icon_id = "dummy_icon"
icon = ToolBitShapeIcon.from_file(tempfile, icon_id)
self.assertIsInstance(icon, ToolBitShapePngIcon)
self.assertEqual(icon.get_id(), icon_id)
self.assertEqual(icon.data, png_data)
self.assertEqual(icon.abbreviations, {})
def test_abbreviations_cached_property_png(self):
# Test abbreviations property and caching for PNG
self.assertEqual(self.icon.abbreviations, {})
def test_get_abbr_png(self):
# Test getting abbreviations for PNG
self.assertIsNone(self.icon.get_abbr("Diameter"))
def test_get_qpixmap(self):
self.skipTest("Skipping test, have no test data")
if __name__ == "__main__":
unittest.main()

View File

@@ -24,10 +24,8 @@ import FreeCAD
import Part
import Path.Main.Job as PathJob
import Path.Op.Vcarve as PathVcarve
import Path.Tool.Bit as PathToolBit
import math
from CAMTests.PathTestUtils import PathTestBase
from CAMTests.PathTestUtils import PathTestWithAssets
class VbitTool(object):
@@ -43,10 +41,11 @@ Scale45 = 2.414214
Scale60 = math.sqrt(3)
class TestPathVcarve(PathTestBase):
class TestPathVcarve(PathTestWithAssets):
"""Test Vcarve milling basics."""
def tearDown(self):
super().tearDown()
if hasattr(self, "doc"):
FreeCAD.closeDocument(self.doc.Name)
@@ -56,8 +55,9 @@ class TestPathVcarve(PathTestBase):
rect = Part.makePolygon([(0, 0, 0), (5, 0, 0), (5, 10, 0), (0, 10, 0), (0, 0, 0)])
part.Shape = Part.makeFace(rect, "Part::FaceMakerSimple")
job = PathJob.Create("Job", [part])
tool_file = PathToolBit.findToolBit("60degree_Vbit.fctb")
job.Tools.Group[0].Tool = PathToolBit.Factory.CreateFrom(tool_file)
toolbit = self.assets.get("toolbit://60degree_Vbit")
loaded_tool = toolbit.attach_to_doc(doc=job.Document)
job.Tools.Group[0].Tool = loaded_tool
op = PathVcarve.Create("TestVCarve")
op.Base = job.Model.Group[0]

View File

@@ -10,6 +10,7 @@ set(Path_Scripts
Init.py
PathCommands.py
TestCAMApp.py
TestCAMGui.py
)
if(BUILD_GUI)
@@ -114,20 +115,160 @@ SET(PathPythonMainSanity_SRCS
SET(PathPythonTools_SRCS
Path/Tool/__init__.py
Path/Tool/Bit.py
Path/Tool/camassets.py
Path/Tool/Controller.py
)
SET(PathPythonToolsAssets_SRCS
Path/Tool/assets/__init__.py
Path/Tool/assets/asset.py
Path/Tool/assets/cache.py
Path/Tool/assets/manager.py
Path/Tool/assets/serializer.py
Path/Tool/assets/uri.py
)
SET(PathPythonToolsAssetsStore_SRCS
Path/Tool/assets/store/__init__.py
Path/Tool/assets/store/base.py
Path/Tool/assets/store/memory.py
Path/Tool/assets/store/filestore.py
)
SET(PathPythonToolsAssetsUi_SRCS
Path/Tool/assets/ui/__init__.py
Path/Tool/assets/ui/filedialog.py
Path/Tool/assets/ui/preferences.py
Path/Tool/assets/ui/util.py
)
SET(PathPythonToolsGui_SRCS
Path/Tool/Gui/__init__.py
Path/Tool/Gui/Bit.py
Path/Tool/Gui/BitCmd.py
Path/Tool/Gui/BitEdit.py
Path/Tool/Gui/BitLibraryCmd.py
Path/Tool/Gui/BitLibrary.py
Path/Tool/Gui/Controller.py
)
SET(PathPythonToolsUi_SRCS
Path/Tool/ui/__init__.py
Path/Tool/ui/docobject.py
Path/Tool/ui/property.py
)
SET(PathPythonToolsToolBit_SRCS
Path/Tool/toolbit/__init__.py
Path/Tool/toolbit/docobject.py
Path/Tool/toolbit/util.py
)
SET(PathPythonToolsToolBitMixins_SRCS
Path/Tool/toolbit/mixins/__init__.py
Path/Tool/toolbit/mixins/rotary.py
Path/Tool/toolbit/mixins/cutting.py
)
SET(PathPythonToolsToolBitModels_SRCS
Path/Tool/toolbit/models/__init__.py
Path/Tool/toolbit/models/ballend.py
Path/Tool/toolbit/models/base.py
Path/Tool/toolbit/models/bullnose.py
Path/Tool/toolbit/models/chamfer.py
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/probe.py
Path/Tool/toolbit/models/reamer.py
Path/Tool/toolbit/models/slittingsaw.py
Path/Tool/toolbit/models/tap.py
Path/Tool/toolbit/models/threadmill.py
Path/Tool/toolbit/models/vbit.py
)
SET(PathPythonToolsToolBitSerializers_SRCS
Path/Tool/toolbit/serializers/__init__.py
Path/Tool/toolbit/serializers/camotics.py
Path/Tool/toolbit/serializers/fctb.py
)
SET(PathPythonToolsToolBitUi_SRCS
Path/Tool/toolbit/ui/__init__.py
Path/Tool/toolbit/ui/editor.py
Path/Tool/toolbit/ui/cmd.py
Path/Tool/toolbit/ui/browser.py
Path/Tool/toolbit/ui/file.py
Path/Tool/toolbit/ui/panel.py
Path/Tool/toolbit/ui/selector.py
Path/Tool/toolbit/ui/tablecell.py
Path/Tool/toolbit/ui/toollist.py
Path/Tool/toolbit/ui/view.py
)
SET(PathPythonToolsLibrary_SRCS
Path/Tool/library/__init__.py
Path/Tool/library/util.py
)
SET(PathPythonToolsLibraryModels_SRCS
Path/Tool/library/models/__init__.py
Path/Tool/library/models/library.py
)
SET(PathPythonToolsLibrarySerializers_SRCS
Path/Tool/library/serializers/__init__.py
Path/Tool/library/serializers/fctl.py
Path/Tool/library/serializers/camotics.py
Path/Tool/library/serializers/linuxcnc.py
)
SET(PathPythonToolsLibraryUi_SRCS
Path/Tool/library/ui/__init__.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
)
SET(PathPythonToolsMachine_SRCS
Path/Tool/machine/__init__.py
)
SET(PathPythonToolsMachineModels_SRCS
Path/Tool/machine/models/__init__.py
Path/Tool/machine/models/machine.py
)
SET(PathPythonToolsShape_SRCS
Path/Tool/shape/__init__.py
Path/Tool/shape/util.py
Path/Tool/shape/doc.py
)
SET(PathPythonToolsShapeModels_SRCS
Path/Tool/shape/models/__init__.py
Path/Tool/shape/models/ballend.py
Path/Tool/shape/models/base.py
Path/Tool/shape/models/bullnose.py
Path/Tool/shape/models/chamfer.py
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/icon.py
Path/Tool/shape/models/probe.py
Path/Tool/shape/models/reamer.py
Path/Tool/shape/models/slittingsaw.py
Path/Tool/shape/models/tap.py
Path/Tool/shape/models/threadmill.py
Path/Tool/shape/models/vbit.py
)
SET(PathPythonToolsShapeUi_SRCS
Path/Tool/shape/ui/__init__.py
Path/Tool/shape/ui/flowlayout.py
Path/Tool/shape/ui/shapebutton.py
Path/Tool/shape/ui/shapeselector.py
Path/Tool/shape/ui/shapewidget.py
)
SET(PathPythonPost_SRCS
Path/Post/__init__.py
Path/Post/Command.py
@@ -267,14 +408,17 @@ SET(Tools_SRCS
)
SET(Tools_Bit_SRCS
Tools/Bit/30degree_Vbit.fctb
Tools/Bit/375-16_Tap.fctb
Tools/Bit/45degree_chamfer.fctb
Tools/Bit/45degree_Vbit.fctb
Tools/Bit/5mm-thread-cutter.fctb
Tools/Bit/5mm_Drill.fctb
Tools/Bit/5mm_Endmill.fctb
Tools/Bit/60degree_Vbit.fctb
Tools/Bit/6mm_Ball_End.fctb
Tools/Bit/6mm_Bullnose.fctb
Tools/Bit/90degree_Vbit.fctb
Tools/Bit/probe.fctb
Tools/Bit/slittingsaw.fctb
)
@@ -285,16 +429,31 @@ SET(Tools_Library_SRCS
SET(Tools_Shape_SRCS
Tools/Shape/ballend.fcstd
Tools/Shape/ballend.svg
Tools/Shape/bullnose.fcstd
Tools/Shape/bullnose.svg
Tools/Shape/chamfer.fcstd
Tools/Shape/chamfer.svg
Tools/Shape/dovetail.fcstd
Tools/Shape/dovetail.svg
Tools/Shape/drill.fcstd
Tools/Shape/drill.svg
Tools/Shape/endmill.fcstd
Tools/Shape/endmill.svg
Tools/Shape/fillet.fcstd
Tools/Shape/fillet.svg
Tools/Shape/probe.fcstd
Tools/Shape/probe.svg
Tools/Shape/reamer.fcstd
Tools/Shape/reamer.svg
Tools/Shape/slittingsaw.fcstd
Tools/Shape/slittingsaw.svg
Tools/Shape/tap.fcstd
Tools/Shape/thread-mill.fcstd
Tools/Shape/v-bit.fcstd
Tools/Shape/tap.svg
Tools/Shape/threadmill.fcstd
Tools/Shape/threadmill.svg
Tools/Shape/vbit.fcstd
Tools/Shape/vbit.svg
)
SET(Tests_SRCS
@@ -346,7 +505,24 @@ SET(Tests_SRCS
CAMTests/TestPathToolChangeGenerator.py
CAMTests/TestPathThreadMilling.py
CAMTests/TestPathThreadMillingGenerator.py
CAMTests/TestPathToolAsset.py
CAMTests/TestPathToolAssetCache.py
CAMTests/TestPathToolAssetUri.py
CAMTests/TestPathToolAssetStore.py
CAMTests/TestPathToolAssetManager.py
CAMTests/TestPathToolBit.py
CAMTests/TestPathToolBitSerializer.py
CAMTests/TestPathToolBitBrowserWidget.py
CAMTests/TestPathToolBitEditorWidget.py
CAMTests/TestPathToolBitListWidget.py
CAMTests/TestPathToolBitPropertyEditorWidget.py
CAMTests/TestPathToolDocumentObjectEditorWidget.py
CAMTests/TestPathToolShapeClasses.py
CAMTests/TestPathToolShapeDoc.py
CAMTests/TestPathToolShapeIcon.py
CAMTests/TestPathToolLibrary.py
CAMTests/TestPathToolLibrarySerializer.py
CAMTests/TestPathToolMachine.py
CAMTests/TestPathToolController.py
CAMTests/TestPathUtil.py
CAMTests/TestPathVcarve.py
@@ -415,7 +591,25 @@ SET(all_files
${PathPythonPost_SRCS}
${PathPythonPostScripts_SRCS}
${PathPythonTools_SRCS}
${PathPythonToolsAssets_SRCS}
${PathPythonToolsAssetsStore_SRCS}
${PathPythonToolsAssetsUi_SRCS}
${PathPythonToolsGui_SRCS}
${PathPythonToolsUi_SRCS}
${PathPythonToolsShape_SRCS}
${PathPythonToolsShapeModels_SRCS}
${PathPythonToolsShapeUi_SRCS}
${PathPythonToolsToolBit_SRCS}
${PathPythonToolsToolBitMixins_SRCS}
${PathPythonToolsToolBitModels_SRCS}
${PathPythonToolsToolBitSerializers_SRCS}
${PathPythonToolsToolBitUi_SRCS}
${PathPythonToolsLibrary_SRCS}
${PathPythonToolsLibraryModels_SRCS}
${PathPythonToolsLibrarySerializers_SRCS}
${PathPythonToolsLibraryUi_SRCS}
${PathPythonToolsMachine_SRCS}
${PathPythonToolsMachineModels_SRCS}
${PathPythonGui_SRCS}
${Tools_SRCS}
${Tools_Bit_SRCS}
@@ -544,6 +738,27 @@ INSTALL(
Mod/CAM/Path/Tool
)
INSTALL(
FILES
${PathPythonToolsAssets_SRCS}
DESTINATION
Mod/CAM/Path/Tool/assets
)
INSTALL(
FILES
${PathPythonToolsAssetsStore_SRCS}
DESTINATION
Mod/CAM/Path/Tool/assets/store
)
INSTALL(
FILES
${PathPythonToolsAssetsUi_SRCS}
DESTINATION
Mod/CAM/Path/Tool/assets/ui
)
INSTALL(
FILES
${PathPythonToolsGui_SRCS}
@@ -551,6 +766,111 @@ INSTALL(
Mod/CAM/Path/Tool/Gui
)
INSTALL(
FILES
${PathPythonToolsUi_SRCS}
DESTINATION
Mod/CAM/Path/Tool/ui
)
INSTALL(
FILES
${PathPythonToolsShape_SRCS}
DESTINATION
Mod/CAM/Path/Tool/shape
)
INSTALL(
FILES
${PathPythonToolsShapeUi_SRCS}
DESTINATION
Mod/CAM/Path/Tool/shape/ui
)
INSTALL(
FILES
${PathPythonToolsShapeModels_SRCS}
DESTINATION
Mod/CAM/Path/Tool/shape/models
)
INSTALL(
FILES
${PathPythonToolsToolBit_SRCS}
DESTINATION
Mod/CAM/Path/Tool/toolbit
)
INSTALL(
FILES
${PathPythonToolsToolBitMixins_SRCS}
DESTINATION
Mod/CAM/Path/Tool/toolbit/mixins
)
INSTALL(
FILES
${PathPythonToolsToolBitModels_SRCS}
DESTINATION
Mod/CAM/Path/Tool/toolbit/models
)
INSTALL(
FILES
${PathPythonToolsToolBitSerializers_SRCS}
DESTINATION
Mod/CAM/Path/Tool/toolbit/serializers
)
INSTALL(
FILES
${PathPythonToolsToolBitUi_SRCS}
DESTINATION
Mod/CAM/Path/Tool/toolbit/ui
)
INSTALL(
FILES
${PathPythonToolsLibrary_SRCS}
DESTINATION
Mod/CAM/Path/Tool/library
)
INSTALL(
FILES
${PathPythonToolsLibraryModels_SRCS}
DESTINATION
Mod/CAM/Path/Tool/library/models
)
INSTALL(
FILES
${PathPythonToolsLibrarySerializers_SRCS}
DESTINATION
Mod/CAM/Path/Tool/library/serializers
)
INSTALL(
FILES
${PathPythonToolsLibraryUi_SRCS}
DESTINATION
Mod/CAM/Path/Tool/library/ui
)
INSTALL(
FILES
${PathPythonToolsMachine_SRCS}
DESTINATION
Mod/CAM/Path/Tool/machine
)
INSTALL(
FILES
${PathPythonToolsMachineModels_SRCS}
DESTINATION
Mod/CAM/Path/Tool/machine/models
)
INSTALL(
FILES
${Tests_SRCS}

View File

@@ -119,11 +119,11 @@
<file>panels/PointEdit.ui</file>
<file>panels/PropertyBag.ui</file>
<file>panels/PropertyCreate.ui</file>
<file>panels/ShapeSelector.ui</file>
<file>panels/SetupGlobal.ui</file>
<file>panels/SetupOp.ui</file>
<file>panels/ToolBitEditor.ui</file>
<file>panels/ToolBitLibraryEdit.ui</file>
<file>panels/ToolBitSelector.ui</file>
<file>panels/TaskPathCamoticsSim.ui</file>
<file>panels/TaskPathSimulator.ui</file>
<file>panels/TaskCAMSimulator.ui</file>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ShapeSelector</class>
<widget class="QDialog" name="ShapeSelector">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>900</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>Select a Tool Shape</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>
</property>
<widget class="QWidget" name="standardTools">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>880</width>
<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>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,237 +1,232 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ToolBitAttributes</class>
<widget class="QWidget" name="ToolBitAttributes">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>489</width>
<height>715</height>
<width>1000</width>
<height>900</height>
</rect>
</property>
<property name="windowTitle">
<string>Tool Bit Attributes</string>
<string>Tool Parameter Editor</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tabWidgetPage1">
<attribute name="title">
<string>Shape</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<widget class="QWidget" name="scrollAreaWidgetContents_6">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>980</width>
<height>849</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Tool Bit</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="toolName">
<property name="toolTip">
<string>Display name of the Tool Bit (initial value taken from the shape file).</string>
</property>
<property name="maxLength">
<number>50</number>
</property>
<property name="placeholderText">
<string>Display Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Shape File</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QWidget" name="widget">
<layout class="QVBoxLayout" name="vBox">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="sizeIncrement">
<size>
<width>1000</width>
<height>1000</height>
</size>
</property>
<property name="baseSize">
<size>
<width>1000</width>
<height>1000</height>
</size>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="toolTab">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<attribute name="title">
<string>Tool</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2" stretch="0,1">
<item>
<widget class="QLineEdit" name="shapePath">
<property name="toolTip">
<string>The file which defines the type and shape of the Tool Bit.</string>
</property>
<property name="placeholderText">
<string>path</string>
</property>
</widget>
<layout class="QVBoxLayout" name="toolTabLayout"/>
</item>
<item>
<widget class="QToolButton" name="shapeSet">
<property name="toolTip">
<string>Change file defining type and shape of Tool Bit.</string>
<widget class="QWidget" name="widget" native="true"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="notesTab">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<attribute name="title">
<string>Notes</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_2" rowstretch="0,0,1" rowminimumheight="0,0,0">
<item row="0" column="0">
<layout class="QGridLayout" name="gridLayout">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<property name="text">
<string notr="true">...</string>
<property name="horizontalSpacing">
<number>12</number>
</property>
<property name="verticalSpacing">
<number>6</number>
</property>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Coating:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="lineEditCoating"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Hardness:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="lineEditHardness"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Materials:</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLineEdit" name="lineEditSupplier"/>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="lineEditMaterials"/>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Supplier:</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QPlainTextEdit" name="plainTextEditNotes">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="bitParams">
<property name="title">
<string>Parameter</string>
</property>
<layout class="QFormLayout" name="formLayout_2">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Point/Tip Angle</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="Gui::InputField" name="toolCuttingEdgeAngle">
<property name="text">
<string notr="true">0 °</string>
</property>
<property name="unit" stdset="0">
<string notr="true">°</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Cutting Edge Height</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="Gui::InputField" name="toolCuttingEdgeHeight">
<property name="text">
<string>0 mm</string>
</property>
<property name="unit" stdset="0">
<string>mm</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QLabel" name="image">
<property name="maximumSize">
<size>
<width>210</width>
<height>297</height>
</size>
</property>
<property name="text">
<string>Image</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</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>277</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="tabWidgetPage2">
<attribute name="title">
<string>Attributes</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTreeView" name="attrTree">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>2</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>300</height>
</size>
</property>
<property name="editTriggers">
<set>QAbstractItemView::AllEditTriggers</set>
</property>
</widget>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<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>
</widget>
<customwidgets>
<customwidget>
<class>Gui::InputField</class>
<extends>QLineEdit</extends>
<header>Gui/InputField.h</header>
</customwidget>
</customwidgets>
<resources>
<include location="../../../../../Gui/Icons/resource.qrc"/>
</resources>
<connections/>
<tabstops>
<tabstop>tabWidget</tabstop>
<tabstop>lineEditCoating</tabstop>
<tabstop>lineEditMaterials</tabstop>
<tabstop>lineEditHardness</tabstop>
<tabstop>lineEditSupplier</tabstop>
</tabstops>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -88,27 +88,7 @@
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="libraryOpen">
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="toolTip">
<string>Select a working path for the tool library editor.</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/document-open.svg</normaloff>:/icons/document-open.svg</iconset>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="addToolTable">
<widget class="QPushButton" name="addLibrary">
<property name="maximumSize">
<size>
<width>16777215</width>
@@ -133,8 +113,9 @@
</property>
</widget>
</item>
<!-- The libraryOpen button item was here -->
<item>
<widget class="QPushButton" name="libraryExport">
<widget class="QPushButton" name="exportLibrary">
<property name="toolTip">
<string>Save the selected library with a new name or export to another format</string>
</property>
@@ -252,7 +233,7 @@
</spacer>
</item>
<item>
<widget class="QPushButton" name="librarySave">
<widget class="QPushButton" name="okButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -266,7 +247,7 @@
</size>
</property>
<property name="toolTip">
<string>Save the current Library</string>
<string>Close the library editor</string>
</property>
<property name="text">
<string>Close</string>

View File

@@ -1,132 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ToolSelector</class>
<widget class="QDockWidget" name="ToolSelector">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>350</width>
<height>542</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>Tool Selector</string>
</property>
<widget class="QWidget" name="dockWidgetContents">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QComboBox" name="cboLibraries"/>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="libraryEditorOpen">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="toolTip">
<string>Library editor...</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/edit-edit.svg</normaloff>:/icons/edit-edit.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>16</width>
<height>16</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QTreeView" name="tools">
<property name="toolTip">
<string>Available Tool Bits to choose from.</string>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="sortingEnabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QPushButton" name="addToolController">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>Create ToolControllers for the selected toolbits and add them to the Job</string>
</property>
<property name="text">
<string>Add To Job</string>
</property>
<property name="icon">
<iconset resource="../../../../../Gui/Icons/resource.qrc">
<normaloff>:/icons/edit_OK.svg</normaloff>:/icons/edit_OK.svg</iconset>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<resources>
<include location="../../../../../Gui/Icons/resource.qrc"/>
<include location="../Path.qrc"/>
</resources>
<connections/>
</ui>

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2014 Yorik van Havre <yorik@uncreated.net> *
# * *
@@ -20,6 +21,10 @@
# * USA *
# * *
# ***************************************************************************
import FreeCAD
FreeCAD.__unit_test__ += ["TestCAMGui"]
class PathCommandGroup:
@@ -58,6 +63,7 @@ class CAMWorkbench(Workbench):
# Add preferences pages - before loading PathGui to properly order pages of Path group
import Path.Dressup.Gui.Preferences as PathPreferencesPathDressup
import Path.Tool.assets.ui.preferences as AssetPreferences
import Path.Main.Gui.PreferencesJob as PathPreferencesPathJob
translate = FreeCAD.Qt.translate
@@ -74,8 +80,8 @@ class CAMWorkbench(Workbench):
from Path.Main.Gui import JobCmd as PathJobCmd
from Path.Main.Gui import SanityCmd as SanityCmd
from Path.Tool.Gui import BitCmd as PathToolBitCmd
from Path.Tool.Gui import BitLibraryCmd as PathToolBitLibraryCmd
from Path.Tool.toolbit.ui import cmd as PathToolBitCmd
from Path.Tool.library.ui import cmd as PathToolBitLibraryCmd
from PySide.QtCore import QT_TRANSLATE_NOOP
@@ -87,6 +93,10 @@ class CAMWorkbench(Workbench):
PathPreferencesPathJob.JobPreferencesPage,
QT_TRANSLATE_NOOP("QObject", "CAM"),
)
FreeCADGui.addPreferencePage(
AssetPreferences.AssetPreferencesPage,
QT_TRANSLATE_NOOP("QObject", "CAM"),
)
FreeCADGui.addPreferencePage(
PathPreferencesPathDressup.DressupPreferencesPage,
QT_TRANSLATE_NOOP("QObject", "CAM"),
@@ -335,7 +345,7 @@ class CAMWorkbench(Workbench):
for cmd in self.dressupcmds:
self.appendContextMenu("", [cmd])
menuAppended = True
if isinstance(obj.Proxy, Path.Tool.Bit.ToolBit):
if isinstance(obj.Proxy, Path.Tool.ToolBit):
self.appendContextMenu("", ["CAM_ToolBitSave", "CAM_ToolBitSaveAs"])
menuAppended = True
if menuAppended:

View File

@@ -321,10 +321,10 @@ class GlobalEditor(object):
self.form.setupStepDownExpr.setText(self.obj.StepDownExpression)
self.form.setupClearanceHeightExpr.setText(self.obj.ClearanceHeightExpression)
self.form.setupSafeHeightExpr.setText(self.obj.SafeHeightExpression)
self.clearanceHeightOffs.updateSpinBox()
self.safeHeightOffs.updateSpinBox()
self.rapidVertical.updateSpinBox()
self.rapidHorizontal.updateSpinBox()
self.clearanceHeightOffs.updateWidget()
self.safeHeightOffs.updateWidget()
self.rapidVertical.updateWidget()
self.rapidHorizontal.updateWidget()
self.selectInComboBox(self.obj.CoolantMode, self.form.setupCoolantMode)
def updateModel(self, recomp=True):

View File

@@ -138,7 +138,8 @@ class QuantitySpinBox(QtCore.QObject):
return False
def onWidgetValueChanged(self):
"""onWidgetValueChanged()... Slot method for determining if a change
"""
Slot method for determining if a change
in widget value is a result of an expression edit, or a simple spinbox change.
If the former, emit a manual `editingFinished` signal because the Expression editor
window returned a value to the base widget, leaving it in read-only mode,
@@ -150,7 +151,7 @@ class QuantitySpinBox(QtCore.QObject):
self.widget.editingFinished.emit()
def attachTo(self, obj, prop=None):
"""attachTo(obj, prop=None) ... use an existing editor for the given object and property"""
"""use an existing editor for the given object and property"""
Path.Log.track(self.prop, prop)
self.obj = obj
self.prop = prop
@@ -168,21 +169,22 @@ class QuantitySpinBox(QtCore.QObject):
self.valid = False
def expression(self):
"""expression() ... returns the expression if one is bound to the property"""
"""returns the expression if one is bound to the property"""
Path.Log.track(self.prop, self.valid)
if self.valid:
return self.widget.property("expression")
return ""
def setMinimum(self, quantity):
"""setMinimum(quantity) ... set the minimum"""
"""set the minimum"""
Path.Log.track(self.prop, self.valid)
if self.valid:
value = quantity.Value if hasattr(quantity, "Value") else quantity
self.widget.setProperty("setMinimum", value)
def updateSpinBox(self, quantity=None):
"""updateSpinBox(quantity=None) ... update the display value of the spin box.
def updateWidget(self, quantity=None):
"""
update the display value of the spin box.
If no value is provided the value of the bound property is used.
quantity can be of type Quantity or Float."""
Path.Log.track(self.prop, self.valid, quantity)
@@ -222,6 +224,215 @@ class QuantitySpinBox(QtCore.QObject):
return None
class PropertyComboBox(QtCore.QObject):
"""Base controller class for properties represented as QComboBox."""
def __init__(self, widget, obj, prop, onBeforeChange=None):
super().__init__()
Path.Log.track(widget)
self.widget = widget
self.onBeforeChange = onBeforeChange
self.prop = None
self.obj = obj
self.valid = False
self.attachTo(obj, prop)
self.widget.currentIndexChanged.connect(self.updateProperty)
def attachTo(self, obj, prop=None):
"""use an existing editor for the given object and property"""
Path.Log.track(self.prop, prop)
self.obj = obj
self.prop = prop
if obj and prop:
attr = PathUtil.getProperty(obj, prop)
if attr is not None:
self.valid = True
self._populateComboBox()
self.updateWidget()
else:
Path.Log.warning("Cannot find property {} of {}".format(prop, obj.Label))
self.valid = False
else:
self.valid = False
def _populateComboBox(self):
"""To be implemented by subclasses"""
raise NotImplementedError
def updateWidget(self, value=None):
"""update the display value of the combo box."""
Path.Log.track(self.prop, self.valid, value)
if self.valid:
if value is None:
value = PathUtil.getProperty(self.obj, self.prop)
index = (
self.widget.findData(value)
if hasattr(self.widget, "findData")
else self.widget.findText(str(value))
)
if index >= 0:
self.widget.setCurrentIndex(index)
def updateProperty(self):
"""update the bound property with the value from the combo box"""
Path.Log.track(self.prop, self.valid)
if self.valid and self.prop:
if self.onBeforeChange:
self.onBeforeChange()
current_value = PathUtil.getProperty(self.obj, self.prop)
new_value = (
self.widget.currentData()
if hasattr(self.widget, "currentData")
else self.widget.currentText()
)
if str(new_value) != str(current_value):
setattr(self.obj, self.prop, new_value)
return True
return False
class IntegerSpinBox(QtCore.QObject):
"""Controller class for integer properties represented as QSpinBox.
IntegerSpinBox(widget, obj, prop, onBeforeChange=None)
widget ... expected to be reference to a QSpinBox
obj ... document object
prop ... canonical name of the (sub-) property
onBeforeChange ... optional callback before property change
"""
def __init__(self, widget, obj, prop, onBeforeChange=None):
super().__init__()
self.widget = widget
self.onBeforeChange = onBeforeChange
self.prop = None
self.obj = obj
self.valid = False
# Configure spin box defaults
self.widget.setMinimum(-2147483647) # Qt's minimum for spin boxes
self.widget.setMaximum(2147483647) # Qt's maximum for spin boxes
self.attachTo(obj, prop)
self.widget.valueChanged.connect(self.updateProperty)
def attachTo(self, obj, prop=None):
"""bind to the given object and property"""
self.obj = obj
self.prop = prop
if obj and prop:
try:
prop_value = PathUtil.getProperty(obj, prop)
if prop_value is not None:
self.valid = True
self.updateWidget()
else:
Path.Log.warning(f"Cannot get value for property {prop} of {obj.Label}")
self.valid = False
except Exception as e:
Path.Log.error(f"Error attaching to property {prop}: {str(e)}")
self.valid = False
else:
self.valid = False
def updateWidget(self, value=None):
"""update the spin box value"""
if self.valid:
try:
if value is None:
value = PathUtil.getProperty(self.obj, self.prop)
# Handle both direct values and Quantity objects
if hasattr(value, "Value"): # For Quantity properties
value = int(value.Value)
self.widget.setValue(int(value))
except Exception as e:
Path.Log.error(f"Error updating spin box: {str(e)}")
def updateProperty(self):
"""update the bound property with the spin box value"""
if self.valid and self.prop:
if self.onBeforeChange:
self.onBeforeChange()
new_value = self.widget.value()
current_value = PathUtil.getProperty(self.obj, self.prop)
# Handle Quantity properties
if hasattr(current_value, "Value"):
if new_value != current_value.Value:
current_value.Value = new_value
return True
elif new_value != current_value:
setattr(self.obj, self.prop, new_value)
return True
return False
def setRange(self, min_val, max_val):
"""set minimum and maximum values"""
self.widget.setMinimum(min_val)
self.widget.setMaximum(max_val)
def setSingleStep(self, step):
"""setSingleStep(step) ... set the step size"""
self.widget.setSingleStep(step)
class BooleanComboBox(PropertyComboBox):
"""Controller class for boolean properties represented as QComboBox."""
def _populateComboBox(self):
self.widget.clear()
self.widget.addItem("True", True)
self.widget.addItem("False", False)
class EnumerationComboBox(PropertyComboBox):
"""Controller class for enumeration properties represented as QComboBox."""
def _populateComboBox(self):
self.widget.clear()
enums = self.obj.getEnumerationsOfProperty(self.prop)
for item in enums:
self.widget.addItem(item, item)
class PropertyLabel(QtCore.QObject):
"""Controller class for read-only property display as QLabel."""
def __init__(self, widget, obj, prop, onBeforeChange=None):
super().__init__()
self.widget = widget
self.obj = obj
self.prop = prop
self.valid = False
self.attachTo(obj, prop)
def attachTo(self, obj, prop=None):
"""bind to the given object and property"""
self.obj = obj
self.prop = prop
if obj and prop:
attr = PathUtil.getProperty(obj, prop)
if attr is not None:
self.valid = True
self.updateWidget()
else:
Path.Log.warning(f"Cannot find property {prop} of {obj.Label}")
self.valid = False
else:
self.valid = False
def updateWidget(self, value=None):
"""update the label text"""
if self.valid:
if value is None:
value = PathUtil.getProperty(self.obj, self.prop)
self.widget.setText(str(value))
def getDocNode():
doc = FreeCADGui.ActiveDocument.Document.Name
tws = FreeCADGui.getMainWindow().findChildren(QtGui.QTreeWidget)

View File

@@ -96,7 +96,7 @@ def isValidBaseObject(obj):
# Can't link to anything inside a geo feature group anymore
Path.Log.debug("%s is inside a geo feature group" % obj.Label)
return False
if hasattr(obj, "BitBody") and hasattr(obj, "BitShape"):
if hasattr(obj, "BitBody") and hasattr(obj, "ShapeName"):
# ToolBit's are not valid base objects
return False
if obj.TypeId in NotValidBaseTypeIds:

View File

@@ -35,10 +35,9 @@ import Path.Main.Gui.JobCmd as PathJobCmd
import Path.Main.Gui.JobDlg as PathJobDlg
import Path.Main.Job as PathJob
import Path.Main.Stock as PathStock
import Path.Tool.Gui.Bit as PathToolBitGui
import Path.Tool.Gui.Controller as PathToolControllerGui
import PathScripts.PathUtils as PathUtils
import json
from Path.Tool.toolbit.ui.selector import ToolBitSelector
import math
import traceback
from PySide import QtWidgets
@@ -1073,29 +1072,14 @@ class TaskPanel:
self.toolControllerSelect()
def toolControllerAdd(self):
# adding a TC from a toolbit directly.
# Try to find a tool number from the currently selected lib. Otherwise
# use next available number
tools = PathToolBitGui.LoadTools()
curLib = Path.Preferences.lastFileToolLibrary()
library = None
if curLib is not None:
with open(curLib) as fp:
library = json.load(fp)
for tool in tools:
toolNum = self.obj.Proxy.nextToolNumber()
if library is not None:
for toolBit in library["tools"]:
if toolBit["path"] == tool.File:
toolNum = toolBit["nr"]
tc = PathToolControllerGui.Create(name=tool.Label, tool=tool, toolNumber=toolNum)
self.obj.Proxy.addToolController(tc)
selector = ToolBitSelector(compact=True)
if not selector.exec_():
return
toolbit = selector.get_selected_tool()
toolbit.attach_to_doc(FreeCAD.ActiveDocument)
toolNum = self.obj.Proxy.nextToolNumber()
tc = PathToolControllerGui.Create(name=toolbit.label, tool=toolbit.obj, toolNumber=toolNum)
self.obj.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
self.updateToolController()

View File

@@ -67,7 +67,6 @@ class JobPreferencesPage:
policy = str(self.form.cboOutputPolicy.currentText())
Path.Preferences.setOutputFileDefaults(path, policy)
self.saveStockSettings()
self.saveToolsSettings()
def saveStockSettings(self):
if self.form.stockGroup.isChecked():
@@ -116,9 +115,6 @@ class JobPreferencesPage:
else:
Path.Preferences.setDefaultStockTemplate("")
def saveToolsSettings(self):
Path.Preferences.setToolsSettings(self.form.toolsAbsolutePaths.isChecked())
def selectComboEntry(self, widget, text):
index = widget.findText(text, QtCore.Qt.MatchFixedString)
if index >= 0:
@@ -189,7 +185,6 @@ class JobPreferencesPage:
self.form.tbOutputFile.clicked.connect(self.browseOutputFile)
self.loadStockSettings()
self.loadToolSettings()
def loadStockSettings(self):
stock = Path.Preferences.defaultStockTemplate()
@@ -283,9 +278,6 @@ class JobPreferencesPage:
self.form.stockCreateBox.hide()
self.form.stockCreateCylinder.hide()
def loadToolSettings(self):
self.form.toolsAbsolutePaths.setChecked(Path.Preferences.toolsStoreAbsolutePaths())
def getPostProcessor(self, name):
if not name in self.processor:
processor = PostProcessorFactory.get_post_processor(None, name)

View File

@@ -368,8 +368,8 @@ class CAMSanity:
)
continue # skip old-style tools
tooldata = data.setdefault(str(TC.ToolNumber), {})
bitshape = tooldata.setdefault("BitShape", "")
if bitshape not in ["", TC.Tool.BitShape]:
bitshape = tooldata.setdefault("ShapeType", "")
if bitshape not in ["", TC.Tool.ShapeType]:
data["squawkData"].append(
self.squawk(
"CAMSanity",
@@ -379,18 +379,18 @@ class CAMSanity:
squawkType="CAUTION",
)
)
tooldata["bitShape"] = TC.Tool.BitShape
tooldata["bitShape"] = TC.Tool.ShapeType
tooldata["description"] = TC.Tool.Label
tooldata["manufacturer"] = ""
tooldata["url"] = ""
tooldata["inspectionNotes"] = ""
tooldata["diameter"] = str(TC.Tool.Diameter)
tooldata["shape"] = TC.Tool.ShapeName
tooldata["shape"] = TC.Tool.ShapeType
tooldata["partNumber"] = ""
if os.path.isfile(TC.Tool.BitShape):
imagedata = TC.Tool.Proxy.getBitThumbnail(TC.Tool)
if os.path.isfile(TC.Tool.ShapeType):
imagedata = TC.Tool.Proxy.get_thumbnail()
else:
imagedata = None
data["squawkData"].append(

View File

@@ -24,6 +24,10 @@ import FreeCAD
import Path
import glob
import os
import pathlib
from collections import defaultdict
from typing import Optional
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
@@ -31,8 +35,11 @@ if False:
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
translate = FreeCAD.Qt.translate
PreferencesGroup = "User parameter:BaseApp/Preferences/Mod/CAM"
DefaultFilePath = "DefaultFilePath"
DefaultJobTemplate = "DefaultJobTemplate"
DefaultStockTemplate = "DefaultStockTemplate"
@@ -44,17 +51,9 @@ PostProcessorBlacklist = "PostProcessorBlacklist"
PostProcessorOutputFile = "PostProcessorOutputFile"
PostProcessorOutputPolicy = "PostProcessorOutputPolicy"
LastPathToolBit = "LastPathToolBit"
LastPathToolLibrary = "LastPathToolLibrary"
LastPathToolShape = "LastPathToolShape"
LastPathToolTable = "LastPathToolTable"
LastFileToolBit = "LastFileToolBit"
LastFileToolLibrary = "LastFileToolLibrary"
LastFileToolShape = "LastFileToolShape"
UseAbsoluteToolPaths = "UseAbsoluteToolPaths"
# OpenLastLibrary = "OpenLastLibrary"
ToolGroup = PreferencesGroup + "/Tools"
ToolPath = "ToolPath"
LastToolLibrary = "LastToolLibrary"
# Linear tolerance to use when generating Paths, eg when tessellating geometry
GeometryTolerance = "GeometryTolerance"
@@ -69,18 +68,84 @@ EnableExperimentalFeatures = "EnableExperimentalFeatures"
EnableAdvancedOCLFeatures = "EnableAdvancedOCLFeatures"
_observers = defaultdict(list) # maps group name to callback functions
def _add_group_observer(group, callback):
"""Add an observer for any changes on the given parameter group"""
_observers[group].append(callback)
def _emit_change(group, *args):
for cb in _observers[group]:
cb(group, *args)
def preferences():
return FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM")
return FreeCAD.ParamGet(PreferencesGroup)
def tool_preferences():
return FreeCAD.ParamGet(ToolGroup)
def addToolPreferenceObserver(callback):
_add_group_observer(ToolGroup, callback)
def pathPostSourcePath():
return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Path/Post/")
def pathDefaultToolsPath(sub=None):
if sub:
return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Tools/", sub)
return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Tools/")
def getBuiltinToolPath() -> pathlib.Path:
home = pathlib.Path(FreeCAD.getHomePath())
return home / "Mod" / "CAM" / "Tools"
def getBuiltinLibraryPath() -> pathlib.Path:
return getBuiltinToolPath() / "Library"
def getBuiltinShapePath() -> pathlib.Path:
return getBuiltinToolPath() / "Shape"
def getBuiltinToolBitPath() -> pathlib.Path:
return getBuiltinToolPath() / "Bit"
def getDefaultAssetPath():
config = pathlib.Path(FreeCAD.ConfigGet("UserConfigPath"))
return config / "Mod" / "CAM" / "Tools"
def getAssetPath() -> pathlib.Path:
pref = tool_preferences()
default = getDefaultAssetPath()
path = pref.GetString(ToolPath, str(default))
return pathlib.Path(path or default)
def setAssetPath(path: pathlib.Path):
assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}"
pref = tool_preferences()
pref.SetString(ToolPath, str(path))
_emit_change(ToolGroup, ToolPath, path)
def getToolBitPath() -> pathlib.Path:
return getAssetPath() / "Bit"
def getLastToolLibrary() -> Optional[str]:
pref = tool_preferences()
return pref.GetString(LastToolLibrary) or None
def setLastToolLibrary(name: str):
assert isinstance(name, str), f"Library name '{name}' is not a string"
pref = tool_preferences()
pref.SetString(LastToolLibrary, name)
def allAvailablePostProcessors():
@@ -161,26 +226,6 @@ def searchPathsPost():
return paths
def searchPathsTool(sub):
paths = []
paths.append(os.path.join(FreeCAD.getHomePath(), "Mod", "CAM", "Tools", sub))
return paths
def toolsStoreAbsolutePaths():
return preferences().GetBool(UseAbsoluteToolPaths, False)
# def toolsOpenLastLibrary():
# return preferences().GetBool(OpenLastLibrary, False)
def setToolsSettings(relative):
pref = preferences()
pref.SetBool(UseAbsoluteToolPaths, relative)
# pref.SetBool(OpenLastLibrary, lastlibrary)
def defaultJobTemplate():
template = preferences().GetString(DefaultJobTemplate)
if "xml" not in template:
@@ -284,65 +329,3 @@ def setPreferencesAdvanced(ocl, warnSpeeds, warnRapids, warnModes, warnOCL, warn
preferences().SetBool(WarningSuppressSelectionMode, warnModes)
preferences().SetBool(WarningSuppressOpenCamLib, warnOCL)
preferences().SetBool(WarningSuppressVelocity, warnVelocity)
def lastFileToolLibrary():
filename = preferences().GetString(LastFileToolLibrary)
if filename.endswith(".fctl") and os.path.isfile(filename):
return filename
libpath = preferences().GetString(LastPathToolLibrary, pathDefaultToolsPath("Library"))
libFiles = [f for f in glob.glob(libpath + "/*.fctl")]
libFiles.sort()
if len(libFiles) >= 1:
filename = libFiles[0]
setLastFileToolLibrary(filename)
Path.Log.track(filename)
return filename
else:
return None
def setLastFileToolLibrary(path):
Path.Log.track(path)
if os.path.isfile(path): # keep the path and file in sync
preferences().SetString(LastPathToolLibrary, os.path.split(path)[0])
return preferences().SetString(LastFileToolLibrary, path)
def lastPathToolBit():
return preferences().GetString(LastPathToolBit, pathDefaultToolsPath("Bit"))
def setLastPathToolBit(path):
return preferences().SetString(LastPathToolBit, path)
def lastPathToolLibrary():
Path.Log.track()
return preferences().GetString(LastPathToolLibrary, pathDefaultToolsPath("Library"))
def setLastPathToolLibrary(path):
Path.Log.track(path)
curLib = lastFileToolLibrary()
Path.Log.debug("curLib: {}".format(curLib))
if curLib and os.path.split(curLib)[0] != path:
setLastFileToolLibrary("") # a path is known but not specific file
return preferences().SetString(LastPathToolLibrary, path)
def lastPathToolShape():
return preferences().GetString(LastPathToolShape, pathDefaultToolsPath("Shape"))
def setLastPathToolShape(path):
return preferences().SetString(LastPathToolShape, path)
def lastPathToolTable():
return preferences().GetString(LastPathToolTable, "")
def setLastPathToolTable(table):
return preferences().SetString(LastPathToolTable, table)

View File

@@ -1,500 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@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 FreeCAD
import Path
import Path.Base.Util as PathUtil
import Path.Base.PropertyBag as PathPropertyBag
import json
import os
import zipfile
from PySide.QtCore import QT_TRANSLATE_NOOP
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
Part = LazyLoader("Part", globals(), "Part")
__title__ = "Tool bits."
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Class to deal with and represent a tool bit."
PropertyGroupShape = "Shape"
_DebugFindTool = False
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def _findToolFile(name, containerFile, typ):
Path.Log.track(name)
if os.path.exists(name): # absolute reference
return name
if containerFile:
rootPath = os.path.dirname(os.path.dirname(containerFile))
paths = [os.path.join(rootPath, typ)]
else:
paths = []
paths.extend(Path.Preferences.searchPathsTool(typ))
def _findFile(path, name):
Path.Log.track(path, name)
fullPath = os.path.join(path, name)
if os.path.exists(fullPath):
return (True, fullPath)
for root, ds, fs in os.walk(path):
for d in ds:
found, fullPath = _findFile(d, name)
if found:
return (True, fullPath)
return (False, None)
for p in paths:
found, path = _findFile(p, name)
if found:
return path
return None
def findToolShape(name, path=None):
"""findToolShape(name, path) ... search for name, if relative path look in path"""
Path.Log.track(name, path)
return _findToolFile(name, path, "Shape")
def findToolBit(name, path=None):
"""findToolBit(name, path) ... search for name, if relative path look in path"""
Path.Log.track(name, path)
if name.endswith(".fctb"):
return _findToolFile(name, path, "Bit")
return _findToolFile("{}.fctb".format(name), path, "Bit")
# Only used in ToolBit unit test module: TestPathToolBit.py
def findToolLibrary(name, path=None):
"""findToolLibrary(name, path) ... search for name, if relative path look in path"""
Path.Log.track(name, path)
if name.endswith(".fctl"):
return _findToolFile(name, path, "Library")
return _findToolFile("{}.fctl".format(name), path, "Library")
def _findRelativePath(path, typ):
Path.Log.track(path, typ)
relative = path
for p in Path.Preferences.searchPathsTool(typ):
if path.startswith(p):
p = path[len(p) :]
if os.path.sep == p[0]:
p = p[1:]
if len(p) < len(relative):
relative = p
return relative
# Unused due to bug fix related to relative paths
"""
def findRelativePathShape(path):
return _findRelativePath(path, 'Shape')
def findRelativePathTool(path):
return _findRelativePath(path, 'Bit')
"""
def findRelativePathLibrary(path):
return _findRelativePath(path, "Library")
class ToolBit(object):
def __init__(self, obj, shapeFile, path=None):
Path.Log.track(obj.Label, shapeFile, path)
self.obj = obj
obj.addProperty(
"App::PropertyFile",
"BitShape",
"Base",
QT_TRANSLATE_NOOP("App::Property", "Shape for bit shape"),
)
obj.addProperty(
"App::PropertyLink",
"BitBody",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The parametrized body representing the tool bit"),
)
obj.addProperty(
"App::PropertyFile",
"File",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The file of the tool"),
)
obj.addProperty(
"App::PropertyString",
"ShapeName",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The name of the shape file"),
)
obj.addProperty(
"App::PropertyStringList",
"BitPropertyNames",
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"),
)
if path:
obj.File = path
if shapeFile is None:
obj.BitShape = "endmill.fcstd"
self._setupBitShape(obj)
self.unloadBitBody(obj)
else:
obj.BitShape = shapeFile
self._setupBitShape(obj)
self.onDocumentRestored(obj)
def dumps(self):
return None
def loads(self, state):
for obj in FreeCAD.ActiveDocument.Objects:
if hasattr(obj, "Proxy") and obj.Proxy == self:
self.obj = obj
break
return None
def onDocumentRestored(self, obj):
# when files are shared it is essential to be able to change/set the shape file,
# otherwise the file is hard to use
# obj.setEditorMode('BitShape', 1)
obj.setEditorMode("BitBody", 2)
obj.setEditorMode("File", 1)
obj.setEditorMode("Shape", 2)
if not hasattr(obj, "BitPropertyNames"):
obj.addProperty(
"App::PropertyStringList",
"BitPropertyNames",
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"),
)
propNames = []
for prop in obj.PropertiesList:
if obj.getGroupOfProperty(prop) == "Bit":
val = obj.getPropertyByName(prop)
typ = obj.getTypeIdOfProperty(prop)
dsc = obj.getDocumentationOfProperty(prop)
obj.removeProperty(prop)
obj.addProperty(typ, prop, PropertyGroupShape, dsc)
PathUtil.setProperty(obj, prop, val)
propNames.append(prop)
elif obj.getGroupOfProperty(prop) == "Attribute":
propNames.append(prop)
obj.BitPropertyNames = propNames
obj.setEditorMode("BitPropertyNames", 2)
for prop in obj.BitPropertyNames:
if obj.getGroupOfProperty(prop) == PropertyGroupShape:
# properties in the Shape group can only be modified while the actual
# shape is loaded, so we have to disable direct property editing
obj.setEditorMode(prop, 1)
else:
# all other custom properties can and should be edited directly in the
# property editor widget, not much value in re-implementing that
obj.setEditorMode(prop, 0)
def onChanged(self, obj, prop):
Path.Log.track(obj.Label, prop)
if prop == "BitShape" and "Restore" not in obj.State:
self._setupBitShape(obj)
def onDelete(self, obj, arg2=None):
Path.Log.track(obj.Label)
self.unloadBitBody(obj)
obj.Document.removeObject(obj.Name)
def _updateBitShape(self, obj, properties=None):
if obj.BitBody is not None:
for attributes in [
o
for o in obj.BitBody.Group
if hasattr(o, "Proxy") and hasattr(o.Proxy, "getCustomProperties")
]:
for prop in attributes.Proxy.getCustomProperties():
# the property might not exist in our local object (new attribute in shape)
# for such attributes we just keep the default
if hasattr(obj, prop):
setattr(attributes, prop, obj.getPropertyByName(prop))
else:
# if the template shape has a new attribute defined we should add that
# to the local object
self._setupProperty(obj, prop, attributes)
propNames = obj.BitPropertyNames
propNames.append(prop)
obj.BitPropertyNames = propNames
self._copyBitShape(obj)
def _copyBitShape(self, obj):
obj.Document.recompute()
if obj.BitBody and obj.BitBody.Shape:
obj.Shape = obj.BitBody.Shape
else:
obj.Shape = Part.Shape()
def _loadBitBody(self, obj, path=None):
Path.Log.track(obj.Label, path)
p = path if path else obj.BitShape
docOpened = False
doc = None
for d in FreeCAD.listDocuments():
if FreeCAD.getDocument(d).FileName == p:
doc = FreeCAD.getDocument(d)
break
if doc is None:
p = findToolShape(p, path if path else obj.File)
if p is None:
raise FileNotFoundError
if not path and p != obj.BitShape:
obj.BitShape = p
Path.Log.debug("ToolBit {} using shape file: {}".format(obj.Label, p))
doc = FreeCAD.openDocument(p, True)
obj.ShapeName = doc.Name
docOpened = True
else:
Path.Log.debug("ToolBit {} already open: {}".format(obj.Label, doc))
return (doc, docOpened)
def _removeBitBody(self, obj):
if obj.BitBody:
obj.BitBody.removeObjectsFromDocument()
obj.Document.removeObject(obj.BitBody.Name)
obj.BitBody = None
def _deleteBitSetup(self, obj):
Path.Log.track(obj.Label)
self._removeBitBody(obj)
self._copyBitShape(obj)
for prop in obj.BitPropertyNames:
obj.removeProperty(prop)
def loadBitBody(self, obj, force=False):
if force or not obj.BitBody:
activeDoc = FreeCAD.ActiveDocument
if force:
self._removeBitBody(obj)
(doc, opened) = self._loadBitBody(obj)
obj.BitBody = obj.Document.copyObject(doc.RootObjects[0], True)
if opened:
FreeCAD.setActiveDocument(activeDoc.Name)
FreeCAD.closeDocument(doc.Name)
self._updateBitShape(obj)
def unloadBitBody(self, obj):
self._removeBitBody(obj)
def _setupProperty(self, obj, prop, orig):
# extract property parameters and values so it can be copied
val = orig.getPropertyByName(prop)
typ = orig.getTypeIdOfProperty(prop)
grp = orig.getGroupOfProperty(prop)
dsc = orig.getDocumentationOfProperty(prop)
obj.addProperty(typ, prop, grp, dsc)
if "App::PropertyEnumeration" == typ:
setattr(obj, prop, orig.getEnumerationsOfProperty(prop))
obj.setEditorMode(prop, 1)
PathUtil.setProperty(obj, prop, val)
def _setupBitShape(self, obj, path=None):
Path.Log.track(obj.Label)
activeDoc = FreeCAD.ActiveDocument
try:
(doc, docOpened) = self._loadBitBody(obj, path)
except FileNotFoundError:
Path.Log.error(
"Could not find shape file {} for tool bit {}".format(obj.BitShape, obj.Label)
)
return
obj.Label = doc.RootObjects[0].Label
self._deleteBitSetup(obj)
bitBody = obj.Document.copyObject(doc.RootObjects[0], True)
docName = doc.Name
if docOpened:
FreeCAD.setActiveDocument(activeDoc.Name)
FreeCAD.closeDocument(doc.Name)
if bitBody.ViewObject:
bitBody.ViewObject.Visibility = False
Path.Log.debug("bitBody.{} ({}): {}".format(bitBody.Label, bitBody.Name, type(bitBody)))
propNames = []
for attributes in [o for o in bitBody.Group if PathPropertyBag.IsPropertyBag(o)]:
Path.Log.debug("Process properties from {}".format(attributes.Label))
for prop in attributes.Proxy.getCustomProperties():
self._setupProperty(obj, prop, attributes)
propNames.append(prop)
if not propNames:
Path.Log.error(
"Did not find a PropertyBag in {} - not a ToolBit shape?".format(docName)
)
# has to happen last because it could trigger op.execute evaluations
obj.BitPropertyNames = propNames
obj.BitBody = bitBody
self._copyBitShape(obj)
def toolShapeProperties(self, obj):
"""toolShapeProperties(obj) ... return all properties defining it's shape"""
return sorted(
[
prop
for prop in obj.BitPropertyNames
if obj.getGroupOfProperty(prop) == PropertyGroupShape
]
)
def toolAdditionalProperties(self, obj):
"""toolShapeProperties(obj) ... return all properties unrelated to it's shape"""
return sorted(
[
prop
for prop in obj.BitPropertyNames
if obj.getGroupOfProperty(prop) != PropertyGroupShape
]
)
def toolGroupsAndProperties(self, obj, includeShape=True):
"""toolGroupsAndProperties(obj) ... returns a dictionary of group names with a list of property names."""
category = {}
for prop in obj.BitPropertyNames:
group = obj.getGroupOfProperty(prop)
if includeShape or group != PropertyGroupShape:
properties = category.get(group, [])
properties.append(prop)
category[group] = properties
return category
def getBitThumbnail(self, obj):
if obj.BitShape:
path = findToolShape(obj.BitShape)
if path:
with open(path, "rb") as fd:
try:
zf = zipfile.ZipFile(fd)
pf = zf.open("thumbnails/Thumbnail.png", "r")
data = pf.read()
pf.close()
return data
except KeyError:
pass
return None
def saveToFile(self, obj, path, setFile=True):
Path.Log.track(path)
try:
with open(path, "w") as fp:
json.dump(self.templateAttrs(obj), fp, indent=" ")
if setFile:
obj.File = path
return True
except (OSError, IOError) as e:
Path.Log.error("Could not save tool {} to {} ({})".format(obj.Label, path, e))
raise
def templateAttrs(self, obj):
attrs = {}
attrs["version"] = 2
attrs["name"] = obj.Label
if Path.Preferences.toolsStoreAbsolutePaths():
attrs["shape"] = obj.BitShape
else:
# attrs['shape'] = findRelativePathShape(obj.BitShape)
# Extract the name of the shape file
__, filShp = os.path.split(
obj.BitShape
) # __ is an ignored placeholder acknowledged by LGTM
attrs["shape"] = str(filShp)
params = {}
for name in obj.BitPropertyNames:
params[name] = PathUtil.getPropertyValueString(obj, name)
attrs["parameter"] = params
params = {}
attrs["attribute"] = params
return attrs
def Declaration(path):
Path.Log.track(path)
with open(path, "r") as fp:
return json.load(fp)
class ToolBitFactory(object):
def CreateFromAttrs(self, attrs, name="ToolBit", path=None):
Path.Log.track(attrs, path)
obj = Factory.Create(name, attrs["shape"], path)
obj.Label = attrs["name"]
params = attrs["parameter"]
for prop in params:
PathUtil.setProperty(obj, prop, params[prop])
attributes = attrs["attribute"]
for att in attributes:
PathUtil.setProperty(obj, att, attributes[att])
obj.Proxy._updateBitShape(obj)
obj.Proxy.unloadBitBody(obj)
return obj
def CreateFrom(self, path, name="ToolBit"):
Path.Log.track(name, path)
if not os.path.isfile(path):
raise FileNotFoundError(f"{path} not found")
try:
data = Declaration(path)
bit = Factory.CreateFromAttrs(data, name, path)
return bit
except (OSError, IOError) as e:
Path.Log.error("%s not a valid tool file (%s)" % (path, e))
raise
def Create(self, name="ToolBit", shapeFile=None, path=None):
Path.Log.track(name, shapeFile, path)
obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", name)
obj.Proxy = ToolBit(obj, shapeFile, path)
return obj
Factory = ToolBitFactory()

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2015 Dan Falck <ddfalck@gmail.com> *
# * 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) *
@@ -25,7 +26,7 @@
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import Path
import Path.Tool.Bit as PathToolBit
from Path.Tool.toolbit import ToolBit
import Path.Base.Generator.toolchange as toolchange
@@ -113,7 +114,7 @@ class ToolController:
self.ensureToolBit(obj)
@classmethod
def propertyEnumerations(self, dataType="data"):
def propertyEnumerations(cls, dataType="data"):
"""helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType.
Args:
dataType = 'data', 'raw', 'translated'
@@ -182,13 +183,11 @@ class ToolController:
obj.ToolNumber = int(template.get(ToolControllerTemplate.ToolNumber))
if template.get(ToolControllerTemplate.Tool):
self.ensureToolBit(obj)
toolVersion = template.get(ToolControllerTemplate.Tool).get(
ToolControllerTemplate.Version
)
tool_data = template.get(ToolControllerTemplate.Tool)
toolVersion = tool_data.get(ToolControllerTemplate.Version)
if toolVersion == 2:
obj.Tool = PathToolBit.Factory.CreateFromAttrs(
template.get(ToolControllerTemplate.Tool)
)
toolbit_instance = ToolBit.from_dict(tool_data)
obj.Tool = toolbit_instance.attach_to_doc(doc=obj.Document)
else:
obj.Tool = None
if toolVersion == 1:
@@ -230,7 +229,7 @@ class ToolController:
attrs[ToolControllerTemplate.HorizRapid] = "%s" % (obj.HorizRapid)
attrs[ToolControllerTemplate.SpindleSpeed] = obj.SpindleSpeed
attrs[ToolControllerTemplate.SpindleDir] = obj.SpindleDir
attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.templateAttrs(obj.Tool)
attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.to_dict()
expressions = []
for expr in obj.ExpressionEngine:
Path.Log.debug("%s: %s" % (expr[0], expr[1]))
@@ -251,26 +250,9 @@ class ToolController:
"toolnumber": obj.ToolNumber,
"toollabel": obj.Label,
"spindlespeed": obj.SpindleSpeed,
"spindledirection": toolchange.SpindleDirection.OFF,
"spindledirection": obj.Tool.Proxy.get_spindle_direction(),
}
if hasattr(obj.Tool, "SpindlePower"):
if not obj.Tool.SpindlePower:
args["spindledirection"] = toolchange.SpindleDirection.OFF
else:
if obj.SpindleDir == "Forward":
args["spindledirection"] = toolchange.SpindleDirection.CW
else:
args["spindledirection"] = toolchange.SpindleDirection.CCW
elif obj.SpindleDir == "None":
args["spindledirection"] = toolchange.SpindleDirection.OFF
else:
if obj.SpindleDir == "Forward":
args["spindledirection"] = toolchange.SpindleDirection.CW
else:
args["spindledirection"] = toolchange.SpindleDirection.CCW
commands = toolchange.generate(**args)
path = Path.Path(commands)
@@ -314,7 +296,10 @@ def Create(
if assignTool:
if not tool:
tool = PathToolBit.Factory.Create()
# Create a default endmill tool bit and attach it to a new DocumentObject
toolbit = ToolBit.from_shape_id("endmill.fcstd")
Path.Log.info(f"Controller.Create: Created toolbit with ID: {toolbit.id}")
tool = toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument)
if tool.ViewObject:
tool.ViewObject.Visibility = False
obj.Tool = tool

View File

@@ -1,270 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide import QtCore, QtGui
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import FreeCADGui
import Path
import Path.Base.Gui.IconViewProvider as PathIconViewProvider
import Path.Tool.Bit as PathToolBit
import Path.Tool.Gui.BitEdit as PathToolBitEdit
import os
__title__ = "Tool Bit UI"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Task panel editor for a ToolBit"
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
translate = FreeCAD.Qt.translate
class ViewProvider(object):
"""ViewProvider for a ToolBit.
It's sole job is to provide an icon and invoke the TaskPanel on edit."""
def __init__(self, vobj, name):
Path.Log.track(name, vobj.Object)
self.panel = None
self.icon = name
self.obj = vobj.Object
self.vobj = vobj
vobj.Proxy = self
def attach(self, vobj):
Path.Log.track(vobj.Object)
self.vobj = vobj
self.obj = vobj.Object
def getIcon(self):
png = self.obj.Proxy.getBitThumbnail(self.obj)
if png:
pixmap = QtGui.QPixmap()
pixmap.loadFromData(png, "PNG")
return QtGui.QIcon(pixmap)
return ":/icons/CAM_ToolBit.svg"
def dumps(self):
return None
def loads(self, state):
return None
def onDelete(self, vobj, arg2=None):
Path.Log.track(vobj.Object.Label)
vobj.Object.Proxy.onDelete(vobj.Object)
def getDisplayMode(self, mode):
return "Default"
def _openTaskPanel(self, vobj, deleteOnReject):
Path.Log.track()
self.panel = TaskPanel(vobj, deleteOnReject)
FreeCADGui.Control.closeDialog()
FreeCADGui.Control.showDialog(self.panel)
self.panel.setupUi()
def setCreate(self, vobj):
Path.Log.track()
self._openTaskPanel(vobj, True)
def setEdit(self, vobj, mode=0):
self._openTaskPanel(vobj, False)
return True
def unsetEdit(self, vobj, mode):
FreeCADGui.Control.closeDialog()
self.panel = None
return
def claimChildren(self):
if self.obj.BitBody:
return [self.obj.BitBody]
return []
def doubleClicked(self, vobj):
if os.path.exists(vobj.Object.BitShape):
self.setEdit(vobj)
else:
msg = translate("CAM_Toolbit", "Toolbit cannot be edited: Shapefile not found")
diag = QtGui.QMessageBox(QtGui.QMessageBox.Warning, "Error", msg)
diag.setWindowModality(QtCore.Qt.ApplicationModal)
diag.exec_()
class TaskPanel:
"""TaskPanel for the SetupSheet - if it is being edited directly."""
def __init__(self, vobj, deleteOnReject):
Path.Log.track(vobj.Object.Label)
self.vobj = vobj
self.obj = vobj.Object
self.editor = PathToolBitEdit.ToolBitEditor(self.obj)
self.form = self.editor.form
self.deleteOnReject = deleteOnReject
FreeCAD.ActiveDocument.openTransaction("Edit ToolBit")
def reject(self):
FreeCAD.ActiveDocument.abortTransaction()
self.editor.reject()
FreeCADGui.Control.closeDialog()
if self.deleteOnReject:
FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit")
self.editor.reject()
FreeCAD.ActiveDocument.removeObject(self.obj.Name)
FreeCAD.ActiveDocument.commitTransaction()
FreeCAD.ActiveDocument.recompute()
def accept(self):
self.editor.accept()
FreeCAD.ActiveDocument.commitTransaction()
FreeCADGui.ActiveDocument.resetEdit()
FreeCADGui.Control.closeDialog()
FreeCAD.ActiveDocument.recompute()
def updateUI(self):
Path.Log.track()
self.editor.updateUI()
def updateModel(self):
self.editor.updateTool()
FreeCAD.ActiveDocument.recompute()
def setupUi(self):
self.editor.setupUI()
class ToolBitGuiFactory(PathToolBit.ToolBitFactory):
def Create(self, name="ToolBit", shapeFile=None, path=None):
"""Create(name = 'ToolBit') ... creates a new tool bit.
It is assumed the tool will be edited immediately so the internal bit body is still attached.
"""
Path.Log.track(name, shapeFile, path)
FreeCAD.ActiveDocument.openTransaction("Create ToolBit")
tool = PathToolBit.ToolBitFactory.Create(self, name, shapeFile, path)
PathIconViewProvider.Attach(tool.ViewObject, name)
FreeCAD.ActiveDocument.commitTransaction()
return tool
def isValidFileName(filename):
print(filename)
try:
with open(filename, "w") as tempfile:
return True
except Exception:
return False
def GetNewToolFile(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
foo = QtGui.QFileDialog.getSaveFileName(
parent, translate("CAM_Toolbit", "Tool"), Path.Preferences.lastPathToolBit(), "*.fctb"
)
if foo and foo[0]:
if not isValidFileName(foo[0]):
msgBox = QtGui.QMessageBox()
msg = translate("CAM_Toolbit", "Invalid Filename")
msgBox.setText(msg)
msgBox.exec_()
else:
Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0]))
return foo[0]
return None
def GetToolFile(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
foo = QtGui.QFileDialog.getOpenFileName(
parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb"
)
if foo and foo[0]:
Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0]))
return foo[0]
return None
def GetToolFiles(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
foo = QtGui.QFileDialog.getOpenFileNames(
parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb"
)
if foo and foo[0]:
Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0][0]))
return foo[0]
return []
def GetToolShapeFile(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
location = Path.Preferences.lastPathToolShape()
if os.path.isfile(location):
location = os.path.split(location)[0]
elif not os.path.isdir(location):
location = Path.Preferences.filePath()
fname = QtGui.QFileDialog.getOpenFileName(
parent, translate("CAM_Toolbit", "Select Tool Shape"), location, "*.fcstd"
)
if fname and fname[0]:
if fname != location:
newloc = os.path.dirname(fname[0])
Path.Preferences.setLastPathToolShape(newloc)
return fname[0]
else:
return None
def LoadTool(parent=None):
"""
LoadTool(parent=None) ... Open a file dialog to load a tool from a file.
"""
foo = GetToolFile(parent)
return PathToolBit.Factory.CreateFrom(foo) if foo else foo
def LoadTools(parent=None):
"""
LoadTool(parent=None) ... Open a file dialog to load a tool from a file.
"""
return [PathToolBit.Factory.CreateFrom(foo) for foo in GetToolFiles(parent)]
# Set the factory so all tools are created with UI
PathToolBit.Factory = ToolBitGuiFactory()
PathIconViewProvider.RegisterViewProvider("ToolBit", ViewProvider)

View File

@@ -1,275 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide import QtCore, QtGui
import FreeCADGui
import Path
import Path.Base.Gui.PropertyEditor as PathPropertyEditor
import Path.Base.Gui.Util as PathGuiUtil
import Path.Base.Util as PathUtil
import os
import re
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class _Delegate(QtGui.QStyledItemDelegate):
"""Handles the creation of an appropriate editing widget for a given property."""
ObjectRole = QtCore.Qt.UserRole + 1
PropertyRole = QtCore.Qt.UserRole + 2
EditorRole = QtCore.Qt.UserRole + 3
def createEditor(self, parent, option, index):
editor = index.data(self.EditorRole)
if editor is None:
obj = index.data(self.ObjectRole)
prp = index.data(self.PropertyRole)
editor = PathPropertyEditor.Editor(obj, prp)
index.model().setData(index, editor, self.EditorRole)
return editor.widget(parent)
def setEditorData(self, widget, index):
# called to update the widget with the current data
index.data(self.EditorRole).setEditorData(widget)
def setModelData(self, widget, model, index):
# called to update the model with the data from the widget
editor = index.data(self.EditorRole)
editor.setModelData(widget)
index.model().setData(
index,
PathUtil.getPropertyValueString(editor.obj, editor.prop),
QtCore.Qt.DisplayRole,
)
class ToolBitEditor(object):
"""UI and controller for editing a ToolBit.
The controller embeds the UI to the parentWidget which has to have a
layout attached to it.
"""
def __init__(self, tool, parentWidget=None, loadBitBody=True):
Path.Log.track()
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui")
if parentWidget:
self.form.setParent(parentWidget)
parentWidget.layout().addWidget(self.form)
self.tool = tool
self.loadbitbody = loadBitBody
if not tool.BitShape:
self.tool.BitShape = "endmill.fcstd"
if self.loadbitbody:
self.tool.Proxy.loadBitBody(self.tool)
# remove example widgets
layout = self.form.bitParams.layout()
for i in range(layout.rowCount() - 1, -1, -1):
layout.removeRow(i)
# used to track property widgets and editors
self.widgets = []
self.setupTool(self.tool)
self.setupAttributes(self.tool)
def setupTool(self, tool):
Path.Log.track()
# Can't delete and add fields to the form because of dangling references in case of
# a focus change. see https://forum.freecad.org/viewtopic.php?f=10&t=52246#p458583
# Instead we keep widgets once created and use them for new properties, and hide all
# which aren't being needed anymore.
def labelText(name):
return re.sub(r"([A-Z][a-z]+)", r" \1", re.sub(r"([A-Z]+)", r" \1", name))
layout = self.form.bitParams.layout()
ui = FreeCADGui.UiLoader()
# for all properties either assign them to existing labels and editors
# or create additional ones for them if not enough have already been
# created.
usedRows = 0
for nr, name in enumerate(tool.Proxy.toolShapeProperties(tool)):
if nr < len(self.widgets):
Path.Log.debug("reuse row: {} [{}]".format(nr, name))
label, qsb, editor = self.widgets[nr]
label.setText(labelText(name))
editor.attachTo(tool, name)
label.show()
qsb.show()
else:
qsb = ui.createWidget("Gui::QuantitySpinBox")
editor = PathGuiUtil.QuantitySpinBox(qsb, tool, name)
label = QtGui.QLabel(labelText(name))
self.widgets.append((label, qsb, editor))
Path.Log.debug("create row: {} [{}] {}".format(nr, name, type(qsb)))
if hasattr(qsb, "editingFinished"):
qsb.editingFinished.connect(self.updateTool)
if nr >= layout.rowCount():
layout.addRow(label, qsb)
usedRows = usedRows + 1
# hide all rows which aren't being used
Path.Log.track(usedRows, len(self.widgets))
for i in range(usedRows, len(self.widgets)):
label, qsb, editor = self.widgets[i]
label.hide()
qsb.hide()
editor.attachTo(None)
Path.Log.debug(" hide row: {}".format(i))
img = tool.Proxy.getBitThumbnail(tool)
if img:
self.form.image.setPixmap(QtGui.QPixmap(QtGui.QImage.fromData(img)))
else:
self.form.image.setPixmap(QtGui.QPixmap())
def setupAttributes(self, tool):
Path.Log.track()
setup = True
if not hasattr(self, "delegate"):
self.delegate = _Delegate(self.form.attrTree)
self.model = QtGui.QStandardItemModel(self.form.attrTree)
self.model.setHorizontalHeaderLabels(["Property", "Value"])
else:
self.model.removeRows(0, self.model.rowCount())
setup = False
attributes = tool.Proxy.toolGroupsAndProperties(tool, False)
for name in attributes:
group = QtGui.QStandardItem()
group.setData(name, QtCore.Qt.EditRole)
group.setEditable(False)
for prop in attributes[name]:
label = QtGui.QStandardItem()
label.setData(prop, QtCore.Qt.EditRole)
label.setEditable(False)
value = QtGui.QStandardItem()
value.setData(PathUtil.getPropertyValueString(tool, prop), QtCore.Qt.DisplayRole)
value.setData(tool, _Delegate.ObjectRole)
value.setData(prop, _Delegate.PropertyRole)
group.appendRow([label, value])
self.model.appendRow(group)
if setup:
self.form.attrTree.setModel(self.model)
self.form.attrTree.setItemDelegateForColumn(1, self.delegate)
self.form.attrTree.expandAll()
self.form.attrTree.resizeColumnToContents(0)
self.form.attrTree.resizeColumnToContents(1)
# self.form.attrTree.collapseAll()
def accept(self):
Path.Log.track()
self.refresh()
self.tool.Proxy.unloadBitBody(self.tool)
def reject(self):
Path.Log.track()
self.tool.Proxy.unloadBitBody(self.tool)
def updateUI(self):
Path.Log.track()
self.form.toolName.setText(self.tool.Label)
self.form.shapePath.setText(self.tool.BitShape)
for lbl, qsb, editor in self.widgets:
editor.updateSpinBox()
def _updateBitShape(self, shapePath):
# Only need to go through this exercise if the shape actually changed.
if self.tool.BitShape != shapePath:
# Before setting a new bitshape we need to make sure that none of
# editors fires an event and tries to access its old property, which
# might not exist anymore.
for lbl, qsb, editor in self.widgets:
editor.attachTo(self.tool, "File")
self.tool.BitShape = shapePath
self.setupTool(self.tool)
self.form.toolName.setText(self.tool.Label)
if self.tool.BitBody and self.tool.BitBody.ViewObject:
if not self.tool.BitBody.ViewObject.Visibility:
self.tool.BitBody.ViewObject.Visibility = True
self.setupAttributes(self.tool)
return True
return False
def updateShape(self):
Path.Log.track()
shapePath = str(self.form.shapePath.text())
# Only need to go through this exercise if the shape actually changed.
if self._updateBitShape(shapePath):
for lbl, qsb, editor in self.widgets:
editor.updateSpinBox()
def updateTool(self):
Path.Log.track()
label = str(self.form.toolName.text())
shape = str(self.form.shapePath.text())
if self.tool.Label != label:
self.tool.Label = label
self._updateBitShape(shape)
for lbl, qsb, editor in self.widgets:
editor.updateProperty()
self.tool.Proxy._updateBitShape(self.tool)
def refresh(self):
Path.Log.track()
self.form.blockSignals(True)
self.updateTool()
self.updateUI()
self.form.blockSignals(False)
def selectShape(self):
Path.Log.track()
path = self.tool.BitShape
if not path:
path = Path.Preferences.lastPathToolShape()
foo = QtGui.QFileDialog.getOpenFileName(self.form, "Path - Tool Shape", path, "*.fcstd")
if foo and foo[0]:
Path.Preferences.setLastPathToolShape(os.path.dirname(foo[0]))
self.form.shapePath.setText(foo[0])
self.updateShape()
def setupUI(self):
Path.Log.track()
self.updateUI()
self.form.toolName.editingFinished.connect(self.refresh)
self.form.shapePath.editingFinished.connect(self.updateShape)
self.form.shapeSet.clicked.connect(self.selectShape)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 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) *
@@ -20,6 +21,7 @@
# * *
# ***************************************************************************
from lazy_loader.lazy_loader import LazyLoader
from PySide import QtCore, QtGui
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
@@ -28,11 +30,7 @@ import Path
import Path.Base.Gui.Util as PathGuiUtil
import Path.Base.Util as PathUtil
import Path.Tool.Controller as PathToolController
import Path.Tool.Gui.Bit as PathToolBitGui
import PathGui
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
from Path.Tool.toolbit.ui.selector import ToolBitSelector
Part = LazyLoader("Part", globals(), "Part")
@@ -162,19 +160,30 @@ class CommandPathToolController(object):
def Activated(self):
Path.Log.track()
job = self.selectedJob()
if job:
tool = PathToolBitGui.ToolBitSelector().getTool()
if tool:
toolNr = None
for tc in job.Tools.Group:
if tc.Tool == tool:
toolNr = tc.ToolNumber
break
if not toolNr:
toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1
tc = Create("TC: {}".format(tool.Label), tool, toolNr)
job.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
if not job:
return
# Let the user select a toolbit
selector = ToolBitSelector()
if not selector.exec_():
return
tool = selector.get_selected_tool()
if not tool:
return
# Find a tool number
toolNr = None
for tc in job.Tools.Group:
if tc.Tool == tool:
toolNr = tc.ToolNumber
break
if not toolNr:
toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1
# Create the new tool controller with the tool.
tc = Create("TC: {}".format(tool.Label), tool, toolNr)
job.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
class ToolControllerEditor(object):

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import sys
from lazy_loader.lazy_loader import LazyLoader
from . import toolbit
from .assets import DummyAssetSerializer
from .camassets import cam_assets
from .library import Library
from .library.serializers import FCTLSerializer
from .toolbit import ToolBit
from .toolbit.serializers import FCTBSerializer
from .shape import ToolBitShape, ToolBitShapePngIcon, ToolBitShapeSvgIcon
from .machine import Machine
# Register asset classes and serializers.
cam_assets.register_asset(Library, FCTLSerializer)
cam_assets.register_asset(ToolBit, FCTBSerializer)
cam_assets.register_asset(ToolBitShape, DummyAssetSerializer)
cam_assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer)
cam_assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer)
cam_assets.register_asset(Machine, DummyAssetSerializer)
# For backward compatibility with files saved before the toolbit rename
# This makes the Path.Tool.toolbit.base module available as Path.Tool.Bit.
# Since C++ does not use the standard Python import mechanism and instead
# unpickles existing objects after looking them up in sys.modules, we
# need to update sys.modules here.
sys.modules[__name__ + ".Bit"] = toolbit.models.base
sys.modules[__name__ + ".Gui.Bit"] = LazyLoader(
"Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view"
)
# Define __all__ for explicit public interface
__all__ = [
"ToolBit",
"cam_assets",
]

View File

@@ -0,0 +1,323 @@
# Asset Management Module
This module implements an asset manager that provides methods for storing,
updating, deleting, and receiving assets for the FreeCAD CAM workbench.
## Goals of the asset manager
While currently the AssetManager has no UI yet, the plan is to add one.
The ultimate vision for the asset manager is to provide a unified UI that
can download assets from arbitrary sources, such as online databases,
Git repositories, and also local storage. It should also allow for copying
between these storages, effectively allowing for publishing assets.
Essentially, something similar to what Blender has:
![Blender Asset Manager](docs/blender-assets.jpg)
## What are assets in CAM?
Assets are arbitrary data, such as FreeCAD models, Tools, and many more.
Specifically in the context of CAM, assets are:
- Tool bit libraries
- Tool bits
- Tool bit shape files
- Tool bit shape icons
- Machines
- Fixtures
- Post processors
- ...
**Assets have dependencies:** For example, a ToolBitLibrary requires ToolBits,
and a ToolBit requires a ToolBitShape (which is a FreeCAD model).
## Challenges
In the current codebase, CAM objects are monoliths that handle everything:
in-memory data, serialization, deserialization, storage. They are tightly
coupled to files, and make assumptions about how other objects are stored.
Examples:
- Tool bits have "File" attributes that they use to collect dependencies
such as ToolBit files and shape files.
- It also writes directly to the disk.
- GuiToolBit performs serialization directly in UI functions.
- ToolBits could not be created without an active document.
As the code base grows, separation of concerns becomes more important.
Managing dependencies between asset becomes a hassle if every object tries
to resolve them in their own way.
# Solution
The main effort went into two key areas:
1. **The generic AssetManager:**
- **Manages storage** while existing FreeCAD tool library file structures retained
- **Manages dependencies** including detection of cyclic dependencies, deep vs. shallow fetching
- **Manages threading** for asynchronous storage, while FreeCAD objects are assembled in the main UI thread
- **Defining a generic asset interface** that classes can implement to become "storable"
2. **Refactoring existing CAM objects for clear separation of concerns:**
- **View**: Should handle user interface only. Existing file system access methods were removed.
- **Object Model**: In-memory representation of an object, for example a ToolBit, Icon, or a ToolBitShape. By giving all classes `from_bytes()` and `to_bytes()` methods; the objects no longer need to handle storage themselves.
- **Storage**: Persisting an object to a file system, database system, or API. This is now handled by the AssetManager
- **Serialization** A serialization protocol needs to be defined. This will allow for better import/export mechanisms in the future
FreeCAD is now fully usable with the changes in place. Work remains to be done on the serializers.
## Asset Manager API usage example
```python
import pathlib
from typing import Any, Mapping, List, Type
from Path.Tool.assets import AssetManager, FileStore, AssetUri, Asset
# Define a simple Material class implementing the Asset interface
class Material(Asset):
asset_type: str = "material"
def __init__(self, name: str):
self.name = name
def get_id() -> str:
return self.name.lower().replace(" ", "-")
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def from_bytes(cls, data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) -> Material:
return cls(data.decode('utf-8'))
def to_bytes(self) -> bytes:
return self.name.encode('utf-8')
manager = AssetManager()
# Register FileStore and the simple asset class
manager.register_store(FileStore("local", pathlib.Path("/tmp/assets")))
manager.register_asset(Material)
# Create and get an asset
asset_uri = manager.add(Material("Copper"))
print(f"Stored with URI: {asset_uri}")
retrieved_asset = manager.get(asset_uri)
print(f"Retrieved: {retrieved_asset}")
```
## The Serializer Protocol
The serializer protocol defines how assets are converted to and from bytes and how their
dependencies are identified. This separation of concerns allows assets to be stored and
retrieved independently of their specific serialization format.
The core components of the protocol are the [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11)
and [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) classes.
- The [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11) class represents an asset object in
memory. It provides methods like [`to_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:69)
and [`from_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:56) which delegate the actual
serialization and deserialization to an [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8)
instance. It also has an [`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/asset.py:49)
method that uses the serializer to find dependencies within the raw asset data.
- The [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) is an abstract base
class that defines the interface for serializers. Concrete implementations of
[`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) are responsible for the
specific logic of converting an asset object to bytes ([`serialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:21)),
converting bytes back to an asset object ([`deserialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:27)),
and extracting dependency URIs from the raw byte data
([`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/serializer.py:15)).
This design allows the AssetManager to work with various asset types and serialization formats
by simply registering the appropriate [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8)
for each asset type.
## Class diagram
```mermaid
classDiagram
direction LR
%% -------------- Asset Manager Module --------------
note for AssetManager "AssetUri structure:
&lt;asset_type&gt;:\//&lt;asset_id&gt;[/&lt;version&gt;]<br/>
Examples:
material:\//1234567/1
toolbitshape:\//endmill/1
material:\//aluminium-6012/2"
class AssetManager["AssetManager
<small>Creates, assembles or deletes assets from URIs</small>"] {
stores: Mapping[str, AssetStore] // maps protocol to store
register_store(store: AssetStore, cacheable: bool = False)
register_asset(asset_class: Type[Asset], serializer: Type[AssetSerializer])
get(uri: AssetUri | str, store: str = "local", depth: Optional[int] = None) Any
get_raw(uri: AssetUri | str, store: str = "local") bytes
add(obj: Asset, store: str = "local") AssetUri
add_raw(asset_type: str, asset_id: str, data: bytes, store: str = "local") AssetUri
delete(uri: AssetUri | str, store: str = "local")
is_empty(asset_type: str | None = None, store: str = "local") bool
list_assets(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local") List[AssetUri]
list_versions(uri: AssetUri | str, store: str = "local") List[AssetUri]
get_bulk(uris: Sequence[AssetUri | str], store: str = "local", depth: Optional[int] = None) List[Any]
fetch(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local", depth: Optional[int] = None) List[Asset]
}
class AssetStore["AssetStore
<small>Stores/Retrieves assets as raw bytes</small>"] {
<<abstract>>
async get(uri: AssetUri) bytes
async count_assets(asset_type: str | None = None) int
async delete(uri: AssetUri)
async create(asset_type: str, asset_id: str, data: bytes) AssetUri
async update(uri: AssetUri, data: bytes) AssetUri
async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri]
async list_versions(uri: AssetUri) List[AssetUri]
async is_empty(asset_type: str | None = None) bool
}
AssetStore *-- AssetManager: has many
class FileStore["FileStore
<small>Stores/Retrieves versioned assets as directories/files</small>"] {
__init__(name: str, filepath: pathlib.Path)
set_dir(new_dir: pathlib.Path)
async get(uri: AssetUri) bytes
async delete(uri: AssetUri)
async create(asset_type: str, asset_id: str, data: bytes) AssetUri
async update(uri: AssetUri, data: bytes) AssetUri
async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri]
async list_versions(uri: AssetUri) List[AssetUri]
async is_empty(asset_type: str | None = None) bool
}
FileStore <|-- AssetStore: is
class MemoryStore["MemoryStore
<small>In-memory store, mostly for testing/demonstration</small>"] {
__init__(name: str)
async get(uri: AssetUri) bytes
async delete(uri: AssetUri)
async create(asset_type: str, asset_id: str, data: bytes) AssetUri
async update(uri: AssetUri, data: bytes) AssetUri
async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri]
async list_versions(uri: AssetUri) List[AssetUri]
async is_empty(asset_type: str | None) bool
dump(print: bool) Dict | None
}
MemoryStore <|-- AssetStore: is
class AssetSerializer["AssetSerializer<br/><small>Abstract base class for asset serializers</small>"] {
<<abstract>>
for_class: Type[Asset]
extensions: Tuple[str]
mime_type: str
extract_dependencies(data: bytes) List[AssetUri]
serialize(asset: Asset) bytes
deserialize(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) Asset
}
AssetSerializer *-- AssetManager: has many
Asset --> AssetSerializer: uses
class Asset["Asset<br/><small>Common interface for all asset types</small>"] {
<<abstract>>
asset_type: str // type of the asset type, e.g., toolbit
get_id() str // Returns a unique ID of the asset
to_bytes(serializer: AssetSerializer) bytes
from_bytes(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]], serializer: Type[AssetSerializer]) Asset
extract_dependencies(data: bytes, serializer: Type[AssetSerializer]) List[AssetUri] // Extracts dependency URIs from bytes
}
Asset *-- AssetManager: creates
namespace AssetManagerModule {
class AssetManager
class AssetStore
class FileStore
class MemoryStore
class AssetSerializer
class Asset
}
%% -------------- CAM Module (as an example) --------------
class ToolBitShape["ToolBitShape<br/><small>for assets with type toolbitshape</small>"] {
<<Asset>>
asset_type: str = "toolbitshape"
get_id() str // Returns a unique ID
from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBitShape
to_bytes(obj: ToolBitShape) bytes
dependencies(data: bytes) List[AssetUri]
}
ToolBitShape ..|> Asset: is
class ToolBit["ToolBit<br/><small>for assets with type toolbit</small>"] {
<<Asset>>
asset_type: str = "toolbit"
get_id() str // Returns a unique ID
from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBit
to_bytes(obj: ToolBit) bytes
dependencies(data: bytes) List[AssetUri]
}
ToolBit ..|> Asset: is
ToolBit --> ToolBitShape: has
namespace CAMModule {
class ToolBitShape
class ToolBit
}
%% -------------- Materials Module (as an example) --------------
class Material["Material<br/><small>for assets with type material</small>"] {
<<Asset>>
asset_type: str = "material"
get_id() str // Returns a unique ID
dependencies(data: bytes) List[AssetUri]
from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) Material
to_bytes(obj: Material) bytes
}
Material ..|> Asset: is
namespace MaterialModule {
class Material
class Material
}
```
# UI Helpers
The `ui` directory contains helper modules for the asset manager's user interface.
- [`filedialog.py`](src/Mod/CAM/Path/Tool/assets/ui/filedialog.py):
Provides file dialogs for importing and exporting assets.
- [`util.py`](src/Mod/CAM/Path/Tool/assets/ui/util.py): Contains general utility
functions used within the asset manager UI.
# What's next
## Shorter term
- Improving the integration of serializers. Ideally the asset manager could help here too:
We can define a common serializer protocol for **all** assets. It could then become the
central point for imports and exports.
## Potential future extensions (longer term)
- Adding a AssetManager UI, to allow for browsing and searching stores for all kinds of
assets (Machines, Fixtures, Libraries, Tools, Shapes, Post Processors, ...)
from all kings of sources (online DB, git repository, etc.).
- Adding a GitStore, to connect to things like the [FreeCAD library](https://github.com/FreeCAD/FreeCAD-library).
- Adding an HttpStore for connectivity to online databases.

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from .asset import Asset
from .manager import AssetManager
from .uri import AssetUri
from .serializer import AssetSerializer, DummyAssetSerializer
from .store.base import AssetStore
from .store.memory import MemoryStore
from .store.filestore import FileStore
__all__ = [
"Asset",
"AssetUri",
"AssetManager",
"AssetSerializer",
"DummyAssetSerializer",
"AssetStore",
"MemoryStore",
"FileStore",
]

View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from __future__ import annotations
import abc
from abc import ABC
from typing import Mapping, List, Optional, Type, TYPE_CHECKING
from .uri import AssetUri
if TYPE_CHECKING:
from .serializer import AssetSerializer
class Asset(ABC):
asset_type: str
def __init__(self, *args, **kwargs):
if not hasattr(self, "asset_type"):
raise ValueError("Asset subclasses must define 'asset_type'.")
@property
def label(self) -> str:
return self.__class__.__name__
@abc.abstractmethod
def get_id(self) -> str:
"""Returns the unique ID of an asset object."""
pass
def get_uri(self) -> AssetUri:
return AssetUri.build(asset_type=self.asset_type, asset_id=self.get_id())
@classmethod
def resolve_name(cls, identifier: str) -> AssetUri:
"""
Resolves an identifier (id, name, or URI) to an AssetUri object.
"""
# 1. If the input is a url string, return the Uri object for it.
if AssetUri.is_uri(identifier):
return AssetUri(identifier)
# 2. Construct the Uri using Uri.build() and return it
return AssetUri.build(
asset_type=cls.asset_type,
asset_id=identifier,
)
@classmethod
def get_uri_from_id(cls, asset_id):
return AssetUri.build(cls.asset_type, asset_id=asset_id)
@classmethod
def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
return serializer.extract_dependencies(data)
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
serializer: Type[AssetSerializer],
) -> Asset:
"""
Creates an object from serialized data and resolved dependencies.
If dependencies is None, it indicates a shallow load where dependencies were not resolved.
"""
return serializer.deserialize(data, id, dependencies)
def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes:
"""Serializes an object into bytes."""
return serializer.serialize(self)

View File

@@ -0,0 +1,172 @@
# -*- 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 time
import hashlib
import logging
from collections import OrderedDict
from typing import Any, Dict, Set, NamedTuple, Optional, Tuple
# For type hinting Asset and AssetUri to avoid circular imports
# from typing import TYPE_CHECKING
# if TYPE_CHECKING:
# from .asset import Asset
# from .uri import AssetUri
logger = logging.getLogger(__name__)
class CacheKey(NamedTuple):
store_name: str
asset_uri_str: str
raw_data_hash: int
dependency_signature: Tuple
class CachedAssetEntry(NamedTuple):
asset: Any # Actually type Asset
size_bytes: int # Estimated size of the raw_data
timestamp: float # For LRU, or just use OrderedDict nature
class AssetCache:
def __init__(self, max_size_bytes: int = 100 * 1024 * 1024): # Default 100MB
self.max_size_bytes: int = max_size_bytes
self.current_size_bytes: int = 0
self._cache: Dict[CacheKey, CachedAssetEntry] = {}
self._lru_order: OrderedDict[CacheKey, None] = OrderedDict()
self._cache_dependents_map: Dict[str, Set[CacheKey]] = {}
self._cache_dependencies_map: Dict[CacheKey, Set[str]] = {}
def _evict_lru(self):
while self.current_size_bytes > self.max_size_bytes and self._lru_order:
oldest_key, _ = self._lru_order.popitem(last=False)
if oldest_key in self._cache:
evicted_entry = self._cache.pop(oldest_key)
self.current_size_bytes -= evicted_entry.size_bytes
logger.debug(
f"Cache Evict (LRU): {oldest_key}, "
f"size {evicted_entry.size_bytes}. "
f"New size: {self.current_size_bytes}"
)
self._remove_key_from_dependency_maps(oldest_key)
def _remove_key_from_dependency_maps(self, cache_key_to_remove: CacheKey):
direct_deps_of_removed = self._cache_dependencies_map.pop(cache_key_to_remove, set())
for dep_uri_str in direct_deps_of_removed:
if dep_uri_str in self._cache_dependents_map:
self._cache_dependents_map[dep_uri_str].discard(cache_key_to_remove)
if not self._cache_dependents_map[dep_uri_str]:
del self._cache_dependents_map[dep_uri_str]
def get(self, key: CacheKey) -> Optional[Any]:
if key in self._cache:
self._lru_order.move_to_end(key)
logger.debug(f"Cache HIT: {key}")
return self._cache[key].asset
logger.debug(f"Cache MISS: {key}")
return None
def put(
self,
key: CacheKey,
asset: Any,
raw_data_size_bytes: int,
direct_dependency_uri_strs: Set[str],
):
if key in self._cache:
self._remove_key_from_dependency_maps(key)
self.current_size_bytes -= self._cache[key].size_bytes
del self._cache[key]
self._lru_order.pop(key, None)
if raw_data_size_bytes > self.max_size_bytes:
logger.warning(
f"Asset {key.asset_uri_str} (size {raw_data_size_bytes}) "
f"too large for cache (max {self.max_size_bytes}). Not caching."
)
return
self.current_size_bytes += raw_data_size_bytes
entry = CachedAssetEntry(asset=asset, size_bytes=raw_data_size_bytes, timestamp=time.time())
self._cache[key] = entry
self._lru_order[key] = None
self._lru_order.move_to_end(key)
self._cache_dependencies_map[key] = direct_dependency_uri_strs
for dep_uri_str in direct_dependency_uri_strs:
self._cache_dependents_map.setdefault(dep_uri_str, set()).add(key)
logger.debug(
f"Cache PUT: {key}, size {raw_data_size_bytes}. "
f"Total cache size: {self.current_size_bytes}"
)
self._evict_lru()
def invalidate_for_uri(self, updated_asset_uri_str: str):
keys_to_remove_from_cache: Set[CacheKey] = set()
invalidation_queue: list[str] = [updated_asset_uri_str]
processed_uris_for_invalidation_round: Set[str] = set()
while invalidation_queue:
current_uri_to_check_str = invalidation_queue.pop(0)
if current_uri_to_check_str in processed_uris_for_invalidation_round:
continue
processed_uris_for_invalidation_round.add(current_uri_to_check_str)
for ck in list(self._cache.keys()):
if ck.asset_uri_str == current_uri_to_check_str:
keys_to_remove_from_cache.add(ck)
dependent_cache_keys = self._cache_dependents_map.get(
current_uri_to_check_str, set()
).copy()
for dep_ck in dependent_cache_keys:
if dep_ck not in keys_to_remove_from_cache:
keys_to_remove_from_cache.add(dep_ck)
parent_uri_of_dep_ck = dep_ck.asset_uri_str
if parent_uri_of_dep_ck not in processed_uris_for_invalidation_round:
invalidation_queue.append(parent_uri_of_dep_ck)
for ck_to_remove in keys_to_remove_from_cache:
if ck_to_remove in self._cache:
entry_to_remove = self._cache.pop(ck_to_remove)
self.current_size_bytes -= entry_to_remove.size_bytes
self._lru_order.pop(ck_to_remove, None)
self._remove_key_from_dependency_maps(ck_to_remove)
if keys_to_remove_from_cache:
logger.debug(
f"Cache invalidated for URI '{updated_asset_uri_str}' and "
f"its dependents. Removed {len(keys_to_remove_from_cache)} "
f"entries. New size: {self.current_size_bytes}"
)
def clear(self):
self._cache.clear()
self._lru_order.clear()
self._cache_dependents_map.clear()
self._cache_dependencies_map.clear()
self.current_size_bytes = 0
logger.info("AssetCache cleared.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -0,0 +1,768 @@
# -*- 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 logging
import asyncio
import threading
import pathlib
import hashlib
from typing import Dict, Any, Type, Optional, List, Sequence, Union, Set, Mapping, Tuple
from dataclasses import dataclass
from PySide import QtCore, QtGui
from .store.base import AssetStore
from .asset import Asset
from .serializer import AssetSerializer
from .uri import AssetUri
from .cache import AssetCache, CacheKey
logger = logging.getLogger(__name__)
@dataclass
class _AssetConstructionData:
"""Holds raw data and type info needed to construct an asset instance."""
uri: AssetUri
raw_data: bytes
asset_class: Type[Asset]
# Stores AssetConstructionData for dependencies, keyed by their AssetUri
dependencies_data: Optional[Dict[AssetUri, Optional["_AssetConstructionData"]]] = None
class AssetManager:
def __init__(self, cache_max_size_bytes: int = 100 * 1024 * 1024):
self.stores: Dict[str, AssetStore] = {}
self._serializers: List[Tuple[Type[AssetSerializer], Type[Asset]]] = []
self._asset_classes: Dict[str, Type[Asset]] = {}
self.asset_cache = AssetCache(max_size_bytes=cache_max_size_bytes)
self._cacheable_stores: Set[str] = set()
logger.debug(f"AssetManager initialized (Thread: {threading.current_thread().name})")
def register_store(self, store: AssetStore, cacheable: bool = False):
"""Registers an AssetStore with the manager."""
logger.debug(f"Registering store: {store.name}, cacheable: {cacheable}")
self.stores[store.name] = store
if cacheable:
self._cacheable_stores.add(store.name)
def get_serializer_for_class(self, asset_class: Type[Asset]):
for serializer, theasset_class in self._serializers:
if issubclass(asset_class, theasset_class):
return serializer
raise ValueError(f"No serializer found for class {asset_class}")
def register_asset(self, asset_class: Type[Asset], serializer: Type[AssetSerializer]):
"""Registers an Asset class with the manager."""
if not issubclass(asset_class, Asset):
raise TypeError(f"Item '{asset_class.__name__}' must be a subclass of Asset.")
if not issubclass(serializer, AssetSerializer):
raise TypeError(f"Item '{serializer.__name__}' must be a subclass of AssetSerializer.")
self._serializers.append((serializer, asset_class))
asset_type_name = getattr(asset_class, "asset_type", None)
if not isinstance(asset_type_name, str) or not asset_type_name: # Ensure not empty
raise TypeError(
f"Asset class '{asset_class.__name__}' must have a non-empty string 'asset_type' attribute."
)
logger.debug(f"Registering asset type: '{asset_type_name}' -> {asset_class.__name__}")
self._asset_classes[asset_type_name] = asset_class
async def _fetch_asset_construction_data_recursive_async(
self,
uri: AssetUri,
store_name: str,
visited_uris: Set[AssetUri],
depth: Optional[int] = None,
) -> Optional[_AssetConstructionData]:
logger.debug(
f"_fetch_asset_construction_data_recursive_async called {store_name} {uri} {depth}"
)
if uri in visited_uris:
logger.error(f"Cyclic dependency detected for URI: {uri}")
raise RuntimeError(f"Cyclic dependency encountered for URI: {uri}")
# Check arguments
store = self.stores.get(store_name)
if not store:
raise ValueError(f"No store registered for name: {store_name}")
asset_class = self._asset_classes.get(uri.asset_type)
if not asset_class:
raise ValueError(f"No asset class registered for asset type: {asset_class}")
# Fetch the requested asset
try:
raw_data = await store.get(uri)
except FileNotFoundError:
logger.debug(
f"_fetch_asset_construction_data_recursive_async: Asset not found for {uri}"
)
return None # Primary asset not found
if depth == 0:
return _AssetConstructionData(
uri=uri,
raw_data=raw_data,
asset_class=asset_class,
dependencies_data=None, # Indicates that no attempt was made to fetch deps
)
# Extract the list of dependencies (non-recursive)
serializer = self.get_serializer_for_class(asset_class)
dependency_uris = asset_class.extract_dependencies(raw_data, serializer)
# Initialize deps_construction_data_map. Any dependencies mapped to None
# indicate that dependencies were intentionally not fetched.
deps_construction_data: Dict[AssetUri, Optional[_AssetConstructionData]] = {}
for dep_uri in dependency_uris:
visited_uris.add(uri)
try:
dep_data = await self._fetch_asset_construction_data_recursive_async(
dep_uri,
store_name,
visited_uris,
None if depth is None else depth - 1,
)
finally:
visited_uris.remove(uri)
deps_construction_data[dep_uri] = dep_data
logger.debug(
f"ToolBitShape '{uri.asset_id}' dependencies_data: {deps_construction_data is None}"
)
return _AssetConstructionData(
uri=uri,
raw_data=raw_data,
asset_class=asset_class,
dependencies_data=deps_construction_data, # Can be None or Dict
)
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
if construction_data.dependencies_data is None:
deps_signature_tuple: Tuple = ("shallow_children",)
else:
deps_signature_tuple = tuple(
sorted(str(uri) for uri in construction_data.dependencies_data.keys())
)
raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16)
return CacheKey(
store_name=store_name_for_cache,
asset_uri_str=str(construction_data.uri),
raw_data_hash=raw_data_hash,
dependency_signature=deps_signature_tuple,
)
def _build_asset_tree_from_data_sync(
self,
construction_data: Optional[_AssetConstructionData],
store_name_for_cache: str,
) -> Asset | None:
"""
Synchronously and recursively builds an asset instance.
Integrates caching logic.
"""
if not construction_data:
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 cache_key:
cached_asset = self.asset_cache.get(cache_key)
if cached_asset is not None:
return cached_asset
logger.debug(
f"BuildAssetTreeSync: Instantiating '{construction_data.uri}' "
f"of type '{construction_data.asset_class.__name__}'"
)
resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None
if construction_data.dependencies_data is not None:
resolved_dependencies = {}
for (
dep_uri,
dep_data_node,
) in construction_data.dependencies_data.items():
# 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)
except Exception as e:
logger.error(
f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}",
exc_info=True,
)
else:
resolved_dependencies[dep_uri] = dep
asset_class = construction_data.asset_class
serializer = self.get_serializer_for_class(asset_class)
try:
final_asset = asset_class.from_bytes(
construction_data.raw_data,
construction_data.uri.asset_id,
resolved_dependencies,
serializer,
)
except Exception as e:
logger.error(
f"Error instantiating asset '{construction_data.uri}' of type '{asset_class.__name__}': {e}",
exc_info=True,
)
return None
if final_asset is not None and cache_key:
# This check implies store_name_for_cache was in _cacheable_stores
direct_deps_uris_strs: Set[str] = set()
if construction_data.dependencies_data is not None:
direct_deps_uris_strs = {
str(uri) for uri in construction_data.dependencies_data.keys()
}
raw_data_size = len(construction_data.raw_data)
self.asset_cache.put(
cache_key,
final_asset,
raw_data_size,
direct_deps_uris_strs,
)
return final_asset
def get(
self,
uri: Union[AssetUri, str],
store: str = "local",
depth: Optional[int] = None,
) -> Asset:
"""
Retrieves an asset by its URI (synchronous wrapper), to a specified depth.
IMPORTANT: Assumes this method is CALLED ONLY from the main UI thread
if Asset.from_bytes performs UI operations.
Depth None means infinite depth. Depth 0 means only this asset, no dependencies.
"""
# Log entry with thread info for verification
calling_thread_name = threading.current_thread().name
logger.debug(
f"AssetManager.get(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}"
)
if (
QtGui.QApplication.instance()
and QtCore.QThread.currentThread() is not QtGui.QApplication.instance().thread()
):
logger.warning(
"AssetManager.get() called from a non-main thread! UI in from_bytes may fail!"
)
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
# Step 1: Fetch all data using asyncio.run
try:
logger.debug(
f"Get: Starting asyncio.run for data fetching of '{asset_uri_obj}', depth {depth}."
)
all_construction_data = asyncio.run(
self._fetch_asset_construction_data_recursive_async(
asset_uri_obj, store, set(), depth
)
)
logger.debug(
f"Get: asyncio.run for data fetching of '{asset_uri_obj}', depth {depth} completed."
)
except Exception as e:
logger.error(
f"Get: Error during asyncio.run data fetching for '{asset_uri_obj}': {e}",
exc_info=False,
)
raise # Re-raise the exception from the async part
if all_construction_data is None:
# This means the top-level asset itself was not found by _fetch_...
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in store '{store}'.")
# Step 2: Synchronously build the asset tree (and call from_bytes)
# This happens in the current thread (which is assumed to be the main UI thread)
deps_count = 0
found_deps_count = 0
if all_construction_data.dependencies_data is not None:
deps_count = len(all_construction_data.dependencies_data)
found_deps_count = sum(
1
for d in all_construction_data.dependencies_data.values()
if d is not None # Count actual data, not None placeholders
)
logger.debug(
f"Get: Starting synchronous asset tree build for '{asset_uri_obj}' "
f"and {deps_count} dependencies ({found_deps_count} resolved)."
)
final_asset = self._build_asset_tree_from_data_sync(
all_construction_data, store_name_for_cache=store
)
logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.")
return final_asset
def get_or_none(
self,
uri: Union[AssetUri, str],
store: str = "local",
depth: Optional[int] = None,
) -> Asset | None:
"""
Convenience wrapper for get() that does not raise FileNotFoundError; returns
None instead
"""
try:
return self.get(uri, store, depth)
except FileNotFoundError:
return None
async def get_async(
self,
uri: Union[AssetUri, str],
store: str = "local",
depth: Optional[int] = None,
) -> Optional[Asset]:
"""
Retrieves an asset by its URI (asynchronous), to a specified depth.
NOTE: If Asset.from_bytes does UI work, this method should ideally be awaited
from an asyncio loop that is integrated with the main UI thread (e.g., via QtAsyncio).
If awaited from a plain worker thread's asyncio loop, from_bytes will run on that worker.
"""
calling_thread_name = threading.current_thread().name
logger.debug(
f"AssetManager.get_async(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}"
)
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
all_construction_data = await self._fetch_asset_construction_data_recursive_async(
asset_uri_obj, store, set(), depth
)
if all_construction_data is None:
# Consistent with get(), if the top-level asset is not found,
# raise FileNotFoundError.
raise FileNotFoundError(
f"Asset '{asset_uri_obj}' not found in store '{store}' (async path)."
)
# return None # Alternative: if Optional[Asset] means asset might not exist
# Instantiation happens in the context of where this get_async was awaited.
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
)
def get_raw(self, uri: Union[AssetUri, str], store: str = "local") -> bytes:
"""Retrieves raw asset data by its URI (synchronous wrapper)."""
logger.debug(
f"AssetManager.get_raw(uri='{uri}', store='{store}') from T:{threading.current_thread().name}"
)
async def _fetch_raw_async():
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
logger.debug(
f"GetRawAsync (internal): Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
try:
selected_store = self.stores[store]
except KeyError:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.get(asset_uri_obj)
try:
return asyncio.run(_fetch_raw_async())
except Exception as e:
logger.error(
f"GetRaw: Error during asyncio.run for '{uri}': {e}",
exc_info=False,
)
raise
async def get_raw_async(self, uri: Union[AssetUri, str], store: str = "local") -> bytes:
"""Retrieves raw asset data by its URI (asynchronous)."""
logger.debug(
f"AssetManager.get_raw_async(uri='{uri}', store='{store}') from T:{threading.current_thread().name}"
)
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
selected_store = self.stores[store]
return await selected_store.get(asset_uri_obj)
def get_bulk(
self,
uris: Sequence[Union[AssetUri, str]],
store: str = "local",
depth: Optional[int] = None,
) -> List[Any]:
"""Retrieves multiple assets by their URIs (synchronous wrapper), to a specified depth."""
logger.debug(
f"AssetManager.get_bulk for {len(uris)} URIs from store '{store}', depth '{depth}'"
)
async def _fetch_all_construction_data_bulk_async():
tasks = [
self._fetch_asset_construction_data_recursive_async(
AssetUri(u) if isinstance(u, str) else u,
store,
set(),
depth,
)
for u in uris
]
# Gather all construction data concurrently
# return_exceptions=True means results list can contain exceptions
return await asyncio.gather(*tasks, return_exceptions=True)
try:
logger.debug("GetBulk: Starting bulk data fetching")
all_construction_data_list = asyncio.run(_fetch_all_construction_data_bulk_async())
logger.debug("GetBulk: bulk data fetching completed")
except Exception as e: # Should ideally not happen if gather returns exceptions
logger.error(
f"GetBulk: Unexpected error during asyncio.run for bulk data: {e}",
exc_info=False,
)
raise
assets = []
for i, data_or_exc in enumerate(all_construction_data_list):
original_uri_input = uris[i]
# Explicitly re-raise exceptions found in the results list
if isinstance(data_or_exc, Exception):
logger.error(
f"GetBulk: Re-raising exception for '{original_uri_input}': {data_or_exc}",
exc_info=False,
)
raise data_or_exc
elif isinstance(data_or_exc, _AssetConstructionData):
# Build asset instance synchronously. Exceptions during build should propagate.
assets.append(
self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store)
)
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)
else: # Should not happen
logger.error(
f"GetBulk: Unexpected item in construction data list for '{original_uri_input}': {type(data_or_exc)}"
)
# Raise an exception for unexpected data types
raise RuntimeError(
f"Unexpected data type for {original_uri_input}: {type(data_or_exc)}"
)
return assets
async def get_bulk_async(
self,
uris: Sequence[Union[AssetUri, str]],
store: str = "local",
depth: Optional[int] = None,
) -> List[Any]:
"""Retrieves multiple assets by their URIs (asynchronous), to a specified depth."""
logger.debug(
f"AssetManager.get_bulk_async for {len(uris)} URIs from store '{store}', depth '{depth}'"
)
tasks = [
self._fetch_asset_construction_data_recursive_async(
AssetUri(u) if isinstance(u, str) else u, store, set(), depth
)
for u in uris
]
all_construction_data_list = await asyncio.gather(*tasks, return_exceptions=True)
assets = []
for i, data_or_exc in enumerate(all_construction_data_list):
if isinstance(data_or_exc, _AssetConstructionData):
assets.append(
self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store)
)
elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None:
assets.append(None)
elif isinstance(data_or_exc, Exception):
assets.append(data_or_exc) # Caller must check
return assets
def fetch(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
depth: Optional[int] = None,
) -> List[Asset]:
"""Fetches asset instances based on type, limit, and offset (synchronous), to a specified depth."""
logger.debug(f"Fetch(type='{asset_type}', store='{store}', depth='{depth}')")
asset_uris = self.list_assets(
asset_type, limit, offset, store
) # list_assets doesn't need depth
results = self.get_bulk(asset_uris, store, depth) # Pass depth to get_bulk
# Filter out non-Asset objects (e.g., None for not found, or exceptions if collected)
return [asset for asset in results if isinstance(asset, Asset)]
async def fetch_async(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
depth: Optional[int] = None,
) -> List[Asset]:
"""Fetches asset instances based on type, limit, and offset (asynchronous), to a specified depth."""
logger.debug(f"FetchAsync(type='{asset_type}', store='{store}', depth='{depth}')")
asset_uris = await self.list_assets_async(
asset_type, limit, offset, store # list_assets_async doesn't need depth
)
results = await self.get_bulk_async(
asset_uris, store, depth
) # Pass depth to get_bulk_async
return [asset for asset in results if isinstance(asset, Asset)]
def list_assets(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
) -> List[AssetUri]:
logger.debug(f"ListAssets(type='{asset_type}', store='{store}')")
return asyncio.run(self.list_assets_async(asset_type, limit, offset, store))
async def list_assets_async(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
) -> List[AssetUri]:
logger.debug(f"ListAssetsAsync executing for type='{asset_type}', store='{store}'")
logger.debug(
f"ListAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
try:
selected_store = self.stores[store]
except KeyError:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.list_assets(asset_type, limit, offset)
def count_assets(
self,
asset_type: Optional[str] = None,
store: str = "local",
) -> int:
logger.debug(f"CountAssets(type='{asset_type}', store='{store}')")
return asyncio.run(self.count_assets_async(asset_type, store))
async def count_assets_async(
self,
asset_type: Optional[str] = None,
store: str = "local",
) -> int:
logger.debug(f"CountAssetsAsync executing for type='{asset_type}', store='{store}'")
logger.debug(
f"CountAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
try:
selected_store = self.stores[store]
except KeyError:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.count_assets(asset_type)
def _is_registered_type(self, obj: Asset) -> bool:
"""Helper to extract asset_type, id, and data from an object instance."""
for registered_class_type in self._asset_classes.values():
if isinstance(obj, registered_class_type):
return True
return False
async def add_async(self, obj: Asset, store: str = "local") -> AssetUri:
"""
Adds an asset to the store, either creating a new one or updating an existing one.
Uses obj.get_url() to determine if the asset exists.
"""
logger.debug(f"AddAsync: Adding {type(obj).__name__} to store '{store}'")
uri = obj.get_uri()
if not self._is_registered_type(obj):
logger.warning(f"Asset has unregistered type '{uri.asset_type}' ({type(obj).__name__})")
serializer = self.get_serializer_for_class(obj.__class__)
data = obj.to_bytes(serializer)
return await self.add_raw_async(uri.asset_type, uri.asset_id, data, store)
def add(self, obj: Asset, store: str = "local") -> AssetUri:
"""Synchronous wrapper for adding an asset to the store."""
logger.debug(
f"Add: Adding {type(obj).__name__} to store '{store}' from T:{threading.current_thread().name}"
)
return asyncio.run(self.add_async(obj, store))
async def add_raw_async(
self, asset_type: str, asset_id: str, data: bytes, store: str = "local"
) -> AssetUri:
"""
Adds raw asset data to the store, either creating a new asset or updating an existing one.
"""
logger.debug(f"AddRawAsync: type='{asset_type}', id='{asset_id}', store='{store}'")
if not asset_type or not asset_id:
raise ValueError("asset_type and asset_id must be provided for add_raw.")
if not isinstance(data, bytes):
raise TypeError("Data for add_raw must be bytes.")
selected_store = self.stores.get(store)
if not selected_store:
raise ValueError(f"No store registered for name: {store}")
uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id)
try:
uri = await selected_store.update(uri, data)
logger.debug(f"AddRawAsync: Updated existing asset at {uri}")
except FileNotFoundError:
logger.debug(
f"AddRawAsync: Asset not found, creating new asset with {asset_type} and {asset_id}"
)
uri = await selected_store.create(asset_type, asset_id, data)
if store in self._cacheable_stores:
self.asset_cache.invalidate_for_uri(str(uri)) # Invalidate after add/update
return uri
def add_raw(
self, asset_type: str, asset_id: str, data: bytes, store: str = "local"
) -> AssetUri:
"""Synchronous wrapper for adding raw asset data to the store."""
logger.debug(
f"AddRaw: type='{asset_type}', id='{asset_id}', store='{store}' from T:{threading.current_thread().name}"
)
try:
return asyncio.run(self.add_raw_async(asset_type, asset_id, data, store))
except Exception as e:
logger.error(
f"AddRaw: Error for type='{asset_type}', id='{asset_id}': {e}", exc_info=False
)
raise
def add_file(
self,
asset_type: str,
path: pathlib.Path,
store: str = "local",
asset_id: str | None = None,
) -> AssetUri:
"""
Convenience wrapper around add_raw().
If asset_id is None, the path.stem is used as the id.
"""
return self.add_raw(asset_type, asset_id or path.stem, path.read_bytes(), store=store)
def delete(self, uri: Union[AssetUri, str], store: str = "local") -> None:
logger.debug(f"Delete URI '{uri}' from store '{store}'")
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
async def _do_delete_async():
selected_store = self.stores[store]
await selected_store.delete(asset_uri_obj)
if store in self._cacheable_stores:
self.asset_cache.invalidate_for_uri(str(asset_uri_obj))
asyncio.run(_do_delete_async())
async def delete_async(self, uri: Union[AssetUri, str], store: str = "local") -> None:
logger.debug(f"DeleteAsync URI '{uri}' from store '{store}'")
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
selected_store = self.stores[store]
await selected_store.delete(asset_uri_obj)
if store in self._cacheable_stores:
self.asset_cache.invalidate_for_uri(str(asset_uri_obj))
async def is_empty_async(self, asset_type: Optional[str] = None, store: str = "local") -> bool:
"""Checks if the asset store has any assets of a given type (asynchronous)."""
logger.debug(f"IsEmptyAsync: type='{asset_type}', store='{store}'")
logger.debug(
f"IsEmptyAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
selected_store = self.stores.get(store)
if not selected_store:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.is_empty(asset_type)
def is_empty(self, asset_type: Optional[str] = None, store: str = "local") -> bool:
"""Checks if the asset store has any assets of a given type (synchronous wrapper)."""
logger.debug(
f"IsEmpty: type='{asset_type}', store='{store}' from T:{threading.current_thread().name}"
)
try:
return asyncio.run(self.is_empty_async(asset_type, store))
except Exception as e:
logger.error(
f"IsEmpty: Error for type='{asset_type}', store='{store}': {e}",
exc_info=False,
) # Changed exc_info to False
raise
async def list_versions_async(
self, uri: Union[AssetUri, str], store: str = "local"
) -> List[AssetUri]:
"""Lists available versions for a given asset URI (asynchronous)."""
logger.debug(f"ListVersionsAsync: uri='{uri}', store='{store}'")
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
logger.debug(
f"ListVersionsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
selected_store = self.stores.get(store)
if not selected_store:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.list_versions(asset_uri_obj)
def list_versions(self, uri: Union[AssetUri, str], store: str = "local") -> List[AssetUri]:
"""Lists available versions for a given asset URI (synchronous wrapper)."""
logger.debug(
f"ListVersions: uri='{uri}', store='{store}' from T:{threading.current_thread().name}"
)
try:
return asyncio.run(self.list_versions_async(uri, store))
except Exception as e:
logger.error(
f"ListVersions: Error for uri='{uri}', store='{store}': {e}",
exc_info=False,
) # Changed exc_info to False
return [] # Return empty list on error to satisfy type hint
def get_registered_asset_types(self) -> List[str]:
"""Returns a list of registered asset type names."""
return list(self._asset_classes.keys())

View File

@@ -0,0 +1,104 @@
# -*- 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 abc
from abc import ABC
from typing import Mapping, List, Optional, Tuple, Type
from .uri import AssetUri
from .asset import Asset
class AssetSerializer(ABC):
for_class: Type[Asset]
extensions: Tuple[str] = tuple()
mime_type: str
can_import: bool = True
can_export: bool = True
@classmethod
@abc.abstractmethod
def get_label(cls) -> str:
pass
@classmethod
@abc.abstractmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
pass
@classmethod
@abc.abstractmethod
def serialize(cls, asset: Asset) -> bytes:
"""Serializes an asset object into bytes."""
pass
@classmethod
@abc.abstractmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> "Asset":
"""
Creates an asset object from serialized data and resolved dependencies.
If dependencies is None, it indicates a shallow load where dependencies
were not resolved.
"""
pass
@classmethod
@abc.abstractmethod
def deep_deserialize(cls, data: bytes) -> Asset:
"""
Like deserialize(), but builds dependencies itself if they are
sufficiently defined in the data.
This method is used for export/import, where some dependencies
may be embedded in the data, while others may not.
"""
pass
class DummyAssetSerializer(AssetSerializer):
"""
A serializer that does nothing. Can be used by simple assets that don't
need a non-native serialization. These type of assets can implement
extract_dependencies(), to_bytes() and from_bytes() methods that ignore
the given serializer.
"""
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
return b""
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Asset:
raise RuntimeError("DummySerializer.deserialize() was called")

View File

@@ -0,0 +1,166 @@
# -*- 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 abc
from typing import List
from ..uri import AssetUri
class AssetStore(abc.ABC):
"""
Abstract base class for storing and retrieving asset data as raw bytes.
Stores are responsible for handling the low-level interaction with a
specific storage backend (e.g., local filesystem, HTTP server) based
on the URI protocol.
"""
def __init__(self, name: str, *args, **kwargs):
self.name = name
@abc.abstractmethod
async def get(self, uri: AssetUri) -> bytes:
"""
Retrieve the raw byte data for the asset at the given URI.
Args:
uri: The unique identifier for the asset.
Returns:
The raw byte data of the asset.
Raises:
FileNotFoundError: If the asset does not exist at the URI.
# Other store-specific exceptions may be raised.
"""
raise NotImplementedError
@abc.abstractmethod
async def delete(self, uri: AssetUri) -> None:
"""
Delete the asset at the given URI.
Args:
uri: The unique identifier for the asset to delete.
Raises:
FileNotFoundError: If the asset does not exist at the URI.
# Other store-specific exceptions may be raised.
"""
raise NotImplementedError
@abc.abstractmethod
async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri:
"""
Create a new asset in the store with the given data.
The store determines the final URI for the new asset. The
`asset_type` can be used to influence the storage location
or URI structure (e.g., as part of the path).
Args:
asset_type: The type of the asset (e.g., 'material',
'toolbitshape').
asset_id: The unique identifier for the asset.
data: The raw byte data of the asset to create.
Returns:
The URI of the newly created asset.
Raises:
# Store-specific exceptions may be raised (e.g., write errors).
"""
raise NotImplementedError
@abc.abstractmethod
async def update(self, uri: AssetUri, data: bytes) -> AssetUri:
"""
Update the asset at the given URI with new data, creating a new version.
Args:
uri: The unique identifier of the asset to update.
data: The new raw byte data for the asset.
Raises:
FileNotFoundError: If the asset does not exist at the URI.
# Other store-specific exceptions may be raised (e.g., write errors).
"""
raise NotImplementedError
@abc.abstractmethod
async def list_assets(
self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None
) -> List[AssetUri]:
"""
List assets in the store, optionally filtered by asset type and
with pagination. For versioned stores, this lists the latest
version of each asset.
Args:
asset_type: Optional filter for asset type.
limit: Maximum number of assets to return.
offset: Number of assets to skip from the beginning.
Returns:
A list of URIs for the assets.
"""
raise NotImplementedError
@abc.abstractmethod
async def count_assets(self, asset_type: str | None = None) -> int:
"""
Counts assets in the store, optionally filtered by asset type.
Args:
asset_type: Optional filter for asset type.
Returns:
The number of assets.
"""
raise NotImplementedError
@abc.abstractmethod
async def list_versions(self, uri: AssetUri) -> List[AssetUri]:
"""
Lists available version identifiers for a specific asset URI.
Args:
uri: The URI of the asset (version component is ignored).
Returns:
A list of URIs pointing to the specific versions of the asset.
"""
raise NotImplementedError
@abc.abstractmethod
async def is_empty(self, asset_type: str | None = None) -> bool:
"""
Checks if the store contains any assets, optionally filtered by asset
type.
Args:
asset_type: Optional filter for asset type.
Returns:
True if the store is empty (or empty for the given asset type),
False otherwise.
"""
raise NotImplementedError

View File

@@ -0,0 +1,501 @@
# -*- 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
import pathlib
from typing import List, Dict, Tuple, Optional, cast
from ..uri import AssetUri
from .base import AssetStore
class FileStore(AssetStore):
"""
Asset store implementation for the local filesystem with optional
versioning.
Maps URIs of the form <asset_type>://<asset_id>[/<version>]
to paths within a base directory.
The mapping to file system paths is configurable depending on the asset
type. Example mapping:
mapping = {
"*": "{asset_type}/{asset_id}/{version}.dat",
"model": "models_dir/{asset_id}-{version}.ml",
"dataset": "data/{asset_id}.csv" # Unversioned (conceptual version "1")
}
Placeholders like {version} are matched greedily (.*), but for compatibility,
versions are expected to be numeric strings for versioned assets.
"""
DEFAULT_MAPPING = {
# Default from original problem doc was "{asset_type}/{asset_id}/{id}/<version>"
# Adjusted to a more common simple case:
"*": "{asset_type}/{asset_id}/{version}"
}
KNOWN_PLACEHOLDERS = {"asset_type", "asset_id", "id", "version"}
def __init__(
self,
name: str,
base_dir: pathlib.Path,
mapping: Optional[Dict[str, str]] = None,
):
super().__init__(name)
self._base_dir = base_dir.resolve()
self._mapping = mapping if mapping is not None else self.DEFAULT_MAPPING.copy()
self._validate_patterns_on_init()
# For _path_to_uri: iterate specific keys before '*' to ensure correct pattern matching
self._sorted_mapping_keys = sorted(self._mapping.keys(), key=lambda k: (k == "*", k))
def _validate_patterns_on_init(self):
if not self._mapping:
raise ValueError("Asset store mapping cannot be empty.")
for asset_type_key, path_format in self._mapping.items():
if not isinstance(path_format, str):
raise TypeError(f"Path format for key '{asset_type_key}' must be a string.")
placeholders_in_format = set(re.findall(r"\{([^}]+)\}", path_format))
for ph_name in placeholders_in_format:
if ph_name not in self.KNOWN_PLACEHOLDERS:
raise ValueError(
f"Unknown placeholder {{{ph_name}}} in pattern: '{path_format}'. Allowed: {self.KNOWN_PLACEHOLDERS}"
)
has_asset_id_ph = "asset_id" in placeholders_in_format or "id" in placeholders_in_format
if not has_asset_id_ph:
raise ValueError(
f"Pattern '{path_format}' for key '{asset_type_key}' must include {{asset_id}} or {{id}}."
)
# CORRECTED LINE: Check for the placeholder name "asset_type" not "{asset_type}"
if asset_type_key == "*" and "asset_type" not in placeholders_in_format:
raise ValueError(
f"Pattern '{path_format}' for wildcard key '*' must include {{asset_type}}."
)
@staticmethod
def _match_path_to_format_string(format_str: str, path_str_posix: str) -> Dict[str, str]:
"""Matches a POSIX-style path string against a format string."""
tokens = re.split(r"\{(.*?)\}", format_str) # format_str uses /
if len(tokens) == 1: # No placeholders
if format_str == path_str_posix:
return {}
raise ValueError(f"Path '{path_str_posix}' does not match pattern '{format_str}'")
keywords = tokens[1::2]
regex_parts = []
for i, literal_part in enumerate(tokens[0::2]):
# Literal parts from format_str (using /) are escaped.
# The path_str_posix is already normalized to /, so direct matching works.
regex_parts.append(re.escape(literal_part))
if i < len(keywords):
regex_parts.append(f"(?P<{keywords[i]}>.*)")
pattern_regex_str = "".join(regex_parts)
match_obj = re.fullmatch(pattern_regex_str, path_str_posix)
if not match_obj:
raise ValueError(
f"Path '{path_str_posix}' does not match format '{format_str}' (regex: '{pattern_regex_str}')"
)
return {kw: match_obj.group(kw) for kw in keywords}
def _get_path_format_for_uri_type(self, uri_asset_type: str) -> str:
if uri_asset_type in self._mapping:
return self._mapping[uri_asset_type]
if "*" in self._mapping:
return self._mapping["*"]
raise ValueError(
f"No mapping pattern for asset_type '{uri_asset_type}' and no '*' fallback."
)
def _path_to_uri(self, file_path: pathlib.Path) -> Optional[AssetUri]:
"""Converts a filesystem path to an AssetUri, if it matches a pattern."""
if not file_path.is_file():
return None
try:
# Convert to relative path object first, then to POSIX string for matching
relative_path_obj = file_path.relative_to(self._base_dir)
relative_path_posix = relative_path_obj.as_posix()
except ValueError:
return None # Path not under base_dir
for asset_type_key in self._sorted_mapping_keys:
path_format_str = self._mapping[asset_type_key] # Pattern uses /
try:
components = FileStore._match_path_to_format_string(
path_format_str, relative_path_posix
)
asset_id = components.get("asset_id", components.get("id"))
if not asset_id:
continue
current_asset_type: str
if "{asset_type}" in path_format_str:
current_asset_type = components.get("asset_type", "")
if not current_asset_type:
continue
else:
current_asset_type = asset_type_key
if current_asset_type == "*":
continue # Invalid state, caught by validation
version_str: str
if "{version}" in path_format_str:
version_str = components.get("version", "")
if not version_str or not version_str.isdigit():
continue
else:
version_str = "1"
return AssetUri.build(
asset_type=current_asset_type,
asset_id=asset_id,
version=version_str,
)
except ValueError: # No match
continue
return None
def set_dir(self, new_dir: pathlib.Path):
"""Sets the base directory for the store."""
self._base_dir = new_dir.resolve()
def _uri_to_path(self, uri: AssetUri) -> pathlib.Path:
"""Converts an AssetUri to a filesystem path using mapping."""
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
format_values: Dict[str, str] = {
"asset_type": uri.asset_type,
"asset_id": uri.asset_id,
"id": uri.asset_id,
}
# Only add 'version' to format_values if the pattern expects it AND uri.version is set.
# uri.version must be a string for .format() (e.g. "1", not None).
if "{version}" in path_format_str:
if uri.version is None:
# This state implies an issue: a versioned pattern is being used
# but the URI hasn't had its version appropriately set (e.g. to "1" for create,
# or resolved from "latest").
raise ValueError(
f"URI version is None for versioned pattern '{path_format_str}'. URI: {uri}"
)
format_values["version"] = uri.version
try:
# Patterns use '/', pathlib handles OS-specific path construction.
resolved_path_str = path_format_str.format(**format_values)
except KeyError as e:
raise ValueError(
f"Pattern '{path_format_str}' placeholder {{{e}}} missing in URI data for {uri}."
)
return self._base_dir / resolved_path_str
async def get(self, uri: AssetUri) -> bytes:
"""Retrieve the raw byte data for the asset at the given URI."""
path_to_read: pathlib.Path
if uri.version == "latest":
query_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
params=uri.params,
)
versions = await self.list_versions(query_uri)
if not versions:
raise FileNotFoundError(f"No versions found for {uri.asset_type}://{uri.asset_id}")
latest_version_uri = versions[-1] # list_versions now returns AssetUri with params
path_to_read = self._uri_to_path(latest_version_uri)
else:
request_uri = uri
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if not is_versioned_pattern:
if uri.version is not None and uri.version != "1":
raise FileNotFoundError(
f"Asset type '{uri.asset_type}' is unversioned. "
f"Version '{uri.version}' invalid for URI {uri}. Use '1' or no version."
)
if uri.version is None: # Conceptual "type://id" -> "type://id/1"
request_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version="1",
params=uri.params,
)
elif (
uri.version is None
): # Versioned pattern but URI has version=None (and not "latest")
raise FileNotFoundError(
f"Version required for asset type '{uri.asset_type}' (pattern: '{path_format_str}'). URI: {uri}"
)
path_to_read = self._uri_to_path(request_uri)
try:
with open(path_to_read, mode="rb") as f:
return f.read()
except FileNotFoundError:
raise FileNotFoundError(f"Asset for URI {uri} not found at path {path_to_read}")
except IsADirectoryError:
raise FileNotFoundError(f"Asset URI {uri} resolved to a directory: {path_to_read}")
async def delete(self, uri: AssetUri) -> None:
"""Delete the asset at the given URI."""
paths_to_delete: List[pathlib.Path] = []
parent_dirs_of_deleted_files = set() # To track for cleanup
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if uri.version is None: # Delete all versions or the single unversioned file
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj)
if (
parsed_uri
and parsed_uri.asset_type == uri.asset_type
and parsed_uri.asset_id == uri.asset_id
):
paths_to_delete.append(path_obj)
else: # Delete a specific version or an unversioned file (if version is "1")
target_uri_for_path = uri
if not is_versioned_pattern:
if uri.version != "1":
return # Idempotent: non-"1" version of unversioned asset "deleted"
target_uri_for_path = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version="1",
params=uri.params,
)
path = self._uri_to_path(target_uri_for_path)
if path.is_file():
paths_to_delete.append(path)
for p_del in paths_to_delete:
try:
p_del.unlink()
parent_dirs_of_deleted_files.add(p_del.parent)
except FileNotFoundError:
pass
# Clean up empty parent directories, from deepest first
sorted_parents = sorted(
list(parent_dirs_of_deleted_files),
key=lambda p: len(p.parts),
reverse=True,
)
for parent_dir in sorted_parents:
current_cleanup_path = parent_dir
while (
current_cleanup_path.exists()
and current_cleanup_path.is_dir()
and current_cleanup_path != self._base_dir
and current_cleanup_path.is_relative_to(self._base_dir)
and not any(current_cleanup_path.iterdir())
):
try:
current_cleanup_path.rmdir()
current_cleanup_path = current_cleanup_path.parent
except OSError:
break
async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri:
"""Create a new asset in the store with the given data."""
# New assets are conceptually version "1"
uri_to_create = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="1")
asset_path = self._uri_to_path(uri_to_create)
if asset_path.exists():
# More specific error messages based on what exists
if asset_path.is_file():
raise FileExistsError(f"Asset file already exists at {asset_path}")
if asset_path.is_dir():
raise IsADirectoryError(f"A directory exists at target path {asset_path}")
raise FileExistsError(f"Path {asset_path} already exists (unknown type).")
asset_path.parent.mkdir(parents=True, exist_ok=True)
with open(asset_path, mode="wb") as f:
f.write(data)
return uri_to_create
async def update(self, uri: AssetUri, data: bytes) -> AssetUri:
"""Update the asset at the given URI with new data, creating a new version."""
# Get a Uri without the version number, use it to find all versions.
query_uri = AssetUri.build(
asset_type=uri.asset_type, asset_id=uri.asset_id, params=uri.params
)
existing_versions = await self.list_versions(query_uri)
if not existing_versions:
raise FileNotFoundError(
f"No versions for asset {uri.asset_type}://{uri.asset_id} to update."
)
# Create a Uri for the NEXT version number.
latest_version_uri = existing_versions[-1]
latest_version_num = int(cast(str, latest_version_uri.version))
next_version_str = str(latest_version_num + 1)
next_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version=next_version_str,
params=uri.params,
)
asset_path = self._uri_to_path(next_uri)
# If the file is versioned, then the new version should not yet exist.
# Double check to be sure.
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if asset_path.exists() and is_versioned_pattern:
raise FileExistsError(f"Asset path for new version {asset_path} already exists.")
# Done. Write to disk.
asset_path.parent.mkdir(parents=True, exist_ok=True)
with open(asset_path, mode="wb") as f:
f.write(data)
return next_uri
async def list_assets(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> List[AssetUri]:
"""
List assets in the store, optionally filtered by asset type and
with pagination. For versioned stores, this lists the latest
version of each asset.
"""
latest_asset_versions: Dict[Tuple[str, str], str] = {}
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj)
if parsed_uri:
if asset_type is not None and parsed_uri.asset_type != asset_type:
continue
key = (parsed_uri.asset_type, parsed_uri.asset_id)
current_version_str = cast(str, parsed_uri.version) # Is "1" or numeric string
if key not in latest_asset_versions or int(current_version_str) > int(
latest_asset_versions[key]
):
latest_asset_versions[key] = current_version_str
result_uris: List[AssetUri] = [
AssetUri.build(
asset_type=atype, asset_id=aid, version=vstr
) # Params not included in list_assets results
for (atype, aid), vstr in latest_asset_versions.items()
]
result_uris.sort(key=lambda u: (u.asset_type, u.asset_id, int(cast(str, u.version))))
start = offset if offset is not None else 0
end = start + limit if limit is not None else len(result_uris)
return result_uris[start:end]
async def count_assets(self, asset_type: Optional[str] = None) -> int:
"""
Counts assets in the store, optionally filtered by asset type.
"""
unique_assets: set[Tuple[str, str]] = set()
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj)
if parsed_uri:
if asset_type is not None and parsed_uri.asset_type != asset_type:
continue
unique_assets.add((parsed_uri.asset_type, parsed_uri.asset_id))
return len(unique_assets)
async def list_versions(self, uri: AssetUri) -> List[AssetUri]:
"""
Lists available version identifiers for a specific asset URI.
Args:
uri: The URI of the asset (version component is ignored, params preserved).
Returns:
A list of AssetUri objects, sorted by version in ascending order.
"""
if uri.asset_id is None:
raise ValueError(f"Asset ID must be specified for listing versions: {uri}")
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if not is_versioned_pattern:
# Check existence of the single unversioned file
# Conceptual version is "1", params from input URI are preserved
path_check_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version="1",
params=uri.params,
)
path_to_asset = self._uri_to_path(path_check_uri)
if path_to_asset.is_file():
return [path_check_uri] # Returns URI with version "1" and original params
return []
found_versions_strs: List[str] = []
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj) # This parsed_uri does not have params
if (
parsed_uri
and parsed_uri.asset_type == uri.asset_type
and parsed_uri.asset_id == uri.asset_id
):
# Version from path is guaranteed numeric string by _path_to_uri for versioned patterns
found_versions_strs.append(cast(str, parsed_uri.version))
if not found_versions_strs:
return []
sorted_unique_versions = sorted(list(set(found_versions_strs)), key=int)
return [
AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version=v_str,
params=uri.params,
)
for v_str in sorted_unique_versions
]
async def is_empty(self, asset_type: Optional[str] = None) -> bool:
"""
Checks if the store contains any assets, optionally filtered by asset
type.
"""
# Reuses list_assets which iterates files.
# Limit=1 makes it stop after finding the first asset.
assets = await self.list_assets(asset_type=asset_type, limit=1)
return not bool(assets)

View File

@@ -0,0 +1,212 @@
# -*- 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 pprint
from typing import Dict, List, Optional
from ..uri import AssetUri
from .base import AssetStore
class MemoryStore(AssetStore):
"""
An in-memory implementation of the AssetStore.
This store keeps all asset data in memory and is primarily intended for
testing and demonstration purposes. It does not provide persistence.
"""
def __init__(self, name: str, *args, **kwargs):
super().__init__(name, *args, **kwargs)
self._data: Dict[str, Dict[str, Dict[str, bytes]]] = {}
self._versions: Dict[str, Dict[str, List[str]]] = {}
async def get(self, uri: AssetUri) -> bytes:
asset_type = uri.asset_type
asset_id = uri.asset_id
version = uri.version or self._get_latest_version(asset_type, asset_id)
if (
asset_type not in self._data
or asset_id not in self._data[asset_type]
or version not in self._data[asset_type][asset_id]
):
raise FileNotFoundError(f"Asset not found: {uri}")
return self._data[asset_type][asset_id][version]
async def delete(self, uri: AssetUri) -> None:
asset_type = uri.asset_type
asset_id = uri.asset_id
version = uri.version # Capture the version from the URI
if asset_type not in self._data or asset_id not in self._data[asset_type]:
# Deleting non-existent asset should not raise an error
return
if version:
# If a version is specified, try to delete only that version
if version in self._data[asset_type][asset_id]:
del self._data[asset_type][asset_id][version]
# Remove version from the versions list
if (
asset_type in self._versions
and asset_id in self._versions[asset_type]
and version in self._versions[asset_type][asset_id]
):
self._versions[asset_type][asset_id].remove(version)
# If no versions left for this asset_id, clean up
if not self._data[asset_type][asset_id]:
del self._data[asset_type][asset_id]
if asset_type in self._versions and asset_id in self._versions[asset_type]:
del self._versions[asset_type][asset_id]
else:
# If no version is specified, delete the entire asset
del self._data[asset_type][asset_id]
if asset_type in self._versions and asset_id in self._versions[asset_type]:
del self._versions[asset_type][asset_id]
# Clean up empty asset types
if asset_type in self._data and not self._data[asset_type]:
del self._data[asset_type]
if asset_type in self._versions and not self._versions[asset_type]:
del self._versions[asset_type]
async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri:
if asset_type not in self._data:
self._data[asset_type] = {}
self._versions[asset_type] = {}
if asset_id in self._data[asset_type]:
# For simplicity, create overwrites existing in this memory store
# A real store might handle this differently or raise an error
pass
if asset_id not in self._data[asset_type]:
self._data[asset_type][asset_id] = {}
self._versions[asset_type][asset_id] = []
version = "1"
self._data[asset_type][asset_id][version] = data
self._versions[asset_type][asset_id].append(version)
return AssetUri(f"{asset_type}://{asset_id}/{version}")
async def update(self, uri: AssetUri, data: bytes) -> AssetUri:
asset_type = uri.asset_type
asset_id = uri.asset_id
if asset_type not in self._data or asset_id not in self._data[asset_type]:
raise FileNotFoundError(f"Asset not found for update: {uri}")
# Update should create a new version
latest_version = self._get_latest_version(asset_type, asset_id)
version = str(int(latest_version or 0) + 1)
self._data[asset_type][asset_id][version] = data
self._versions[asset_type][asset_id].append(version)
return AssetUri(f"{asset_type}://{asset_id}/{version}")
async def list_assets(
self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None
) -> List[AssetUri]:
all_uris: List[AssetUri] = []
for current_type, assets in self._data.items():
if asset_type is None or current_type == asset_type:
for asset_id in assets:
latest_version = self._get_latest_version(current_type, asset_id)
if latest_version:
all_uris.append(AssetUri(f"{current_type}://{asset_id}/{latest_version}"))
# Apply offset and limit
start = offset if offset is not None else 0
end = start + limit if limit is not None else len(all_uris)
return all_uris[start:end]
async def count_assets(self, asset_type: str | None = None) -> int:
"""
Counts assets in the store, optionally filtered by asset type.
"""
if asset_type is None:
count = 0
for assets_by_id in self._data.values():
count += len(assets_by_id)
return count
else:
if asset_type in self._data:
return len(self._data[asset_type])
return 0
async def list_versions(self, uri: AssetUri) -> List[AssetUri]:
asset_type = uri.asset_type
asset_id = uri.asset_id
if asset_type not in self._versions or asset_id not in self._versions[asset_type]:
return []
version_uris: List[AssetUri] = []
for version in self._versions[asset_type][asset_id]:
version_uris.append(AssetUri(f"{asset_type}://{asset_id}/{version}"))
return version_uris
async def is_empty(self, asset_type: str | None = None) -> bool:
if asset_type is None:
return not bool(self._data)
else:
return asset_type not in self._data or not bool(self._data[asset_type])
def _get_latest_version(self, asset_type: str, asset_id: str) -> Optional[str]:
if (
asset_type in self._versions
and asset_id in self._versions[asset_type]
and self._versions[asset_type][asset_id]
):
return self._versions[asset_type][asset_id][-1]
return None
def dump(self, print: bool = False) -> Dict[str, Dict[str, Dict[str, bytes]]] | None:
"""
Dumps the entire content of the memory store.
Args:
print (bool): If True, pretty-prints the data to the console,
excluding the asset data itself.
Returns:
Dict[str, Dict[str, Dict[str, bytes]]] | None: The stored data as a
dictionary, or None if print is True.
"""
if not print:
return self._data
printable_data = {}
for asset_type, assets in self._data.items():
printable_data[asset_type] = {}
for asset_id, versions in assets.items():
printable_data[asset_type][asset_id] = {}
for version, data_bytes in versions.items():
printable_data[asset_type][asset_id][
version
] = f"<data skipped, {len(data_bytes)} bytes>"
pprint.pprint(printable_data, indent=4)
return self._data

View File

@@ -0,0 +1,6 @@
from .filedialog import AssetOpenDialog, AssetSaveDialog
__all__ = [
"AssetOpenDialog",
"AssetSaveDialog",
]

View File

@@ -0,0 +1,158 @@
# -*- 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 pathlib
import FreeCAD
import Path
from typing import Optional, Tuple, Type, Iterable
from PySide.QtWidgets import QFileDialog, QMessageBox
from ..serializer import AssetSerializer, Asset
from .util import (
make_import_filters,
make_export_filters,
get_serializer_from_extension,
)
class AssetOpenDialog(QFileDialog):
def __init__(
self,
asset_type: Type[Asset],
serializers: Iterable[Type[AssetSerializer]],
parent=None,
):
super().__init__(parent)
self.asset_type = asset_type
self.serializers = list(serializers)
self.setWindowTitle("Open an asset")
self.setFileMode(QFileDialog.ExistingFile)
filters = make_import_filters(self.serializers)
self.setNameFilters(filters)
if filters:
self.selectNameFilter(filters[0]) # Default to "All supported files"
def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]:
"""Deserialize the selected file using the appropriate serializer."""
file_extension = file_path.suffix.lower()
serializer_class = get_serializer_from_extension(
self.serializers, file_extension, for_import=True
)
if not serializer_class:
QMessageBox.critical(
self,
"Error",
f"No supported serializer found for file extension '{file_extension}'",
)
return None
try:
raw_data = file_path.read_bytes()
asset = serializer_class.deep_deserialize(raw_data)
if not isinstance(asset, self.asset_type):
raise TypeError(f"Deserialized asset is not of type {self.asset_type.asset_type}")
return asset
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import asset: {e}")
return None
def exec(self) -> Optional[Tuple[pathlib.Path, Asset]]:
if super().exec_():
filenames = self.selectedFiles()
if filenames:
file_path = pathlib.Path(filenames[0])
asset = self._deserialize_selected_file(file_path)
if asset:
return file_path, asset
return None
class AssetSaveDialog(QFileDialog):
def __init__(
self,
asset_type: Type[Asset],
serializers: Iterable[Type[AssetSerializer]],
parent=None,
):
super().__init__(parent)
self.asset_type = asset_type
self.serializers = list(serializers)
self.setFileMode(QFileDialog.AnyFile)
self.setAcceptMode(QFileDialog.AcceptSave)
self.filters, self.serializer_map = make_export_filters(self.serializers)
self.setNameFilters(self.filters)
if self.filters:
self.selectNameFilter(self.filters[0]) # Default to "Automatic"
self.filterSelected.connect(self.update_default_suffix)
def update_default_suffix(self, filter_str: str):
"""Update the default suffix based on the selected filter."""
if filter_str == "Automatic (*)":
self.setDefaultSuffix("") # No default for Automatic
else:
serializer = self.serializer_map.get(filter_str)
if serializer and serializer.extensions:
self.setDefaultSuffix(serializer.extensions[0])
def _serialize_selected_file(
self,
file_path: pathlib.Path,
asset: Asset,
serializer_class: Type[AssetSerializer],
) -> bool:
"""Serialize and save the asset."""
try:
raw_data = serializer_class.serialize(asset)
file_path.write_bytes(raw_data)
return True
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to export asset: {e}")
return False
def exec(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]:
self.setWindowTitle(f"Save {asset.label}")
if super().exec_():
selected_filter = self.selectedNameFilter()
file_path = pathlib.Path(self.selectedFiles()[0])
if selected_filter == "Automatic (*)":
if not file_path.suffix:
QMessageBox.critical(
self,
"Error",
"Please specify a file extension for automatic serializer selection.",
)
return None
file_extension = file_path.suffix.lower()
serializer_class = get_serializer_from_extension(
self.serializers, file_extension, for_import=False
)
if not serializer_class:
QMessageBox.critical(
self,
"Error",
f"No serializer found for extension '{file_extension}'",
)
return None
else:
serializer_class = self.serializer_map.get(selected_filter)
if not serializer_class:
raise ValueError(f"No serializer found for filter '{selected_filter}'")
if self._serialize_selected_file(file_path, asset, serializer_class):
return file_path, serializer_class
return None

View File

@@ -0,0 +1,128 @@
# -*- 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 pathlib
import tempfile
import FreeCAD
import Path
from PySide import QtGui, QtCore
translate = FreeCAD.Qt.translate
def _is_writable_dir(path: pathlib.Path) -> bool:
"""
Check if a path is a writable directory.
Returns True if writable, False otherwise.
"""
if not path.is_dir():
return False
try:
with tempfile.NamedTemporaryFile(dir=str(path), delete=True):
return True
except (OSError, PermissionError):
return False
class AssetPreferencesPage:
def __init__(self, parent=None):
self.form = QtGui.QToolBox()
self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets"))
asset_path_widget = QtGui.QWidget()
main_layout = QtGui.QHBoxLayout(asset_path_widget)
# Create widgets
self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:"))
self.asset_path_edit = QtGui.QLineEdit()
self.asset_path_note_label = QtGui.QLabel(
translate(
"CAM_PreferencesAssets",
"Note: Select the directory that will contain the "
"Bit/, Shape/, and Library/ subfolders.",
)
)
self.asset_path_note_label.setWordWrap(True)
self.select_path_button = QtGui.QToolButton()
self.select_path_button.setIcon(QtGui.QIcon.fromTheme("folder-open"))
self.select_path_button.clicked.connect(self.selectAssetPath)
self.reset_path_button = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Reset"))
self.reset_path_button.clicked.connect(self.resetAssetPath)
# Set note label font to italic
font = self.asset_path_note_label.font()
font.setItalic(True)
self.asset_path_note_label.setFont(font)
# Layout for asset path section
edit_button_layout = QtGui.QGridLayout()
edit_button_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop)
edit_button_layout.addItem(
QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding),
2,
0,
1,
4,
)
main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop)
self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets"))
def selectAssetPath(self):
# Implement directory selection dialog
path = QtGui.QFileDialog.getExistingDirectory(
self.form,
translate("CAM_PreferencesAssets", "Select Asset Directory"),
self.asset_path_edit.text(),
)
if path:
self.asset_path_edit.setText(str(path))
def resetAssetPath(self):
# Implement resetting path to default
default_path = Path.Preferences.getDefaultAssetPath()
self.asset_path_edit.setText(str(default_path))
def saveSettings(self):
# Check path is writable, then call Path.Preferences.setAssetPath()
asset_path = pathlib.Path(self.asset_path_edit.text())
param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Path")
if param.GetBool("CheckAssetPathWritable", True):
if not _is_writable_dir(asset_path):
QtGui.QMessageBox.warning(
self.form,
translate("CAM_PreferencesAssets", "Warning"),
translate("CAM_PreferencesAssets", "The selected asset path is not writable."),
)
return False
Path.Preferences.setAssetPath(asset_path)
return True
def loadSettings(self):
# use getAssetPath() to initialize UI
asset_path = Path.Preferences.getAssetPath()
self.asset_path_edit.setText(str(asset_path))

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from typing import List, Dict, Optional, Iterable, Type
from ..serializer import AssetSerializer
def make_import_filters(serializers: Iterable[Type[AssetSerializer]]) -> List[str]:
"""
Generates file dialog filters for importing assets.
Args:
serializers: A list of AssetSerializer classes.
Returns:
A list of filter strings, starting with "All supported files".
"""
all_extensions = []
filters = []
for serializer_class in serializers:
if not serializer_class.can_import or not serializer_class.extensions:
continue
all_extensions.extend(serializer_class.extensions)
label = serializer_class.get_label()
extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions])
filters.append(f"{label} ({extensions})")
# Add "All supported files" filter if there are any extensions
if all_extensions:
combined_extensions = " ".join([f"*{ext}" for ext in sorted(list(set(all_extensions)))])
filters.insert(0, f"All supported files ({combined_extensions})")
return filters
def make_export_filters(
serializers: Iterable[Type[AssetSerializer]],
) -> tuple[List[str], Dict[str, Type[AssetSerializer]]]:
"""
Generates file dialog filters for exporting assets and a serializer map.
Args:
serializers: A list of AssetSerializer classes.
Returns:
A tuple of (filters, serializer_map) where filters is a list of filter strings
starting with "Automatic", and serializer_map maps filter strings to serializers.
"""
filters = ["Automatic (*)"]
serializer_map = {}
for serializer_class in serializers:
if not serializer_class.can_export or not serializer_class.extensions:
continue
label = serializer_class.get_label()
extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions])
filter_str = f"{label} ({extensions})"
filters.append(filter_str)
serializer_map[filter_str] = serializer_class
return filters, serializer_map
def get_serializer_from_extension(
serializers: Iterable[Type[AssetSerializer]],
file_extension: str,
for_import: bool | None = None,
) -> Optional[Type[AssetSerializer]]:
"""
Finds a serializer class based on the file extension and import/export capability.
Args:
serializers: A list of AssetSerializer classes.
file_extension: The file extension (without the leading dot).
for_import: None = both, True = import, False = export
Returns:
The matching AssetSerializer class, or None if not found.
"""
for_export = for_import is not True
for_import = for_import is True
for ser in serializers:
if for_import and not ser.can_import:
continue
if for_export and not ser.can_export:
continue
if file_extension in ser.extensions:
return ser
return None

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from __future__ import annotations
import urllib.parse
from typing import Dict, Any, Mapping
class AssetUri:
"""
Represents an asset URI with components.
The URI structure is: <asset_type>://<asset_id>[/version]
"""
def __init__(self, uri_string: str):
# Manually parse the URI string
parts = uri_string.split("://", 1)
if len(parts) != 2:
raise ValueError(f"Invalid URI structure: {uri_string}")
self.asset_type = parts[0]
rest = parts[1]
# Split asset_id, version, and params
path_and_query = rest.split("?", 1)
path_parts = path_and_query[0].split("/")
if not path_parts or not path_parts[0]:
raise ValueError(f"Invalid URI structure: {uri_string}")
self.asset_id = path_parts[0]
self.version = path_parts[1] if len(path_parts) > 1 else None
if len(path_parts) > 2:
raise ValueError(f"Invalid URI path structure: {uri_string}")
self.params: Dict[str, list[str]] = {}
if len(path_and_query) > 1:
self.params = urllib.parse.parse_qs(path_and_query[1])
if not self.asset_type or not self.asset_id:
raise ValueError(f"Invalid URI structure: {uri_string}")
def __str__(self) -> str:
path = f"/{self.version}" if self.version else ""
query = urllib.parse.urlencode(self.params, doseq=True) if self.params else ""
uri_string = urllib.parse.urlunparse((self.asset_type, self.asset_id, path, "", query, ""))
return uri_string
def __repr__(self) -> str:
return f"AssetUri('{str(self)}')"
def __eq__(self, other: Any) -> bool:
if not isinstance(other, AssetUri):
return NotImplemented
return (
self.asset_type == other.asset_type
and self.asset_id == other.asset_id
and self.version == other.version
and self.params == other.params
)
def __hash__(self) -> int:
"""Returns a hash value for the AssetUri."""
return hash((self.asset_type, self.asset_id, self.version, frozenset(self.params.items())))
@classmethod
def is_uri(cls, uri: AssetUri | str) -> bool:
"""Checks if the given string is a valid URI."""
if isinstance(uri, AssetUri):
return True
try:
AssetUri(uri)
except ValueError:
return False
return True
@staticmethod
def build(
asset_type: str,
asset_id: str,
version: str | None = None,
params: Mapping[str, str | list[str]] | None = None,
) -> AssetUri:
"""Builds a Uri object from components."""
uri = AssetUri.__new__(AssetUri) # Create a new instance without calling __init__
uri.asset_type = asset_type
uri.asset_id = asset_id
uri.version = version
uri.params = {}
if params:
for key, value in params.items():
if isinstance(value, list):
uri.params[key] = value
else:
uri.params[key] = [value]
return uri

View File

@@ -0,0 +1,111 @@
# -*- 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 Path
from Path import Preferences
from Path.Preferences import addToolPreferenceObserver
from .assets import AssetManager, FileStore
def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in library
if it is currently empty.
"""
builtin_library_path = Preferences.getBuiltinLibraryPath()
if asset_manager.is_empty("toolbitlibrary", store=store_name):
for path in builtin_library_path.glob("*.fctl"):
asset_manager.add_file("toolbitlibrary", path)
def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in bits
if it is currently empty.
"""
builtin_toolbit_path = Preferences.getBuiltinToolBitPath()
if asset_manager.is_empty("toolbit", store=store_name):
for path in builtin_toolbit_path.glob("*.fctb"):
asset_manager.add_file("toolbit", path)
def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in shapes
if it is currently empty.
"""
builtin_shape_path = Preferences.getBuiltinShapePath()
if asset_manager.is_empty("toolbitshape", store=store_name):
for path in builtin_shape_path.glob("*.fcstd"):
asset_manager.add_file("toolbitshape", path)
if asset_manager.is_empty("toolbitshapesvg", store=store_name):
for path in builtin_shape_path.glob("*.svg"):
asset_manager.add_file("toolbitshapesvg", path, asset_id=path.stem + ".svg")
if asset_manager.is_empty("toolbitshapepng", store=store_name):
for path in builtin_shape_path.glob("*.png"):
asset_manager.add_file("toolbitshapepng", path, asset_id=path.stem + ".png")
def ensure_assets_initialized(asset_manager: AssetManager, store="local"):
"""
Ensures the given store is initialized with built-in assets.
"""
ensure_library_assets_initialized(asset_manager, store)
ensure_toolbit_assets_initialized(asset_manager, store)
ensure_toolbitshape_assets_initialized(asset_manager, store)
def _on_asset_path_changed(group, key, value):
Path.Log.info(f"CAM asset directory changed in preferences: {group} {key} {value}")
cam_asset_store.set_dir(Preferences.getAssetPath())
ensure_assets_initialized(cam_assets)
# Set up the local CAM asset storage.
cam_asset_store = FileStore(
name="local",
base_dir=Preferences.getAssetPath(),
mapping={
"toolbitlibrary": "Library/{asset_id}.fctl",
"toolbit": "Bit/{asset_id}.fctb",
"toolbitshape": "Shape/{asset_id}.fcstd",
"toolbitshapesvg": "Shape/{asset_id}", # Asset ID has ".svg" included
"toolbitshapepng": "Shape/{asset_id}", # Asset ID has ".png" included
"machine": "Machine/{asset_id}.fcm",
},
)
# Set up the CAM asset manager.
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
cam_assets = AssetManager()
cam_assets.register_store(cam_asset_store)
try:
ensure_assets_initialized(cam_assets)
except Exception as e:
Path.Log.error(f"Failed to initialize CAM assets in {cam_asset_store._base_dir}: {e}")
else:
Path.Log.debug(f"Using CAM assets in {cam_asset_store._base_dir}")
addToolPreferenceObserver(_on_asset_path_changed)

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from .models.library import Library
__all__ = [
"Library",
]

View File

@@ -0,0 +1,182 @@
# -*- 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 uuid
import pathlib
from typing import Mapping, Union, Optional, List, Dict, cast
import Path
from ...assets import Asset, AssetUri
from ...toolbit import ToolBit
class Library(Asset):
asset_type: str = "toolbitlibrary"
API_VERSION = 1
def __init__(self, label, id=None):
self.id = id if id is not None else str(uuid.uuid4())
self._label = label
self._bits: List[ToolBit] = []
self._bit_nos: Dict[int, ToolBit] = {}
self._bit_urls: Dict[AssetUri, ToolBit] = {}
@property
def label(self) -> str:
return self._label
def get_id(self) -> str:
"""Returns the unique identifier for the Library instance."""
return self.id
@classmethod
def resolve_name(cls, identifier: Union[str, AssetUri, pathlib.Path]) -> AssetUri:
"""
Resolves various forms of library identifiers to a canonical AssetUri string.
Handles direct AssetUri objects, URI strings, asset IDs, or legacy filenames.
Returns the canonical URI string or None if resolution fails.
"""
if isinstance(identifier, AssetUri):
return identifier
if isinstance(identifier, str) and AssetUri.is_uri(identifier):
return AssetUri(identifier)
if isinstance(identifier, pathlib.Path): # Handle direct Path objects (legacy filenames)
identifier = identifier.stem # Use the filename stem as potential ID
if not isinstance(identifier, str):
raise ValueError("Failed to resolve {identifier} to a Uri")
return AssetUri.build(asset_type=Library.asset_type, asset_id=identifier)
def to_dict(self) -> dict:
"""Returns a dictionary representation of the Library in the specified format."""
tools_list = []
for tool_no, tool in self._bit_nos.items():
tools_list.append(
{"nr": tool_no, "path": f"{tool.get_id()}.fctb"} # Tool ID with .fctb extension
)
return {"label": self.label, "tools": tools_list, "version": self.API_VERSION}
@classmethod
def from_dict(
cls,
data_dict: dict,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> "Library":
"""
Creates a Library instance from a dictionary and resolved dependencies.
If dependencies is None, it's a shallow load, and tools are not populated.
"""
library = cls(data_dict.get("label", id or "Unnamed Library"), id=id)
if dependencies is None:
Path.Log.debug(
f"Library.from_dict: Shallow load for library '{library.label}' (id: {id}). Tools not populated."
)
return library # Only process tools if dependencies were resolved
tools_list = data_dict.get("tools", [])
for tool_data in tools_list:
tool_no = tool_data["nr"]
tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID
tool_uri = AssetUri(f"toolbit://{tool_id}")
bit = cast(ToolBit, dependencies.get(tool_uri))
if bit:
library.add_bit(bit, bit_no=tool_no)
else:
raise ValueError(f"Tool with id {tool_id} not found in dependencies")
return library
def __str__(self):
return '{} "{}"'.format(self.id, self.label)
def __eq__(self, other):
return self.id == other.id
def __iter__(self):
return self._bits.__iter__()
def get_next_bit_no(self):
bit_nolist = sorted(self._bit_nos, reverse=True)
return bit_nolist[0] + 1 if bit_nolist else 1
def get_bit_no_from_bit(self, bit: ToolBit) -> Optional[int]:
for bit_no, thebit in self._bit_nos.items():
if bit == thebit:
return bit_no
return None
def get_tool_by_uri(self, uri: AssetUri) -> Optional[ToolBit]:
for tool in self._bit_nos.values():
if tool.get_uri() == uri:
return tool
return None
def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]:
if bit not in self._bits:
return
# 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
# 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
# add the removed bit into a new bit_no.
old_bit = self._bit_nos.pop(bit_no, None)
old_bit_no = self.get_bit_no_from_bit(bit)
if old_bit_no:
del self._bit_nos[old_bit_no]
self._bit_nos[bit_no] = bit
if old_bit:
self.assign_new_bit_no(old_bit)
return bit_no
def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]:
if bit not in self._bits:
self._bits.append(bit)
return self.assign_new_bit_no(bit, bit_no)
def get_bits(self) -> List[ToolBit]:
return self._bits
def has_bit(self, bit: ToolBit) -> bool:
for t in self._bits:
if bit.id == t.id:
return True
return False
def remove_bit(self, bit: ToolBit):
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 dump(self, summarize: bool = False):
title = 'Library "{}" ({}) (instance {})'.format(self.label, self.id, id(self))
print("-" * len(title))
print(title)
print("-" * len(title))
for bit in self._bits:
print(f"- {bit.label} ({bit.get_id()})")
print()

View File

@@ -0,0 +1,13 @@
from .camotics import CamoticsLibrarySerializer
from .fctl import FCTLSerializer
from .linuxcnc import LinuxCNCSerializer
all_serializers = CamoticsLibrarySerializer, FCTLSerializer, LinuxCNCSerializer
__all__ = [
"CamoticsLibrarySerializer",
"FCTLSerializer",
"LinuxCNCSerializer",
]

View File

@@ -0,0 +1,171 @@
# -*- 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 uuid
import json
from typing import Mapping, List, Optional, Type
import FreeCAD
from ...assets import Asset, AssetUri, AssetSerializer
from ...toolbit import ToolBit
from ...toolbit.mixins import RotaryToolBitMixin
from ...shape import ToolBitShape, ToolBitShapeEndmill
from ..models.library import Library
SHAPEMAP = {
"ballend": "Ballnose",
"endmill": "Cylindrical",
"v-bit": "Conical",
"chamfer": "Snubnose",
}
SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items())
tooltemplate = {
"units": "metric",
"shape": "Cylindrical",
"length": 10,
"diameter": 3.125,
"description": "",
}
class CamoticsLibrarySerializer(AssetSerializer):
for_class: Type[Asset] = Library
extensions: tuple[str] = (".ctbl",)
mime_type: str = "application/json"
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "Camotics Tool Library")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
if not isinstance(asset, Library):
raise TypeError("Asset must be a Library instance")
toollist = {}
for tool_no, tool in asset._bit_nos.items():
assert isinstance(tool, RotaryToolBitMixin)
toolitem = tooltemplate.copy()
diameter_value = tool.get_diameter()
# Ensure diameter is a float, handle Quantity and other types
diameter_serializable = 2.0 # Default value as float
if isinstance(diameter_value, FreeCAD.Units.Quantity):
try:
val_mm = diameter_value.getValueAs("mm")
if val_mm is not None:
diameter_serializable = float(val_mm)
except ValueError:
# Fallback to raw value if unit conversion fails
raw_val = diameter_value.Value if hasattr(diameter_value, "Value") else None
if isinstance(raw_val, (int, float)):
diameter_serializable = float(raw_val)
elif isinstance(diameter_value, (int, float)):
diameter_serializable = float(diameter_value) if diameter_value is not None else 2.0
toolitem["diameter"] = diameter_serializable
toolitem["description"] = tool.label
length_value = tool.get_length()
# Ensure length is a float, handle Quantity and other types
length_serializable = 10.0 # Default value as float
if isinstance(length_value, FreeCAD.Units.Quantity):
try:
val_mm = length_value.getValueAs("mm")
if val_mm is not None:
length_serializable = float(val_mm)
except ValueError:
# Fallback to raw value if unit conversion fails
raw_val = length_value.Value if hasattr(length_value, "Value") else None
if isinstance(raw_val, (int, float)):
length_serializable = float(raw_val)
elif isinstance(length_value, (int, float)):
length_serializable = float(length_value) if length_value is not None else 10.0
toolitem["length"] = length_serializable
toolitem["shape"] = SHAPEMAP.get(tool._tool_bit_shape.name, "Cylindrical")
toollist[str(tool_no)] = toolitem
return json.dumps(toollist, indent=2).encode("utf-8")
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Library:
try:
data_dict = json.loads(data.decode("utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"Failed to decode JSON data: {e}") from e
library = Library(id, id=id)
for tool_no_str, toolitem in data_dict.items():
try:
tool_no = int(tool_no_str)
except ValueError:
print(f"Warning: Skipping invalid tool number: {tool_no_str}")
continue
# Find the shape class to use
shape_name_str = SHAPEMAP_REVERSE.get(toolitem.get("shape", "Cylindrical"), "endmill")
shape_class = ToolBitShape.get_subclass_by_name(shape_name_str)
if not shape_class:
print(f"Warning: Unknown shape name '{shape_name_str}', defaulting to endmill")
shape_class = ToolBitShapeEndmill
# Translate parameters to FreeCAD types
params = {}
try:
diameter = float(toolitem.get("diameter", 2))
params["Diameter"] = FreeCAD.Units.Quantity(f"{diameter} mm")
except (ValueError, TypeError):
print(f"Warning: Invalid diameter for tool {tool_no_str}, skipping.")
try:
length = float(toolitem.get("length", 10))
params["Length"] = FreeCAD.Units.Quantity(f"{length} mm")
except (ValueError, TypeError):
print(f"Warning: Invalid length for tool {tool_no_str}, skipping.")
# Create the shape
shape_id = shape_name_str.lower()
tool_bit_shape = shape_class(shape_id, **params)
# Create the toolbit
tool = ToolBit(tool_bit_shape, id=f"camotics_tool_{tool_no_str}")
tool.label = toolitem.get("description", "")
library.add_bit(tool, tool_no)
return library
@classmethod
def deep_deserialize(cls, data: bytes) -> Library:
# TODO: Build tools here
return cls.deserialize(data, str(uuid.uuid4()), {})

View File

@@ -0,0 +1,107 @@
# -*- 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 uuid
import json
from typing import Mapping, List, Optional
import pathlib
import FreeCAD
import Path
from ...assets import Asset, AssetUri, AssetSerializer
from ...toolbit import ToolBit
from ..models.library import Library
class FCTLSerializer(AssetSerializer):
for_class = Library
extensions = (".fctl",)
mime_type = "application/x-freecad-toolbit-library"
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
data_dict = json.loads(data.decode("utf-8"))
tools_list = data_dict.get("tools", [])
tool_ids = [pathlib.Path(tool["path"]).stem for tool in tools_list]
return [AssetUri(f"toolbit://{tool_id}") for tool_id in tool_ids]
@classmethod
def serialize(cls, asset: Asset) -> bytes:
"""Serializes a Library object into bytes."""
if not isinstance(asset, Library):
raise TypeError(f"Expected Library instance, got {type(asset).__name__}")
attrs = asset.to_dict()
return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8")
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Library:
"""
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
# instance, overriding any 'id' that might be in the data_dict (which
# is from an older version of the format).
library = Library(data_dict.get("label", id or "Unnamed Library"), id=id)
if dependencies is None:
Path.Log.debug(
f"FCTLSerializer.deserialize: Shallow load for library '{library.label}' (id: {id}). Tools not populated."
)
return library # Only process tools if dependencies were resolved
tools_list = data_dict.get("tools", [])
for tool_data in tools_list:
tool_no = tool_data["nr"]
tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID
tool_uri = AssetUri(f"toolbit://{tool_id}")
tool = dependencies.get(tool_uri)
if tool:
# Ensure the dependency is a ToolBit instance
if not isinstance(tool, ToolBit):
Path.Log.warning(
f"Dependency for tool '{tool_id}' is not a ToolBit instance. Skipping."
)
continue
library.add_bit(tool, bit_no=tool_no)
else:
# This should not happen if dependencies were resolved correctly,
# but as a safeguard, log a warning and skip the tool.
Path.Log.warning(
f"Tool with id {tool_id} not found in dependencies during deserialization."
)
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()), {})

View File

@@ -0,0 +1,78 @@
# -*- 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 io
from typing import Mapping, List, Optional, Type
import FreeCAD
import Path
from ...assets import Asset, AssetUri, AssetSerializer
from ...toolbit import ToolBit
from ...toolbit.mixins import RotaryToolBitMixin
from ..models.library import Library
class LinuxCNCSerializer(AssetSerializer):
for_class: Type[Asset] = Library
extensions: tuple[str] = (".tbl",)
mime_type: str = "text/plain"
can_import = False
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "LinuxCNC Tool Table")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
if not isinstance(asset, Library):
raise TypeError("Asset must be a Library instance")
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()
pocket = "P" # 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:.1f} ;{bit.label}\n"
output.write(line.encode("ascii", "ignore"))
return output.getvalue()
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Library:
# LinuxCNC .tbl files do not contain enough information to fully
# reconstruct a Library and its ToolBits.
# Therefore, deserialization is not supported.
raise NotImplementedError("Deserialization is not supported for LinuxCNC .tbl files.")

View File

@@ -0,0 +1,116 @@
# -*- 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 *
# * *
# ***************************************************************************
"""Widget for browsing Tool Library assets with filtering and sorting."""
from typing import cast
from PySide import QtGui
import Path
from ...toolbit.ui.browser import ToolBitBrowserWidget
from ...assets import AssetManager
from ...library import Library
class LibraryBrowserWidget(ToolBitBrowserWidget):
"""
A widget to browse, filter, and select Tool Library assets from the
AssetManager, with sorting and batch insertion, including library selection.
"""
def __init__(
self,
asset_manager: AssetManager,
store: str = "local",
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)
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
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()
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 = []
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

View File

@@ -24,6 +24,9 @@ from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import FreeCADGui
import Path
from Path.Tool.library.ui.dock import ToolBitLibraryDock
from Path.Tool.library.ui.editor import LibraryEditor
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
@@ -34,9 +37,9 @@ else:
translate = FreeCAD.Qt.translate
class CommandToolBitSelectorOpen:
class CommandToolBitLibraryDockOpen:
"""
Command to toggle the ToolBitSelector Dock
Command to toggle the ToolBitLibraryDock
"""
def __init__(self):
@@ -52,16 +55,14 @@ class CommandToolBitSelectorOpen:
}
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return True
def Activated(self):
import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui
dock = PathToolBitLibraryGui.ToolBitSelector()
dock = ToolBitLibraryDock()
dock.open()
class CommandToolBitLibraryOpen:
class CommandLibraryEditorOpen:
"""
Command to open ToolBitLibrary editor.
"""
@@ -80,19 +81,16 @@ class CommandToolBitLibraryOpen:
}
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return True
def Activated(self):
import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui
library = PathToolBitLibraryGui.ToolBitLibrary()
library = LibraryEditor()
library.open()
if FreeCAD.GuiUp:
FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandToolBitLibraryOpen())
FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitSelectorOpen())
FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandLibraryEditorOpen())
FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitLibraryDockOpen())
BarList = ["CAM_ToolBitDock"]
MenuList = ["CAM_ToolBitLibraryOpen", "CAM_ToolBitDock"]

View File

@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 2020 Schildkroet *
# * 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 *
# * *
# ***************************************************************************
"""ToolBit Library Dock Widget."""
import FreeCAD
import FreeCADGui
import Path
import Path.Tool.Gui.Controller as PathToolControllerGui
import PathScripts.PathUtilsGui as PathUtilsGui
from PySide import QtGui, QtCore
from functools import partial
from typing import List, Tuple
from ...assets import AssetUri
from ...camassets import cam_assets, ensure_assets_initialized
from ...toolbit import ToolBit
from .editor import LibraryEditor
from .browser import LibraryBrowserWidget
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
translate = FreeCAD.Qt.translate
class ToolBitLibraryDock(object):
"""Controller for displaying a library and creating ToolControllers"""
def __init__(self):
ensure_assets_initialized(cam_assets)
# Create the main form widget directly
self.form = QtGui.QDockWidget()
self.form.setObjectName("ToolSelector")
self.form.setWindowTitle(translate("CAM_ToolBit", "Tool Selector"))
# Create the browser widget
self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets)
self._setup_ui()
def _setup_ui(self):
"""Setup the form and load the tooltable data"""
Path.Log.track()
# Create a main widget and layout for the dock
main_widget = QtGui.QWidget()
main_layout = QtGui.QVBoxLayout(main_widget)
# Add the browser widget to the layout
main_layout.addWidget(self.browser_widget)
# Create buttons
self.libraryEditorOpenButton = QtGui.QPushButton(
translate("CAM_ToolBit", "Open Library Editor")
)
self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job"))
# Add buttons to a horizontal layout
button_layout = QtGui.QHBoxLayout()
button_layout.addWidget(self.libraryEditorOpenButton)
button_layout.addWidget(self.addToolControllerButton)
# Add the button layout to the main layout
main_layout.addLayout(button_layout)
# Set the main widget as the dock's widget
self.form.setWidget(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.libraryEditorOpenButton.clicked.connect(self._open_editor)
self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc))
# Initial state of buttons
self._update_state()
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)
def _open_editor(self):
library = LibraryEditor()
library.open()
# After editing, we might need to refresh the libraries in the browser widget
# Assuming _populate_libraries is the correct method to call
self.browser_widget.refresh()
def _add_tool_to_doc(self) -> List[Tuple[int, ToolBit]]:
"""
Get the selected toolbit assets from the browser widget.
"""
Path.Log.track()
tools = []
selected_toolbits = self.browser_widget.get_selected_bits()
for toolbit in selected_toolbits:
# Need to get the tool number for this toolbit from the currently
# selected library in the browser widget.
toolNr = self.browser_widget.get_tool_no_from_current_library(toolbit)
if toolNr is not None:
toolbit.attach_to_doc(FreeCAD.ActiveDocument)
tools.append((toolNr, toolbit))
else:
Path.Log.warning(
f"Could not get tool number for toolbit {toolbit.get_uri()} in selected library."
)
return tools
def _add_tool_controller_to_doc(self, index=None):
"""
if no jobs, don't do anything, otherwise all TCs for all
selected toolbit assets
"""
Path.Log.track()
jobs = PathUtilsGui.PathUtils.GetJobs()
if len(jobs) == 0:
QtGui.QMessageBox.information(
self.form,
translate("CAM_ToolBit", "No Job Found"),
translate("CAM_ToolBit", "Please create a Job first."),
)
return
elif len(jobs) == 1:
job = jobs[0]
else:
userinput = PathUtilsGui.PathUtilsUserInput()
job = userinput.chooseJob(jobs)
if job is None: # user may have canceled
return
# Get the selected toolbit assets
selected_tools = self._add_tool_to_doc()
for toolNr, toolbit in selected_tools:
tc = PathToolControllerGui.Create(f"TC: {toolbit.label}", toolbit.obj, toolNr)
job.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
def open(self, path=None):
"""load library stored in path and bring up ui"""
docs = FreeCADGui.getMainWindow().findChildren(QtGui.QDockWidget)
for doc in docs:
if doc.objectName() == "ToolSelector":
if doc.isVisible():
doc.deleteLater()
return
else:
doc.setVisible(True)
return
mw = FreeCADGui.getMainWindow()
mw.addDockWidget(
QtCore.Qt.RightDockWidgetArea,
self.form,
QtCore.Qt.Orientation.Vertical,
)

View File

@@ -0,0 +1,642 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 2020 Schildkroet *
# * 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 FreeCAD
import FreeCADGui
import Path
import PySide
from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap
from PySide.QtCore import Qt
import os
import uuid as UUID
from typing import List, cast
from ...assets import AssetUri
from ...assets.ui import AssetOpenDialog, AssetSaveDialog
from ...camassets import cam_assets, ensure_assets_initialized
from ...shape.ui.shapeselector import ShapeSelector
from ...toolbit import ToolBit
from ...toolbit.serializers import all_serializers as toolbit_serializers
from ...toolbit.ui import ToolBitEditor
from ...library import Library
from ...library.serializers import all_serializers as library_serializers
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
_UuidRole = PySide.QtCore.Qt.UserRole + 1
_PathRole = PySide.QtCore.Qt.UserRole + 2
_LibraryRole = PySide.QtCore.Qt.UserRole + 3
translate = FreeCAD.Qt.translate
class _TableView(PySide.QtGui.QTableView):
"""Subclass of QTableView to support rearrange and copying of ToolBits"""
def __init__(self, parent):
PySide.QtGui.QTableView.__init__(self, parent)
self.setDragEnabled(False)
self.setAcceptDrops(False)
self.setDropIndicatorShown(False)
self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly)
self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction)
self.setSortingEnabled(True)
self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows)
self.verticalHeader().hide()
def supportedDropActions(self):
return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction]
def _uuidOfRow(self, row):
model = self.toolModel()
return model.data(model.index(row, 0), _UuidRole)
def _rowWithUuid(self, uuid):
model = self.toolModel()
for row in range(model.rowCount()):
if self._uuidOfRow(row) == uuid:
return row
return None
def _copyTool(self, uuid_, dstRow):
model = self.toolModel()
model.insertRow(dstRow)
srcRow = self._rowWithUuid(uuid_)
for col in range(model.columnCount()):
srcItem = model.item(srcRow, col)
model.setData(
model.index(dstRow, col),
srcItem.data(PySide.QtCore.Qt.EditRole),
PySide.QtCore.Qt.EditRole,
)
if col == 0:
model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole)
# Even a clone of a tool gets its own uuid so it can be identified when
# rearranging the order or inserting/deleting rows
model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole)
else:
model.item(dstRow, col).setEditable(False)
def _copyTools(self, uuids, dst):
for i, uuid in enumerate(uuids):
self._copyTool(uuid, dst + i)
def dropEvent(self, event):
"""Handle drop events on the tool table"""
Path.Log.track()
mime = event.mimeData()
data = mime.data("application/x-qstandarditemmodeldatalist")
stream = PySide.QtCore.QDataStream(data)
srcRows = []
while not stream.atEnd():
row = stream.readInt32()
srcRows.append(row)
# get the uuids of all srcRows
model = self.toolModel()
srcUuids = [self._uuidOfRow(row) for row in set(srcRows)]
destRow = self.rowAt(event.pos().y())
self._copyTools(srcUuids, destRow)
if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction():
for uuid in srcUuids:
model.removeRow(self._rowWithUuid(uuid))
class ModelFactory:
"""Helper class to generate qtdata models for toolbit libraries"""
@staticmethod
def find_libraries(model) -> QStandardItemModel:
"""
Finds all the fctl files in a location.
Returns a QStandardItemModel.
"""
Path.Log.track()
model.clear()
# Use AssetManager to fetch library assets (depth=0 for shallow fetch)
try:
# Fetch library assets themselves, not their deep dependencies (toolbits).
# depth=0 means "fetch this asset, but not its dependencies"
# The 'fetch' method returns actual Asset objects.
libraries = cast(List[Library], cam_assets.fetch(asset_type="toolbitlibrary", depth=0))
except Exception as e:
Path.Log.error(f"Failed to fetch toolbit libraries: {e}")
return model # Return empty model on error
# Sort by label for consistent ordering, falling back to asset_id if label is missing
def get_sort_key(library):
label = getattr(library, "label", None)
return label if label else library.get_id()
for library in sorted(libraries, key=get_sort_key):
lib_uri_str = str(library.get_uri())
libItem = QStandardItem(library.label or library.get_id())
libItem.setToolTip(f"ID: {library.get_id()}\nURI: {lib_uri_str}")
libItem.setData(lib_uri_str, _LibraryRole) # Store the URI string
libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg"))
model.appendRow(libItem)
Path.Log.debug("model rows: {}".format(model.rowCount()))
return model
@staticmethod
def __library_load(library_uri: str, data_model: QStandardItemModel):
Path.Log.track(library_uri)
if library_uri:
# Store the AssetUri string, not just the name
Path.Preferences.setLastToolLibrary(library_uri)
try:
# Load the library asset using AssetManager
loaded_library = cam_assets.get(AssetUri(library_uri), depth=1)
except Exception as e:
Path.Log.error(f"Failed to load library from {library_uri}: {e}")
raise
# Iterate over the loaded ToolBit asset instances
for tool_no, tool_bit in sorted(loaded_library._bit_nos.items()):
data_model.appendRow(
ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri()))
)
@staticmethod
def _generate_tooltip(toolbit: dict) -> str:
"""
Generate an HTML tooltip for a given toolbit dictionary.
Args:
toolbit (dict): A dictionary containing toolbit information.
Returns:
str: An HTML string representing the tooltip.
"""
tooltip = f"<b>Name:</b> {toolbit['name']}<br>"
tooltip += f"<b>Shape File:</b> {toolbit['shape']}<br>"
tooltip += "<b>Parameters:</b><br>"
parameters = toolbit.get("parameter", {})
if parameters:
for key, value in parameters.items():
tooltip += f" <b>{key}:</b> {value}<br>"
else:
tooltip += " No parameters provided.<br>"
attributes = toolbit.get("attribute", {})
if attributes:
tooltip += "<b>Attributes:</b><br>"
for key, value in attributes.items():
tooltip += f" <b>{key}:</b> {value}<br>"
return tooltip
@staticmethod
def _tool_add(nr: int, tool: dict, path: str):
str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0]
tooltip = ModelFactory._generate_tooltip(tool)
tool_nr = QStandardItem()
tool_nr.setData(nr, Qt.EditRole)
tool_nr.setData(path, _PathRole)
tool_nr.setData(UUID.uuid4(), _UuidRole)
tool_nr.setToolTip(tooltip)
tool_name = QStandardItem()
tool_name.setData(tool["name"], Qt.EditRole)
tool_name.setEditable(False)
tool_name.setToolTip(tooltip)
tool_shape = QStandardItem()
tool_shape.setData(str_shape, Qt.EditRole)
tool_shape.setEditable(False)
return [tool_nr, tool_name, tool_shape]
@staticmethod
def library_open(model: QStandardItemModel, library_uri: str) -> QStandardItemModel:
"""
Opens the tools in a library using its AssetUri.
Returns a QStandardItemModel.
"""
Path.Log.track(library_uri)
ModelFactory.__library_load(library_uri, model)
Path.Log.debug("model rows: {}".format(model.rowCount()))
return model
class LibraryEditor(object):
"""LibraryEditor is the controller for
displaying/selecting/creating/editing a collection of ToolBits."""
def __init__(self):
Path.Log.track()
ensure_assets_initialized(cam_assets)
self.factory = ModelFactory()
self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames()))
self.listModel = PySide.QtGui.QStandardItemModel()
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui")
self.toolTableView = _TableView(self.form.toolTableGroup)
self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView)
self.form.toolTable.hide()
self.setupUI()
self.title = self.form.windowTitle()
# Connect signals for tool editing
self.toolTableView.doubleClicked.connect(self.toolEdit)
def toolBitNew(self):
"""Create a new toolbit asset and add it to the current library"""
Path.Log.track()
if not self.current_library:
PySide.QtGui.QMessageBox.warning(
self.form,
translate("CAM_ToolBit", "No Library Loaded"),
translate("CAM_ToolBit", "Load or create a tool library first."),
)
return
# Select the shape for the new toolbit
selector = ShapeSelector()
shape = selector.show()
if shape is None: # user canceled
return
try:
# Find the appropriate ToolBit subclass based on the shape name
tool_bit_classes = {b.SHAPE_CLASS.name: b for b in ToolBit.__subclasses__()}
tool_bit_class = tool_bit_classes.get(shape.name)
if not tool_bit_class:
raise ValueError(f"No ToolBit subclass found for shape '{shape.name}'")
# Create a new ToolBit instance using the subclass constructor
# The constructor will generate a UUID
toolbit = tool_bit_class(shape)
# 1. Save the individual toolbit asset first.
tool_asset_uri = cam_assets.add(toolbit)
Path.Log.debug(f"toolBitNew: Saved tool with URI: {tool_asset_uri}")
# 2. Add the toolbit (which now has a persisted URI) to the current library's model
tool_no = self.current_library.add_bit(toolbit)
Path.Log.debug(
f"toolBitNew: Added toolbit {toolbit.get_id()} (URI: {toolbit.get_uri()}) "
f"to current_library with tool number {tool_no}."
)
# 3. Add the new tool directly to the UI model
new_row_items = ModelFactory._tool_add(
tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit
)
self.toolModel.appendRow(new_row_items)
# 4. Save the library (which now references the saved toolbit)
self._saveCurrentLibrary()
except Exception as e:
Path.Log.error(f"Failed to create or add new toolbit: {e}")
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Creating Toolbit"),
str(e),
)
raise
def toolBitExisting(self):
"""Add an existing toolbit asset to the current library"""
Path.Log.track()
if not self.current_library:
PySide.QtGui.QMessageBox.warning(
self.form,
translate("CAM_ToolBit", "No Library Loaded"),
translate("CAM_ToolBit", "Load or create a tool library first."),
)
return
# Open the file dialog
dialog = AssetOpenDialog(ToolBit, toolbit_serializers, self.form)
dialog_result = dialog.exec()
if not dialog_result:
return # User canceled or error
file_path, toolbit = dialog_result
toolbit = cast(ToolBit, toolbit)
try:
# Add the existing toolbit to the current library's model
# The add_bit method handles assigning a tool number and returns it.
cam_assets.add(toolbit)
tool_no = self.current_library.add_bit(toolbit)
# Add the new tool directly to the UI model
new_row_items = ModelFactory._tool_add(
tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit
)
self.toolModel.appendRow(new_row_items)
# Save the library (which now references the added toolbit)
# Use cam_assets.add directly for internal save on existing toolbit
self._saveCurrentLibrary()
except Exception as e:
Path.Log.error(
f"Failed to add imported toolbit {toolbit.get_id()} "
f"from {file_path} to library: {e}"
)
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Adding Imported Toolbit"),
str(e),
)
raise
def toolDelete(self):
"""Delete a tool"""
Path.Log.track()
selected_indices = self.toolTableView.selectedIndexes()
if not selected_indices:
return
if not self.current_library:
Path.Log.error("toolDelete: No current_library loaded. Cannot delete tools.")
return
# Collect unique rows to process, as selectedIndexes can return multiple indices per row
selected_rows = sorted(list(set(index.row() for index in selected_indices)), reverse=True)
# Remove the rows from the library model.
for row in selected_rows:
item_tool_nr_or_uri = self.toolModel.item(row, 0) # Column 0 stores _PathRole
tool_uri_string = item_tool_nr_or_uri.data(_PathRole)
tool_uri = AssetUri(tool_uri_string)
bit = self.current_library.get_tool_by_uri(tool_uri)
self.current_library.remove_bit(bit)
self.toolModel.removeRows(row, 1)
Path.Log.info(f"toolDelete: Removed {len(selected_rows)} rows from UI model.")
# Save the library after deleting a tool
self._saveCurrentLibrary()
def toolSelect(self, selected, deselected):
sel = len(self.toolTableView.selectedIndexes()) > 0
self.form.toolDelete.setEnabled(sel)
def tableSelected(self, index):
"""loads the tools for the selected tool table"""
Path.Log.track()
item = index.model().itemFromIndex(index)
library_uri_string = item.data(_LibraryRole)
self._loadSelectedLibraryTools(library_uri_string)
def open(self):
Path.Log.track()
return self.form.exec_()
def toolEdit(self, selected):
"""Edit the selected tool bit asset"""
Path.Log.track()
item = self.toolModel.item(selected.row(), 0)
if selected.column() == 0:
return # Assuming tool number editing is handled directly in the table model
toolbit_uri_string = item.data(_PathRole)
if not toolbit_uri_string:
Path.Log.error("No toolbit URI found for selected item.")
return
toolbit_uri = AssetUri(toolbit_uri_string)
# Load the toolbit asset for editing
try:
bit = cam_assets.get(toolbit_uri)
editor_dialog = ToolBitEditor(bit, self.form) # Create dialog instance
result = editor_dialog.show() # Show as modal dialog
if result == PySide.QtGui.QDialog.Accepted:
# The editor updates the toolbit directly, so we just need to save
cam_assets.add(bit)
Path.Log.info(f"Toolbit {bit.get_id()} saved.")
# Refresh the display and save the library
self._loadSelectedLibraryTools(
self.current_library.get_uri() if self.current_library else None
)
# Save the library after editing a toolbit
self._saveCurrentLibrary()
except Exception as e:
Path.Log.error(f"Failed to load or edit toolbit asset {toolbit_uri_string}: {e}")
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Editing Toolbit"),
str(e),
)
raise
def libraryNew(self):
"""Create a new tool library asset"""
Path.Log.track()
# Get the desired library name (label) from the user
library_label, ok = PySide.QtGui.QInputDialog.getText(
self.form,
translate("CAM_ToolBit", "New Tool Library"),
translate("CAM_ToolBit", "Enter a name for the new library:"),
)
if not ok or not library_label:
return
# Create a new Library asset instance, UUID will be auto-generated
new_library = Library(library_label)
uri = cam_assets.add(new_library)
Path.Log.info(f"New library created: {uri}")
# Refresh the list of libraries in the UI
self._refreshLibraryListModel()
self._loadSelectedLibraryTools(uri)
# Attempt to select the newly added library in the list
for i in range(self.listModel.rowCount()):
item = self.listModel.item(i)
if item and item.data(_LibraryRole) == str(uri):
curIndex = self.listModel.indexFromItem(item)
self.form.TableList.setCurrentIndex(curIndex)
Path.Log.debug(f"libraryNew: Selected new library '{str(uri)}' in TableList.")
break
def _refreshLibraryListModel(self):
"""Clears and repopulates the self.listModel with available libraries."""
Path.Log.track()
self.listModel.clear()
self.factory.find_libraries(self.listModel)
self.listModel.setHorizontalHeaderLabels(["Library"])
def _saveCurrentLibrary(self):
"""Internal method to save the current tool library asset"""
Path.Log.track()
if not self.current_library:
Path.Log.warning("_saveCurrentLibrary: No library asset loaded to save.")
return
try:
cam_assets.add(self.current_library)
Path.Log.info(
f"_saveCurrentLibrary: Library " f"{self.current_library.get_uri()} saved."
)
except Exception as e:
Path.Log.error(
f"_saveCurrentLibrary: Failed to save library "
f"{self.current_library.get_uri()}: {e}"
)
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Saving Library"),
str(e),
)
raise
def exportLibrary(self):
"""Export the current tool library asset to a file"""
Path.Log.track()
if not self.current_library:
PySide.QtGui.QMessageBox.warning(
self.form,
translate("CAM_ToolBit", "No Library Loaded"),
translate("CAM_ToolBit", "Load or create a tool library first."),
)
return
dialog = AssetSaveDialog(self.current_library, library_serializers, self.form)
dialog_result = dialog.exec(self.current_library)
if not dialog_result:
return # User canceled or error
file_path, serializer_class = dialog_result
Path.Log.info(
f"Exported library {self.current_library.label} "
f"to {file_path} using serializer {serializer_class.__name__}"
)
def columnNames(self):
return [
"Tn",
translate("CAM_ToolBit", "Tool"),
translate("CAM_ToolBit", "Shape"),
]
def _loadSelectedLibraryTools(self, library_uri: AssetUri | str | None = None):
"""Loads tools for the given library_uri into self.toolModel and selects it in the list."""
Path.Log.track(library_uri)
self.toolModel.clear()
# library_uri is now expected to be a string URI or None when called from setupUI/tableSelected.
# AssetUri object conversion is handled by cam_assets.get() if needed.
self.current_library = None # Reset current_library before loading
if not library_uri:
self.form.setWindowTitle("Tool Library Editor - No Library Selected")
return
# Fetch the library from the asset manager
try:
self.current_library = cam_assets.get(library_uri, depth=1)
except Exception as e:
Path.Log.error(f"Failed to load library asset {library_uri}: {e}")
self.form.setWindowTitle("Tool Library Editor - Error")
return
# Success! Add the tools to the toolModel.
self.toolTableView.setUpdatesEnabled(False)
self.form.setWindowTitle(f"Tool Library Editor - {self.current_library.label}")
for tool_no, tool_bit in sorted(self.current_library._bit_nos.items()):
self.toolModel.appendRow(
ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri()))
)
self.toolModel.setHorizontalHeaderLabels(self.columnNames())
self.toolTableView.setUpdatesEnabled(True)
def setupUI(self):
"""Setup the form and load the tool library data"""
Path.Log.track()
self.form.TableList.setModel(self.listModel)
self._refreshLibraryListModel()
self.toolTableView.setModel(self.toolModel)
# Find the last used library.
last_used_lib_identifier = Path.Preferences.getLastToolLibrary()
Path.Log.debug(
f"setupUI: Last used library identifier from prefs: '{last_used_lib_identifier}'"
)
last_used_lib_uri = None
if last_used_lib_identifier:
last_used_lib_uri = Library.resolve_name(last_used_lib_identifier)
# Find it in the list.
index = 0
for i in range(self.listModel.rowCount()):
item = self.listModel.item(i)
if item and item.data(_LibraryRole) == str(last_used_lib_uri):
index = i
break
# Select it.
if index <= self.listModel.rowCount():
item = self.listModel.item(index)
if item: # Should always be true, but...
library_uri_str = item.data(_LibraryRole)
self.form.TableList.setCurrentIndex(self.listModel.index(index, 0))
# Load tools for the selected library.
self._loadSelectedLibraryTools(library_uri_str)
self.toolTableView.resizeColumnsToContents()
self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect)
self.form.TableList.clicked.connect(self.tableSelected)
self.form.toolAdd.clicked.connect(self.toolBitExisting)
self.form.toolDelete.clicked.connect(self.toolDelete)
self.form.toolCreate.clicked.connect(self.toolBitNew)
self.form.addLibrary.clicked.connect(self.libraryNew)
self.form.exportLibrary.clicked.connect(self.exportLibrary)
self.form.okButton.clicked.connect(self.form.close)
self.toolSelect([], [])

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 The FreeCAD team *
# * *
# * 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 *
# * *
# ***************************************************************************

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from .models.machine import Machine
__all__ = [
"Machine",
]

View File

@@ -0,0 +1,434 @@
# -*- 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 uuid
import json
import FreeCAD
from FreeCAD import Base
from typing import Optional, Union, Mapping, List
from ...assets import Asset, AssetUri, AssetSerializer
class Machine(Asset):
"""Represents a machine with various operational parameters."""
asset_type: str = "machine"
API_VERSION = 1
UNIT_CONVERSIONS = {
"hp": 745.7, # hp to W
"in-lbf": 0.112985, # in-lbf to N*m
"inch/min": 25.4, # inch/min to mm/min
"rpm": 1.0 / 60.0, # rpm to 1/s
"kW": 1000.0, # kW to W
"Nm": 1.0, # Nm to N*m
"mm/min": 1.0, # mm/min to mm/min
}
def __init__(
self,
label: str = "Machine",
max_power: Union[int, float, FreeCAD.Units.Quantity] = 2,
min_rpm: Union[int, float, FreeCAD.Units.Quantity] = 3000,
max_rpm: Union[int, float, FreeCAD.Units.Quantity] = 60000,
max_torque: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None,
peak_torque_rpm: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None,
min_feed: Union[int, float, FreeCAD.Units.Quantity] = 1,
max_feed: Union[int, float, FreeCAD.Units.Quantity] = 2000,
id: Optional[str] = None,
) -> None:
"""
Initializes a Machine object.
Args:
label: The label of the machine.
max_power: The maximum power of the machine (kW or Quantity).
min_rpm: The minimum RPM of the machine (RPM or Quantity).
max_rpm: The maximum RPM of the machine (RPM or Quantity).
max_torque: The maximum torque of the machine (Nm or Quantity).
peak_torque_rpm: The RPM at which peak torque is achieved
(RPM or Quantity).
min_feed: The minimum feed rate of the machine
(mm/min or Quantity).
max_feed: The maximum feed rate of the machine
(mm/min or Quantity).
id: The unique identifier of the machine.
"""
self.id = id or str(uuid.uuid1())
self._label = label
# Initialize max_power (W)
if isinstance(max_power, FreeCAD.Units.Quantity):
self._max_power = max_power.getValueAs("W").Value
elif isinstance(max_power, (int, float)):
self._max_power = max_power * self.UNIT_CONVERSIONS["kW"]
else:
self._max_power = 2000.0
# Initialize min_rpm (1/s)
if isinstance(min_rpm, FreeCAD.Units.Quantity):
try:
self._min_rpm = min_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
self._min_rpm = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
elif isinstance(min_rpm, (int, float)):
self._min_rpm = min_rpm * self.UNIT_CONVERSIONS["rpm"]
else:
self._min_rpm = 3000 * self.UNIT_CONVERSIONS["rpm"]
# Initialize max_rpm (1/s)
if isinstance(max_rpm, FreeCAD.Units.Quantity):
try:
self._max_rpm = max_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
self._max_rpm = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
elif isinstance(max_rpm, (int, float)):
self._max_rpm = max_rpm * self.UNIT_CONVERSIONS["rpm"]
else:
self._max_rpm = 60000 * self.UNIT_CONVERSIONS["rpm"]
# Initialize min_feed (mm/min)
if isinstance(min_feed, FreeCAD.Units.Quantity):
self._min_feed = min_feed.getValueAs("mm/min").Value
elif isinstance(min_feed, (int, float)):
self._min_feed = min_feed
else:
self._min_feed = 1.0
# Initialize max_feed (mm/min)
if isinstance(max_feed, FreeCAD.Units.Quantity):
self._max_feed = max_feed.getValueAs("mm/min").Value
elif isinstance(max_feed, (int, float)):
self._max_feed = max_feed
else:
self._max_feed = 2000.0
# Initialize peak_torque_rpm (1/s)
if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity):
try:
self._peak_torque_rpm = peak_torque_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
self._peak_torque_rpm = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
elif isinstance(peak_torque_rpm, (int, float)):
self._peak_torque_rpm = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"]
else:
self._peak_torque_rpm = self._max_rpm / 3
# Initialize max_torque (N*m)
if isinstance(max_torque, FreeCAD.Units.Quantity):
self._max_torque = max_torque.getValueAs("Nm").Value
elif isinstance(max_torque, (int, float)):
self._max_torque = max_torque
else:
# Convert 1/s to rpm
peak_rpm_for_calc = self._peak_torque_rpm * 60
self._max_torque = (
self._max_power * 9.5488 / peak_rpm_for_calc if peak_rpm_for_calc else float("inf")
)
def get_id(self) -> str:
"""Returns the unique identifier for the Machine instance."""
return self.id
def to_dict(self) -> dict:
"""Returns a dictionary representation of the Machine."""
return {
"version": self.API_VERSION,
"id": self.id,
"label": self.label,
"max_power": self._max_power, # W
"min_rpm": self._min_rpm, # 1/s
"max_rpm": self._max_rpm, # 1/s
"max_torque": self._max_torque, # Nm
"peak_torque_rpm": self._peak_torque_rpm, # 1/s
"min_feed": self._min_feed, # mm/min
"max_feed": self._max_feed, # mm/min
}
def to_bytes(self, serializer: AssetSerializer) -> bytes:
"""Serializes the Machine object to bytes using to_dict."""
data_dict = self.to_dict()
json_str = json.dumps(data_dict)
return json_str.encode("utf-8")
@classmethod
def from_dict(cls, data_dict: dict, id: str) -> "Machine":
"""Creates a Machine instance from a dictionary."""
machine = cls(
label=data_dict.get("label", "Machine"),
max_power=data_dict.get("max_power", 2000.0), # W
min_rpm=data_dict.get("min_rpm", 3000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s
max_rpm=data_dict.get("max_rpm", 60000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s
max_torque=data_dict.get("max_torque", None), # Nm
peak_torque_rpm=data_dict.get("peak_torque_rpm", None), # 1/s
min_feed=data_dict.get("min_feed", 1.0), # mm/min
max_feed=data_dict.get("max_feed", 2000.0), # mm/min
id=id,
)
return machine
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> "Machine":
"""
Deserializes bytes into a Machine instance using from_dict.
"""
# If dependencies is None, it's fine as Machine doesn't use it.
data_dict = json.loads(data.decode("utf-8"))
return cls.from_dict(data_dict, id)
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
"""Returns a list of AssetUri dependencies parsed from the serialized data."""
return [] # Machine has no dependencies
@property
def max_power(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_power, "W")
@property
def min_rpm(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._min_rpm, "1/s")
@property
def max_rpm(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_rpm, "1/s")
@property
def max_torque(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_torque, "Nm")
@property
def peak_torque_rpm(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._peak_torque_rpm, "1/s")
@property
def min_feed(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._min_feed, "mm/min")
@property
def max_feed(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_feed, "mm/min")
@property
def label(self) -> str:
return self._label
@label.setter
def label(self, label: str) -> None:
self._label = label
def get_min_rpm_value(self) -> float:
"""Helper method to get minimum RPM value for display/testing."""
return self._min_rpm * 60
def get_max_rpm_value(self) -> float:
"""Helper method to get maximum RPM value for display/testing."""
return self._max_rpm * 60
def get_peak_torque_rpm_value(self) -> float:
"""Helper method to get peak torque RPM value for display/testing."""
return self._peak_torque_rpm * 60
def validate(self) -> None:
"""Validates the machine parameters."""
if not self.label:
raise AttributeError("Machine name is required")
if self._peak_torque_rpm > self._max_rpm:
err = ("Peak Torque RPM {ptrpm:.2f} must be less than max RPM " "{max_rpm:.2f}").format(
ptrpm=self._peak_torque_rpm * 60, max_rpm=self._max_rpm * 60
)
raise AttributeError(err)
if self._max_rpm <= self._min_rpm:
raise AttributeError("Max RPM must be larger than min RPM")
if self._max_feed <= self._min_feed:
raise AttributeError("Max feed must be larger than min feed")
def get_torque_at_rpm(self, rpm: Union[int, float, FreeCAD.Units.Quantity]) -> float:
"""
Calculates the torque at a given RPM.
Args:
rpm: The RPM value (int, float, or Quantity).
Returns:
The torque at the given RPM in Nm.
"""
if isinstance(rpm, FreeCAD.Units.Quantity):
try:
rpm_hz = rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
rpm_hz = rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
rpm_hz = rpm * self.UNIT_CONVERSIONS["rpm"]
max_torque_nm = self._max_torque
peak_torque_rpm_hz = self._peak_torque_rpm
peak_rpm_for_calc = peak_torque_rpm_hz * 60
rpm_for_calc = rpm_hz * 60
torque_at_current_rpm = (
self._max_power * 9.5488 / rpm_for_calc if rpm_for_calc else float("inf")
)
if rpm_for_calc <= peak_rpm_for_calc:
torque_at_current_rpm = (
max_torque_nm / peak_rpm_for_calc * rpm_for_calc
if peak_rpm_for_calc
else float("inf")
)
return min(max_torque_nm, torque_at_current_rpm)
def set_max_power(self, power: Union[int, float], unit: Optional[str] = None) -> None:
"""Sets the maximum power of the machine."""
unit = unit or "kW"
if unit in self.UNIT_CONVERSIONS:
power_value = power * self.UNIT_CONVERSIONS[unit]
else:
power_value = FreeCAD.Units.Quantity(power, unit).getValueAs("W").Value
self._max_power = power_value
if self._max_power <= 0:
raise AttributeError("Max power must be positive")
def set_min_rpm(self, min_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None:
"""Sets the minimum RPM of the machine."""
if isinstance(min_rpm, FreeCAD.Units.Quantity):
try:
min_rpm_value = min_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
min_rpm_value = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
min_rpm_value = min_rpm * self.UNIT_CONVERSIONS["rpm"]
self._min_rpm = min_rpm_value
if self._min_rpm < 0:
raise AttributeError("Min RPM cannot be negative")
if self._min_rpm >= self._max_rpm:
self._max_rpm = min_rpm_value + 1.0 / 60.0
def set_max_rpm(self, max_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None:
"""Sets the maximum RPM of the machine."""
if isinstance(max_rpm, FreeCAD.Units.Quantity):
try:
max_rpm_value = max_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
max_rpm_value = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
max_rpm_value = max_rpm * self.UNIT_CONVERSIONS["rpm"]
self._max_rpm = max_rpm_value
if self._max_rpm <= 0:
raise AttributeError("Max RPM must be positive")
if self._max_rpm <= self._min_rpm:
self._min_rpm = max(0, max_rpm_value - 1.0 / 60.0)
def set_min_feed(
self,
min_feed: Union[int, float, FreeCAD.Units.Quantity],
unit: Optional[str] = None,
) -> None:
"""Sets the minimum feed rate of the machine."""
unit = unit or "mm/min"
if unit in self.UNIT_CONVERSIONS:
min_feed_value = min_feed * self.UNIT_CONVERSIONS[unit]
else:
min_feed_value = FreeCAD.Units.Quantity(min_feed, unit).getValueAs("mm/min").Value
self._min_feed = min_feed_value
if self._min_feed < 0:
raise AttributeError("Min feed cannot be negative")
if self._min_feed >= self._max_feed:
self._max_feed = min_feed_value + 1.0
def set_max_feed(
self,
max_feed: Union[int, float, FreeCAD.Units.Quantity],
unit: Optional[str] = None,
) -> None:
"""Sets the maximum feed rate of the machine."""
unit = unit or "mm/min"
if unit in self.UNIT_CONVERSIONS:
max_feed_value = max_feed * self.UNIT_CONVERSIONS[unit]
else:
max_feed_value = FreeCAD.Units.Quantity(max_feed, unit).getValueAs("mm/min").Value
self._max_feed = max_feed_value
if self._max_feed <= 0:
raise AttributeError("Max feed must be positive")
if self._max_feed <= self._min_feed:
self._min_feed = max(0, max_feed_value - 1.0)
def set_peak_torque_rpm(
self, peak_torque_rpm: Union[int, float, FreeCAD.Units.Quantity]
) -> None:
"""Sets the peak torque RPM of the machine."""
if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity):
try:
peak_torque_rpm_value = peak_torque_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
peak_torque_rpm_value = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
peak_torque_rpm_value = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"]
self._peak_torque_rpm = peak_torque_rpm_value
if self._peak_torque_rpm < 0:
raise AttributeError("Peak torque RPM cannot be negative")
def set_max_torque(
self,
max_torque: Union[int, float, FreeCAD.Units.Quantity],
unit: Optional[str] = None,
) -> None:
"""Sets the maximum torque of the machine."""
unit = unit or "Nm"
if unit in self.UNIT_CONVERSIONS:
max_torque_value = max_torque * self.UNIT_CONVERSIONS[unit]
else:
max_torque_value = FreeCAD.Units.Quantity(max_torque, unit).getValueAs("Nm").Value
self._max_torque = max_torque_value
if self._max_torque <= 0:
raise AttributeError("Max torque must be positive")
def dump(self, do_print: bool = True) -> Optional[str]:
"""
Dumps machine information to console or returns it as a string.
Args:
do_print: If True, prints the information to the console.
If False, returns the information as a string.
Returns:
A formatted string containing machine information if do_print is
False, otherwise None.
"""
min_rpm_value = self._min_rpm * 60
max_rpm_value = self._max_rpm * 60
peak_torque_rpm_value = self._peak_torque_rpm * 60
output = ""
output += f"Machine {self.label}:\n"
output += f" Max power: {self._max_power:.2f} W\n"
output += f" RPM: {min_rpm_value:.2f} RPM - {max_rpm_value:.2f} RPM\n"
output += f" Feed: {self.min_feed.UserString} - " f"{self.max_feed.UserString}\n"
output += (
f" Peak torque: {self._max_torque:.2f} Nm at " f"{peak_torque_rpm_value:.2f} RPM\n"
)
output += f" Max_torque: {self._max_torque} Nm\n"
if do_print:
print(output)
return output

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# This package aggregates tool bit shape classes.
# Import the base class and all concrete shape classes
from .models.base import ToolBitShape
from .models.ballend import ToolBitShapeBallend
from .models.chamfer import ToolBitShapeChamfer
from .models.dovetail import ToolBitShapeDovetail
from .models.drill import ToolBitShapeDrill
from .models.endmill import ToolBitShapeEndmill
from .models.fillet import ToolBitShapeFillet
from .models.probe import ToolBitShapeProbe
from .models.reamer import ToolBitShapeReamer
from .models.slittingsaw import ToolBitShapeSlittingSaw
from .models.tap import ToolBitShapeTap
from .models.threadmill import ToolBitShapeThreadMill
from .models.bullnose import ToolBitShapeBullnose
from .models.vbit import ToolBitShapeVBit
from .models.icon import (
ToolBitShapeIcon,
ToolBitShapePngIcon,
ToolBitShapeSvgIcon,
)
# A list of the name of each ToolBitShape
TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__()])
# Define __all__ for explicit public interface
__all__ = [
"ToolBitShape",
"ToolBitShapeBallend",
"ToolBitShapeChamfer",
"ToolBitShapeDovetail",
"ToolBitShapeDrill",
"ToolBitShapeEndmill",
"ToolBitShapeFillet",
"ToolBitShapeProbe",
"ToolBitShapeReamer",
"ToolBitShapeSlittingSaw",
"ToolBitShapeTap",
"ToolBitShapeThreadMill",
"ToolBitShapeBullnose",
"ToolBitShapeVBit",
"TOOL_BIT_SHAPE_NAMES",
"ToolBitShapeIcon",
"ToolBitShapeSvgIcon",
"ToolBitShapePngIcon",
]

View File

@@ -0,0 +1,189 @@
# -*- 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 FreeCAD
import Path.Base.Util as PathUtil
from typing import Dict, List, Any, Optional
import tempfile
import os
def find_shape_object(doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]:
"""
Find the primary object representing the shape in a document.
Looks for PartDesign::Body, then Part::Feature. Falls back to the first
object if no better candidate is found.
Args:
doc (FreeCAD.Document): The document to search within.
Returns:
Optional[FreeCAD.DocumentObject]: The found object or None.
"""
obj = None
# Prioritize Body
for o in doc.Objects:
if o.isDerivedFrom("PartDesign::Body"):
return o
# Keep track of the first Part::Feature found as a fallback
if obj is None and o.isDerivedFrom("Part::Feature"):
obj = o
if obj:
return obj
# Fallback to the very first object if nothing else suitable found
return doc.Objects[0] if doc.Objects else None
def get_object_properties(
obj: "FreeCAD.DocumentObject", expected_params: List[str]
) -> Dict[str, Any]:
"""
Extract properties matching expected_params from a FreeCAD PropertyBag.
Issues warnings for missing parameters but does not raise an error.
Args:
obj: The PropertyBag to extract properties from.
expected_params (List[str]): A list of property names to look for.
Returns:
Dict[str, Any]: A dictionary mapping property names to their values.
Values are FreeCAD native types.
"""
properties = {}
for name in expected_params:
if hasattr(obj, name):
properties[name] = getattr(obj, name)
else:
# Log a warning if a parameter expected by the shape class is missing
FreeCAD.Console.PrintWarning(
f"Parameter '{name}' not found on object '{obj.Label}' "
f"({obj.Name}). Default value will be used by the shape class.\n"
)
properties[name] = None # Indicate missing value
return properties
def update_shape_object_properties(
obj: "FreeCAD.DocumentObject", parameters: Dict[str, Any]
) -> None:
"""
Update properties of a FreeCAD PropertyBag based on a dictionary of parameters.
Args:
obj (FreeCAD.DocumentObject): The PropertyBag to update properties on.
parameters (Dict[str, Any]): A dictionary of property names and values.
"""
for name, value in parameters.items():
if hasattr(obj, name):
try:
PathUtil.setProperty(obj, name, value)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Failed to set property '{name}' on object '{obj.Label}'"
f" ({obj.Name}) with value '{value}': {e}\n"
)
else:
FreeCAD.Console.PrintWarning(
f"Property '{name}' not found on object '{obj.Label}'" f" ({obj.Name}). Skipping.\n"
)
def get_doc_state() -> Any:
"""
Used to make a "snapshot" of the current state of FreeCAD, to allow
for restoring the ActiveDocument and selection state later.
"""
doc_name = FreeCAD.ActiveDocument.Name if FreeCAD.ActiveDocument else None
if FreeCAD.GuiUp:
import FreeCADGui
selection = FreeCADGui.Selection.getSelection()
else:
selection = []
return doc_name, selection
def restore_doc_state(state):
doc_name, selection = state
if doc_name:
FreeCAD.setActiveDocument(doc_name)
if FreeCAD.GuiUp:
import FreeCADGui
for sel in selection:
FreeCADGui.Selection.addSelection(doc_name, sel.Name)
class ShapeDocFromBytes:
"""
Context manager to create and manage a temporary FreeCAD document,
loading content from a byte string.
"""
def __init__(self, content: bytes):
self._content = content
self._doc = None
self._temp_file = None
self._old_state = None
def __enter__(self) -> "FreeCAD.Document":
"""Creates a new temporary FreeCAD document or loads cache if provided."""
# Create a temporary file and write the cache content to it
with tempfile.NamedTemporaryFile(suffix=".FCStd", delete=False) as tmp_file:
tmp_file.write(self._content)
self._temp_file = tmp_file.name
# When we open a new document, FreeCAD loses the state, of the active
# document (i.e. current selection), even if the newly opened document
# is a hidden one.
# So we need to restore the active document state at the end.
self._old_state = get_doc_state()
# Open the document from the temporary file
# Use a specific name to avoid clashes if multiple docs are open
# Open the document from the temporary file
self._doc = FreeCAD.openDocument(self._temp_file, hidden=True)
if not self._doc:
raise RuntimeError(f"Failed to open document from {self._temp_file}")
return self._doc
def __exit__(self, exc_type, exc_value, traceback) -> None:
"""Closes the temporary FreeCAD document and cleans up the temp file."""
if self._doc:
# Note that .closeDocument() is extremely slow; it takes
# almost 400ms per document - much longer than opening!
FreeCAD.closeDocument(self._doc.Name)
self._doc = None
# Restore the original active document
restore_doc_state(self._old_state)
# Clean up the temporary file if it was created
if self._temp_file and os.path.exists(self._temp_file):
try:
os.remove(self._temp_file)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Failed to remove temporary file {self._temp_file}: {e}\n"
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,59 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeBallend(ToolBitShape):
name: str = "Ballend"
aliases = ("ballend",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Ballend")

View File

@@ -0,0 +1,630 @@
# -*- 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 pathlib
import FreeCAD
import Path
import os
from typing import Dict, List, Any, Mapping, Optional, Tuple, Type, cast
import zipfile
import xml.etree.ElementTree as ET
import io
import tempfile
from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer
from ...camassets import cam_assets
from ..doc import (
find_shape_object,
get_object_properties,
update_shape_object_properties,
ShapeDocFromBytes,
)
from .icon import ToolBitShapeIcon
class ToolBitShape(Asset):
"""Abstract base class for tool bit shapes."""
asset_type: str = "toolbitshape"
# The name is used...
# 1. as a base for the default filename. E.g. if the name is
# "Endmill", then by default the file is "endmill.fcstd".
# 2. to identify the shape class from a shape.fcstd file.
# Upon loading a shape, the name of the body in the shape
# file is read. It MUST match one of the names.
name: str
# Aliases exist for backward compatibility. If an existing .fctb file
# references a shape such as "v-bit.fctb", and that shape file cannot
# be found, then we can attempt to find a shape class from the string
# "v-bit", "vbit", etc.
aliases: Tuple[str, ...] = tuple()
def __init__(self, id: str, **kwargs: Any):
"""
Initialize the shape.
Args:
id (str): The unique identifier for the shape.
**kwargs: Keyword arguments for shape parameters (e.g., Diameter).
Values should be FreeCAD.Units.Quantity where applicable.
"""
# _params will be populated with default values after loading
self._params: Dict[str, Any] = {}
# Stores default parameter values loaded from the FCStd file
self._defaults: Dict[str, Any] = {}
# Keeps the loaded FreeCAD document content for this instance
self._data: Optional[bytes] = None
self.id: str = id
self.is_builtin: bool = True
self.icon: Optional[ToolBitShapeIcon] = None
# Assign parameters
for param, value in kwargs.items():
self.set_parameter(param, value)
def __str__(self):
params_str = ", ".join(f"{name}={val}" for name, val in self._params.items())
return f"{self.name}({params_str})"
def __repr__(self):
return self.__str__()
def get_id(self) -> str:
"""
Get the ID of the shape.
Returns:
str: The ID of the shape.
"""
return self.id
@classmethod
def _get_shape_class_from_doc(cls, doc: "FreeCAD.Document") -> Type["ToolBitShape"]:
# Find the Body object to identify the shape type
body_obj = find_shape_object(doc)
if not body_obj:
raise ValueError(f"No 'PartDesign::Body' object found in {doc}")
# Find the correct subclass based on the body label
shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()}
shape_class = shape_classes.get(body_obj.Label)
if not shape_class:
raise ValueError(
f"No ToolBitShape subclass found matching Body label '{body_obj.Label}' in {doc}"
)
return shape_class
@classmethod
def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]:
"""
Identifies the ToolBitShape subclass from the raw bytes of an FCStd file
by parsing the XML content to find the Body label.
Args:
data (bytes): The raw bytes of the .FCStd file.
Returns:
Type[ToolBitShape]: The appropriate ToolBitShape subclass.
Raises:
ValueError: If the data is not a valid FCStd file, Document.xml is
missing, no Body object is found, or the Body label
does not match a known shape name.
"""
try:
# FCStd files are zip archives
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# Read Document.xml from the archive
with zf.open("Document.xml") as doc_xml_file:
tree = ET.parse(doc_xml_file)
root = tree.getroot()
# Extract name of the main Body from XML tree using xpath.
# The body should be a PartDesign::Body, and its label is
# stored in an Property element with a matching name.
body_label = None
xpath = './/Object[@name="Body"]//Property[@name="Label"]/String'
body_label_elem = root.find(xpath)
if body_label_elem is not None:
body_label = body_label_elem.get("value")
if not body_label:
raise ValueError(
"No 'Label' property found for 'PartDesign::Body' object using XPath"
)
# Find the correct subclass based on the body label
shape_class = cls.get_subclass_by_name(body_label)
if not shape_class:
raise ValueError(
f"No ToolBitShape subclass found matching Body label '{body_label}'"
)
return shape_class
except zipfile.BadZipFile:
raise ValueError("Invalid FCStd file data (not a valid zip archive)")
except KeyError:
raise ValueError("Invalid FCStd file data (Document.xml not found)")
except ET.ParseError:
raise ValueError("Error parsing Document.xml")
except Exception as e:
# Catch any other unexpected errors during parsing
raise ValueError(f"Error processing FCStd data: {e}")
@classmethod
def _find_property_object(cls, doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]:
"""
Find the PropertyBag object named "Attributes" in a document.
Args:
doc (FreeCAD.Document): The document to search within.
Returns:
Optional[FreeCAD.DocumentObject]: The found object or None.
"""
for o in doc.Objects:
# Check if the object has a Label property and if its value is "Attributes"
# This seems to be the convention in the shape files.
if hasattr(o, "Label") and o.Label == "Attributes":
# We assume this object holds the parameters.
# Further type checking (e.g., for App::FeaturePython or PropertyBag)
# could be added if needed, but Label check might be sufficient.
return o
return None
@classmethod
def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]:
"""
Extracts URIs of dependencies from the raw bytes of an FCStd file.
For ToolBitShape, this is the associated ToolBitShapeIcon, identified
by the same ID as the shape asset.
"""
Path.Log.debug(f"ToolBitShape.extract_dependencies called for {cls.__name__}")
assert (
serializer == DummyAssetSerializer
), f"ToolBitShape supports only native import, not {serializer}"
# A ToolBitShape asset depends on a ToolBitShapeIcon asset with the same ID.
# We need to extract the shape ID from the FCStd data.
try:
# Open the shape data temporarily to get the Body label, which can
# be used to derive the ID if needed, or assume the ID is available
# in the data somehow (e.g., in a property).
# For now, let's assume the ID is implicitly the asset name derived
# from the Body label.
shape_class = cls.get_shape_class_from_bytes(data)
shape_id = shape_class.name.lower() # Assuming ID is lowercase name
# Construct the URI for the corresponding icon asset
svg_uri = AssetUri.build(
asset_type="toolbitshapesvg",
asset_id=shape_id + ".svg",
)
png_uri = AssetUri.build(
asset_type="toolbitshapepng",
asset_id=shape_id + ".png",
)
return [svg_uri, png_uri]
except Exception as e:
# If we can't extract the shape ID or something goes wrong,
# assume no dependencies for now.
Path.Log.error(f"Failed to extract dependencies from shape data: {e}")
return []
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
serializer: Type[AssetSerializer],
) -> "ToolBitShape":
"""
Create a ToolBitShape instance from the raw bytes of an FCStd file.
Identifies the correct subclass based on the Body label in the file,
loads parameters, and caches the document content.
Args:
data (bytes): The raw bytes of the .FCStd file.
id (str): The unique identifier for the shape.
dependencies (Optional[Mapping[AssetUri, Any]]): A mapping of
resolved dependencies. If None, shallow load was attempted.
Returns:
ToolBitShape: An instance of the appropriate ToolBitShape subclass.
Raises:
ValueError: If the data cannot be opened, no Body or PropertyBag
is found, or the Body label does not match a known
shape name.
Exception: For other potential FreeCAD errors during loading.
"""
assert serializer == DummyAssetSerializer, "ToolBitShape supports only native import"
# Open the shape data temporarily to get the Body label and parameters
with ShapeDocFromBytes(data) as temp_doc:
if not temp_doc:
# This case might be covered by ShapeDocFromBytes exceptions,
# but keeping for clarity.
raise ValueError("Failed to open shape document from bytes")
# Determine the specific subclass of ToolBitShape using the new method
shape_class = ToolBitShape.get_shape_class_from_bytes(data)
# Load properties from the temporary document
props_obj = ToolBitShape._find_property_object(temp_doc)
if not props_obj:
raise ValueError("No 'Attributes' PropertyBag object found in document bytes")
# Get properties from the properties object
expected_params = shape_class.get_expected_shape_parameters()
loaded_params = get_object_properties(props_obj, expected_params)
missing_params = [
name
for name in expected_params
if name not in loaded_params or loaded_params[name] is None
]
if missing_params:
raise ValueError(
f"Validation error: Object '{props_obj.Label}' in document bytes "
+ f"is missing parameters for {shape_class.__name__}: {', '.join(missing_params)}"
)
# Instantiate the specific subclass with the provided ID
instance = shape_class(id=id)
instance._data = data # Cache the byte content
instance._defaults = loaded_params
if dependencies: # dependencies is None = shallow load
# Assign resolved dependencies (like the icon) to the instance
# The icon has the same ID as the shape, with .png or .svg appended.
icon_uri = AssetUri.build(
asset_type="toolbitshapesvg",
asset_id=id + ".svg",
)
instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri))
if not instance.icon:
icon_uri = AssetUri.build(
asset_type="toolbitshapepng",
asset_id=id + ".png",
)
instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri))
# Update instance parameters, prioritizing loaded defaults but not
# overwriting parameters that may already be set during __init__
instance._params = instance._defaults | instance._params
return instance
def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes:
"""
Serializes a ToolBitShape object to bytes (e.g., an fcstd file).
This is required by the Asset interface.
"""
assert serializer == DummyAssetSerializer, "ToolBitShape supports only native export"
doc = None
try:
# Create a new temporary document
doc = FreeCAD.newDocument("TemporaryShapeDoc", hidden=True)
# Add the shape's body to the temporary document
self.make_body(doc)
# Recompute the document to ensure the body is created
doc.recompute()
# Save the temporary document to a temporary file
# We cannot use NamedTemporaryFile on Windows, because there
# doc.saveAs() may not have permission to access the tempfile
# while the NamedTemporaryFile is open.
# So we use TemporaryDirectory instead, to ensure cleanup while
# still having a the temporary file inside it.
with tempfile.TemporaryDirectory() as thedir:
temp_file_path = pathlib.Path(thedir, "temp.FCStd")
doc.saveAs(str(temp_file_path))
return temp_file_path.read_bytes()
finally:
# Clean up the temporary document
if doc:
FreeCAD.closeDocument(doc.Name)
@classmethod
def from_file(cls, filepath: pathlib.Path, **kwargs: Any) -> "ToolBitShape":
"""
Create a ToolBitShape instance from an FCStd file.
Reads the file bytes and delegates to from_bytes().
Args:
filepath (pathlib.Path): Path to the .FCStd file.
**kwargs: Keyword arguments for shape parameters to override defaults.
Returns:
ToolBitShape: An instance of the appropriate ToolBitShape subclass.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the file cannot be opened, no Body or PropertyBag
is found, or the Body label does not match a known
shape name.
Exception: For other potential FreeCAD errors during loading.
"""
if not filepath.exists():
raise FileNotFoundError(f"Shape file not found: {filepath}")
try:
data = filepath.read_bytes()
# Extract the ID from the filename (without extension)
shape_id = filepath.stem
# Pass an empty dictionary for dependencies when loading from a single file
# TODO: pass ToolBitShapeIcon as a dependency
instance = cls.from_bytes(data, shape_id, {}, DummyAssetSerializer)
# Apply kwargs parameters after loading from bytes
if kwargs:
instance.set_parameters(**kwargs)
return instance
except (FileNotFoundError, ValueError) as e:
raise e
except Exception as e:
raise RuntimeError(f"Failed to create shape from {filepath}: {e}")
@classmethod
def get_subclass_by_name(
cls, name: str, default: Type["ToolBitShape"] | None = None
) -> Optional[Type["ToolBitShape"]]:
"""
Retrieves a ToolBitShape class by its name or alias.
"""
name = name.lower()
for thecls in cls.__subclasses__():
if (
thecls.name.lower() == name
or thecls.__name__.lower() == name
or name in thecls.aliases
):
return thecls
return default
@classmethod
def resolve_name(cls, identifier: str) -> AssetUri:
"""
Resolves an identifier (alias, name, filename, or URI) to a Uri object.
"""
# 1. If the input is a url string, return the AssetUri for it.
if AssetUri.is_uri(identifier):
return AssetUri(identifier)
# 2. If the input is a filename (with extension), assume the asset
# name is the base name.
asset_name = identifier
if identifier.endswith(".fcstd"):
asset_name = os.path.splitext(os.path.basename(identifier))[0]
# 3. Use get_subclass_by_name to try to resolve alias to a class.
# if one is found, use the class.name.
shape_class = cls.get_subclass_by_name(asset_name.lower())
if shape_class:
asset_name = shape_class.name.lower()
# 4. Construct the Uri using AssetUri.build() and return it
return AssetUri.build(
asset_type="toolbitshape",
asset_id=asset_name,
)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
"""
Subclasses must define the dictionary mapping parameter names to
translations and FreeCAD property type strings (e.g.,
'App::PropertyLength').
The schema defines any parameters that MUST be in the shape file.
Any attempt to load a shape file that does not match the schema
will cause an error.
"""
raise NotImplementedError
@property
def label(self) -> str:
"""Return a user friendly, translatable display name."""
raise NotImplementedError
def reset_parameters(self):
"""Reset parameters to their default values."""
self._params.update(self._defaults)
def get_parameter_label(self, param_name: str) -> str:
"""
Get the user-facing label for a given parameter name.
"""
str_param_name = str(param_name)
entry = self.schema().get(param_name)
return entry[0] if entry else str_param_name
def get_parameter_property_type(self, param_name: str) -> str:
"""
Get the FreeCAD property type string for a given parameter name.
"""
return self.schema()[param_name][1]
def get_parameters(self) -> Dict[str, Any]:
"""
Get the dictionary of current parameters and their values.
Returns:
dict: A dictionary mapping parameter names to their values.
"""
return self._params
def get_parameter(self, name: str) -> Any:
"""
Get the value of a specific parameter.
Args:
name (str): The name of the parameter.
Returns:
The value of the parameter (often a FreeCAD.Units.Quantity).
Raises:
KeyError: If the parameter name is not valid for this shape.
"""
if name not in self.schema():
raise KeyError(f"Shape '{self.name}' has no parameter '{name}'")
return self._params[name]
def set_parameter(self, name: str, value: Any):
"""
Set the value of a specific parameter.
Args:
name (str): The name of the parameter.
value: The new value for the parameter. Should be compatible
with the expected type (e.g., FreeCAD.Units.Quantity).
Raises:
KeyError: If the parameter name is not valid for this shape.
"""
if name not in self.schema().keys():
Path.Log.debug(
f"Shape '{self.name}' was given an invalid parameter '{name}'. Has {self._params}\n"
)
# Log to confirm this path is taken when an invalid parameter is given
Path.Log.debug(
f"Invalid parameter '{name}' for shape "
f"'{self.name}', returning without raising KeyError."
)
return
self._params[name] = value
def set_parameters(self, **kwargs):
"""
Set multiple parameters using keyword arguments.
Args:
**kwargs: Keyword arguments where keys are parameter names.
"""
for name, value in kwargs.items():
try:
self.set_parameter(name, value)
except KeyError:
Path.Log.debug(f"Ignoring unknown parameter '{name}' for shape '{self.name}'.\n")
@classmethod
def get_expected_shape_parameters(cls) -> List[str]:
"""
Get a list of parameter names expected by this shape class based on
its schema.
Returns:
list[str]: List of parameter names.
"""
return list(cls.schema().keys())
def make_body(self, doc: "FreeCAD.Document"):
"""
Generates the body of the ToolBitShape and copies it to the provided
document.
"""
assert self._data is not None
with ShapeDocFromBytes(self._data) as tmp_doc:
shape = find_shape_object(tmp_doc)
if not shape:
FreeCAD.Console.PrintWarning(
"No suitable shape object found in document. " "Cannot create solid shape.\n"
)
return None
props = self._find_property_object(tmp_doc)
if not props:
FreeCAD.Console.PrintWarning(
"No suitable shape object found in document. " "Cannot create solid shape.\n"
)
return None
update_shape_object_properties(props, self.get_parameters())
# Recompute the document to apply property changes
tmp_doc.recompute()
# Copy the body to the given document without immediate compute.
return doc.copyObject(shape, True)
"""
Retrieves the thumbnail data for the tool bit shape in PNG format.
"""
def get_icon(self) -> Optional[ToolBitShapeIcon]:
"""
Get the associated ToolBitShapeIcon instance. Tries to load one from
the asset manager if none was assigned.
Returns:
Optional[ToolBitShapeIcon]: The icon instance, or None if none found.
"""
if self.icon:
return self.icon
# Try to get a matching SVG from the asset manager.
self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.id}.svg")
if self.icon:
return self.icon
self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.name.lower()}.svg")
if self.icon:
return self.icon
# Try to get a matching PNG from the asset manager.
self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.id}.png")
if self.icon:
return self.icon
self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.name.lower()}.png")
if self.icon:
return self.icon
return None
def get_thumbnail(self) -> Optional[bytes]:
"""
Retrieves the thumbnail data for the tool bit shape in PNG format,
as embedded in the shape file.
"""
if not self._data:
return None
with zipfile.ZipFile(io.BytesIO(self._data)) as zf:
try:
with zf.open("thumbnails/Thumbnail.png", "r") as tn:
return tn.read()
except KeyError:
pass
return None

View File

@@ -0,0 +1,63 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeBullnose(ToolBitShape):
name = "Bullnose"
aliases = "bullnose", "torus"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"FlatRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Torus radius"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Torus")

View File

@@ -0,0 +1,63 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeChamfer(ToolBitShape):
name = "Chamfer"
aliases = ("chamfer",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"),
"App::PropertyAngle",
),
"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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Chamfer")

View File

@@ -0,0 +1,75 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeDovetail(ToolBitShape):
name = "Dovetail"
aliases = ("dovetail",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"TipDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Crest height"),
"App::PropertyLength",
),
"CuttingEdgeAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"),
"App::PropertyAngle",
),
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Dovetail height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Major diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"NeckDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"),
"App::PropertyLength",
),
"NeckHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Neck length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Dovetail")

View File

@@ -0,0 +1,55 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeDrill(ToolBitShape):
name = "Drill"
aliases = ("drill",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"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",
),
"TipAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Tip angle"),
"App::PropertyAngle",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("CAM", "Drill")

View File

@@ -0,0 +1,59 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeEndmill(ToolBitShape):
name = "Endmill"
aliases = ("endmill",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
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:
return FreeCAD.Qt.translate("ToolBitShape", "Endmill")

View File

@@ -0,0 +1,63 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeFillet(ToolBitShape):
name = "Fillet"
aliases = ("fillet",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CrownHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Crown height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"FilletRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer")

View File

@@ -0,0 +1,296 @@
# -*- 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 pathlib
import xml.etree.ElementTree as ET
from typing import Mapping, Optional
from functools import cached_property
from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer
import Path.Tool.shape.util as util
from PySide import QtCore, QtGui, QtSvg
_svg_ns = {"s": "http://www.w3.org/2000/svg"}
class ToolBitShapeIcon(Asset):
"""Abstract base class for tool bit shape icons."""
def __init__(self, id: str, data: bytes):
"""
Initialize the icon.
Args:
id (str): The unique identifier for the icon, including extension.
data (bytes): The raw icon data (e.g., SVG or PNG bytes).
"""
self.id: str = id
self.data: bytes = data
def get_id(self) -> str:
"""
Get the ID of the icon.
Returns:
str: The ID of the icon.
"""
return self.id
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
serializer: AssetSerializer,
) -> "ToolBitShapeIcon":
"""
Create a ToolBitShapeIcon instance from raw bytes.
Args:
data (bytes): The raw bytes of the icon file.
id (str): The ID of the asset, including extension.
dependencies (Optional[Mapping[AssetUri, Asset]]): A mapping of resolved dependencies (not used for icons).
Returns:
ToolBitShapeIcon: An instance of ToolBitShapeIcon.
"""
assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native import"
return cls(id=id, data=data)
def to_bytes(self, serializer: AssetSerializer) -> bytes:
"""
Serializes a ToolBitShapeIcon object to bytes.
"""
assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native export"
return self.data
@classmethod
def from_file(cls, filepath: pathlib.Path, id: str) -> "ToolBitShapeIcon":
"""
Create a ToolBitShapeIcon instance from a file.
Args:
filepath (pathlib.Path): Path to the icon file (.svg or .png).
shape_id_base (str): The base ID of the associated shape.
Returns:
ToolBitShapeIcon: An instance of ToolBitShapeIcon.
Raises:
FileNotFoundError: If the file does not exist.
"""
if not filepath.exists():
raise FileNotFoundError(f"Icon file not found: {filepath}")
data = filepath.read_bytes()
if filepath.suffix.lower() == ".png":
return ToolBitShapePngIcon(id, data)
elif filepath.suffix.lower() == ".svg":
return ToolBitShapeSvgIcon(id, data)
else:
raise NotImplementedError(f"unsupported icon file: {filepath}")
@classmethod
def from_shape_data(cls, shape_data: bytes, id: str) -> Optional["ToolBitShapeIcon"]:
"""
Create a thumbnail icon from shape data bytes.
Args:
shape_data (bytes): The raw bytes of the shape file (.FCStd).
shape_id_base (str): The base ID of the associated shape.
Returns:
Optional[ToolBitShapeIcon]: An instance of ToolBitShapeIcon (PNG), or None.
"""
image_bytes = util.create_thumbnail_from_data(shape_data)
if not image_bytes:
return None
# Assuming create_thumbnail_from_data returns PNG data
return ToolBitShapePngIcon(id=id, data=image_bytes)
def get_size_in_bytes(self) -> int:
"""
Get the size of the icon data in bytes.
"""
return len(self.data)
@cached_property
def abbreviations(self) -> Mapping[str, str]:
"""
Returns a cached mapping of parameter abbreviations from the icon data.
"""
return {}
def get_abbr(self, param_name: str) -> Optional[str]:
"""
Retrieves the abbreviation for a given parameter name.
Args:
param_name: The name of the parameter.
Returns:
The abbreviation string, or None if not found.
"""
normalized_param_name = param_name.lower().replace(" ", "_")
return self.abbreviations.get(normalized_param_name)
def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes:
"""
Returns the icon data as PNG bytes.
"""
raise NotImplementedError
def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap:
"""
Returns the icon data as a QPixmap.
"""
raise NotImplementedError
class ToolBitShapeSvgIcon(ToolBitShapeIcon):
asset_type: str = "toolbitshapesvg"
def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes:
"""
Converts SVG icon data to PNG and returns it using QtSvg.
"""
if icon_size is None:
icon_size = QtCore.QSize(48, 48)
image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32)
image.fill(QtGui.Qt.transparent)
painter = QtGui.QPainter(image)
buffer = QtCore.QBuffer(QtCore.QByteArray(self.data))
buffer.open(QtCore.QIODevice.ReadOnly)
svg_renderer = QtSvg.QSvgRenderer(buffer)
svg_renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio)
svg_renderer.render(painter)
painter.end()
byte_array = QtCore.QByteArray()
buffer = QtCore.QBuffer(byte_array)
buffer.open(QtCore.QIODevice.WriteOnly)
image.save(buffer, "PNG")
return bytes(byte_array)
def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap:
"""
Returns the SVG icon data as a QPixmap using QtSvg.
"""
if icon_size is None:
icon_size = QtCore.QSize(48, 48)
icon_ba = QtCore.QByteArray(self.data)
image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32)
image.fill(QtGui.Qt.transparent)
painter = QtGui.QPainter(image)
buffer = QtCore.QBuffer(icon_ba) # PySide6
buffer.open(QtCore.QIODevice.ReadOnly)
data = QtCore.QXmlStreamReader(buffer)
renderer = QtSvg.QSvgRenderer(data)
renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio)
renderer.render(painter)
painter.end()
return QtGui.QPixmap.fromImage(image)
@cached_property
def abbreviations(self) -> Mapping[str, str]:
"""
Returns a cached mapping of parameter abbreviations from the icon data.
Only applicable for SVG icons.
"""
if self.data:
return self.get_abbreviations_from_svg(self.data)
return {}
def get_abbr(self, param_name: str) -> Optional[str]:
"""
Retrieves the abbreviation for a given parameter name.
Args:
param_name: The name of the parameter.
Returns:
The abbreviation string, or None if not found.
"""
normalized_param_name = param_name.lower().replace(" ", "_")
return self.abbreviations.get(normalized_param_name)
@staticmethod
def get_abbreviations_from_svg(svg: bytes) -> Mapping[str, str]:
"""
Extract abbreviations from SVG text elements.
"""
try:
tree = ET.fromstring(svg)
except ET.ParseError:
return {}
result = {}
for text_elem in tree.findall(".//s:text", _svg_ns):
id = text_elem.attrib.get("id", _svg_ns)
if id is None or not isinstance(id, str):
continue
abbr = text_elem.text
if abbr is not None:
result[id.lower()] = abbr
span_elem = text_elem.find(".//s:tspan", _svg_ns)
if span_elem is None:
continue
abbr = span_elem.text
result[id.lower()] = abbr
return result
class ToolBitShapePngIcon(ToolBitShapeIcon):
asset_type: str = "toolbitshapepng"
def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes:
"""
Returns the PNG icon data.
"""
# For PNG, resizing might be needed if icon_size is different
# from the original size. Simple return for now.
return self.data
def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap:
"""
Returns the PNG icon data as a QPixmap.
"""
if icon_size is None:
icon_size = QtCore.QSize(48, 48)
pixmap = QtGui.QPixmap()
pixmap.loadFromData(self.data, "PNG")
# Scale the pixmap if the requested size is different
if pixmap.size() != icon_size:
pixmap = pixmap.scaled(
icon_size,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation,
)
return pixmap

View File

@@ -0,0 +1,51 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeProbe(ToolBitShape):
name = "Probe"
aliases = ("probe",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Ball diameter"),
"App::PropertyLength",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Length of probe"),
"App::PropertyLength",
),
"ShaftDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shaft diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Probe")

View File

@@ -0,0 +1,55 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeReamer(ToolBitShape):
name = "Reamer"
aliases = ("reamer",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Reamer")

View File

@@ -0,0 +1,67 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeSlittingSaw(ToolBitShape):
name = "SlittingSaw"
aliases = "slittingsaw", "slitting-saw"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"BladeThickness": (
FreeCAD.Qt.translate("ToolBitShape", "Blade thickness"),
"App::PropertyLength",
),
"CapDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Cap diameter"),
"App::PropertyLength",
),
"CapHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cap 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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Slitting Saw")

View File

@@ -0,0 +1,63 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeTap(ToolBitShape):
name = "Tap"
aliases = ("Tap",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeLength": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge length"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Tap diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall length of tap"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"TipAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Tip angle"),
"App::PropertyAngle",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Tap")

View File

@@ -0,0 +1,71 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeThreadMill(ToolBitShape):
name = "ThreadMill"
aliases = "threadmill", "thread-mill"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"Crest": (
FreeCAD.Qt.translate("ToolBitShape", "Crest height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Major diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"NeckDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"),
"App::PropertyLength",
),
"NeckLength": (
FreeCAD.Qt.translate("ToolBitShape", "Neck length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"cuttingAngle": ( # TODO rename to CuttingAngle
FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"),
"App::PropertyAngle",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Thread Mill")

View File

@@ -0,0 +1,67 @@
# -*- 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 FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeVBit(ToolBitShape):
name = "VBit"
aliases = "vbit", "v-bit"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"),
"App::PropertyAngle",
),
"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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"TipDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Tip diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "V-Bit")

View File

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide.QtCore import *
from PySide.QtGui import *
class FlowLayout(QLayout):
widthChanged = Signal(int)
def __init__(self, parent=None, margin=0, spacing=-1, orientation=Qt.Horizontal):
super(FlowLayout, self).__init__(parent)
if parent is not None:
self.setContentsMargins(margin, margin, margin, margin)
self.setSpacing(spacing)
self.itemList = []
self.orientation = orientation
def __del__(self):
item = self.takeAt(0)
while item:
item = self.takeAt(0)
def addItem(self, item):
self.itemList.append(item)
def count(self):
return len(self.itemList)
def itemAt(self, index):
if index >= 0 and index < len(self.itemList):
return self.itemList[index]
return None
def takeAt(self, index):
if index >= 0 and index < len(self.itemList):
return self.itemList.pop(index)
return None
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
if self.orientation == Qt.Horizontal:
return self.doLayoutHorizontal(QRect(0, 0, width, 0), True)
elif self.orientation == Qt.Vertical:
return self.doLayoutVertical(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
if self.orientation == Qt.Horizontal:
self.doLayoutHorizontal(rect, False)
elif self.orientation == Qt.Vertical:
self.doLayoutVertical(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self.itemList:
size = size.expandedTo(item.minimumSize())
margin, _, _, _ = self.getContentsMargins()
size += QSize(2 * margin, 2 * margin)
return size
def doLayoutHorizontal(self, rect, testOnly):
# Get initial coordinates of the drawing region (should be 0, 0)
x = rect.x()
y = rect.y()
lineHeight = 0
i = 0
for item in self.itemList:
wid = item.widget()
# Space X and Y is item spacing horizontally and vertically
spaceX = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
spaceY = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
# Determine the coordinate we want to place the item at
# It should be placed at : initial coordinate of the rect + width of the item + spacing
nextX = x + item.sizeHint().width() + spaceX
# If the calculated nextX is greater than the outer bound...
if nextX - spaceX > rect.right() and lineHeight > 0:
x = rect.x() # Reset X coordinate to origin of drawing region
y = y + lineHeight + spaceY # Move Y coordinate to the next line
nextX = (
x + item.sizeHint().width() + spaceX
) # Recalculate nextX based on the new X coordinate
lineHeight = 0
if not testOnly:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = nextX # Store the next starting X coordinate for next item
lineHeight = max(lineHeight, item.sizeHint().height())
i = i + 1
return y + lineHeight - rect.y()
def doLayoutVertical(self, rect, testOnly):
# Get initial coordinates of the drawing region (should be 0, 0)
x = rect.x()
y = rect.y()
# Initialize column width and line height
columnWidth = 0
lineHeight = 0
# Space between items
spaceX = 0
spaceY = 0
# Variables that will represent the position of the widgets in a 2D Array
i = 0
j = 0
for item in self.itemList:
wid = item.widget()
# Space X and Y is item spacing horizontally and vertically
spaceX = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
spaceY = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
# Determine the coordinate we want to place the item at
# It should be placed at : initial coordinate of the rect + width of the item + spacing
nextY = y + item.sizeHint().height() + spaceY
# If the calculated nextY is greater than the outer bound, move to the next column
if nextY - spaceY > rect.bottom() and columnWidth > 0:
y = rect.y() # Reset y coordinate to origin of drawing region
x = x + columnWidth + spaceX # Move X coordinate to the next column
nextY = (
y + item.sizeHint().height() + spaceY
) # Recalculate nextX based on the new X coordinate
# Reset the column width
columnWidth = 0
# Set indexes of the item for the 2D array
j += 1
i = 0
# Assign 2D array indexes
item.x_index = i
item.y_index = j
# Only call setGeometry (which place the actual widget using coordinates) if testOnly is false
# For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??)
if not testOnly:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
y = nextY # Store the next starting Y coordinate for next item
columnWidth = max(
columnWidth, item.sizeHint().width()
) # Update the width of the column
lineHeight = max(lineHeight, item.sizeHint().height()) # Update the height of the line
i += 1 # Increment i
# Only call setGeometry (which place the actual widget using coordinates) if testOnly is false
# For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??)
if not testOnly:
self.calculateMaxWidth(i)
self.widthChanged.emit(self.totalMaxWidth + spaceX * self.itemsOnWidestRow)
return lineHeight
# Method to calculate the maximum width among each "row" of the flow layout
# This will be useful to let the UI know the total width of the flow layout
def calculateMaxWidth(self, numberOfRows):
# Init variables
self.totalMaxWidth = 0
self.itemsOnWidestRow = 0
# For each "row", calculate the total width by adding the width of each item
# and then update the totalMaxWidth if the calculated width is greater than the current value
# Also update the number of items on the widest row
for i in range(numberOfRows):
rowWidth = 0
itemsOnWidestRow = 0
for item in self.itemList:
# Only compare items from the same row
if item.x_index == i:
rowWidth += item.sizeHint().width()
itemsOnWidestRow += 1
if rowWidth > self.totalMaxWidth:
self.totalMaxWidth = rowWidth
self.itemsOnWidestRow = itemsOnWidestRow

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide import QtGui, QtCore
class ShapeButton(QtGui.QToolButton):
def __init__(self, shape, parent=None):
super(ShapeButton, self).__init__(parent)
self.shape = shape
self.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
self.setText(shape.label)
self.setFixedSize(128, 128)
self.setBaseSize(128, 128)
self.icon_size = QtCore.QSize(71, 100)
self.setIconSize(self.icon_size)
self._update_icon()
def set_text(self, text):
self.label.setText(text)
def _update_icon(self):
icon = self.shape.get_icon()
if icon:
pixmap = icon.get_qpixmap(self.icon_size)
self.setIcon(QtGui.QIcon(pixmap))

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program is distributed in the hope that it will be useful, *
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
# * GNU Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from typing import Optional, cast
import FreeCADGui
from functools import partial
from PySide import QtGui
from ...camassets import cam_assets
from .. import ToolBitShape
from .flowlayout import FlowLayout
from .shapebutton import ShapeButton
class ShapeSelector:
def __init__(self):
self.shape = None
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ShapeSelector.ui")
self.form.buttonBox.clicked.connect(self.form.close)
self.flows = {}
self.update_shapes()
self.form.toolBox.setCurrentIndex(0)
def _add_shape_group(self, toolbox):
if toolbox in self.flows:
return self.flows[toolbox]
flow = FlowLayout(toolbox, orientation=QtGui.Qt.Horizontal)
flow.widthChanged.connect(lambda x: toolbox.setMinimumWidth(x))
self.flows[toolbox] = flow
return flow
def _add_shapes(self, toolbox, shapes):
flow = self._add_shape_group(toolbox)
# Remove all shapes first.
for i in reversed(range(flow.count())):
flow.itemAt(i).widget().setParent(None)
# Add all shapes.
for shape in sorted(shapes, key=lambda x: x.label):
button = ShapeButton(shape)
flow.addWidget(button)
cb = partial(self.on_shape_button_clicked, shape)
button.clicked.connect(cb)
def update_shapes(self):
# Retrieve each shape asset
shapes = set(cam_assets.fetch(asset_type="toolbitshape"))
builtin = set(s for s in shapes if cast(ToolBitShape, s).is_builtin)
self._add_shapes(self.form.standardTools, builtin)
self._add_shapes(self.form.customTools, shapes - builtin)
def on_shape_button_clicked(self, shape):
self.shape = shape
self.form.close()
def show(self) -> Optional[ToolBitShape]:
self.form.exec()
return self.shape

Some files were not shown because too many files have changed in this diff Show More