WIP: Blender node graphs: design #3

Draft
libby wants to merge 5 commits from libby/blender-node-graphs-design into main
4 changed files with 1728 additions and 0 deletions

View 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
View 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 | |

View 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)

View 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"
]
}
]
}