CAM: Improved handling of toolbit shape type inference

This commit is contained in:
Samuel Abels
2025-05-27 12:07:19 +02:00
parent 7e635bed46
commit 857d7269ee
8 changed files with 120 additions and 70 deletions

View File

@@ -156,7 +156,7 @@ class TestPathToolAssetManager(unittest.TestCase):
with self.assertRaises(ValueError) as cm:
manager.get(non_registered_uri)
self.assertIn("No asset class registered for asset type:", str(cm.exception))
self.assertIn("No asset class registered for URI:", str(cm.exception))
def test_delete(self):
# Setup AssetManager with a real LocalStore
@@ -428,7 +428,7 @@ class TestPathToolAssetManager(unittest.TestCase):
# 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))
self.assertIn("No asset class registered for URI:", str(cm.exception))
# Now test fetching with a registered asset type filter
# Setup a new manager and store to avoid state from previous test

View File

@@ -94,7 +94,6 @@ class TestPathToolShapeDoc(unittest.TestCase):
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
@@ -107,16 +106,6 @@ class TestPathToolShapeDoc(unittest.TestCase):
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")

View File

@@ -18,6 +18,7 @@ cam_assets.register_asset(ToolBitShape, DummyAssetSerializer)
cam_assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer)
cam_assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer)
cam_assets.register_asset(Machine, DummyAssetSerializer)
cam_assets.setup()
# For backward compatibility with files saved before the toolbit rename
# This makes the Path.Tool.toolbit.base module available as Path.Tool.Bit.

View File

@@ -46,6 +46,7 @@ from .cache import AssetCache, CacheKey
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
@dataclass
@@ -120,7 +121,7 @@ class AssetManager:
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}")
raise ValueError(f"No asset class registered for URI: {uri}")
# Fetch the requested asset, trying each store in order
raw_data = None

View File

@@ -19,6 +19,8 @@
# * USA *
# * *
# ***************************************************************************
import json
import pathlib
from typing import Optional, Union, Sequence
import Path
from Path import Preferences
@@ -38,6 +40,37 @@ def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: s
asset_manager.add_file("toolbitlibrary", path)
def ensure_toolbits_have_shape_type(asset_manager: AssetManager, store_name: str = "local"):
from .shape import ToolBitShape
toolbit_uris = asset_manager.list_assets(
asset_type="toolbit",
store=store_name,
)
for uri in toolbit_uris:
data = asset_manager.get_raw(uri, store=store_name)
attrs = json.loads(data)
if "shape-type" in attrs:
continue
shape_id = pathlib.Path(
str(attrs.get("shape", ""))
).stem # backward compatibility. used to be a filename
if not shape_id:
Path.Log.error(f"ToolBit {uri} missing shape ID")
continue
shape_class = ToolBitShape.get_shape_class_from_id(shape_id)
if not shape_class:
Path.Log.warning(f"Toolbit {uri} has no shape-type attribute, and failed to infer it")
continue
attrs["shape-type"] = shape_class.name
Path.Log.info(f"Migrating toolbit {uri}: Adding shape-type attribute '{shape_class.name}'")
data = json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8")
asset_manager.add_raw("toolbit", uri.asset_id, data, store=store_name)
def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in bits
@@ -49,6 +82,8 @@ def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: s
for path in builtin_toolbit_path.glob("*.fctb"):
asset_manager.add_file("toolbit", path)
ensure_toolbits_have_shape_type(asset_manager, store_name)
def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
@@ -133,6 +168,14 @@ class CamAssetManager(AssetManager):
self.register_store(user_asset_store)
self.register_store(builtin_asset_store)
def setup(self):
try:
ensure_assets_initialized(cam_assets)
except Exception as e:
Path.Log.error(f"Failed to initialize CAM assets in {user_asset_store._base_dir}: {e}")
else:
Path.Log.debug(f"Using CAM assets in {user_asset_store._base_dir}")
def get(
self,
uri: Union[AssetUri, str],
@@ -161,10 +204,4 @@ class CamAssetManager(AssetManager):
# Set up the CAM asset manager.
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
cam_assets = CamAssetManager()
try:
ensure_assets_initialized(cam_assets)
except Exception as e:
Path.Log.error(f"Failed to initialize CAM assets in {user_asset_store._base_dir}: {e}")
else:
Path.Log.debug(f"Using CAM assets in {user_asset_store._base_dir}")
addToolPreferenceObserver(_on_asset_path_changed)

View File

@@ -127,6 +127,68 @@ class ToolBitShape(Asset):
)
return shape_class
@classmethod
def get_shape_class_from_id(
cls,
shape_id: str,
shape_type: str | None = None,
default: Type["ToolBitShape"] = None,
) -> Type["ToolBitShape"]:
"""
Extracts the shape class from the given ID and shape_type, retrieving it
from the asset manager if necessary.
"""
# Best method: if the shape-type is specified, use that.
if shape_type:
return cls.get_subclass_by_name(shape_type)
# If no shape type is specified, try to find the shape class from the ID.
shape_class = cls.get_subclass_by_name(shape_id)
if shape_class:
return shape_class
# If that also fails, try to load the shape to get the class.
Path.Log.debug(
f'Failed to infer shape type from "{shape_id}", trying to load'
f' the shape "{shape_id}" to determine the class. This may'
" negatively impact performance."
)
shape_asset_uri = ToolBitShape.resolve_name(shape_id)
data = cam_assets.get_raw(shape_asset_uri)
if data:
try:
shape_class = ToolBitShape.get_shape_class_from_bytes(data)
except ValueError:
pass
else:
return shape_class
# Otherwise use the default, if we have one.
shape_types = [c.name for c in ToolBitShape.__subclasses__()]
if default is not None:
Path.Log.warning(
f'Failed to infer shape type from {shape_id}, using "{default.name}".'
f" To fix, name the body in the shape file to one of: {shape_types}"
)
return default
# If all else fails, try to guess the shape class from the ID.
shape_class = ToolBitShape.guess_subclass_from_name(shape_id)
if shape_class:
Path.Log.warning(
f'Failed to infer shape type from "{shape_id}",'
f' guessing "{shape_class.name}".'
f" To fix, name the body in the shape file to one of: {shape_types}"
)
return shape_class
# Default to endmill if nothing else works
Path.Log.warning(
f"Failed to infer shape type from {shape_id}."
f" To fix, name the body in the shape file to one of: {shape_types}"
)
return None
@classmethod
def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]:
"""

View File

@@ -19,7 +19,7 @@
# * USA *
# * *
# ***************************************************************************
from typing import Optional, cast
from typing import Optional
import FreeCADGui
from functools import partial
from PySide import QtGui
@@ -65,11 +65,14 @@ class ShapeSelector:
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)
builtin = cam_assets.fetch(asset_type="toolbitshape", store="builtin")
print(cam_assets.list_assets(asset_type="toolbitshape", store="builtin"))
builtin = {c.id: c for c in builtin}
custom = cam_assets.fetch(asset_type="toolbitshape", store="local")
for shape in custom:
builtin.pop(shape.id, None)
self._add_shapes(self.form.standardTools, builtin.values())
self._add_shapes(self.form.customTools, custom)
def on_shape_button_clicked(self, shape):
self.shape = shape

View File

@@ -89,49 +89,6 @@ class ToolBit(Asset, ABC):
return subclass
raise ValueError(f"No ToolBit subclass found for shape {type(shape).__name__}")
@classmethod
def _get_shape_type(cls, shape_id: str, shape_type: str | None) -> Type[ToolBitShape]:
"""
Extracts the shape class from the attributes dictionary.
"""
# Best method: if the shape-type is specified, use that.
if shape_type:
return ToolBitShape.get_subclass_by_name(shape_type)
# If no shape type is specified, try to find the shape class from the ID.
shape_class = ToolBitShape.get_subclass_by_name(shape_id)
if shape_class:
return shape_class
# If that also fails, try to load the shape to get the class.
Path.Log.debug(
f'Failed to infer shape type from "{shape_id}", trying to load'
f' the shape "{shape_id}" to determine the class. This may'
" negatively impact performance."
)
shape_asset_uri = ToolBitShape.resolve_name(shape_id)
shape = cam_assets.get(shape_asset_uri, depth=0)
if shape:
return shape.__class__
# If all else fails, try to guess the shape class from the ID.
shape_types = [c.name for c in ToolBitShape.__subclasses__()]
shape_class = ToolBitShape.guess_subclass_from_name(shape_id)
if shape_class:
Path.Log.warning(
f'Failed to infer shape type from "{shape_id}",'
f' guessing "{shape_class.name}".'
f" To fix, name the body in the shape file to one of: {shape_types}"
)
return shape_class
# Default to endmill if nothing else works
Path.Log.warning(
f'Failed to infer shape type from {shape_id}, using "endmill".'
f" To fix, name the body in the shape file to one of: {shape_types}"
)
return ToolBitShapeEndmill
@classmethod
def from_dict(cls, attrs: Mapping, shallow: bool = False) -> "ToolBit":
"""
@@ -144,7 +101,7 @@ class ToolBit(Asset, ABC):
if not shape_id:
raise ValueError("ToolBit dictionary is missing 'shape' key")
shape_class = cls._get_shape_type(shape_id, attrs.get("shape-type"))
shape_class = ToolBitShape.get_shape_class_from_id(shape_id, attrs.get("shape-type"))
# Create a ToolBitShape instance.
if not shallow: # Shallow means: skip loading of child assets