From d1f888f7acab6779eb34569cec7f6e27be6d48fb Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 12:45:21 +0000 Subject: [PATCH 1/5] docs: Blender node graph design (typed graph document, actions, safety) Design only. Typed brokkr.blender.node_graph.v0 document, tree kinds, read-back, the node actions, idempotency by node id, error slugs, exclusion of script nodes, testable-without-Blender split, version matrix, cut plan. Co-Authored-By: Claude Sonnet 5.5 --- docs/blender-node-graphs.md | 322 ++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/blender-node-graphs.md diff --git a/docs/blender-node-graphs.md b/docs/blender-node-graphs.md new file mode 100644 index 0000000..2ac3b13 --- /dev/null +++ b/docs/blender-node-graphs.md @@ -0,0 +1,322 @@ +# Blender node graphs: design + +Status: design only, no code. Draft for review. Nothing here is implemented; every +Blender API claim comes from Blender's documented Python API and has **not** been +run against a live Blender (none is installed on the machines that wrote this). + +## 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), +animation/driver authoring on node values, simulation and repeat *zones* (they need +paired nodes; see Open questions), baking, add-on node packs, and anything that +executes user code. + +## 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 [InterfaceSocket] groups only (geometry, shaderGroup): the tree's own inputs/outputs + 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 string, for group nodes: treeKey of the referenced node group + mute bool +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 ... + default Value, inputs only, absent when the socket has no stored default (Geometry, Shader) + linked bool, read-back only +Link + fromNode, fromSocket (identifier) -> toNode, toSocket (identifier) +InterfaceSocket + identifier, name, inOut (Input|Output), type, default, min, max, subtype +Value + tagged: number | integer | bool | string | vector3 | rgba | 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 `treeKey`; the referenced tree must exist + (create it first with `createNodeGroup`). A group may not contain itself, directly + or through other groups (refused, see Errors). + +## 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. | +| `linkSockets` | tree, `fromNode`, `fromSocket`, `toNode`, `toSocket` | Links outputs to an input. Replaces an existing link into a single-link input. | +| `unlinkSockets` | tree, `toNode`, `toSocket`, optional `fromNode`, `fromSocket` | Removes links into an input (all, or the one matching). None to remove: `accepted`. | +| `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 (`modifier[identifier]`), validated against the group's interface type. | + +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. A different + source into a single-link input replaces the link and says so in the message. +- `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. + +## 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 | +| `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. +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. +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. +- 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. +- 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 | +| 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 | same | + +The add-on declares `blender: (4, 0, 0)` today, so v1 supports 4.0+ and answers +`version_unsupported` below it. Rows marked "confirm" are claims this document could +not check. + +## Cut plan + +Each cut is its own PR with tests that fail when its rule breaks. + +1. **Document and guards, no bpy.** Types, canonical form, hash; id/identifier/value + validation; exclusion and allowlist; caps. Pure tests. +2. **Pure graph engine.** In-memory graph, idempotent apply of the action set, 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 and applies ops + to it; fake-`bpy` tests; `assignNodeTree`, `setModifierInput`. Read-back publishing. +4. **Live smoke.** Headless Blender script and the round-trip test; version matrix + filled in with what Blender actually does. +5. **Node recipes (optional).** Named, tested graph builders (a procedural scatter, a + PBR material) expressed as documents, so clients do not rediscover identifiers. + +## 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. **Zones.** Simulation and repeat zones come as input/output node pairs + (`pair_with_output`). Excluded in v1; admitting them needs a paired-add op and a + paired-remove rule. +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. -- 2.47.3 From f7d211a74d77d1b4167e9babfbe4721c364b355e Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 13:16:04 +0000 Subject: [PATCH 2/5] docs: worked node-graph examples and the design gaps they expose Five real setups (grass tuft, index-of-nearest helpers, raycast helpers, shortest-path labyrinth, Blender essentials as library groups) plus the scripted habitats, expressed as Brokkr ops where possible, with eleven numbered design gaps. Nothing run in Blender. Co-Authored-By: Claude Sonnet 5.5 --- docs/blender-node-graph-examples.md | 331 ++++++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 docs/blender-node-graph-examples.md diff --git a/docs/blender-node-graph-examples.md b/docs/blender-node-graph-examples.md new file mode 100644 index 0000000..094279b --- /dev/null +++ b/docs/blender-node-graph-examples.md @@ -0,0 +1,331 @@ +# 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 cannot yet say. Nothing here was run in Blender: the graph shapes come from +sources that were (below), the socket names are Blender's display names written where +the real **identifiers** must be read back first (identifiers are usually equal to the +names, with `_001` suffixes on repeats such as Math's three `Value` inputs), and no op +was applied to a live tree. A design gap is marked **GAP** and numbered; the list is at +the end. + +## 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 +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} +addItem cap VECTOR "Value" # GAP 1: not an action yet +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, 44 in all; 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.Value -> rnorm.Vector rnorm.Vector -> tan.Vector +cap.Value -> 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.Value -> 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 +``` + +(`pos2` is a third `GeometryNodeInputPosition` evaluated on the instance domain; it and +the omitted node lines follow the same pattern.) About 38 nodes and 44 links. + +**Expressible:** everything except the capture item. **Needs:** + +- **GAP 1, item sockets.** Capture Attribute's `Value` is an *item* the node grows + (`capture_items.new(type, name)`), not a property or an input default. VibeGeometry + hit this directly: on Blender 5.1 a generic capture call did not create the item, and + the verified script adds it explicitly. The design's `setNodeProperty` cannot make it. +- **GAP 3, field sockets.** `scatter."Density Factor"`, `scl.Scale`, `c2.Value` are + *fields*; whether a socket accepts one, and an interface socket's field-ness, is not + in the document. +- **Verification.** VibeGeometry judged correctness by evaluated vertex/edge/face + counts. The design has no way to read the evaluated result (**GAP 8**). + +Ordering note: `scatter` has Poisson-only sockets (`Distance Min`, `Density Max`) that +exist only after `distribute_method` is set, so `addNode` must apply properties before +inputs (the design already says so) and the client must treat identifiers as valid only +after the properties are applied. + +## 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:** the helper groups, all of them, as plain graphs. **Needs:** + +- **GAP 2, zones.** The thing that makes them a simulation (Simulation Input/Output, or + Repeat) is excluded in v1. Without it the helpers are inert; with it they are the + point of the setup. +- **GAP 3, field-valued group outputs.** `update_velocity` returns a field and takes + field inputs. An interface socket must say its structure (single value, field, or + dynamic) and may default to an implicit field such as `POSITION`. +- **Harnesses.** VibeGeometry could only observe `update_velocity` by wiring its vector + into `Set Position` of a test point cloud; the same trick is how any Brokkr test of a + field-only group works (**GAP 8** again). + +## 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:** all three helpers. **Needs:** **GAP 2** (one Cast Rays per bounce lives +in a Repeat zone in the full graph); **GAP 6** for the object reference +(`ref(object)` is in the design, but an Object Info node's *object* input is a socket +default the document must type as a reference, which it does, so this one is covered); +the legacy **Euler vector socket** `rotation_type` enum differs by node type (verify). + +## 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} + addItem 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:** every node is a plain node; the sequencing is plain links. +**Needs:** **GAP 1** (capture items, three of them), **GAP 4** (Join Geometry is a +*multi-input* socket: many links into one socket, where order is meaningful, and the +design says a link "replaces an existing link into a single-link input", which is wrong +for this socket). 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's `geometry_nodes_essentials.blend` ships these as node groups +(names read from the file): `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`, `Screen to 3D Space`, `Curve UV`, `Is Edge Boundary`, `Is Edge +Loose`, `Is Edge Manifold`, `Is Edge UV Seam`, and cylindrical/spherical combine and +separate helpers (35 groups). `geometry_nodes_dynamics_assets.blend` ships 59 more +(XPBD Simulation, Collider, Gravity, Pin Positions, Hair Dynamics, Edge Length +Constraint, `For Each Sim Geometry`, `For Each Typed Bundle`). **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] +addNode scatter GeometryNodeGroup group="Scatter on Surface" # a library group, not in this file +addNode rotate GeometryNodeGroup group="Random Rotation" +gin.Geometry->scatter.Geometry gin.Density->scatter.Density scatter.Geometry->gout.Geometry +assignNodeTree geometry "Rock Field" objectName="Terrain" +``` + +**Needs:** **GAP 5.** The design lets a group node reference only a tree that exists in +the current file (`group_not_found` otherwise). Blender's essentials are *assets in a +library*: the group must first be linked or appended. An action that links a named +asset from a configured asset library is the highest-value missing piece for geometry +nodes: it turns the 35 essentials into building blocks. It is also a file read, so it +takes the same confinement as `importGltf` (the asset library is a configured directory, +or the Blender-shipped essentials path). + +## 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 + +| # | gap | seen in | smallest fix | +|---|---|---|---| +| 1 | **Item sockets**: Capture Attribute items, Repeat/Simulation state items, Menu and Index Switch items, Join/Bundle items are grown by the node, not set as properties | 1, 4 | `addNodeItem` (`nodeId`, `kind`, `socketType`, `name`) in the batch; items appear in the document as ordered `items`; identifiers valid only after | +| 2 | **Zones**: Simulation, Repeat, For Each Element come as paired input/output nodes; no action pairs them | 2, 3, essentials | A `addZone` op that creates the pair and links the state items; read-back carries `pairedWith` | +| 3 | **Field-ness and implicit defaults**: whether a socket takes a field, and an interface socket's structure, default input (`POSITION`, `NORMAL`, `INDEX`), domain, hide-value, panels | 1, 2 | Extend `InterfaceSocket` with `structure`, `defaultInput`, `attributeDomain`, `hideValue`, `panel` | +| 4 | **Multi-input sockets** (Join Geometry): many links, order matters, the replace rule is wrong | 4 | `Socket.multiInput`; `linkSockets` takes `position`; no replace on multi-input sockets | +| 5 | **Library/asset groups**: essentials are linked assets, not present in the file | 5 | `linkNodeGroupAsset` from a configured asset-library directory, confined like `importGltf` | +| 6 | **Socket types beyond the design's list**: Menu (enum-valued socket; VibeGeometry's third patch "tolerate menu socket defaults"), Bundle, Closure, List | essentials (`For Each Typed Bundle`, `String to List`) | Add `menu`, `bundle`, `closure`, `list` to `Socket.type` and `Value` (menu as `enum`) | +| 7 | **Authoring at scale**: hundreds of nodes; the DSL compresses with loops | 6 | Recipes (cut 5) promoted to required; batch size cap raised for generated graphs, or documents applied by `applyNodeGraph` | +| 8 | **No evaluated read-back**: VibeGeometry verified every translation by evaluated vertex/edge/face counts; the design reads only the tree | 1-4 | `queryEvaluatedGeometry`: counts per component, bounding box, attribute names, for an object's evaluated result | +| 9 | **Baking and caches** (Bake node, simulation bake, geometry caches in the dynamics assets) | 2, essentials | Out of scope for v1; needs its own action and a confined cache directory | +| 10 | **Drivers and animation** on node values (a keyframed `Value` node is how many setups animate) | common | Out of scope by safety rule 2; keyframes (data only) could be added without drivers | +| 11 | **Node parenting and frames, labels, colours**: `NodeFrame` is allowed but `Node` has no `parent` or colour | readable graphs | Add `parent`, `color` to `Node` | + +## Lessons carried into the design + +- VibeGeometry verified on **Blender 5.1.1**; the machine here has **5.2.3 LTS** (Steam). + The design's version matrix still says "confirm" for the 5.x rows; these sources + support: group interface through `tree.interface`, capture items explicit, menu + socket defaults need tolerance, nested tree-group references behave differently on 4.x + (their patch 0001 fixes `Geometry Script` for it). +- 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). -- 2.47.3 From 521d3f06258a92947ddad2ebfe268ba1d598c5a9 Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 13:16:17 +0000 Subject: [PATCH 3/5] docs: link the worked examples, tighten example 3, zones recommendation Co-Authored-By: Claude Sonnet 5.5 --- docs/blender-node-graph-examples.md | 17 ++++++++--------- docs/blender-node-graphs.md | 11 +++++++++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/blender-node-graph-examples.md b/docs/blender-node-graph-examples.md index 094279b..1340445 100644 --- a/docs/blender-node-graph-examples.md +++ b/docs/blender-node-graph-examples.md @@ -52,7 +52,8 @@ createNodeGroup geometry "Grass Tuft Generator" interface=[ # seed surface addNode disc GeometryNodeMeshCircle properties{fill_type: TRIANGLE_FAN} inputs{Vertices: 12} addNode sub GeometryNodeSubdivideMesh inputs{Level: 3} -addNode pos1 GeometryNodeInputPosition +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} @@ -96,7 +97,7 @@ addNode smooth GeometryNodeSetShadeSmooth properties{domain: FACE} input addNode mat GeometryNodeSetMaterial ``` -Links (the wiring, 44 in all; the group inputs fan out): +Links (the wiring, about 45; the group inputs fan out): ```text gin.Radius -> disc.Radius disc.Mesh -> sub.Mesh @@ -119,8 +120,7 @@ rsw.Output -> c2m.Scale c2m.Mesh -> smooth.Mesh smooth.Mes mat.Geometry -> gout.Geometry ``` -(`pos2` is a third `GeometryNodeInputPosition` evaluated on the instance domain; it and -the omitted node lines follow the same pattern.) About 38 nodes and 44 links. +About 40 nodes and 45 links; the counts are from reading the script, not from Blender. **Expressible:** everything except the capture item. **Needs:** @@ -211,11 +211,10 @@ fwd.Vector->turn.Vector gin.Rotation->turn.Rotation turn.Vector->gout.Direct `Distance`), `ShaderNodeVectorMath` SCALE 0.999, `GeometryNodeSetPosition` at `Hit Index`, `ShaderNodeVectorMath` REFLECT, `ShaderNodeMath` ADD and `+1` for the index. -**Expressible:** all three helpers. **Needs:** **GAP 2** (one Cast Rays per bounce lives -in a Repeat zone in the full graph); **GAP 6** for the object reference -(`ref(object)` is in the design, but an Object Info node's *object* input is a socket -default the document must type as a reference, which it does, so this one is covered); -the legacy **Euler vector socket** `rotation_type` enum differs by node type (verify). +**Expressible:** all three helpers, including the target object, which is a +`ref(object, name)` default on the Object Info node. **Needs:** **GAP 2** (the full +graph runs one Cast Rays per bounce inside a Repeat zone). The Euler/axis `rotation_type` +enum values differ by node type and are read from the running Blender, not assumed. ## 4. Shortest-path labyrinth diff --git a/docs/blender-node-graphs.md b/docs/blender-node-graphs.md index 2ac3b13..a9a57eb 100644 --- a/docs/blender-node-graphs.md +++ b/docs/blender-node-graphs.md @@ -20,6 +20,11 @@ animation/driver authoring on node values, simulation and repeat *zones* (they n paired nodes; see Open questions), baking, add-on node packs, and anything that executes user code. +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 4 +(item sockets, zones, field-ness on interface sockets, multi-input sockets) change +this document's contract and are to be folded in before cut 1. + ## Tree kinds and owners A **tree** is addressed by `treeKind` and `treeKey`. The owner decides where the @@ -274,7 +279,7 @@ the repo, run by a human or on a runner with 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 | +| group interface | `tree.inputs/outputs` | `tree.interface.new_socket(...)` | same as 4.x (a Blender 5.1.1 translation corpus used it; Blender 5.2.3 LTS is installed on the Starfire workstation and can check the rest) | | 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 | same | @@ -314,7 +319,9 @@ Each cut is its own PR with tests that fail when its rule breaks. smoke to see which is more faithful. 3. **Zones.** Simulation and repeat zones come as input/output node pairs (`pair_with_output`). Excluded in v1; admitting them needs a paired-add op and a - paired-remove rule. + paired-remove rule. The worked examples show this is not optional for geometry + nodes: the index-of-nearest and raycast setups are inert helpers without a zone + (examples, gap 2). Recommend promoting zones into v1 as `addZone`. 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 -- 2.47.3 From 349d8160b27b2b63f6374e4d9ee95b63e5da508a Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 7 Nov 2026 13:37:59 +0000 Subject: [PATCH 4/5] docs: close node-graph design gaps 1-6, 8, 11; add headless Blender 5.2.3 probe Folds item sockets (addNodeItem), zones (addZone, in v1), interface field-ness, multi-input sockets, library groups (linkNodeGroupAsset) and evaluated read-back (queryEvaluated) into the design. node_graph_probe.py ran 37 checks on Blender 5.2.3 LTS; it corrected the modifier input API, opaque interface identifiers, capture item identifiers, Poisson socket availability and the library asset counts. A verification-status table records what was and was not run. Co-Authored-By: Claude Sonnet 5.5 --- docs/blender-node-graph-examples.md | 218 ++++--- docs/blender-node-graphs.md | 323 ++++++++-- .../blender/tests/live/node_graph_probe.py | 220 +++++++ .../tests/live/node_graph_probe.result.json | 560 ++++++++++++++++++ 4 files changed, 1195 insertions(+), 126 deletions(-) create mode 100644 surfaces/blender/tests/live/node_graph_probe.py create mode 100644 surfaces/blender/tests/live/node_graph_probe.result.json diff --git a/docs/blender-node-graph-examples.md b/docs/blender-node-graph-examples.md index 1340445..02eeed8 100644 --- a/docs/blender-node-graph-examples.md +++ b/docs/blender-node-graph-examples.md @@ -2,12 +2,21 @@ 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 cannot yet say. Nothing here was run in Blender: the graph shapes come from -sources that were (below), the socket names are Blender's display names written where -the real **identifiers** must be read back first (identifiers are usually equal to the -names, with `_001` suffixes on repeats such as Math's three `Value` inputs), and no op -was applied to a live tree. A design gap is marked **GAP** and numbered; the list is at -the end. +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 @@ -74,7 +83,7 @@ addNode blade GeometryNodeSetCurveRadius addNode inst GeometryNodeInstanceOnPoints addNode scl GeometryNodeScaleInstances inputs{"Local Space": true} addNode cap GeometryNodeCaptureAttribute properties{domain: INSTANCE} -addItem cap VECTOR "Value" # GAP 1: not an action yet +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} @@ -109,10 +118,10 @@ gin.Height -> hvec.Z hvec.Vector -> spine.Offset spine.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.Value -> rnorm.Vector rnorm.Vector -> tan.Vector -cap.Value -> rlen2.Vector rlen2.Value -> cfall.Value gin.Radius -> cfall."From Max" +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.Value -> rot.Center +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" @@ -122,22 +131,28 @@ mat.Geometry -> gout.Geometry About 40 nodes and 45 links; the counts are from reading the script, not from Blender. -**Expressible:** everything except the capture item. **Needs:** +**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.** Capture Attribute's `Value` is an *item* the node grows - (`capture_items.new(type, name)`), not a property or an input default. VibeGeometry - hit this directly: on Blender 5.1 a generic capture call did not create the item, and - the verified script adds it explicitly. The design's `setNodeProperty` cannot make it. -- **GAP 3, field sockets.** `scatter."Density Factor"`, `scl.Scale`, `c2.Value` are - *fields*; whether a socket accepts one, and an interface socket's field-ness, is not - in the document. -- **Verification.** VibeGeometry judged correctness by evaluated vertex/edge/face - counts. The design has no way to read the evaluated result (**GAP 8**). +- **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: `scatter` has Poisson-only sockets (`Distance Min`, `Density Max`) that -exist only after `distribute_method` is set, so `addNode` must apply properties before -inputs (the design already says so) and the client must treat identifiers as valid only -after the properties are applied. +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 @@ -176,17 +191,32 @@ setpos.Geometry->gout.Geometry object (`ref(object, name)`) through `GeometryNodeObjectInfo`. `update_velocity` has *no geometry input at all*: it is a pure field function returning a vector. -**Expressible:** the helper groups, all of them, as plain graphs. **Needs:** +**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.** The thing that makes them a simulation (Simulation Input/Output, or - Repeat) is excluded in v1. Without it the helpers are inert; with it they are the - point of the setup. -- **GAP 3, field-valued group outputs.** `update_velocity` returns a field and takes - field inputs. An interface socket must say its structure (single value, field, or - dynamic) and may default to an implicit field such as `POSITION`. +- **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; the same trick is how any Brokkr test of a - field-only group works (**GAP 8** again). + 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 @@ -211,10 +241,13 @@ fwd.Vector->turn.Vector gin.Rotation->turn.Rotation turn.Vector->gout.Direct `Distance`), `ShaderNodeVectorMath` SCALE 0.999, `GeometryNodeSetPosition` at `Hit Index`, `ShaderNodeVectorMath` REFLECT, `ShaderNodeMath` ADD and `+1` for the index. -**Expressible:** all three helpers, including the target object, which is a -`ref(object, name)` default on the Object Info node. **Needs:** **GAP 2** (the full -graph runs one Cast Rays per bounce inside a Repeat zone). The Euler/axis `rotation_type` -enum values differ by node type and are read from the running Blender, not assumed. +**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 @@ -233,7 +266,7 @@ 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} + addItem BOOLEAN # GAP 1 +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 @@ -243,11 +276,14 @@ addNode fillet GeometryNodeFilletCurve -> GeometryNodeSetCurveRadius -> Geometry addNode join GeometryNodeJoinGeometry # markers + tubes + base grid ``` -**Expressible:** every node is a plain node; the sequencing is plain links. -**Needs:** **GAP 1** (capture items, three of them), **GAP 4** (Join Geometry is a -*multi-input* socket: many links into one socket, where order is meaningful, and the -design says a link "replaces an existing link into a single-link input", which is wrong -for this socket). VibeGeometry's note on this graph is a design requirement for Brokkr: +**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 @@ -255,32 +291,41 @@ inferred names. ## 5. Library node groups: scatter, tube, array from Blender's essentials -**Source:** Blender 5.2's `geometry_nodes_essentials.blend` ships these as node groups -(names read from the file): `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`, `Screen to 3D Space`, `Curve UV`, `Is Edge Boundary`, `Is Edge -Loose`, `Is Edge Manifold`, `Is Edge UV Seam`, and cylindrical/spherical combine and -separate helpers (35 groups). `geometry_nodes_dynamics_assets.blend` ships 59 more -(XPBD Simulation, Collider, Gravity, Pin Positions, Hair Dynamics, Edge Length -Constraint, `For Each Sim Geometry`, `For Each Typed Bundle`). **Does:** a user -composes a scatter in two nodes instead of thirty. +**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] -addNode scatter GeometryNodeGroup group="Scatter on Surface" # a library group, not in this file -addNode rotate GeometryNodeGroup group="Random Rotation" -gin.Geometry->scatter.Geometry gin.Density->scatter.Density scatter.Geometry->gout.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. scatter. -> gout.Geometry assignNodeTree geometry "Rock Field" objectName="Terrain" ``` -**Needs:** **GAP 5.** The design lets a group node reference only a tree that exists in -the current file (`group_not_found` otherwise). Blender's essentials are *assets in a -library*: the group must first be linked or appended. An action that links a named -asset from a configured asset library is the highest-value missing piece for geometry -nodes: it turns the 35 essentials into building blocks. It is also a file read, so it -takes the same confinement as `importGltf` (the asset library is a configured directory, -or the Blender-shipped essentials path). +**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) @@ -303,27 +348,38 @@ form is shorter because functions and loops are the compression. Two honest read ## Design gaps found -| # | gap | seen in | smallest fix | -|---|---|---|---| -| 1 | **Item sockets**: Capture Attribute items, Repeat/Simulation state items, Menu and Index Switch items, Join/Bundle items are grown by the node, not set as properties | 1, 4 | `addNodeItem` (`nodeId`, `kind`, `socketType`, `name`) in the batch; items appear in the document as ordered `items`; identifiers valid only after | -| 2 | **Zones**: Simulation, Repeat, For Each Element come as paired input/output nodes; no action pairs them | 2, 3, essentials | A `addZone` op that creates the pair and links the state items; read-back carries `pairedWith` | -| 3 | **Field-ness and implicit defaults**: whether a socket takes a field, and an interface socket's structure, default input (`POSITION`, `NORMAL`, `INDEX`), domain, hide-value, panels | 1, 2 | Extend `InterfaceSocket` with `structure`, `defaultInput`, `attributeDomain`, `hideValue`, `panel` | -| 4 | **Multi-input sockets** (Join Geometry): many links, order matters, the replace rule is wrong | 4 | `Socket.multiInput`; `linkSockets` takes `position`; no replace on multi-input sockets | -| 5 | **Library/asset groups**: essentials are linked assets, not present in the file | 5 | `linkNodeGroupAsset` from a configured asset-library directory, confined like `importGltf` | -| 6 | **Socket types beyond the design's list**: Menu (enum-valued socket; VibeGeometry's third patch "tolerate menu socket defaults"), Bundle, Closure, List | essentials (`For Each Typed Bundle`, `String to List`) | Add `menu`, `bundle`, `closure`, `list` to `Socket.type` and `Value` (menu as `enum`) | -| 7 | **Authoring at scale**: hundreds of nodes; the DSL compresses with loops | 6 | Recipes (cut 5) promoted to required; batch size cap raised for generated graphs, or documents applied by `applyNodeGraph` | -| 8 | **No evaluated read-back**: VibeGeometry verified every translation by evaluated vertex/edge/face counts; the design reads only the tree | 1-4 | `queryEvaluatedGeometry`: counts per component, bounding box, attribute names, for an object's evaluated result | -| 9 | **Baking and caches** (Bake node, simulation bake, geometry caches in the dynamics assets) | 2, essentials | Out of scope for v1; needs its own action and a confined cache directory | -| 10 | **Drivers and animation** on node values (a keyframed `Value` node is how many setups animate) | common | Out of scope by safety rule 2; keyframes (data only) could be added without drivers | -| 11 | **Node parenting and frames, labels, colours**: `NodeFrame` is allowed but `Node` has no `parent` or colour | readable graphs | Add `parent`, `color` to `Node` | +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..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 machine here has **5.2.3 LTS** (Steam). - The design's version matrix still says "confirm" for the 5.x rows; these sources - support: group interface through `tree.interface`, capture items explicit, menu - socket defaults need tolerance, nested tree-group references behave differently on 4.x - (their patch 0001 fixes `Geometry Script` for it). +- 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). diff --git a/docs/blender-node-graphs.md b/docs/blender-node-graphs.md index a9a57eb..fe40cba 100644 --- a/docs/blender-node-graphs.md +++ b/docs/blender-node-graphs.md @@ -1,8 +1,12 @@ # Blender node graphs: design -Status: design only, no code. Draft for review. Nothing here is implemented; every -Blender API claim comes from Blender's documented Python API and has **not** been -run against a live Blender (none is installed on the machines that wrote this). +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 @@ -15,15 +19,16 @@ 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), -animation/driver authoring on node values, simulation and repeat *zones* (they need -paired nodes; see Open questions), baking, add-on node packs, and anything that -executes user code. +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 4 -(item sockets, zones, field-ness on interface sockets, multi-input sockets) change -this document's contract and are to be folded in before cut 1. +[`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 @@ -58,7 +63,7 @@ NodeGraph 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 [InterfaceSocket] groups only (geometry, shaderGroup): the tree's own inputs/outputs + 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 @@ -70,20 +75,47 @@ Node 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 string, for group nodes: treeKey of the referenced node group + 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 ... + 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, name, inOut (Input|Output), type, default, min, max, subtype + 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 | ref(kind, name) + tagged: number | integer | bool | string | vector3 | rgba | menu(item) | ref(kind, name) PropertyValue tagged: number | integer | bool | string | enum(identifier) | ref(kind, name) ``` @@ -104,9 +136,38 @@ Rules: - **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 `treeKey`; the referenced tree must exist - (create it first with `createNodeGroup`). A group may not contain itself, directly - or through other groups (refused, see Errors). +- **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 @@ -140,10 +201,16 @@ Receipts keep `accepted`/`failed`, with `message` naming the rule; the receipt's | `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. | -| `linkSockets` | tree, `fromNode`, `fromSocket`, `toNode`, `toSocket` | Links outputs to an input. Replaces an existing link into a single-link input. | +| `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 (`modifier[identifier]`), validated against the group's interface type. | +| `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..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. @@ -168,8 +235,17 @@ application.** 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. A different - source into a single-link input replaces the link and says so in the message. +- `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 @@ -179,6 +255,110 @@ application.** 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 @@ -204,6 +384,13 @@ The code is a stable slug at the start of `message`, for clients to branch on. | `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 | @@ -236,11 +423,18 @@ without Blender: 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. + 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. + 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. @@ -250,7 +444,8 @@ without Blender: 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. + `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 @@ -271,6 +466,10 @@ the repo, run by a human or on a runner with Blender): - 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. @@ -279,30 +478,42 @@ the repo, run by a human or on a runner with 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 (a Blender 5.1.1 translation corpus used it; Blender 5.2.3 LTS is installed on the Starfire workstation and can check the rest) | +| 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..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 | same | +| 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. Rows marked "confirm" are claims this document could -not check. +`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, canonical form, hash; id/identifier/value - validation; exclusion and allowlist; caps. Pure tests. -2. **Pure graph engine.** In-memory graph, idempotent apply of the action set, 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 and applies ops - to it; fake-`bpy` tests; `assignNodeTree`, `setModifierInput`. Read-back publishing. -4. **Live smoke.** Headless Blender script and the round-trip test; version matrix - filled in with what Blender actually does. -5. **Node recipes (optional).** Named, tested graph builders (a procedural scatter, a - PBR material) expressed as documents, so clients do not rediscover identifiers. +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 @@ -317,13 +528,35 @@ Each cut is its own PR with tests that fail when its rule breaks. 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. **Zones.** Simulation and repeat zones come as input/output node pairs - (`pair_with_output`). Excluded in v1; admitting them needs a paired-add op and a - paired-remove rule. The worked examples show this is not optional for geometry - nodes: the index-of-nearest and raycast setups are inert helpers without a zone - (examples, gap 2). Recommend promoting zones into v1 as `addZone`. +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..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 | | diff --git a/surfaces/blender/tests/live/node_graph_probe.py b/surfaces/blender/tests/live/node_graph_probe.py new file mode 100644 index 0000000..227b908 --- /dev/null +++ b/surfaces/blender/tests/live/node_graph_probe.py @@ -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) diff --git a/surfaces/blender/tests/live/node_graph_probe.result.json b/surfaces/blender/tests/live/node_graph_probe.result.json new file mode 100644 index 0000000..015f065 --- /dev/null +++ b/surfaces/blender/tests/live/node_graph_probe.result.json @@ -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" + ] + } + ] +} \ No newline at end of file -- 2.47.3 From f45e1abafeb20fb3af9f1036ef9327342592742d Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 06:35:45 +0000 Subject: [PATCH 5/5] docs: setModifierInput spelling is getattr(inputs, id).value on 5.2.3 inputs[id] returns a raw ID property group without .value; found by the implementation (surfaces/blender node_actions.py on libby/blender-gn-recreation). Co-Authored-By: Claude Sonnet 5.5 --- docs/blender-node-graph-examples.md | 2 +- docs/blender-node-graphs.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/blender-node-graph-examples.md b/docs/blender-node-graph-examples.md index 02eeed8..9c3e8ef 100644 --- a/docs/blender-node-graph-examples.md +++ b/docs/blender-node-graph-examples.md @@ -370,7 +370,7 @@ 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..value`, plus `update_tag` | +| 13 | **`modifier[identifier]` does not exist on 5.2.3**: `setModifierInput` as first written would fail | corrected: `getattr(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 | diff --git a/docs/blender-node-graphs.md b/docs/blender-node-graphs.md index fe40cba..3cf249c 100644 --- a/docs/blender-node-graphs.md +++ b/docs/blender-node-graphs.md @@ -210,7 +210,7 @@ Receipts keep `accepted`/`failed`, with `message` naming the rule; the receipt's | `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..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]**. | +| `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 `getattr(modifier.properties.inputs, identifier).value = v` [verified]; `modifier[identifier]` raises, and so does indexing: `modifier.properties.inputs[identifier]` is a raw ID property group with no `.value` (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. @@ -479,7 +479,7 @@ the repo, run by a human or on a runner with 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..value`; `modifier[id]` raises **[verified 5.2.3]**; earlier 5.x unverified | +| modifier inputs | `modifier[id]` | `modifier[id]` | `getattr(modifier.properties.inputs, id).value` (`inputs[id]` is a raw ID property group); `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 | @@ -558,5 +558,5 @@ Starfire, 37 checks, all passing) established, and what it did not. | 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..value`; `modifier[id]` raises | `bake_*`, panel state setters | +| Modifier | `getattr(modifier.properties.inputs, id).value` (`inputs[id]` is a raw ID property group); `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 | | -- 2.47.3