nodeeditor-qtpy 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodeeditor/__init__.py +93 -0
- nodeeditor/node_colors_config.py +343 -0
- nodeeditor/node_content_widget.py +129 -0
- nodeeditor/node_decorators.py +65 -0
- nodeeditor/node_edge.py +518 -0
- nodeeditor/node_edge_dragging.py +185 -0
- nodeeditor/node_edge_intersect.py +219 -0
- nodeeditor/node_edge_rerouting.py +216 -0
- nodeeditor/node_edge_snapping.py +95 -0
- nodeeditor/node_edge_validators.py +82 -0
- nodeeditor/node_editor_widget.py +257 -0
- nodeeditor/node_editor_window.py +448 -0
- nodeeditor/node_graph.py +583 -0
- nodeeditor/node_graphics_cutline.py +57 -0
- nodeeditor/node_graphics_edge.py +375 -0
- nodeeditor/node_graphics_edge_path.py +226 -0
- nodeeditor/node_graphics_node.py +693 -0
- nodeeditor/node_graphics_scene.py +369 -0
- nodeeditor/node_graphics_socket.py +262 -0
- nodeeditor/node_graphics_view.py +907 -0
- nodeeditor/node_group.py +1286 -0
- nodeeditor/node_group_node.py +5 -0
- nodeeditor/node_icon_content_widget.py +176 -0
- nodeeditor/node_icon_graphics_node.py +524 -0
- nodeeditor/node_multi_input_node.py +450 -0
- nodeeditor/node_node.py +848 -0
- nodeeditor/node_scene.py +1106 -0
- nodeeditor/node_scene_clipboard.py +314 -0
- nodeeditor/node_scene_history.py +556 -0
- nodeeditor/node_serializable.py +43 -0
- nodeeditor/node_socket.py +445 -0
- nodeeditor/node_socket_shapes.py +261 -0
- nodeeditor/py.typed +0 -0
- nodeeditor/qss/nodestyle.qss +3 -0
- nodeeditor/utils.py +108 -0
- nodeeditor/utils_no_qt.py +21 -0
- nodeeditor_qtpy-0.8.1.dist-info/METADATA +165 -0
- nodeeditor_qtpy-0.8.1.dist-info/RECORD +40 -0
- nodeeditor_qtpy-0.8.1.dist-info/WHEEL +4 -0
- nodeeditor_qtpy-0.8.1.dist-info/licenses/LICENCE +23 -0
nodeeditor/node_scene.py
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
A module containing the representation of the NodeEditor's Scene
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import orjson as json
|
|
8
|
+
from orjson import JSONDecodeError, OPT_INDENT_2
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from qtpy.QtCore import QRectF, Qt, QPoint
|
|
11
|
+
from qtpy.QtWidgets import QGraphicsItem
|
|
12
|
+
from nodeeditor.utils_no_qt import dumpException, pp
|
|
13
|
+
from nodeeditor.node_serializable import Serializable
|
|
14
|
+
from nodeeditor.node_graphics_scene import QDMGraphicsScene
|
|
15
|
+
from nodeeditor.node_node import Node
|
|
16
|
+
from nodeeditor.node_edge import Edge
|
|
17
|
+
from nodeeditor.node_scene_history import SceneHistory
|
|
18
|
+
from nodeeditor.node_scene_clipboard import SceneClipboard
|
|
19
|
+
from nodeeditor.node_colors_config import get_color_scheme
|
|
20
|
+
import nodeeditor.node_graph as node_graph
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple, Any, Callable, OrderedDict as OrderedDictType, Type
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from nodeeditor.node_graphics_view import QDMGraphicsView
|
|
27
|
+
from nodeeditor.node_socket import Socket
|
|
28
|
+
NodeClassType = Callable[[dict], Type['Node']]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEBUG_REMOVE_WARNINGS = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidFile(Exception):
|
|
35
|
+
"""Raised when a file cannot be loaded as a `Scene`."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Scene(Serializable):
|
|
39
|
+
"""Class representing NodeEditor's `Scene`"""
|
|
40
|
+
historyClass = SceneHistory
|
|
41
|
+
clipboardClass = SceneClipboard
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
"""
|
|
45
|
+
:Instance Attributes:
|
|
46
|
+
|
|
47
|
+
- **nodes** - list of `Nodes` in this `Scene`
|
|
48
|
+
- **edges** - list of `Edges` in this `Scene`
|
|
49
|
+
- **history** - Instance of :class:`~nodeeditor.node_scene_history.SceneHistory`
|
|
50
|
+
- **clipboard** - Instance of :class:`~nodeeditor.node_scene_clipboard.SceneClipboard`
|
|
51
|
+
- **scene_width** - width of this `Scene` in pixels
|
|
52
|
+
- **scene_height** - height of this `Scene` in pixels
|
|
53
|
+
"""
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.nodes: List[Node] = []
|
|
56
|
+
self.edges: List[Edge] = []
|
|
57
|
+
self.groups: List = [] # List of GroupNode instances
|
|
58
|
+
|
|
59
|
+
# current filename assigned to this scene
|
|
60
|
+
self.filename: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
self.scene_width: int = 64000
|
|
63
|
+
self.scene_height: int = 64000
|
|
64
|
+
|
|
65
|
+
# custom flag used to suppress triggering onItemSelected which does a bunch of stuff
|
|
66
|
+
self._silent_selection_events: bool = False
|
|
67
|
+
|
|
68
|
+
self._has_been_modified: bool = False
|
|
69
|
+
self._last_selected_items: Optional[List[QGraphicsItem]] = None
|
|
70
|
+
self._last_selected_socket: Optional[Socket] = None
|
|
71
|
+
self._last_selected_edges: Optional[List[Edge]] = None
|
|
72
|
+
|
|
73
|
+
# initialize all listeners
|
|
74
|
+
self._has_been_modified_listeners: List[Callable[[], None]] = []
|
|
75
|
+
self._item_selected_listeners: List[Callable[[], None]] = []
|
|
76
|
+
self._items_deselected_listeners: List[Callable[[], None]] = []
|
|
77
|
+
|
|
78
|
+
# here we can store callback for retrieving the class for Nodes
|
|
79
|
+
self.node_class_selector: Optional['NodeClassType'] = None
|
|
80
|
+
|
|
81
|
+
self.initUI()
|
|
82
|
+
self.history = self.historyClass(self)
|
|
83
|
+
self.clipboard = self.clipboardClass(self)
|
|
84
|
+
|
|
85
|
+
self.grScene.itemSelected.connect(self.onItemSelected)
|
|
86
|
+
self.grScene.itemsDeselected.connect(self.onItemsDeselected)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def has_been_modified(self):
|
|
90
|
+
"""
|
|
91
|
+
Has this `Scene` been modified?
|
|
92
|
+
|
|
93
|
+
:getter: ``True`` if the `Scene` has been modified
|
|
94
|
+
:setter: set new state. Triggers `Has Been Modified` event
|
|
95
|
+
:type: ``bool``
|
|
96
|
+
"""
|
|
97
|
+
return self._has_been_modified
|
|
98
|
+
|
|
99
|
+
@has_been_modified.setter
|
|
100
|
+
def has_been_modified(self, value) -> None:
|
|
101
|
+
if not self._has_been_modified and value:
|
|
102
|
+
# set it now, because we will be reading it soon
|
|
103
|
+
self._has_been_modified = value
|
|
104
|
+
|
|
105
|
+
# call all registered listeners
|
|
106
|
+
for callback in self._has_been_modified_listeners:
|
|
107
|
+
callback()
|
|
108
|
+
|
|
109
|
+
self._has_been_modified = value
|
|
110
|
+
|
|
111
|
+
def initUI(self) -> None:
|
|
112
|
+
"""Set up Graphics Scene Instance"""
|
|
113
|
+
self.grScene = QDMGraphicsScene(self)
|
|
114
|
+
self.grScene.setGrScene(self.scene_width, self.scene_height)
|
|
115
|
+
|
|
116
|
+
def getNodeByID(self, node_id: int):
|
|
117
|
+
"""
|
|
118
|
+
Find node in the scene according to provided `node_id`
|
|
119
|
+
|
|
120
|
+
:param node_id: ID of the node we are looking for
|
|
121
|
+
:type node_id: ``int``
|
|
122
|
+
:return: Found ``Node`` or ``None``
|
|
123
|
+
"""
|
|
124
|
+
for node in self.nodes:
|
|
125
|
+
if node.id == node_id:
|
|
126
|
+
return node
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Graph algorithms
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# These are thin wrappers around :mod:`nodeeditor.node_graph`, which
|
|
133
|
+
# implements them natively. The graph is derived from this `Scene` on
|
|
134
|
+
# every call, so it always reflects the current connections.
|
|
135
|
+
|
|
136
|
+
def topological_order(self) -> List[Node]:
|
|
137
|
+
"""The `Nodes` of this `Scene` in dependency order.
|
|
138
|
+
|
|
139
|
+
Every `Node` comes after the `Nodes` feeding it. `Nodes` that do not
|
|
140
|
+
depend on each other keep their scene order, so the result is
|
|
141
|
+
reproducible for a given graph.
|
|
142
|
+
|
|
143
|
+
:return: the `Nodes` in dependency order
|
|
144
|
+
:rtype: ``list`` of :class:`~nodeeditor.node_node.Node`
|
|
145
|
+
:raises :class:`~nodeeditor.node_graph.GraphCycleError` if the graph
|
|
146
|
+
contains a cycle
|
|
147
|
+
"""
|
|
148
|
+
return node_graph.topological_order(self)
|
|
149
|
+
|
|
150
|
+
def descendants(self, node: Node) -> List[Node]:
|
|
151
|
+
"""Every `Node` reachable downstream of `node`, excluding `node`.
|
|
152
|
+
|
|
153
|
+
Ordered breadth-first, so the nearest nodes come first. A node
|
|
154
|
+
reachable through several paths appears once.
|
|
155
|
+
|
|
156
|
+
:param node: the `Node` to start from
|
|
157
|
+
:type node: :class:`~nodeeditor.node_node.Node`
|
|
158
|
+
:return: the downstream `Nodes`
|
|
159
|
+
:rtype: ``list`` of :class:`~nodeeditor.node_node.Node`
|
|
160
|
+
"""
|
|
161
|
+
return node_graph.descendants(self, node)
|
|
162
|
+
|
|
163
|
+
def ancestors(self, node: Node) -> List[Node]:
|
|
164
|
+
"""Every `Node` feeding `node`, excluding `node` itself.
|
|
165
|
+
|
|
166
|
+
Ordered breadth-first, so the nearest nodes come first.
|
|
167
|
+
|
|
168
|
+
:param node: the `Node` to start from
|
|
169
|
+
:type node: :class:`~nodeeditor.node_node.Node`
|
|
170
|
+
:return: the upstream `Nodes`
|
|
171
|
+
:rtype: ``list`` of :class:`~nodeeditor.node_node.Node`
|
|
172
|
+
"""
|
|
173
|
+
return node_graph.ancestors(self, node)
|
|
174
|
+
|
|
175
|
+
def components(self) -> List[List[Node]]:
|
|
176
|
+
"""The disconnected subgraphs of this `Scene`.
|
|
177
|
+
|
|
178
|
+
Two `Nodes` belong to the same component when a chain of `Edges`
|
|
179
|
+
connects them, regardless of direction. A `Node` without any
|
|
180
|
+
connection forms a component of its own. The components come in scene
|
|
181
|
+
order.
|
|
182
|
+
|
|
183
|
+
:return: one list of `Nodes` per component
|
|
184
|
+
:rtype: ``list`` of ``list`` of :class:`~nodeeditor.node_node.Node`
|
|
185
|
+
"""
|
|
186
|
+
return node_graph.components(self)
|
|
187
|
+
|
|
188
|
+
def cycles(self) -> List[List[Edge]]:
|
|
189
|
+
"""The cycles of this `Scene`.
|
|
190
|
+
|
|
191
|
+
One representative cycle per cyclic region. Within a cycle, the
|
|
192
|
+
`Edges` are listed in traversal order and the last one leads back to
|
|
193
|
+
the node the first one started from.
|
|
194
|
+
|
|
195
|
+
:return: the cycles, each a list of `Edges`
|
|
196
|
+
:rtype: ``list`` of ``list`` of :class:`~nodeeditor.node_edge.Edge`
|
|
197
|
+
"""
|
|
198
|
+
return node_graph.cycles(self)
|
|
199
|
+
|
|
200
|
+
def paths(self, source: Node, target: Node) -> List[List[Tuple[Node, Optional[Edge]]]]:
|
|
201
|
+
"""Every simple path from `source` to `target`.
|
|
202
|
+
|
|
203
|
+
A path is a list of ``(node, edge)`` steps, where the `edge` of a step
|
|
204
|
+
is the `Edge` that led into its `Node`. The first step carries
|
|
205
|
+
``None`` as its edge.
|
|
206
|
+
|
|
207
|
+
:param source: the `Node` to start from
|
|
208
|
+
:type source: :class:`~nodeeditor.node_node.Node`
|
|
209
|
+
:param target: the `Node` to reach
|
|
210
|
+
:type target: :class:`~nodeeditor.node_node.Node`
|
|
211
|
+
:return: the paths found
|
|
212
|
+
:rtype: ``list`` of ``list`` of ``(Node, Edge)``
|
|
213
|
+
"""
|
|
214
|
+
return node_graph.paths(self, source, target)
|
|
215
|
+
|
|
216
|
+
def shortest_path(
|
|
217
|
+
self, source: Node, target: Node
|
|
218
|
+
) -> Optional[List[Tuple[Node, Optional[Edge]]]]:
|
|
219
|
+
"""The shortest path from `source` to `target`, counted in `Edges`.
|
|
220
|
+
|
|
221
|
+
:param source: the `Node` to start from
|
|
222
|
+
:type source: :class:`~nodeeditor.node_node.Node`
|
|
223
|
+
:param target: the `Node` to reach
|
|
224
|
+
:type target: :class:`~nodeeditor.node_node.Node`
|
|
225
|
+
:return: the shortest path, or ``None`` when `target` is unreachable
|
|
226
|
+
:rtype: ``list`` of ``(Node, Edge)`` or ``None``
|
|
227
|
+
"""
|
|
228
|
+
return node_graph.shortest_path(self, source, target)
|
|
229
|
+
|
|
230
|
+
def setSilentSelectionEvents(self, value: bool = True) -> None:
|
|
231
|
+
"""Calling this can suppress onItemSelected events to be triggered. This is useful when working with clipboard"""
|
|
232
|
+
self._silent_selection_events = value
|
|
233
|
+
|
|
234
|
+
def onItemSelected(self, silent: bool = False) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Handle Item selection and trigger event `Item Selected`
|
|
237
|
+
|
|
238
|
+
:param silent: If ``True`` scene's onItemSelected won't be called and history stamp not stored
|
|
239
|
+
:type silent: ``bool``
|
|
240
|
+
"""
|
|
241
|
+
if self._silent_selection_events:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
current_selected_items = self.getSelectedItems()
|
|
245
|
+
if current_selected_items != self._last_selected_items:
|
|
246
|
+
self._last_selected_items = current_selected_items
|
|
247
|
+
if not silent:
|
|
248
|
+
# we could create some kind of UI which could be serialized,
|
|
249
|
+
# therefore first run all callbacks...
|
|
250
|
+
for callback in self._item_selected_listeners:
|
|
251
|
+
callback()
|
|
252
|
+
# and store history as a last step always
|
|
253
|
+
self.history.storeHistory("Selection Changed")
|
|
254
|
+
|
|
255
|
+
def onItemsDeselected(self, silent: bool = False) -> None:
|
|
256
|
+
"""
|
|
257
|
+
Handle Items deselection and trigger event `Items Deselected`
|
|
258
|
+
|
|
259
|
+
:param silent: If ``True`` scene's onItemsDeselected won't be called and history stamp not stored
|
|
260
|
+
:type silent: ``bool``
|
|
261
|
+
"""
|
|
262
|
+
# somehow this event is being triggered when we start dragging file outside of our application
|
|
263
|
+
# or we just loose focus on our app? -- which does not mean we've deselected item in the scene!
|
|
264
|
+
# double check if the selection has actually changed, since
|
|
265
|
+
current_selected_items = self.getSelectedItems()
|
|
266
|
+
if current_selected_items == self._last_selected_items:
|
|
267
|
+
# print("Qt itemsDeselected Invalid Event! Ignoring")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
self.resetLastSelectedStates()
|
|
271
|
+
if current_selected_items == []:
|
|
272
|
+
self._last_selected_items = []
|
|
273
|
+
if not silent:
|
|
274
|
+
self.history.storeHistory("Deselected Everything")
|
|
275
|
+
for callback in self._items_deselected_listeners:
|
|
276
|
+
callback()
|
|
277
|
+
|
|
278
|
+
def isModified(self) -> bool:
|
|
279
|
+
"""Is this `Scene` dirty aka `has been modified` ?
|
|
280
|
+
|
|
281
|
+
:return: ``True`` if `Scene` has been modified
|
|
282
|
+
:rtype: ``bool``
|
|
283
|
+
"""
|
|
284
|
+
return self.has_been_modified
|
|
285
|
+
|
|
286
|
+
def getSelectedItems(self) -> list:
|
|
287
|
+
"""
|
|
288
|
+
Returns currently selected Graphics Items
|
|
289
|
+
|
|
290
|
+
:return: list of ``QGraphicsItems``
|
|
291
|
+
:rtype: list[QGraphicsItem]
|
|
292
|
+
"""
|
|
293
|
+
return self.grScene.selectedItems()
|
|
294
|
+
|
|
295
|
+
def getSelectedNodes(self) -> list:
|
|
296
|
+
"""
|
|
297
|
+
Returns currently selected Nodes
|
|
298
|
+
|
|
299
|
+
:return: list of ``Node``
|
|
300
|
+
:rtype: list[Node]
|
|
301
|
+
"""
|
|
302
|
+
return [item.node for item in self.getSelectedItems() if hasattr(item, "node")]
|
|
303
|
+
|
|
304
|
+
def doDeselectItems(self, silent: bool = False) -> None:
|
|
305
|
+
"""
|
|
306
|
+
Deselects everything in scene
|
|
307
|
+
|
|
308
|
+
:param silent: If ``True`` scene's onItemsDeselected won't be called
|
|
309
|
+
:type silent: ``bool``
|
|
310
|
+
"""
|
|
311
|
+
for item in self.getSelectedItems():
|
|
312
|
+
item.setSelected(False)
|
|
313
|
+
if not silent:
|
|
314
|
+
self.onItemsDeselected()
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# Undo / Redo entry points
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def undo(self) -> None:
|
|
321
|
+
"""Revert the most recent recorded change.
|
|
322
|
+
|
|
323
|
+
Routed through the scene rather than called on ``history`` directly so
|
|
324
|
+
subclasses can layer an alternative undo backend underneath the
|
|
325
|
+
default snapshot implementation.
|
|
326
|
+
"""
|
|
327
|
+
self.history.undo()
|
|
328
|
+
|
|
329
|
+
def redo(self) -> None:
|
|
330
|
+
"""Reapply the most recently reverted change."""
|
|
331
|
+
self.history.redo()
|
|
332
|
+
|
|
333
|
+
def canUndo(self) -> bool:
|
|
334
|
+
"""Is there a recorded change which can be undone?
|
|
335
|
+
|
|
336
|
+
:return: ``True`` if the `Scene` history holds an undoable change
|
|
337
|
+
:rtype: ``bool``
|
|
338
|
+
"""
|
|
339
|
+
return self.history.canUndo()
|
|
340
|
+
|
|
341
|
+
def canRedo(self) -> bool:
|
|
342
|
+
"""Is there a reverted change which can be redone?
|
|
343
|
+
|
|
344
|
+
:return: ``True`` if the `Scene` history holds a redoable change
|
|
345
|
+
:rtype: ``bool``
|
|
346
|
+
"""
|
|
347
|
+
return self.history.canRedo()
|
|
348
|
+
|
|
349
|
+
# our helper listener functions
|
|
350
|
+
def addHasBeenModifiedListener(self, callback: Callable[[], None]) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Register callback for `Has Been Modified` event
|
|
353
|
+
|
|
354
|
+
:param callback: callback function
|
|
355
|
+
"""
|
|
356
|
+
self._has_been_modified_listeners.append(callback)
|
|
357
|
+
|
|
358
|
+
def addItemSelectedListener(self, callback: Callable[[], None]) -> None:
|
|
359
|
+
"""
|
|
360
|
+
Register callback for `Item Selected` event
|
|
361
|
+
|
|
362
|
+
:param callback: callback function
|
|
363
|
+
"""
|
|
364
|
+
self._item_selected_listeners.append(callback)
|
|
365
|
+
|
|
366
|
+
def addItemsDeselectedListener(self, callback: Callable[[], None]) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Register callback for `Items Deselected` event
|
|
369
|
+
|
|
370
|
+
:param callback: callback function
|
|
371
|
+
"""
|
|
372
|
+
self._items_deselected_listeners.append(callback)
|
|
373
|
+
|
|
374
|
+
def addDragEnterListener(self, callback: Callable[[], None]) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Register callback for `Drag Enter` event
|
|
377
|
+
|
|
378
|
+
:param callback: callback function
|
|
379
|
+
"""
|
|
380
|
+
self.getView().addDragEnterListener(callback)
|
|
381
|
+
|
|
382
|
+
def addDropListener(self, callback: Callable[[], None]) -> None:
|
|
383
|
+
"""
|
|
384
|
+
Register callback for `Drop` event
|
|
385
|
+
|
|
386
|
+
:param callback: callback function
|
|
387
|
+
"""
|
|
388
|
+
self.getView().addDropListener(callback)
|
|
389
|
+
|
|
390
|
+
# custom flag to detect node or edge has been selected....
|
|
391
|
+
def resetLastSelectedStates(self) -> None:
|
|
392
|
+
"""Resets internal `selected flags` in all `Nodes` and `Edges` in the `Scene`"""
|
|
393
|
+
for node in self.nodes:
|
|
394
|
+
node.grNode._last_selected_state = False
|
|
395
|
+
for edge in self.edges:
|
|
396
|
+
edge.grEdge._last_selected_state = False
|
|
397
|
+
|
|
398
|
+
def getView(self) -> 'QDMGraphicsView':
|
|
399
|
+
"""Shortcut for returning `Scene` ``QGraphicsView``
|
|
400
|
+
|
|
401
|
+
:return: ``QGraphicsView`` attached to the `Scene`
|
|
402
|
+
:rtype: ``QGraphicsView``
|
|
403
|
+
"""
|
|
404
|
+
return self.grScene.views()[0]
|
|
405
|
+
|
|
406
|
+
def getItemAt(self, pos: 'QPoint') -> Optional['QGraphicsItem']:
|
|
407
|
+
"""Shortcut for retrieving item at provided `Scene` position
|
|
408
|
+
|
|
409
|
+
:param pos: scene position
|
|
410
|
+
:type pos: ``QPointF``
|
|
411
|
+
:return: Qt Graphics Item at scene position
|
|
412
|
+
:rtype: ``QGraphicsItem``
|
|
413
|
+
"""
|
|
414
|
+
return self.getView().itemAt(pos)
|
|
415
|
+
|
|
416
|
+
# ============================================================================
|
|
417
|
+
# Color Configuration Methods
|
|
418
|
+
# ============================================================================
|
|
419
|
+
|
|
420
|
+
def getColorScheme(self):
|
|
421
|
+
"""Get the global color scheme instance used by all scenes
|
|
422
|
+
|
|
423
|
+
:return: NodeEditorColorScheme instance
|
|
424
|
+
"""
|
|
425
|
+
return get_color_scheme()
|
|
426
|
+
|
|
427
|
+
def setEdgeColors(
|
|
428
|
+
self, default=None, selected=None, hovered=None, dragging=None
|
|
429
|
+
) -> None:
|
|
430
|
+
"""Update edge colors and refresh the scene
|
|
431
|
+
|
|
432
|
+
:param default: Default edge color (hex string, QColor, or RGB tuple)
|
|
433
|
+
:param selected: Selected edge color
|
|
434
|
+
:param hovered: Hovered edge color
|
|
435
|
+
:param dragging: Dragging edge color
|
|
436
|
+
|
|
437
|
+
Example::
|
|
438
|
+
|
|
439
|
+
scene.setEdgeColors(default="#FF0000", selected="#00FF00")
|
|
440
|
+
"""
|
|
441
|
+
scheme = self.getColorScheme()
|
|
442
|
+
scheme.edges.set_all(
|
|
443
|
+
default=default, selected=selected, hovered=hovered, dragging=dragging
|
|
444
|
+
)
|
|
445
|
+
self._refresh_edge_colors()
|
|
446
|
+
|
|
447
|
+
def setSocketColors(self, outline=None, highlight=None, type_colors=None) -> None:
|
|
448
|
+
"""Update socket colors and refresh the scene
|
|
449
|
+
|
|
450
|
+
:param outline: Socket outline color (hex string, QColor, or RGB tuple)
|
|
451
|
+
:param highlight: Socket highlight color
|
|
452
|
+
:param type_colors: List of colors for socket types
|
|
453
|
+
|
|
454
|
+
Example::
|
|
455
|
+
|
|
456
|
+
scene.setSocketColors(
|
|
457
|
+
outline="#000000",
|
|
458
|
+
highlight="#00FF00",
|
|
459
|
+
type_colors=["#FF0000", "#00FF00", "#0000FF"]
|
|
460
|
+
)
|
|
461
|
+
"""
|
|
462
|
+
scheme = self.getColorScheme()
|
|
463
|
+
scheme.sockets.set_all(
|
|
464
|
+
outline=outline, highlight=highlight, type_colors=type_colors
|
|
465
|
+
)
|
|
466
|
+
self._refresh_socket_colors()
|
|
467
|
+
|
|
468
|
+
def setHighlightColor(self, color) -> None:
|
|
469
|
+
"""Update the color used for highlighting edges and sockets
|
|
470
|
+
|
|
471
|
+
:param color: Highlight color (hex string, QColor, or RGB tuple)
|
|
472
|
+
|
|
473
|
+
Example::
|
|
474
|
+
|
|
475
|
+
scene.setHighlightColor("#00FF00")
|
|
476
|
+
"""
|
|
477
|
+
scheme = self.getColorScheme()
|
|
478
|
+
scheme.edges.hovered = color
|
|
479
|
+
scheme.sockets.highlight = color
|
|
480
|
+
self._refresh_colors()
|
|
481
|
+
|
|
482
|
+
def setSocketTypeColor(self, socket_type: int, color) -> None:
|
|
483
|
+
"""Update the color for a specific socket type
|
|
484
|
+
|
|
485
|
+
:param socket_type: Socket type index
|
|
486
|
+
:param color: Color to set (hex string, QColor, or RGB tuple)
|
|
487
|
+
|
|
488
|
+
Example::
|
|
489
|
+
|
|
490
|
+
scene.setSocketTypeColor(0, "#FF0000") # Set type 0 to red
|
|
491
|
+
"""
|
|
492
|
+
scheme = self.getColorScheme()
|
|
493
|
+
scheme.sockets.set_type_color(socket_type, color)
|
|
494
|
+
self._refresh_socket_colors()
|
|
495
|
+
|
|
496
|
+
def _refresh_edge_colors(self) -> None:
|
|
497
|
+
"""Refresh all edges in the scene with updated colors"""
|
|
498
|
+
for edge in self.edges:
|
|
499
|
+
if hasattr(edge, "grEdge") and edge.grEdge:
|
|
500
|
+
edge.grEdge.initAssets()
|
|
501
|
+
edge.grEdge.update()
|
|
502
|
+
|
|
503
|
+
def _refresh_socket_colors(self) -> None:
|
|
504
|
+
"""Refresh all sockets in the scene with updated colors"""
|
|
505
|
+
for node in self.nodes:
|
|
506
|
+
if hasattr(node, "grNode") and node.grNode:
|
|
507
|
+
# Refresh the node graphics item (for hover colors, etc.)
|
|
508
|
+
node.grNode.initAssets()
|
|
509
|
+
# Refresh input sockets
|
|
510
|
+
for socket in node.inputs:
|
|
511
|
+
if hasattr(socket, "grSocket") and socket.grSocket:
|
|
512
|
+
socket.grSocket.initAssets()
|
|
513
|
+
socket.grSocket.update()
|
|
514
|
+
# Refresh output sockets
|
|
515
|
+
for socket in node.outputs:
|
|
516
|
+
if hasattr(socket, "grSocket") and socket.grSocket:
|
|
517
|
+
socket.grSocket.initAssets()
|
|
518
|
+
socket.grSocket.update()
|
|
519
|
+
# Update the node graphics item
|
|
520
|
+
node.grNode.update()
|
|
521
|
+
|
|
522
|
+
def _refresh_colors(self) -> None:
|
|
523
|
+
"""Refresh all colors in the scene (edges, sockets, highlights)"""
|
|
524
|
+
self._refresh_edge_colors()
|
|
525
|
+
self._refresh_socket_colors()
|
|
526
|
+
self.grScene.update()
|
|
527
|
+
|
|
528
|
+
def addNode(self, node: Node) -> None:
|
|
529
|
+
"""Add :class:`~nodeeditor.node_node.Node` to this `Scene`
|
|
530
|
+
|
|
531
|
+
:param node: :class:`~nodeeditor.node_node.Node` to be added to this `Scene`
|
|
532
|
+
:type node: :class:`~nodeeditor.node_node.Node`
|
|
533
|
+
"""
|
|
534
|
+
self.nodes.append(node)
|
|
535
|
+
|
|
536
|
+
def addEdge(self, edge: Edge) -> None:
|
|
537
|
+
"""Add :class:`~nodeeditor.node_edge.Edge` to this `Scene`
|
|
538
|
+
|
|
539
|
+
:param edge: :class:`~nodeeditor.node_edge.Edge` to be added to this `Scene`
|
|
540
|
+
:return: :class:`~nodeeditor.node_edge.Edge`
|
|
541
|
+
"""
|
|
542
|
+
self.edges.append(edge)
|
|
543
|
+
|
|
544
|
+
def removeNode(self, node: Node) -> None:
|
|
545
|
+
"""Remove :class:`~nodeeditor.node_node.Node` from this `Scene`
|
|
546
|
+
|
|
547
|
+
:param node: :class:`~nodeeditor.node_node.Node` to be removed from this `Scene`
|
|
548
|
+
:type node: :class:`~nodeeditor.node_node.Node`
|
|
549
|
+
"""
|
|
550
|
+
if node in self.nodes:
|
|
551
|
+
self.nodes.remove(node)
|
|
552
|
+
else:
|
|
553
|
+
if DEBUG_REMOVE_WARNINGS:
|
|
554
|
+
print("!W:", "Scene::removeNode", "wanna remove nodeeditor", node,
|
|
555
|
+
"from self.nodes but it's not in the list!")
|
|
556
|
+
|
|
557
|
+
def removeEdge(self, edge: Edge) -> None:
|
|
558
|
+
"""Remove :class:`~nodeeditor.node_edge.Edge` from this `Scene`
|
|
559
|
+
|
|
560
|
+
:param edge: :class:`~nodeeditor.node_edge.Edge` to be remove from this `Scene`
|
|
561
|
+
:return: :class:`~nodeeditor.node_edge.Edge`
|
|
562
|
+
"""
|
|
563
|
+
if edge in self.edges:
|
|
564
|
+
self.edges.remove(edge)
|
|
565
|
+
else:
|
|
566
|
+
if DEBUG_REMOVE_WARNINGS:
|
|
567
|
+
print("!W:", "Scene::removeEdge", "wanna remove edge", edge,
|
|
568
|
+
"from self.edges but it's not in the list!")
|
|
569
|
+
|
|
570
|
+
def addGroup(self, group) -> None:
|
|
571
|
+
"""Add Group to this `Scene`
|
|
572
|
+
|
|
573
|
+
:param group: Group to be added to this `Scene`
|
|
574
|
+
"""
|
|
575
|
+
if group not in self.groups:
|
|
576
|
+
self.groups.append(group)
|
|
577
|
+
|
|
578
|
+
def removeGroup(self, group) -> None:
|
|
579
|
+
"""Remove Group from this `Scene`
|
|
580
|
+
|
|
581
|
+
:param group: Group to be removed from this `Scene`
|
|
582
|
+
"""
|
|
583
|
+
if group in self.groups:
|
|
584
|
+
self.groups.remove(group)
|
|
585
|
+
else:
|
|
586
|
+
if DEBUG_REMOVE_WARNINGS:
|
|
587
|
+
print(
|
|
588
|
+
"!W:",
|
|
589
|
+
"Scene::removeGroup",
|
|
590
|
+
"wanna remove group",
|
|
591
|
+
group,
|
|
592
|
+
"from self.groups but it's not in the list!",
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def getGroupById(self, group_id: int):
|
|
596
|
+
"""
|
|
597
|
+
Find the `Group` in this `Scene` according to the provided `group_id`
|
|
598
|
+
|
|
599
|
+
:param group_id: ID of the group we are looking for
|
|
600
|
+
:type group_id: ``int``
|
|
601
|
+
:return: Found `Group` or ``None``
|
|
602
|
+
"""
|
|
603
|
+
for group in self.groups:
|
|
604
|
+
if getattr(group, 'id', None) == group_id:
|
|
605
|
+
return group
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
def findCollapsedGroupForNode(self, node):
|
|
609
|
+
"""Return collapsed Group containing `node`, or None."""
|
|
610
|
+
try:
|
|
611
|
+
grp = getattr(node, 'parent_group', None)
|
|
612
|
+
if grp is not None and grp in self.groups and getattr(grp, '_collapsed', False):
|
|
613
|
+
return grp
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
def findGroupForDrop(self, scenepos, exclude_nodes=None):
|
|
619
|
+
"""Topmost smallest group whose *bounding* rect contains `scenepos`.
|
|
620
|
+
|
|
621
|
+
Uses boundingRect (not header-only shape) so drops onto the body work.
|
|
622
|
+
Returns None if no candidate. `exclude_nodes` skips groups that already
|
|
623
|
+
contain all dragged nodes (avoids redundant history stamps).
|
|
624
|
+
"""
|
|
625
|
+
try:
|
|
626
|
+
from qtpy.QtCore import QPointF
|
|
627
|
+
if not isinstance(scenepos, QPointF):
|
|
628
|
+
try:
|
|
629
|
+
scenepos = QPointF(scenepos.x(), scenepos.y())
|
|
630
|
+
except Exception:
|
|
631
|
+
return None
|
|
632
|
+
best = None
|
|
633
|
+
best_area = None
|
|
634
|
+
exclude_ids = set()
|
|
635
|
+
try:
|
|
636
|
+
for n in list(exclude_nodes or []):
|
|
637
|
+
nid = getattr(n, 'id', None)
|
|
638
|
+
exclude_ids.add(nid if nid is not None else id(n))
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
for grp in list(getattr(self, 'groups', [])):
|
|
642
|
+
try:
|
|
643
|
+
gr = grp
|
|
644
|
+
# map scene pos into group local coords, test full rect
|
|
645
|
+
try:
|
|
646
|
+
lp = gr.mapFromScene(scenepos)
|
|
647
|
+
except Exception:
|
|
648
|
+
continue
|
|
649
|
+
if not gr.rect().contains(lp):
|
|
650
|
+
continue
|
|
651
|
+
if exclude_ids:
|
|
652
|
+
try:
|
|
653
|
+
members = set()
|
|
654
|
+
for n in list(getattr(grp, 'child_nodes', [])):
|
|
655
|
+
nid = getattr(n, 'id', None)
|
|
656
|
+
members.add(nid if nid is not None else id(n))
|
|
657
|
+
if members and members.issuperset(exclude_ids):
|
|
658
|
+
# all dragged nodes already inside; still allow
|
|
659
|
+
# highlight? No - skip to avoid noop stamps.
|
|
660
|
+
all_inside = True
|
|
661
|
+
for n in list(exclude_nodes or []):
|
|
662
|
+
if n not in list(getattr(grp, 'child_nodes', [])):
|
|
663
|
+
all_inside = False
|
|
664
|
+
break
|
|
665
|
+
if all_inside:
|
|
666
|
+
continue
|
|
667
|
+
except Exception:
|
|
668
|
+
pass
|
|
669
|
+
try:
|
|
670
|
+
area = gr.rect().width() * gr.rect().height()
|
|
671
|
+
except Exception:
|
|
672
|
+
area = float('inf')
|
|
673
|
+
if best is None or area < best_area:
|
|
674
|
+
best, best_area = grp, area
|
|
675
|
+
except Exception:
|
|
676
|
+
continue
|
|
677
|
+
return best
|
|
678
|
+
except Exception:
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
def dropNodesIntoGroup(self, nodes, scenepos) -> bool:
|
|
682
|
+
"""Move `nodes` into group under `scenepos`. Returns True if changed."""
|
|
683
|
+
try:
|
|
684
|
+
grp = self.findGroupForDrop(scenepos, exclude_nodes=nodes)
|
|
685
|
+
if grp is None:
|
|
686
|
+
return False
|
|
687
|
+
changed = False
|
|
688
|
+
for node in list(nodes or []):
|
|
689
|
+
try:
|
|
690
|
+
if node is None or getattr(node, 'grNode', None) is None:
|
|
691
|
+
continue
|
|
692
|
+
if getattr(node, 'parent_group', None) is grp:
|
|
693
|
+
continue
|
|
694
|
+
grp.addNode(node)
|
|
695
|
+
changed = True
|
|
696
|
+
except Exception:
|
|
697
|
+
continue
|
|
698
|
+
if changed:
|
|
699
|
+
try:
|
|
700
|
+
if getattr(grp, '_collapsed', False):
|
|
701
|
+
grp._updateCollapsedSize()
|
|
702
|
+
grp.refreshExternalEdges()
|
|
703
|
+
else:
|
|
704
|
+
grp.updateBounds()
|
|
705
|
+
except Exception:
|
|
706
|
+
pass
|
|
707
|
+
try:
|
|
708
|
+
self.has_been_modified = True
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
return changed
|
|
712
|
+
except Exception:
|
|
713
|
+
return False
|
|
714
|
+
|
|
715
|
+
def clear(self) -> None:
|
|
716
|
+
"""Remove all `Nodes`, `Edges` and `Groups` from this `Scene`"""
|
|
717
|
+
while len(self.nodes) > 0:
|
|
718
|
+
try:
|
|
719
|
+
self.nodes[0].remove()
|
|
720
|
+
except Exception:
|
|
721
|
+
# ensure progress even if a node is broken
|
|
722
|
+
try:
|
|
723
|
+
self.nodes.pop(0)
|
|
724
|
+
except Exception:
|
|
725
|
+
break
|
|
726
|
+
# nodes detach from groups on remove; drop any remaining group graphics
|
|
727
|
+
for group in list(self.groups):
|
|
728
|
+
try:
|
|
729
|
+
try:
|
|
730
|
+
if self.grScene is not None and group in self.grScene.items():
|
|
731
|
+
self.grScene.removeItem(group)
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
self.groups.clear()
|
|
737
|
+
|
|
738
|
+
self.has_been_modified = False
|
|
739
|
+
|
|
740
|
+
def saveToFile(self, filename: str) -> None:
|
|
741
|
+
"""
|
|
742
|
+
Save this `Scene` to the file on disk.
|
|
743
|
+
|
|
744
|
+
:param filename: where to save this scene
|
|
745
|
+
:type filename: ``str``
|
|
746
|
+
"""
|
|
747
|
+
# orjson returns bytes, so we need to decode to str before writing
|
|
748
|
+
with open(filename, "w") as file:
|
|
749
|
+
json_str = json.dumps(
|
|
750
|
+
self.serialize(),
|
|
751
|
+
option=OPT_INDENT_2, # Use orjson's built-in indentation option
|
|
752
|
+
).decode("utf-8")
|
|
753
|
+
file.write(json_str)
|
|
754
|
+
# print("saving to", filename, "was successfull.")
|
|
755
|
+
|
|
756
|
+
self.has_been_modified = False
|
|
757
|
+
self.filename = filename
|
|
758
|
+
|
|
759
|
+
def loadFromFile(self, filename: str):
|
|
760
|
+
"""
|
|
761
|
+
Load `Scene` from a file on disk
|
|
762
|
+
|
|
763
|
+
:param filename: from what file to load the `Scene`
|
|
764
|
+
:type filename: ``str``
|
|
765
|
+
:raises: :class:`~nodeeditor.node_scene.InvalidFile` if there was an error decoding JSON file
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
with open(filename, "r") as file:
|
|
769
|
+
raw_data = file.read()
|
|
770
|
+
try:
|
|
771
|
+
data = json.loads(raw_data)
|
|
772
|
+
self.filename = filename
|
|
773
|
+
self.deserialize(data)
|
|
774
|
+
self.has_been_modified = False
|
|
775
|
+
except JSONDecodeError:
|
|
776
|
+
raise InvalidFile("%s is not a valid JSON file" %
|
|
777
|
+
os.path.basename(filename))
|
|
778
|
+
except Exception as e:
|
|
779
|
+
dumpException(e)
|
|
780
|
+
|
|
781
|
+
def getEdgeClass(self):
|
|
782
|
+
"""Return the class representing Edge. Override me if needed"""
|
|
783
|
+
return Edge
|
|
784
|
+
|
|
785
|
+
def setNodeClassSelector(self, class_selecting_function: 'NodeClassType') -> None: # noqa
|
|
786
|
+
"""
|
|
787
|
+
Set the function which decides what `Node` class to instantiate when deserializing `Scene`.
|
|
788
|
+
If not set, we will always instantiate :class:`~nodeeditor.node_node.Node` for each `Node` in the `Scene`
|
|
789
|
+
|
|
790
|
+
:param class_selecting_function: function which returns `Node` class type (not instance) from `Node` serialized ``dict`` data
|
|
791
|
+
:type class_selecting_function: ``function``
|
|
792
|
+
:return: Class Type of `Node` to be instantiated during deserialization
|
|
793
|
+
:rtype: `Node` class type
|
|
794
|
+
"""
|
|
795
|
+
self.node_class_selector = class_selecting_function
|
|
796
|
+
|
|
797
|
+
def getNodeClassFromData(self, data: dict) -> Type['Node']:
|
|
798
|
+
"""
|
|
799
|
+
Takes `Node` serialized data and determines which `Node Class` to instantiate according the description
|
|
800
|
+
in the serialized Node
|
|
801
|
+
|
|
802
|
+
:param data: serialized `Node` object data
|
|
803
|
+
:type data: ``dict``
|
|
804
|
+
:return: Instance of `Node` class to be used in this Scene
|
|
805
|
+
:rtype: `Node` class instance
|
|
806
|
+
"""
|
|
807
|
+
# Groups are first-class (Scene.groups), never created via node path.
|
|
808
|
+
# Keep a guard so group payloads accidentally routed here don't crash.
|
|
809
|
+
if data.get("type") in ("Group", "GroupNode"):
|
|
810
|
+
from nodeeditor.node_group import Group
|
|
811
|
+
|
|
812
|
+
return Group # type: ignore[return-value]
|
|
813
|
+
|
|
814
|
+
return Node if self.node_class_selector is None else self.node_class_selector(data)
|
|
815
|
+
|
|
816
|
+
def serialize(self) -> OrderedDict:
|
|
817
|
+
"""
|
|
818
|
+
Serialize this `Scene` into an ``OrderedDict`` for storing in memory or on disk.
|
|
819
|
+
|
|
820
|
+
:return: data serialized in ``OrderedDict``
|
|
821
|
+
:rtype: ``OrderedDict``
|
|
822
|
+
"""
|
|
823
|
+
nodes: List[dict] = []
|
|
824
|
+
edges: List[dict] = []
|
|
825
|
+
groups: List[dict] = []
|
|
826
|
+
for node in self.nodes:
|
|
827
|
+
new_node = node.serialize()
|
|
828
|
+
if not any(new_node['id'] == a['id'] for a in nodes):
|
|
829
|
+
nodes.append(new_node)
|
|
830
|
+
for edge in self.edges:
|
|
831
|
+
new_edge = edge.serialize()
|
|
832
|
+
if not any(new_edge['id'] == a['id'] for a in edges):
|
|
833
|
+
edges.append(new_edge)
|
|
834
|
+
for group in self.groups:
|
|
835
|
+
new_group = group.serialize()
|
|
836
|
+
if not any(new_group["id"] == a["id"] for a in groups):
|
|
837
|
+
groups.append(new_group)
|
|
838
|
+
return OrderedDict(
|
|
839
|
+
[
|
|
840
|
+
("id", self.id),
|
|
841
|
+
("scene_width", self.scene_width),
|
|
842
|
+
("scene_height", self.scene_height),
|
|
843
|
+
("nodes", nodes),
|
|
844
|
+
("edges", edges),
|
|
845
|
+
("groups", groups),
|
|
846
|
+
]
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def deserialize(self, data: dict, hashmap: Optional[dict] = None, restore_id: bool = True, *args: Any, **kwargs: Any) -> bool:
|
|
850
|
+
"""
|
|
851
|
+
Deserialize this `Scene` from a ``dict`` of serialized data.
|
|
852
|
+
|
|
853
|
+
`Nodes`, `Edges` and `Groups` already present in the `Scene` are reused,
|
|
854
|
+
the ones missing from the data are created and the leftover ones are removed.
|
|
855
|
+
|
|
856
|
+
:param data: Dictionary containing serialized data
|
|
857
|
+
:type data: ``dict``
|
|
858
|
+
:param hashmap: Helper dictionary containing references (by id == key) to existing objects
|
|
859
|
+
:type hashmap: ``dict``
|
|
860
|
+
:param restore_id: True if we are creating new Sockets. False is useful when loading existing
|
|
861
|
+
Sockets of which we want to keep the existing object's `id`.
|
|
862
|
+
:type restore_id: bool
|
|
863
|
+
:return: ``True`` if deserialization was successful, otherwise ``False``
|
|
864
|
+
:rtype: ``bool``
|
|
865
|
+
"""
|
|
866
|
+
hashmap = hashmap or {}
|
|
867
|
+
|
|
868
|
+
if restore_id:
|
|
869
|
+
self.id = data['id']
|
|
870
|
+
|
|
871
|
+
# -- deserialize NODES
|
|
872
|
+
|
|
873
|
+
# Instead of recreating all the nodes, reuse existing ones...
|
|
874
|
+
# get list of all current nodes:
|
|
875
|
+
all_nodes = self.nodes.copy()
|
|
876
|
+
|
|
877
|
+
# go through deserialized nodes:
|
|
878
|
+
for node_data in data['nodes']:
|
|
879
|
+
# can we find this node in the scene?
|
|
880
|
+
found_node: Optional[Node] = None
|
|
881
|
+
for node in all_nodes:
|
|
882
|
+
if node.id == node_data['id']:
|
|
883
|
+
found_node = node
|
|
884
|
+
break
|
|
885
|
+
|
|
886
|
+
if not found_node:
|
|
887
|
+
try:
|
|
888
|
+
new_node = self.getNodeClassFromData(node_data)(self)
|
|
889
|
+
new_node.deserialize(
|
|
890
|
+
node_data, hashmap, restore_id, *args, **kwargs)
|
|
891
|
+
new_node.onDeserialized(node_data)
|
|
892
|
+
# print("New node for", node_data['title'])
|
|
893
|
+
except:
|
|
894
|
+
dumpException()
|
|
895
|
+
else:
|
|
896
|
+
try:
|
|
897
|
+
found_node.deserialize(node_data, hashmap,
|
|
898
|
+
restore_id, *args, **kwargs)
|
|
899
|
+
found_node.onDeserialized(node_data)
|
|
900
|
+
all_nodes.remove(found_node)
|
|
901
|
+
# print("Reused", node_data['title'])
|
|
902
|
+
except:
|
|
903
|
+
dumpException()
|
|
904
|
+
|
|
905
|
+
# remove nodes which are left in the scene and were NOT in the serialized data!
|
|
906
|
+
# that means they were not in the graph before...
|
|
907
|
+
while all_nodes != []:
|
|
908
|
+
node = all_nodes.pop()
|
|
909
|
+
node.remove()
|
|
910
|
+
|
|
911
|
+
# -- deserialize EDGES
|
|
912
|
+
|
|
913
|
+
# Instead of recreating all the edges, reuse existing ones...
|
|
914
|
+
# get list of all current edges:
|
|
915
|
+
all_edges = self.edges.copy()
|
|
916
|
+
|
|
917
|
+
# go through deserialized edges:
|
|
918
|
+
for edge_data in data['edges']:
|
|
919
|
+
# can we find this node in the scene?
|
|
920
|
+
found_edge: Optional[Edge] = None
|
|
921
|
+
for edge in all_edges:
|
|
922
|
+
if edge.id == edge_data['id']:
|
|
923
|
+
found_edge = edge
|
|
924
|
+
break
|
|
925
|
+
|
|
926
|
+
if not found_edge:
|
|
927
|
+
new_edge = None
|
|
928
|
+
try:
|
|
929
|
+
new_edge = self.getEdgeClass()(self)
|
|
930
|
+
ok = new_edge.deserialize(
|
|
931
|
+
edge_data, hashmap, restore_id, *args, **kwargs)
|
|
932
|
+
if not ok:
|
|
933
|
+
# clean up half-created edge so groups phase still runs
|
|
934
|
+
try:
|
|
935
|
+
try:
|
|
936
|
+
if getattr(new_edge, 'grEdge', None) is not None:
|
|
937
|
+
self.grScene.removeItem(new_edge.grEdge)
|
|
938
|
+
except Exception:
|
|
939
|
+
pass
|
|
940
|
+
if new_edge in self.edges:
|
|
941
|
+
self.edges.remove(new_edge)
|
|
942
|
+
except Exception:
|
|
943
|
+
pass
|
|
944
|
+
except Exception:
|
|
945
|
+
try:
|
|
946
|
+
dumpException()
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
try:
|
|
950
|
+
if new_edge is not None:
|
|
951
|
+
try:
|
|
952
|
+
if getattr(new_edge, 'grEdge', None) is not None:
|
|
953
|
+
self.grScene.removeItem(new_edge.grEdge)
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
if new_edge in self.edges:
|
|
957
|
+
self.edges.remove(new_edge)
|
|
958
|
+
except Exception:
|
|
959
|
+
pass
|
|
960
|
+
# print("New edge for", edge_data)
|
|
961
|
+
else:
|
|
962
|
+
try:
|
|
963
|
+
found_edge.deserialize(edge_data, hashmap,
|
|
964
|
+
restore_id, *args, **kwargs)
|
|
965
|
+
except Exception:
|
|
966
|
+
try:
|
|
967
|
+
dumpException()
|
|
968
|
+
except Exception:
|
|
969
|
+
pass
|
|
970
|
+
all_edges.remove(found_edge)
|
|
971
|
+
|
|
972
|
+
# remove nodes which are left in the scene and were NOT in the serialized data!
|
|
973
|
+
# that means they were not in the graph before...
|
|
974
|
+
while all_edges != []:
|
|
975
|
+
edge = all_edges.pop()
|
|
976
|
+
edge.remove()
|
|
977
|
+
|
|
978
|
+
# -- deserialize GROUPS (v2 clean break, best-effort old load as expanded)
|
|
979
|
+
all_groups = self.groups.copy()
|
|
980
|
+
|
|
981
|
+
for group_data in data.get("groups", []):
|
|
982
|
+
found_group = None
|
|
983
|
+
try:
|
|
984
|
+
gid = group_data.get("id", None)
|
|
985
|
+
except Exception:
|
|
986
|
+
gid = None
|
|
987
|
+
for group in all_groups:
|
|
988
|
+
if getattr(group, 'id', None) == gid:
|
|
989
|
+
found_group = group
|
|
990
|
+
break
|
|
991
|
+
|
|
992
|
+
if not found_group:
|
|
993
|
+
try:
|
|
994
|
+
from nodeeditor.node_group import Group
|
|
995
|
+
|
|
996
|
+
new_group = Group(self)
|
|
997
|
+
new_group.deserialize(
|
|
998
|
+
group_data, hashmap, restore_id, *args, **kwargs
|
|
999
|
+
)
|
|
1000
|
+
# Group.__init__ already added itself; guard double-add
|
|
1001
|
+
if new_group not in self.groups:
|
|
1002
|
+
self.groups.append(new_group)
|
|
1003
|
+
except Exception:
|
|
1004
|
+
dumpException()
|
|
1005
|
+
else:
|
|
1006
|
+
try:
|
|
1007
|
+
found_group.deserialize(
|
|
1008
|
+
group_data, hashmap, restore_id, *args, **kwargs
|
|
1009
|
+
)
|
|
1010
|
+
all_groups.remove(found_group)
|
|
1011
|
+
except Exception:
|
|
1012
|
+
dumpException()
|
|
1013
|
+
|
|
1014
|
+
while all_groups != []:
|
|
1015
|
+
group = all_groups.pop()
|
|
1016
|
+
try:
|
|
1017
|
+
# dispose graphics + list entry (no child deletion on undo/load)
|
|
1018
|
+
# clear dangling parent_group pointers first
|
|
1019
|
+
try:
|
|
1020
|
+
for n in list(getattr(group, 'child_nodes', [])):
|
|
1021
|
+
try:
|
|
1022
|
+
if getattr(n, 'parent_group', None) is group:
|
|
1023
|
+
n.parent_group = None
|
|
1024
|
+
except Exception:
|
|
1025
|
+
pass
|
|
1026
|
+
try:
|
|
1027
|
+
group.child_nodes.clear()
|
|
1028
|
+
except Exception:
|
|
1029
|
+
pass
|
|
1030
|
+
except Exception:
|
|
1031
|
+
pass
|
|
1032
|
+
try:
|
|
1033
|
+
if self.grScene is not None and group in self.grScene.items():
|
|
1034
|
+
self.grScene.removeItem(group)
|
|
1035
|
+
except Exception:
|
|
1036
|
+
pass
|
|
1037
|
+
if group in self.groups:
|
|
1038
|
+
self.groups.remove(group)
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass
|
|
1041
|
+
|
|
1042
|
+
# -- restore child node relationships for groups
|
|
1043
|
+
# detach all first to handle moves between groups reliably
|
|
1044
|
+
try:
|
|
1045
|
+
for grp in list(self.groups):
|
|
1046
|
+
try:
|
|
1047
|
+
for n in list(getattr(grp, 'child_nodes', [])):
|
|
1048
|
+
try:
|
|
1049
|
+
if getattr(n, 'parent_group', None) is grp:
|
|
1050
|
+
n.parent_group = None
|
|
1051
|
+
except Exception:
|
|
1052
|
+
pass
|
|
1053
|
+
grp.child_nodes.clear()
|
|
1054
|
+
except Exception:
|
|
1055
|
+
continue
|
|
1056
|
+
except Exception:
|
|
1057
|
+
pass
|
|
1058
|
+
|
|
1059
|
+
for group_data in data.get("groups", []):
|
|
1060
|
+
group = None
|
|
1061
|
+
try:
|
|
1062
|
+
gid = group_data.get("id", None)
|
|
1063
|
+
except Exception:
|
|
1064
|
+
gid = None
|
|
1065
|
+
for g in self.groups:
|
|
1066
|
+
if getattr(g, 'id', None) == gid:
|
|
1067
|
+
group = g
|
|
1068
|
+
break
|
|
1069
|
+
if group is None:
|
|
1070
|
+
continue
|
|
1071
|
+
# v2 key "children", old key "child_node_ids" (clean break: old loads expanded)
|
|
1072
|
+
child_ids = group_data.get(
|
|
1073
|
+
"children", group_data.get("child_node_ids", []))
|
|
1074
|
+
try:
|
|
1075
|
+
node_by_id = {getattr(n, 'id', None): n for n in self.nodes}
|
|
1076
|
+
for child_id in list(child_ids or []):
|
|
1077
|
+
node = node_by_id.get(child_id, None)
|
|
1078
|
+
if node is not None:
|
|
1079
|
+
group.addNode(node)
|
|
1080
|
+
except Exception:
|
|
1081
|
+
pass
|
|
1082
|
+
try:
|
|
1083
|
+
if getattr(group, '_collapsed', False):
|
|
1084
|
+
group.applyCollapsedAfterLoad()
|
|
1085
|
+
else:
|
|
1086
|
+
# mirror expand() visuals so internal edges don't stay hidden
|
|
1087
|
+
# after undo/redo from collapsed -> expanded
|
|
1088
|
+
try:
|
|
1089
|
+
group.restoreExpandedVisuals()
|
|
1090
|
+
except Exception:
|
|
1091
|
+
pass
|
|
1092
|
+
except Exception:
|
|
1093
|
+
pass
|
|
1094
|
+
|
|
1095
|
+
# final pass: collapsed proxy positions need real socket scenePos; refresh
|
|
1096
|
+
try:
|
|
1097
|
+
for grp in list(self.groups):
|
|
1098
|
+
try:
|
|
1099
|
+
if getattr(grp, '_collapsed', False):
|
|
1100
|
+
grp.refreshExternalEdges()
|
|
1101
|
+
except Exception:
|
|
1102
|
+
continue
|
|
1103
|
+
except Exception:
|
|
1104
|
+
pass
|
|
1105
|
+
|
|
1106
|
+
return True
|