diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py
index 773641c33e..b2d36cb7ca 100644
--- a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py
+++ b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py
@@ -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."""
diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py
index aa34fd4f66..dc09140ef7 100644
--- a/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py
+++ b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py
@@ -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")
diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py
index 26ac88230f..e73674b388 100644
--- a/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py
+++ b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py
@@ -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")
diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt
index 6ba07c39af..b2e1e55ad7 100644
--- a/src/Mod/CAM/CMakeLists.txt
+++ b/src/Mod/CAM/CMakeLists.txt
@@ -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
diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui
index ebb60688a5..8153ae64fe 100644
--- a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui
+++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui
@@ -6,8 +6,8 @@
0
0
- 1000
- 900
+ 750
+ 800
@@ -41,14 +41,14 @@
- 1000
- 1000
+ 650
+ 850
- 1000
- 1000
+ 650
+ 850
diff --git a/src/Mod/CAM/Path/Op/SurfaceSupport.py b/src/Mod/CAM/Path/Op/SurfaceSupport.py
index 9962dcdf02..55bce6c161 100644
--- a/src/Mod/CAM/Path/Op/SurfaceSupport.py
+++ b/src/Mod/CAM/Path/Op/SurfaceSupport.py
@@ -2652,6 +2652,7 @@ class OCL_Tool:
"drill": "ConeCutter",
"engraver": "ConeCutter",
"v_bit": "ConeCutter",
+ "v-bit": "ConeCutter",
"vbit": "ConeCutter",
"chamfer": "None",
}
diff --git a/src/Mod/CAM/Path/Tool/docobject/models/docobject.py b/src/Mod/CAM/Path/Tool/docobject/models/docobject.py
index 1218fa84fa..8fb3cdd4be 100644
--- a/src/Mod/CAM/Path/Tool/docobject/models/docobject.py
+++ b/src/Mod/CAM/Path/Tool/docobject/models/docobject.py
@@ -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)
diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py
index c4de58afc8..8b6737bddb 100644
--- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py
+++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py
@@ -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)
diff --git a/src/Mod/CAM/Path/Tool/shape/doc.py b/src/Mod/CAM/Path/Tool/shape/doc.py
index 28387dc3c2..d1c5c463df 100644
--- a/src/Mod/CAM/Path/Tool/shape/doc.py
+++ b/src/Mod/CAM/Path/Tool/shape/doc.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py
index de7c42c210..312109318b 100644
--- a/src/Mod/CAM/Path/Tool/shape/models/base.py
+++ b/src/Mod/CAM/Path/Tool/shape/models/base.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py b/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py
index 1b5979ded2..78e9591c0f 100644
--- a/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py
+++ b/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py
index 2bea5969f2..bcbc505178 100644
--- a/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py
+++ b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py
index 73eb9cec37..086b91a7aa 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py
@@ -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")
diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py
index 6a8d85bd98..3f3c7155a4 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py
@@ -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):
"""
diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py
index aea9787a19..f5c8f10d15 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py
@@ -33,6 +33,7 @@ from ...assets.asset import Asset
SHAPEMAP = {
"ballend": "Ballnose",
"endmill": "Cylindrical",
+ "v-bit": "Conical",
"vbit": "Conical",
"chamfer": "Snubnose",
}
diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
index 67d28eff74..0722d770a2 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py
index 09c310e5c1..f9c7c22847 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py
index 3a81493ee2..03976184de 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/util.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/util.py
@@ -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
diff --git a/src/Mod/CAM/Tools/Shape/threadmill.fcstd b/src/Mod/CAM/Tools/Shape/thread-mill.fcstd
similarity index 100%
rename from src/Mod/CAM/Tools/Shape/threadmill.fcstd
rename to src/Mod/CAM/Tools/Shape/thread-mill.fcstd
diff --git a/src/Mod/CAM/Tools/Shape/threadmill.svg b/src/Mod/CAM/Tools/Shape/thread-mill.svg
similarity index 99%
rename from src/Mod/CAM/Tools/Shape/threadmill.svg
rename to src/Mod/CAM/Tools/Shape/thread-mill.svg
index aa811b634c..904d1f3aa5 100644
--- a/src/Mod/CAM/Tools/Shape/threadmill.svg
+++ b/src/Mod/CAM/Tools/Shape/thread-mill.svg
@@ -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"
diff --git a/src/Mod/CAM/Tools/Shape/vbit.fcstd b/src/Mod/CAM/Tools/Shape/v-bit.fcstd
similarity index 100%
rename from src/Mod/CAM/Tools/Shape/vbit.fcstd
rename to src/Mod/CAM/Tools/Shape/v-bit.fcstd
diff --git a/src/Mod/CAM/Tools/Shape/vbit.svg b/src/Mod/CAM/Tools/Shape/v-bit.svg
similarity index 99%
rename from src/Mod/CAM/Tools/Shape/vbit.svg
rename to src/Mod/CAM/Tools/Shape/v-bit.svg
index 975022b615..e6e327b813 100644
--- a/src/Mod/CAM/Tools/Shape/vbit.svg
+++ b/src/Mod/CAM/Tools/Shape/v-bit.svg
@@ -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"