# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2019 sliptonic * # * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * # * as published by the Free Software Foundation; either version 2 of * # * the License, or (at your option) any later version. * # * for detail see the LICENCE text file. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU Library General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with this program; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** import FreeCAD import Path import Path.Base.Util as PathUtil import json import uuid import pathlib from abc import ABC from itertools import chain from lazy_loader.lazy_loader import LazyLoader from typing import Any, List, Optional, Tuple, Type, Union, Mapping, cast from PySide.QtCore import QT_TRANSLATE_NOOP from Path.Base.Generator import toolchange from ...docobject import DetachedDocumentObject from ...assets.asset import Asset from ...camassets import cam_assets from ...shape import ToolBitShape, ToolBitShapeCustom, ToolBitShapeIcon from ..util import to_json, format_value from ..migration import ParameterAccessor, migrate_parameters ToolBitView = LazyLoader("Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view") class ToolBitRecomputeObserver: """Document observer that triggers queued visual updates after recompute completes.""" def __init__(self, toolbit_proxy): self.toolbit_proxy = toolbit_proxy def slotRecomputedDocument(self, doc): """Called when document recompute is finished.""" # Check if the toolbit object is still valid try: obj_doc = self.toolbit_proxy.obj.Document except ReferenceError: # Object has been deleted or does not exist, nothing to do return # Only process updates for the correct document if doc != obj_doc: return # Process any queued visual updates if self.toolbit_proxy and hasattr(self.toolbit_proxy, "_process_queued_visual_update"): Path.Log.debug("Document recompute finished, processing queued visual update") self.toolbit_proxy._process_queued_visual_update() PropertyGroupShape = "Shape" 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 ToolBit(Asset, ABC): asset_type: str = "toolbit" SHAPE_CLASS: Type[ToolBitShape] # Abstract class attribute 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() self.obj.Proxy = self self._tool_bit_shape: ToolBitShape = tool_bit_shape self._in_update = False self._create_base_properties() self.obj.ToolBitID = self.get_id() self.obj.ShapeID = tool_bit_shape.get_id() self.obj.ShapeType = tool_bit_shape.name self.obj.Label = tool_bit_shape.label or f"New {tool_bit_shape.name}" # Initialize properties self._update_tool_properties() def __eq__(self, other): """Compare ToolBit objects based on their unique ID.""" if not isinstance(other, ToolBit): return False return self.id == other.id @staticmethod def _find_subclass_for_shape(shape: ToolBitShape) -> Type["ToolBit"]: """ Finds the appropriate ToolBit subclass for a given ToolBitShape instance. """ for subclass in ToolBit.__subclasses__(): if isinstance(shape, subclass.SHAPE_CLASS): return subclass raise ValueError(f"No ToolBit subclass found for shape {type(shape).__name__}") @classmethod def from_dict(cls, attrs: Mapping, shallow: bool = False) -> "ToolBit": """ Creates and populates a ToolBit instance from a dictionary. """ # Find the shape ID. shape_id = pathlib.Path( str(attrs.get("shape", "")) ).stem # backward compatibility. used to be a filename if not shape_id: raise ValueError("ToolBit dictionary is missing 'shape' key") # Try to find the shape type. Default to Unknown if necessary. if "shape" in attrs and "shape-type" not in attrs: attrs["shape-type"] = attrs["shape"] shape_type = attrs.get("shape-type") shape_class = ToolBitShape.get_shape_class_from_id(shape_id, shape_type) if not shape_class: Path.Log.debug( f"Failed to find usable shape for ID '{shape_id}'" f" (shape type {shape_type}). Falling back to 'Unknown'" ) shape_class = ToolBitShapeCustom # Create a ToolBitShape instance. if not shallow: # Shallow means: skip loading of child assets shape_asset_uri = ToolBitShape.resolve_name(shape_id) try: tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_asset_uri)) except FileNotFoundError: Path.Log.debug(f"ToolBit.from_dict: Shape asset {shape_asset_uri} not found.") # Rely on the fallback below else: return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) # Ending up here means we either could not load the shape asset, # or we are in shallow mode and do not want to load it. # Create a shape instance from scratch as a "placeholder". params = attrs.get("parameter", {}) tool_bit_shape = shape_class(shape_id, **params) Path.Log.debug( f"ToolBit.from_dict: created shape instance {tool_bit_shape.name}" f" from {shape_id}. Uri: {tool_bit_shape.get_uri()}" ) # Now that we have a shape, create the toolbit instance. return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) @classmethod 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 # Get params and attributes. params = attrs.get("parameter", {}) attr = attrs.get("attribute", {}) # Filter parameters if method exists if ( hasattr(tool_bit_shape.__class__, "filter_parameters") and callable(getattr(tool_bit_shape.__class__, "filter_parameters")) and isinstance(params, dict) ): params = tool_bit_shape.__class__.filter_parameters(params) # Update parameters. for param_name, param_value in params.items(): tool_bit_shape.set_parameter(param_name, param_value) if hasattr(toolbit.obj, param_name): PathUtil.setProperty(toolbit.obj, param_name, param_value) # 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: Path.Log.debug( f"ToolBit {id} Attribute '{attr_name}' not found on" f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})" f" '{toolbit.obj.Label}'. Skipping." ) toolbit._update_tool_properties() return toolbit @classmethod def from_shape_id(cls, shape_id: str, label: Optional[str] = None) -> "ToolBit": """ Creates and populates a ToolBit instance from a shape ID. """ attrs = {"shape": shape_id, "name": label} return cls.from_dict(attrs) @classmethod def from_file(cls, path: Union[str, pathlib.Path]) -> "ToolBit": """ Creates and populates a ToolBit instance from a .fctb file. """ path = pathlib.Path(path) with path.open("r") as fp: attrs_map = json.load(fp) return cls.from_dict(attrs_map) @property def label(self) -> str: return self.obj.Label @label.setter def label(self, label: str): self.obj.Label = label def get_shape_name(self) -> str: """Returns the shape name of the tool bit.""" return self._tool_bit_shape.name def set_shape_name(self, name: str): """Sets the shape name of the tool bit.""" self._tool_bit_shape.name = name @property def summary(self) -> str: """ To be overridden by subclasses to provide a better summary including parameter values. Used as "subtitle" for the tool in the UI. Example: "3.2 mm endmill, 4-flute, 8 mm cutting edge" """ return self.get_shape_name() def _create_base_properties(self): # Create the properties in the Base group. if not hasattr(self.obj, "ShapeID"): self.obj.addProperty( "App::PropertyString", "ShapeID", "Base", QT_TRANSLATE_NOOP( "App::Property", "The unique ID of the tool shape (.fcstd)", ), ) if not hasattr(self.obj, "ShapeType"): self.obj.addProperty( "App::PropertyEnumeration", "ShapeType", "Base", QT_TRANSLATE_NOOP( "App::Property", "The tool shape type", ), ) names = [c.name for c in ToolBitShape.__subclasses__()] self.obj.ShapeType = names self.obj.ShapeType = ToolBitShapeCustom.name if not hasattr(self.obj, "BitBody"): self.obj.addProperty( "App::PropertyLink", "BitBody", "Base", QT_TRANSLATE_NOOP( "App::Property", "The parametrized body representing the tool bit", ), ) if not hasattr(self.obj, "ToolBitID"): self.obj.addProperty( "App::PropertyString", "ToolBitID", "Base", QT_TRANSLATE_NOOP("App::Property", "The unique ID of the toolbit"), ) # 0 = read/write, 1 = read only, 2 = hide self.obj.setEditorMode("ShapeID", 1) self.obj.setEditorMode("ShapeType", 1) self.obj.setEditorMode("ToolBitID", 1) self.obj.setEditorMode("BitBody", 2) self.obj.setEditorMode("Shape", 2) # Create the ToolBit properties that are shared by all tool bits if not hasattr(self.obj, "Units"): self.obj.addProperty( "App::PropertyEnumeration", "Units", "Attributes", QT_TRANSLATE_NOOP("App::Property", "Measurement units for the tool bit"), ) self.obj.Units = ["Metric", "Imperial"] self.obj.Units = "Metric" # Default value if not hasattr(self.obj, "SpindleDirection"): self.obj.addProperty( "App::PropertyEnumeration", "SpindleDirection", "Attributes", QT_TRANSLATE_NOOP("App::Property", "Direction of spindle rotation"), ) self.obj.SpindleDirection = ["Forward", "Reverse", "None"] self.obj.SpindleDirection = "Forward" # Default value if not hasattr(self.obj, "Material"): self.obj.addProperty( "App::PropertyEnumeration", "Material", "Attributes", QT_TRANSLATE_NOOP("App::Property", "Tool material"), ) self.obj.Material = ["HSS", "Carbide"] self.obj.Material = "HSS" # Default value def get_id(self) -> str: """Returns the unique ID of the tool bit.""" return self.id def set_id(self, id: str = None): self.id = id if id is not None else str(uuid.uuid4()) def _promote_toolbit(self): """ Updates the toolbit properties for backward compatibility. Ensure obj.ShapeID and obj.ToolBitID are set, handling legacy cases. Also promotes embedded toolbits to correct shape type if needed. """ Path.Log.track(f"Promoting tool bit {self.obj.Label}") # Ensure ShapeID is set (handling legacy BitShape/ShapeName) name = None if hasattr(self.obj, "ShapeID") and self.obj.ShapeID: name = self.obj.ShapeID elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile: name = pathlib.Path(self.obj.ShapeFile).stem elif hasattr(self.obj, "BitShape") and self.obj.BitShape: name = pathlib.Path(self.obj.BitShape).stem elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName: name = pathlib.Path(self.obj.ShapeName).stem if name is None: raise ValueError("ToolBit is missing a shape ID") uri = ToolBitShape.resolve_name(name) if uri is None: raise ValueError(f"Failed to identify ID of ToolBit from '{name}'") self.obj.ShapeID = uri.asset_id # Ensure ShapeType is set thetype = None if hasattr(self.obj, "ShapeType") and self.obj.ShapeType: thetype = self.obj.ShapeType elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile: thetype = pathlib.Path(self.obj.ShapeFile).stem elif hasattr(self.obj, "BitShape") and self.obj.BitShape: thetype = pathlib.Path(self.obj.BitShape).stem elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName: thetype = pathlib.Path(self.obj.ShapeName).stem if thetype is None: raise ValueError("ToolBit is missing a shape type") shape_class = ToolBitShape.get_subclass_by_name(thetype) if shape_class is None: raise ValueError(f"Failed to identify shape of ToolBit from '{thetype}'") self.obj.ShapeType = shape_class.name # Promote embedded toolbits to correct shape type if still Custom if self.obj.ShapeType == "Custom": shape_id = getattr(self.obj, "ShapeID", None) if shape_id: shape_class = ToolBitShape.get_subclass_by_name(shape_id) if shape_class and shape_class.name != "Custom": self.obj.ShapeType = shape_class.name self._tool_bit_shape = shape_class(shape_id) Path.Log.info( f"Promoted embedded toolbit '{self.obj.Label}' to shape '{shape_class.name}' via ShapeID" ) # Ensure ToolBitID is set if hasattr(self.obj, "File"): self.id = pathlib.Path(self.obj.File).stem self.obj.ToolBitID = self.id Path.Log.debug(f"Set ToolBitID to {self.obj.ToolBitID}") # Update SpindleDirection: # Old tools may still have "CCW", "CW", "Off", "None". # New tools use "None", "Forward", "Reverse". normalized_direction = old_direction = self.obj.SpindleDirection if isinstance(old_direction, str): lower_direction = old_direction.lower() if lower_direction in ("none", "off"): normalized_direction = "None" elif lower_direction in ("cw", "forward"): normalized_direction = "Forward" elif lower_direction in ("ccw", "reverse"): normalized_direction = "Reverse" self.obj.SpindleDirection = ["Forward", "Reverse", "None"] self.obj.SpindleDirection = normalized_direction if old_direction != normalized_direction: Path.Log.info( f"Promoted tool bit {self.obj.Label}: SpindleDirection from {old_direction} to {self.obj.SpindleDirection}" ) # Drop legacy properties. legacy = "ShapeFile", "File", "BitShape", "ShapeName" for name in legacy: if hasattr(self.obj, name): value = getattr(self.obj, name) self.obj.removeProperty(name) Path.Log.debug(f"Removed obsolete property '{name}' ('{value}').") # Get the schema properties from the current shape shape_cls = ToolBitShape.get_subclass_by_name(self.obj.ShapeType) if not shape_cls: raise ValueError(f"Failed to find shape class named '{self.obj.ShapeType}'") shape_schema_props = shape_cls.schema().keys() # Move properties that are part of the shape schema to the "Shape" group for prop_name in self.obj.PropertiesList: if ( self.obj.getGroupOfProperty(prop_name) == PropertyGroupShape or prop_name not in shape_schema_props ): continue try: Path.Log.debug(f"Moving property '{prop_name}' to group '{PropertyGroupShape}'") # Get property details before removing prop_type = self.obj.getTypeIdOfProperty(prop_name) prop_doc = self.obj.getDocumentationOfProperty(prop_name) prop_value = self.obj.getPropertyByName(prop_name) # Remove the property self.obj.removeProperty(prop_name) # Add the property back to the Shape group self.obj.addProperty(prop_type, prop_name, PropertyGroupShape, prop_doc) self._in_update = True # Prevent onChanged from running PathUtil.setProperty(self.obj, prop_name, prop_value) Path.Log.info(f"Moved property '{prop_name}' to group '{PropertyGroupShape}'") except Exception as e: Path.Log.error( f"Failed to move property '{prop_name}' to group '{PropertyGroupShape}': {e}" ) raise finally: self._in_update = False def onDocumentRestored(self, obj): Path.Log.track(obj.Label) # Assign self.obj to the restored object self.obj = obj self.obj.Proxy = self if not hasattr(self, "id"): self.id = str(uuid.uuid4()) Path.Log.debug( f"Assigned new id {self.id} for ToolBit {obj.Label} during document restore" ) # Our constructor previously created the base properties in the # DetachedDocumentObject, which was now replaced. # So here we need to ensure to set them up in the new (real) DocumentObject # as well. self._create_base_properties() self._promote_toolbit() # Get the shape instance based on the ShapeType. We try two approaches # to find the shape and shape class: # 1. If the asset with the given type exists, use that. # 2. Otherwise create a new empty instance shape_uri = ToolBitShape.resolve_name(self.obj.ShapeType) try: # Best case: we directly find the shape file in our assets. self._tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_uri)) except FileNotFoundError: # Otherwise, try to at least identify the type of the shape. shape_class = ToolBitShape.get_subclass_by_name(shape_uri.asset_id) if not shape_class: raise ValueError( "Failed to identify class of ToolBitShape from name " f"'{self.obj.ShapeType}' (asset id {shape_uri.asset_id})" ) self._tool_bit_shape = shape_class(shape_uri.asset_id) # If BitBody exists and is in a different document after document restore, # it means a shallow copy occurred. We need to re-initialize the visual # representation and properties to ensure a deep copy of the BitBody # and its properties. # Only re-initialize properties from shape if not restoring from file if self.obj.BitBody and self.obj.BitBody.Document != self.obj.Document: Path.Log.debug( f"onDocumentRestored: Re-initializing BitBody for {self.obj.Label} after copy" ) self._update_visual_representation() # Ensure the correct ViewProvider is attached during document restore, # because some legacy fcstd files may still have references to old view # providers. if hasattr(self.obj, "ViewObject") and self.obj.ViewObject: if hasattr(self.obj.ViewObject, "Proxy") and not isinstance( self.obj.ViewObject.Proxy, ToolBitView.ViewProvider ): Path.Log.debug(f"onDocumentRestored: Attaching ViewProvider for {self.obj.Label}") ToolBitView.ViewProvider(self.obj.ViewObject, "ToolBit") # Migrate legacy parameters using unified accessor migrate_parameters(ParameterAccessor(obj)) # Filter parameters if method exists (removes FlatRadius from obj) filter_func = getattr(self._tool_bit_shape.__class__, "filter_parameters", None) if callable(filter_func): # Only filter if FlatRadius is present if "FlatRadius" in self.obj.PropertiesList: try: self.obj.removeProperty("FlatRadius") Path.Log.info(f"Filtered out FlatRadius for {self.obj.Label}") except Exception as e: Path.Log.error(f"Failed to remove FlatRadius for {self.obj.Label}: {e}") # Copy properties from the restored object to the ToolBitShape. for name, item in self._tool_bit_shape.schema().items(): if name in self.obj.PropertiesList: value = self.obj.getPropertyByName(name) self._tool_bit_shape.set_parameter(name, value) # Ensure property state is correct after restore. self._update_tool_properties() def attach_to_doc( self, doc: FreeCAD.Document, label: Optional[str] = None ) -> FreeCAD.DocumentObject: """ Creates a new FreeCAD DocumentObject in the given document and attaches this ToolBit instance to it. """ label = label or self.label or self._tool_bit_shape.label tool_doc_obj = doc.addObject("Part::FeaturePython", label) self.attach_to_obj(tool_doc_obj, label=label) return tool_doc_obj def attach_to_obj(self, tool_doc_obj: FreeCAD.DocumentObject, label: Optional[str] = None): """ Attaches the ToolBit instance to an existing FreeCAD DocumentObject. Transfers properties from the internal DetachedDocumentObject to the tool_doc_obj and updates the visual representation. """ if not isinstance(self.obj, DetachedDocumentObject): Path.Log.warning( f"ToolBit {self.obj.Label} is already attached to a " "DocumentObject. Skipping attach_to_obj." ) return Path.Log.track(f"Attaching ToolBit to {tool_doc_obj.Label}") temp_obj = self.obj self.obj = tool_doc_obj self.obj.Proxy = self if FreeCAD.GuiUp: ToolBitView.ViewProvider(self.obj.ViewObject, "ToolBit") self._create_base_properties() # Transfer property values from the detached object to the real object self._suppress_visual_update = True temp_obj.copy_to(self.obj) # Ensure label is set self.obj.Label = label or self.label or self._tool_bit_shape.label # Update the visual representation now that it's attached self._update_tool_properties() self._suppress_visual_update = False self._update_visual_representation() def onChanged(self, obj, prop): Path.Log.track(obj.Label, prop) # Avoid acting during document restore or internal updates if "Restore" in obj.State: return if getattr(self, "_suppress_visual_update", False): return if hasattr(self, "_in_update") and self._in_update: Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.") return # We only care about updates that affect the Shape if obj.getGroupOfProperty(prop) != PropertyGroupShape: return self._in_update = True try: new_value = obj.getPropertyByName(prop) Path.Log.debug( f"Shape parameter '{prop}' changed to {new_value}. " f"Queuing visual representation update." ) self._tool_bit_shape.set_parameter(prop, new_value) self._queue_visual_update() finally: self._in_update = False def onDelete(self, obj, arg2=None): Path.Log.track(obj.Label) # Clean up any pending observer if hasattr(self, "_recompute_observer"): FreeCAD.removeDocumentObserver(self._recompute_observer) del self._recompute_observer self._removeBitBody() obj.Document.removeObject(obj.Name) def _removeBitBody(self): if self.obj.BitBody: self.obj.BitBody.removeObjectsFromDocument() self.obj.Document.removeObject(self.obj.BitBody.Name) self.obj.BitBody = None def _setupProperty(self, prop, orig): # extract property parameters and values so it can be copied val = orig.getPropertyByName(prop) typ = orig.getTypeIdOfProperty(prop) grp = orig.getGroupOfProperty(prop) dsc = orig.getDocumentationOfProperty(prop) self.obj.addProperty(typ, prop, grp, dsc) if "App::PropertyEnumeration" == typ: setattr(self.obj, prop, orig.getEnumerationsOfProperty(prop)) self.obj.setEditorMode(prop, 1) PathUtil.setProperty(self.obj, prop, val) def _get_props(self, group: Optional[Union[str, Tuple[str, ...]]] = None) -> List[str]: """ Returns a list of property names from the given group(s) for the object. Returns all groups if the group argument is None. """ props_in_group = [] # Use PropertiesList to get all property names for prop in self.obj.PropertiesList: prop_group = self.obj.getGroupOfProperty(prop) if group is None: props_in_group.append(prop) elif isinstance(group, str) and prop_group == group: props_in_group.append(prop) elif isinstance(group, tuple) and prop_group in group: props_in_group.append(prop) return props_in_group def get_property(self, name: str): return self.obj.getPropertyByName(name) def get_property_str( self, name: str, default: str | None = None, precision: int | None = None ) -> str | None: value = self.get_property(name) return format_value(value, precision=precision, units=self.obj.Units) if value else default def set_property(self, name: str, value: Any): return self.obj.setPropertyByName(name, value) def get_property_label_from_name(self, name: str): return self.obj.getPropertyByName def get_icon(self) -> Optional[ToolBitShapeIcon]: """ Retrieves the thumbnail data for the tool bit shape, as taken from the explicit SVG or PNG, if the shape has one. """ if self._tool_bit_shape: return self._tool_bit_shape.get_icon() return None def get_thumbnail(self) -> Optional[bytes]: """ Retrieves the thumbnail data for the tool bit shape in PNG format, as embedded in the shape file. Fallback to the icon from get_icon() (converted to PNG) """ if not self._tool_bit_shape: return None png_data = self._tool_bit_shape.get_thumbnail() if png_data: return png_data icon = self.get_icon() if icon: return icon.get_png() return None def _remove_properties(self, group, prop_names): for name in prop_names: if hasattr(self.obj, name): if self.obj.getGroupOfProperty(name) == group: try: self.obj.removeProperty(name) Path.Log.debug(f"Removed property: {group}.{name}") except Exception as e: Path.Log.error(f"Failed removing property '{group}.{name}': {e}") else: Path.Log.warning(f"'{group}.{name}' failed to remove property, not found") def _update_tool_properties(self): """ Initializes or updates the tool bit's properties based on the current _tool_bit_shape. Adds/updates shape parameters, removes obsolete shape parameters, and updates the edit state of them. Does not handle updating the visual representation. """ Path.Log.track(self.obj.Label) # 1. Add/Update properties for the new shape for name, item in self._tool_bit_shape.schema().items(): docstring = item[0] prop_type = item[1] if not prop_type: Path.Log.error( f"No property type for parameter '{name}' in shape " f"'{self._tool_bit_shape.name}'. Skipping." ) continue # Add new property if not hasattr(self.obj, name): self.obj.addProperty(prop_type, name, "Shape", docstring) Path.Log.debug(f"Added new shape property: {name}") # Ensure editor mode is correct self.obj.setEditorMode(name, 0) try: value = self._tool_bit_shape.get_parameter(name) except KeyError: continue # Retain existing property value. # Conditional to avoid unnecessary migration warning when called # from onDocumentRestored. if value is not None and getattr(self.obj, name) != value: PathUtil.setProperty(self.obj, name, value) # 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 if value is not None and getattr(self.obj, name) != value: PathUtil.setProperty(self.obj, name, value) self.obj.setEditorMode(name, 0) # 3. Ensure Units property exists and is set if not hasattr(self.obj, "Units"): Path.Log.debug("Adding Units property") self.obj.addProperty( "App::PropertyEnumeration", "Units", "Attributes", QT_TRANSLATE_NOOP("App::Property", "Measurement units for the tool bit"), ) self.obj.Units = ["Metric", "Imperial"] self.obj.Units = "Metric" # Default value units_value = self._tool_bit_shape.get_parameters().get("Units") if units_value in ("Metric", "Imperial") and self.obj.Units != units_value: PathUtil.setProperty(self.obj, "Units", units_value) # 4. Ensure SpindleDirection property exists and is set # Maybe this could be done with a global schema or added to each # shape schema? if not hasattr(self.obj, "SpindleDirection"): self.obj.addProperty( "App::PropertyEnumeration", "SpindleDirection", "Attributes", QT_TRANSLATE_NOOP("App::Property", "Direction of spindle rotation"), ) self.obj.SpindleDirection = ["Forward", "Reverse", "None"] self.obj.SpindleDirection = "Forward" # Default value spindle_value = self._tool_bit_shape.get_parameters().get("SpindleDirection") if ( spindle_value in ("Forward", "Reverse", "None") and self.obj.SpindleDirection != spindle_value ): # self.obj.SpindleDirection = spindle_value PathUtil.setProperty(self.obj, "SpindleDirection", spindle_value) # 5. Ensure Material property exists and is set if not hasattr(self.obj, "Material"): self.obj.addProperty( "App::PropertyEnumeration", "Material", "Attributes", QT_TRANSLATE_NOOP("App::Property", "Tool material"), ) self.obj.Material = ["HSS", "Carbide"] self.obj.Material = "HSS" # Default value material_value = self._tool_bit_shape.get_parameters().get("Material") if material_value in ("HSS", "Carbide") and self.obj.Material != material_value: PathUtil.setProperty(self.obj, "Material", material_value) def _queue_visual_update(self): """Queue a visual update to be processed after document recompute is complete.""" if not hasattr(self, "_visual_update_queued"): self._visual_update_queued = False if not self._visual_update_queued: self._visual_update_queued = True Path.Log.debug(f"Queuing visual update for {self.obj.Label}") # Set up a document observer to process the update after recompute self._setup_recompute_observer() def _setup_recompute_observer(self): """Set up a document observer to process queued visual updates after recompute.""" if not hasattr(self, "_recompute_observer"): Path.Log.debug(f"Setting up recompute observer for {self.obj.Label}") self._recompute_observer = ToolBitRecomputeObserver(self) FreeCAD.addDocumentObserver(self._recompute_observer) def _process_queued_visual_update(self): """Process the queued visual update.""" if hasattr(self, "_visual_update_queued") and self._visual_update_queued: self._visual_update_queued = False Path.Log.debug(f"Processing queued visual update for {self.obj.Label}") self._update_visual_representation() # Clean up the observer if hasattr(self, "_recompute_observer"): FreeCAD.removeDocumentObserver(self._recompute_observer) del self._recompute_observer def _update_visual_representation(self): """ Updates the visual representation of the tool bit based on the current _tool_bit_shape. Creates or updates the BitBody and copies its shape to the main object. """ if isinstance(self.obj, DetachedDocumentObject): return Path.Log.track(self.obj.Label) # Remove existing BitBody if it exists self._removeBitBody() try: # Use the shape's make_body method to create the visual representation body = self._tool_bit_shape.make_body(self.obj.Document) if not body: Path.Log.error( f"Failed to create visual representation for shape " f"'{self._tool_bit_shape.name}'" ) return # Assign the created object to BitBody and copy its shape self.obj.BitBody = body self.obj.Shape = self.obj.BitBody.Shape # Copy the evaluated Solid shape # Hide the visual representation and remove from tree if hasattr(self.obj.BitBody, "ViewObject") and self.obj.BitBody.ViewObject: self.obj.BitBody.ViewObject.Visibility = False self.obj.BitBody.ViewObject.ShowInTree = False except Exception as e: Path.Log.error( f"Failed to create visual representation using make_body for shape" f" '{self._tool_bit_shape.name}': {e}" ) raise def to_dict(self): """ Returns a dictionary representation of the tool bit. Returns: A dictionary with tool bit properties, JSON-serializable. """ Path.Log.track(self.obj.Label) attrs = {} attrs["version"] = 2 attrs["id"] = self.id attrs["name"] = self.obj.Label attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd" attrs["shape-type"] = self._tool_bit_shape.name attrs["parameter"] = {} attrs["attribute"] = {} # Store all shape parameter names and attribute names param_names = self._tool_bit_shape.get_parameters() attr_props = self._get_props("Attributes") property_names = list(chain(param_names, attr_props)) for name in property_names: value = getattr(self.obj, name, None) if value is None or isinstance(value, FreeCAD.DocumentObject): Path.Log.warning( f"Excluding property '{name}' from serialization " f"(type {type(value).__name__ if value is not None else 'None'}, value {value})" ) try: serialized_value = to_json(value) attrs["parameter"][name] = serialized_value except (TypeError, ValueError) as e: Path.Log.warning( f"Excluding property '{name}' from serialization " f"(type {type(value).__name__}, value {value}): {e}" ) Path.Log.debug(f"to_dict output for {self.obj.Label}: {attrs}") return attrs def __getstate__(self): """ Prepare the ToolBit for pickling by excluding non-picklable attributes. Returns: A dictionary with picklable and JSON-serializable state. """ Path.Log.track("ToolBit.__getstate__") state = { "id": getattr(self, "id", str(uuid.uuid4())), # Fallback to new UUID "_in_update": getattr(self, "_in_update", False), # Fallback to False "_obj_data": self.to_dict(), } if not getattr(self, "_tool_bit_shape", None): return state # Store minimal shape data to reconstruct _tool_bit_shape state["_shape_data"] = { "id": self._tool_bit_shape.get_id(), "name": self._tool_bit_shape.name, "parameters": { name: to_json(getattr(self.obj, name, None)) for name in self._tool_bit_shape.get_parameters() if not isinstance(getattr(self.obj, name, None), FreeCAD.DocumentObject) }, } return state def get_spindle_direction(self) -> toolchange.SpindleDirection: """ Returns the spindle direction for this toolbit. The direction is determined by the ToolBit's properties and safety rules: - Returns SpindleDirection.OFF if the tool cannot rotate (e.g., a probe). - Returns SpindleDirection.CW for clockwise or 'forward' spindle direction. - Returns SpindleDirection.CCW for counterclockwise or any other value. - Defaults to SpindleDirection.OFF if not specified. """ # To be safe, never allow non-rotatable shapes (such as probes) to rotate. if not self.can_rotate(): return toolchange.SpindleDirection.OFF # Otherwise use power from defined attribute. if hasattr(self.obj, "SpindleDirection") and self.obj.SpindleDirection is not None: if self.obj.SpindleDirection.lower() in ("cw", "forward"): return toolchange.SpindleDirection.CW else: return toolchange.SpindleDirection.CCW # Default to keeping spindle off. return toolchange.SpindleDirection.OFF def can_rotate(self) -> bool: """ Whether the spindle is allowed to rotate for this kind of ToolBit. This mostly exists as a safe-hold for probes, which should never rotate. """ return True