From d2f2befaa9e4f6a61939e8ab481e7868847d2b3c Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Thu, 29 May 2025 19:50:51 +0200 Subject: [PATCH] CAM: Handle import of unknown shape types gracefully; allow case insensitive filenames, and allow invalid tool ID data type --- .../CAM/CAMTests/TestPathToolAssetManager.py | 2 +- src/Mod/CAM/CMakeLists.txt | 2 + src/Mod/CAM/Path/Tool/assets/manager.py | 8 +-- .../CAM/Path/Tool/assets/store/filestore.py | 15 ++++- src/Mod/CAM/Path/Tool/camassets.py | 8 ++- src/Mod/CAM/Path/Tool/library/ui/dock.py | 1 - src/Mod/CAM/Path/Tool/shape/__init__.py | 6 +- src/Mod/CAM/Path/Tool/shape/models/base.py | 65 +++++-------------- src/Mod/CAM/Path/Tool/shape/models/custom.py | 43 ++++++++++++ src/Mod/CAM/Path/Tool/toolbit/__init__.py | 6 +- src/Mod/CAM/Path/Tool/toolbit/models/base.py | 14 +++- .../CAM/Path/Tool/toolbit/models/custom.py | 37 +++++++++++ .../CAM/Path/Tool/toolbit/serializers/fctb.py | 1 - src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 2 +- 14 files changed, 143 insertions(+), 67 deletions(-) create mode 100644 src/Mod/CAM/Path/Tool/shape/models/custom.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/custom.py diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py index f54ff3f0e0..3043060f9e 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py @@ -314,7 +314,7 @@ class TestPathToolAssetManager(unittest.TestCase): with self.assertRaises(FileNotFoundError) as cm: manager.get_raw(non_existent_uri, store="non_existent_store") self.assertIn( - "Asset 'type://id/1' not found in stores '['non_existent_store']'.", str(cm.exception) + "Asset 'type://id/1' not found in stores '['non_existent_store']'", str(cm.exception) ) def test_is_empty(self): diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index dcae63ade9..d04a7a2957 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -171,6 +171,7 @@ SET(PathPythonToolsToolBitModels_SRCS Path/Tool/toolbit/models/base.py Path/Tool/toolbit/models/bullnose.py Path/Tool/toolbit/models/chamfer.py + Path/Tool/toolbit/models/custom.py Path/Tool/toolbit/models/dovetail.py Path/Tool/toolbit/models/drill.py Path/Tool/toolbit/models/endmill.py @@ -248,6 +249,7 @@ SET(PathPythonToolsShapeModels_SRCS Path/Tool/shape/models/base.py Path/Tool/shape/models/bullnose.py Path/Tool/shape/models/chamfer.py + Path/Tool/shape/models/custom.py Path/Tool/shape/models/dovetail.py Path/Tool/shape/models/drill.py Path/Tool/shape/models/endmill.py diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py index 85feaa7c00..9095a6ec15 100644 --- a/src/Mod/CAM/Path/Tool/assets/manager.py +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -346,7 +346,7 @@ class AssetManager: 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 stores '{stores_list}'.") + raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'") # 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) @@ -415,7 +415,7 @@ class AssetManager: # Consistent with get(), if the top-level asset is not found, # raise FileNotFoundError. raise FileNotFoundError( - f"Asset '{asset_uri_obj}' not found in stores '{stores_list}' (async path)." + f"Asset '{asset_uri_obj}' not found in stores '{stores_list}' (async path)" ) # return None # Alternative: if Optional[Asset] means asset might not exist @@ -459,7 +459,7 @@ class AssetManager: f"GetRawAsync: Asset {asset_uri_obj} not found in store {current_store_name}" ) continue - raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'.") + raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'") try: return asyncio.run(_fetch_raw_async(stores_list)) @@ -499,7 +499,7 @@ class AssetManager: ) continue - raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'.") + raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in stores '{stores_list}'") def get_bulk( self, diff --git a/src/Mod/CAM/Path/Tool/assets/store/filestore.py b/src/Mod/CAM/Path/Tool/assets/store/filestore.py index a8bb246c5c..9db0f29cb5 100644 --- a/src/Mod/CAM/Path/Tool/assets/store/filestore.py +++ b/src/Mod/CAM/Path/Tool/assets/store/filestore.py @@ -26,6 +26,15 @@ from ..uri import AssetUri from .base import AssetStore +def _resolve_case_insensitive(path: pathlib.Path) -> pathlib.Path: + if path.is_file(): + return path + try: + return next(path.parent.glob(path.name, case_sensitive=False)) + except StopIteration: + return path + + class FileStore(AssetStore): """ Asset store implementation for the local filesystem with optional @@ -114,7 +123,7 @@ class FileStore(AssetStore): regex_parts.append(f"(?P<{keywords[i]}>.*)") pattern_regex_str = "".join(regex_parts) - match_obj = re.fullmatch(pattern_regex_str, path_str_posix) + match_obj = re.fullmatch(pattern_regex_str, path_str_posix, re.I) if not match_obj: raise ValueError( @@ -258,6 +267,7 @@ class FileStore(AssetStore): ) path_to_read = self._uri_to_path(request_uri) + path_to_read = _resolve_case_insensitive(path_to_read) try: with open(path_to_read, mode="rb") as f: return f.read() @@ -307,6 +317,7 @@ class FileStore(AssetStore): ) path = self._uri_to_path(target_uri_for_path) + path = _resolve_case_insensitive(path) if path.is_file(): paths_to_delete.append(path) @@ -380,6 +391,7 @@ class FileStore(AssetStore): params=uri.params, ) asset_path = self._uri_to_path(next_uri) + asset_path = _resolve_case_insensitive(asset_path) # If the file is versioned, then the new version should not yet exist. # Double check to be sure. @@ -472,6 +484,7 @@ class FileStore(AssetStore): params=uri.params, ) path_to_asset = self._uri_to_path(path_check_uri) + path_to_asset = _resolve_case_insensitive(path_to_asset) if path_to_asset.is_file(): return [path_check_uri] # Returns URI with version "1" and original params return [] diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index da6c019d29..38f1c6f2a6 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -61,9 +61,13 @@ def ensure_toolbits_have_shape_type(asset_manager: AssetManager, store_name: str Path.Log.error(f"ToolBit {uri} missing shape ID") continue - shape_class = ToolBitShape.get_shape_class_from_id(shape_id) + try: + shape_class = ToolBitShape.get_shape_class_from_id(shape_id) + except Exception as e: + Path.Log.error(f"Failed to load toolbit {uri}: {e}. Skipping") + continue if not shape_class: - Path.Log.warning(f"Toolbit {uri} has no shape-type attribute, and failed to infer it") + Path.Log.error(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}'") diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py index f610de45d5..2667af7a64 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/dock.py +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -31,7 +31,6 @@ 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 diff --git a/src/Mod/CAM/Path/Tool/shape/__init__.py b/src/Mod/CAM/Path/Tool/shape/__init__.py index d70cd5de4a..70aa088931 100644 --- a/src/Mod/CAM/Path/Tool/shape/__init__.py +++ b/src/Mod/CAM/Path/Tool/shape/__init__.py @@ -4,7 +4,9 @@ # Import the base class and all concrete shape classes from .models.base import ToolBitShape from .models.ballend import ToolBitShapeBallend +from .models.bullnose import ToolBitShapeBullnose from .models.chamfer import ToolBitShapeChamfer +from .models.custom import ToolBitShapeCustom from .models.dovetail import ToolBitShapeDovetail from .models.drill import ToolBitShapeDrill from .models.endmill import ToolBitShapeEndmill @@ -14,7 +16,6 @@ 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, @@ -29,7 +30,9 @@ TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__() __all__ = [ "ToolBitShape", "ToolBitShapeBallend", + "ToolBitShapeBullnose", "ToolBitShapeChamfer", + "ToolBitShapeCustom", "ToolBitShapeDovetail", "ToolBitShapeDrill", "ToolBitShapeEndmill", @@ -39,7 +42,6 @@ __all__ = [ "ToolBitShapeSlittingSaw", "ToolBitShapeTap", "ToolBitShapeThreadMill", - "ToolBitShapeBullnose", "ToolBitShapeVBit", "TOOL_BIT_SHAPE_NAMES", "ToolBitShapeIcon", diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py index 339c4f43b0..de7c42c210 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/base.py +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -123,9 +123,7 @@ class ToolBitShape(Asset): shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()} shape_class = shape_classes.get(body_obj.Label) if not shape_class: - raise ValueError( - f"No ToolBitShape subclass found matching Body label '{body_obj.Label}' in {doc}" - ) + return ToolBitShape.get_subclass_by_id("Custom") return shape_class @classmethod @@ -133,18 +131,18 @@ class ToolBitShape(Asset): cls, shape_id: str, shape_type: str | None = None, - default: Type["ToolBitShape"] = None, - ) -> Type["ToolBitShape"]: + default: Type["ToolBitShape"] | None = None, + ) -> Optional[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) + 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 = cls.get_subclass_by_name(shape_id) + shape_class = ToolBitShape.get_subclass_by_name(shape_id) if shape_class: return shape_class @@ -155,8 +153,11 @@ class ToolBitShape(Asset): " negatively impact performance." ) shape_asset_uri = ToolBitShape.resolve_name(shape_id) - data = cam_assets.get_raw(shape_asset_uri) - if data: + try: + data = cam_assets.get_raw(shape_asset_uri) + except FileNotFoundError: + pass # rely on fallback below + else: try: shape_class = ToolBitShape.get_shape_class_from_bytes(data) except ValueError: @@ -167,28 +168,14 @@ class ToolBitShape(Asset): # 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( + Path.Log.debug( 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 + # Default to Custom if nothing else works + return ToolBitShape.get_subclass_by_name("Custom") @classmethod def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]: @@ -231,11 +218,7 @@ class ToolBitShape(Asset): # 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 + return shape_class or ToolBitShape.get_subclass_by_id("Custom") except zipfile.BadZipFile: raise ValueError("Invalid FCStd file data (not a valid zip archive)") @@ -351,23 +334,7 @@ class ToolBitShape(Asset): shape_class = ToolBitShape.get_shape_class_from_bytes(data) except Exception as e: Path.Log.debug(f"{id}: Failed to determine shape class from bytes: {e}") - shape_types = [c.name for c in ToolBitShape.__subclasses__()] - shape_class = ToolBitShape.guess_subclass_from_name(id) - if shape_class: - Path.Log.warning( - f"{id}: failed to infer shape type from bytes," - f' guessing "{shape_class.name}". To fix, name' - f" the body in the shape file to one of: {shape_types}" - ) - else: - Path.Log.warning( - f"{id}: failed to infer shape type from bytes," - f' using "endmill". To fix, name' - f" the body in the shape file to one of: {shape_types}" - ) - from .endmill import ToolBitShapeEndmill - - shape_class = ToolBitShapeEndmill + shape_class = ToolBitShape.get_shape_class_from_id("Custom") # Load properties from the temporary document props_obj = ToolBitShape._find_property_object(temp_doc) @@ -537,7 +504,7 @@ class ToolBitShape(Asset): # 2. If the input is a filename (with extension), assume the asset # name is the base name. asset_name = identifier - if identifier.endswith(".fcstd"): + if pathlib.Path(identifier).suffix.lower() == ".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. diff --git a/src/Mod/CAM/Path/Tool/shape/models/custom.py b/src/Mod/CAM/Path/Tool/shape/models/custom.py new file mode 100644 index 0000000000..bdd1c12e8b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/custom.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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 ToolBitShapeCustom(ToolBitShape): + """ + Schema-less tool, used as a fallback when loading legacy tools + for backward compatibility + """ + + name: str = "Custom" + aliases = ("custom",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return {} + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Unknown custom shape") diff --git a/src/Mod/CAM/Path/Tool/toolbit/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/__init__.py index 5b1def1d18..9ae1e129ad 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/__init__.py +++ b/src/Mod/CAM/Path/Tool/toolbit/__init__.py @@ -4,7 +4,9 @@ # Import the base class and all concrete shape classes from .models.base import ToolBit from .models.ballend import ToolBitBallend +from .models.bullnose import ToolBitBullnose from .models.chamfer import ToolBitChamfer +from .models.custom import ToolBitCustom from .models.dovetail import ToolBitDovetail from .models.drill import ToolBitDrill from .models.endmill import ToolBitEndmill @@ -14,14 +16,15 @@ from .models.reamer import ToolBitReamer from .models.slittingsaw import ToolBitSlittingSaw from .models.tap import ToolBitTap from .models.threadmill import ToolBitThreadMill -from .models.bullnose import ToolBitBullnose from .models.vbit import ToolBitVBit # Define __all__ for explicit public interface __all__ = [ "ToolBit", "ToolBitBallend", + "ToolBitBullnose", "ToolBitChamfer", + "ToolBitCustom", "ToolBitDovetail", "ToolBitDrill", "ToolBitEndmill", @@ -31,6 +34,5 @@ __all__ = [ "ToolBitSlittingSaw", "ToolBitTap", "ToolBitThreadMill", - "ToolBitBullnose", "ToolBitVBit", ] diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index d28b202f9b..ec81cf3ccc 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -35,7 +35,7 @@ from PySide.QtCore import QT_TRANSLATE_NOOP from Path.Base.Generator import toolchange from ...assets import Asset from ...camassets import cam_assets -from ...shape import ToolBitShape, ToolBitShapeEndmill, ToolBitShapeIcon +from ...shape import ToolBitShape, ToolBitShapeCustom, ToolBitShapeIcon from ..docobject import DetachedDocumentObject from ..util import to_json, format_value @@ -101,7 +101,15 @@ class ToolBit(Asset, ABC): if not shape_id: raise ValueError("ToolBit dictionary is missing 'shape' key") - shape_class = ToolBitShape.get_shape_class_from_id(shape_id, attrs.get("shape-type")) + # Try to find the shape type. Default to Unknown if necessary. + shape_type = attrs.get("shape-type") + shape_class = ToolBitShape.get_shape_class_from_id(shape_id, shape_type) + if not shape_class: + Path.Log.debug( + f"Failed to find usable shape for ID '{shape_id}'" + f" (shape type {shape_type}). Falling back to 'Unknown'" + ) + shape_class = ToolBitShapeCustom # Create a ToolBitShape instance. if not shallow: # Shallow means: skip loading of child assets @@ -230,7 +238,7 @@ class ToolBit(Asset, ABC): ) names = [c.name for c in ToolBitShape.__subclasses__()] self.obj.ShapeType = names - self.obj.ShapeType = ToolBitShapeEndmill.name + self.obj.ShapeType = ToolBitShapeCustom.name if not hasattr(self.obj, "BitBody"): self.obj.addProperty( "App::PropertyLink", diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/custom.py b/src/Mod/CAM/Path/Tool/toolbit/models/custom.py new file mode 100644 index 0000000000..b32004a796 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/custom.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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 +from ...shape import ToolBitShapeCustom +from .base import ToolBit + + +class ToolBitCustom(ToolBit): + SHAPE_CLASS = ToolBitShapeCustom + + def __init__(self, shape: ToolBitShapeCustom, id: str | None = None): + Path.Log.track(f"ToolBitCustom __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + + @property + def summary(self) -> str: + return FreeCAD.Qt.translate("CAM", "Unknown custom toolbit type") diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py index f6f38af6b6..67d28eff74 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py @@ -26,7 +26,6 @@ import FreeCAD from ...assets import Asset, AssetUri, AssetSerializer from ...shape import ToolBitShape from ..models.base import ToolBit -from Path.Base import Util as PathUtil class FCTBSerializer(AssetSerializer): diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 0828143414..6a6fc6face 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -143,7 +143,7 @@ class ToolBitBrowserWidget(QtGui.QWidget): self._all_assets.sort(key=lambda x: x.label.lower()) elif self._sort_key == "tool_no" and self._tool_no_factory: self._all_assets.sort( - key=lambda x: (self._tool_no_factory(x) or 0) if self._tool_no_factory else 0 + key=lambda x: (int(self._tool_no_factory(x)) or 0) if self._tool_no_factory else 0 ) def _trigger_fetch(self):