nodebpy 0.7.2__tar.gz → 0.8.0__tar.gz
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-0.7.2 → nodebpy-0.8.0}/PKG-INFO +24 -22
- {nodebpy-0.7.2 → nodebpy-0.8.0}/README.md +20 -20
- {nodebpy-0.7.2 → nodebpy-0.8.0}/pyproject.toml +6 -4
- nodebpy-0.8.0/src/nodebpy/arrange.py +336 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/builder.py +25 -10
- nodebpy-0.7.2/src/nodebpy/arrange.py +0 -365
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/__init__.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/__init__.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/color.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/group.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/input.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/output.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/texture.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/color.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/group.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/input.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/manual.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/output.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/color.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/converter.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/grid.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/group.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/input.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/interface.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/manual.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/output.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/script.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/shader.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/texture.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/vector.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/nodes/shader/zone.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/screenshot.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/sockets.py +0 -0
- {nodebpy-0.7.2 → nodebpy-0.8.0}/src/nodebpy/types.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: nodebpy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Build nodes trees in Blender more elegantly with code
|
|
5
5
|
Author: Brady Johnston
|
|
6
6
|
Author-email: Brady Johnston <brady.johnston@me.com>
|
|
7
|
-
Requires-Dist: networkx>=3.6.1
|
|
8
7
|
Requires-Dist: bpy>=5.0.1 ; extra == 'bpy'
|
|
9
8
|
Requires-Dist: fake-bpy-module>=20260113 ; extra == 'dev'
|
|
10
9
|
Requires-Dist: jsondiff>=2.2.1 ; extra == 'dev'
|
|
10
|
+
Requires-Dist: networkx>=3.6.1 ; extra == 'dev'
|
|
11
11
|
Requires-Dist: pytest>=9.0.2 ; extra == 'dev'
|
|
12
12
|
Requires-Dist: pytest-cov>=7.0.0 ; extra == 'dev'
|
|
13
13
|
Requires-Dist: quarto-cli>=1.8.26 ; extra == 'dev'
|
|
@@ -16,10 +16,12 @@ Requires-Dist: ruff>=0.14.11 ; extra == 'dev'
|
|
|
16
16
|
Requires-Dist: syrupy>=5.0.0 ; extra == 'dev'
|
|
17
17
|
Requires-Dist: tree-clipper>=0.1.1 ; extra == 'dev'
|
|
18
18
|
Requires-Dist: ipython>=8.0.0 ; extra == 'jupyter'
|
|
19
|
+
Requires-Dist: networkx>=3.6.1 ; extra == 'networkx'
|
|
19
20
|
Requires-Python: >=3.11
|
|
20
21
|
Provides-Extra: bpy
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Provides-Extra: jupyter
|
|
24
|
+
Provides-Extra: networkx
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
24
26
|
|
|
25
27
|
|
|
@@ -57,9 +59,9 @@ In `nodebpy` we use the `>>` operator to link from one node or socket into anoth
|
|
|
57
59
|
This should feel and behave much like the <kbd>Alt</kbd> + <kbd>Right Click</kbd> drag between nodes in [Node Wrangler](https://docs.blender.org/manual/en/latest/addons/node/node_wrangler.html). It will use some smart logic to match the most compatible sockets between the nodes, but if you ever want to be explicit you do so. The input and output sockets of a node are accessible as properties via the `i_*` and `o_*` prefixes, or you can use the `...` placeholder to specify the particular input to be user, or pass in the previous node as a named argument.
|
|
58
60
|
|
|
59
61
|
```py
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
g.Vector() >> g.SetPosition().i_offset
|
|
63
|
+
g.Vector() >> g.SetPosition(offset=...)
|
|
64
|
+
g.SetPosition(offset=g.Vector())
|
|
63
65
|
```
|
|
64
66
|
|
|
65
67
|
The `>>` operator will always look for the _most_ compatible sockets first (matching data types) before looking for other compatible but not identical socket data types to link.
|
|
@@ -76,7 +78,7 @@ Entering the `tree.inputs` and `tree.outputs` contexts will let you add new inte
|
|
|
76
78
|
|
|
77
79
|
```py
|
|
78
80
|
with TreeBuilder("MyTree") as tree:
|
|
79
|
-
points =
|
|
81
|
+
points = g.Points(position=g.RandomValue.vector(min=-1))
|
|
80
82
|
with tree.outputs:
|
|
81
83
|
points >> s.SocketGeometry("New Points")
|
|
82
84
|
```
|
|
@@ -86,7 +88,7 @@ with TreeBuilder("MyTree") as tree:
|
|
|
86
88
|
The node tree below creates a integer input and geometry output to the node group. We create a `rotation` variable that can be used later on as an argument, then construct a longer chain of nodes being created and linked together. The nodes are added and linked as each node is instantiated. After we exit the tree context, the nodes are automatically arranged.
|
|
87
89
|
|
|
88
90
|
``` python
|
|
89
|
-
from nodebpy import TreeBuilder,
|
|
91
|
+
from nodebpy import TreeBuilder, geometry as g, sockets as s
|
|
90
92
|
|
|
91
93
|
with TreeBuilder("AnotherTree", collapse=True) as tree:
|
|
92
94
|
with tree.inputs:
|
|
@@ -95,21 +97,21 @@ with TreeBuilder("AnotherTree", collapse=True) as tree:
|
|
|
95
97
|
instances = s.SocketGeometry("Instances")
|
|
96
98
|
|
|
97
99
|
rotation = (
|
|
98
|
-
|
|
99
|
-
>>
|
|
100
|
-
>>
|
|
100
|
+
g.RandomValue.vector(min=-1, seed=2)
|
|
101
|
+
>> g.AlignRotationToVector()
|
|
102
|
+
>> g.RotateRotation(rotate_by=g.AxisAngleToRotation(angle=0.3))
|
|
101
103
|
)
|
|
102
104
|
|
|
103
105
|
_ = (
|
|
104
106
|
count
|
|
105
|
-
>>
|
|
106
|
-
>>
|
|
107
|
-
>>
|
|
108
|
-
position=
|
|
107
|
+
>> g.Points(position=g.RandomValue.vector(min=-1))
|
|
108
|
+
>> g.InstanceOnPoints(instance=g.Cube(), rotation=rotation)
|
|
109
|
+
>> g.SetPosition(
|
|
110
|
+
position=g.Position() * 2.0 + (0, 0.2, 0.3),
|
|
109
111
|
offset=(0, 0, 0.1),
|
|
110
112
|
)
|
|
111
|
-
>>
|
|
112
|
-
>>
|
|
113
|
+
>> g.RealizeInstances()
|
|
114
|
+
>> g.InstanceOnPoints(g.Cube(), instance=...)
|
|
113
115
|
>> instances
|
|
114
116
|
)
|
|
115
117
|
```
|
|
@@ -126,16 +128,16 @@ The basic math operators also automatically add relevant nodes with their operat
|
|
|
126
128
|
|
|
127
129
|
```py
|
|
128
130
|
# operation is exposed as a property
|
|
129
|
-
math =
|
|
131
|
+
math = g.Math(1.0, 2.0, operation='ADD')
|
|
130
132
|
math.operation = "SUBTRACT"
|
|
131
133
|
|
|
132
134
|
# operation can be chose as a class method
|
|
133
|
-
math =
|
|
134
|
-
math =
|
|
135
|
+
math = g.Math.subtract(1.0, 2.0)
|
|
136
|
+
math = g.Math.add(1.0, 2.0)
|
|
135
137
|
|
|
136
|
-
# these are equivalent, the
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
# these are equivalent, the g.Math.multiply is automatically added
|
|
139
|
+
g.Value(1.0) * 2
|
|
140
|
+
g.Math.multiply(g.Value(1.0), 2.0)
|
|
139
141
|
```
|
|
140
142
|
|
|
141
143
|
# Design Considerations
|
|
@@ -33,9 +33,9 @@ In `nodebpy` we use the `>>` operator to link from one node or socket into anoth
|
|
|
33
33
|
This should feel and behave much like the <kbd>Alt</kbd> + <kbd>Right Click</kbd> drag between nodes in [Node Wrangler](https://docs.blender.org/manual/en/latest/addons/node/node_wrangler.html). It will use some smart logic to match the most compatible sockets between the nodes, but if you ever want to be explicit you do so. The input and output sockets of a node are accessible as properties via the `i_*` and `o_*` prefixes, or you can use the `...` placeholder to specify the particular input to be user, or pass in the previous node as a named argument.
|
|
34
34
|
|
|
35
35
|
```py
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
g.Vector() >> g.SetPosition().i_offset
|
|
37
|
+
g.Vector() >> g.SetPosition(offset=...)
|
|
38
|
+
g.SetPosition(offset=g.Vector())
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
The `>>` operator will always look for the _most_ compatible sockets first (matching data types) before looking for other compatible but not identical socket data types to link.
|
|
@@ -52,7 +52,7 @@ Entering the `tree.inputs` and `tree.outputs` contexts will let you add new inte
|
|
|
52
52
|
|
|
53
53
|
```py
|
|
54
54
|
with TreeBuilder("MyTree") as tree:
|
|
55
|
-
points =
|
|
55
|
+
points = g.Points(position=g.RandomValue.vector(min=-1))
|
|
56
56
|
with tree.outputs:
|
|
57
57
|
points >> s.SocketGeometry("New Points")
|
|
58
58
|
```
|
|
@@ -62,7 +62,7 @@ with TreeBuilder("MyTree") as tree:
|
|
|
62
62
|
The node tree below creates a integer input and geometry output to the node group. We create a `rotation` variable that can be used later on as an argument, then construct a longer chain of nodes being created and linked together. The nodes are added and linked as each node is instantiated. After we exit the tree context, the nodes are automatically arranged.
|
|
63
63
|
|
|
64
64
|
``` python
|
|
65
|
-
from nodebpy import TreeBuilder,
|
|
65
|
+
from nodebpy import TreeBuilder, geometry as g, sockets as s
|
|
66
66
|
|
|
67
67
|
with TreeBuilder("AnotherTree", collapse=True) as tree:
|
|
68
68
|
with tree.inputs:
|
|
@@ -71,21 +71,21 @@ with TreeBuilder("AnotherTree", collapse=True) as tree:
|
|
|
71
71
|
instances = s.SocketGeometry("Instances")
|
|
72
72
|
|
|
73
73
|
rotation = (
|
|
74
|
-
|
|
75
|
-
>>
|
|
76
|
-
>>
|
|
74
|
+
g.RandomValue.vector(min=-1, seed=2)
|
|
75
|
+
>> g.AlignRotationToVector()
|
|
76
|
+
>> g.RotateRotation(rotate_by=g.AxisAngleToRotation(angle=0.3))
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
_ = (
|
|
80
80
|
count
|
|
81
|
-
>>
|
|
82
|
-
>>
|
|
83
|
-
>>
|
|
84
|
-
position=
|
|
81
|
+
>> g.Points(position=g.RandomValue.vector(min=-1))
|
|
82
|
+
>> g.InstanceOnPoints(instance=g.Cube(), rotation=rotation)
|
|
83
|
+
>> g.SetPosition(
|
|
84
|
+
position=g.Position() * 2.0 + (0, 0.2, 0.3),
|
|
85
85
|
offset=(0, 0, 0.1),
|
|
86
86
|
)
|
|
87
|
-
>>
|
|
88
|
-
>>
|
|
87
|
+
>> g.RealizeInstances()
|
|
88
|
+
>> g.InstanceOnPoints(g.Cube(), instance=...)
|
|
89
89
|
>> instances
|
|
90
90
|
)
|
|
91
91
|
```
|
|
@@ -102,16 +102,16 @@ The basic math operators also automatically add relevant nodes with their operat
|
|
|
102
102
|
|
|
103
103
|
```py
|
|
104
104
|
# operation is exposed as a property
|
|
105
|
-
math =
|
|
105
|
+
math = g.Math(1.0, 2.0, operation='ADD')
|
|
106
106
|
math.operation = "SUBTRACT"
|
|
107
107
|
|
|
108
108
|
# operation can be chose as a class method
|
|
109
|
-
math =
|
|
110
|
-
math =
|
|
109
|
+
math = g.Math.subtract(1.0, 2.0)
|
|
110
|
+
math = g.Math.add(1.0, 2.0)
|
|
111
111
|
|
|
112
|
-
# these are equivalent, the
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
# these are equivalent, the g.Math.multiply is automatically added
|
|
113
|
+
g.Value(1.0) * 2
|
|
114
|
+
g.Math.multiply(g.Value(1.0), 2.0)
|
|
115
115
|
```
|
|
116
116
|
|
|
117
117
|
# Design Considerations
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nodebpy"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Build nodes trees in Blender more elegantly with code"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Brady Johnston", email = "brady.johnston@me.com" }
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
|
-
dependencies = [
|
|
11
|
-
"networkx>=3.6.1",
|
|
12
|
-
]
|
|
10
|
+
dependencies = []
|
|
13
11
|
|
|
14
12
|
[project.scripts]
|
|
15
13
|
nodebpy = "nodebpy:main"
|
|
16
14
|
|
|
17
15
|
[project.optional-dependencies]
|
|
16
|
+
networkx = [
|
|
17
|
+
"networkx>=3.6.1",
|
|
18
|
+
]
|
|
18
19
|
bpy = [
|
|
19
20
|
"bpy>=5.0.1",
|
|
20
21
|
]
|
|
@@ -24,6 +25,7 @@ jupyter = [
|
|
|
24
25
|
dev = [
|
|
25
26
|
"fake-bpy-module>=20260113",
|
|
26
27
|
"jsondiff>=2.2.1",
|
|
28
|
+
"networkx>=3.6.1",
|
|
27
29
|
"pytest>=9.0.2",
|
|
28
30
|
"pytest-cov>=7.0.0",
|
|
29
31
|
"quarto-cli>=1.8.26",
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter, deque
|
|
4
|
+
|
|
5
|
+
import bpy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _is_layoutable(node: bpy.types.Node) -> bool:
|
|
9
|
+
"""Check if a node should be included in column-based layout.
|
|
10
|
+
|
|
11
|
+
Frame nodes are containers and reroute nodes are tiny routing helpers;
|
|
12
|
+
neither should occupy a full column slot.
|
|
13
|
+
"""
|
|
14
|
+
return node.bl_idname not in ("NodeFrame", "NodeReroute")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_dependency_graph(
|
|
18
|
+
tree: bpy.types.NodeTree,
|
|
19
|
+
) -> tuple[dict[bpy.types.Node, set[bpy.types.Node]], Counter]:
|
|
20
|
+
"""Build a graph of node dependencies and count input connections.
|
|
21
|
+
|
|
22
|
+
Only layoutable nodes (excluding frames and reroutes) are included.
|
|
23
|
+
"""
|
|
24
|
+
layoutable = {n for n in tree.nodes if _is_layoutable(n)}
|
|
25
|
+
dependency_graph: dict[bpy.types.Node, set[bpy.types.Node]] = {
|
|
26
|
+
node: set() for node in layoutable
|
|
27
|
+
}
|
|
28
|
+
socket_input_connection_count: Counter = Counter()
|
|
29
|
+
|
|
30
|
+
for link in tree.links:
|
|
31
|
+
if link.from_node in layoutable and link.to_node in layoutable:
|
|
32
|
+
dependency_graph[link.from_node].add(link.to_node)
|
|
33
|
+
socket_input_connection_count[link.to_socket] += 1
|
|
34
|
+
|
|
35
|
+
return dependency_graph, socket_input_connection_count
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def topological_sort(
|
|
39
|
+
dependency_graph: dict[bpy.types.Node, set[bpy.types.Node]],
|
|
40
|
+
) -> list[bpy.types.Node]:
|
|
41
|
+
"""Sort nodes in topological (dependency) order using Kahn's algorithm."""
|
|
42
|
+
incoming = {node: 0 for node in dependency_graph}
|
|
43
|
+
for dependents in dependency_graph.values():
|
|
44
|
+
for target in dependents:
|
|
45
|
+
incoming[target] += 1
|
|
46
|
+
|
|
47
|
+
queue = deque(node for node, count in incoming.items() if count == 0)
|
|
48
|
+
result: list[bpy.types.Node] = []
|
|
49
|
+
|
|
50
|
+
while queue:
|
|
51
|
+
current = queue.popleft()
|
|
52
|
+
result.append(current)
|
|
53
|
+
for dependent in dependency_graph[current]:
|
|
54
|
+
incoming[dependent] -= 1
|
|
55
|
+
if incoming[dependent] == 0:
|
|
56
|
+
queue.append(dependent)
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def organize_into_columns(
|
|
62
|
+
nodes_in_order: list[bpy.types.Node],
|
|
63
|
+
dependency_graph: dict[bpy.types.Node, set[bpy.types.Node]],
|
|
64
|
+
) -> list[list[bpy.types.Node]]:
|
|
65
|
+
"""Assign each node to a column based on its furthest dependent."""
|
|
66
|
+
columns: list[list[bpy.types.Node]] = []
|
|
67
|
+
column_of: dict[bpy.types.Node, int] = {}
|
|
68
|
+
|
|
69
|
+
for node in reversed(nodes_in_order):
|
|
70
|
+
col = (
|
|
71
|
+
max(
|
|
72
|
+
(column_of[dep] for dep in dependency_graph[node]),
|
|
73
|
+
default=-1,
|
|
74
|
+
)
|
|
75
|
+
+ 1
|
|
76
|
+
)
|
|
77
|
+
column_of[node] = col
|
|
78
|
+
|
|
79
|
+
if col == len(columns):
|
|
80
|
+
columns.append([node])
|
|
81
|
+
else:
|
|
82
|
+
columns[col].append(node)
|
|
83
|
+
|
|
84
|
+
# reverse so flow goes left-to-right
|
|
85
|
+
return list(reversed(columns))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def calculate_node_dimensions(
|
|
89
|
+
node: bpy.types.Node,
|
|
90
|
+
socket_input_connection_count: Counter,
|
|
91
|
+
interface_scale: float,
|
|
92
|
+
) -> tuple[float, float]:
|
|
93
|
+
"""Calculate the visual dimensions of a node.
|
|
94
|
+
|
|
95
|
+
When a node is collapsed (``node.hide is True``) only linked sockets
|
|
96
|
+
contribute to the height, and header / property / vector-expansion rows
|
|
97
|
+
are omitted.
|
|
98
|
+
"""
|
|
99
|
+
HEADER = 20
|
|
100
|
+
SOCKET = 32
|
|
101
|
+
HIDDEN_SOCKET = 14
|
|
102
|
+
HIDDEN_HEADER = 30
|
|
103
|
+
|
|
104
|
+
if node.hide:
|
|
105
|
+
linked_inputs = sum(1 for s in node.inputs if s.enabled and s.is_linked)
|
|
106
|
+
linked_outputs = sum(1 for s in node.outputs if s.enabled and s.is_linked)
|
|
107
|
+
visible = max(linked_inputs, linked_outputs, 1)
|
|
108
|
+
height = (HIDDEN_HEADER + visible * HIDDEN_SOCKET) * interface_scale
|
|
109
|
+
return node.width, height
|
|
110
|
+
PROPERTY_ROW = 28
|
|
111
|
+
VECTOR_EXPANDED = 84
|
|
112
|
+
|
|
113
|
+
enabled_inputs = sum(1 for s in node.inputs if s.enabled)
|
|
114
|
+
enabled_outputs = sum(1 for s in node.outputs if s.enabled)
|
|
115
|
+
|
|
116
|
+
# count properties specific to this node type (not inherited)
|
|
117
|
+
inherited_ids = {
|
|
118
|
+
prop.identifier
|
|
119
|
+
for base in type(node).__bases__
|
|
120
|
+
for prop in base.bl_rna.properties
|
|
121
|
+
}
|
|
122
|
+
node_property_count = sum(
|
|
123
|
+
1 for prop in node.bl_rna.properties if prop.identifier not in inherited_ids
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# count vector inputs that need expanded UI widgets (not connected)
|
|
127
|
+
unconnected_vectors = sum(
|
|
128
|
+
1
|
|
129
|
+
for s in node.inputs
|
|
130
|
+
if s.enabled and s.type == "VECTOR" and socket_input_connection_count[s] == 0
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
height = (
|
|
134
|
+
HEADER
|
|
135
|
+
+ enabled_outputs * SOCKET
|
|
136
|
+
+ node_property_count * PROPERTY_ROW
|
|
137
|
+
+ enabled_inputs * SOCKET
|
|
138
|
+
+ unconnected_vectors * VECTOR_EXPANDED
|
|
139
|
+
) * interface_scale
|
|
140
|
+
|
|
141
|
+
return node.width, height
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _socket_index(socket: bpy.types.NodeSocket) -> int:
|
|
145
|
+
"""Return the index of a socket among its node's enabled sockets."""
|
|
146
|
+
collection = socket.node.inputs if not socket.is_output else socket.node.outputs
|
|
147
|
+
idx = 0
|
|
148
|
+
for s in collection:
|
|
149
|
+
if s == socket:
|
|
150
|
+
return idx
|
|
151
|
+
if s.enabled:
|
|
152
|
+
idx += 1
|
|
153
|
+
return idx
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _reduce_crossings(
|
|
157
|
+
columns: list[list[bpy.types.Node]],
|
|
158
|
+
tree: bpy.types.NodeTree,
|
|
159
|
+
passes: int = 4,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Reorder nodes within columns to reduce edge crossings.
|
|
162
|
+
|
|
163
|
+
Uses the barycenter heuristic with socket-level precision: for each node
|
|
164
|
+
compute its weight from the position of the sockets it connects to in the
|
|
165
|
+
adjacent column, then sort by that weight. This correctly distinguishes
|
|
166
|
+
nodes that connect to different sockets on the same target.
|
|
167
|
+
|
|
168
|
+
Alternating forward and backward sweeps iteratively improve the ordering.
|
|
169
|
+
"""
|
|
170
|
+
if len(columns) < 2:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
layoutable = {n for col in columns for n in col}
|
|
174
|
+
col_of = {n: ci for ci, col in enumerate(columns) for n in col}
|
|
175
|
+
|
|
176
|
+
# Pre-compute per-node link weights towards each adjacent column direction.
|
|
177
|
+
# For a forward sweep (fixing col i, sorting col i+1), a node in col i+1
|
|
178
|
+
# cares about its connections INTO col i. The weight of each connection is
|
|
179
|
+
# the position of the *node* in the fixed column plus a fractional offset
|
|
180
|
+
# derived from the socket index, so that multiple links to the same node
|
|
181
|
+
# produce distinct, correctly ordered weights.
|
|
182
|
+
#
|
|
183
|
+
# We store raw (neighbour_node, socket_fraction) pairs per node per
|
|
184
|
+
# direction and resolve them during each sweep once column order is known.
|
|
185
|
+
|
|
186
|
+
# link records: for each layoutable node, collect tuples of
|
|
187
|
+
# (neighbour_node, socket_fraction)
|
|
188
|
+
# keyed by which side the neighbour is on (left or right).
|
|
189
|
+
left_links: dict[bpy.types.Node, list[tuple[bpy.types.Node, float]]] = {
|
|
190
|
+
n: [] for n in layoutable
|
|
191
|
+
}
|
|
192
|
+
right_links: dict[bpy.types.Node, list[tuple[bpy.types.Node, float]]] = {
|
|
193
|
+
n: [] for n in layoutable
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for link in tree.links:
|
|
197
|
+
src, dst = link.from_node, link.to_node
|
|
198
|
+
if src not in layoutable or dst not in layoutable:
|
|
199
|
+
continue
|
|
200
|
+
src_col, dst_col = col_of[src], col_of[dst]
|
|
201
|
+
if src_col >= dst_col:
|
|
202
|
+
continue # only consider forward edges
|
|
203
|
+
|
|
204
|
+
# Weight based on socket position on the neighbour node.
|
|
205
|
+
# For a node in the right column looking left: the relevant socket is
|
|
206
|
+
# on the source node (output side).
|
|
207
|
+
# For a node in the left column looking right: the relevant socket is
|
|
208
|
+
# on the target node (input side).
|
|
209
|
+
out_count = max(1, sum(1 for s in src.outputs if s.enabled))
|
|
210
|
+
in_count = max(1, sum(1 for s in dst.inputs if s.enabled))
|
|
211
|
+
src_frac = _socket_index(link.from_socket) / out_count
|
|
212
|
+
dst_frac = _socket_index(link.to_socket) / in_count
|
|
213
|
+
|
|
214
|
+
# dst looks left towards src: weight by src's output socket position
|
|
215
|
+
left_links[dst].append((src, src_frac))
|
|
216
|
+
# src looks right towards dst: weight by dst's input socket position
|
|
217
|
+
right_links[src].append((dst, dst_frac))
|
|
218
|
+
|
|
219
|
+
for iteration in range(passes):
|
|
220
|
+
if iteration % 2 == 0:
|
|
221
|
+
# forward sweep: fix column i, sort column i+1
|
|
222
|
+
col_range = range(1, len(columns))
|
|
223
|
+
else:
|
|
224
|
+
# backward sweep: fix column i, sort column i-1
|
|
225
|
+
col_range = range(len(columns) - 2, -1, -1)
|
|
226
|
+
|
|
227
|
+
for ci in col_range:
|
|
228
|
+
if iteration % 2 == 0:
|
|
229
|
+
fixed_col = columns[ci - 1]
|
|
230
|
+
links_map = left_links
|
|
231
|
+
else:
|
|
232
|
+
fixed_col = columns[ci + 1]
|
|
233
|
+
links_map = right_links
|
|
234
|
+
|
|
235
|
+
pos_in_fixed = {node: idx for idx, node in enumerate(fixed_col)}
|
|
236
|
+
original_pos = {node: float(idx) for idx, node in enumerate(columns[ci])}
|
|
237
|
+
|
|
238
|
+
barycenters: dict[bpy.types.Node, float] = {}
|
|
239
|
+
for node in columns[ci]:
|
|
240
|
+
weights = [
|
|
241
|
+
pos_in_fixed[nb] + frac
|
|
242
|
+
for nb, frac in links_map[node]
|
|
243
|
+
if nb in pos_in_fixed
|
|
244
|
+
]
|
|
245
|
+
if weights:
|
|
246
|
+
barycenters[node] = sum(weights) / len(weights)
|
|
247
|
+
else:
|
|
248
|
+
barycenters[node] = original_pos[node]
|
|
249
|
+
|
|
250
|
+
columns[ci].sort(key=lambda n: barycenters[n])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def position_nodes_in_columns(
|
|
254
|
+
columns: list[list[bpy.types.Node]],
|
|
255
|
+
connection_counts: Counter,
|
|
256
|
+
spacing: tuple[float, float] = (50, 25),
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Position nodes column-by-column with the given spacing.
|
|
259
|
+
|
|
260
|
+
Consecutive collapsed nodes are stacked tightly (with minimal gap) to
|
|
261
|
+
keep related math/converter chains visually grouped together.
|
|
262
|
+
"""
|
|
263
|
+
COLLAPSED_GAP = 4
|
|
264
|
+
|
|
265
|
+
x = 0.0
|
|
266
|
+
for column in columns:
|
|
267
|
+
col_width = 0.0
|
|
268
|
+
y = 0.0
|
|
269
|
+
prev_hidden = False
|
|
270
|
+
|
|
271
|
+
for node in column:
|
|
272
|
+
node.update()
|
|
273
|
+
|
|
274
|
+
width, height = calculate_node_dimensions(node, connection_counts, 1.0)
|
|
275
|
+
|
|
276
|
+
if width > col_width:
|
|
277
|
+
col_width = width
|
|
278
|
+
|
|
279
|
+
node.location = (x, y)
|
|
280
|
+
|
|
281
|
+
# use tight spacing between consecutive collapsed nodes
|
|
282
|
+
if node.hide and prev_hidden:
|
|
283
|
+
y -= height + COLLAPSED_GAP
|
|
284
|
+
else:
|
|
285
|
+
y -= height + spacing[1]
|
|
286
|
+
|
|
287
|
+
prev_hidden = node.hide
|
|
288
|
+
|
|
289
|
+
x += col_width + spacing[0]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def position_reroutes(tree: bpy.types.NodeTree) -> None:
|
|
293
|
+
"""Place reroute nodes midway between their source and target."""
|
|
294
|
+
for node in tree.nodes:
|
|
295
|
+
if node.bl_idname != "NodeReroute":
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
sources: list[bpy.types.Node] = []
|
|
299
|
+
targets: list[bpy.types.Node] = []
|
|
300
|
+
for link in tree.links:
|
|
301
|
+
if link.to_node == node:
|
|
302
|
+
sources.append(link.from_node)
|
|
303
|
+
if link.from_node == node:
|
|
304
|
+
targets.append(link.to_node)
|
|
305
|
+
|
|
306
|
+
neighbours = sources + targets
|
|
307
|
+
if not neighbours:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
avg_x = sum(n.location.x for n in neighbours) / len(neighbours)
|
|
311
|
+
avg_y = sum(n.location.y for n in neighbours) / len(neighbours)
|
|
312
|
+
node.location = (avg_x, avg_y)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def arrange_tree(
|
|
316
|
+
tree: bpy.types.NodeTree,
|
|
317
|
+
spacing: tuple[float, float] = (50, 25),
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Arrange nodes in a node tree based on their dependencies.
|
|
320
|
+
|
|
321
|
+
Organises layoutable nodes into columns from left to right and positions
|
|
322
|
+
reroute nodes between their neighbours. Frame nodes are left untouched.
|
|
323
|
+
"""
|
|
324
|
+
if not tree.nodes:
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
dependency_graph, connection_counts = build_dependency_graph(tree)
|
|
328
|
+
|
|
329
|
+
if not dependency_graph:
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
nodes_in_order = topological_sort(dependency_graph)
|
|
333
|
+
columns = organize_into_columns(nodes_in_order, dependency_graph)
|
|
334
|
+
_reduce_crossings(columns, tree)
|
|
335
|
+
position_nodes_in_columns(columns, connection_counts, spacing)
|
|
336
|
+
position_reroutes(tree)
|
|
@@ -18,7 +18,7 @@ from bpy.types import (
|
|
|
18
18
|
ShaderNodeTree,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
-
from .
|
|
21
|
+
from .arrange import arrange_tree
|
|
22
22
|
from .types import (
|
|
23
23
|
LINKABLE,
|
|
24
24
|
SOCKET_COMPATIBILITY,
|
|
@@ -32,8 +32,6 @@ from .types import (
|
|
|
32
32
|
_SocketShapeStructureType,
|
|
33
33
|
)
|
|
34
34
|
|
|
35
|
-
# from .arrange import arrange_tree
|
|
36
|
-
|
|
37
35
|
GEO_NODE_NAMES = (
|
|
38
36
|
f"GeometryNode{name}"
|
|
39
37
|
for name in (
|
|
@@ -169,7 +167,7 @@ class TreeBuilder:
|
|
|
169
167
|
"GeometryNodeTree", "ShaderNodeTree", "CompositorNodeTree"
|
|
170
168
|
] = "GeometryNodeTree",
|
|
171
169
|
collapse: bool = False,
|
|
172
|
-
arrange:
|
|
170
|
+
arrange: Literal["sugiyama", "simple"] | None = "sugiyama",
|
|
173
171
|
fake_user: bool = False,
|
|
174
172
|
):
|
|
175
173
|
if isinstance(tree, str):
|
|
@@ -191,7 +189,7 @@ class TreeBuilder:
|
|
|
191
189
|
name: GeometryNodeTree | str = "Geometry Nodes",
|
|
192
190
|
*,
|
|
193
191
|
collapse: bool = False,
|
|
194
|
-
arrange:
|
|
192
|
+
arrange: Literal["sugiyama", "simple"] | None = "sugiyama",
|
|
195
193
|
fake_user: bool = False,
|
|
196
194
|
) -> "TreeBuilder":
|
|
197
195
|
"""Create a geometry node tree."""
|
|
@@ -209,7 +207,7 @@ class TreeBuilder:
|
|
|
209
207
|
name: ShaderNodeTree | str = "Shader Nodes",
|
|
210
208
|
*,
|
|
211
209
|
collapse: bool = False,
|
|
212
|
-
arrange:
|
|
210
|
+
arrange: Literal["sugiyama", "simple"] | None = "sugiyama",
|
|
213
211
|
fake_user: bool = False,
|
|
214
212
|
) -> "TreeBuilder":
|
|
215
213
|
"""Create a shader node tree."""
|
|
@@ -227,7 +225,7 @@ class TreeBuilder:
|
|
|
227
225
|
name: CompositorNodeTree | str = "Compositor Nodes",
|
|
228
226
|
*,
|
|
229
227
|
collapse: bool = False,
|
|
230
|
-
arrange:
|
|
228
|
+
arrange: Literal["sugiyama", "simple"] | None = "sugiyama",
|
|
231
229
|
fake_user: bool = False,
|
|
232
230
|
) -> "TreeBuilder":
|
|
233
231
|
"""Create a compositor node tree."""
|
|
@@ -264,7 +262,7 @@ class TreeBuilder:
|
|
|
264
262
|
return self
|
|
265
263
|
|
|
266
264
|
def __exit__(self, *args):
|
|
267
|
-
if self._arrange:
|
|
265
|
+
if self._arrange is not None:
|
|
268
266
|
self.arrange()
|
|
269
267
|
self._apply_input_defaults()
|
|
270
268
|
self.deactivate_tree()
|
|
@@ -280,8 +278,25 @@ class TreeBuilder:
|
|
|
280
278
|
return len(self.nodes)
|
|
281
279
|
|
|
282
280
|
def arrange(self):
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
if self._arrange == "sugiyama":
|
|
282
|
+
try:
|
|
283
|
+
from .lib.nodearrange import arrange as nodearrange
|
|
284
|
+
|
|
285
|
+
nodearrange.sugiyama.sugiyama_layout(self.tree)
|
|
286
|
+
nodearrange.sugiyama.config.reset()
|
|
287
|
+
except ImportError as e:
|
|
288
|
+
if "networkx" not in str(e):
|
|
289
|
+
raise
|
|
290
|
+
import warnings
|
|
291
|
+
|
|
292
|
+
warnings.warn(
|
|
293
|
+
"networkx is not installed, falling back to simple arrangement. "
|
|
294
|
+
"Install networkx for the Sugiyama layout: pip install nodebpy[networkx]",
|
|
295
|
+
stacklevel=2,
|
|
296
|
+
)
|
|
297
|
+
arrange_tree(self.tree)
|
|
298
|
+
elif self._arrange == "simple":
|
|
299
|
+
arrange_tree(self.tree)
|
|
285
300
|
|
|
286
301
|
def _repr_markdown_(self) -> str | None:
|
|
287
302
|
"""
|
|
@@ -1,365 +0,0 @@
|
|
|
1
|
-
import typing
|
|
2
|
-
from collections import Counter, deque
|
|
3
|
-
|
|
4
|
-
import bpy
|
|
5
|
-
from mathutils import Vector
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def contains_geo_socket(sockets: bpy.types.NodeInputs | bpy.types.NodeOutputs) -> bool:
|
|
9
|
-
"""
|
|
10
|
-
Check if any socket in the collection is a geometry socket
|
|
11
|
-
|
|
12
|
-
Parameters
|
|
13
|
-
----------
|
|
14
|
-
sockets : bpy.types.NodeInputs | bpy.types.NodeOutputs
|
|
15
|
-
Collection of node sockets to check
|
|
16
|
-
|
|
17
|
-
Returns
|
|
18
|
-
-------
|
|
19
|
-
bool
|
|
20
|
-
True if any socket is a geometry socket
|
|
21
|
-
"""
|
|
22
|
-
return any([s.bl_idname == "NodeSocketGeometry" for s in sockets])
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def node_has_geo_socket(node: bpy.types.GeometryNode) -> bool:
|
|
26
|
-
"""
|
|
27
|
-
Check if a node has any geometry sockets in its inputs or outputs
|
|
28
|
-
|
|
29
|
-
Parameters
|
|
30
|
-
----------
|
|
31
|
-
node : bpy.types.GeometryNode
|
|
32
|
-
The node to check
|
|
33
|
-
|
|
34
|
-
Returns
|
|
35
|
-
-------
|
|
36
|
-
bool
|
|
37
|
-
True if the node has at least one geometry socket
|
|
38
|
-
"""
|
|
39
|
-
return any([contains_geo_socket(x) for x in [node.inputs, node.outputs]])
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def build_dependency_graph(tree: bpy.types.NodeTree) -> tuple[dict, Counter]:
|
|
43
|
-
"""
|
|
44
|
-
Build a graph representing node dependencies and count input connections
|
|
45
|
-
|
|
46
|
-
Parameters
|
|
47
|
-
----------
|
|
48
|
-
tree : bpy.types.NodeTree
|
|
49
|
-
The node tree to analyze
|
|
50
|
-
|
|
51
|
-
Returns
|
|
52
|
-
-------
|
|
53
|
-
tuple
|
|
54
|
-
Contains:
|
|
55
|
-
dict
|
|
56
|
-
Mapping of nodes to their dependent nodes
|
|
57
|
-
Counter
|
|
58
|
-
Count of connections for each socket
|
|
59
|
-
"""
|
|
60
|
-
dependency_graph = {node: set() for node in tree.nodes}
|
|
61
|
-
socket_input_connection_count = Counter()
|
|
62
|
-
|
|
63
|
-
# populate the graph based on node connections
|
|
64
|
-
for link in tree.links:
|
|
65
|
-
dependency_graph[link.from_node].add(link.to_node)
|
|
66
|
-
socket_input_connection_count[link.to_socket] += 1
|
|
67
|
-
|
|
68
|
-
return dependency_graph, socket_input_connection_count
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def topological_sort(dependency_graph: dict) -> list:
|
|
72
|
-
"""
|
|
73
|
-
Sort nodes by their dependencies using a topological sort algorithm
|
|
74
|
-
|
|
75
|
-
Parameters
|
|
76
|
-
----------
|
|
77
|
-
dependency_graph : dict
|
|
78
|
-
Mapping of nodes to their dependent nodes
|
|
79
|
-
|
|
80
|
-
Returns
|
|
81
|
-
-------
|
|
82
|
-
list
|
|
83
|
-
Nodes sorted in topological order
|
|
84
|
-
"""
|
|
85
|
-
# count incoming connections for each node
|
|
86
|
-
incoming_connection_count = {node: 0 for node in dependency_graph}
|
|
87
|
-
for source_node in dependency_graph:
|
|
88
|
-
for target_node in dependency_graph[source_node]:
|
|
89
|
-
incoming_connection_count[target_node] += 1
|
|
90
|
-
|
|
91
|
-
# start with nodes that have no dependencies
|
|
92
|
-
processing_queue = deque(
|
|
93
|
-
[
|
|
94
|
-
node
|
|
95
|
-
for node in incoming_connection_count
|
|
96
|
-
if incoming_connection_count[node] == 0
|
|
97
|
-
]
|
|
98
|
-
)
|
|
99
|
-
sorted_node_order = []
|
|
100
|
-
|
|
101
|
-
# process nodes in breadth-first order
|
|
102
|
-
while processing_queue:
|
|
103
|
-
current_node = processing_queue.popleft()
|
|
104
|
-
sorted_node_order.append(current_node)
|
|
105
|
-
|
|
106
|
-
# update counts for nodes that depend on the current node
|
|
107
|
-
for dependent_node in dependency_graph[current_node]:
|
|
108
|
-
incoming_connection_count[dependent_node] -= 1
|
|
109
|
-
# if all dependencies are processed, add to queue
|
|
110
|
-
if incoming_connection_count[dependent_node] == 0:
|
|
111
|
-
processing_queue.append(dependent_node)
|
|
112
|
-
|
|
113
|
-
return sorted_node_order
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def organize_into_columns(nodes_in_order: list, dependency_graph: dict) -> list:
|
|
117
|
-
"""
|
|
118
|
-
Organize nodes into columns based on their dependencies
|
|
119
|
-
|
|
120
|
-
Parameters
|
|
121
|
-
----------
|
|
122
|
-
nodes_in_order : list
|
|
123
|
-
Nodes sorted in topological order
|
|
124
|
-
dependency_graph : dict
|
|
125
|
-
Mapping of nodes to their dependent nodes
|
|
126
|
-
|
|
127
|
-
Returns
|
|
128
|
-
-------
|
|
129
|
-
list
|
|
130
|
-
Columns of nodes, where each column is a list of nodes
|
|
131
|
-
"""
|
|
132
|
-
node_columns = []
|
|
133
|
-
node_column_assignment = {}
|
|
134
|
-
|
|
135
|
-
for node in reversed(nodes_in_order):
|
|
136
|
-
# node goes in column after its furthest dependent
|
|
137
|
-
node_column_assignment[node] = (
|
|
138
|
-
max(
|
|
139
|
-
[
|
|
140
|
-
node_column_assignment[dependent_node]
|
|
141
|
-
for dependent_node in dependency_graph[node]
|
|
142
|
-
],
|
|
143
|
-
default=-1,
|
|
144
|
-
)
|
|
145
|
-
+ 1
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
# add node to its assigned column
|
|
149
|
-
if node_column_assignment[node] == len(node_columns):
|
|
150
|
-
node_columns.append([node])
|
|
151
|
-
else:
|
|
152
|
-
node_columns[node_column_assignment[node]].append(node)
|
|
153
|
-
|
|
154
|
-
# reverse columns to get left-to-right flow
|
|
155
|
-
return list(reversed(node_columns))
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def calculate_node_dimensions(
|
|
159
|
-
node: bpy.types.Node, socket_input_connection_count: Counter, interface_scale: float
|
|
160
|
-
) -> tuple[float, float]:
|
|
161
|
-
"""
|
|
162
|
-
Calculate the visual dimensions of a node
|
|
163
|
-
|
|
164
|
-
Parameters
|
|
165
|
-
----------
|
|
166
|
-
node : bpy.types.Node
|
|
167
|
-
The node to calculate dimensions for
|
|
168
|
-
socket_input_connection_count : Counter
|
|
169
|
-
Counter of connections for each socket
|
|
170
|
-
interface_scale : float
|
|
171
|
-
UI scale factor
|
|
172
|
-
|
|
173
|
-
Returns
|
|
174
|
-
-------
|
|
175
|
-
tuple[float, float]
|
|
176
|
-
Width and height of the node
|
|
177
|
-
"""
|
|
178
|
-
# height constants for different node elements
|
|
179
|
-
node_header_height = 20
|
|
180
|
-
node_socket_height = 28
|
|
181
|
-
node_property_row_height = 28
|
|
182
|
-
node_vector_input_height = 84
|
|
183
|
-
|
|
184
|
-
# count enabled inputs and outputs
|
|
185
|
-
enabled_input_count = len(
|
|
186
|
-
list(filter(lambda input_socket: input_socket.enabled, node.inputs))
|
|
187
|
-
)
|
|
188
|
-
enabled_output_count = len(
|
|
189
|
-
list(filter(lambda output_socket: output_socket.enabled, node.outputs))
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
# get properties specific to this node type (not inherited)
|
|
193
|
-
inherited_property_ids = [
|
|
194
|
-
property.identifier
|
|
195
|
-
for base_class in type(node).__bases__
|
|
196
|
-
for property in base_class.bl_rna.properties
|
|
197
|
-
]
|
|
198
|
-
|
|
199
|
-
node_specific_property_count = len(
|
|
200
|
-
[
|
|
201
|
-
property
|
|
202
|
-
for property in node.bl_rna.properties
|
|
203
|
-
if property.identifier not in inherited_property_ids
|
|
204
|
-
]
|
|
205
|
-
)
|
|
206
|
-
|
|
207
|
-
# count vector inputs that need UI widgets (not connected)
|
|
208
|
-
unconnected_vector_input_count = len(
|
|
209
|
-
list(
|
|
210
|
-
filter(
|
|
211
|
-
lambda input_socket: (
|
|
212
|
-
input_socket.enabled
|
|
213
|
-
and input_socket.type == "VECTOR"
|
|
214
|
-
and socket_input_connection_count[input_socket] == 0
|
|
215
|
-
),
|
|
216
|
-
node.inputs,
|
|
217
|
-
)
|
|
218
|
-
)
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
# calculate total node height based on components
|
|
222
|
-
total_node_height = (
|
|
223
|
-
node_header_height
|
|
224
|
-
+ (enabled_output_count * node_socket_height)
|
|
225
|
-
+ (node_specific_property_count * node_property_row_height)
|
|
226
|
-
+ (enabled_input_count * node_socket_height)
|
|
227
|
-
+ (unconnected_vector_input_count * node_vector_input_height)
|
|
228
|
-
) * interface_scale
|
|
229
|
-
|
|
230
|
-
return node.width, total_node_height
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def position_nodes_in_columns(
|
|
234
|
-
node_columns: list,
|
|
235
|
-
socket_input_connection_count: Counter,
|
|
236
|
-
spacing: typing.Tuple[float, float] = (50, 25),
|
|
237
|
-
) -> None:
|
|
238
|
-
"""Position nodes in columns with appropriate spacing
|
|
239
|
-
|
|
240
|
-
Parameters
|
|
241
|
-
----------
|
|
242
|
-
node_columns : list
|
|
243
|
-
List of columns, where each column is a list of nodes
|
|
244
|
-
socket_input_connection_count : Counter
|
|
245
|
-
Counter of connections for each socket
|
|
246
|
-
spacing : tuple of float, optional
|
|
247
|
-
Tuple of (horizontal, vertical) spacing between nodes, by default (50, 25)
|
|
248
|
-
"""
|
|
249
|
-
interface_scale = 1.0
|
|
250
|
-
non_geo_offset = 20 + 28 * 2 # header + 2 socket heights
|
|
251
|
-
|
|
252
|
-
# position nodes column by column
|
|
253
|
-
position_x = 0
|
|
254
|
-
for column in node_columns:
|
|
255
|
-
widest_node_in_column = 0
|
|
256
|
-
position_y = 0
|
|
257
|
-
|
|
258
|
-
for node in column:
|
|
259
|
-
node.update()
|
|
260
|
-
|
|
261
|
-
width, height = calculate_node_dimensions(
|
|
262
|
-
node, socket_input_connection_count, interface_scale
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
# track widest node for column spacing
|
|
266
|
-
if width > widest_node_in_column:
|
|
267
|
-
widest_node_in_column = width
|
|
268
|
-
|
|
269
|
-
# position node
|
|
270
|
-
node.location = (position_x, position_y)
|
|
271
|
-
|
|
272
|
-
# adjust position for non-geometry nodes
|
|
273
|
-
if not node_has_geo_socket(node):
|
|
274
|
-
node.location -= Vector((0, non_geo_offset))
|
|
275
|
-
|
|
276
|
-
# move down for next node with spacing
|
|
277
|
-
position_y -= height + spacing[1]
|
|
278
|
-
|
|
279
|
-
# move right for next column with spacing
|
|
280
|
-
position_x += widest_node_in_column + spacing[0]
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def position_special_nodes(
|
|
284
|
-
tree: bpy.types.NodeTree, vertical_offset: float = 100
|
|
285
|
-
) -> None:
|
|
286
|
-
"""Position special nodes like Group Input and Group Output at the top
|
|
287
|
-
|
|
288
|
-
Parameters
|
|
289
|
-
----------
|
|
290
|
-
tree : bpy.types.NodeTree
|
|
291
|
-
The node tree to modify
|
|
292
|
-
vertical_offset : float, optional
|
|
293
|
-
Vertical offset above the highest node, by default 100
|
|
294
|
-
|
|
295
|
-
Returns
|
|
296
|
-
-------
|
|
297
|
-
None
|
|
298
|
-
"""
|
|
299
|
-
highest_y_position = max([node.location[1] for node in tree.nodes])
|
|
300
|
-
for special_node_name in ["Group Input", "Group Output"]:
|
|
301
|
-
if special_node_name in tree.nodes:
|
|
302
|
-
special_node = tree.nodes[special_node_name]
|
|
303
|
-
special_node.location = (
|
|
304
|
-
special_node.location[0],
|
|
305
|
-
highest_y_position + vertical_offset,
|
|
306
|
-
)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def cleanup_orphaned_nodes(
|
|
310
|
-
tree: bpy.types.NodeTree, max_iter: int = 100, add_group_input: bool = True
|
|
311
|
-
) -> None:
|
|
312
|
-
"""Remove nodes that are not connected to anything"""
|
|
313
|
-
to_remove = []
|
|
314
|
-
for _ in range(max_iter):
|
|
315
|
-
for node in tree.nodes:
|
|
316
|
-
if len(node.outputs) == 0:
|
|
317
|
-
continue
|
|
318
|
-
if not any([s.is_linked for s in node.outputs]):
|
|
319
|
-
to_remove.append(node)
|
|
320
|
-
|
|
321
|
-
if len(to_remove) == 0:
|
|
322
|
-
break
|
|
323
|
-
|
|
324
|
-
for node in to_remove:
|
|
325
|
-
tree.nodes.remove(node)
|
|
326
|
-
|
|
327
|
-
to_remove = []
|
|
328
|
-
|
|
329
|
-
if add_group_input and "Group Input" not in tree.nodes:
|
|
330
|
-
n_input = tree.nodes.new("NodeGroupInput")
|
|
331
|
-
if "Join Geometry" in tree.nodes:
|
|
332
|
-
tree.links.new(n_input.outputs[0], tree.nodes["Join Geometry"].inputs[0])
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def arrange_tree(
|
|
336
|
-
tree: bpy.types.NodeTree,
|
|
337
|
-
spacing: typing.Tuple[float, float] = (50, 25),
|
|
338
|
-
add_group_input: bool = True,
|
|
339
|
-
) -> None:
|
|
340
|
-
"""Arrange nodes in a node tree based on their dependencies
|
|
341
|
-
|
|
342
|
-
Parameters
|
|
343
|
-
----------
|
|
344
|
-
tree : bpy.types.GeometryNodeTree
|
|
345
|
-
The node tree to arrange
|
|
346
|
-
spacing : tuple of float
|
|
347
|
-
Tuple of (horizontal, vertical) spacing between nodes
|
|
348
|
-
|
|
349
|
-
Returns
|
|
350
|
-
-------
|
|
351
|
-
None
|
|
352
|
-
This function modifies the node tree in place
|
|
353
|
-
|
|
354
|
-
Notes
|
|
355
|
-
-----
|
|
356
|
-
This function organizes nodes into columns and positions them with appropriate spacing.
|
|
357
|
-
Nodes are arranged from left to right based on their dependencies, with special
|
|
358
|
-
handling for geometry nodes and group input/output nodes.
|
|
359
|
-
"""
|
|
360
|
-
cleanup_orphaned_nodes(tree, add_group_input=add_group_input)
|
|
361
|
-
dependency_graph, socket_input_connection_count = build_dependency_graph(tree)
|
|
362
|
-
nodes_in_dependency_order = topological_sort(dependency_graph)
|
|
363
|
-
node_columns = organize_into_columns(nodes_in_dependency_order, dependency_graph)
|
|
364
|
-
position_nodes_in_columns(node_columns, socket_input_connection_count, spacing)
|
|
365
|
-
position_special_nodes(tree)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|