Download src/Mod/CAM/Path/Tool/toolbit/models/base.py from AbdulElahGwaith/FreeCAD: direct link, hf CLI and curl.
- Browser
- Download file 42.8 kB
-
https://huggingface.co/AbdulElahGwaith/FreeCAD/resolve/main/src/Mod/CAM/Path/Tool/toolbit/models/base.py
- Command line
-
hf download hf://AbdulElahGwaith/FreeCAD/src/Mod/CAM/Path/Tool/toolbit/models/base.py
-
curl -L -o base.py https://huggingface.co/AbdulElahGwaith/FreeCAD/resolve/main/src/Mod/CAM/Path/Tool/toolbit/models/base.py
42.8 kB
| # SPDX-License-Identifier: LGPL-2.1-or-later | |
| # *************************************************************************** | |
| # * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> * | |
| # * 2025 Samuel Abels <knipknap@gmail.com> * | |
| # * * | |
| # * 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 | |
| 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__}") | |
| 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")) | |
| 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 | |
| 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) | |
| 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) | |
| def label(self) -> str: | |
| return self.obj.Label | |
| 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 | |
| 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 | |