nodebpy 0.1.0__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.
nodebpy/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from . import nodes, sockets, screenshot
2
+ from .builder import TreeBuilder
3
+ from .screenshot import generate_mermaid_diagram, save_mermaid_diagram
4
+
5
+ __all__ = [
6
+ "nodes",
7
+ "sockets",
8
+ "screenshot",
9
+ "TreeBuilder",
10
+ "generate_mermaid_diagram",
11
+ "save_mermaid_diagram",
12
+ ]
nodebpy/arrange.py ADDED
@@ -0,0 +1,362 @@
1
+ import typing
2
+ from collections import Counter, deque
3
+ import bpy
4
+ from mathutils import Vector
5
+
6
+
7
+ def contains_geo_socket(sockets: bpy.types.NodeInputs | bpy.types.NodeOutputs) -> bool:
8
+ """
9
+ Check if any socket in the collection is a geometry socket
10
+
11
+ Parameters
12
+ ----------
13
+ sockets : bpy.types.NodeInputs | bpy.types.NodeOutputs
14
+ Collection of node sockets to check
15
+
16
+ Returns
17
+ -------
18
+ bool
19
+ True if any socket is a geometry socket
20
+ """
21
+ return any([s.bl_idname == "NodeSocketGeometry" for s in sockets])
22
+
23
+
24
+ def node_has_geo_socket(node: bpy.types.GeometryNode) -> bool:
25
+ """
26
+ Check if a node has any geometry sockets in its inputs or outputs
27
+
28
+ Parameters
29
+ ----------
30
+ node : bpy.types.GeometryNode
31
+ The node to check
32
+
33
+ Returns
34
+ -------
35
+ bool
36
+ True if the node has at least one geometry socket
37
+ """
38
+ return any([contains_geo_socket(x) for x in [node.inputs, node.outputs]])
39
+
40
+
41
+ def build_dependency_graph(tree: bpy.types.NodeTree) -> tuple[dict, Counter]:
42
+ """
43
+ Build a graph representing node dependencies and count input connections
44
+
45
+ Parameters
46
+ ----------
47
+ tree : bpy.types.NodeTree
48
+ The node tree to analyze
49
+
50
+ Returns
51
+ -------
52
+ tuple
53
+ Contains:
54
+ dict
55
+ Mapping of nodes to their dependent nodes
56
+ Counter
57
+ Count of connections for each socket
58
+ """
59
+ dependency_graph = {node: set() for node in tree.nodes}
60
+ socket_input_connection_count = Counter()
61
+
62
+ # populate the graph based on node connections
63
+ for link in tree.links:
64
+ dependency_graph[link.from_node].add(link.to_node)
65
+ socket_input_connection_count[link.to_socket] += 1
66
+
67
+ return dependency_graph, socket_input_connection_count
68
+
69
+
70
+ def topological_sort(dependency_graph: dict) -> list:
71
+ """
72
+ Sort nodes by their dependencies using a topological sort algorithm
73
+
74
+ Parameters
75
+ ----------
76
+ dependency_graph : dict
77
+ Mapping of nodes to their dependent nodes
78
+
79
+ Returns
80
+ -------
81
+ list
82
+ Nodes sorted in topological order
83
+ """
84
+ # count incoming connections for each node
85
+ incoming_connection_count = {node: 0 for node in dependency_graph}
86
+ for source_node in dependency_graph:
87
+ for target_node in dependency_graph[source_node]:
88
+ incoming_connection_count[target_node] += 1
89
+
90
+ # start with nodes that have no dependencies
91
+ processing_queue = deque(
92
+ [
93
+ node
94
+ for node in incoming_connection_count
95
+ if incoming_connection_count[node] == 0
96
+ ]
97
+ )
98
+ sorted_node_order = []
99
+
100
+ # process nodes in breadth-first order
101
+ while processing_queue:
102
+ current_node = processing_queue.popleft()
103
+ sorted_node_order.append(current_node)
104
+
105
+ # update counts for nodes that depend on the current node
106
+ for dependent_node in dependency_graph[current_node]:
107
+ incoming_connection_count[dependent_node] -= 1
108
+ # if all dependencies are processed, add to queue
109
+ if incoming_connection_count[dependent_node] == 0:
110
+ processing_queue.append(dependent_node)
111
+
112
+ return sorted_node_order
113
+
114
+
115
+ def organize_into_columns(nodes_in_order: list, dependency_graph: dict) -> list:
116
+ """
117
+ Organize nodes into columns based on their dependencies
118
+
119
+ Parameters
120
+ ----------
121
+ nodes_in_order : list
122
+ Nodes sorted in topological order
123
+ dependency_graph : dict
124
+ Mapping of nodes to their dependent nodes
125
+
126
+ Returns
127
+ -------
128
+ list
129
+ Columns of nodes, where each column is a list of nodes
130
+ """
131
+ node_columns = []
132
+ node_column_assignment = {}
133
+
134
+ for node in reversed(nodes_in_order):
135
+ # node goes in column after its furthest dependent
136
+ node_column_assignment[node] = (
137
+ max(
138
+ [
139
+ node_column_assignment[dependent_node]
140
+ for dependent_node in dependency_graph[node]
141
+ ],
142
+ default=-1,
143
+ )
144
+ + 1
145
+ )
146
+
147
+ # add node to its assigned column
148
+ if node_column_assignment[node] == len(node_columns):
149
+ node_columns.append([node])
150
+ else:
151
+ node_columns[node_column_assignment[node]].append(node)
152
+
153
+ # reverse columns to get left-to-right flow
154
+ return list(reversed(node_columns))
155
+
156
+
157
+ def calculate_node_dimensions(
158
+ node: bpy.types.Node, socket_input_connection_count: Counter, interface_scale: float
159
+ ) -> tuple[float, float]:
160
+ """
161
+ Calculate the visual dimensions of a node
162
+
163
+ Parameters
164
+ ----------
165
+ node : bpy.types.Node
166
+ The node to calculate dimensions for
167
+ socket_input_connection_count : Counter
168
+ Counter of connections for each socket
169
+ interface_scale : float
170
+ UI scale factor
171
+
172
+ Returns
173
+ -------
174
+ tuple[float, float]
175
+ Width and height of the node
176
+ """
177
+ # height constants for different node elements
178
+ node_header_height = 20
179
+ node_socket_height = 28
180
+ node_property_row_height = 28
181
+ node_vector_input_height = 84
182
+
183
+ # count enabled inputs and outputs
184
+ enabled_input_count = len(
185
+ list(filter(lambda input_socket: input_socket.enabled, node.inputs))
186
+ )
187
+ enabled_output_count = len(
188
+ list(filter(lambda output_socket: output_socket.enabled, node.outputs))
189
+ )
190
+
191
+ # get properties specific to this node type (not inherited)
192
+ inherited_property_ids = [
193
+ property.identifier
194
+ for base_class in type(node).__bases__
195
+ for property in base_class.bl_rna.properties
196
+ ]
197
+
198
+ node_specific_property_count = len(
199
+ [
200
+ property
201
+ for property in node.bl_rna.properties
202
+ if property.identifier not in inherited_property_ids
203
+ ]
204
+ )
205
+
206
+ # count vector inputs that need UI widgets (not connected)
207
+ unconnected_vector_input_count = len(
208
+ list(
209
+ filter(
210
+ lambda input_socket: input_socket.enabled
211
+ and input_socket.type == "VECTOR"
212
+ and socket_input_connection_count[input_socket] == 0,
213
+ node.inputs,
214
+ )
215
+ )
216
+ )
217
+
218
+ # calculate total node height based on components
219
+ total_node_height = (
220
+ node_header_height
221
+ + (enabled_output_count * node_socket_height)
222
+ + (node_specific_property_count * node_property_row_height)
223
+ + (enabled_input_count * node_socket_height)
224
+ + (unconnected_vector_input_count * node_vector_input_height)
225
+ ) * interface_scale
226
+
227
+ return node.width, total_node_height
228
+
229
+
230
+ def position_nodes_in_columns(
231
+ node_columns: list,
232
+ socket_input_connection_count: Counter,
233
+ spacing: typing.Tuple[float, float] = (50, 25),
234
+ ) -> None:
235
+ """Position nodes in columns with appropriate spacing
236
+
237
+ Parameters
238
+ ----------
239
+ node_columns : list
240
+ List of columns, where each column is a list of nodes
241
+ socket_input_connection_count : Counter
242
+ Counter of connections for each socket
243
+ spacing : tuple of float, optional
244
+ Tuple of (horizontal, vertical) spacing between nodes, by default (50, 25)
245
+ """
246
+ interface_scale = bpy.context.preferences.view.ui_scale
247
+ non_geo_offset = 20 + 28 * 2 # header + 2 socket heights
248
+
249
+ # position nodes column by column
250
+ position_x = 0
251
+ for column in node_columns:
252
+ widest_node_in_column = 0
253
+ position_y = 0
254
+
255
+ for node in column:
256
+ node.update()
257
+
258
+ width, height = calculate_node_dimensions(
259
+ node, socket_input_connection_count, interface_scale
260
+ )
261
+
262
+ # track widest node for column spacing
263
+ if width > widest_node_in_column:
264
+ widest_node_in_column = width
265
+
266
+ # position node
267
+ node.location = (position_x, position_y)
268
+
269
+ # adjust position for non-geometry nodes
270
+ if not node_has_geo_socket(node):
271
+ node.location -= Vector((0, non_geo_offset))
272
+
273
+ # move down for next node with spacing
274
+ position_y -= height + spacing[1]
275
+
276
+ # move right for next column with spacing
277
+ position_x += widest_node_in_column + spacing[0]
278
+
279
+
280
+ def position_special_nodes(
281
+ tree: bpy.types.NodeTree, vertical_offset: float = 100
282
+ ) -> None:
283
+ """Position special nodes like Group Input and Group Output at the top
284
+
285
+ Parameters
286
+ ----------
287
+ tree : bpy.types.NodeTree
288
+ The node tree to modify
289
+ vertical_offset : float, optional
290
+ Vertical offset above the highest node, by default 100
291
+
292
+ Returns
293
+ -------
294
+ None
295
+ """
296
+ highest_y_position = max([node.location[1] for node in tree.nodes])
297
+ for special_node_name in ["Group Input", "Group Output"]:
298
+ if special_node_name in tree.nodes:
299
+ special_node = tree.nodes[special_node_name]
300
+ special_node.location = (
301
+ special_node.location[0],
302
+ highest_y_position + vertical_offset,
303
+ )
304
+
305
+
306
+ def cleanup_orphaned_nodes(
307
+ tree: bpy.types.NodeTree, max_iter: int = 100, add_group_input: bool = True
308
+ ) -> None:
309
+ """Remove nodes that are not connected to anything"""
310
+ to_remove = []
311
+ for _ in range(max_iter):
312
+ for node in tree.nodes:
313
+ if len(node.outputs) == 0:
314
+ continue
315
+ if not any([s.is_linked for s in node.outputs]):
316
+ to_remove.append(node)
317
+
318
+ if len(to_remove) == 0:
319
+ break
320
+
321
+ for node in to_remove:
322
+ tree.nodes.remove(node)
323
+
324
+ to_remove = []
325
+
326
+ if add_group_input and "Group Input" not in tree.nodes:
327
+ n_input = tree.nodes.new("NodeGroupInput")
328
+ if "Join Geometry" in tree.nodes:
329
+ tree.links.new(n_input.outputs[0], tree.nodes["Join Geometry"].inputs[0])
330
+
331
+
332
+ def arrange_tree(
333
+ tree: bpy.types.NodeTree,
334
+ spacing: typing.Tuple[float, float] = (50, 25),
335
+ add_group_input: bool = True,
336
+ ) -> None:
337
+ """Arrange nodes in a node tree based on their dependencies
338
+
339
+ Parameters
340
+ ----------
341
+ tree : bpy.types.GeometryNodeTree
342
+ The node tree to arrange
343
+ spacing : tuple of float
344
+ Tuple of (horizontal, vertical) spacing between nodes
345
+
346
+ Returns
347
+ -------
348
+ None
349
+ This function modifies the node tree in place
350
+
351
+ Notes
352
+ -----
353
+ This function organizes nodes into columns and positions them with appropriate spacing.
354
+ Nodes are arranged from left to right based on their dependencies, with special
355
+ handling for geometry nodes and group input/output nodes.
356
+ """
357
+ cleanup_orphaned_nodes(tree, add_group_input=add_group_input)
358
+ dependency_graph, socket_input_connection_count = build_dependency_graph(tree)
359
+ nodes_in_dependency_order = topological_sort(dependency_graph)
360
+ node_columns = organize_into_columns(nodes_in_dependency_order, dependency_graph)
361
+ position_nodes_in_columns(node_columns, socket_input_connection_count, spacing)
362
+ position_special_nodes(tree)