File size: 8,535 Bytes
985c397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# SPDX-License-Identifier: LGPL-2.1-or-later

# ***************************************************************************
# *   Copyright (c) 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
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}")