CAM: Fix: ToolBitShapeCustom properties not editable if they had no type specified in the schema

This commit is contained in:
Samuel Abels
2025-06-29 21:34:06 +02:00
parent f7038b9d64
commit 1f4799ecf4
5 changed files with 81 additions and 39 deletions

View File

@@ -68,6 +68,18 @@ class DetachedDocumentObject:
# Initialize Quantity properties with a default value
self._properties[name] = FreeCAD.Units.Quantity(0.0)
def removeProperty(self, name: str) -> None:
"""Removes a property from the detached object."""
if name in self._properties:
if name in self.PropertiesList:
self.PropertiesList.remove(name)
del self._properties[name]
self._property_groups.pop(name, None)
self._property_types.pop(name, None)
self._property_docs.pop(name, None)
self._editor_modes.pop(name, None)
self._property_enums.pop(name, None)
def getPropertyByName(self, name: str) -> Any:
"""Mimics FreeCAD DocumentObject.getPropertyByName."""
return self._properties.get(name)

View File

@@ -67,7 +67,7 @@ def get_unset_value_for(attribute_type: str):
def get_object_properties(
obj: "FreeCAD.DocumentObject",
props: List[str] | None = None,
props: Optional[List[str]] = None,
group: Optional[str] = None,
) -> Dict[str, Tuple[Any, str]]:
"""

View File

@@ -133,15 +133,15 @@ 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:
return ToolBitShape.get_subclass_by_id("Custom")
return ToolBitShape.get_subclass_by_name("Custom")
return shape_class
@classmethod
def get_shape_class_from_id(
cls,
shape_id: str,
shape_type: str | None = None,
default: Type["ToolBitShape"] | None = None,
shape_type: Optional[str] = None,
default: Optional[Type["ToolBitShape"]] = None,
) -> Optional[Type["ToolBitShape"]]:
"""
Extracts the shape class from the given ID and shape_type, retrieving it
@@ -228,7 +228,7 @@ class ToolBitShape(Asset):
# Find the correct subclass based on the body label
shape_class = cls.get_subclass_by_name(body_label)
return shape_class or ToolBitShape.get_subclass_by_id("Custom")
return shape_class or ToolBitShape.get_subclass_by_name("Custom")
except zipfile.BadZipFile:
raise ValueError("Invalid FCStd file data (not a valid zip archive)")
@@ -384,7 +384,7 @@ class ToolBitShape(Asset):
f" In future releases, these shapes will not load!"
)
for param in missing_params:
param_type = shape_class.get_parameter_property_type(param)
param_type = shape_class.get_schema_property_type(param)
loaded_params[param] = get_unset_value_for(param_type)
loaded_param_types[param] = param_type # Store the type for missing params
@@ -489,7 +489,7 @@ class ToolBitShape(Asset):
@classmethod
def get_subclass_by_name(
cls, name: str, default: Type["ToolBitShape"] | None = None
cls, name: str, default: Optional[Type["ToolBitShape"]] = None
) -> Optional[Type["ToolBitShape"]]:
"""
Retrieves a ToolBitShape class by its name or alias.
@@ -506,7 +506,7 @@ class ToolBitShape(Asset):
@classmethod
def guess_subclass_from_name(
cls, name: str, default: Type["ToolBitShape"] | None = None
cls, name: str, default: Optional[Type["ToolBitShape"]] = None
) -> Optional[Type["ToolBitShape"]]:
"""
Retrieves a ToolBitShape class by its name or alias.
@@ -578,12 +578,43 @@ class ToolBitShape(Asset):
return entry[0] if entry else str_param_name
@classmethod
def get_parameter_property_type(cls, param_name: str) -> str:
def get_schema_property_type(cls, param_name: str) -> str:
"""
Get the FreeCAD property type string for a given parameter name.
"""
return cls.schema()[param_name][1]
def get_parameter_property_type(
self, param_name: str, default: str = "App::PropertyString"
) -> str:
"""
Get the FreeCAD property type string for a given parameter name.
"""
try:
return self.get_schema_property_type(param_name)
except KeyError:
try:
return self._param_types[param_name]
except KeyError:
return default
def _normalize_value(self, name: str, value: Any) -> Any:
"""
Normalize the value for a parameter based on its expected type.
This is a placeholder for any type-specific normalization logic.
Args:
name (str): The name of the parameter.
value: The value to normalize.
Returns:
The normalized value, potentially converted to a FreeCAD.Units.Quantity.
"""
prop_type = self.get_parameter_property_type(name)
if prop_type in ("App::PropertyDistance", "App::PropertyLength", "App::PropertyAngle"):
return FreeCAD.Units.Quantity(value)
return value
def get_parameters(self) -> Dict[str, Any]:
"""
Get the dictionary of current parameters and their values.
@@ -591,7 +622,7 @@ class ToolBitShape(Asset):
Returns:
dict: A dictionary mapping parameter names to their values.
"""
return self._params
return {name: self._normalize_value(name, value) for name, value in self._params.items()}
def get_parameter(self, name: str) -> Any:
"""
@@ -608,7 +639,7 @@ class ToolBitShape(Asset):
"""
if name not in self.schema():
raise KeyError(f"Shape '{self.name}' has no parameter '{name}'")
return self._params[name]
return self._normalize_value(name, self._params[name])
def set_parameter(self, name: str, value: Any):
"""
@@ -622,18 +653,7 @@ class ToolBitShape(Asset):
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
self._params[name] = self._normalize_value(name, value)
def set_parameters(self, **kwargs):
"""

View File

@@ -56,7 +56,7 @@ class ToolBit(Asset, ABC):
asset_type: str = "toolbit"
SHAPE_CLASS: Type[ToolBitShape] # Abstract class attribute
def __init__(self, tool_bit_shape: ToolBitShape, id: str | None = None):
def __init__(self, tool_bit_shape: ToolBitShape, id: Optional[str] = None):
Path.Log.track("ToolBit __init__ called")
self.id = id if id is not None else str(uuid.uuid4())
self.obj = DetachedDocumentObject()
@@ -136,7 +136,12 @@ class ToolBit(Asset, ABC):
return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id"))
@classmethod
def from_shape(cls, tool_bit_shape: ToolBitShape, attrs: Mapping, id: str | None) -> "ToolBit":
def from_shape(
cls,
tool_bit_shape: ToolBitShape,
attrs: Mapping,
id: Optional[str] = None,
) -> "ToolBit":
selected_toolbit_subclass = cls._find_subclass_for_shape(tool_bit_shape)
toolbit = selected_toolbit_subclass(tool_bit_shape, id=id)
toolbit.label = attrs.get("name") or tool_bit_shape.label
@@ -145,18 +150,11 @@ class ToolBit(Asset, ABC):
params = attrs.get("parameter", {})
attr = attrs.get("attribute", {})
# Update parameters; these are stored in the document model object.
# Update parameters.
for param_name, param_value in params.items():
if hasattr(toolbit.obj, param_name):
PathUtil.setProperty(toolbit.obj, param_name, param_value)
else:
Path.Log.debug(
f" ToolBit {id} Parameter '{param_name}' not found on"
f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})"
f" '{toolbit.obj.Label}'. Skipping."
)
tool_bit_shape.set_parameter(param_name, param_value)
# Update parameters; these are stored in the document model object.
# Update attributes; these are stored in the document model object.
for attr_name, attr_value in attr.items():
if hasattr(toolbit.obj, attr_name):
PathUtil.setProperty(toolbit.obj, attr_name, attr_value)
@@ -167,6 +165,7 @@ class ToolBit(Asset, ABC):
f" '{toolbit.obj.Label}'. Skipping."
)
toolbit._update_tool_properties()
return toolbit
@classmethod
@@ -586,7 +585,7 @@ class ToolBit(Asset, ABC):
def get_property(self, name: str):
return self.obj.getPropertyByName(name)
def get_property_str(self, name: str, default: str | None = None) -> str | None:
def get_property_str(self, name: str, default: Optional[str] = None) -> Optional[str]:
value = self.get_property(name)
return format_value(value) if value else default
@@ -654,8 +653,6 @@ class ToolBit(Asset, ABC):
)
continue
docstring = self._tool_bit_shape.get_parameter_label(name)
# Add new property
if not hasattr(self.obj, name):
self.obj.addProperty(prop_type, name, "Shape", docstring)
@@ -682,9 +679,21 @@ class ToolBit(Asset, ABC):
continue
prop_type = self._tool_bit_shape.get_parameter_type(name)
docstring = QT_TRANSLATE_NOOP("App::Property", f"Custom property from shape: {name}")
# Skip existing properties if they have a different type
if hasattr(self.obj, name) and self.obj.getTypeIdOfProperty(name) != prop_type:
Path.Log.debug(
f"Skipping existing property '{name}' due to type mismatch."
f" has type {self.obj.getTypeIdOfProperty(name)}, expected {prop_type}"
)
continue
# Add the property if it does not exist
if not hasattr(self.obj, name):
self.obj.addProperty(prop_type, name, PropertyGroupShape, docstring)
Path.Log.debug(f"Added custom shape property: {name} ({prop_type})")
# Set the property value
PathUtil.setProperty(self.obj, name, value)
self.obj.setEditorMode(name, 0)

View File

@@ -22,6 +22,7 @@
"""Widget for editing a ToolBit object."""
from typing import Optional
from PySide import QtGui, QtCore
import FreeCAD
import FreeCADGui
@@ -38,7 +39,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget):
# Signal emitted when the toolbit data has been modified
toolBitChanged = QtCore.Signal()
def __init__(self, toolbit: ToolBit | None = None, parent=None, icon: bool = True):
def __init__(self, toolbit: Optional[ToolBit] = None, parent=None, icon: bool = True):
super().__init__(parent)
self._toolbit = None
self._show_shape = icon