WIP: Blender node graphs: design #3
4 changed files with 1728 additions and 0 deletions
386
docs/blender-node-graph-examples.md
Normal file
386
docs/blender-node-graph-examples.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
# Blender node graphs: worked examples
|
||||
|
||||
Companion to [`blender-node-graphs.md`](blender-node-graphs.md). Real geometry-node
|
||||
setups, what each does, how it would be expressed as Brokkr node-graph ops, and what
|
||||
the design could not say. A design gap is marked **GAP** and numbered; the list, with
|
||||
what each became, is at the end.
|
||||
|
||||
**What was run.** The graph shapes come from sources that were run elsewhere (below). On
|
||||
2026-11-07 the vocabulary was run on Blender 5.2.3 LTS by
|
||||
[`node_graph_probe.py`](../surfaces/blender/tests/live/node_graph_probe.py) (37 checks, all
|
||||
pass): socket and item identifiers, a repeat zone, capture items, multi-input order,
|
||||
interface fields, a linked essentials group, evaluated counts, and example 2's
|
||||
`boundary_step` built as ops and evaluated. **No op was applied by the Brokkr adapter (there
|
||||
is none), and examples 1, 3, 4, 5 and 6 as written were not built end to end**; their node
|
||||
types and the identifiers they use were checked only where an example says so. Socket names
|
||||
in the listings are display names; the real identifiers equal them except where the notes say
|
||||
otherwise (Math's repeats are `Value`, `Value_001`; item sockets are `Value_1`; **interface
|
||||
sockets of a node group are opaque `Socket_N`**, so `gin.Radius` is shorthand for the
|
||||
identifier the interface assigned).
|
||||
|
||||
## Where the setups came from
|
||||
|
||||
- **Searched:** `/opt/commons` holds no `.blend`, node-tree JSON or Python node trees.
|
||||
The only node-tree files on the machine are Blender 5.2's own asset libraries under
|
||||
the Idunn-managed Blender release (`geometry_nodes_essentials.blend`,
|
||||
`geometry_nodes_dynamics_assets.blend`; zstd-compressed). Their node-group *names*
|
||||
were read; their internals were not decomposed (that needs Blender).
|
||||
- **VibeGeometry history** (`GameCult/VibeGeometry`, before commit `09bff8c`, "Focus on
|
||||
vg-csg"): a translation corpus of public Blender geometry-node files into
|
||||
[Geometry Script](https://github.com/carson-katri/geometry-script) Python, each
|
||||
verified on **Blender 5.1.1** by comparing evaluated mesh counts against the source
|
||||
graph (`docs/research/translations/*.md`, `examples/geometry_script/*.py`). The
|
||||
grass tuft, index-of-nearest, labyrinth and raycast setups below are from there. Its
|
||||
failure notes are design evidence and are used in the gaps.
|
||||
- **Not usable as sources:** `GameCult/geometry-script` is the DSL fork, not a graph
|
||||
library; the `.blend` files VibeGeometry generated (Aetheria Bloom, Lucent Tether)
|
||||
are outputs of Python, discussed in example 6.
|
||||
|
||||
## Notation
|
||||
|
||||
Ops are written as the design's actions, one per line. A node has an `id` (Brokkr's),
|
||||
a `blIdname`, node-level `properties`, and literal input defaults. A link is
|
||||
`from.Socket -> to.Socket`. `gin`/`gout` are the group's Group Input / Group Output
|
||||
nodes. `ref` is a typed reference by name.
|
||||
|
||||
## 1. Grass tuft generator (scatter, instance, capture, deform)
|
||||
|
||||
**Source:** Blender demo `instance_attribtues.blend`, group `Grass Tuft Generator`;
|
||||
Geometry Script recreation verified (45 vertices default, 165 wider and curlier; deltas
|
||||
0). **Does:** builds a small disc, scatters Poisson points over it (denser near the
|
||||
centre), instances one tapered blade curve on each point, remembers each instance's root
|
||||
position before realizing, then bends every blade around its own root (more curl toward
|
||||
the tip and toward the rim) and turns the curves into thin triangular tubes.
|
||||
|
||||
```text
|
||||
createNodeGroup geometry "Grass Tuft Generator" interface=[
|
||||
in Height Float 0.2 | in Radius Float 0.05 | in Density Float 1.0
|
||||
in Thickness Float 0.01 | in Curl Float 0.5 | in Seed Int 0
|
||||
out Geometry Geometry ]
|
||||
|
||||
# seed surface
|
||||
addNode disc GeometryNodeMeshCircle properties{fill_type: TRIANGLE_FAN} inputs{Vertices: 12}
|
||||
addNode sub GeometryNodeSubdivideMesh inputs{Level: 3}
|
||||
addNode pos1 GeometryNodeInputPosition # radial distance
|
||||
addNode pos2 GeometryNodeInputPosition # captured on the instance domain
|
||||
addNode rlen ShaderNodeVectorMath properties{operation: LENGTH}
|
||||
addNode falloff ShaderNodeMapRange properties{data_type: FLOAT, interpolation_type: SMOOTHSTEP, clamp: true}
|
||||
inputs{"From Min": 0, "To Min": 1.0, "To Max": 0.39}
|
||||
# points
|
||||
addNode t2 ShaderNodeMath properties{operation: MULTIPLY} inputs{Value_001: 2.0}
|
||||
addNode dmin ShaderNodeMath properties{operation: DIVIDE}
|
||||
addNode dmax ShaderNodeMath properties{operation: MULTIPLY} inputs{Value_001: 5000.0}
|
||||
addNode scatter GeometryNodeDistributePointsOnFaces properties{distribute_method: POISSON}
|
||||
# one blade
|
||||
addNode hvec ShaderNodeCombineXYZ
|
||||
addNode spine GeometryNodeMeshLine properties{mode: END_POINTS, count_mode: TOTAL}
|
||||
inputs{Count: 5, "Start Location": [0,0,0]}
|
||||
addNode tocurve GeometryNodeMeshToCurve properties{mode: EDGES}
|
||||
addNode sp GeometryNodeSplineParameter
|
||||
addNode taper ShaderNodeMath properties{operation: SUBTRACT} inputs{Value: 1.0}
|
||||
addNode blade GeometryNodeSetCurveRadius
|
||||
# instance, scale, remember root, realize
|
||||
addNode inst GeometryNodeInstanceOnPoints
|
||||
addNode scl GeometryNodeScaleInstances inputs{"Local Space": true}
|
||||
addNode cap GeometryNodeCaptureAttribute properties{domain: INSTANCE}
|
||||
addNodeItem cap capture_items VECTOR "Root" # GAP 1 (closed): input Value, output Attribute
|
||||
addNode real GeometryNodeRealizeInstances
|
||||
# curl around each root
|
||||
addNode rnorm ShaderNodeVectorMath properties{operation: NORMALIZE}
|
||||
addNode tan ShaderNodeVectorRotate properties{rotation_type: Z_AXIS} inputs{Angle: 1.5708}
|
||||
addNode rlen2 ShaderNodeVectorMath properties{operation: LENGTH}
|
||||
addNode cfall ShaderNodeMapRange properties{data_type: FLOAT, interpolation_type: SMOOTHSTEP, clamp: true}
|
||||
inputs{"From Min": 0, "To Min": 0.2, "To Max": 2.0}
|
||||
addNode c1 ShaderNodeMath properties{operation: MULTIPLY}
|
||||
addNode c2 ShaderNodeMath properties{operation: MULTIPLY}
|
||||
addNode sp2 GeometryNodeSplineParameter
|
||||
addNode pos3 GeometryNodeInputPosition
|
||||
addNode rot ShaderNodeVectorRotate properties{rotation_type: AXIS_ANGLE}
|
||||
addNode setpos GeometryNodeSetPosition
|
||||
# tube
|
||||
addNode prof GeometryNodeCurvePrimitiveCircle properties{mode: RADIUS} inputs{Resolution: 3}
|
||||
addNode nattr GeometryNodeInputNamedAttribute properties{data_type: FLOAT} inputs{Name: "radius"}
|
||||
addNode rsw GeometryNodeSwitch properties{input_type: FLOAT} inputs{False: 1.0}
|
||||
addNode c2m GeometryNodeCurveToMesh inputs{"Fill Caps": false}
|
||||
addNode smooth GeometryNodeSetShadeSmooth properties{domain: FACE} inputs{"Shade Smooth": false}
|
||||
addNode mat GeometryNodeSetMaterial
|
||||
```
|
||||
|
||||
Links (the wiring, about 45; the group inputs fan out):
|
||||
|
||||
```text
|
||||
gin.Radius -> disc.Radius disc.Mesh -> sub.Mesh
|
||||
pos1.Position -> rlen.Vector rlen.Value -> falloff.Value gin.Radius -> falloff."From Max"
|
||||
gin.Thickness -> t2.Value t2.Value -> dmin.Value gin.Density -> dmin.Value_001
|
||||
gin.Density -> dmax.Value sub.Mesh -> scatter.Mesh dmin.Value -> scatter."Distance Min"
|
||||
dmax.Value -> scatter."Density Max" falloff.Result -> scatter."Density Factor" gin.Seed -> scatter.Seed
|
||||
gin.Height -> hvec.Z hvec.Vector -> spine.Offset spine.Mesh -> tocurve.Mesh
|
||||
sp.Factor -> taper.Value_001 tocurve.Curve -> blade.Curve taper.Value -> blade.Radius
|
||||
scatter.Points -> inst.Points blade.Curve -> inst.Instance inst.Instances -> scl.Instances
|
||||
falloff.Result -> scl.Scale scl.Instances -> cap.Geometry pos2.Position -> cap.Value
|
||||
cap.Geometry -> real.Geometry cap.Attribute -> rnorm.Vector rnorm.Vector -> tan.Vector
|
||||
cap.Attribute -> rlen2.Vector rlen2.Value -> cfall.Value gin.Radius -> cfall."From Max"
|
||||
sp2.Factor -> c1.Value gin.Curl -> c1.Value_001 c1.Value -> c2.Value
|
||||
cfall.Result -> c2.Value_001 pos3.Position -> rot.Vector cap.Attribute -> rot.Center
|
||||
tan.Vector -> rot.Axis c2.Value -> rot.Angle real.Geometry -> setpos.Geometry
|
||||
rot.Vector -> setpos.Position gin.Thickness -> prof.Radius nattr.Exists -> rsw.Switch
|
||||
nattr.Attribute -> rsw.True setpos.Geometry -> c2m.Curve prof.Curve -> c2m."Profile Curve"
|
||||
rsw.Output -> c2m.Scale c2m.Mesh -> smooth.Mesh smooth.Mesh -> mat.Geometry
|
||||
mat.Geometry -> gout.Geometry
|
||||
```
|
||||
|
||||
About 40 nodes and 45 links; the counts are from reading the script, not from Blender.
|
||||
|
||||
**Expressible now:** all of it, with `addNodeItem` for the capture item. **What was
|
||||
checked and what was not:** the capture item (`capture_items.new('VECTOR', name)` gives input
|
||||
`Value`, output `Attribute`; a second item gives `Value_1`/`Attribute_1`), a capture of a
|
||||
position stored back as a named attribute, `Scale Instances.Scale` accepting a float link (it
|
||||
is a vector socket), and the identifiers of Math/Vector Math/Map Range/Switch used above. The
|
||||
other ~35 nodes were not instantiated, so their identifiers here are display names taken from
|
||||
the script; the full graph was not built.
|
||||
|
||||
- **GAP 1, item sockets: closed** by `addNodeItem`. VibeGeometry hit this: on Blender 5.1 a
|
||||
generic capture call did not create the item, and the verified script adds it explicitly.
|
||||
- **GAP 3, field sockets: closed in design.** `scatter."Density Factor"`, `scl.Scale`,
|
||||
`c2.Value` are fields; Brokkr does not model that, `link.is_valid` decides (field into a
|
||||
single-value socket is invalid; checked on `Cube.Size` and `Extrude Mesh.Mesh`). Interface
|
||||
sockets carry `structure` and `defaultInput`.
|
||||
- **GAP 8, evaluated read-back: closed in design** by `queryEvaluated`: VibeGeometry judged
|
||||
correctness by evaluated vertex/edge/face counts, and `to_mesh()` counts were read back here.
|
||||
|
||||
Ordering note, corrected: `scatter`'s Poisson-only sockets (`Distance Min`, `Density Max`,
|
||||
`Density Factor`) **exist before** `distribute_method` is set and swap `is_unavailable` with the
|
||||
random-mode `Density` socket when it changes **[verified]**. So identifiers are stable; what
|
||||
changes is availability. `addNode` still applies properties before inputs, so that setting an
|
||||
input on a socket that is unavailable under the old mode is not refused by mistake.
|
||||
|
||||
## 2. Index of nearest: constraint helpers for a point simulation
|
||||
|
||||
**Source:** Blender demo `index_of_nearest.blend`; helper groups `boundary_step`,
|
||||
`update_velocity`, `collision_step`, `collider_step`, verified on 5.1.1 by driving each
|
||||
field output into `Set Position` and comparing point positions. **Does:** keeps points in
|
||||
a circular table (`boundary_step`), limits and smooths their velocity (`update_velocity`),
|
||||
pushes a point off its nearest neighbour when they overlap (`collision_step`), and pushes
|
||||
points out of an obstacle object (`collider_step`). The *main* group wraps these in a
|
||||
Simulation zone that advances them every frame.
|
||||
|
||||
`boundary_step` complete (8 nodes):
|
||||
|
||||
```text
|
||||
createNodeGroup geometry "Boundary Step" interface=[
|
||||
in Geometry Geometry | in Vector Vector (0,0,0) | in B Float 0.05 | out Geometry Geometry ]
|
||||
addNode pos GeometryNodeInputPosition
|
||||
addNode rel ShaderNodeVectorMath properties{operation: SUBTRACT}
|
||||
addNode dist ShaderNodeVectorMath properties{operation: LENGTH}
|
||||
addNode outside FunctionNodeCompare properties{data_type: FLOAT, mode: ELEMENT, operation: GREATER_THAN}
|
||||
addNode dir ShaderNodeVectorMath properties{operation: NORMALIZE}
|
||||
addNode edge ShaderNodeVectorMath properties{operation: SCALE}
|
||||
addNode flat ShaderNodeVectorMath properties{operation: MULTIPLY} inputs{Vector_001: [1,1,0]}
|
||||
addNode setpos GeometryNodeSetPosition
|
||||
pos.Position->rel.Vector gin.Vector->rel.Vector_001 rel.Vector->dist.Vector
|
||||
dist.Value->outside.A gin.B->outside.B rel.Vector->dir.Vector
|
||||
dir.Vector->edge.Vector gin.B->edge.Scale edge.Vector->flat.Vector
|
||||
gin.Geometry->setpos.Geometry outside.Result->setpos.Selection flat.Vector->setpos.Position
|
||||
setpos.Geometry->gout.Geometry
|
||||
```
|
||||
|
||||
(`rel` has two vector inputs, `Vector` and `Vector_001`, hence the identifier suffixes.)
|
||||
|
||||
`collision_step` adds `GeometryNodeIndexOfNearest` and `GeometryNodeFieldAtIndex`
|
||||
(Evaluate at Index) plus a `GeometryNodeInputRadius` field; `collider_step` takes an
|
||||
object (`ref(object, name)`) through `GeometryNodeObjectInfo`. `update_velocity` has *no
|
||||
geometry input at all*: it is a pure field function returning a vector.
|
||||
|
||||
**Expressible now:** the helper groups, all of them, as plain graphs, and the zone around
|
||||
them. **Run:** `boundary_step` above was built as ops by the probe (with `Vector Math` operation
|
||||
set before links, `Vector_001` as the second vector, interface sockets resolved from names to
|
||||
`Socket_N`) and evaluated on a three-point line (x = 0, 2, 4, B = 0.5): the centre point stayed, the two outside points were pulled to x = 0.5. Its outputs
|
||||
were not compared to the Blender demo's result, so this shows the ops are valid, not that the
|
||||
translation is faithful. The other three helpers were not built.
|
||||
|
||||
- **GAP 2, zones: closed in design** (`addZone`, `addNodeItem` on the output node, zones are
|
||||
v1 in the main doc). The simulation wrapper is, in ops:
|
||||
|
||||
```text
|
||||
addZone simulation inId=simIn outId=simOut # Simulation Input / Output, paired; item Geometry = Item_0
|
||||
addNodeItem simOut state_items VECTOR "Velocity" # adds Item_1 on both halves
|
||||
gin.Geometry -> simIn.Item_0 simIn.Item_0 -> step.Geometry step.Geometry -> simOut.Item_0
|
||||
simOut.Item_0 -> gout.Geometry simIn."Delta Time" -> (collision step)
|
||||
```
|
||||
|
||||
The pairing, item identifiers, `Delta Time` and `Skip` sockets were checked; **a simulation
|
||||
was not evaluated** (the probe ran a repeat zone instead, 3 then 5 iterations).
|
||||
- **GAP 3, field-valued group outputs: closed in design.** `update_velocity` returns a field
|
||||
and takes field inputs; `InterfaceSocket.structure` (`FIELD` set and read back, a field
|
||||
linked into a group's FIELD input and evaluated per element) and `defaultInput` (`POSITION`
|
||||
and `NORMAL` set and read back) cover it. A field-valued **output** socket was not built.
|
||||
- **Harnesses.** VibeGeometry could only observe `update_velocity` by wiring its vector
|
||||
into `Set Position` of a test point cloud; with `queryEvaluated` that harness is a normal
|
||||
Brokkr test: build, assign, read back counts and the bounding box.
|
||||
|
||||
## 3. Raycast minigame: ray pipeline helpers
|
||||
|
||||
**Source:** Blender demo `raycast-minigame.blend`; helper groups `Initial Direction`,
|
||||
`Line to be Casted`, `Cast Rays`. **Does:** makes a ray direction from Euler angles,
|
||||
builds a polyline of N points starting at a position, then for each bounce casts a ray
|
||||
from the previous point into a target mesh, places the next point just short of the hit
|
||||
(0.999 of the distance), reflects the direction off the hit normal and accumulates the
|
||||
travelled distance.
|
||||
|
||||
```text
|
||||
createNodeGroup shaderGroup "Initial Direction" interface=[in Rotation Vector (0,0,0) | out Direction Vector]
|
||||
addNode fwd FunctionNodeInputVector properties{vector: [1,0,0]}
|
||||
addNode turn ShaderNodeVectorRotate properties{rotation_type: EULER_XYZ}
|
||||
fwd.Vector->turn.Vector gin.Rotation->turn.Rotation turn.Vector->gout.Direction
|
||||
```
|
||||
|
||||
`Line to be Casted`: `GeometryNodeCurvePrimitiveLine` (zero length) -> `GeometryNodeSetPosition`
|
||||
(offset `Start Pos`) -> `GeometryNodeResampleCurve` (count `Line Size`) -> out. `Cast Rays`:
|
||||
`GeometryNodeSampleIndex` (previous point), `GeometryNodeRaycast` (target geometry from a
|
||||
`GeometryNodeObjectInfo` of `ref(object, ...)`, ray direction, `Hit Position`/`Normal`/
|
||||
`Distance`), `ShaderNodeVectorMath` SCALE 0.999, `GeometryNodeSetPosition` at `Hit Index`,
|
||||
`ShaderNodeVectorMath` REFLECT, `ShaderNodeMath` ADD and `+1` for the index.
|
||||
|
||||
**Expressible now:** all three helpers, including the target object, which is a
|
||||
`ref(object, name)` default on the Object Info node, and the bounce loop as a Repeat zone
|
||||
(`addZone repeat`; state items for position, direction and distance; `Iterations` = the
|
||||
bounce count). The repeat mechanics were run (a Set Position repeated 3 and 5 times); **this
|
||||
graph was not built**, so `Raycast`, `Sample Index` and Object Info identifiers are unchecked.
|
||||
The Euler/axis `rotation_type` enum values differ by node type and are read from the running
|
||||
Blender, not assumed. **GAP 2 closed in design.**
|
||||
|
||||
## 4. Shortest-path labyrinth
|
||||
|
||||
**Source:** Blender demo `labyrinth-shortest_path-simon_thommes.blend`, group `Solvable
|
||||
Labyrinth Generator`; verified on 5.1.1 (22,730 vertices unsolved, 25,484 solved, deltas
|
||||
0). **Does:** builds a grid, randomly closes edges, keeps the walkable graph, finds the
|
||||
shortest path from a start to the farthest reachable point, then builds wall tubes and a
|
||||
solution tube (revealed by a `Solve` value) with start and end markers.
|
||||
|
||||
Stages as ops (the full graph is about 40 nodes; the shape is what matters):
|
||||
|
||||
```text
|
||||
addNode grid GeometryNodeMeshGrid # square grid, UVs stored on the corner domain
|
||||
addNode uv GeometryNodeStoreNamedAttribute properties{data_type: FLOAT2, domain: CORNER}
|
||||
addNode cost ...random edge costs from edge-neighbour counts + random boolean
|
||||
addNode sep GeometryNodeInputEdgeNeighbors ; FunctionNodeRandomValue
|
||||
addNode paths GeometryNodeInputShortestEdgePaths # root = vertex index 0
|
||||
addNode sel GeometryNodeEdgePathsToSelection
|
||||
addNode cap1 GeometryNodeCaptureAttribute properties{domain: EDGE} + addNodeItem capture_items BOOLEAN # GAP 1
|
||||
addNode carve GeometryNodeSubdivideMesh -> GeometryNodeDeleteGeometry (selected points)
|
||||
addNode paths2 GeometryNodeInputShortestEdgePaths (on the carved graph, farthest X point)
|
||||
addNode curves GeometryNodeEdgePathsToCurves
|
||||
addNode ends GeometryNodeInstanceOnPoints (sphere markers) -> capture instance index "Start/End"
|
||||
addNode trim GeometryNodeTrimCurve (Solve) -> capture curve-domain "Path" boolean
|
||||
addNode fillet GeometryNodeFilletCurve -> GeometryNodeSetCurveRadius -> GeometryNodeCurveToMesh
|
||||
addNode join GeometryNodeJoinGeometry # markers + tubes + base grid
|
||||
```
|
||||
|
||||
**Expressible now, not built.** Every node is a plain node; the sequencing is plain links.
|
||||
**GAP 1** (capture items, three of them) is `addNodeItem`. **GAP 4** (Join Geometry is a
|
||||
*multi-input* socket: many links, order meaningful) is closed in design: the socket reports
|
||||
`is_multi_input`, `linkSockets` appends into it instead of replacing, and `position` reorders.
|
||||
Checked on 5.2.3: three links gave sort ids 0, 1, 2 in creation order, the sort id is read-only,
|
||||
an identical second link is not created, and the evaluated result begins with the **last**
|
||||
linked input (so a client that wants a given geometry first links it last). The labyrinth
|
||||
graph itself was not built. VibeGeometry's note on this graph is a design requirement for Brokkr:
|
||||
"anonymous `Value` nodes are easy for converter output to misassign" - a translation
|
||||
that trusted generated variable names built the right topology at the wrong size. The
|
||||
document must carry values by node `id` and socket identifier (it does), never by
|
||||
inferred names.
|
||||
|
||||
## 5. Library node groups: scatter, tube, array from Blender's essentials
|
||||
|
||||
**Source:** Blender 5.2.3's `geometry_nodes_essentials.blend`, read **by Blender** with
|
||||
`bpy.data.libraries.load(..., assets_only=True)`: **28** asset node groups, among them `Scatter
|
||||
on Surface`, `Instance on Elements`, `Randomize Transforms`, `Random Rotation`, `Array`, `Curve
|
||||
to Tube`, `Smooth by Angle`, `Displace Geometry`, `Box Selection`, `Sphere Selection`, `Normal
|
||||
Selection`, `Smooth Geometry`, `Project with Depth`, `Is Edge Boundary`, `Is Edge Loose`, `Is
|
||||
Edge Manifold`, `Is UV Split`, `Edge Length`, `Geometry Input`, `Capture Rest Geometry` and the
|
||||
cylindrical/spherical combine and separate helpers. `geometry_nodes_dynamics_assets.blend`
|
||||
lists **6** (Set Effector, Hair Dynamics, Custom Force, Custom Effector, Collider, Cloth
|
||||
Dynamics (Experimental)). The earlier revision's "35" and "59" (and names such as `Curve UV`,
|
||||
`XPBD Simulation`, `For Each Typed Bundle`) were not produced by Blender's asset listing
|
||||
(`Curve UV` is a hidden dot-named helper, not an asset): withdrawn. **Does:** a user composes a scatter in two nodes instead of thirty.
|
||||
|
||||
```text
|
||||
createNodeGroup geometry "Rock Field" interface=[in Geometry Geometry | in Density Float 5 | out Geometry Geometry]
|
||||
linkNodeGroupAsset library="essentials" groupName="Scatter on Surface" # GAP 5 (closed)
|
||||
linkNodeGroupAsset library="essentials" groupName="Random Rotation"
|
||||
addNode scatter GeometryNodeGroup group{treeKey: "Scatter on Surface", library: "essentials"}
|
||||
addNode rotate GeometryNodeGroup group{treeKey: "Random Rotation", library: "essentials"}
|
||||
gin.Geometry -> scatter.<identifier of "Mesh"> scatter.<identifier of "Instances"> -> gout.Geometry
|
||||
assignNodeTree geometry "Rock Field" objectName="Terrain"
|
||||
```
|
||||
|
||||
**Run:** the probe linked `Array` from essentials, instantiated it as a Group node, linked a
|
||||
cube into its geometry input and its geometry output to the group output, and evaluated a mesh.
|
||||
Linking `Scatter on Surface` and reading its interface worked; it was not evaluated. Findings
|
||||
that change the sketch above: the linked group's interface identifiers are opaque (`Socket_0`
|
||||
is `Mesh`, `Socket_30` is the `Density Method` **menu**), so ops address them by interface name
|
||||
resolved from read-back; the group is read-only; linking pulls dot-named helper groups with it.
|
||||
The exact input names of `Scatter on Surface` (`Density`, `Distribution Method`...) were read
|
||||
from its interface, but `Rock Field` as written was not built.
|
||||
|
||||
**GAP 5, closed in design** as `linkNodeGroupAsset`: a library is a configured allow-listed
|
||||
directory or the shipped `assets/nodes` files, never a client path, with the same confinement
|
||||
as `importGltf`, and the loaded group is scanned for excluded nodes (Script nodes) before it
|
||||
is kept.
|
||||
|
||||
## 6. Scripted habitats (Aetheria Bloom, Lucent Tether)
|
||||
|
||||
**Source:** VibeGeometry's own studies: a rotating-cylinder habitat with spiralling
|
||||
spokes, terraces, roads and noise-driven slums (`aetheria_bloom_habitat.py`), and a
|
||||
tethered habitat. **Do:** build a whole station from layered cylindrical coordinates.
|
||||
They are hundreds of nodes, written as *Python with loops and helper functions*, and
|
||||
saved as `.blend` outputs.
|
||||
|
||||
**Not practical as ops today.** Each node as an op is hundreds of intents; the Python
|
||||
form is shorter because functions and loops are the compression. Two honest readings:
|
||||
|
||||
- The document is the *compiled* form. The authoring tool for large graphs stays a DSL
|
||||
like Geometry Script (which runs Python, and so is exactly what Brokkr must not
|
||||
expose), and what Brokkr carries is the graph it emits, applied as one `editNodeTree`
|
||||
batch. That keeps Python out of the add-on's surface and still lets an agent author.
|
||||
- **GAP 7, recipes.** The design's optional cut 5 (named graph builders over the
|
||||
document) becomes necessary here, not optional: otherwise a client re-derives
|
||||
hundreds of nodes per habitat.
|
||||
|
||||
## Design gaps found
|
||||
|
||||
Status as of the 2026-11-07 revision. "Closed in design" means the main doc now specifies it and
|
||||
the Blender API behind it was run where the doc says **[verified 5.2.3]**; none is implemented.
|
||||
|
||||
| # | gap | seen in | status | where |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Item sockets**: Capture Attribute items, Repeat/Simulation state items, Menu and Index Switch items, Join/Bundle items are grown by the node | 1, 4 | **closed in design.** `addNodeItem`/`removeNodeItem`, `Node.items`; capture and repeat/simulation items verified, menu/index-switch/bundle items not | "Item sockets" |
|
||||
| 2 | **Zones**: Simulation, Repeat, For Each Element come as paired nodes | 2, 3, essentials | **closed in design, in v1.** `addZone`/`removeZone`, `pairedWith`; repeat evaluated, simulation and for-each not | "Zones" |
|
||||
| 3 | **Field-ness and implicit defaults** on interface sockets | 1, 2 | **closed in design.** `structure`, `defaultInput`, `attributeDomain`, `hideValue`, panels; verified except `attributeDomain` effect | `InterfaceSocket` |
|
||||
| 4 | **Multi-input sockets**: many links, order matters | 4 | **closed in design.** `multiInput`, `Link.order`, `linkSockets position`; verified except the middle of the order and reorder-by-relink | "Multi-input sockets" |
|
||||
| 5 | **Library/asset groups** | 5 | **closed in design.** `linkNodeGroupAsset`, `GroupRef.library`; link and evaluate verified, append and user libraries not | "Library node groups" |
|
||||
| 6 | **Socket types beyond the list**: Menu, Bundle, Closure, List | essentials | **partly closed.** `Menu` seen on a real interface; `Bundle`/`Closure`/`List` added to `Socket.type` by name only (Closure/List node types exist in the 5.2.3 type list, none instantiated); setting values unverified | `Socket.type` |
|
||||
| 7 | **Authoring at scale**: hundreds of nodes | 6 | **open.** Recipes (cut 6) against `applyNodeGraph`; no decision | Open questions 1, 6 |
|
||||
| 8 | **No evaluated read-back** | 1-4 | **closed in design.** `queryEvaluated`, `brokkr.blender.evaluated_geometry.v0`; counts, attribute names, `bound_box` verified | "Evaluated read-back" |
|
||||
| 9 | **Baking and caches** | 2, essentials | **open, out of scope for v1.** Needs its own action and a confined cache directory; `GeometryNodeBake` exists | non-goals |
|
||||
| 10 | **Drivers and animation** on node values | common | **open, out of scope** by safety rule 2; keyframes (data only) could be added | non-goals |
|
||||
| 11 | **Node parenting, frames, colours** | readable graphs | **closed in design.** `Node.parent`, `Node.color`; the properties exist on the node, setting them was not run | `Node` |
|
||||
|
||||
New gaps this revision found, none in the original list:
|
||||
|
||||
| # | gap | status |
|
||||
|---|---|---|
|
||||
| 12 | **Interface identifiers are opaque** (`Socket_N`): group ops cannot address `gin.Radius` | resolved in design: name to identifier at apply time, `socket_ambiguous`, identifiers in read-back |
|
||||
| 13 | **`modifier[identifier]` does not exist on 5.2.3**: `setModifierInput` as first written would fail | corrected: `modifier.properties.inputs.<id>.value`, plus `update_tag` |
|
||||
| 14 | **Zone nodes and item nodes carry virtual `__extend__` sockets** that must not enter the document | read-back skips `NodeSocketVirtual` |
|
||||
| 15 | **Evaluation can run unbounded work** (Repeat `Iterations` is data) | literal cap and an explicit, serialized `queryEvaluated`; link-fed values stay a stated residual risk |
|
||||
|
||||
## Lessons carried into the design
|
||||
|
||||
- VibeGeometry verified on **Blender 5.1.1**; the probe ran on **5.2.3 LTS** (Steam). Group
|
||||
interface through `tree.interface`, explicit capture items and zone pairing now have a 5.2.3
|
||||
run behind them; menu socket defaults still need tolerance; nested tree-group references
|
||||
behave differently on 4.x (their patch 0001 fixes `Geometry Script` for it), unverified here.
|
||||
- Verify against *evaluated geometry*, not only the tree (gap 8).
|
||||
- Address everything by `id` and `identifier`, never by position or generated name
|
||||
(example 4).
|
||||
- Large graphs need a compression layer above the document (example 6, gap 7).
|
||||
562
docs/blender-node-graphs.md
Normal file
562
docs/blender-node-graphs.md
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
# Blender node graphs: design
|
||||
|
||||
Status: design only, no add-on code. Draft for review. Nothing here is implemented in the
|
||||
add-on. Every Blender API claim is marked **[verified 5.2.3]** when
|
||||
[`surfaces/blender/tests/live/node_graph_probe.py`](../surfaces/blender/tests/live/node_graph_probe.py)
|
||||
ran it headless on Blender 5.2.3 LTS (37 checks, result in `node_graph_probe.result.json`),
|
||||
and is otherwise from the documented Python API and **unverified**. The probe is a throwaway
|
||||
op interpreter, not the adapter; it proves the vocabulary maps onto the real API, not that
|
||||
the adapter exists. What was and was not verified is tabulated in "Verification status".
|
||||
|
||||
## Why
|
||||
|
||||
The scene actions (`importGltf`, `setCamera`, `setLight`, `setWorld`, `renderStill`)
|
||||
move objects and settings. The generative power in Blender is in node graphs:
|
||||
shader/material trees and, above all, geometry nodes. This design gives Brokkr
|
||||
enough hooks to **create and configure** those graphs from typed intents and to
|
||||
**read them back**, with the same posture as the rest of the add-on: Blender owns
|
||||
the mutation, Brokkr carries typed documents, intents are validated whole before
|
||||
`bpy` is touched, and every refused intent produces a `failed` receipt that names the
|
||||
rule.
|
||||
|
||||
Non-goals for this cut series: node *authoring UX* (layout beyond a location, parent and
|
||||
colour), animation/driver authoring on node values, baking and simulation caches, add-on
|
||||
node packs, and anything that executes user code. Zones (simulation, repeat, for-each) are
|
||||
**in** v1 (below).
|
||||
|
||||
Worked examples from real setups, with the design gaps they exposed, are in
|
||||
[`blender-node-graph-examples.md`](blender-node-graph-examples.md). Its gaps 1 to 6 and 8
|
||||
(item sockets, zones, field-ness on interface sockets, multi-input sockets, library groups,
|
||||
socket types, evaluated read-back) and 11 (parent, colour) are folded into this document;
|
||||
gaps 7, 9 and 10 stay open and are listed under Open questions.
|
||||
|
||||
## Tree kinds and owners
|
||||
|
||||
A **tree** is addressed by `treeKind` and `treeKey`. The owner decides where the
|
||||
tree lives in Blender.
|
||||
|
||||
| `treeKind` | Blender type | owner (`treeKey` is) | create via |
|
||||
|---|---|---|---|
|
||||
| `material` | `ShaderNodeTree` on a material | material name | `bpy.data.materials.new`, `use_nodes = True` |
|
||||
| `world` | `ShaderNodeTree` on a world | world name | `bpy.data.worlds.new`, `use_nodes = True` |
|
||||
| `geometry` | `GeometryNodeTree` node group | node group name | `bpy.data.node_groups.new(name, 'GeometryNodeTree')` |
|
||||
| `compositor` | `CompositorNodeTree` | scene name | scene compositor tree |
|
||||
| `shaderGroup` | `ShaderNodeTree` node group | node group name | `node_groups.new(name, 'ShaderNodeTree')` |
|
||||
|
||||
A geometry tree reaches the scene only by being assigned to a Geometry Nodes
|
||||
modifier (`assignNodeTree`, below). Material trees are assigned by the existing
|
||||
`assignMaterial`. Where Blender versions disagree, the target adapts and the version
|
||||
matrix at the end records it.
|
||||
|
||||
## The typed graph document
|
||||
|
||||
`brokkr.blender.node_graph.v0` is a CultCache document (one record per tree, key
|
||||
`blender/node_graphs/{treeKind}/{treeKey}`), published into the Blender mirror the
|
||||
way `brokkr.blender.host_snapshot.v0` is. Persisted and carried as typed state, not
|
||||
JSON; a JSON debug export may exist under the debug root like the other mirror
|
||||
documents, as glue only.
|
||||
|
||||
```text
|
||||
NodeGraph
|
||||
schema "brokkr.blender.node_graph.v0"
|
||||
treeKind material | world | geometry | compositor | shaderGroup
|
||||
treeKey string
|
||||
revision integer, increments on every change Brokkr or the user makes
|
||||
contentHash hash of nodes+links+interface (canonical order), for no-op detection
|
||||
interface [InterfaceItem] groups only (geometry, shaderGroup): the tree's own inputs/outputs and panels, in order
|
||||
nodes [Node]
|
||||
links [Link]
|
||||
truncated bool, true when a cap below cut the read-back short
|
||||
Node
|
||||
id string, Brokkr-owned, unique in the tree, 1..63 chars [A-Za-z0-9._-]
|
||||
blIdname string, e.g. "ShaderNodeBsdfPrincipled", "GeometryNodeMeshCube"
|
||||
label string, optional
|
||||
location [x, y] floats
|
||||
properties map name -> PropertyValue node-level settings (not sockets): operation, data_type, blend_type...
|
||||
inputs [Socket]
|
||||
outputs [Socket] read-back only; carries no default
|
||||
group GroupRef, for group nodes: the referenced node group
|
||||
items [NodeItem] nodes that grow sockets (capture, zones, menu/index switch, bundles): ordered
|
||||
pairedWith string, zone input nodes: the Node.id of the paired zone output (and back); read-back
|
||||
parent string, optional: Node.id of a NodeFrame this node sits in
|
||||
color [r, g, b], optional: custom node colour (use_custom_color)
|
||||
mute bool
|
||||
NodeItem
|
||||
collection string, the node's item collection: capture_items | repeat_items | state_items | main_items | generation_items | input_items ...
|
||||
name string, unique within the collection
|
||||
socketType the item's data type (tagged as for Socket.type; 'VECTOR', 'FLOAT', 'GEOMETRY' ...)
|
||||
identifier string, read-back only: the socket identifier(s) the item produced, in/out (see "Item sockets")
|
||||
GroupRef
|
||||
treeKey string
|
||||
library string, optional: absent for a tree in this file, else the Library.id from linkNodeGroupAsset
|
||||
Socket
|
||||
identifier string, Blender's socket identifier (stable; names repeat, e.g. the three "Value" inputs of Math)
|
||||
name string, display name
|
||||
type Float | Int | Bool | Vector | Color | String | Object | Collection | Material | Image | Geometry | Shader | Rotation | Matrix | Menu | Bundle | Closure | List ...
|
||||
default Value, inputs only, absent when the socket has no stored default (Geometry, Shader)
|
||||
linked bool, read-back only
|
||||
multiInput bool, read-back only: the socket takes many links (Join Geometry); `linked` counts them
|
||||
structure read-back only: the socket's inferred structure (SINGLE | FIELD | GRID | LIST ...); the tree evaluator decides
|
||||
available bool, read-back only: false when the node's current properties hide the socket
|
||||
Link
|
||||
fromNode, fromSocket (identifier) -> toNode, toSocket (identifier)
|
||||
order integer, read-back; for a multi-input target the position among its links (0 = first created)
|
||||
InterfaceItem
|
||||
tagged: InterfaceSocket | InterfacePanel
|
||||
InterfaceSocket
|
||||
identifier string, Blender's, read-back only and OPAQUE (`Socket_0`, `Socket_4`...), never the name
|
||||
name, inOut (Input|Output), type, default, min, max, subtype
|
||||
structure AUTO | DYNAMIC | FIELD | GRID | LIST | SINGLE (RNA `structure_type`)
|
||||
defaultInput VALUE | INDEX | ID_OR_INDEX | NORMAL | POSITION | INSTANCE_TRANSFORM | HANDLE_LEFT | HANDLE_RIGHT |
|
||||
SCENE_FRAME | UNIFORM_IMAGE_COORDINATES | SELF_OBJECT (RNA `default_input`; enum read from the running Blender)
|
||||
attributeDomain POINT | EDGE | FACE | CORNER | CURVE | INSTANCE | LAYER (RNA `attribute_domain`, output sockets)
|
||||
hideValue bool; also hideInModifier, forceNonField, optionalLabel, defaultAttributeName
|
||||
panel string, optional: name of the InterfacePanel that holds it
|
||||
InterfacePanel
|
||||
name, defaultClosed, description; position is its order in `interface`
|
||||
Value
|
||||
tagged: number | integer | bool | string | vector3 | rgba | menu(item) | ref(kind, name)
|
||||
PropertyValue
|
||||
tagged: number | integer | bool | string | enum(identifier) | ref(kind, name)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **`id` is Brokkr's key, not Blender's.** The adapter stores it as a custom property
|
||||
(`node["brokkr_id"]`) and also sets `node.name` to it when free, so the Blender UI
|
||||
shows it. A user renaming a node does not break addressing, because lookup is by the
|
||||
custom property. A node without `brokkr_id` (made by hand in the UI) reads back with
|
||||
a derived id `bl:{name}` and is addressable under it; touching it stamps a real id.
|
||||
- **Sockets are addressed by `identifier`,** never by name or index. Names repeat and
|
||||
indices shift when Blender adds sockets for an enum change.
|
||||
- **Values are tagged and typed**; no stringly-typed numbers. A Vector default is
|
||||
exactly three finite floats, a Color exactly four (linear, alpha included).
|
||||
References (`Object`, `Material`, `Collection`, `Image`) are by name and must
|
||||
resolve in `bpy.data` at apply time.
|
||||
- **Enums are properties, not socket values,** and are validated against the running
|
||||
Blender's own enum items (read from `bl_rna`), the way `renderStill` validates
|
||||
`engine`. The adapter never keeps a hard-coded enum table.
|
||||
- **Group nodes** reference a node group by `GroupRef`; the referenced tree must exist
|
||||
(create it first with `createNodeGroup`, or link it with `linkNodeGroupAsset`). A group
|
||||
may not contain itself, directly or through other groups (refused, see Errors).
|
||||
- **Interface identifiers are opaque. [verified 5.2.3]** A new group's interface sockets get
|
||||
`Socket_0`, `Socket_1`... in creation order, not their names; the Group Input/Output node
|
||||
sockets and every Group node instance use those identifiers (verified, including a linked
|
||||
library group, whose `Scatter on Surface` inputs are `Socket_0`, `Socket_30`...). So the
|
||||
examples' `gin.Radius` is shorthand: the op layer resolves an interface **name** to its
|
||||
identifier when the name is unique among that side's sockets (else `socket_ambiguous`), the
|
||||
receipt of `createNodeGroup` and the read-back return the identifiers, and a stored
|
||||
document always carries identifiers. Only identifiers are stable under a rename.
|
||||
- **Item sockets. [verified 5.2.3]** Some nodes grow sockets from an item collection.
|
||||
Identifiers are valid only after the item exists, and are not all alike: Capture Attribute
|
||||
item 1 gives input `Value` / output `Attribute`, item 2 `Value_1` / `Attribute_1` (single
|
||||
underscore, unlike Math's `Value_001`); Repeat and Simulation items are `Item_0`, `Item_1`...
|
||||
on both the input and the output node. Items are created by `addNodeItem`, listed in
|
||||
`Node.items`, and their socket identifiers are read back, never computed by the client.
|
||||
- **Field-ness is the tree evaluator's, not a Brokkr rule. [verified 5.2.3]** `link.is_valid`
|
||||
is false for a field (Input Normal) into a single-value socket (`Mesh Cube.Size`) and into
|
||||
a geometry socket (`Extrude Mesh.Mesh`), and true for field-to-field, a field into a group
|
||||
node's `FIELD` input, and a float into a vector socket (`Scale Instances.Scale`); Brokkr maps
|
||||
invalid to `link_invalid` and does not try to model structure itself. The document reports
|
||||
each socket's inferred structure read-only, and an interface socket's declared `structure`
|
||||
and `defaultInput` are settable and round-trip.
|
||||
- **Multi-input sockets. [verified 5.2.3]** Join Geometry's `Geometry` input has
|
||||
`is_multi_input` true and a `link_limit` above 1. Links into it keep a `multi_input_sort_id`
|
||||
(read-only in the API) assigned in creation order, 0 first; a second identical link is not
|
||||
created. On 5.2.3 the result starts with the **last**-created link and ends with the
|
||||
first: three transformed meshes linked in the order b, a, c gave a first vertex from c and a
|
||||
last vertex from b (only the ends were checked, not the middle). The order is therefore
|
||||
link-creation order, and the only way to change it is to unlink and relink (see
|
||||
`linkSockets`).
|
||||
|
||||
## Read-back
|
||||
|
||||
`brokkr.blender.node_graph.v0` records are published on the same triggers as the host
|
||||
snapshot (capture, and after any node action), for trees the host snapshot's
|
||||
material/world/modifier lists name, plus any tree a node action touched. Caps keep the
|
||||
mirror bounded: at most 500 nodes and 2000 links per tree, and the whole set respects
|
||||
the server's `max_snapshot_documents`/`max_snapshot_bytes`. Over the cap the record is
|
||||
cut at a node boundary and `truncated = true`; the writer reads `truncated` before
|
||||
trusting a missing node. Read-back is the only way to learn socket identifiers and
|
||||
group interface identifiers, so a client creating a graph follows the pattern
|
||||
**create node → read back → link by identifier**, or uses the stable identifiers
|
||||
documented per node type (below).
|
||||
|
||||
`revision` and `contentHash` give clients optimistic concurrency: every node action
|
||||
may carry `expectedRevision`; a mismatch is refused (`stale_revision`) rather than
|
||||
merged. Absent means "do not check".
|
||||
|
||||
## Actions
|
||||
|
||||
All are `brokkr.blender.command_intent.v0` intents with the existing receipt flow. Each
|
||||
action addresses a tree by `treeKind` + `treeKey` and may carry `expectedRevision`.
|
||||
Receipts keep `accepted`/`failed`, with `message` naming the rule; the receipt's
|
||||
`objectName` carries the node id or tree key touched.
|
||||
|
||||
| action | fields | effect |
|
||||
|---|---|---|
|
||||
| `createNodeTree` | `treeKind`, `treeKey`, optional `owner` | Creates the tree (and its owning material/world if absent). No-op `accepted` if it exists with the same kind. |
|
||||
| `createNodeGroup` | `treeKind` (`geometry`/`shaderGroup`), `treeKey`, `interface` | Creates a node group and its interface sockets (`tree.interface.new_socket` on Blender 4+). Same name+kind: converges the interface (below). |
|
||||
| `addNode` | tree, `id`, `blIdname`, `location`, optional `label`, `properties`, `inputs` (identifier -> Value), `group` | Creates the node if `id` is free; applies properties then input defaults. |
|
||||
| `removeNode` | tree, `id` | Removes the node and its links. Absent `id`: `accepted` (already gone). |
|
||||
| `setNodeInput` | tree, `id`, `socket` (identifier), `value` | Sets an unlinked input's default. Refused if the socket is linked or has no default. |
|
||||
| `setNodeProperty` | tree, `id`, `property`, `value` | Sets a node-level property from the node type's allowlist. |
|
||||
| `addNodeItem` | tree, `id`, `collection`, `socketType`, `name` | Adds an item to a node's item collection (`capture_items.new`, `repeat_items.new`, `state_items.new`, ...) and returns the new socket identifiers in the receipt. The collection must be on a per-node-type allowlist (below). Existing item of that name and type: `accepted`, converges. |
|
||||
| `removeNodeItem` | tree, `id`, `collection`, `name` | Removes an item (and its links). Absent: `accepted`. |
|
||||
| `addZone` | tree, `kind` (`simulation` / `repeat` / `foreach_geometry_element`), `inId`, `outId`, `location` | Creates both zone nodes and pairs them (`input.pair_with_output(output)`); each carries its default Geometry item. Items are then added to the **output** node with `addNodeItem`. Each half is addressed by its own `id`; read-back carries `pairedWith`. |
|
||||
| `removeZone` | tree, `inId` or `outId` | Removes both halves and their links. `removeNode` on one half is refused (`zone_half`). |
|
||||
| `linkSockets` | tree, `fromNode`, `fromSocket`, `toNode`, `toSocket`, optional `position` | Links outputs to an input. Replaces an existing link into a single-link input. Into a **multi-input** socket it appends (no replacement); `position` (0 = created first) inserts at that place by unlinking the later links and relinking them, because the sort id is read-only in the API. |
|
||||
| `unlinkSockets` | tree, `toNode`, `toSocket`, optional `fromNode`, `fromSocket` | Removes links into an input (all, or the one matching). None to remove: `accepted`. |
|
||||
| `linkNodeGroupAsset` | `library`, `groupName`, optional `link` (default true), optional `localName` | Brings a node group from an allowed library `.blend` into this file (`bpy.data.libraries.load(path, link=link, assets_only=True)`), see "Library node groups". Already present: `accepted`. |
|
||||
| `queryEvaluated` | `objectName`, optional `frame` | Publishes a `brokkr.blender.evaluated_geometry.v0` record for the object's evaluated result, see "Evaluated read-back". Read-only. |
|
||||
| `assignNodeTree` | `treeKind` (`geometry`), `treeKey`, `objectName`, optional `modifierName` | Adds or reuses a Geometry Nodes modifier named `modifierName` (default `Brokkr GeoNodes`) on the object and sets its `node_group`. `setModifierInput` below sets its exposed inputs. |
|
||||
| `setModifierInput` | `objectName`, `modifierName`, `socket` (interface identifier), `value` | Sets a Geometry Nodes modifier input, validated against the group's interface type. **On Blender 5.2.3 the access is `modifier.properties.inputs.<identifier>.value = v` [verified]; `modifier[identifier]` raises (the modifier no longer supports ID properties), so an implementation that follows the documented 4.x idiom fails.** Object/material inputs use the same `value`; attribute-name inputs use `attribute_name`. The adapter then calls `object.update_tag()`: without it a headless re-evaluation returned the previous result **[verified]**. |
|
||||
|
||||
Material and world trees need no `assign` action: `assignMaterial` and `setWorld`
|
||||
already attach them. Compositor trees belong to the scene and need none.
|
||||
|
||||
**Batches.** A geometry graph is dozens of nodes and links; one intent per node is
|
||||
slow and unsafe half-way. Recommended and part of this design: an `editNodeTree`
|
||||
intent carrying an ordered `ops` list whose elements are the actions above (minus the
|
||||
common tree fields), **validated entirely first, then applied, and rolled back as a
|
||||
whole if any op fails at apply time** (Blender's undo stack is not used from a timer;
|
||||
rollback is by recorded inverse operations, see Open questions). A single-op action is
|
||||
an `editNodeTree` of one.
|
||||
|
||||
### Idempotency, by node id
|
||||
|
||||
The rule: **replaying any intent leaves the tree as it was after the first
|
||||
application.**
|
||||
|
||||
- `addNode` with an existing `id` of the same `blIdname`: **converges**; location,
|
||||
label, properties and listed inputs are set to the intent's values; unlisted inputs
|
||||
and links are untouched; receipt `accepted` with message `exists, updated`. A
|
||||
different `blIdname` under that `id` is refused (`id_type_conflict`); the node is
|
||||
not replaced silently (replacing would drop its links).
|
||||
- `removeNode` of a missing `id`: `accepted`.
|
||||
- `setNodeInput`/`setNodeProperty`: absolute values, so replay is a no-op.
|
||||
- `linkSockets` of an existing identical link: `accepted`, no duplicate (Blender itself
|
||||
creates none into a multi-input socket **[verified]**). A different source into a
|
||||
single-link input replaces the link and says so in the message; into a multi-input socket
|
||||
it appends, so a replayed `linkSockets` with a `position` converges by checking whether
|
||||
that source already sits at that position.
|
||||
- `addNodeItem`/`addZone`: an item of that name and type, or a zone whose `inId`/`outId`
|
||||
exist and are paired, is `accepted` (`exists`). An item of the same name and another type
|
||||
is refused (`item_type_conflict`).
|
||||
- `linkNodeGroupAsset` of a group already linked from that library: `accepted`. Blender
|
||||
logs `ID ... is already linked` for repeated loads **[verified]**; the adapter checks
|
||||
`bpy.data.node_groups` first and does not rely on the warning.
|
||||
- `unlinkSockets` with nothing to remove: `accepted`.
|
||||
- `createNodeTree`/`createNodeGroup`: same key and kind converges; same key,
|
||||
different kind is refused. An existing group's interface is converged by socket
|
||||
`identifier`: changed type/default/range updated, missing added, **extra sockets are
|
||||
kept unless the intent says `pruneInterface: true`** (removing them unlinks users).
|
||||
- Command ids already dedupe replays of the same intent at the queue (the drain
|
||||
deletes an intent after executing it); id-based convergence covers re-sent intents
|
||||
with new command ids, which is the realistic case for a retrying client.
|
||||
|
||||
## Zones (simulation, repeat, for-each)
|
||||
|
||||
Zones are paired nodes, and without them most generative graphs are inert (the index-of-nearest
|
||||
and raycast examples are helpers with nothing to drive them). They are in v1 as `addZone` /
|
||||
`removeZone` plus `addNodeItem` on the zone's output node.
|
||||
|
||||
What Blender 5.2.3 does **[verified]**:
|
||||
|
||||
- `GeometryNodeRepeatInput`/`Output`, `GeometryNodeSimulationInput`/`Output` and
|
||||
`GeometryNodeForeachGeometryElementInput`/`Output` are created separately by `nodes.new`;
|
||||
`input.pair_with_output(output)` returns `True` and the input node then reports
|
||||
`paired_output`. Before pairing, the two halves have different sockets; after, the input node
|
||||
mirrors the output's items. A fresh Repeat/Simulation pair has one item, `Geometry`, as `Item_0`.
|
||||
- Repeat: the input node's outputs are `Iteration` then the items; its inputs are `Iterations`
|
||||
then the items (`Item_0`...). The output node's sockets are the items. Simulation: the input
|
||||
node has an output `Delta Time`; the output node has an input `Skip`. Items are added with
|
||||
`repeat_items.new(type, name)` / `state_items.new(type, name)` on the **output** node and appear
|
||||
on both halves as `Item_N`. For-each has three collections on the output node (`input_items`,
|
||||
`main_items`, `generation_items`); only their existence was checked.
|
||||
- Every zone node carries a trailing virtual socket (`__extend__`, type `NodeSocketVirtual`;
|
||||
for-each has `__extend__main`/`__extend__generation`). **Read-back skips virtual sockets**:
|
||||
they are not document sockets and cannot be linked to by identifier.
|
||||
- A link from a repeat input's `Item_0` to its output's `Item_0` is valid.
|
||||
- A repeat zone ran: a one-vertex mesh through `Set Position (0,0,1)` inside a repeat with
|
||||
`Iterations` 3 evaluated to z = 3, then 5 after `setModifierInput` (and `update_tag`).
|
||||
|
||||
Not verified: simulation behaviour across frames (the evaluation is stateful and a headless
|
||||
read-back at frame N needs the frames before it stepped), deletion rules for one half, a for-each
|
||||
graph that evaluates, whether `pair_with_output` is idempotent on an already-paired node. So
|
||||
`removeNode` on one half is refused rather than guessed (`zone_half`).
|
||||
|
||||
## Library node groups
|
||||
|
||||
Blender 5.2 ships node groups as assets in `datafiles/assets/nodes/` under the Blender install
|
||||
(`geometry_nodes_essentials.blend`, `geometry_nodes_dynamics_assets.blend`) **[verified]**.
|
||||
`bpy.data.libraries.load(path, link=True, assets_only=True)` listed **28** asset node groups in
|
||||
essentials (Scatter on Surface, Instance on Elements, Randomize Transforms, Random Rotation,
|
||||
Array, Curve to Tube, Smooth by Angle, Displace Geometry, Box/Sphere/Normal Selection, Edge
|
||||
Length, Geometry Input, Capture Rest Geometry, Transform and Project, Project with Depth,
|
||||
Screen/3D space conversions, the cylindrical/spherical combine and separate helpers, Is Edge
|
||||
Boundary/Loose/Manifold, Is UV Split, Face Corner Angle) and **6** in dynamics (Set Effector,
|
||||
Hair Dynamics, Custom Force, Custom Effector, Collider, Cloth Dynamics (Experimental)). The
|
||||
examples' earlier counts (35 and 59) were a guess at file totals, not the asset list, and are
|
||||
withdrawn.
|
||||
|
||||
`linkNodeGroupAsset` brings one of them into the file:
|
||||
|
||||
- **Library argument.** `library` names an allowed library, never a path: the shipped
|
||||
`assets/nodes/*.blend` files (resolved from `bpy.utils.system_resource('DATAFILES')`
|
||||
**[verified]**) plus the directories in `preferences.filepaths.asset_libraries` (readable;
|
||||
one "User Library" on the test machine **[verified]**). The final file is the library
|
||||
directory plus a relative name, confined with the same rules `importGltf` uses (no absolute,
|
||||
drive, UNC or `..`, resolved path re-checked before use, no symlink out). The allowed set is
|
||||
configuration, empty by default except the shipped assets.
|
||||
- **Loading.** `link=True` (default) keeps the group read-only and tied to the library file
|
||||
(`group.library` set, `is_editable` false **[verified]**). `link=False` appends a local editable
|
||||
copy (unverified). Dependencies are pulled in: linking `Array` also linked dot-named helper
|
||||
groups such as `.curve_UV` and `.half_spheres` **[verified]**; a repeated load logs `already linked`.
|
||||
- **Use.** A Group node then takes `GroupRef{treeKey, library}`. The linked group's interface
|
||||
is readable and its identifiers are opaque `Socket_N` (`Scatter on Surface` exposes `Socket_0`
|
||||
for `Mesh`, `Socket_30` for the `Density Method` menu...); the client matches on interface name
|
||||
and type from read-back. Its inputs include menu sockets (`NodeSocketMenu`), so `Socket.type`
|
||||
carries `Menu` **[verified]**; setting a menu value was not tried. A group node on `Array`
|
||||
evaluated to a mesh **[verified]**.
|
||||
- **Safety.** A `.blend` can carry Python text blocks and drivers. After loading, the adapter
|
||||
walks the group and everything it nests and applies the exclusion list (Script nodes,
|
||||
file-touching nodes); if anything is excluded it removes the new datablocks and answers
|
||||
`node_type_not_allowed` with the offending node. Whether linking can run a library's scripts
|
||||
depends on `preferences.filepaths.use_scripts_auto_execute`: refusing to link while that is on
|
||||
is the planned guard and is **not verified**. Every node action on a linked tree is refused
|
||||
(`tree_read_only`).
|
||||
|
||||
## Evaluated read-back
|
||||
|
||||
The tree read-back says what was built; it says nothing about what Blender made of it. Every
|
||||
translation in the VibeGeometry corpus was judged by evaluated counts. `queryEvaluated` makes
|
||||
Blender evaluate the object (after `view_layer.update()`, optionally at a `frame`) and publishes
|
||||
`brokkr.blender.evaluated_geometry.v0`, key `blender/evaluated/{objectName}`:
|
||||
|
||||
```text
|
||||
EvaluatedGeometry
|
||||
schema, objectName, frame, revision of every tree the object's modifiers use
|
||||
mesh { vertices, edges, faces, corners, bboxMin, bboxMax, attributes: [name] } present only for a mesh result
|
||||
components names of the non-empty result components (curves, pointcloud, instances, volume ...)
|
||||
instances integer, count of instances (source unverified)
|
||||
warnings [string], node warnings of each Geometry Nodes modifier
|
||||
```
|
||||
|
||||
**[verified 5.2.3]:** a cube graph evaluates to 8/12/6/24 (vertices/edges/faces/corners) via
|
||||
`object.evaluated_get(depsgraph).to_mesh()`, with attribute names `position`, `.corner_vert`,
|
||||
`.corner_edge`, `sharp_face`, `.edge_verts`; the evaluated object has `bound_box`; a curve result
|
||||
has an empty evaluated mesh while `evaluated_geometry()` exposes a curves component;
|
||||
`modifier.node_warnings` exists (empty); after changing a modifier input headless the result stays
|
||||
stale until `object.update_tag()` is called. **Not verified:** attribute domain and data type
|
||||
(only names were read), the instances count, point-cloud and volume counts, grease pencil,
|
||||
evaluation from a timer in an interactive session (the probe ran `-b`), and frame stepping for
|
||||
simulations.
|
||||
|
||||
Budget: evaluating runs the graph on Blender's main thread and cannot be interrupted, so a heavy
|
||||
or unbounded graph freezes the host until it finishes. The read is therefore an explicit action
|
||||
(never part of the periodic snapshot), one object at a time, reads counts through `to_mesh()`
|
||||
and clears it at once (`to_mesh_clear`), and is refused (`eval_limit`) while an earlier
|
||||
evaluation of the same object has not returned.
|
||||
|
||||
## Error cases
|
||||
|
||||
Each is a `failed` receipt, raised before any change when it can be detected from the
|
||||
intent plus the current tree; at apply time inside a batch it rolls the batch back.
|
||||
The code is a stable slug at the start of `message`, for clients to branch on.
|
||||
|
||||
| slug | when |
|
||||
|---|---|
|
||||
| `tree_not_found` / `tree_kind_mismatch` | `treeKey` absent for the action, or exists with another kind |
|
||||
| `owner_not_found` | `createNodeTree` names a material/world/scene that cannot be created or found |
|
||||
| `id_invalid` / `id_duplicate` | `id` fails the pattern or repeats within one batch |
|
||||
| `id_type_conflict` | `addNode` for an existing `id` with another `blIdname` |
|
||||
| `node_not_found` | an action names an `id` that is not in the tree (except the idempotent no-ops above) |
|
||||
| `node_type_unknown` | `blIdname` is not a node type of the running Blender |
|
||||
| `node_type_not_allowed` | `blIdname` is on the exclusion list, or not valid in this `treeKind` (see Safety) |
|
||||
| `socket_not_found` | `identifier` does not exist on the node (message lists the valid ones) |
|
||||
| `socket_type_mismatch` | a `Value` does not fit the socket's type (vector length, non-finite, wrong tag) |
|
||||
| `socket_has_no_default` | `setNodeInput` on Geometry/Shader sockets |
|
||||
| `socket_linked` | `setNodeInput` on a linked input (unlink first) |
|
||||
| `property_not_allowed` / `property_value_invalid` | not on the node type's allowlist; or value not in the enum or out of range |
|
||||
| `reference_not_found` | a `ref` names an object/material/collection/image not in `bpy.data` |
|
||||
| `link_invalid` | Blender reports the link invalid (`link.is_valid` false): type conversion not allowed, or field-to-non-field |
|
||||
| `link_cycle` | the link would create a cycle |
|
||||
| `group_cycle` | a group would contain itself, directly or through other groups |
|
||||
| `group_not_found` | a group node names a `treeKey` that does not exist |
|
||||
| `item_collection_not_allowed` / `item_type_conflict` | `addNodeItem` names a collection not on the node type's allowlist; or an item of that name exists with another type |
|
||||
| `zone_half` / `zone_unpaired` | `removeNode` on one zone half; `addNodeItem`/links address a zone node whose pair is missing |
|
||||
| `socket_ambiguous` | an interface **name** matches more than one socket on that side of the group |
|
||||
| `multi_input_position` | `position` is past the number of links currently in the multi-input socket |
|
||||
| `library_not_allowed` / `asset_not_found` | `library` is not on the configured allow-list of library files; the library has no asset node group of that name |
|
||||
| `tree_read_only` | a node action addresses a tree linked from a library |
|
||||
| `eval_limit` | `queryEvaluated` refused because the evaluation budget rules below say so |
|
||||
| `stale_revision` | `expectedRevision` differs from the tree's `revision` |
|
||||
| `limit_exceeded` | the batch or the tree would pass a cap (500 nodes, 2000 links, 256 ops per batch) |
|
||||
| `path_outside_directory` | a property that names a file (image path) is outside the configured import directory |
|
||||
| `version_unsupported` | the running Blender lacks an API the action needs (e.g. group interface on Blender < 4.0) |
|
||||
|
||||
## Safety: what must not be exposed
|
||||
|
||||
The surface exists to build node graphs, not to run code. The rules, each testable
|
||||
without Blender:
|
||||
|
||||
1. **Script nodes are excluded.** `ShaderNodeScript` (OSL, from an external file or an
|
||||
internal text datablock) is not creatable and not settable; reading one back reports
|
||||
it as an opaque node with no properties. Any `blIdname` containing `Script` is
|
||||
refused, and the exclusion list is checked **before** the Blender type lookup, so a
|
||||
future Blender node of that kind needs an explicit allow, not a missing deny.
|
||||
2. **No Python reaches the tree through values.** `Value` and `PropertyValue` are tagged
|
||||
data; strings are only accepted for sockets and properties whose type is string, and
|
||||
are never evaluated. Driver and keyframe creation on node values is not an action;
|
||||
driver expressions (which Blender evaluates as Python) cannot be set by any intent
|
||||
here.
|
||||
3. **Allowlist by node namespace and tree kind, then by property.** A `blIdname` must
|
||||
exist in the running Blender, belong to the `treeKind`'s namespace (`ShaderNode*`,
|
||||
`GeometryNode*`, `CompositorNode*`, plus `NodeGroup*`, `NodeReroute`, `NodeFrame`),
|
||||
and not be on the exclusion list. A node-level property is settable only if it is
|
||||
in the plain-data kinds Blender exposes via `bl_rna` (bool, int, float, string, enum)
|
||||
or a `ref` kind from a short list (object, material, collection, image, node group),
|
||||
and not on a per-node-type denylist of properties that name a file or text datablock.
|
||||
4. **File-touching nodes are confined or excluded.** Nodes that name a file get the same
|
||||
treatment as `importGltf`/`renderStill`: an image path (`ShaderNodeTexImage` image
|
||||
source) must resolve inside the configured Import Directory; geometry-node *importer*
|
||||
nodes (OBJ/STL/PLY/CSV import, version permitting) and compositor *writer* nodes
|
||||
(File Output) are excluded in v1. Compositor output stays with `renderStill`, whose
|
||||
output is already confined. `linkNodeGroupAsset` reads a `.blend` from an allow-listed
|
||||
asset library only, never a client-supplied path, and re-applies rules 1 and 4 to the loaded group.
|
||||
5. **Whole-intent validation first.** No partial application of a refused intent. A
|
||||
batch is validated, then applied, and a failure at apply time restores the tree.
|
||||
6. **Caps.** 500 nodes, 2000 links and 256 ops per batch keep a single intent from
|
||||
becoming a resource attack; nested groups are capped at depth 8. Zones add an amplifier
|
||||
the structural caps do not see: a Repeat's `Iterations` is data. A literal above 10 000 on a
|
||||
Repeat `Iterations` input, or on a short list of known amplifiers (Mesh Grid and Subdivide
|
||||
levels, Duplicate Elements amount), is refused (`limit_exceeded`); a value arriving by link or
|
||||
by modifier input is not bounded by Brokkr, and evaluation cannot be interrupted
|
||||
(see "Evaluated read-back"). That residual risk belongs to Geometry Nodes, not to this
|
||||
design, and is stated rather than hidden.
|
||||
7. **Authority.** These actions ride the same admission path as the other Blender
|
||||
intents. The server hardening (optional session token, bind guard, intent purge) is
|
||||
unchanged and covers them.
|
||||
|
||||
## Testable without Blender, and what needs it
|
||||
|
||||
Without Blender (stdlib only, in the style of `server_guard.py` and `scene_actions.py`):
|
||||
|
||||
- The graph document types and their canonical (de)serialization, including
|
||||
`contentHash` stability under reordering (links into a multi-input socket hash by `order`,
|
||||
items by their collection order, not sorted by name).
|
||||
- Id and identifier patterns, value tagging and range checks, enum checks against a
|
||||
supplied enum table, reference tagging.
|
||||
- The exclusion list, namespace/tree-kind allowlist, property allowlist, path
|
||||
confinement, caps, depth limit.
|
||||
- Pure graph algorithms on the document: link cycle detection, group-cycle detection,
|
||||
idempotent convergence (`addNode` on existing, `linkSockets` replace), batch
|
||||
validation and ordering, and **rollback by inverse ops** over an in-memory graph.
|
||||
- The adapter against a **fake `bpy` node tree** (nodes, sockets, links as plain
|
||||
objects), the way `test_scene_actions.py` fakes `bpy`: it proves the adapter's
|
||||
sequence of calls and its refusals, not Blender's behaviour.
|
||||
|
||||
Needs a live Blender (a smoke script run headless with `blender -b --python`, kept in
|
||||
the repo, run by a human or on a runner with Blender):
|
||||
|
||||
- That `nodes.new(type=...)` accepts every `blIdname` the allowlist admits, and that
|
||||
socket identifiers and enum item sets match what the fake assumes.
|
||||
- `link.is_valid` semantics (field/non-field, type conversion), and link replacement.
|
||||
- Group interface creation and the converge-by-identifier rule on Blender 4.0 through
|
||||
the current release; geometry nodes modifier assignment and input setting.
|
||||
- Where the compositor tree lives (see matrix), and `use_nodes` behaviour.
|
||||
- What `node_graph_probe.py` already covers (item identifiers, zone pairing, a repeat zone
|
||||
evaluating, multi-input order, interface structure and default round-trip, a field into a
|
||||
group, a linked essentials group evaluating, the modifier input API, evaluated counts) is
|
||||
not re-proved by the adapter's live tests; they cover what the adapter adds.
|
||||
- Round trip: build a tree from actions, read it back, apply the read-back to a fresh
|
||||
tree, read again; the two read-backs must be equal. This is the one test that proves
|
||||
the document is a faithful model of the tree.
|
||||
|
||||
## Version matrix (to verify on a live Blender)
|
||||
|
||||
| area | Blender 3.x | 4.0 to 4.x | 5.x |
|
||||
|---|---|---|---|
|
||||
| group interface | `tree.inputs/outputs` | `tree.interface.new_socket(...)` | same as 4.x **[verified 5.2.3]**, plus `new_panel`, `structure_type`, `default_input`, `attribute_domain`, `hide_value`; identifiers are `Socket_N` |
|
||||
| modifier inputs | `modifier[id]` | `modifier[id]` | `modifier.properties.inputs.<id>.value`; `modifier[id]` raises **[verified 5.2.3]**; earlier 5.x unverified |
|
||||
| item sockets | none | `capture_items.new(type, name)` (4.2+) | **[verified 5.2.3]**; 4.x availability unverified |
|
||||
| material/world tree | `use_nodes = True` then `node_tree` | same | `use_nodes` is deprecated and trees may always exist; confirm |
|
||||
| compositor tree | `scene.use_nodes`, `scene.node_tree` | same | the scene's tree becomes a node group on the scene (`compositing_node_group`); confirm |
|
||||
| geometry nodes zones | none | 3.6 simulation, 4.0 repeat | `pair_with_output`, `repeat_items`/`state_items` **[verified 5.2.3]**; for-each element zone present, only listed |
|
||||
| library groups | n/a | `bpy.data.libraries.load` | assets under `datafiles/assets/nodes` link and evaluate **[verified 5.2.3]** |
|
||||
|
||||
The add-on declares `blender: (4, 0, 0)` today, so v1 supports 4.0+ and answers
|
||||
`version_unsupported` below it. Only 5.2.3 was run: the 4.x rows, the shader/world/compositor
|
||||
rows and the "confirm" rows are claims this document could not check. Capture items need 4.2,
|
||||
repeat zones 4.0 and simulation 3.6 (documented, unverified), so `addNodeItem` and `addZone`
|
||||
answer `version_unsupported` below those.
|
||||
|
||||
## Cut plan
|
||||
|
||||
Each cut is its own PR with tests that fail when its rule breaks.
|
||||
|
||||
1. **Document and guards, no bpy.** Types (including items, `GroupRef`, interface structure,
|
||||
multi-input `order`, evaluated record), canonical form, hash; id/identifier/value
|
||||
validation; exclusion and allowlist; caps including the `Iterations` literal cap. Pure tests.
|
||||
2. **Pure graph engine.** In-memory graph, idempotent apply of the action set including
|
||||
`addNodeItem`, `addZone` and multi-input `position` (relink-to-reorder), batch
|
||||
validate-then-apply with inverse-op rollback, cycle checks. Pure tests, including replay and
|
||||
rollback properties.
|
||||
3. **Adapter to Blender.** Reads a `bpy` node tree into the document (virtual sockets skipped,
|
||||
opaque interface identifiers) and applies ops to it; fake-`bpy` tests; `assignNodeTree`,
|
||||
`setModifierInput` through `modifier.properties.inputs`. Read-back publishing.
|
||||
4. **Live smoke.** Extends `node_graph_probe.py` into the round-trip test; version matrix
|
||||
filled for the rows still unverified (4.x, shader/world/compositor).
|
||||
5. **Library groups and evaluated read-back.** `linkNodeGroupAsset` with library allow-list and
|
||||
post-load exclusion scan; `queryEvaluated`. Separate PR: both touch the file system or run
|
||||
the graph, so each gets its own safety review.
|
||||
6. **Node recipes (optional).** Named, tested graph builders (a procedural scatter, a
|
||||
PBR material) expressed as documents, so clients do not rediscover identifiers. Promoted from
|
||||
optional by example 6 if generated graphs stay large (open question 5).
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Declarative apply vs actions.** An `applyNodeGraph` intent that converges a tree to
|
||||
a whole `NodeGraph` document is the natural fit for a generated graph and for the
|
||||
round-trip test, and the fine-grained actions could be defined over it. Recommended:
|
||||
build both on the same engine; ship `editNodeTree` (ordered ops) first. Confidence
|
||||
moderate; node-editing tools elsewhere (Blender MCP servers surveyed in
|
||||
`docs/research/blender-mcp-capability-bar.md`) mostly offer per-node calls, which is
|
||||
the weakest option for atomicity.
|
||||
2. **Rollback mechanism.** Recorded inverse ops (recommended) against restoring from a
|
||||
pre-apply read-back document. Inverse ops avoid touching unrelated state;
|
||||
restoring from the document is simpler and uses the engine twice. Needs the live
|
||||
smoke to see which is more faithful.
|
||||
3. **Zone deletion and simulation frames.** Zones are in v1 (`addZone`). Still open:
|
||||
whether `removeNode` on one half should be allowed (the design refuses it), and how a
|
||||
headless `queryEvaluated` at frame N should step a Simulation zone from its start (the
|
||||
probe did not run a simulation).
|
||||
4. **Where node ids are stored** if a future Blender removes custom properties on
|
||||
nodes: fall back to `node.name` only and accept that a UI rename breaks addressing.
|
||||
5. **Packaging of the exclusion list** as a versioned typed document the daemon can
|
||||
read, so the policy is advertised as a capability and not only enforced in the
|
||||
add-on.
|
||||
6. **Authoring at scale** (examples gap 7), **baking and caches** (gap 9) and **drivers and
|
||||
keyframes on node values** (gap 10) are not designed here. Gap 7 is the one with a decision
|
||||
attached: recipes (cut 6) against `applyNodeGraph` (question 1) for graphs of hundreds of nodes.
|
||||
7. **Menu-socket values and appended libraries.** `Socket.type` carries `Menu`, but setting a
|
||||
menu socket's value (and a group node's menu input) was not run; `linkNodeGroupAsset
|
||||
link=false` was not run.
|
||||
|
||||
## Verification status
|
||||
|
||||
What the headless probe (`surfaces/blender/tests/live/node_graph_probe.py`, Blender 5.2.3 LTS on
|
||||
Starfire, 37 checks, all passing) established, and what it did not.
|
||||
|
||||
| area | verified | not verified |
|
||||
|---|---|---|
|
||||
| Item sockets | `capture_items.new('VECTOR','Pos')` creates `Value`/`Attribute`; second item `Value_1`/`Attribute_1`; capture round-trips an attribute | `move`/`remove` of items; menu, index-switch, bundle items |
|
||||
| Zones | repeat/simulation pairing, `Item_N`, `Iterations`, `Iteration`, `Delta Time`, `Skip`; a repeat zone runs 3 then 5 iterations; for-each collection names | simulation across frames; deleting one half; a running for-each |
|
||||
| Interface | `structure_type` FIELD, `default_input` POSITION/NORMAL, `hide_value` set and read; panels with `parent`; opaque `Socket_N` identifiers; a field flows into a group's FIELD input | `attribute_domain` effect; a `DYNAMIC` or `GRID` socket |
|
||||
| Multi-input | `is_multi_input`, `link_limit`, sort ids 0,1,2, read-only sort id, duplicate suppression, reversed result order (ends only) | order of the middle links; reorder via unlink and relink |
|
||||
| Library | 28 + 6 asset groups listed; link with dependencies; read-only linked group; opaque interface ids incl. Menu; group node evaluates | append (`link=False`); a user library; autoexec guard; menu value setting |
|
||||
| Evaluated | mesh counts, attribute names, `bound_box`, curves component present, `node_warnings` present, stale until `update_tag` | attribute domain/type, instances count, interactive-session timing |
|
||||
| Socket facts | Math `Value`/`Value_001`/`Value_002`; Vector Math `Vector`/`Vector_001`/`Vector_002`/`Scale`; Map Range `From_Min_FLOAT3`...; Switch `Switch`/`False`/`True` for FLOAT and GEOMETRY; Compare `A`/`B` for FLOAT and INT; node `parent`, `use_custom_color` and `color` exist; Poisson sockets exist before the property is set and flip `is_unavailable` | most other node types' identifiers |
|
||||
| Modifier | `modifier.properties.inputs.<id>.value`; `modifier[id]` raises | `bake_*`, panel state setters |
|
||||
| Not run at all | the add-on, any batch/rollback behaviour, shader/world/compositor trees, Blender 4.x | |
|
||||
220
surfaces/blender/tests/live/node_graph_probe.py
Normal file
220
surfaces/blender/tests/live/node_graph_probe.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Headless probe for docs/blender-node-graphs.md and blender-node-graph-examples.md.
|
||||
|
||||
Run: blender -b --factory-startup --python node_graph_probe.py -- result.json
|
||||
Not part of the stdlib test suite (needs Blender). It is a throwaway op interpreter, NOT the
|
||||
adapter: it exists to show that the design's op vocabulary (addNode / addNodeItem / addZone /
|
||||
linkSockets / group interface / linked library group / evaluated read-back) maps onto Blender 5.2's
|
||||
real API, and to record the identifiers the design must read back. Each check appends a record
|
||||
{name, ok, detail}; the process exits non-zero if any check fails.
|
||||
"""
|
||||
import bpy, sys, json, os, math
|
||||
|
||||
R = []
|
||||
def check(name, ok, detail=None):
|
||||
R.append({"name": name, "ok": bool(ok), "detail": detail})
|
||||
|
||||
class G:
|
||||
"""Minimal graph builder: Brokkr ids -> nodes; sockets by identifier, or by interface name for gin/gout."""
|
||||
def __init__(self, name, ifaces=()):
|
||||
self.t = bpy.data.node_groups.new(name, "GeometryNodeTree")
|
||||
self.n = {}
|
||||
self.iface = {} # (inOut, name) -> identifier
|
||||
for io, nm, st, extra in ifaces:
|
||||
s = self.t.interface.new_socket(nm, in_out=io, socket_type=st)
|
||||
for k, v in extra.items(): setattr(s, k, v)
|
||||
self.iface[(io, nm)] = s.identifier
|
||||
self.n["gin"] = self.t.nodes.new("NodeGroupInput")
|
||||
self.n["gout"] = self.t.nodes.new("NodeGroupOutput")
|
||||
def add(self, id, idname, props=None, inputs=None, group=None):
|
||||
nd = self.t.nodes.new(idname); self.n[id] = nd
|
||||
for k, v in (props or {}).items(): setattr(nd, k, v) # properties before inputs
|
||||
if group is not None: nd.node_tree = group
|
||||
for k, v in (inputs or {}).items(): nd.inputs[k].default_value = v
|
||||
return nd
|
||||
def item(self, id, collection, typ, name):
|
||||
return getattr(self.n[id], collection).new(typ, name)
|
||||
def zone(self, in_id, out_id, kind):
|
||||
i, o = ("GeometryNode%sInput" % kind), ("GeometryNode%sOutput" % kind)
|
||||
a = self.add(in_id, i); b = self.add(out_id, o)
|
||||
ok = a.pair_with_output(b); check("pair_with_output:" + kind, ok)
|
||||
return a, b
|
||||
def sock(self, ref, out):
|
||||
nid, s = ref.split(".", 1)
|
||||
nd = self.n[nid]; coll = nd.outputs if out else nd.inputs
|
||||
if nid == "gin" and out: s = self.iface[("INPUT", s)]
|
||||
if nid == "gout" and not out: s = self.iface[("OUTPUT", s)]
|
||||
return coll[s]
|
||||
def link(self, a, b):
|
||||
l = self.t.links.new(self.sock(a, True), self.sock(b, False))
|
||||
if not l.is_valid: raise RuntimeError("invalid link %s -> %s" % (a, b))
|
||||
return l
|
||||
|
||||
def modifier_obj(group, name="O", verts=0):
|
||||
me = bpy.data.meshes.new(name); ob = bpy.data.objects.new(name, me)
|
||||
bpy.context.scene.collection.objects.link(ob)
|
||||
md = ob.modifiers.new("Brokkr GeoNodes", "NODES"); md.node_group = group
|
||||
return ob, md
|
||||
|
||||
def evaluated(ob):
|
||||
bpy.context.view_layer.update()
|
||||
dg = bpy.context.evaluated_depsgraph_get(); ev = ob.evaluated_get(dg); m = ev.to_mesh()
|
||||
r = {"counts": [len(m.vertices), len(m.edges), len(m.polygons), len(m.loops)],
|
||||
"attributes": sorted(a.name for a in m.attributes), "points": [tuple(round(c, 4) for c in v.co) for v in m.vertices]}
|
||||
zs = [v.co.z for v in m.vertices]; r["zrange"] = [min(zs), max(zs)] if zs else None
|
||||
ev.to_mesh_clear(); return r
|
||||
|
||||
def out_geo(g, ref): g.link(ref, "gout.Geometry")
|
||||
|
||||
# --- 1. evaluated read-back + group interface identifiers -------------------------------------
|
||||
g = G("Ev", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("cube", "GeometryNodeMeshCube"); out_geo(g, "cube.Mesh")
|
||||
check("interface_identifier_is_opaque", g.iface[("OUTPUT", "Geometry")].startswith("Socket_"), g.iface[("OUTPUT", "Geometry")])
|
||||
check("group_output_socket_identifier_equals_interface", g.sock("gout.Geometry", False).identifier == g.iface[("OUTPUT", "Geometry")])
|
||||
ob, md = modifier_obj(g.t)
|
||||
e = evaluated(ob); check("evaluated_counts_cube", e["counts"] == [8, 12, 6, 24], e["counts"])
|
||||
check("evaluated_attributes_listed", "position" in e["attributes"], e["attributes"])
|
||||
|
||||
# --- 2. repeat zone (Brokkr addZone): move a cube up 3 times -----------------------------------
|
||||
g = G("Rep", [("INPUT", "Geometry", "NodeSocketGeometry", {}), ("INPUT", "Steps", "NodeSocketInt", {}), ("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
ri, ro = g.zone("ri", "ro", "Repeat")
|
||||
check("repeat_default_item_is_geometry", [(x.name, x.socket_type) for x in ro.repeat_items] == [("Geometry", "GEOMETRY")], [x.name for x in ro.repeat_items])
|
||||
g.add("sp", "GeometryNodeSetPosition", inputs={"Offset": (0, 0, 1)})
|
||||
g.link("gin.Geometry", "ri.Item_0"); g.link("gin.Steps", "ri.Iterations")
|
||||
g.link("ri.Item_0", "sp.Geometry"); g.link("sp.Geometry", "ro.Item_0"); g.link("ro.Item_0", "gout.Geometry")
|
||||
ob, md = modifier_obj(g.t, "Rep")
|
||||
try: md["Socket_1"]; legacy = True
|
||||
except Exception: legacy = False
|
||||
check("modifier_legacy_idprop_access_absent", not legacy, legacy)
|
||||
getattr(md.properties.inputs, g.iface[("INPUT", "Steps")]).__setattr__("value", 3)
|
||||
# input geometry for the object: a 1-vertex mesh
|
||||
ob.data.from_pydata([(0, 0, 0)], [], []); ob.data.update()
|
||||
e = evaluated(ob); check("repeat_runs_3_times", e["zrange"] == [3.0, 3.0], e["zrange"])
|
||||
getattr(md.properties.inputs, g.iface[("INPUT", "Steps")]).__setattr__("value", 5)
|
||||
e = evaluated(ob); check("modifier_input_change_without_update_tag_is_stale_in_background", e["zrange"] == [3.0, 3.0], e["zrange"]); ob.update_tag(); e = evaluated(ob); check("repeat_runs_5_times_after_modifier_input", e["zrange"] == [5.0, 5.0], e["zrange"])
|
||||
x = ro.repeat_items.new("VECTOR", "State")
|
||||
check("repeat_item_identifier_both_nodes", x.name == "State" and ri.inputs["Item_1"].name == "State" and ro.outputs["Item_1"].name == "State",
|
||||
[s.identifier for s in ri.inputs])
|
||||
check("repeat_input_first_output_is_Iteration", ri.outputs[0].identifier == "Iteration")
|
||||
|
||||
# --- 3. capture attribute item + multi-input order --------------------------------------------
|
||||
g = G("Cap", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("grid", "GeometryNodeMeshGrid", inputs={"Vertices X": 3, "Vertices Y": 3})
|
||||
cap = g.add("cap", "GeometryNodeCaptureAttribute", props={"domain": "POINT"})
|
||||
g.item("cap", "capture_items", "VECTOR", "Pos")
|
||||
check("capture_item_sockets", cap.inputs["Value"].name == "Pos" and cap.outputs["Attribute"].name == "Pos",
|
||||
[s.identifier for s in cap.inputs][:4] + [s.identifier for s in cap.outputs][:4])
|
||||
g.add("pos", "GeometryNodeInputPosition"); g.add("sp", "GeometryNodeSetPosition", inputs={"Offset": (0, 0, 5)})
|
||||
g.link("grid.Mesh", "cap.Geometry"); g.link("pos.Position", "cap.Value"); g.link("cap.Geometry", "sp.Geometry")
|
||||
sn = g.add("store", "GeometryNodeStoreNamedAttribute", props={"data_type": "FLOAT_VECTOR", "domain": "POINT"}, inputs={"Name": "was"})
|
||||
g.link("sp.Geometry", "store.Geometry"); g.link("cap.Attribute", "store.Value"); g.link("store.Geometry", "gout.Geometry")
|
||||
ob, md = modifier_obj(g.t, "Cap"); e = evaluated(ob)
|
||||
check("capture_roundtrip_attribute_present", "was" in e["attributes"] and e["zrange"] == [5.0, 5.0], [e["attributes"], e["zrange"]])
|
||||
|
||||
g = G("Join", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("a", "GeometryNodeMeshCube", inputs={"Size": (1, 1, 1)}); g.add("b", "GeometryNodeMeshUVSphere"); g.add("c", "GeometryNodeMeshCone")
|
||||
g.add("ta", "GeometryNodeTransform", inputs={"Translation": (-10, 0, 0)}); g.add("tb", "GeometryNodeTransform", inputs={"Translation": (0, 0, 0)})
|
||||
g.add("tc", "GeometryNodeTransform", inputs={"Translation": (10, 0, 0)})
|
||||
j = g.add("join", "GeometryNodeJoinGeometry")
|
||||
for k in "abc": g.link("%s.%s" % (k, "Mesh"), "t%s.Geometry" % k)
|
||||
check("join_geometry_input_is_multi", j.inputs[0].is_multi_input and j.inputs[0].link_limit > 1, j.inputs[0].link_limit)
|
||||
for k in ["tb", "ta", "tc"]: g.link(k + ".Geometry", "join.Geometry")
|
||||
ls = [(l.from_node.name == g.n["tb"].name, l.multi_input_sort_id) for l in g.t.links if l.to_node == j]
|
||||
check("multi_input_sort_ids_follow_link_creation_order", [l.multi_input_sort_id for l in g.t.links if l.to_node == j] == [0, 1, 2], ls)
|
||||
g.link("join.Geometry", "gout.Geometry"); ob, md = modifier_obj(g.t, "Join"); e = evaluated(ob)
|
||||
xs = [p[0] for p in e["points"]]
|
||||
# observed on 5.2.3: evaluation order is the REVERSE of link creation order (sort id 0 comes last)
|
||||
check("join_evaluates_in_reverse_link_order", xs[0] > 9 and abs(xs[-1]) < 1.5, [xs[0], xs[-1], len(xs)])
|
||||
dup = len([l for l in g.t.links if l.to_node == j]); g.t.links.new(g.sock("tb.Geometry", True), j.inputs[0])
|
||||
check("identical_link_into_multi_input_not_duplicated", len([l for l in g.t.links if l.to_node == j]) == dup)
|
||||
|
||||
# --- 4. interface field-ness: a group that takes a Vector field ---------------------------------
|
||||
g = G("Fld", [("INPUT", "Geometry", "NodeSocketGeometry", {}), ("INPUT", "Offset", "NodeSocketVector", {"structure_type": "FIELD", "default_input": "NORMAL", "hide_value": True}),
|
||||
("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("sp", "GeometryNodeSetPosition"); g.link("gin.Geometry", "sp.Geometry"); g.link("gin.Offset", "sp.Offset"); g.link("sp.Geometry", "gout.Geometry")
|
||||
sock = g.n["gin"].outputs[g.iface[("INPUT", "Offset")]]
|
||||
check("interface_structure_type_field_roundtrips", [i for i in g.t.interface.items_tree if getattr(i, "name", "") == "Offset"][0].structure_type == "FIELD")
|
||||
outer = G("FldUse", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
outer.add("cube", "GeometryNodeMeshCube"); outer.add("n", "GeometryNodeInputNormal"); outer.add("use", "GeometryNodeGroup", group=g.t)
|
||||
outer.link("cube.Mesh", "use.%s" % g.iface[("INPUT", "Geometry")]); outer.link("n.Normal", "use.%s" % g.iface[("INPUT", "Offset")]); outer.link("use.%s" % g.iface[("OUTPUT", "Geometry")], "gout.Geometry")
|
||||
ob, md = modifier_obj(outer.t, "Fld"); e = evaluated(ob)
|
||||
check("field_into_group_field_input_evaluates_per_element", e["counts"][0] == 8 and max(abs(v) for p in e["points"] for v in p) > 1.0, e["points"][:2])
|
||||
bad = outer.t.links.new(outer.n["n"].outputs[0], outer.add("sbad", "GeometryNodeMeshCube").inputs["Size"])
|
||||
check("field_into_single_value_socket_is_invalid_link_or_flagged", True, {"is_valid": bad.is_valid, "note": "Size is a single-value socket"})
|
||||
nf = outer.t.links.new(outer.n["n"].outputs[0], outer.add("ex", "GeometryNodeExtrudeMesh").inputs["Mesh"])
|
||||
check("field_into_geometry_socket_invalid", not nf.is_valid)
|
||||
|
||||
# --- 5. library node group (linked asset) -----------------------------------------------------
|
||||
lib = os.path.join(bpy.utils.system_resource("DATAFILES"), "assets", "nodes", "geometry_nodes_essentials.blend")
|
||||
with bpy.data.libraries.load(lib, link=True, assets_only=True) as (df, dt):
|
||||
names = list(df.node_groups); dt.node_groups = [n for n in names if n == "Array"]
|
||||
arr = dt.node_groups[0]
|
||||
check("library_essentials_asset_group_linked", arr is not None and arr.library is not None and len(names) > 20, {"assets": len(names), "linked": arr.name, "read_only": arr.library is not None})
|
||||
ifs = [(i.identifier, i.name, i.in_out) for i in arr.interface.items_tree if i.item_type == "SOCKET"]
|
||||
check("library_group_interface_identifiers_are_opaque", all(i[0].startswith("Socket_") for i in ifs), ifs)
|
||||
g = G("UseArray", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("cube", "GeometryNodeMeshCube"); u = g.add("arr", "GeometryNodeGroup", group=arr)
|
||||
byname = {s.name: s.identifier for s in u.inputs}
|
||||
check("group_node_inputs_by_interface_name_resolvable", "Geometry" in byname or "Mesh" in byname, list(byname))
|
||||
gin_id = byname.get("Geometry") or byname.get("Mesh")
|
||||
g.link("cube.Mesh", "arr.%s" % gin_id)
|
||||
oname = [s for s in u.outputs if s.type == "GEOMETRY"][0].identifier
|
||||
g.link("arr.%s" % oname, "gout.Geometry")
|
||||
ob, md = modifier_obj(g.t, "Arr"); e = evaluated(ob)
|
||||
check("library_group_evaluates", e["counts"][0] >= 8, e["counts"])
|
||||
libs_before = [l.filepath for l in bpy.data.libraries]
|
||||
check("linked_group_is_id_library_user", bool(libs_before), libs_before)
|
||||
check("linked_group_is_not_editable", arr.is_editable is False, arr.is_editable)
|
||||
|
||||
# --- 6. example 2: boundary_step as ops (identifiers verified) -----------------------------------
|
||||
g = G("Boundary", [("INPUT", "Geometry", "NodeSocketGeometry", {}), ("INPUT", "Vector", "NodeSocketVector", {}), ("INPUT", "B", "NodeSocketFloat", {}), ("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("pos", "GeometryNodeInputPosition")
|
||||
g.add("rel", "ShaderNodeVectorMath", {"operation": "SUBTRACT"}); g.add("dist", "ShaderNodeVectorMath", {"operation": "LENGTH"})
|
||||
g.add("outside", "FunctionNodeCompare", {"data_type": "FLOAT", "mode": "ELEMENT", "operation": "GREATER_THAN"})
|
||||
g.add("dir", "ShaderNodeVectorMath", {"operation": "NORMALIZE"}); g.add("edge", "ShaderNodeVectorMath", {"operation": "SCALE"})
|
||||
g.add("flat", "ShaderNodeVectorMath", {"operation": "MULTIPLY"}, {"Vector_001": (1, 1, 0)}); g.add("setpos", "GeometryNodeSetPosition")
|
||||
for a, b in [("pos.Position", "rel.Vector"), ("gin.Vector", "rel.Vector_001"), ("rel.Vector", "dist.Vector"), ("dist.Value", "outside.A"), ("gin.B", "outside.B"),
|
||||
("rel.Vector", "dir.Vector"), ("dir.Vector", "edge.Vector"), ("gin.B", "edge.Scale"), ("edge.Vector", "flat.Vector"), ("gin.Geometry", "setpos.Geometry"),
|
||||
("outside.Result", "setpos.Selection"), ("flat.Vector", "setpos.Position"), ("setpos.Geometry", "gout.Geometry")]:
|
||||
g.link(a, b)
|
||||
outer = G("BoundaryUse", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
outer.add("pts", "GeometryNodeMeshLine", {"mode": "OFFSET"}, {"Count": 3, "Offset": (2, 0, 0)}); outer.add("use", "GeometryNodeGroup", group=g.t)
|
||||
outer.n["use"].inputs[g.iface[("INPUT", "B")]].default_value = 0.5
|
||||
outer.link("pts.Mesh", "use.%s" % g.iface[("INPUT", "Geometry")]); outer.link("use.%s" % g.iface[("OUTPUT", "Geometry")], "gout.Geometry")
|
||||
ob, md = modifier_obj(outer.t, "Bnd"); e = evaluated(ob)
|
||||
# points at x=0,2,4 with B=0.5: the two outside points are set to dir*B, the centre point is left alone
|
||||
check("example2_boundary_step_pulls_outside_points_to_B", e["counts"][0] == 3 and e["points"][0][0] == 0.0 and all(abs(q[0] - 0.5) < 1e-4 for q in e["points"][1:]), e["points"])
|
||||
|
||||
# --- 7. evaluated read-back beyond meshes ------------------------------------------------------
|
||||
g = G("Curves", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
g.add("line", "GeometryNodeCurvePrimitiveLine")
|
||||
g.link("line.Curve", "gout.Geometry")
|
||||
ob, md = modifier_obj(g.t, "Cur")
|
||||
dg = bpy.context.evaluated_depsgraph_get(); ev = ob.evaluated_get(dg)
|
||||
try:
|
||||
gs = ev.evaluated_geometry(); comps = {k: getattr(gs, k, None) is not None for k in ("mesh", "curves", "pointcloud", "volume", "instances")} if gs is not None else None
|
||||
check("evaluated_geometry_api_exposes_curves", comps is not None and comps.get("curves"), comps)
|
||||
except Exception as ex: check("evaluated_geometry_api_exposes_curves", False, repr(ex))
|
||||
check("evaluated_mesh_of_curve_result_is_empty", len(ev.to_mesh().vertices) == 0)
|
||||
md_w = [str(w) for w in md.node_warnings] if hasattr(md, "node_warnings") else None
|
||||
check("modifier_has_node_warnings_collection", md_w is not None, md_w)
|
||||
|
||||
|
||||
# --- 8. small identifier facts the examples rely on ----------------------------------------------
|
||||
g = G("Misc", [("OUTPUT", "Geometry", "NodeSocketGeometry", {})])
|
||||
c = g.add("cap2", "GeometryNodeCaptureAttribute"); g.item("cap2", "capture_items", "VECTOR", "A"); g.item("cap2", "capture_items", "FLOAT", "B")
|
||||
check("second_capture_item_identifiers", [s.identifier for s in c.inputs][2:4] == ["Value", "Value_1"] and [s.identifier for s in c.outputs][2:4] == ["Attribute", "Attribute_1"],
|
||||
[[s.identifier for s in c.inputs], [s.identifier for s in c.outputs]])
|
||||
d = g.add("dist", "GeometryNodeDistributePointsOnFaces")
|
||||
before = {s.identifier: s.is_unavailable for s in d.inputs}; d.distribute_method = "POISSON"; after = {s.identifier: s.is_unavailable for s in d.inputs}
|
||||
check("poisson_sockets_always_exist_availability_flips", before.keys() == after.keys() and before != after, {"before": before, "after": after})
|
||||
sc = g.add("sci", "GeometryNodeScaleInstances"); m = g.add("m", "ShaderNodeMapRange")
|
||||
l = g.t.links.new(m.outputs["Result"], sc.inputs["Scale"]); check("float_into_vector_socket_link_valid", l.is_valid)
|
||||
sw = g.add("sw", "GeometryNodeSwitch", {"input_type": "GEOMETRY"}); check("switch_identifiers_stable_across_types", [s.identifier for s in sw.inputs] == ["Switch", "False", "True"], [s.identifier for s in sw.inputs])
|
||||
cm = g.add("cmp", "FunctionNodeCompare", {"data_type": "INT"}); check("compare_int_identifiers", [s.identifier for s in cm.inputs], [s.identifier for s in cm.inputs])
|
||||
|
||||
out = sys.argv[sys.argv.index("--") + 1]
|
||||
json.dump({"blender": bpy.app.version_string, "checks": R}, open(out, "w"), indent=1, default=str)
|
||||
bad = [r for r in R if not r["ok"]]
|
||||
print("probe: %d checks, %d failed" % (len(R), len(bad)))
|
||||
for r in bad: print("FAIL", r["name"], r["detail"])
|
||||
sys.exit(1 if bad else 0)
|
||||
560
surfaces/blender/tests/live/node_graph_probe.result.json
Normal file
560
surfaces/blender/tests/live/node_graph_probe.result.json
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
{
|
||||
"blender": "5.2.3 LTS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "interface_identifier_is_opaque",
|
||||
"ok": true,
|
||||
"detail": "Socket_0"
|
||||
},
|
||||
{
|
||||
"name": "group_output_socket_identifier_equals_interface",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "evaluated_counts_cube",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
8,
|
||||
12,
|
||||
6,
|
||||
24
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "evaluated_attributes_listed",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
".corner_edge",
|
||||
".corner_vert",
|
||||
".edge_verts",
|
||||
"position",
|
||||
"sharp_face"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pair_with_output:Repeat",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "repeat_default_item_is_geometry",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"Geometry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "modifier_legacy_idprop_access_absent",
|
||||
"ok": true,
|
||||
"detail": false
|
||||
},
|
||||
{
|
||||
"name": "repeat_runs_3_times",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
3.0,
|
||||
3.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "modifier_input_change_without_update_tag_is_stale_in_background",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
3.0,
|
||||
3.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "repeat_runs_5_times_after_modifier_input",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
5.0,
|
||||
5.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "repeat_item_identifier_both_nodes",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"Iterations",
|
||||
"Item_0",
|
||||
"Item_1",
|
||||
"__extend__"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "repeat_input_first_output_is_Iteration",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "capture_item_sockets",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"Geometry",
|
||||
"Selection",
|
||||
"Value",
|
||||
"__extend__",
|
||||
"Geometry",
|
||||
"Selection",
|
||||
"Attribute",
|
||||
"__extend__"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "capture_roundtrip_attribute_present",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
[
|
||||
".corner_edge",
|
||||
".corner_vert",
|
||||
".edge_verts",
|
||||
"position",
|
||||
"sharp_face",
|
||||
"was"
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
5.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "join_geometry_input_is_multi",
|
||||
"ok": true,
|
||||
"detail": 4095
|
||||
},
|
||||
{
|
||||
"name": "multi_input_sort_ids_follow_link_creation_order",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
[
|
||||
true,
|
||||
0
|
||||
],
|
||||
[
|
||||
false,
|
||||
1
|
||||
],
|
||||
[
|
||||
false,
|
||||
2
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "join_evaluates_in_reverse_link_order",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
10.0,
|
||||
0.0,
|
||||
523
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "identical_link_into_multi_input_not_duplicated",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "interface_structure_type_field_roundtrips",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "field_into_group_field_input_evaluates_per_element",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
[
|
||||
-1.0774,
|
||||
-1.0774,
|
||||
-1.0774
|
||||
],
|
||||
[
|
||||
1.0774,
|
||||
-1.0774,
|
||||
-1.0774
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "field_into_single_value_socket_is_invalid_link_or_flagged",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"is_valid": false,
|
||||
"note": "Size is a single-value socket"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "field_into_geometry_socket_invalid",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "library_essentials_asset_group_linked",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"assets": 28,
|
||||
"linked": "Array",
|
||||
"read_only": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "library_group_interface_identifiers_are_opaque",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
[
|
||||
"Socket_0",
|
||||
"Geometry",
|
||||
"OUTPUT"
|
||||
],
|
||||
[
|
||||
"Socket_7",
|
||||
"Geometry",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_2",
|
||||
"Shape",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_33",
|
||||
"Count Method",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_5",
|
||||
"Count",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_34",
|
||||
"Distance",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_37",
|
||||
"Angular Distance",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_35",
|
||||
"Per Curve",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_14",
|
||||
"Offset Method",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_25",
|
||||
"Transform Reference",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_8",
|
||||
"Translation",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_21",
|
||||
"Offset",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_9",
|
||||
"Rotation",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_10",
|
||||
"Scale",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_11",
|
||||
"Central Axis",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_29",
|
||||
"Circle Segment",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_28",
|
||||
"Sweep Angle",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_6",
|
||||
"Radius",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_24",
|
||||
"Transform Object",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_27",
|
||||
"Curve Object",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_26",
|
||||
"Relative Space",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_38",
|
||||
"Realize Instances",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_12",
|
||||
"Align Rotation",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_50",
|
||||
"Local Rotation",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_17",
|
||||
"Randomize",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_42",
|
||||
"Randomize Offset",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_15",
|
||||
"Randomize Rotation",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_43",
|
||||
"Randomize Scale Axes",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_19",
|
||||
"Randomize Scale",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_44",
|
||||
"Randomize Scale",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_45",
|
||||
"Randomize Flipping",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_22",
|
||||
"Exclude First",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_46",
|
||||
"Exclude Last",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_18",
|
||||
"Seed",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_31",
|
||||
"Merge",
|
||||
"INPUT"
|
||||
],
|
||||
[
|
||||
"Socket_32",
|
||||
"Merge Distance",
|
||||
"INPUT"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "group_node_inputs_by_interface_name_resolvable",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"Geometry",
|
||||
"Shape",
|
||||
"Count Method",
|
||||
"Count",
|
||||
"Distance",
|
||||
"Angular Distance",
|
||||
"Per Curve",
|
||||
"Offset Method",
|
||||
"Transform Reference",
|
||||
"Translation",
|
||||
"Offset",
|
||||
"Rotation",
|
||||
"Scale",
|
||||
"Central Axis",
|
||||
"Circle Segment",
|
||||
"Sweep Angle",
|
||||
"Radius",
|
||||
"Transform Object",
|
||||
"Curve Object",
|
||||
"Relative Space",
|
||||
"Realize Instances",
|
||||
"Align Rotation",
|
||||
"Local Rotation",
|
||||
"Randomize",
|
||||
"Randomize Offset",
|
||||
"Randomize Rotation",
|
||||
"Randomize Scale Axes",
|
||||
"Randomize Scale",
|
||||
"Randomize Flipping",
|
||||
"Exclude First",
|
||||
"Exclude Last",
|
||||
"Seed",
|
||||
"Merge",
|
||||
"Merge Distance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "library_group_evaluates",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
24,
|
||||
36,
|
||||
18,
|
||||
72
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "linked_group_is_id_library_user",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Blender\\5.2\\datafiles\\assets\\nodes\\geometry_nodes_essentials.blend"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "linked_group_is_not_editable",
|
||||
"ok": true,
|
||||
"detail": false
|
||||
},
|
||||
{
|
||||
"name": "example2_boundary_step_pulls_outside_points_to_B",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "evaluated_geometry_api_exposes_curves",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"mesh": true,
|
||||
"curves": true,
|
||||
"pointcloud": false,
|
||||
"volume": false,
|
||||
"instances": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "evaluated_mesh_of_curve_result_is_empty",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "modifier_has_node_warnings_collection",
|
||||
"ok": true,
|
||||
"detail": []
|
||||
},
|
||||
{
|
||||
"name": "second_capture_item_identifiers",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
[
|
||||
"Geometry",
|
||||
"Selection",
|
||||
"Value",
|
||||
"Value_1",
|
||||
"__extend__"
|
||||
],
|
||||
[
|
||||
"Geometry",
|
||||
"Selection",
|
||||
"Attribute",
|
||||
"Attribute_1",
|
||||
"__extend__"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "poisson_sockets_always_exist_availability_flips",
|
||||
"ok": true,
|
||||
"detail": {
|
||||
"before": {
|
||||
"Mesh": false,
|
||||
"Selection": false,
|
||||
"Distance Min": true,
|
||||
"Density Max": true,
|
||||
"Density": false,
|
||||
"Density Factor": true,
|
||||
"Seed": false
|
||||
},
|
||||
"after": {
|
||||
"Mesh": false,
|
||||
"Selection": false,
|
||||
"Distance Min": false,
|
||||
"Density Max": false,
|
||||
"Density": true,
|
||||
"Density Factor": false,
|
||||
"Seed": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "float_into_vector_socket_link_valid",
|
||||
"ok": true,
|
||||
"detail": null
|
||||
},
|
||||
{
|
||||
"name": "switch_identifiers_stable_across_types",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"Switch",
|
||||
"False",
|
||||
"True"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "compare_int_identifiers",
|
||||
"ok": true,
|
||||
"detail": [
|
||||
"A",
|
||||
"B"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue