Merge pull request #22228 from knipknap/fix-custom-attribute-edit
CAM: Various bugfixes for CAM tool management
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2652,6 +2652,7 @@ class OCL_Tool:
|
||||
"drill": "ConeCutter",
|
||||
"engraver": "ConeCutter",
|
||||
"v_bit": "ConeCutter",
|
||||
"v-bit": "ConeCutter",
|
||||
"vbit": "ConeCutter",
|
||||
"chamfer": "None",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -33,6 +33,7 @@ from ...assets.asset import Asset
|
||||
SHAPEMAP = {
|
||||
"ballend": "Ballnose",
|
||||
"endmill": "Cylindrical",
|
||||
"v-bit": "Conical",
|
||||
"vbit": "Conical",
|
||||
"chamfer": "Snubnose",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user