File size: 9,124 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
# ***************************************************************************
# *   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
from typing import Any, Dict, List, Optional


class DetachedDocumentObject:
    """
    A lightweight class mimicking the property API of a FreeCAD DocumentObject.

    This class is used by ToolBit instances when they are not associated
    with a real FreeCAD DocumentObject, allowing properties to be stored
    and accessed in a detached state.
    """

    def __init__(self, label: str = "DetachedObject"):
        self.Label: str = label
        self.Name: str = label.replace(" ", "_")
        self.PropertiesList: List[str] = []
        self._properties: Dict[str, Any] = {}
        self._property_groups: Dict[str, Optional[str]] = {}
        self._property_types: Dict[str, Optional[str]] = {}
        self._property_docs: Dict[str, Optional[str]] = {}
        self._editor_modes: Dict[str, int] = {}
        self._property_enums: Dict[str, List[str]] = {}

    def addProperty(
        self,
        thetype: Optional[str],
        name: str,
        group: Optional[str],
        doc: Optional[str],
    ) -> None:
        """Mimics FreeCAD DocumentObject.addProperty."""
        if name not in self._properties:
            self.PropertiesList.append(name)
            self._properties[name] = None
            self._property_groups[name] = group
            self._property_types[name] = thetype
            self._property_docs[name] = doc
            if thetype in [
                "App::PropertyQuantity",
                "App::PropertyLength",
                "App::PropertyArea",
                "App::PropertyVolume",
                "App::PropertyAngle",
            ]:
                # 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)

    def setPropertyByName(self, name: str, value: Any) -> None:
        """Mimics FreeCAD DocumentObject.setPropertyByName."""
        self._properties[name] = value

    def __setattr__(self, name: str, value: Any) -> None:
        """
        Intercept attribute assignment. This is done to behave like
        FreeCAD's DocumentObject, which may have any property assigned,
        pre-defined or not.
        Without this, code linters report an error when trying to set
        a property that is not defined in the class.

        Handles assignment of enumeration choices (lists/tuples) and
        converts string representations of Quantity types to Quantity objects.
        """
        if name in ("PropertiesList", "Label", "Name") or name.startswith("_"):
            super().__setattr__(name, value)
            return

        # Handle assignment of enumeration choices (list/tuple)
        prop_type = self._property_types.get(name)
        if prop_type == "App::PropertyEnumeration" and isinstance(value, (list, tuple)):
            self._property_enums[name] = list(value)
            assert len(value) > 0, f"Enum property '{name}' must have at least one entry"
            self._properties.setdefault(name, value[0])
            return

        # Attempt to convert string values to Quantity if the property type is Quantity
        elif prop_type in [
            "App::PropertyQuantity",
            "App::PropertyLength",
            "App::PropertyArea",
            "App::PropertyVolume",
            "App::PropertyAngle",
        ]:
            value = FreeCAD.Units.Quantity(value)

        # Store the (potentially converted) value
        self._properties[name] = value
        Path.Log.debug(
            f"DetachedDocumentObject: Set property '{name}' to "
            f"value {value} (type: {type(value)})"
        )

    def __getattr__(self, name: str) -> Any:
        """Intercept attribute access."""
        if name in self._properties:
            return self._properties[name]
        # Default behaviour: raise AttributeError
        raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")

    def setEditorMode(self, name: str, mode: int) -> None:
        """Stores editor mode settings in detached state."""
        self._editor_modes[name] = mode

    def getEditorMode(self, name: str) -> int:
        """Stores editor mode settings in detached state."""
        return self._editor_modes.get(name, 0) or 0

    def getGroupOfProperty(self, name: str) -> Optional[str]:
        """Returns the stored group for a property in detached state."""
        return self._property_groups.get(name)

    def getTypeIdOfProperty(self, name: str) -> Optional[str]:
        """Returns the stored type string for a property in detached state."""
        return self._property_types.get(name)

    def getEnumerationsOfProperty(self, name: str) -> List[str]:
        """Returns the stored enumeration list for a property."""
        return self._property_enums.get(name, [])

    @property
    def ExpressionEngine(self) -> List[Any]:
        """Mimics the ExpressionEngine attribute of a real DocumentObject."""
        return []  # Return an empty list to satisfy iteration

    def copy_to(self, obj: FreeCAD.DocumentObject) -> None:
        """
        Copies properties from this detached object to a real DocumentObject.
        """
        for prop_name in self.PropertiesList:
            if not hasattr(self, prop_name):
                continue

            prop_value = self.getPropertyByName(prop_name)
            prop_type = self._property_types.get(prop_name)
            prop_group = self._property_groups.get(prop_name)
            prop_doc = self._property_docs.get(prop_name, "")
            prop_editor_mode = self._editor_modes.get(prop_name)

            # If the property doesn't exist in the target object, add it
            if not hasattr(obj, prop_name):
                # For enums, addProperty expects "App::PropertyEnumeration"
                # The list of choices is set afterwards.
                obj.addProperty(prop_type, prop_name, prop_group, prop_doc)

            # If it's an enumeration, set its list of choices first
            if prop_type == "App::PropertyEnumeration":
                enum_choices = self._property_enums.get(prop_name)
                assert enum_choices is not None
                setattr(obj, prop_name, enum_choices)

            # Set the property value and editor mode
            try:
                if prop_type == "App::PropertyEnumeration":
                    first_choice = self._property_enums[prop_name][0]
                    setattr(obj, prop_name, first_choice)
                setattr(obj, prop_name, prop_value)

            except Exception as e:
                Path.Log.error(
                    f"Error setting property {prop_name} to {prop_value} "
                    f"(type: {type(prop_value)}, expected type: {prop_type}): {e}"
                )
                raise

            if prop_editor_mode is not None:
                obj.setEditorMode(prop_name, prop_editor_mode)