CAM: Handle import of unknown shape types gracefully; allow case insensitive filenames, and allow invalid tool ID data type

This commit is contained in:
Samuel Abels
2025-05-29 19:50:51 +02:00
parent 0ddf511fbf
commit d2f2befaa9
14 changed files with 143 additions and 67 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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,

View File

@@ -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 []

View File

@@ -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}'")

View File

@@ -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

View File

@@ -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",

View File

@@ -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.

View File

@@ -0,0 +1,43 @@
# -*- 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 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")

View File

@@ -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",
]

View File

@@ -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",

View File

@@ -0,0 +1,37 @@
# -*- 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
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")

View File

@@ -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):

View File

@@ -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):