diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py index ac23e43556..f54ff3f0e0 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py @@ -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 diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py index 02e024c732..26ac88230f 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py @@ -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") diff --git a/src/Mod/CAM/Path/Tool/__init__.py b/src/Mod/CAM/Path/Tool/__init__.py index 36cb227147..042c771e31 100644 --- a/src/Mod/CAM/Path/Tool/__init__.py +++ b/src/Mod/CAM/Path/Tool/__init__.py @@ -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. diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py index a2cd1f6651..85feaa7c00 100644 --- a/src/Mod/CAM/Path/Tool/assets/manager.py +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -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 diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index 7ee11cd376..65001ee4e4 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -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) diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py index 47219d40c4..9ca8b366b3 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/base.py +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -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"]: """ diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py index 4fa3e1885b..97b823cf74 100644 --- a/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py @@ -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 diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index ca0cf7bfd6..e44f91f895 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -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