# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 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 from typing import Dict, List, Any, Optional, Tuple import tempfile import os def find_shape_object(doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]: """ Find the primary object representing the shape in a document. Looks for PartDesign::Body, then Part::Feature. Falls back to the first object if no better candidate is found. Args: doc (FreeCAD.Document): The document to search within. Returns: Optional[FreeCAD.DocumentObject]: The found object or None. """ obj = None # Prioritize Body for o in doc.Objects: if o.isDerivedFrom("PartDesign::Body"): return o # Keep track of the first Part::Feature found as a fallback if obj is None and o.isDerivedFrom("Part::Feature"): obj = o if obj: return obj # Fallback to the very first object if nothing else suitable found return doc.Objects[0] if doc.Objects else None def get_unset_value_for(attribute_type: str): if attribute_type == "App::PropertyLength": return FreeCAD.Units.Quantity(0) elif attribute_type == "App::PropertyString": return "" elif attribute_type == "App::PropertyInteger": return 0 return None def get_object_properties( obj: "FreeCAD.DocumentObject", props: Optional[List[str]] = None, group: Optional[str] = None, exclude_groups: Optional[List[str]] = None, ) -> Dict[str, Tuple[Any, str]]: """ 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. 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, 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): 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, "App::PropertyString" return properties def update_shape_object_properties( obj: "FreeCAD.DocumentObject", parameters: Dict[str, Any] ) -> None: """ Update properties of a FreeCAD PropertyBag based on a dictionary of parameters. Args: obj (FreeCAD.DocumentObject): The PropertyBag to update properties on. parameters (Dict[str, Any]): A dictionary of property names and values. """ for name, value in parameters.items(): if hasattr(obj, name): try: PathUtil.setProperty(obj, name, value) except Exception as e: Path.Log.warning( f"Failed to set property '{name}' on object '{obj.Label}'" f" ({obj.Name}) with value '{value}': {e}" ) else: # This simply means that the Shape File doesn't have this property in it. Path.Log.debug( f"Property '{name}' not found on object '{obj.Label}' ({obj.Name}). Skipping." ) def get_doc_state() -> Any: """ Used to make a "snapshot" of the current state of FreeCAD, to allow for restoring the ActiveDocument and selection state later. """ doc_name = FreeCAD.ActiveDocument.Name if FreeCAD.ActiveDocument else None if FreeCAD.GuiUp: import FreeCADGui selection = FreeCADGui.Selection.getSelection() else: selection = [] return doc_name, selection def restore_doc_state(state): doc_name, selection = state if doc_name: FreeCAD.setActiveDocument(doc_name) if FreeCAD.GuiUp: import FreeCADGui for sel in selection: FreeCADGui.Selection.addSelection(doc_name, sel.Name) class ShapeDocFromBytes: """ Context manager to create and manage a temporary FreeCAD document, loading content from a byte string. """ def __init__(self, content: bytes): self._content = content self._doc = None self._temp_file = None self._old_state = None def __enter__(self) -> "FreeCAD.Document": """Creates a new temporary FreeCAD document or loads cache if provided.""" # Create a temporary file and write the cache content to it with tempfile.NamedTemporaryFile(suffix=".FCStd", delete=False) as tmp_file: tmp_file.write(self._content) self._temp_file = tmp_file.name # When we open a new document, FreeCAD loses the state, of the active # document (i.e. current selection), even if the newly opened document # is a hidden one. # So we need to restore the active document state at the end. self._old_state = get_doc_state() # Open the document from the temporary file # Use a specific name to avoid clashes if multiple docs are open # Open the document from the temporary file self._doc = FreeCAD.openDocument(self._temp_file, hidden=True) if not self._doc: raise RuntimeError(f"Failed to open document from {self._temp_file}") return self._doc def __exit__(self, exc_type, exc_value, traceback) -> None: """Closes the temporary FreeCAD document and cleans up the temp file.""" if self._doc: # Note that .closeDocument() is extremely slow; it takes # almost 400ms per document - much longer than opening! FreeCAD.closeDocument(self._doc.Name) self._doc = None # Restore the original active document restore_doc_state(self._old_state) # Clean up the temporary file if it was created if self._temp_file and os.path.exists(self._temp_file): try: os.remove(self._temp_file) except Exception as e: Path.Log.warning(f"Failed to remove temporary file {self._temp_file}: {e}")