Merge pull request #22228 from knipknap/fix-custom-attribute-edit

CAM: Various bugfixes for CAM tool management
This commit is contained in:
sliptonic
2025-08-04 10:39:25 -05:00
committed by GitHub
22 changed files with 259 additions and 130 deletions

View File

@@ -96,7 +96,7 @@ class TestFCTBSerializer(_BaseToolBitSerializerTestCase):
self.assertEqual(data.get("name"), "Test Tool")
self.assertEqual(data.get("shape"), "endmill.fcstd")
self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm")
self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm", data)
self.assertEqual(data.get("parameter", {}).get("Length"), "15.00 mm", data)
def test_extract_dependencies(self):
"""Test dependency extraction for FCTB."""

View File

@@ -151,13 +151,11 @@ class TestPathToolShapeClasses(PathTestWithAssets):
def test_base_resolve_name(self):
"""Test resolving shape aliases to canonical names."""
self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("Ballend").asset_id, "ballend")
self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("VBit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose")
self.assertEqual(ToolBitShape.resolve_name("slitting-saw").asset_id, "slittingsaw")
self.assertEqual(ToolBitShape.resolve_name("SlittingSaw").asset_id, "slittingsaw")
self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "v-bit")
self.assertEqual(ToolBitShape.resolve_name("vbit").asset_id, "vbit")
self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "torus")
self.assertEqual(ToolBitShape.resolve_name("torus.fcstd").asset_id, "torus")
self.assertEqual(ToolBitShape.resolve_name("SlittingSaw").asset_id, "SlittingSaw")
# Test unknown name - should return the input name
self.assertEqual(ToolBitShape.resolve_name("nonexistent").asset_id, "nonexistent")
self.assertEqual(ToolBitShape.resolve_name("UnknownShape").asset_id, "UnknownShape")
@@ -322,13 +320,13 @@ class TestPathToolShapeClasses(PathTestWithAssets):
def test_toolbitshapethreadmill_defaults(self):
"""Test ToolBitShapeThreadMill default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("threadmill")
shape = self._test_shape_common("thread-mill")
self.assertEqual(shape["Diameter"].Value, 5.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["cuttingAngle"].Value, 60.0)
self.assertEqual(unit(shape["cuttingAngle"]), "°")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("threadmill")
uri = ToolBitShape.resolve_name("thread-mill")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("cuttingAngle"), "Cutting angle")
@@ -348,7 +346,7 @@ class TestPathToolShapeClasses(PathTestWithAssets):
def test_toolbitshapevbit_defaults(self):
"""Test ToolBitShapeVBit default parameters and labels."""
# Provide a dummy filepath for instantiation.
shape = self._test_shape_common("vbit")
shape = self._test_shape_common("v-bit")
self.assertEqual(shape["Diameter"].Value, 10.0)
self.assertEqual(unit(shape["Diameter"]), "mm")
self.assertEqual(shape["CuttingEdgeAngle"].Value, 90.0)
@@ -356,7 +354,7 @@ class TestPathToolShapeClasses(PathTestWithAssets):
self.assertEqual(shape["TipDiameter"].Value, 1.0)
self.assertEqual(unit(shape["TipDiameter"]), "mm")
# Need an instance to get parameter labels, get it from the asset manager
uri = ToolBitShape.resolve_name("vbit")
uri = ToolBitShape.resolve_name("v-bit")
instance = self.assets.get(uri)
self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting edge angle")

View File

@@ -34,6 +34,7 @@ class TestPathToolShapeDoc(unittest.TestCase):
mock_doc.Objects = [mock_obj]
mock_obj.Label = "MockObjectLabel"
mock_obj.Name = "MockObjectName"
mock_obj.getTypeIdOfProperty = MagicMock(return_value="App::PropertyString")
# Ensure mock_doc also has a Name attribute used in tests/code
mock_doc.Name = "Document_Mock" # Used in closeDocument calls
@@ -91,7 +92,13 @@ class TestPathToolShapeDoc(unittest.TestCase):
setattr(mock_obj, "Length", "50 mm")
params = doc.get_object_properties(mock_obj, ["Diameter", "Length"])
# Expecting just the values, not tuples
self.assertEqual(params, {"Diameter": "10 mm", "Length": "50 mm"})
self.assertEqual(
params,
{
"Diameter": ("10 mm", "App::PropertyString"),
"Length": ("50 mm", "App::PropertyString"),
},
)
mock_freecad.Console.PrintWarning.assert_not_called()
def test_doc_get_object_properties_missing(self):
@@ -105,7 +112,13 @@ class TestPathToolShapeDoc(unittest.TestCase):
delattr(mock_obj, "Height")
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
self.assertEqual(
params,
{
"Diameter": ("10 mm", "App::PropertyString"),
"Height": (None, "App::PropertyString"),
},
) # Height is missing
@patch("FreeCAD.openDocument")
@patch("FreeCAD.getDocument")

View File

@@ -462,10 +462,10 @@ SET(Tools_Shape_SRCS
Tools/Shape/slittingsaw.svg
Tools/Shape/tap.fcstd
Tools/Shape/tap.svg
Tools/Shape/threadmill.fcstd
Tools/Shape/threadmill.svg
Tools/Shape/vbit.fcstd
Tools/Shape/vbit.svg
Tools/Shape/thread-mill.fcstd
Tools/Shape/thread-mill.svg
Tools/Shape/v-bit.fcstd
Tools/Shape/v-bit.svg
)
SET(Tests_SRCS

View File

@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>1000</width>
<height>900</height>
<width>750</width>
<height>800</height>
</rect>
</property>
<property name="windowTitle">
@@ -41,14 +41,14 @@
</property>
<property name="sizeIncrement">
<size>
<width>1000</width>
<height>1000</height>
<width>650</width>
<height>850</height>
</size>
</property>
<property name="baseSize">
<size>
<width>1000</width>
<height>1000</height>
<width>650</width>
<height>850</height>
</size>
</property>
<property name="currentIndex">

View File

@@ -2652,6 +2652,7 @@ class OCL_Tool:
"drill": "ConeCutter",
"engraver": "ConeCutter",
"v_bit": "ConeCutter",
"v-bit": "ConeCutter",
"vbit": "ConeCutter",
"chamfer": "None",
}

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

@@ -81,7 +81,11 @@ class FCTLSerializer(AssetSerializer):
tools_list = data_dict.get("tools", [])
for tool_data in tools_list:
tool_no = tool_data["nr"]
try:
tool_no = int(tool_data["nr"])
except ValueError:
Path.Log.warning(f"Invalid tool ID in tool data: {tool_data}. Skipping.")
continue
tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID
tool_uri = AssetUri(f"toolbit://{tool_id}")
tool = dependencies.get(tool_uri)

View File

@@ -23,7 +23,7 @@
import FreeCAD
import Path
import Path.Base.Util as PathUtil
from typing import Dict, List, Any, Optional
from typing import Dict, List, Any, Optional, Tuple
import tempfile
import os
@@ -67,35 +67,43 @@ 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, Any]:
exclude_groups: Optional[List[str]] = None,
) -> Dict[str, Tuple[Any, str]]:
"""
Extract properties matching expected_params from a FreeCAD PropertyBag.
Extract properties from a FreeCAD PropertyBag, including their types.
Issues warnings for missing parameters but does not raise an error.
Args:
obj: The PropertyBag to extract properties from.
expected_params (List[str]): A list of property names to look for.
props (List[str], optional): A list of property names to look for.
If None, all properties in obj.PropertiesList are considered.
group (str, optional): If provided, only properties belonging to this group are extracted.
Returns:
Dict[str, Any]: A dictionary mapping property names to their values.
Values are FreeCAD native types.
Dict[str, Tuple[Any, str]]: A dictionary mapping property names to a tuple
(value, type_id). Values are FreeCAD native types.
If a property is missing, its value will be None.
"""
properties = {}
for name in props or obj.PropertiesList:
if group and not obj.getGroupOfProperty(name) == group:
continue
if exclude_groups and obj.getGroupOfProperty(name) in exclude_groups:
continue
if hasattr(obj, name):
properties[name] = getattr(obj, name)
value = getattr(obj, name)
type_id = obj.getTypeIdOfProperty(name)
properties[name] = value, type_id
else:
# Log a warning if a parameter expected by the shape class is missing
Path.Log.debug(
f"Parameter '{name}' not found on object '{obj.Label}' "
f"({obj.Name}). Default value will be used by the shape class."
)
properties[name] = None # Indicate missing value
properties[name] = None, "App::PropertyString"
return properties

View File

@@ -41,6 +41,13 @@ from ..doc import (
from .icon import ToolBitShapeIcon
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class ToolBitShape(Asset):
"""Abstract base class for tool bit shapes."""
@@ -75,6 +82,9 @@ class ToolBitShape(Asset):
# Stores default parameter values loaded from the FCStd file
self._defaults: Dict[str, Any] = {}
# Stores the FreeCAD property types for each parameter
self._param_types: Dict[str, str] = {}
# Keeps the loaded FreeCAD document content for this instance
self._data: Optional[bytes] = None
@@ -123,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
@@ -218,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)")
@@ -321,6 +331,7 @@ class ToolBitShape(Asset):
Exception: For other potential FreeCAD errors during loading.
"""
assert serializer == DummyAssetSerializer, "ToolBitShape supports only native import"
Path.Log.debug(f"{id}: ToolBitShape.from_bytes called with {len(data)} bytes")
# Open the shape data temporarily to get the Body label and parameters
with ShapeDocFromBytes(data) as temp_doc:
@@ -335,12 +346,25 @@ class ToolBitShape(Asset):
except Exception as e:
Path.Log.debug(f"{id}: Failed to determine shape class from bytes: {e}")
shape_class = ToolBitShape.get_shape_class_from_id("Custom")
if shape_class is None:
# This should ideally not happen due to get_shape_class_from_bytes fallback
# but added for linter satisfaction.
raise ValueError("Shape class could not be determined.")
# Load properties from the temporary document
props_obj = ToolBitShape._find_property_object(temp_doc)
if not props_obj:
raise ValueError("No 'Attributes' PropertyBag object found in document bytes")
loaded_params = get_object_properties(props_obj, group="Shape")
# loaded_raw_params will now be Dict[str, Tuple[Any, str]]
loaded_raw_params = get_object_properties(props_obj, exclude_groups=["", "Base"])
# Separate values and types, and populate _param_types
loaded_params = {}
loaded_param_types = {}
for name, (value, type_id) in loaded_raw_params.items():
loaded_params[name] = value
loaded_param_types[name] = type_id
# For now, we log missing parameters, but do not raise an error.
# This allows for more flexible shape files that may not have all
@@ -360,13 +384,16 @@ 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
# Instantiate the specific subclass with the provided ID
instance = shape_class(id=id)
instance._data = data # Keep the byte content
instance._defaults = loaded_params
instance._param_types = loaded_param_types
Path.Log.debug(f"Params: {instance._params} {instance._defaults}")
instance._params = instance._defaults | instance._params
if dependencies: # dependencies is None = shallow load
@@ -442,6 +469,7 @@ class ToolBitShape(Asset):
"""
if not filepath.exists():
raise FileNotFoundError(f"Shape file not found: {filepath}")
Path.Log.debug(f"{id}: ToolBitShape.from_file called with {filepath}")
try:
data = filepath.read_bytes()
@@ -461,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.
@@ -478,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.
@@ -495,7 +523,7 @@ class ToolBitShape(Asset):
@classmethod
def resolve_name(cls, identifier: str) -> AssetUri:
"""
Resolves an identifier (alias, name, filename, or URI) to a Uri object.
Resolves an identifier (name, filename, or URI) to a Uri object.
"""
# 1. If the input is a url string, return the AssetUri for it.
if AssetUri.is_uri(identifier):
@@ -507,13 +535,7 @@ class ToolBitShape(Asset):
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.
# if one is found, use the class.name.
shape_class = cls.get_subclass_by_name(asset_name.lower())
if shape_class:
asset_name = shape_class.name.lower()
# 4. Construct the Uri using AssetUri.build() and return it
# 3. Construct the Uri using AssetUri.build() and return it
return AssetUri.build(
asset_type="toolbitshape",
asset_id=asset_name,
@@ -550,12 +572,53 @@ 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)
elif prop_type == "App::PropertyInteger":
return int(value)
elif prop_type == "App::PropertyFloat":
return float(value)
elif prop_type == "App::PropertyBool":
if value in ("True", "true", "1"):
return True
elif value in ("False", "false", "0"):
return False
return bool(value)
return str(value)
def get_parameters(self) -> Dict[str, Any]:
"""
Get the dictionary of current parameters and their values.
@@ -563,7 +626,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:
"""
@@ -580,7 +643,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):
"""
@@ -594,18 +657,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):
"""
@@ -664,6 +716,13 @@ class ToolBitShape(Asset):
Retrieves the thumbnail data for the tool bit shape in PNG format.
"""
def get_parameter_type(self, name: str) -> str:
"""
Get the FreeCAD property type string for a given parameter name,
as loaded from the FCStd file.
"""
return self._param_types.get(name, "App::PropertyString")
def get_icon(self) -> Optional[ToolBitShapeIcon]:
"""
Get the associated ToolBitShapeIcon instance. Tries to load one from
@@ -676,18 +735,16 @@ class ToolBitShape(Asset):
return self.icon
# Try to get a matching SVG from the asset manager.
self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.id}.svg")
if self.icon:
return self.icon
self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.name.lower()}.svg")
self.icon = cast(
ToolBitShapeIcon, cam_assets.get_or_none(f"toolbitshapesvg://{self.id}.svg")
)
if self.icon:
return self.icon
# Try to get a matching PNG from the asset manager.
self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.id}.png")
if self.icon:
return self.icon
self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.name.lower()}.png")
self.icon = cast(
ToolBitShapeIcon, cam_assets.get_or_none(f"toolbitshapepng://{self.id}.png")
)
if self.icon:
return self.icon
return None

View File

@@ -20,6 +20,7 @@
# * *
# ***************************************************************************
from PySide import QtGui, QtCore
from .shapewidget import ShapeWidget
class ShapeButton(QtGui.QToolButton):
@@ -27,10 +28,6 @@ class ShapeButton(QtGui.QToolButton):
super(ShapeButton, self).__init__(parent)
self.shape = shape
# Remove default text handling and use a custom layout
# self.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
# self.setText(f"{shape.label}\n{shape.id}")
self.vbox = QtGui.QVBoxLayout(self)
self.vbox.setAlignment(
QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter
@@ -38,8 +35,7 @@ class ShapeButton(QtGui.QToolButton):
self.vbox.setContentsMargins(0, 0, 0, 0)
self.vbox.setSpacing(0)
self.icon_widget = QtGui.QLabel()
self.icon_widget.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
self.icon_widget = ShapeWidget(self.shape, QtCore.QSize(71, 70))
self.label_widget = QtGui.QLabel(shape.label)
self.label_widget.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter)
@@ -60,22 +56,7 @@ class ShapeButton(QtGui.QToolButton):
self.setFixedSize(128, 128)
self.setBaseSize(128, 128)
# Adjust icon size to make space for text.
# Total height is 128. Let's allocate 70 for icon, 25 for label, 25 for ID.
self.icon_size = QtCore.QSize(71, 70)
# self.setIconSize(self.icon_size) # Removed as custom layout handles sizing
self._update_icon()
def set_text(self, text):
# Update the text of the label widget
self.label_widget.setText(text)
def _update_icon(self):
icon = self.shape.get_icon()
if icon:
# Set the pixmap on the icon_widget QLabel
pixmap = icon.get_qpixmap(self.icon_size)
self.icon_widget.setPixmap(pixmap)
else:
self.icon_widget.clear() # Clear pixmap if no icon

View File

@@ -19,25 +19,50 @@
# * USA *
# * *
# ***************************************************************************
from typing import Optional
from PySide import QtGui, QtCore
from ..models.base import ToolBitShape
def _png2qpixmap(data, icon_size):
pixmap = QtGui.QPixmap()
pixmap.loadFromData(data, "PNG")
# Scale the pixmap if the requested size is different
if pixmap.size() != icon_size:
pixmap = pixmap.scaled(
icon_size,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation,
)
return pixmap
class ShapeWidget(QtGui.QWidget):
def __init__(self, shape, parent=None):
def __init__(self, shape: ToolBitShape, icon_size: Optional[QtCore.QSize] = None, parent=None):
super(ShapeWidget, self).__init__(parent)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setAlignment(QtCore.Qt.AlignHCenter)
self.shape = shape
ratio = self.devicePixelRatioF()
self.icon_size = QtCore.QSize(200 * ratio, 235 * ratio)
self.icon_size = icon_size or QtCore.QSize(200, 235)
self.icon_widget = QtGui.QLabel()
self.layout.addWidget(self.icon_widget)
self._update_icon()
def _update_icon(self):
ratio = self.devicePixelRatioF()
size = self.icon_size * ratio
icon = self.shape.get_icon()
if icon:
pixmap = icon.get_qpixmap(self.icon_size)
pixmap = icon.get_qpixmap(size)
self.icon_widget.setPixmap(pixmap)
return
thumbnail = self.shape.get_thumbnail()
if thumbnail:
pixmap = _png2qpixmap(thumbnail, size)
self.icon_widget.setPixmap(pixmap)
return
self.icon_widget.clear() # Clear pixmap if no icon

View File

@@ -34,7 +34,7 @@ class CuttingToolMixin:
obj.addProperty(
"App::PropertyLength",
"Chipload",
"Base",
"Attributes",
QT_TRANSLATE_NOOP("App::Property", "Chipload per tooth"),
)
obj.Chipload = FreeCAD.Units.Quantity("0.0 mm")

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,19 +150,16 @@ 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; the separation between parameters and attributes
# is currently not well defined, so for now we add them to the
# ToolBitShape and the DocumentObject.
# Discussion: https://github.com/FreeCAD/FreeCAD/issues/21722
for attr_name, attr_value in attr.items():
tool_bit_shape.set_parameter(attr_name, attr_value)
if hasattr(toolbit.obj, attr_name):
PathUtil.setProperty(toolbit.obj, attr_name, attr_value)
else:
@@ -167,6 +169,7 @@ class ToolBit(Asset, ABC):
f" '{toolbit.obj.Label}'. Skipping."
)
toolbit._update_tool_properties()
return toolbit
@classmethod
@@ -586,7 +589,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 +657,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)
@@ -674,16 +675,31 @@ class ToolBit(Asset, ABC):
if value is not None and getattr(self.obj, name) != value:
setattr(self.obj, name, value)
# 2. Remove obsolete shape properties
# These are properties currently listed AND in the Shape group,
# but not required by the new shape.
current_shape_prop_names = set(self._get_props("Shape"))
new_shape_param_names = self._tool_bit_shape.schema().keys()
obsolete = current_shape_prop_names - new_shape_param_names
Path.Log.debug(f"Removing obsolete shape properties: {obsolete} from {self.obj.Label}")
# Gracefully skipping the deletion for now;
# in future releases we may handle schema violations more strictly
# self._remove_properties("Shape", obsolete)
# 2. Add additional properties that are part of the shape,
# but not part of the schema.
schema_prop_names = set(self._tool_bit_shape.schema().keys())
for name, value in self._tool_bit_shape.get_parameters().items():
if name in schema_prop_names:
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)
def _update_visual_representation(self):
"""

View File

@@ -33,6 +33,7 @@ from ...assets.asset import Asset
SHAPEMAP = {
"ballend": "Ballnose",
"endmill": "Cylindrical",
"v-bit": "Conical",
"vbit": "Conical",
"chamfer": "Snubnose",
}

View File

@@ -28,6 +28,13 @@ from ...shape import ToolBitShape
from ..models.base import ToolBit
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class FCTBSerializer(AssetSerializer):
for_class = ToolBit
mime_type = "application/x-freecad-toolbit"
@@ -40,7 +47,7 @@ class FCTBSerializer(AssetSerializer):
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
Path.Log.info(f"FCTBSerializer.extract_dependencies: raw data = {data!r}")
Path.Log.debug(f"FCTBSerializer.extract_dependencies: raw data = {data!r}")
data_dict = json.loads(data.decode("utf-8"))
shape = data_dict["shape"]
return [ToolBitShape.resolve_name(shape)]
@@ -70,6 +77,7 @@ class FCTBSerializer(AssetSerializer):
if dependencies is None:
# Shallow load: dependencies are not resolved.
# Delegate to from_dict with shallow=True.
Path.Log.debug(f"FCTBSerializer.deserialize: shallow. id = {id!r}, attrs = {attrs!r}")
return ToolBit.from_dict(attrs, shallow=True)
# Full load: dependencies are resolved.
@@ -93,6 +101,10 @@ class FCTBSerializer(AssetSerializer):
)
# Find the correct ToolBit subclass for the shape
Path.Log.debug(
f"FCTBSerializer.deserialize: shape = {shape!r}, id = {id!r},"
f" params = {shape.get_parameters()}, attrs = {attrs!r}"
)
return ToolBit.from_shape(shape, attrs, id)
@classmethod

View File

@@ -22,9 +22,10 @@
"""Widget for editing a ToolBit object."""
from typing import Optional
from PySide import QtGui, QtCore
import FreeCAD
import FreeCADGui
from PySide import QtGui, QtCore
from ...shape.ui.shapewidget import ShapeWidget
from ...docobject.ui import DocumentObjectEditorWidget
from ..models.base import ToolBit
@@ -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

View File

@@ -25,7 +25,7 @@ import FreeCAD
def to_json(value):
"""Convert a value to JSON format."""
if isinstance(value, FreeCAD.Units.Quantity):
return str(value)
return value.UserString
return value

View File

@@ -5,7 +5,7 @@
viewBox="0 0 210 297"
height="297mm"
width="210mm"
sodipodi:docname="threadmill.svg"
sodipodi:docname="thread-mill.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -5,7 +5,7 @@
viewBox="0 0 210 297"
height="297mm"
width="210mm"
sodipodi:docname="vbit.svg"
sodipodi:docname="v-bit.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB