Claude Skill

scenario-blender-geometry-nodes

Use when building or debugging Geometry Nodes, headless or live: procedural modeling, scattering objects on a surface, arrays along a curve, repeat or for-each loops, simulation zones, 5.2 physics (cloth, colliders, forces, effectors), closures, bundles, matrices, node-group asse

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_blender_scenario-blender-geometry-nodes-dc592af.zip · 53 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-geometry-nodes
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Blender Geometry Nodes (procedural systems and simulations)

Expert Geometry Nodes work means thinking in fields evaluated on domains, keeping systems declarative (data and instructions come in from outside), and shipping tools that artists drive from modifier inputs without opening the tree. For an agent it also means proving every tree with numbers read from the evaluated geometry and a render, because the node editor's red links and warnings are invisible from Python unless you ask for them. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

REQUIRED BACKGROUND: scenario-blender-expert (execution channel, review loop, 5.2 API changes). Hair node groups (Interpolate, Clump, Curl, Hair Dynamics grooms): scenario-blender-hair.

Stance (the expert delta)

  • A field is a deferred question, not data. Thommes: it is evaluated by the node that consumes it, on that node's domain and geometry; values exist only once named or consumed. Consequence [added, verified]: two coupled updates (position and velocity) must be captured together in one Capture Attribute, or the second reads the first's new value.
  • Domain changes interpolate; everything becomes a Boolean by a rule. Erindale: float > 0 is true (vectors: any non-zero), so a subdivided vertex-group selection bleeds; decide with Float to Integer or Compare with epsilon. Prefer trait selections (position, normal, proximity, raycast) over index ones, unless you generated the geometry and its order is the design (the Blender Studio matrix video drives a stack from Index == 0).
  • Simulation only when history matters. Thommes: a simulation is rules applied to state; an animation is a function of time. If nothing depends on the previous frame, drive it from Scene Time and get scrubbing and sub-frames free.
  • Loops are a last resort. Thommes: built-in nodes working on all elements use acceleration structures, loops cannot. For-each is for black-box generators run once per element: field outside, single value inside.
  • Everything is object-local. Thommes: a (0, 0, -1) vector rotates with the object. World directions go through Self Object > Object Info > Invert Matrix > Transform Direction.
  • Tools, not trees. Thommes: expose every scene dependency and tweakable value as a group input, never hard-reference an object inside a reusable tree, and drive gizmos only from Group Input sockets, through invertible math, into the first input of each math node.
  • 5.2 physics is declarative. Thommes (BCON26): forces, colliders and constraints travel as bundles and closures into a black-box solver. Customize from outside first (inputs, bundles, Custom Effector closures); make the asset local only to edit a built-in constraint. Any per-step custom attribute must be listed in Extra Sim Attributes, and topology-changing sims must append new elements at the end.

Establish first

Question Why it changes the plan Default when silent
What consumes the result: shader, another modifier, export, physics? Crossing a boundary needs a named attribute; export may need realized geometry Named attributes for anything a shader reads; keep instances
Does it depend on the previous frame? Simulation zone and baking vs Scene Time function Function of time
One-off authoring step or reusable asset? One-off may be applied (Thommes' hair noise); assets never are Reusable: expose inputs, panels, sensible min/max
Scale: element counts, viewport cost Realize, loops and simulations cost; instances are cheap Instances, fields, no loops
World or object space for directions and ground Object-local trap World up, world gravity

Workflow

  1. Plan the data flow. Name the component and domain each value lives on, where each field gets evaluated, and which primitive fits: plain fields first, Accumulate Field for per-group sums, repeat zone for N dependent passes, for-each for a black-box generator per element, simulation zone for state, closure to inject behavior, bundle to pass many named values. GATE: you can say for every field which node evaluates it.
  2. Build with bx_gn.Builder (headless or live, idempotent: a tree of the same name is rebuilt in place). b.node(kind, prop=..., ins={...}) sets properties before inputs, links sources, assigns constants; b.zone(...) pairs zones and adds items; b.join([...]) keeps list order. GATE: b.done() returns (zones paired, every link created).
  3. Attach and set inputs with G.attach(obj, tree, inputs={...}) or G.set_inputs(md, {...}); "attr:NAME" switches a field input to an attribute or vertex group. GATE: G.check(md)["ok"], no hard_refs on an asset.
  4. Measure. G.stats(obj) (counts, attributes per domain, world bbox with instances), G.attribute, G.instance_transforms, G.positions. Then the two robustness probes: rotate the object 90 degrees (world-space effects keep direction) and double the input resolution (trait selections scale, index ones jump). GATE: numbers match the analytic expectation.
  5. Look. G.snapshot(objs, path, views=..., frames=...) renders the current scene state with material colors and shadows (floating instances show); bx_review.review for realized meshes. Open every sheet. GATE: references/critique.md visual items pass.
  6. Simulations. Step every frame in order with G.step(range(...)), or G.bake(obj) for random access; assert bounds, monotonic states, counts, no NaN. GATE: a direct jump to a late frame equals the stepped result only after a bake.
  7. Package (assets): inputs grouped in panels with defaults, min/max and subtypes; tree.is_modifier = True; tree.asset_mark(); drop it on a different object (Thommes' Suzanne test). GATE: works on the second object with no tree edit.

Recipes in scripts/bx_gn.py, tested and rendered: scatter_tree (density x mask, world-space slope limit, upright or aligned, pick from object and collection), curve_array_tree (tangent-aligned array, alternating twist makes a chain), growth_tree (repeat-zone extrusion), particle_drop_tree (simulation zone with emission, world gravity, ground bounce). Prefer the shipped Essentials assets (Scatter on Surface, Array, Instance on Elements, Randomize Transforms) when their inputs cover the brief; build your own when you need world-space rules, masks or custom attributes.

Numbers

Value What it is relative to Source
Melt sim: 300 K ambient, 2000 K source, heating 0.05, cooling 0.01 per frame per-step Mix factors; small because they accumulate Thommes BCON23
Blur Attribute weight 0.5; smooth 5 iterations; melt mask 500 to 2000 K heat diffusion and softening Thommes BCON23
Drip 0.01 per frame along -Z; normal mix 0.1 (0.5 "looks like baking bread") per-step offset Thommes BCON23
Tearing threshold 1 = tears as soon as longer than rest (too sensitive); reinforce 10; base random 1 to 1.1; Voronoi mode edge length ratio Thommes BCON26
Sewing ramp: rest length x factor 1 to 0 over 1 s of accumulated Delta Time avoids snapping Thommes BCON26
Threads on tears: about 2 cm, 3 points each performance cap Thommes BCON26
Cloth asset defaults: Substeps 5, Constraint Steps 15, Collision Radius 0.01, Tearing Threshold 1.2 read from the 5.2.1 asset digest [verified]
Gizmo position = size x 0.5 for a centered grid; random factor 0.2 to 1 on the SECOND input keeps drags glued Thommes BCON24
Stack: box origin at its bottom; random scale XY 1 to 1.5, Z 0.05 to 0.3 Accumulate Field Trailing Matrix video
Flatten regions: island min 5 faces; proximity falloff 0 to 0.2 m, Smoother Step; Volume to Mesh threshold about 0.5 Erindale exercise Erindale
Realize Instances: Realize All off, Depth 1 flattens one hierarchy level Thommes BCON24

Quality gates

Measurable (code):

  • G.check(md): ok (no invalid link, no ERROR warning, zones paired), hard_refs empty for assets, Warning nodes you added report as expected.
  • Counts match expectation: scatter count within about 12 percent of density x mask x eligible area [verified test]; curve array count = floor(length / spacing); repeat growth faces = F0 + sides x selected x iterations.
  • Attributes exist on the right domain and type, values in range (G.attribute).
  • Rotation probe and resolution probe (step 4).
  • Simulation: counts per frame, bounds (nothing below the ground), prefix-stable ids when appending, energy or state monotonic where it must be, baked jump equals stepped.
  • G.time_eval(obj) for hot trees; flag repeat or for-each zones in them.

Visual (G.snapshot, bx_review): scatter density reads natural and nothing floats or sinks; arrays follow the curve without flips; growth has no self-intersection at the base; simulation strip shows motion then rest; shader-driven attributes checked at render resolution. Gizmo feel is GUI only.

Common mistakes

Mistake What it looks like Fix
tree.links.new across trees returns None, nothing links, no error Builder.link raises; never reuse a bound links.new after switching trees
Setting md.properties.inputs.<id>.value only result unchanged obj.update_tag() (or G.set_inputs)
Random Value with its implicit ID inside a for-each zone red link, output silently wrong; md.node_warnings empty zone Index into ID; G.check lists invalid_links
Same Seed on two Random Value nodes scale and rotation move together (correlation 1.0 [verified]) offset seeds (seed + k)
State not wired Simulation Input to Output value resets each frame wire every state item through
Custom attribute inside Cloth or Hair Dynamics resets every step, no warning list it in Extra Sim Attributes
Hard-referenced controller object in an asset works once, breaks on reuse Object socket on the Group Input
Coupled fields updated by two nodes velocity computed from the already-moved position one Capture Attribute for both
Join order assumed = link order new elements inserted first, solver or ids break b.join([state, new])
Local direction used as world effect rotates with the object Self Object matrix conversion
Closure signature by auto-population ERROR "Closure does not have output" create both item lists, same names

Blender 5.2 notes

  • Modifier inputs: md.properties.inputs.<Socket_N>.value (old md["Socket_N"] raises TypeError), then obj.update_tag().
  • Menus are input sockets: Transform Geometry Mode = 'Matrix', Resample Curve Mode, Custom Force Mode, Custom Effector Stage (menu Socket_1; a string input is also named Stage, Socket_2).
  • node.inputs["A"] on a Mix returns the available socket in 5.2.1; duplicate enabled names (Cloth's two Gravity) still need identifiers. bx_gn.sock raises with the identifiers.
  • Join Geometry puts the last created link first [verified].
  • Evaluated obj.bound_box ignores instances; the instance point cloud's position attribute reads zeros, use instance_transform [verified]. md.execution_time stays 0.0 headless.
  • Simulation: the first frame already runs the zone body; a jump past the cache computes one step; simulation_nodes_cache_calculate_to_frame returns PASS_THROUGH headless and computes nothing; simulation_nodes_cache_bake works (packed) [verified].
  • Lists (Field to List, Get List Item), Bundles, Closures and GeometryNodeXPBDSolver exist; modal node tools do not (experimental builds only).
  • Shipped assets: <DATAFILES>/assets/nodes/geometry_nodes_essentials.blend, geometry_nodes_dynamics_assets.blend, procedural_hair_node_assets.blend (G.append_group(name, library)).

References

  • references/expert-notes.md: Thommes, Erindale and Johnny Matthews principles with timestamps; load when designing a system or an asset.
  • references/procedures.md: tested code: raw-bpy procedures A to H (attribute pipeline, float simulation, repeat expand, for-each, closures, matrix stacking, gizmos, cloth) and the bx_gn recipes and physics tests; load before writing any tree.
  • references/critique.md: the rubric to judge a tree, an asset and a simulation; load at every gate.
  • references/sources.md: the eight source videos, what each is best for, best timestamps.
  • scripts/bx_gn.py: builder, modifier, evaluation, simulation, snapshot helpers and recipes (docstrings list the traps).
Files (skills)
  • references
    • critique.md 11.1 KB
      # Critique rubric: judging a Geometry Nodes tree, asset or simulation
      
      Use this at every gate. Each line says what the experts look at, how the agent checks it, and what fails. "Code" items run through `bx_gn`; "Look" items need a render opened with the image reader (`G.snapshot`, or `bx_review.review` for realized meshes).
      
      ## 1. The tree is sound (always)
      
      | Check                                        | How                                                                          | Fail when                                                                                                               |
      | -------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
      | Every link exists and is valid               | `G.check(md)["invalid_links"]` after one evaluation (nested groups included) | any entry: field into a single-value socket, gizmo on a non-invertible source, incompatible types                       |
      | No errors or unexpected warnings             | `G.check(md)["warnings"]`                                                    | any ERROR (closure signature, missing data); a WARNING you did not design                                               |
      | Zones paired, output linked                  | `unpaired_zones`, `output_unlinked`                                          | either non-empty or True                                                                                                |
      | Every field has a known evaluation point     | read the tree: which node consumes it, on which domain and geometry          | coupled values written by separate nodes (position then velocity), a field reused on a different geometry than intended |
      | Named attributes where a boundary is crossed | `G.stats(obj)` attributes, `G.attribute` domain and type                     | the shader, another modifier or the physics cache expects a name that is not there                                      |
      | Seeds independent                            | one exposed Seed, offset per Random Value                                    | two Random Value nodes share Seed and ID (values move together)                                                         |
      
      ## 2. The result is right (numbers before looks)
      
      | Check                                 | How                                                                                                                                                                           | Fail when                                                                        |
      | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
      | Counts match the analytic expectation | scatter: density x mask x eligible area (about 12 percent tolerance); arrays: floor(length / spacing); repeat growth: F0 + sides x selected x iterations; sims: rate x frames | off by more than sampling noise, or a count that jumps with an unrelated change  |
      | Values in range                       | `G.attribute(...)` min / max; temperature within ambient and source, factors within 0 to 1                                                                                    | NaN, out of range, constant where it should vary                                 |
      | Rotation probe                        | rotate or flip the object, re-evaluate                                                                                                                                        | "up", "down", gravity or ground follow the object instead of the world           |
      | Resolution probe                      | double the input resolution                                                                                                                                                   | a trait selection's share changes a lot, or an index selection silently moves    |
      | Instances where they should be        | `G.instance_transforms` against the host surface (BVH nearest), slopes, normals                                                                                               | instance on a face steeper than the limit, Z axis off the normal in aligned mode |
      | Topology after mesh operations        | `bx_audit.audit(obj, evaluated=True)`                                                                                                                                         | non-manifold edges, flipped faces, zero-area faces you did not intend            |
      
      ## 3. It is a tool, not a tree (reusable assets)
      
      | Check                               | How                                                                                                                                   | Fail when                                                                                                             |
      | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
      | No scene data inside                | `G.check(md)["hard_refs"]`                                                                                                            | an Object or Collection set on a node instead of a Group Input socket (Thommes BCON23)                                |
      | Interface a stranger can use        | read `tree.interface.items_tree`                                                                                                      | unnamed or technical socket names, no panels, missing min / max, angles without ANGLE subtype, factors without FACTOR |
      | Defaults produce something sensible | attach to a fresh primitive with no inputs set                                                                                        | empty output or an explosion at defaults                                                                              |
      | Works on a second object            | Thommes' Suzanne test: attach to a different mesh, check and look                                                                     | needs a tree edit to work elsewhere                                                                                   |
      | Gizmos wired for artists            | gizmo Value links trace to Group Input sockets through the first input of invertible math; Transform outputs joined into the geometry | gizmo on an internal value (invisible without the editor), `is_valid` False, gizmo left behind after a transform      |
      | Published correctly                 | `tree.is_modifier`, `tree.asset_data`                                                                                                 | not shown in Add Modifier / asset browser when it should be                                                           |
      
      ## 4. It is cheap enough
      
      | Check                | How                                                                    | Fail when                                                         |
      | -------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------- |
      | Evaluation time      | `G.time_eval(obj)` (not `md.execution_time`, which reads 0.0 headless) | hot tree slower than the brief allows                             |
      | Loops justified      | list repeat and for-each zones                                         | a for-each that a field node could replace (Thommes: last resort) |
      | Instances kept       | stats: instances vs realized verts                                     | realized for no downstream reason                                 |
      | Static chunks cached | Bake node set to Still after heavy static work                         | recomputed every frame or file load                               |
      | Secondary sims light | points per strand, substeps                                            | more than needed (Thommes caps threads at 3 points)               |
      
      ## 5. Simulations behave
      
      | Check                      | How                                                                       | Fail when                                                                                    |
      | -------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
      | State persists             | step frames, read the state attribute                                     | resets each frame (not wired through, or not in Extra Sim Attributes for the physics assets) |
      | Stepping discipline        | `G.step` in order, or `G.bake` before random access                       | results taken after a jump (one step only)                                                   |
      | Bounds and contacts        | nothing below the ground or inside a collider, pins drift under tolerance | penetration, pinned points sliding                                                           |
      | Monotonic where it must be | irreversible states (molten) never decrease                               | a Max(previous, current) missing                                                             |
      | Append-only topology       | ids of existing elements unchanged frame to frame                         | new elements inserted before old ones                                                        |
      | Frame range                | current frame inside the simulation range                                 | frame 0 or negative ("nothing works", Thommes BCON26)                                        |
      | Bake equals stepping       | compare a baked jump with the stepped frame                               | differs                                                                                      |
      
      ## 6. It looks right (render and open it)
      
      - **Scatter:** density reads natural (no grid, no clumps from correlated seeds), slopes bare where they should be, nothing floating or sunk (Workbench shadows in `G.snapshot` show contact), variation in scale and spin, instance attributes visible in the shader.
      - **Arrays along curves:** even spacing, no flips at curvature changes, links or tiles interlock where intended.
      - **Procedural modeling (repeat, extrude):** silhouette reads, no self-intersection at the base, tapering even; check silhouette and matcap in `bx_review`.
      - **Simulations:** a frame strip (`G.snapshot(..., frames=[...])`) shows the motion you expect then rest; melt, drip, tear and fabric stiffness read naturally; tears asymmetric and reinforced where garments are reinforced (Thommes BCON26).
      - **Attribute-driven shading:** edge masks, wrinkle or stretch maps, temperature glow checked at render resolution, not only in the viewport.
      - **Gizmos:** placement and drag feel can only be judged in a live GUI session (viewport screenshot); headless, verify wiring only and say so.
      
    • expert-notes.md 17.7 KB
      # Expert notes: how the Geometry Nodes experts think
      
      Six of the eight sources are one voice: Simon Thommes, Blender Studio technical artist and artist-side member of the Geometry Nodes design team since late 2020. Erindale (fields and selections) and Johnny Matthews (closures) confirm the core ideas independently. Timestamps are `[video id hh:mm:ss]`; ids are listed in `sources.md`. Items marked [added] come from this skill's own 5.2.1 tests, not from the videos.
      
      ## 1. The mental model (Thommes)
      
      - **Geometry is a container.** One geometry holds components (mesh, curves, point cloud, volume, instances; Grease Pencil since 4.3 behaves like "instances of curves"); each component has domains (point, edge, face, corner, curve, instance, layer); domains hold attributes. Vertex groups and UV maps are just attributes. `[1nvzwhbL-k0 00:03:18 to 00:05:28]`, `[Opzk1wUhzCw 00:23:56]`
      - **"It's just 3D data that has a certain meaning."** Understanding the container is what makes node work predictable. `[1nvzwhbL-k0 00:02:45]`
      - **Fields hold no data.** A field describes how to compute a value per element; it is evaluated where a geometry consumes it, on that node's domain. "The actual data is not in this node... it's only in the geometry." Diamond sockets are fields, round ones single values; dashed links carry fields. `[1nvzwhbL-k0 00:06:00, 00:10:59]`, `[-kwAc-tPmVM 00:31:10]`
        - [added, verified] The practical corollary for code: two values that update together (a particle's position and its velocity) must be computed in one Capture Attribute; if Store Named Attribute writes the velocity first, a later Set Position that reads velocity sees the new value (test_03: the naive order double-counts gravity in flight). `particle_drop_tree` captures both.
      - **Name to cross a boundary.** Inside one tree, anonymous attributes (noodles) are fine; a shader, another modifier or the 5.2 physics cache only sees named attributes. "Nothing happens until the output is named." `[1nvzwhbL-k0 00:12:06]`, `[H2NRCcsX9Xo 00:18:30]`
      - **Store to freeze.** "It's not a face area anymore, it's whatever the value is": storing a derived value cuts it from its source, which is how rest states survive deformation (stretch maps: deformed face area / stored rest face area). `[1nvzwhbL-k0 00:15:22, 00:16:28]`
      - **Five kinds of production jobs:** attribute pipeline, quick hacks (a one-node Switch that hides a variation regardless of visibility flags), creative tools, technical tools, parametric assets. A small personal tool is legitimate "even when Blender should have it". `[1nvzwhbL-k0 00:08:13, 00:17:32, 00:45:19]`
      - **Put control where the variation lives.** Per-object modifier inputs beat parameters baked into a shared shader: duplicate the object and tweak. `[1nvzwhbL-k0 00:09:19]`
      - **Work on the input, not the output.** Drawing curves that drive a result keeps creative control; drawn curves have uneven density, so Resample before scattering and Fillet for round corners (embroidery). `[1nvzwhbL-k0 00:21:27, 00:23:40]`
      - **Attach to deforming surfaces through UV space.** Curves authored in the badge's UV space and projected with Sample UV Surface follow transforms, edit-mode scaling and UV edits "like a texture". Debug view: Set Position to the UV map after Split Edges (UV seams are not split on the mesh). `[1nvzwhbL-k0 00:24:47 to 00:29:47]`
      - **Accumulate Field is "the sum of previous elements within a group".** Trajectories for sparks, debris and smoke cards are accumulated per-step velocities grouped by trajectory id; everything is a function of time, so sub-frames and slow motion are free. `[1nvzwhbL-k0 00:35:19, 00:37:31]`
      - **Instances plus per-instance attributes** (id, age) feed the shader without realizing, which keeps smoke and fire cheap. `[1nvzwhbL-k0 00:40:51]`
      
      ## 2. Simulation (Thommes BCON23, BCON26)
      
      - **"Simulation is not physics."** A set of rules transforms the current state into the next; an animation is a predetermined function of time. Choose simulation only when state must accumulate or react to live input. `[H2NRCcsX9Xo 00:02:50, 00:04:30, 00:07:53]`
      - **Zone mechanics.** First frame: the Simulation Input takes the incoming data. Every step: the body runs once. Next frame: the input outputs what reached the output last frame. "As soon as the simulation runs... this input is not taken into account at all anymore, it's only the initialization." Wire every state item from input to output or it resets. `[H2NRCcsX9Xo 00:06:09, 00:12:20, 00:12:55]`
        - [added, verified] The first frame already runs the body (a zone that emits 12 points per step has 12 points at frame 1). A jump past the cache computes one step. Stepping fills a memory cache that replays exactly. `simulation_nodes_cache_calculate_to_frame` is a no-op headless; bake for random access.
      - **Per-step increments must be small** because they accumulate: heating 0.05, cooling 0.01, drip 0.01. Use Delta Time for frame-rate independence; mesh-resolution independence is your job. `[H2NRCcsX9Xo 00:15:39, 00:19:41, 00:27:01, 00:41:15]`
      - **Irreversible state = Maximum(previous, current)** (molten never unmelts). `[H2NRCcsX9Xo 00:25:54]`
      - **Rest position** (`obj.add_rest_position_attribute`) stops procedural textures swimming on deforming meshes; also stabilizes hair distribution. `[H2NRCcsX9Xo 00:28:42, 00:38:20]`
      - **Blend flow directions** to avoid self-folding: tangent flow mixed 0.5 with straight gravity, then 0.1 of the normal (0.5 "looks like baking bread"). `[H2NRCcsX9Xo 00:30:26, 00:30:58]`
      - **Everything is object-local.** A (0, 0, -1) vector rotates with the object; the 3.6 workaround was an anchor empty; in 5.2 use matrices. `[H2NRCcsX9Xo 00:31:30, 00:38:55]`
      - **The "big mistake":** a controller object referenced inside the tree; expose an Object socket so every user brings their own. "I would recommend to not create references to data blocks... inside of the node trees unless you really know what you're doing." Prove reuse by dropping the asset on Suzanne. `[H2NRCcsX9Xo 00:33:12, 00:35:31]`
      - **Keep changing topology outside the simulation** and Sample Index the simulated data into it, so counts can change live (passing the stack through the zone freezes the first frame's instance count). `[rLfxq5KavzM 00:23:28]`
      - **Append, never insert.** For hair dynamics on curves spawned by tears, "it needs to basically only have them added at the end": collect new elements in a simulation zone that starts empty and joins the new ones each step. `[8hMflOZVbSs 00:27:39, 00:28:12]`
        - [added, verified] Join Geometry puts the last created link first; build joins so the state comes first (`Builder.join`).
      
      ## 3. The 5.2 physics framework (Thommes BCON26)
      
      - **Physics is built out of Geometry Nodes.** Cloth Dynamics, Hair Dynamics, Collider, Custom Force, Custom Effector are node groups around one solver node (`GeometryNodeXPBDSolver`). `[8hMflOZVbSs 00:01:43, 00:15:13]`
      - **Declarative, not imperative.** "You really pass in what the solver actually does from the outside": constraints, forces and effectors arrive as bundles and closures. `[8hMflOZVbSs 00:09:38 to 00:10:45]`
      - **"A solution plus an error."** The solver iterates constraints to satisfy them to a similar degree and returns a Residual Error. `[8hMflOZVbSs 00:15:46, 00:16:19]`
      - **Three depths of customization:** asset inputs; extra effectors and forces as bundles (name the Combine Bundle entries so the structure stays readable); make the asset local and edit its constraints (sewing: Custom Length on Is Edge Loose). `[8hMflOZVbSs 00:05:03, 00:07:24, 00:16:52]`
      - **Collider two ways:** a collection of objects carrying the Collider modifier (artist-friendly), or the Collider node's bundle plugged into Effectors (built in the tree); same result. `[8hMflOZVbSs 00:03:57, 00:05:38]`
      - **Soften instantaneous constraints.** Seams ramp their rest length from 1 to 0 over one second of accumulated Delta Time. `[8hMflOZVbSs 00:17:58, 00:18:30]`
      - **Tearing calibration:** threshold 1 tears as soon as an edge exceeds rest (too sensitive); reinforce the neck with a Switch to 10; randomize the base 1 to 1.1; Voronoi pattern; raise Bendiness because the solver is always slightly bendy. `[8hMflOZVbSs 00:21:26 to 00:23:40]`
      - **Pick the stage deliberately:** Pre-Simulation initializes once, Pre-Solve and Post-Solve run per step. `[8hMflOZVbSs 00:30:58 to 00:33:19]`
        - [added, verified] With the default 5 substeps a Post-Solve or Pre-Solve effector ran once per frame; Pre-Simulation once on the first frame.
      - **Extra Sim Attributes.** The system copies the input into a simulation cache and merges back only known attributes, so you can still paint or edit the input after baking. "Any attribute that you want to propagate from one step to another basically needs to be added here." `[8hMflOZVbSs 00:36:13 to 00:38:30]`
        - [added, verified] Unlisted, a per-step counter stays at 1 every frame with no warning; listed, it counts 1 to 10.
      - **After tearing, sample on face corners.** Store the corner index at Pre-Simulation and Duplicate Elements (Face) so indices stay coherent when one edge becomes two. `[8hMflOZVbSs 00:40:44 to 00:42:22]`
      - **"Nothing works"?** Check you are on a frame inside the simulation range (he lost time on frame -1). `[8hMflOZVbSs 00:36:50]`
      
      ## 4. Loops, assets and gizmos (Thommes BCON24 and the 4.3 video)
      
      - **Tools, not trees.** "Forget about nodes": ship node assets with gizmos that artists use in the viewport without knowing nodes drive them. `[-kwAc-tPmVM 00:02:11]`
      - **Repeat vs for-each.** Repeat = serial chain of the same operation N times; For Each Geometry Element = the body runs once per element, in parallel. `[-kwAc-tPmVM 00:29:26, 00:30:01]`
      - **Loops are a last resort.** "Usually try to not rely on this node if you don't have to"; "whenever you can solve a problem without using the for each element zone it's probably more efficient". Built-ins use acceleration structures. Exception: a black-box generator per element, where "the simple stupid way which works" buys creative flexibility. `[-kwAc-tPmVM 00:38:29, 00:52:26]`, `[Opzk1wUhzCw 00:18:04]`
      - **For-each sockets:** field outside, single value inside; Random Value's implicit ID is a field, so plug the zone Index into ID; seed = Index + an exposed offset; generated attributes must match the output domain (Instance after Geometry to Instance), and attributes belong to the geometry output above them. `[-kwAc-tPmVM 00:31:10, 00:40:13, 00:43:20]`, `[Opzk1wUhzCw 00:17:31]`
        - [added, verified] That red link is invisible in `md.node_warnings`; only `link.is_valid` shows it after an evaluation.
      - **Name geometry** (Set Geometry Name + Join Strings) so the spreadsheet hierarchy stays legible. `[Opzk1wUhzCw 00:18:34 to 00:20:49]`
      - **Collections of generators:** Realize Instances with Realize All off, Depth 1 flattens exactly one level so Pick Instance sees every tree. `[-kwAc-tPmVM 00:49:01]`
      - **Gizmos drive backwards.** "Whatever goes into this value socket of the gizmo here will be driven kind of backwards." There is no value output. `[-kwAc-tPmVM 00:06:27]`, `[Opzk1wUhzCw 00:01:43]`
      - **Drive the scaled value.** Position the grid gizmo at size x 0.5 and link the gizmo through the same multiply, or the arrow detaches while dragging. Back-propagation works only through invertible math, always into the first input. `[-kwAc-tPmVM 00:14:11, 00:53:32]`, `[Opzk1wUhzCw 00:04:25]`
      - **Randomness goes on the second input** (gizmo value 10 x random 0.2 to 1). `[-kwAc-tPmVM 00:41:26]`, `[Opzk1wUhzCw 00:15:52]`
      - **Join gizmo Transform outputs into the geometry** before later transforms; only Group Input sockets give artist-facing gizmos; the Transform gizmo follows the viewport orientation setting. `[-kwAc-tPmVM 00:21:33, 00:23:13, 00:25:33]`
      - **Warning node Show pass-through** makes checks lazy; **Bake node set to Still** caches a heavy static upstream chunk (packed). `[Opzk1wUhzCw 00:21:41, 00:22:59]`
      
      ## 5. Matrices (Blender Studio "New Matrix Socket" video, narrator likely Thommes [?])
      
      - **A 4x4 matrix is a transformation in one socket,** not a grid of numbers; transforms can be fields and attributes. `[rLfxq5KavzM 00:00:50, 00:05:22]`
      - **Multiplication order = which space the second transform acts in;** swap the inputs to move along the other object's axes. `[rLfxq5KavzM 00:04:12]`
      - **Pivot where the math happens:** box origin at its bottom face makes stacking trivial. `[rLfxq5KavzM 00:08:08]`
      - **Accumulate Field on transforms:** Trailing (excludes self) for contact stacks, Leading (includes self) for propagated offsets; randomize the step transform so lean propagates. `[rLfxq5KavzM 00:11:43, 00:14:49, 00:20:50]`
      - **Strip what you do not want** (scale from a control empty) with Separate / Combine Transform. `[rLfxq5KavzM 00:22:45]`
      
      ## 6. Selections (Erindale)
      
      - **"Selections are the most important thing in geometry nodes."** A selection is a yes/no question asked of each element. `[p4rwhifXNCw 00:00:00, 00:16:01]`
      - **Implicit Boolean:** float/int > 0 true; vectors true when non-zero [verified: (-1, 0, 0) is true]. `[p4rwhifXNCw 00:05:32]`
      - **Interpolation bleeds:** after Subdivide, vertex-group floats become 0.5, 0.25 and the selection grows; fix with Float to Integer (Round, Ceiling, Floor) or Compare Equal with epsilon. `[p4rwhifXNCw 00:10:14 to 00:12:39]`
      - **Trait over index:** "index-based stuff does have a tendency to break at the slightest change". Grids: row = index floored-modulo N, column = floor(index / N). `[p4rwhifXNCw 00:17:39, 00:18:44, 00:20:21]`
      - **Per-group questions = Accumulate Field** with Island Index as Group ID: Trailing starts at 0, Total counts (Value 1); sum / count = mean. `[p4rwhifXNCw 00:26:10 to 00:27:17, 01:03:05]`
      - **Normal selection** for scattering on flatter ground: Compare, Vector, Direction against (0, 0, 1), Angle + epsilon; point normals are interpolated so edge vertices need more epsilon. `[p4rwhifXNCw 00:35:19 to 00:36:58]`
      - **Raycast** Is Hit and Hit Distance ("a big node but it calculates fast"). `[p4rwhifXNCw 00:37:47 to 00:39:32]`
      - **When a node needs faces, make faces:** extrude a curve into a ribbon to use Edge Angle, then back to curves. `[p4rwhifXNCw 00:44:02]`
      - **Topological expand:** Evaluate on Domain point > edge > point + Ceil in a repeat zone; crisp ring-by-ring growth vs Blur Attribute's soft diffusion. `[p4rwhifXNCw 00:48:46, 00:49:53]`
      - **Map Range for soft masks,** Compare for discrete ones; transfer island values back with Sample Nearest + Sample Index and blend by proximity (0 to 0.2 m, Smoother Step). `[p4rwhifXNCw 01:05:52 to 01:09:48]`
      - **Look at every selection** (small icospheres on selected points, random color per island) before building on it. `[p4rwhifXNCw 00:03:19, 00:25:28]`
      
      ## 7. Closures and bundles (Johnny Matthews)
      
      - **A closure is a function passed as data:** "those nodes will be treated as if they were right here, in this particular context". Keep the fixed machinery in a group, inject the varying part. `[BqMos-6sSy4 00:05:16, 00:07:57, 00:09:09]`
      - **Signatures must match;** auto-population fails "in several situations": add the items by hand. `[BqMos-6sSy4 00:10:12, 00:10:43]` [verified: matching is by item name]
      - **Closures are a data type:** switch them (Index Switch, Menu Switch), wrap each in a small group exposing its parameters. `[BqMos-6sSy4 00:12:20, 00:13:29]`
      - The payoff is declarative systems like the 5.2 physics nodes. `[BqMos-6sSy4 00:17:53]`
      
      ## Where the experts differ, and the deciding condition
      
      | Choice                   | Option A                                                  | Option B                                        | Decide by                                                                                                               |
      | ------------------------ | --------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
      | Index vs trait selection | Index (Thommes' stack Index == 0, for-each Index as seed) | Traits (Erindale)                               | Index when you generated the geometry and its order is the design; traits on input meshes or when resolution may change |
      | Spread over topology     | Blur Attribute (Thommes' heat)                            | Domain-chain expand in a repeat zone (Erindale) | Soft diffusion vs crisp rings with a step count                                                                         |
      | Hard vs soft mask        | Compare                                                   | Map Range with a smooth curve                   | Delete / separate vs blend                                                                                              |
      | Colliders                | Collection with Collider modifiers                        | Collider bundle built in the tree               | Artist-placed objects vs procedural effectors                                                                           |
      | Customize physics        | From outside (inputs, bundles, closures)                  | Local copy of the asset                         | Local only to change a built-in constraint                                                                              |
      | Attribute output         | Store Named Attribute                                     | Group output field named in the modifier        | Name fixed by the pipeline (shader) vs user-chosen                                                                      |
      | Apply the modifier       | Apply and keep working (hair noise)                       | Never apply                                     | One-off authoring step vs shot-level variation                                                                          |
      | Loop                     | For-each over a black box                                 | Field rewrite                                   | Creative flexibility and small counts vs performance                                                                    |
      
    • procedures.md 54.6 KB
      # Geometry Nodes procedures (tested on Blender 5.2.1 LTS)
      
      Every code block in this file ran headless on Blender 5.2.1 LTS on 2026-09-24 with
      `blender --background --factory-startup --python-exit-code 1 --python <test>`. Raw-bpy blocks A to H are
      copied from the test that asserts them; recipe blocks are the exact source of `scripts/bx_gn.py`.
      
      | Test script (tests/code/blender-geometry-nodes/) | Covers                                                                                                                                         | Result |
      | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
      | `test_00_verified_procedures.py`                 | Procedures A to H in raw bpy (the distiller's asserting script)                                                                                | ALL OK |
      | `test_01_builder.py`                             | bx_gn core: identifiers, socket resolution, link validation, inputs, QA channel, join order, zones, layout, readers, assets, seeds, publishing | 32/32  |
      | `test_02_recipes.py`                             | scatter, curve array (chain), repeat growth, with renders                                                                                      | 20/20  |
      | `test_03_simulation.py`                          | particle drop simulation, cache, frame jump, calculate_to_frame, bake, rotated host, naive vs captured update, frame strip                     | 13/13  |
      | `test_04_physics_assets.py`                      | Cloth Dynamics + Collider bundle, Custom Force closure, Custom Effector stages, Extra Sim Attributes                                           | 11/11  |
      
      Renders written by the tests (open them when you rerun): `archive/tests/geonodes/skill_out/recipe_scatter.png`,
      `recipe_chain.png`, `recipe_growth.png`, `growth_review/review_sheet.png`, `recipe_particles.png`, `physics_cloth.png`.
      
      ## 0. Build rules for 5.2 (raw bpy)
      
      1. `tree = bpy.data.node_groups.new(name, 'GeometryNodeTree')`; add `NodeGroupInput` / `NodeGroupOutput` yourself. `tree.is_modifier` defaults False but assigning the tree to a Nodes modifier works; `is_modifier = True` + `tree.asset_mark()` only to publish it [verified].
      2. Interface: `tree.interface.new_socket(name, description="", in_out='INPUT'|'OUTPUT', socket_type='NodeSocketFloat', parent=panel)`, `new_panel(name, default_closed=True)`, `clear()`. Item props: `default_value`, `min_value`, `max_value`, `subtype` ('FACTOR', 'ANGLE', 'DISTANCE'), `dimensions` (2D vectors), `attribute_domain` (outputs), `default_attribute_name`, `hide_value`, `hide_in_modifier`, `force_non_field`, `structure_type`. `NodeSocketFloatFactor` is not a valid socket_type: use subtype [verified].
      3. `tree.links.new(a, b)` returns None, silently, when a socket belongs to another tree [verified]. Assert the return value.
      4. Set node properties (`data_type`, `domain`, `operation`, `mode`, `input_type`) BEFORE touching sockets: they decide which sockets exist. Menus are sockets: `inputs['Mode'].default_value = 'Matrix'` (Transform Geometry), `'Length'` (Resample Curve).
      5. Name lookup: 5.2.1 returns the available socket for Mix `inputs['A']` (A_Vector when data_type is VECTOR) [verified], but duplicate enabled names return the first: Cloth Dynamics has `Gravity` bool (Socket_5) and vector (Socket_3); Custom Effector has `Stage` menu (Socket_1) and string (Socket_2). Use identifiers. Math inputs are `Value`, `Value_001`, `Value_002` (use indices). Capture Attribute items are named by you but identified `Value` / `Attribute` (`_001` ...). Points node output is named `Points`, identified `Geometry`.
      6. Zones: `zi.pair_with_output(zo)` for Simulation, Repeat, For Each Geometry Element, Closure. Items: `state_items`, `repeat_items`, `input_items` / `main_items` / `generation_items` (+ `.domain`), closure `input_items` / `output_items`, Evaluate Closure `input_items` / `output_items`, Combine Bundle `bundle_items`, Capture Attribute `capture_items`. Types: FLOAT, INT, BOOLEAN, VECTOR, RGBA, ROTATION, MATRIX, STRING, GEOMETRY, BUNDLE, CLOSURE.
      7. Zone membership is decided by links: a node is inside only when it connects into the zone output (Thommes 4.3 video 00:12:35).
      8. Modifier inputs: `getattr(md.properties.inputs, item.identifier).value = v`, attribute mode `.type = 'ATTRIBUTE'; .attribute_name = "x"`; outputs `md.properties.outputs.<id>.attribute_name`. THEN `obj.update_tag()`: setting `.value` alone does not re-evaluate in 5.2.1 [verified].
      9. Read results: `gs = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).evaluated_geometry()`; keep `gs` referenced while reading `.mesh`, `.curves`, `.pointcloud`, `.instances_pointcloud()` (attributes `instance_transform`, `.reference_index`, plus every point attribute carried onto the instances), `.instance_references()`, `.name`. The instance point cloud's `position` attribute reads zeros: use `instance_transform` [verified].
      10. Compare bpy structs with `==`, never `is`: every attribute access returns a new Python wrapper (`md.node_group is md.node_group` can be False) [verified in a layout test].
      
      ## 1. bx_gn quick reference
      
      ```python
      import sys; sys.path.append("<skill>/scripts"); import bx_gn as G
      b = G.Builder("Name", geometry_in=True, geometry_out=True)   # rebuilds an existing tree in place
      s = b.input("Density", "FLOAT", 10.0, min=0, max=None, subtype=None, panel="Group", description="",
                  dimensions=None, single_value=False, default_attribute=None)   # -> Group Input socket
      o = b.output("Mask", "FLOAT", domain="EDGE")                 # -> Group Output socket
      n = b.node("Math", operation="MULTIPLY", ins={0: s, 1: 0.5}) # alias | bare name | bl_idname
      b.set(n, "Value_001", 2.0); b.link(src, dst)                 # src: socket | node | (node, key)
      zi, zo = b.zone("SIMULATION", items=[("VECTOR", "Velocity", "POINT")])
      zi, zo = b.zone("REPEAT", items=[("BOOLEAN", "Tip")], iterations=s)
      zi, zo = b.zone("FOREACH", domain="POINT", inputs=[("VECTOR", "Position")], generation=[("FLOAT", "H", "INSTANCE")])
      zi, zo = b.zone("CLOSURE", inputs=[("FLOAT", "x")], outputs=[("FLOAT", "fx")])
      b.items(node, "bundle_items" | "capture_items" | "input_items" | "output_items", [("TYPE", "name")])
      j = b.join([a, c])                                            # output order = list order
      b.output_geometry(src); tree = b.done()                       # zone check + layout
      md = G.attach(obj, tree, name=None, inputs={"Density": 20, "Mask": "attr:weights"})
      G.set_inputs(md, {...}); G.get_inputs(md); G.set_output_attribute(md, "Mask", "edge_mask")
      G.check(md)          # {'ok', 'invalid_links', 'warnings', 'unpaired_zones', 'hard_refs', 'output_unlinked'}
      G.stats(obj); G.attribute(obj, "name", "mesh"|"curves"|"pointcloud"|"instances"); G.positions(obj, comp, world)
      G.instance_transforms(obj, world=False); G.world_bbox([objs]); G.time_eval(obj)
      G.step(range(1, 61), fn); G.bake(obj); G.free_bake(obj)
      G.append_group("Cloth Dynamics (Experimental)", "dynamics"); G.list_assets("essentials")
      G.material("Name", (r, g, b)); G.snapshot([objs], "/abs/out.png", views=("threequarter", "top"), frames=[4, 12, 60])
      ```
      
      ## Procedure A: attribute pipeline to the shader (Thommes BCON22 00:09:19)
      
      Edge Angle (Signed) x Scale stored on EDGE as `edge_mask`; the shader reads it with an Attribute node. Rest-state variant: a GN modifier BEFORE the deformer stores Face Area as `rest_face_area` (store to freeze); one AFTER divides the current Face Area by it (ratio < 1 compressed, > 1 stretched). Alternative to Store Named Attribute: a float group output with `attribute_domain = 'EDGE'` named through `G.set_output_attribute` (test_01).
      Verified on 5.2.1: `test_00_verified_procedures.py` block A (helpers first, used by A to H).
      
      ```python
      import bpy, math
      # ---- helpers
      def new_tree(name, geo_in=True):
          t = bpy.data.node_groups.new(name, 'GeometryNodeTree')
          if geo_in:
              t.interface.new_socket("Geometry", in_out='INPUT', socket_type='NodeSocketGeometry')
          t.interface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry')
          return t, t.nodes.new('NodeGroupInput'), t.nodes.new('NodeGroupOutput')
      
      def link(t, a, b):
          l = t.links.new(a, b)
          assert l is not None, f"links.new returned None: {a.node.name}.{a.identifier} -> {b.node.name}.{b.identifier} (sockets from another tree?)"
          return l
      
      def add_gn(ob, tree, name=None):
          md = ob.modifiers.new(name or tree.name, 'NODES'); md.node_group = tree; return md
      
      def set_input(ob, md, socket_name, value):
          item = [i for i in md.node_group.interface.items_tree
                  if i.item_type == 'SOCKET' and i.in_out == 'INPUT' and i.name == socket_name][0]
          getattr(md.properties.inputs, item.identifier).value = value
          ob.update_tag()                      # 5.2.1: setting the value alone does NOT re-evaluate
      
      def evaluated(ob):
          return ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
      
      def qa(t, md):
          bad = [(l.from_node.name, l.from_socket.identifier, l.to_node.name, l.to_socket.identifier)
                 for l in t.links if not l.is_valid]
          warn = [(w.type, w.message) for w in md.node_warnings]
          return bad, warn
      
      
      for o in list(bpy.data.objects):
          bpy.data.objects.remove(o)
      # ---- A. attribute pipeline (edge mask for the shader)
      bpy.ops.mesh.primitive_cube_add(); cube = bpy.context.object
      t, gi, go = new_tree("EdgeMask")
      sc_ = t.interface.new_socket("Scale", in_out='INPUT', socket_type='NodeSocketFloat'); sc_.default_value = 1.0
      ea = t.nodes.new('GeometryNodeInputMeshEdgeAngle')
      mul = t.nodes.new('ShaderNodeMath'); mul.operation = 'MULTIPLY'
      st = t.nodes.new('GeometryNodeStoreNamedAttribute'); st.data_type = 'FLOAT'; st.domain = 'EDGE'
      st.inputs['Name'].default_value = "edge_mask"
      link(t, ea.outputs['Signed Angle'], mul.inputs[0]); link(t, gi.outputs['Scale'], mul.inputs[1])
      link(t, gi.outputs['Geometry'], st.inputs['Geometry']); link(t, mul.outputs[0], st.inputs['Value'])
      link(t, st.outputs['Geometry'], go.inputs['Geometry'])
      md = add_gn(cube, t)
      set_input(cube, md, "Scale", 2.0)
      a = evaluated(cube).data.attributes["edge_mask"]
      assert a.domain == 'EDGE' and abs(a.data[0].value - math.pi) < 1e-3, a.data[0].value
      assert qa(t, md) == ([], [])
      print("A ok: edge_mask on EDGE =", round(a.data[0].value, 4))
      ```
      
      ## Procedure B: custom simulation with a float state (Thommes BCON23 00:11:12 to 00:16:11)
      
      State item Temperature; the value into the Simulation Input is read on the first frame only; wire input to output or it resets; expose the controller Object; step every frame. For irreversible memory add a second state combined with Math MAXIMUM of previous and current (00:25:54); multiply rates by Delta Time for frame-rate independence.
      Verified on 5.2.1: block B (300 K min, 1635 K max after 30 frames).
      
      ```python
      # ---- B. simulation zone with a float state (heating)
      bpy.ops.mesh.primitive_grid_add(x_subdivisions=20, y_subdivisions=20, size=2); grid = bpy.context.object
      bpy.ops.object.empty_add(location=(0.5, 0.5, 0)); ctrl = bpy.context.object
      t, gi, go = new_tree("Heat")
      t.interface.new_socket("Controller", in_out='INPUT', socket_type='NodeSocketObject')   # expose, never hard-reference
      sin = t.nodes.new('GeometryNodeSimulationInput'); sout = t.nodes.new('GeometryNodeSimulationOutput')
      assert sin.pair_with_output(sout)
      item = sout.state_items.new('FLOAT', "Temperature"); item.attribute_domain = 'POINT'
      env = t.nodes.new('ShaderNodeValue'); env.outputs[0].default_value = 300.0          # initial state, read on first frame only
      link(t, gi.outputs['Geometry'], sin.inputs['Geometry']); link(t, env.outputs[0], sin.inputs['Temperature'])
      oi = t.nodes.new('GeometryNodeObjectInfo'); oi.transform_space = 'RELATIVE'
      link(t, gi.outputs['Controller'], oi.inputs['Object'])
      pos = t.nodes.new('GeometryNodeInputPosition')
      dist = t.nodes.new('ShaderNodeVectorMath'); dist.operation = 'DISTANCE'
      link(t, pos.outputs[0], dist.inputs[0]); link(t, oi.outputs['Location'], dist.inputs[1])
      mr = t.nodes.new('ShaderNodeMapRange'); mr.inputs['From Min'].default_value = 0.5; mr.inputs['From Max'].default_value = 0.0
      link(t, dist.outputs['Value'], mr.inputs['Value'])
      rate = t.nodes.new('ShaderNodeMath'); rate.operation = 'MULTIPLY'; rate.inputs[1].default_value = 0.05
      link(t, mr.outputs['Result'], rate.inputs[0])
      mix = t.nodes.new('ShaderNodeMix'); mix.data_type = 'FLOAT'; mix.inputs['B'].default_value = 2000.0   # name lookup hits A_Float/B_Float
      link(t, rate.outputs[0], mix.inputs['Factor']); link(t, sin.outputs['Temperature'], mix.inputs['A'])
      link(t, mix.outputs['Result'], sout.inputs['Temperature']); link(t, sin.outputs['Geometry'], sout.inputs['Geometry'])
      st = t.nodes.new('GeometryNodeStoreNamedAttribute'); st.inputs['Name'].default_value = "Temperature"
      link(t, sout.outputs['Geometry'], st.inputs['Geometry']); link(t, sout.outputs['Temperature'], st.inputs['Value'])
      link(t, st.outputs['Geometry'], go.inputs['Geometry'])
      md = add_gn(grid, t)
      set_input(grid, md, "Controller", ctrl)
      scene = bpy.context.scene
      for f in range(1, 31):                      # step every frame; a jump computes only ONE step
          scene.frame_set(f)
      temps = [d.value for d in evaluated(grid).data.attributes["Temperature"].data]
      assert min(temps) == 300.0 and 1600 < max(temps) < 1700, (min(temps), max(temps))
      print("B ok: temperature min/max after 30 frames", round(min(temps), 1), round(max(temps), 1))
      scene.frame_set(1)
      ```
      
      ## Procedure C: iterative topology operation with a repeat zone (Erindale 00:47:08)
      
      Evaluate on Domain POINT > EDGE > POINT, Ceil, captured each iteration. Counts 1, 5, 13, 25 for 0 to 3 steps on an interior vertex. The builder version is in test_01 (same counts). `primitive_grid_add(x_subdivisions=21)` gives 22 x 22 vertices in 5.2.1: compute the center index from the real count.
      Verified on 5.2.1: block C.
      
      ```python
      # ---- C. repeat zone (expand a selection over topology)
      bpy.ops.mesh.primitive_grid_add(x_subdivisions=21, y_subdivisions=21, size=2); g2 = bpy.context.object
      t, gi, go = new_tree("ExpandSelection")
      t.interface.new_socket("Steps", in_out='INPUT', socket_type='NodeSocketInt').default_value = 3
      idx = t.nodes.new('GeometryNodeInputIndex')
      cmp = t.nodes.new('FunctionNodeCompare'); cmp.data_type = 'INT'; cmp.operation = 'EQUAL'; cmp.inputs['B'].default_value = 253
      link(t, idx.outputs[0], cmp.inputs['A'])
      rin = t.nodes.new('GeometryNodeRepeatInput'); rout = t.nodes.new('GeometryNodeRepeatOutput'); assert rin.pair_with_output(rout)
      rout.repeat_items.new('BOOLEAN', "Selection")
      link(t, gi.outputs['Geometry'], rin.inputs['Geometry']); link(t, cmp.outputs[0], rin.inputs['Selection'])
      link(t, gi.outputs['Steps'], rin.inputs['Iterations'])
      d1 = t.nodes.new('GeometryNodeFieldOnDomain'); d1.domain = 'POINT'; d1.data_type = 'FLOAT'
      d2 = t.nodes.new('GeometryNodeFieldOnDomain'); d2.domain = 'EDGE'; d2.data_type = 'FLOAT'
      d3 = t.nodes.new('GeometryNodeFieldOnDomain'); d3.domain = 'POINT'; d3.data_type = 'FLOAT'
      ceil = t.nodes.new('ShaderNodeMath'); ceil.operation = 'CEIL'
      link(t, rin.outputs['Selection'], d1.inputs[0]); link(t, d1.outputs[0], d2.inputs[0])
      link(t, d2.outputs[0], d3.inputs[0]); link(t, d3.outputs[0], ceil.inputs[0])
      cap = t.nodes.new('GeometryNodeCaptureAttribute'); cap.domain = 'POINT'; cap.capture_items.new('BOOLEAN', "Sel")
      link(t, rin.outputs['Geometry'], cap.inputs['Geometry']); link(t, ceil.outputs[0], cap.inputs['Sel'])
      link(t, cap.outputs['Geometry'], rout.inputs['Geometry']); link(t, cap.outputs['Sel'], rout.inputs['Selection'])
      st = t.nodes.new('GeometryNodeStoreNamedAttribute'); st.data_type = 'BOOLEAN'; st.inputs['Name'].default_value = "sel"
      link(t, rout.outputs['Geometry'], st.inputs['Geometry']); link(t, rout.outputs['Selection'], st.inputs['Value'])
      link(t, st.outputs['Geometry'], go.inputs['Geometry'])
      md = add_gn(g2, t)
      counts = []
      for n in (0, 1, 2, 3):
          set_input(g2, md, "Steps", n)
          counts.append(sum(1 for d in evaluated(g2).data.attributes["sel"].data if d.value))
      assert counts == [1, 5, 13, 25], counts          # diamond growth on an interior vertex (22x22 grid, index 253)
      print("C ok: selected verts per step", counts)
      ```
      
      ## Procedure D: per-element generator with for-each (Thommes BCON24 00:28:54)
      
      Field outside, single value inside; zone Index as the Random Value ID (the implicit ID field gives a red link that `md.node_warnings` does not report: `G.check` lists it in `invalid_links`, test_01). Generated geometry socket `Generation_0`; generated attributes need `item.domain` matching the output (INSTANCE after Geometry to Instance). Prefer a field-based rewrite when performance matters.
      Verified on 5.2.1: block D.
      
      ```python
      # ---- D. for-each element zone (one object per point)
      bpy.ops.mesh.primitive_plane_add(); host = bpy.context.object
      t, gi, go = new_tree("ForEachCubes")
      grd = t.nodes.new('GeometryNodeMeshGrid'); grd.inputs['Vertices X'].default_value = 3; grd.inputs['Vertices Y'].default_value = 3
      fin = t.nodes.new('GeometryNodeForeachGeometryElementInput'); fout = t.nodes.new('GeometryNodeForeachGeometryElementOutput')
      assert fin.pair_with_output(fout); fout.domain = 'POINT'
      fout.input_items.new('VECTOR', "Position")                   # field outside, single value inside
      link(t, grd.outputs['Mesh'], fin.inputs['Geometry']); link(t, t.nodes.new('GeometryNodeInputPosition').outputs[0], fin.inputs['Position'])
      cb = t.nodes.new('GeometryNodeMeshCube'); cb.inputs['Size'].default_value = (0.3, 0.3, 0.3)
      xf = t.nodes.new('GeometryNodeTransform')
      rv = t.nodes.new('FunctionNodeRandomValue'); rv.inputs['Min'].default_value = 0.5; rv.inputs['Max'].default_value = 1.5
      link(t, fin.outputs['Index'], rv.inputs['ID'])              # without this the implicit ID field makes an invalid (red) link
      link(t, cb.outputs['Mesh'], xf.inputs['Geometry']); link(t, fin.outputs['Position'], xf.inputs['Translation'])
      link(t, rv.outputs['Value'], xf.inputs['Scale'])
      link(t, xf.outputs['Geometry'], fout.inputs['Generation_0'])
      gn = t.nodes.new('GeometryNodeSetGeometryName'); gn.inputs['Name'].default_value = "Cubes"
      link(t, fout.outputs['Generation_0'], gn.inputs['Geometry']); link(t, gn.outputs[0], go.inputs['Geometry'])
      md = add_gn(host, t)
      e = evaluated(host)
      assert len(e.data.vertices) == 72 and e.evaluated_geometry().name == "Cubes"
      assert qa(t, md) == ([], [])
      print("D ok: 9 cubes, geometry name", e.evaluated_geometry().name)
      ```
      
      ## Procedure E: closure passed into a group (Johnny Matthews 00:07:57)
      
      Evaluate Closure inside the group, Closure zone outside, both signatures created explicitly with the same item names. A mismatch evaluates to 0 and reports `ERROR: Closure does not have output: "fx"` in `md.node_warnings`. Index Switch / Menu Switch with `data_type = 'CLOSURE'` chooses among closures.
      Verified on 5.2.1: block E; builder version in test_01.
      
      ```python
      # ---- E. closure passed into a group (declarative pattern)
      inner = bpy.data.node_groups.new("Graph", 'GeometryNodeTree')
      inner.interface.new_socket("x", in_out='INPUT', socket_type='NodeSocketFloat')
      inner.interface.new_socket("Function", in_out='INPUT', socket_type='NodeSocketClosure')
      inner.interface.new_socket("f(x)", in_out='OUTPUT', socket_type='NodeSocketFloat')
      igi, igo = inner.nodes.new('NodeGroupInput'), inner.nodes.new('NodeGroupOutput')
      evc = inner.nodes.new('NodeEvaluateClosure'); evc.input_items.new('FLOAT', "x"); evc.output_items.new('FLOAT', "fx")
      link(inner, igi.outputs['Function'], evc.inputs['Closure']); link(inner, igi.outputs['x'], evc.inputs['x'])
      link(inner, evc.outputs['fx'], igo.inputs['f(x)'])
      t, gi, go = new_tree("ClosureGraph")
      pts = t.nodes.new('GeometryNodePoints'); pts.inputs['Count'].default_value = 50
      xs = t.nodes.new('ShaderNodeMath'); xs.operation = 'MULTIPLY'; xs.inputs[1].default_value = 0.1
      link(t, t.nodes.new('GeometryNodeInputIndex').outputs[0], xs.inputs[0])
      grp = t.nodes.new('GeometryNodeGroup'); grp.node_tree = inner
      cin = t.nodes.new('NodeClosureInput'); cout = t.nodes.new('NodeClosureOutput'); assert cin.pair_with_output(cout)
      cout.input_items.new('FLOAT', "x"); cout.output_items.new('FLOAT', "fx")      # names must match the Evaluate Closure items
      sine = t.nodes.new('ShaderNodeMath'); sine.operation = 'SINE'
      link(t, cin.outputs['x'], sine.inputs[0]); link(t, sine.outputs[0], cout.inputs['fx'])
      link(t, cout.outputs['Closure'], grp.inputs['Function']); link(t, xs.outputs[0], grp.inputs['x'])
      cxyz = t.nodes.new('ShaderNodeCombineXYZ'); link(t, xs.outputs[0], cxyz.inputs['X']); link(t, grp.outputs['f(x)'], cxyz.inputs['Y'])
      sp = t.nodes.new('GeometryNodeSetPosition'); link(t, pts.outputs[0], sp.inputs['Geometry']); link(t, cxyz.outputs[0], sp.inputs['Position'])
      p2v = t.nodes.new('GeometryNodePointsToVertices'); link(t, sp.outputs[0], p2v.inputs[0]); link(t, p2v.outputs[0], go.inputs['Geometry'])
      host.modifiers.clear(); md = add_gn(host, t)
      vs = evaluated(host).data.vertices
      assert max(abs(v.co.y - math.sin(v.co.x)) for v in vs) < 1e-5
      cout.output_items[0].name = "y"                                   # deliberate signature mismatch
      evaluated(host)
      assert any("fx" in w.message for w in md.node_warnings)
      cout.output_items[0].name = "fx"
      print("E ok: closure evaluated inside group; mismatch reported in md.node_warnings")
      ```
      
      ## Procedure F: transform stacking with matrices (Matrix video 00:08:08 to 00:13:56)
      
      Box origin at its bottom; Accumulate Field TRANSFORM on INSTANCE, Trailing (excludes self) into Separate/Combine Transform with Instance Scale; each box sits exactly on the heights below. Randomize the STEP transform (rotation, XY offset) for a lean that propagates.
      Verified on 5.2.1: block F (z_i = sum of heights below within 1e-4).
      
      ```python
      # ---- F. matrix stacking (Accumulate Field on transforms)
      t, gi, go = new_tree("Stack")
      t.interface.new_socket("Count", in_out='INPUT', socket_type='NodeSocketInt').default_value = 6
      p = t.nodes.new('GeometryNodePoints'); link(t, gi.outputs['Count'], p.inputs['Count'])
      box = t.nodes.new('GeometryNodeMeshCube')
      lift = t.nodes.new('GeometryNodeTransform'); lift.inputs['Translation'].default_value = (0, 0, 0.5)   # origin at the bottom
      link(t, box.outputs['Mesh'], lift.inputs['Geometry'])
      iop = t.nodes.new('GeometryNodeInstanceOnPoints'); link(t, p.outputs[0], iop.inputs['Points']); link(t, lift.outputs[0], iop.inputs['Instance'])
      rnd = t.nodes.new('FunctionNodeRandomValue'); rnd.data_type = 'FLOAT_VECTOR'
      rnd.inputs['Min'].default_value = (1, 1, 0.05); rnd.inputs['Max'].default_value = (1.5, 1.5, 0.3)
      link(t, rnd.outputs['Value'], iop.inputs['Scale'])
      isc = t.nodes.new('GeometryNodeInputInstanceScale')
      zonly = t.nodes.new('ShaderNodeVectorMath'); zonly.operation = 'MULTIPLY'; zonly.inputs[1].default_value = (0, 0, 1)
      link(t, isc.outputs[0], zonly.inputs[0])
      step = t.nodes.new('FunctionNodeCombineTransform'); link(t, zonly.outputs[0], step.inputs['Translation'])
      acc = t.nodes.new('GeometryNodeAccumulateField'); acc.data_type = 'TRANSFORM'; acc.domain = 'INSTANCE'
      link(t, step.outputs[0], acc.inputs['Value'])
      sep = t.nodes.new('FunctionNodeSeparateTransform'); link(t, acc.outputs['Trailing'], sep.inputs[0])   # Trailing = previous boxes only
      ct = t.nodes.new('FunctionNodeCombineTransform')
      link(t, sep.outputs['Translation'], ct.inputs['Translation']); link(t, sep.outputs['Rotation'], ct.inputs['Rotation'])
      link(t, isc.outputs[0], ct.inputs['Scale'])
      sit = t.nodes.new('GeometryNodeSetInstanceTransform'); link(t, iop.outputs[0], sit.inputs['Instances']); link(t, ct.outputs[0], sit.inputs['Transform'])
      link(t, sit.outputs[0], go.inputs['Geometry'])
      host.modifiers.clear(); md = add_gn(host, t)
      e = evaluated(host); gs = e.evaluated_geometry()      # keep gs alive, else the pointcloud is freed (ReferenceError)
      ipc = gs.instances_pointcloud()
      mats = [d.value for d in ipc.attributes['instance_transform'].data]
      import mathutils
      zs = [mathutils.Matrix(m).translation.z for m in mats]; hs = [mathutils.Matrix(m).to_scale().z for m in mats]
      assert all(abs(zs[i] - sum(hs[:i])) < 1e-4 for i in range(len(zs)))
      print("F ok: each box sits on the sum of the heights below it")
      ```
      
      ## Procedure G: gizmo-ready asset (Thommes BCON24 00:11:26, 4.3 video 00:03:20)
      
      Gizmo Value linked from the same scaled value used for its Position, through the FIRST input of the math node; gizmo Transform output joined into the geometry; artist-facing gizmos drive Group Input sockets. A gizmo on a non-invertible source (Random Value) evaluates to `link.is_valid == False`. Dragging is GUI only.
      Verified on 5.2.1: block G (wiring only).
      
      ```python
      # ---- G. gizmo wiring (value must be invertible back to a source)
      t, gi, go = new_tree("GizmoGrid", geo_in=False)
      t.interface.new_socket("Size X", in_out='INPUT', socket_type='NodeSocketFloat').default_value = 2.0
      g = t.nodes.new('GeometryNodeMeshGrid'); link(t, gi.outputs['Size X'], g.inputs['Size X'])
      half = t.nodes.new('ShaderNodeMath'); half.operation = 'MULTIPLY'; half.inputs[1].default_value = 0.5
      link(t, gi.outputs['Size X'], half.inputs[0])                   # driven value must be the FIRST input
      pos3 = t.nodes.new('ShaderNodeCombineXYZ'); link(t, half.outputs[0], pos3.inputs['X'])
      gz = t.nodes.new('GeometryNodeGizmoLinear'); gz.color_id = 'X'; gz.inputs['Direction'].default_value = (1, 0, 0)
      lk = link(t, half.outputs[0], gz.inputs['Value']); link(t, pos3.outputs[0], gz.inputs['Position'])
      j = t.nodes.new('GeometryNodeJoinGeometry')
      link(t, g.outputs['Mesh'], j.inputs[0]); link(t, gz.outputs['Transform'], j.inputs[0])       # keeps gizmo attached after transforms
      link(t, j.outputs[0], go.inputs['Geometry'])
      host.modifiers.clear(); md = add_gn(host, t); evaluated(host)
      bad_src = t.nodes.new('FunctionNodeRandomValue'); gz2 = t.nodes.new('GeometryNodeGizmoLinear')
      lk2 = link(t, bad_src.outputs['Value'], gz2.inputs['Value']); evaluated(host)
      assert lk.is_valid and not lk2.is_valid
      t.nodes.remove(gz2); t.nodes.remove(bad_src)
      print("G ok: gizmo on group input valid; gizmo on non-invertible source flagged is_valid=False")
      ```
      
      ## Procedure H: 5.2 cloth with collider and custom force (Thommes BCON26 00:01:43 to 00:08:30)
      
      Append the dynamics assets (local copies), vertex group `Group` (the default Pin Group attribute), collider object in its own collection with the Collider asset as its modifier, wrapper tree: Cloth Dynamics group node, Named Attribute into Pin Group, collection into Effectors Collection, Custom Force in a named Combine Bundle entry into Effectors. The builder version with a Custom Force closure is Procedure N.
      Verified on 5.2.1: block H (no vertex inside the sphere after 30 frames).
      
      ```python
      # ---- H. 5.2 physics: Cloth Dynamics asset + Collider + Custom Force bundle
      import os
      lib = os.path.join(bpy.utils.system_resource('DATAFILES'), "assets", "nodes", "geometry_nodes_dynamics_assets.blend")  # system_resource(path=<file>) returns ''
      with bpy.data.libraries.load(lib, link=False) as (src, dst):
          dst.node_groups = ["Cloth Dynamics (Experimental)", "Collider", "Custom Force"]
      cloth_ng, collider_ng, force_ng = dst.node_groups
      bpy.ops.mesh.primitive_grid_add(x_subdivisions=20, y_subdivisions=20, size=2, location=(0, 0, 1.5)); cl = bpy.context.object
      cl.vertex_groups.new(name="Group").add([v.index for v in cl.data.vertices if v.co.y > 0.99], 1.0, 'REPLACE')  # default Pin Group attribute
      coll = bpy.data.collections.new("Colliders"); scene.collection.children.link(coll)
      bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, location=(0, -0.3, 0.8)); sph = bpy.context.object
      for c in sph.users_collection: c.objects.unlink(sph)
      coll.objects.link(sph)
      add_gn(sph, collider_ng, "Collider")
      t, gi, go = new_tree("ClothWrap")
      cn = t.nodes.new('GeometryNodeGroup'); cn.node_tree = cloth_ng
      link(t, gi.outputs['Geometry'], cn.inputs['Geometry']); link(t, cn.outputs['Geometry'], go.inputs['Geometry'])
      t.interface.new_socket("Colliders", in_out='INPUT', socket_type='NodeSocketCollection')
      link(t, gi.outputs['Colliders'], cn.inputs['Effectors Collection'])
      na = t.nodes.new('GeometryNodeInputNamedAttribute'); na.inputs['Name'].default_value = "Group"
      link(t, na.outputs['Attribute'], cn.inputs['Pin Group'])
      force = t.nodes.new('GeometryNodeGroup'); force.node_tree = force_ng; force.inputs['Force'].default_value = (0, 0, 2.0)
      bund = t.nodes.new('NodeCombineBundle'); bund.bundle_items.new('BUNDLE', "Updraft")   # named entries, many effectors
      link(t, force.outputs['Force'], bund.inputs['Updraft']); link(t, bund.outputs['Bundle'], cn.inputs['Effectors'])
      md = add_gn(cl, t)
      set_input(cl, md, "Colliders", coll)
      for f in range(1, 31):
          scene.frame_set(f)
      c0 = sph.matrix_world.translation
      wv = [cl.matrix_world @ v.co for v in evaluated(cl).data.vertices]
      assert sum(1 for q in wv if (q - c0).length < 0.49) == 0
      assert qa(t, md) == ([], [])
      print("H ok: cloth settles over collider, no vertex inside; lowest z", round(min(q.z for q in wv), 3))
      ```
      
      ## Procedure I: the builder loop with QA (test_01)
      
      ```python
      import bx_gn as G
      b = G.Builder("Lift")
      h = b.input("Height", "FLOAT", 0.0)
      b.output_geometry(b.node("SetPosition", ins={"Geometry": b.geo_in,
                                                   "Offset": b.node("CombineXYZ", ins={"Z": h})}))
      md = G.attach(cube, b.done(), inputs={"Height": 3.0})       # tags the object: result updates
      rep = G.check(md); assert rep["ok"], rep
      G.set_inputs(md, {"Height": "attr:lift"})                   # vertex group drives a field input
      ```
      
      What test_01 asserts beyond this: every alias in `NODE_IDS` instantiates (79); ambiguous names raise with identifiers; cross-tree links raise; raw `.value` without `update_tag()` leaves the result unchanged; `md["Socket_1"] = v` raises TypeError; the for-each implicit-ID red link is flagged and fixed by the zone Index; a closure name mismatch is an ERROR warning; a hard-referenced Object Info object is reported; `b.join` keeps list order (raw Blender reversed it); layout puts the output rightmost with no overlap; world bbox includes instances while the evaluated `bound_box` does not; `md.execution_time` is 0.0 headless; a float group output becomes a named EDGE attribute; Essentials lists Scatter on Surface and Array; a rebuilt tree keeps its pointer; two Random Value nodes with the same Seed correlate at 1.0; `is_modifier` + `asset_mark()` publish.
      
      ## Procedure J: scatter on a surface (test_02; Erindale 00:35:19 normal selection, Thommes BCON23 00:31:30 local space)
      
      Density x mask (attribute or vertex group via `attr:`), slope limit against WORLD up (Compare, Vector, Direction, LESS_EQUAL, Angle), upright (Align Rotation to Vector Z to world up) or normal-aligned rotation, random spin around the local Z, random scale, pick among an Object and the children of a Collection, per-instance `scatter_rand` for shaders, instances kept unless Realize.
      
      ```python
      def _world_up_local(b):
          """(0,0,1) world expressed in the modified object's space: Self Object > Object Info >
          Invert Matrix > Transform Direction (Thommes: everything in GN is object-local)."""
          info = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": b.node("SelfObject")})
          inv = b.node("InvertMatrix", ins={"Matrix": (info, "Transform")})
          return b.node("TransformDirection", ins={"Direction": (0.0, 0.0, 1.0), "Transform": (inv, "Matrix")}), info, inv
      def _seed_plus(b, seed, k):
          """Offset seeds per Random Value node: two nodes with the same ID and Seed return correlated values."""
          return b.node("IntegerMath", operation="ADD", ins={0: seed, 1: k})
      def scatter_tree(name="BX Scatter"):
          """Scatter on a surface: density x mask (attribute/vertex group), world-up slope limit,
          upright or normal-aligned rotation with random spin, random scale, pick from an object
          and/or a collection, per-instance 'scatter_rand' attribute for shaders, optional realize.
          Instances stay instances by default (cheap; shaders read instance attributes)."""
          b = Builder(name)
          inst_obj = b.input("Instance", "OBJECT", description="object instanced on the points")
          inst_col = b.input("Instances", "COLLECTION", description="pick randomly among the children")
          dens = b.input("Density", "FLOAT", 10.0, min=0.0, description="points per square metre (object space)")
          mask = b.input("Density Mask", "FLOAT", 1.0, min=0.0, max=1.0, subtype="FACTOR",
                         description="0 to 1; switch to an attribute or vertex group with attr:NAME")
          slope = b.input("Max Slope", "FLOAT", math.radians(35.0), min=0.0, max=math.pi, subtype="ANGLE",
                          description="faces steeper than this (vs world up) get nothing")
          seed = b.input("Seed", "INT", 0)
          smin = b.input("Scale Min", "FLOAT", 0.8, min=0.0, panel="Transform")
          smax = b.input("Scale Max", "FLOAT", 1.2, min=0.0, panel="Transform")
          align = b.input("Align to Normal", "BOOL", False, panel="Transform",
                          description="off: upright in world (trees); on: follow the surface (rocks, moss)")
          spin = b.input("Random Spin", "FLOAT", 1.0, min=0.0, max=1.0, subtype="FACTOR", panel="Transform")
          realize = b.input("Realize", "BOOL", False, panel="Output")
      
          up, _, _ = _world_up_local(b)
          flat = b.node("Compare", data_type="VECTOR", mode="DIRECTION", operation="LESS_EQUAL",
                        ins={"A": b.node("Normal"), "B": up, "Angle": slope})
          density = b.node("Math", operation="MULTIPLY", ins={0: dens, 1: mask})
          dist = b.node("DistributePointsOnFaces", ins={"Mesh": b.geo_in, "Selection": flat, "Density": density,
                                                        "Seed": seed})
          upright = b.node("AlignRotationToVector", axis="Z", ins={"Vector": up})
          base_rot = b.node("Switch", input_type="ROTATION", ins={"Switch": align, "False": upright,
                                                                   "True": (dist, "Rotation")})
          two_pi = b.node("Math", operation="MULTIPLY", ins={0: spin, 1: 2 * math.pi})
          ang = b.node("RandomValue", data_type="FLOAT", ins={"Min": 0.0, "Max": two_pi, "Seed": _seed_plus(b, seed, 1)})
          rot = b.node("RotateRotation", rotation_space="LOCAL",
                       ins={"Rotation": base_rot,
                            "Rotate By": b.node("EulerToRotation", ins={"Euler": b.node("CombineXYZ", ins={"Z": ang})})})
          scl = b.node("RandomValue", data_type="FLOAT", ins={"Min": smin, "Max": smax, "Seed": _seed_plus(b, seed, 2)})
          oi = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": inst_obj, "As Instance": True})
          ci = b.node("CollectionInfo", ins={"Collection": inst_col, "Separate Children": True, "Reset Children": True})
          pool = b.join([(oi, "Geometry"), ci])
          pick = b.node("RandomValue", data_type="INT", ins={"Min": 0, "Max": 9999, "Seed": _seed_plus(b, seed, 3)})
          iop = b.node("InstanceOnPoints", ins={"Points": dist, "Instance": pool, "Pick Instance": True,
                                                "Instance Index": pick, "Rotation": rot, "Scale": scl})
          rid = b.node("RandomValue", data_type="FLOAT", ins={"Seed": _seed_plus(b, seed, 4)})
          tagged = b.node("StoreNamedAttribute", data_type="FLOAT", domain="INSTANCE",
                          ins={"Geometry": iop, "Name": "scatter_rand", "Value": rid})
          real = b.node("RealizeInstances", ins={"Geometry": tagged})
          res = b.node("Switch", input_type="GEOMETRY", ins={"Switch": realize, "False": tagged, "True": real})
          b.output_geometry(b.join([b.geo_in, res]))
          return b.done()
      ```
      
      Usage and gates (test_02): terrain 10 x 10 m with hills, density 12, `Density Mask = "attr:density"` (weight 0 at -X to 1 at +X), Max Slope 30 degrees. Measured: 159 instances for 158.1 expected; steepest host face under an instance 29.97 degrees; upright Z dot world Z > 0.9999; aligned Z dot face normal 1.0; scale inside [0.7, 1.3]; mask split 5 left vs 58 right; 3 instance references; flipped terrain gives 0 (world-space test).
      
      ```python
      md = G.attach(terrain, G.scatter_tree(), inputs={"Instance": rock, "Instances": plants, "Density": 12.0,
                    "Density Mask": "attr:density", "Max Slope": math.radians(30), "Seed": 3,
                    "Scale Min": 0.7, "Scale Max": 1.3})
      ```
      
      ## Procedure K: array along a curve, a chain (test_02; Thommes BCON22 00:23:07 resample for even spacing)
      
      Curve to Points in Length mode gives even spacing plus Tangent and Normal; two Align Rotation to Vector nodes (X to tangent, then Z to normal pivoting on X) make the frame explicit; an alternating roll around the tangent (Index floored-modulo 2 x angle) turns torus links into a chain.
      
      ```python
      def curve_array_tree(name="BX Curve Array"):
          """Array an object along the modified curve at a fixed spacing: instance X along the tangent,
          Z along the curve normal, optional alternating twist around the tangent (chain links: 90
          degrees), random scale, keep or drop the curve, optional realize."""
          b = Builder(name)
          inst = b.input("Instance", "OBJECT")
          spacing = b.input("Spacing", "FLOAT", 0.25, min=0.001, subtype="DISTANCE")
          scale = b.input("Scale", "FLOAT", 1.0, min=0.0)
          rscale = b.input("Random Scale", "FLOAT", 0.0, min=0.0, max=1.0, subtype="FACTOR")
          twist = b.input("Alternate Twist", "FLOAT", 0.0, subtype="ANGLE", description="extra roll on every other instance")
          seed = b.input("Seed", "INT", 0)
          keep = b.input("Keep Curve", "BOOL", False, panel="Output")
          realize = b.input("Realize", "BOOL", False, panel="Output")
      
          pts = b.node("CurveToPoints", mode="LENGTH", ins={"Curve": b.geo_in, "Length": spacing})
          a1 = b.node("AlignRotationToVector", axis="X", ins={"Vector": (pts, "Tangent")})
          a2 = b.node("AlignRotationToVector", axis="Z", pivot_axis="X", ins={"Rotation": a1, "Vector": (pts, "Normal")})
          odd = b.node("Math", operation="FLOORED_MODULO", ins={0: b.node("Index"), 1: 2.0})
          roll = b.node("Math", operation="MULTIPLY", ins={0: odd, 1: twist})
          rot = b.node("RotateRotation", rotation_space="LOCAL",
                       ins={"Rotation": a2,
                            "Rotate By": b.node("EulerToRotation", ins={"Euler": b.node("CombineXYZ", ins={"X": roll})})})
          lo = b.node("Math", operation="SUBTRACT", ins={0: 1.0, 1: rscale})
          hi = b.node("Math", operation="ADD", ins={0: 1.0, 1: rscale})
          rnd = b.node("RandomValue", data_type="FLOAT", ins={"Min": lo, "Max": hi, "Seed": seed})
          s = b.node("Math", operation="MULTIPLY", ins={0: scale, 1: rnd})
          oi = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": inst, "As Instance": True})
          iop = b.node("InstanceOnPoints", ins={"Points": pts, "Instance": (oi, "Geometry"), "Rotation": rot, "Scale": s})
          real = b.node("RealizeInstances", ins={"Geometry": iop})
          arr = b.node("Switch", input_type="GEOMETRY", ins={"Switch": realize, "False": iop, "True": real})
          both = b.join([b.geo_in, arr])
          res = b.node("Switch", input_type="GEOMETRY", ins={"Switch": keep, "False": arr, "True": both})
          b.output_geometry(res)
          return b.done()
      ```
      
      Measured on a Bezier circle of radius 2 (length 12.566) with spacing 0.44 and twist 90 degrees: 29 links; max radius error 0.004; instance X dot tangent 1.0; consecutive Z axes perpendicular (dot 0.0); Keep Curve adds the curves component; Realize gives 29 x 320 vertices.
      
      ## Procedure L: repeat-zone growth (test_02; Thommes BCON24 00:29:26 repeat = serial chain of the same operation)
      
      Random faces (Random Value BOOLEAN, Probability) extrude individually along normalize(Normal + Upward), the Top selection and a shrinking Step are carried as repeat items, tips are scaled by Taper, then Subdivision Surface and smooth shading.
      
      ```python
      def growth_tree(name="BX Growth"):
          """Repeat-zone growth: random faces extrude along (normal + upward bias) for N iterations,
          each step shorter (Taper) and each tip scaled down, then subdivided and smoothed.
          Face count grows linearly: faces = F0 + sides_per_face * selected * iterations."""
          b = Builder(name)
          iters = b.input("Iterations", "INT", 6, min=0, max=64)
          step0 = b.input("Step", "FLOAT", 0.25, min=0.0, subtype="DISTANCE")
          taper = b.input("Taper", "FLOAT", 0.8, min=0.05, max=1.0, subtype="FACTOR")
          prob = b.input("Probability", "FLOAT", 0.3, min=0.0, max=1.0, subtype="FACTOR")
          upward = b.input("Upward", "FLOAT", 0.5, description="bias of the growth direction toward +Z (object space)")
          seed = b.input("Seed", "INT", 0)
          level = b.input("Smooth Level", "INT", 1, min=0, max=3)
      
          start = b.node("RandomValue", data_type="BOOLEAN", ins={"Probability": prob, "Seed": seed})
          zi, zo = b.zone("REPEAT", items=[("BOOLEAN", "Tip"), ("FLOAT", "Step")], iterations=iters)
          b.set(zi, "Geometry", b.geo_in)
          b.set(zi, "Tip", start)
          b.set(zi, "Step", step0)
          bias = b.node("CombineXYZ", ins={"Z": upward})
          d = b.node("VectorMath", operation="NORMALIZE",
                     ins={0: b.node("VectorMath", operation="ADD", ins={0: b.node("Normal"), 1: bias})})
          ex = b.node("ExtrudeMesh", mode="FACES", ins={"Mesh": (zi, "Geometry"), "Selection": (zi, "Tip"), "Offset": d,
                                                         "Offset Scale": (zi, "Step"), "Individual": True})
          sc = b.node("ScaleElements", domain="FACE", ins={"Geometry": ex, "Selection": (ex, "Top"), "Scale": taper})
          nxt = b.node("Math", operation="MULTIPLY", ins={0: (zi, "Step"), 1: taper})
          b.link(sc, (zo, "Geometry"))
          b.link((ex, "Top"), (zo, "Tip"))
          b.link(nxt, (zo, "Step"))
          sub = b.node("SubdivisionSurface", ins={"Mesh": (zo, "Geometry"), "Level": level})
          smooth = b.node("SetShadeSmooth", ins={"Geometry": sub})
          b.output_geometry(smooth)
          return b.done()
      ```
      
      Measured on an icosphere (80 triangles): faces 80, 143, 206, 269, 332 for 0 to 4 iterations (21 selected triangles x 3 side quads per iteration); max radius 1.0, 1.279, 1.507, 1.692, 1.841 under the geometric-series bound 1.886; 6 iterations at level 1: 1,752 faces, 0 non-manifold, 0 flipped (`bx_audit.audit(obj, evaluated=True)`), 0.025 s. Visual note: neighboring faces extruded individually start from a shared edge, so clusters form stars at the base; lower Probability if that reads wrong.
      
      ## Procedure M: simulation zone, particle drop (test_03; Thommes BCON23 00:06:09 zone mechanics, BCON26 00:28:12 append order)
      
      The state starts empty (nothing into the Simulation Input geometry); each step appends new points AFTER the existing ones (`b.join([state, new])`) with a `pid`; semi-implicit Euler in world space (Self Object matrices); ground contact with restitution and friction; position and velocity captured together; icosphere instances outside the zone (instances carry `pid` and `velocity`).
      
      ```python
      def particle_drop_tree(name="BX Particle Drop"):
          """Simulation zone: points emitted in a world-space box for N frames fall under world gravity,
          bounce on a world ground plane (restitution, friction) and settle. New points are appended
          at the END of the state (Join order), each with a stable 'pid'. Position and velocity are
          computed together in one Capture Attribute so neither reads the other's new value.
          Outputs the modified geometry plus icosphere instances (Material input)."""
          b = Builder(name)
          rate = b.input("Rate", "INT", 20, min=0, description="points emitted per frame")
          nframes = b.input("Emit Frames", "INT", 20, min=0)
          center = b.input("Emit Center", "VECTOR", (0.0, 0.0, 3.0), description="world space")
          size = b.input("Emit Size", "VECTOR", (2.0, 2.0, 0.5))
          grav = b.input("Gravity", "FLOAT", 9.81, panel="Physics")
          rest = b.input("Restitution", "FLOAT", 0.45, min=0.0, max=1.0, subtype="FACTOR", panel="Physics")
          fric = b.input("Friction", "FLOAT", 0.3, min=0.0, max=1.0, subtype="FACTOR", panel="Physics")
          ground = b.input("Ground Height", "FLOAT", 0.0, description="world Z", panel="Physics")
          rad = b.input("Radius", "FLOAT", 0.06, min=0.001, subtype="DISTANCE")
          seed = b.input("Seed", "INT", 0)
          mat = b.input("Material", "MATERIAL")
      
          info = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": b.node("SelfObject")})
          to_world = (info, "Transform")
          to_local = (b.node("InvertMatrix", ins={"Matrix": to_world}), "Matrix")
          zi, zo = b.zone("SIMULATION")        # nothing into zi Geometry: the state starts empty
      
          # emission, world-space box converted to object space
          frame = (b.node("SceneTime"), "Frame")
          emitting = b.node("Compare", data_type="FLOAT", operation="LESS_EQUAL", ins={"A": frame, "B": nframes})
          count = b.node("Switch", input_type="INT", ins={"Switch": emitting, "False": 0, "True": rate})
          fseed = b.node("Math", operation="MULTIPLY_ADD", ins={0: seed, 1: 7919.0, 2: frame})
          jitter = b.node("RandomValue", data_type="FLOAT_VECTOR",
                          ins={"Min": (-0.5, -0.5, -0.5), "Max": (0.5, 0.5, 0.5), "Seed": fseed})
          wpos = b.node("VectorMath", operation="MULTIPLY_ADD", ins={0: jitter, 1: size, 2: center})
          newp = b.node("Points", ins={"Count": count, "Position": b.node("TransformPoint", ins={"Vector": wpos,
                                                                                                 "Transform": to_local}),
                                       "Radius": rad})
          born = b.node("Math", operation="SUBTRACT", ins={0: frame, 1: 1.0})
          pid = b.node("Math", operation="MULTIPLY_ADD", ins={0: born, 1: rate, 2: b.node("Index")})
          newp = b.node("StoreNamedAttribute", data_type="INT", domain="POINT",
                        ins={"Geometry": newp, "Name": "pid", "Value": pid})
          state = b.join([(zi, "Geometry"), newp])          # existing points first, new ones appended
      
          # integrate in world space (semi-implicit Euler), collide with the ground plane
          dt = (zi, "Delta Time")
          vel = b.node("NamedAttribute", data_type="FLOAT_VECTOR", ins={"Name": "velocity"})
          g = b.node("CombineXYZ", ins={"Z": b.node("Math", operation="MULTIPLY", ins={0: grav, 1: -1.0})})
          v1 = b.node("VectorMath", operation="MULTIPLY_ADD", ins={0: g, 1: dt, 2: (vel, "Attribute")})
          pw = b.node("TransformPoint", ins={"Vector": b.node("Position"), "Transform": to_world})
          p1 = b.node("VectorMath", operation="MULTIPLY_ADD", ins={0: v1, 1: dt, 2: pw})
          sp1 = b.node("SeparateXYZ", ins={"Vector": p1})
          floor = b.node("Math", operation="ADD", ins={0: ground, 1: rad})
          hit = b.node("Compare", data_type="FLOAT", operation="LESS_THAN", ins={"A": (sp1, "Z"), "B": floor})
          p_hit = b.node("CombineXYZ", ins={"X": (sp1, "X"), "Y": (sp1, "Y"), "Z": floor})
          p2 = b.node("Switch", input_type="VECTOR", ins={"Switch": hit, "False": p1, "True": p_hit})
          sv = b.node("SeparateXYZ", ins={"Vector": v1})
          keep = b.node("Math", operation="SUBTRACT", ins={0: 1.0, 1: fric})
          bounce = b.node("Math", operation="MULTIPLY", ins={0: (sv, "Z"), 1: b.node("Math", operation="MULTIPLY",
                                                                                      ins={0: rest, 1: -1.0})})
          v_hit = b.node("CombineXYZ", ins={"X": b.node("Math", operation="MULTIPLY", ins={0: (sv, "X"), 1: keep}),
                                            "Y": b.node("Math", operation="MULTIPLY", ins={0: (sv, "Y"), 1: keep}),
                                            "Z": bounce})
          v2 = b.node("Switch", input_type="VECTOR", ins={"Switch": hit, "False": v1, "True": v_hit})
          cap = b.node("CaptureAttribute", domain="POINT")
          b.items(cap, "capture_items", [("VECTOR", "p"), ("VECTOR", "v")])
          b.set(cap, "Geometry", state)
          b.set(cap, "p", p2)
          b.set(cap, "v", v2)
          stv = b.node("StoreNamedAttribute", data_type="FLOAT_VECTOR", domain="POINT",
                       ins={"Geometry": (cap, "Geometry"), "Name": "velocity", "Value": (cap, "v")})
          setp = b.node("SetPosition", ins={"Geometry": stv, "Position": b.node("TransformPoint", ins={
              "Vector": (cap, "p"), "Transform": to_local})})
          b.link(setp, (zo, "Geometry"))
      
          ball = b.node("MeshIcoSphere", ins={"Radius": 1.0, "Subdivisions": 2})
          iop = b.node("InstanceOnPoints", ins={"Points": (zo, "Geometry"), "Instance": ball, "Scale": rad})
          shaded = b.node("SetMaterial", ins={"Geometry": iop, "Material": mat})
          b.output_geometry(b.join([b.geo_in, shaded]))
          return b.done()
      ```
      
      Stepping facts measured in 5.2.1 (test_03):
      
      - The first frame already runs the zone body: 12 points at frame 1 for Rate 12; count = Rate x min(frame, Emit Frames).
      - Nothing below ground + radius at any frame (min 0.06 for radius 0.06); pid prefix identical frame to frame (append-only); mean speed 3.87 at frame 25, 0.19 at 60; all resting by frame 80.
      - Stepping fills a memory cache: `frame_set(15)` after stepping to 80 replays frame 15 exactly.
      - A jump past the cache computes ONE step: stepping to 10 then `frame_set(40)` gives the 120 frame-10 points one step lower (mean z 2.65 to 2.55) instead of 240 mostly landed points.
      - `bpy.ops.object.simulation_nodes_cache_calculate_to_frame(selected=True)` returns `{'PASS_THROUGH'}` headless and computes nothing (frame 45 afterwards = one step from frame 1). Use `G.step`.
      - `G.bake(obj)` returns FINISHED (`md.bake_target == 'PACKED'`); afterwards a direct jump to frame 50 equals the stepped frame 50.
      - A host rotated 90 degrees about X still drops along world -Z and lands at world z = radius.
      - Why one Capture Attribute: the same tree rewired naively (Store velocity, then Set Position reading it) reads the NEW velocity and double-counts gravity in flight: mean z 2.875 instead of 2.923 at frame 5.
      
      ```python
      md = G.attach(ground, G.particle_drop_tree(), inputs={"Rate": 12, "Emit Frames": 20, "Radius": 0.06,
                    "Seed": 1, "Material": G.material("Ball", (0.9, 0.45, 0.1))})
      res = G.step(range(1, 81), lambda f: len(G.instance_transforms(ground)))
      G.bake(ground)
      G.snapshot([ground], "/abs/out/particles.png", frames=[4, 12, 24, 60])
      ```
      
      ## Procedure N: 5.2 physics assets from the builder (test_04; Thommes BCON26)
      
      Custom Force closure signature: inputs Geometry (GEOMETRY), To World Transform (MATRIX); outputs Geometry, Selection (BOOLEAN), Force (VECTOR). Custom Effector Geometry Effector closure: inputs Geometry, To World Transform; output Geometry. Stage menu items: Default, Pre-Simulation, Pre-Solve, Post-Solve, Custom (read from the 5.2.1 assets).
      
      ```python
      def cloth_tree(name, lift=None):
          b = G.Builder(name)
          colliders = b.input("Colliders", "COLLECTION")
          c = b.node("Group", node_tree=cloth_ng, ins={"Geometry": b.geo_in, "Effectors Collection": colliders,
                                                       "Pin Group": (b.node("NamedAttribute", ins={"Name": "Group"}),
                                                                     "Attribute")})
          if lift is not None:
              # Custom Force in Closure mode: signature (Geometry, To World Transform) -> (Geometry, Selection, Force)
              f = b.node("Group", node_tree=force_ng, ins={"Mode": "Closure"})
              zi, zo = b.zone("CLOSURE", inputs=[("GEOMETRY", "Geometry"), ("MATRIX", "To World Transform")],
                              outputs=[("GEOMETRY", "Geometry"), ("BOOLEAN", "Selection"), ("VECTOR", "Force")])
              b.link((zi, "Geometry"), (zo, "Geometry"))
              b.set(zo, "Selection", True)
              b.set(zo, "Force", (0.0, 0.0, lift))
              b.link((zo, "Closure"), (f, "Closure"))
              bund = b.node("CombineBundle")
              b.items(bund, "bundle_items", [("BUNDLE", "Lift")])
              b.link((f, "Force"), (bund, "Lift"))
              b.link(bund, (c, "Effectors"))
          b.output_geometry((c, "Geometry"))
          return b.done()
      ```
      
      ```python
      def ager(name, extra, stage, x):
          ob = cloth_obj(name, (x, 8, 2))
          b = G.Builder("W_" + name)
          c = b.node("Group", node_tree=cloth_ng, ins={"Geometry": b.geo_in, "Extra Sim Attributes": extra,
                                                       "Pin Group": (b.node("NamedAttribute", ins={"Name": "Group"}),
                                                                     "Attribute")})
          e = b.node("Group", node_tree=eff_ng, ins={"Socket_1": stage})   # two inputs named Stage: menu Socket_1, string Socket_2
          zi, zo = b.zone("CLOSURE", inputs=[("GEOMETRY", "Geometry"), ("MATRIX", "To World Transform")],
                          outputs=[("GEOMETRY", "Geometry")])
          age = b.node("NamedAttribute", data_type="FLOAT", ins={"Name": "age"})
          st = b.node("StoreNamedAttribute", data_type="FLOAT", domain="POINT",
                      ins={"Geometry": (zi, "Geometry"), "Name": "age",
                           "Value": b.node("Math", operation="ADD", ins={0: (age, "Attribute"), 1: 1.0})})
          b.link(st, (zo, "Geometry"))
          b.link((zo, "Closure"), (e, "Geometry Effector"))
          bund = b.node("CombineBundle")
          b.items(bund, "bundle_items", [("BUNDLE", "Ager")])
          b.link((e, "Effector"), (bund, "Ager"))
          b.link(bund, (c, "Effectors"))
          b.output_geometry((c, "Geometry"))
          return ob, G.attach(ob, b.done())
      ```
      
      Measured (test_04): cloth over the sphere, 0 vertices inside after 30 frames, pinned row drift 0.5 mm; an upward 6.0 closure force leaves the cloth's lowest point at z 0.33 instead of -0.37. Extra Sim Attributes, `age += 1` per step in a Custom Effector closure, 10 frames:
      
      | Extra Sim Attributes | Stage          | age after frames 1..10                                     |
      | -------------------- | -------------- | ---------------------------------------------------------- |
      | ""                   | Post-Solve     | 1, 1, 1, ... (resets every step, no warning)               |
      | "age"                | Post-Solve     | 1, 2, ..., 10                                              |
      | "age"                | Pre-Solve      | 1, 2, ..., 10 (once per frame with the default 5 substeps) |
      | "age"                | Pre-Simulation | 1, 1, 1, ... (runs on the first frame only)                |
      
      Not re-tested here: sewing by editing the local Edge Length Constraint (Custom Length on Is Edge Loose, 1 s ramp), tearing thresholds, Hair Dynamics on curves spawned mid-simulation. The ordering rule they need is the one Procedure M verifies with `pid`.
      
      ## Procedure O: robustness probes (any tree)
      
      ```python
      # world-space probe: flip or rotate the host, effects that mean "up" or "down" must follow the world
      obj.rotation_euler = (math.pi, 0, 0); bpy.context.view_layer.update(); obj.update_tag()
      flipped = len(G.instance_transforms(obj))          # scatter: 0 when the slope test is in world space
      # resolution probe: trait selections scale with resolution, index selections jump
      G.set_inputs(md, {"Resolution": 2 * base_res})
      # timing: loops and simulations in hot paths
      print(G.time_eval(obj))
      ```
      
      ## Node identifiers (verified to instantiate in 5.2.1)
      
      `bx_gn.NODE_IDS` lists the aliases; bare names resolve by prefix (`SetPosition` -> `GeometryNodeSetPosition`). Irregular ones worth remembering: Math `ShaderNodeMath`, Vector Math `ShaderNodeVectorMath`, Map Range `ShaderNodeMapRange`, Mix `ShaderNodeMix`, Combine/Separate XYZ `ShaderNodeCombineXYZ` / `ShaderNodeSeparateXYZ`, Value `ShaderNodeValue`, Noise `ShaderNodeTexNoise`, Compare `FunctionNodeCompare`, Random Value `FunctionNodeRandomValue`, Integer Math `FunctionNodeIntegerMath`, Align Rotation to Vector `FunctionNodeAlignRotationToVector`, Rotate Rotation `FunctionNodeRotateRotation`, Multiply Matrices `FunctionNodeMatrixMultiply`, Evaluate on Domain `GeometryNodeFieldOnDomain`, Evaluate at Index `GeometryNodeFieldAtIndex`, Transform Geometry `GeometryNodeTransform`, Curve Tangent `GeometryNodeInputTangent`, Bounding Box `GeometryNodeBoundBox`, Geometry Proximity `GeometryNodeProximity`, Get List Item `GeometryNodeListGetItem`, zones `GeometryNodeSimulationInput/Output`, `GeometryNodeRepeatInput/Output`, `GeometryNodeForeachGeometryElementInput/Output`, `NodeClosureInput/Output`, `NodeEvaluateClosure`, `NodeCombineBundle`, `NodeSeparateBundle`, physics `GeometryNodeXPBDSolver`, gizmos `GeometryNodeGizmoLinear/Dial/Transform`.
      
    • sources.md 7.3 KB
      # Sources
      
      Eight videos, distilled in `notes/geonodes/` (digest `_digest_geonodes.md`). Six are Simon Thommes (Blender Studio technical artist, artist-side member of the Geometry Nodes design team since late 2020); the others are Erindale and Johnny Matthews.
      
      | Id          | Title                                                                                                   | Expert, credential                                                      | Blender on screen | Best for                                                                                                                                                                                      | Best timestamps                                                                                                                                                                                                                     |
      | ----------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | 1nvzwhbL-k0 | "We can do that with Geometry Nodes..." (BCON22, 47:30) https://www.youtube.com/watch?v=1nvzwhbL-k0     | Simon Thommes                                                           | 3.4 alpha         | The container and field mental model; production taxonomy; attribute pipeline to shaders; store-to-freeze rest data; UV-space curves on deforming meshes; trajectory FX with Accumulate Field | 00:02:45 geometry as data; 00:06:00 fields; 00:09:19 edge mask; 00:13:12 stretch map; 00:24:47 UV-space projection; 00:33:09 trajectories; 00:40:51 instance attributes                                                             |
      | H2NRCcsX9Xo | Simulation Nodes: Making Nodes Come to Life (BCON23, 48:20) https://www.youtube.com/watch?v=H2NRCcsX9Xo | Simon Thommes                                                           | 3.6               | Simulation vs animation; zone mechanics; complete melting-bronze build with values; the hard-reference asset mistake                                                                          | 00:02:50 definition; 00:06:09 zone loop; 00:12:55 initial value; 00:18:30 named attribute for shaders; 00:25:54 Max memory; 00:31:30 local space; 00:35:31 no data-block references                                                 |
      | -kwAc-tPmVM | Forget About Nodes (BCON24, 55:02) https://www.youtube.com/watch?v=-kwAc-tPmVM                          | Simon Thommes                                                           | 4.3 beta          | Gizmo asset design that survives nesting; for-each mental model and its cost; instance hierarchies                                                                                            | 00:06:27 gizmos drive backwards; 00:14:11 drive the scaled value; 00:23:13 join gizmo transforms; 00:29:26 repeat vs for-each; 00:38:29 loops last resort; 00:49:01 realize depth 1; 00:53:32 invertible first input                |
      | Opzk1wUhzCw | Gizmos, For-Each Zone and more! (4.3, 25:09) https://www.youtube.com/watch?v=Opzk1wUhzCw                | Simon Thommes (official channel)                                        | 4.3               | Short version of the gizmo and for-each material plus generated-attribute domains, lazy Warning node, Still bakes                                                                             | 00:03:20 gizmo through multiply; 00:12:35 zone membership by links; 00:17:31 generated attribute domain; 00:21:41 Warning pass-through; 00:22:59 Still bake                                                                         |
      | 8hMflOZVbSs | Controlling Physics With Geometry Nodes (BCON26, 49:57) https://www.youtube.com/watch?v=8hMflOZVbSs     | Simon Thommes                                                           | 5.2 LTS           | The only source on the 5.2 XPBD physics framework: declarative bundles and closures, three depths of customization, Extra Sim Attributes, append order, tearing                               | 00:05:03 collider as bundle; 00:10:45 declarative; 00:16:52 make local for sewing; 00:21:26 tearing calibration; 00:28:12 append at the end; 00:31:32 effector stages; 00:36:13 Extra Sim Attributes; 00:40:44 face-corner sampling |
      | p4rwhifXNCw | Select What You Want in Geometry Nodes (1:14:20) https://www.youtube.com/watch?v=p4rwhifXNCw            | Erindale, GN educator, author of courses and the Erindale Toolkit       | 4.0 alpha         | Catalog of selection strategies, implicit conversions, per-island statistics, a full flatten-regions exercise                                                                                 | 00:05:32 implicit Boolean; 00:10:14 interpolation bleed; 00:17:39 trait over index; 00:26:10 Accumulate per island; 00:35:19 normal selection; 00:48:46 topological expand; 00:53:35 flatten exercise                               |
      | BqMos-6sSy4 | Closures in Geometry Nodes Explained (19:15) https://www.youtube.com/watch?v=BqMos-6sSy4                | Johnny Matthews, GN educator (credential beyond the channel not stated) | 5.x               | Minimal closure pattern, signature matching, closures as switchable data; captions machine-translated ("lock" = closure)                                                                      | 00:07:57 Evaluate Closure in a group; 00:10:12 manual signature; 00:13:29 switching closures; 00:17:53 link to physics                                                                                                              |
      | rLfxq5KavzM | The New Matrix Socket in Blender 4.2 (25:01) https://www.youtube.com/watch?v=rLfxq5KavzM                | Blender Studio; narrator unnamed, voice matches Thommes [?]             | 4.2               | Transforms as one socket, multiplication order, Accumulate Field on transforms, a simulation that propagates a transform                                                                      | 00:04:12 order; 00:08:08 pivot at the bottom; 00:11:43 stacking; 00:20:50 Leading vs Trailing; 00:22:45 strip scale; 00:23:28 topology outside the sim                                                                              |
      
      ## Version caveats
      
      - 3.6 baking lived in the Physics tab; 5.2 bakes from the modifier Bake panel or the Bake node, packed by default (`simulation_nodes_cache_bake`).
      - The anchor-empty world-space trick (BCON23) is replaced by matrix sockets (4.2+).
      - "There is no list node" (Erindale, 2023) is outdated: 5.2 has Lists; Accumulate Field is still the idiomatic per-group tool.
      - Transform Geometry's mode, Resample Curve's mode and similar options are menu input sockets in 5.x.
      - Modal node tools, Modal Timer and Modal Event (end of BCON26) are experimental-build only, absent from 5.2.1.
      - Workshop-only assets (Controller Influence, Smooth Mesh, Aligned Surface Tangent, Birch generator, Erindale Toolkit) are not shipped. Closest 5.2 Essentials: Sphere / Box Selection, Smooth Geometry, Scatter on Surface, Randomize Transforms, Is Edge Loose, Is Edge Boundary.
      
  • scripts
    • bx_gn.py 55.7 KB
      """
      bx_gn: Geometry Nodes from Python for Blender 5.2 (tested on 5.2.1 LTS).
      
      Build a node tree declaratively, attach it as a modifier, verify it by evaluating
      the depsgraph, and render what it makes. It encodes the traps that break
      hand-written GN code in 5.x:
        - tree.links.new() returns None (silently) when a socket belongs to another tree;
        - socket names are not unique (assets have two enabled 'Gravity' inputs; Math has three 'Value');
        - setting md.properties.inputs.<id>.value does not re-evaluate until obj.update_tag();
        - red links (field into a single-value socket, gizmo on a non-invertible source)
          only appear as link.is_valid == False AFTER an evaluation, never as an error;
        - Join Geometry puts the LAST created link FIRST in its output;
        - evaluated obj.bound_box ignores instances;
        - md.execution_time stays 0.0 headless (time with perf_counter instead).
      
        import sys; sys.path.append("<skill>/scripts"); import bx_gn as G
        b = G.Builder("Lift")                                  # tree + Group Input/Output (rebuilt in place if it exists)
        h = b.input("Height", "FLOAT", 0.5, min=0.0)           # returns the Group Input socket
        off = b.node("CombineXYZ", ins={"Z": h})               # aliases or full bl_idnames; ins: socket/node = link, else default
        sp = b.node("SetPosition", ins={"Geometry": b.geo_in, "Offset": off})
        b.output_geometry(sp)
        tree = b.done()                                        # zone pairing check + auto layout
        md = G.attach(obj, tree, inputs={"Height": 1.0})       # sets inputs, then obj.update_tag()
        rep = G.check(md); assert rep["ok"], rep                # invalid links, warnings, unpaired zones, hard refs
        print(G.stats(obj))                                     # counts per component, attributes per domain, world bbox
        G.snapshot([obj], "/abs/out/look.png")                 # Workbench render of the CURRENT frame; open it
      
      Zones: zi, zo = b.zone("SIMULATION" | "REPEAT" | "FOREACH" | "CLOSURE", ...) pairs them and adds items.
      Recipes (tested, rendered): scatter_tree, curve_array_tree, growth_tree, particle_drop_tree.
      Test suite: tests/code/blender-geometry-nodes/ (run each with blender -b --factory-startup --python).
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import math
      import os
      import statistics
      import time
      
      import bpy
      from mathutils import Matrix, Vector
      
      # --------------------------------------------------------------------------- identifiers
      # Friendly aliases for node bl_idnames whose names are irregular. Anything else can be
      # passed as a full bl_idname, or as a bare name that becomes GeometryNode<name>,
      # FunctionNode<name>, ShaderNode<name> or Node<name> (first that exists).
      NODE_IDS = {
          # group and zones
          "GroupInput": "NodeGroupInput", "GroupOutput": "NodeGroupOutput", "Group": "GeometryNodeGroup",
          "SimulationInput": "GeometryNodeSimulationInput", "SimulationOutput": "GeometryNodeSimulationOutput",
          "RepeatInput": "GeometryNodeRepeatInput", "RepeatOutput": "GeometryNodeRepeatOutput",
          "ForEachInput": "GeometryNodeForeachGeometryElementInput",
          "ForEachOutput": "GeometryNodeForeachGeometryElementOutput",
          "ClosureInput": "NodeClosureInput", "ClosureOutput": "NodeClosureOutput",
          "EvaluateClosure": "NodeEvaluateClosure", "CombineBundle": "NodeCombineBundle",
          "SeparateBundle": "NodeSeparateBundle", "Bake": "GeometryNodeBake", "Warning": "GeometryNodeWarning",
          # values and math
          "Value": "ShaderNodeValue", "Integer": "FunctionNodeInputInt", "Boolean": "FunctionNodeInputBool",
          "Vector": "FunctionNodeInputVector", "Math": "ShaderNodeMath", "VectorMath": "ShaderNodeVectorMath",
          "IntegerMath": "FunctionNodeIntegerMath", "BooleanMath": "FunctionNodeBooleanMath",
          "Compare": "FunctionNodeCompare", "MapRange": "ShaderNodeMapRange", "Mix": "ShaderNodeMix",
          "Clamp": "ShaderNodeClamp", "FloatCurve": "ShaderNodeFloatCurve", "FloatToInt": "FunctionNodeFloatToInt",
          "RandomValue": "FunctionNodeRandomValue", "CombineXYZ": "ShaderNodeCombineXYZ",
          "SeparateXYZ": "ShaderNodeSeparateXYZ", "NoiseTexture": "ShaderNodeTexNoise",
          "Switch": "GeometryNodeSwitch", "IndexSwitch": "GeometryNodeIndexSwitch", "MenuSwitch": "GeometryNodeMenuSwitch",
          "AccumulateField": "GeometryNodeAccumulateField", "EvaluateOnDomain": "GeometryNodeFieldOnDomain",
          "EvaluateAtIndex": "GeometryNodeFieldAtIndex", "StringJoin": "GeometryNodeStringJoin",
          "ValueToString": "FunctionNodeValueToString",
          # rotation and matrices
          "AlignRotationToVector": "FunctionNodeAlignRotationToVector", "RotateRotation": "FunctionNodeRotateRotation",
          "EulerToRotation": "FunctionNodeEulerToRotation", "RotationToEuler": "FunctionNodeRotationToEuler",
          "AxisAngleToRotation": "FunctionNodeAxisAngleToRotation", "CombineTransform": "FunctionNodeCombineTransform",
          "SeparateTransform": "FunctionNodeSeparateTransform", "MultiplyMatrices": "FunctionNodeMatrixMultiply",
          "InvertMatrix": "FunctionNodeInvertMatrix", "TransformPoint": "FunctionNodeTransformPoint",
          "TransformDirection": "FunctionNodeTransformDirection",
          # inputs (read nodes)
          "Position": "GeometryNodeInputPosition", "Normal": "GeometryNodeInputNormal", "Index": "GeometryNodeInputIndex",
          "ID": "GeometryNodeInputID", "NamedAttribute": "GeometryNodeInputNamedAttribute",
          "SceneTime": "GeometryNodeInputSceneTime", "SelfObject": "GeometryNodeSelfObject",
          "ObjectInfo": "GeometryNodeObjectInfo", "CollectionInfo": "GeometryNodeCollectionInfo",
          "EdgeAngle": "GeometryNodeInputMeshEdgeAngle", "FaceArea": "GeometryNodeInputMeshFaceArea",
          "MeshIsland": "GeometryNodeInputMeshIsland", "EdgeNeighbors": "GeometryNodeInputMeshEdgeNeighbors",
          "FaceNeighbors": "GeometryNodeInputMeshFaceNeighbors", "SplineParameter": "GeometryNodeSplineParameter",
          "CurveTangent": "GeometryNodeInputTangent", "InstanceScale": "GeometryNodeInputInstanceScale",
          "InstanceRotation": "GeometryNodeInputInstanceRotation", "InstanceTransform": "GeometryNodeInstanceTransform",
          # geometry operations
          "Transform": "GeometryNodeTransform", "BoundingBox": "GeometryNodeBoundBox",
          "Proximity": "GeometryNodeProximity", "CurveCircle": "GeometryNodeCurvePrimitiveCircle",
          "CurveLine": "GeometryNodeCurvePrimitiveLine", "GetListItem": "GeometryNodeListGetItem",
          "XPBDSolver": "GeometryNodeXPBDSolver",
      }
      _PREFIXES = ("GeometryNode", "FunctionNode", "ShaderNode", "Node")
      
      SOCKET_TYPES = {
          "GEOMETRY": "NodeSocketGeometry", "FLOAT": "NodeSocketFloat", "INT": "NodeSocketInt",
          "BOOL": "NodeSocketBool", "BOOLEAN": "NodeSocketBool", "VECTOR": "NodeSocketVector",
          "COLOR": "NodeSocketColor", "RGBA": "NodeSocketColor", "ROTATION": "NodeSocketRotation",
          "MATRIX": "NodeSocketMatrix", "STRING": "NodeSocketString", "MENU": "NodeSocketMenu",
          "OBJECT": "NodeSocketObject", "COLLECTION": "NodeSocketCollection", "MATERIAL": "NodeSocketMaterial",
          "IMAGE": "NodeSocketImage", "BUNDLE": "NodeSocketBundle", "CLOSURE": "NodeSocketClosure",
      }
      # zone / capture / bundle item type strings
      _ITEM_TYPES = {"BOOL": "BOOLEAN", "COLOR": "RGBA"}
      
      ZONES = {
          "SIMULATION": ("GeometryNodeSimulationInput", "GeometryNodeSimulationOutput"),
          "REPEAT": ("GeometryNodeRepeatInput", "GeometryNodeRepeatOutput"),
          "FOREACH": ("GeometryNodeForeachGeometryElementInput", "GeometryNodeForeachGeometryElementOutput"),
          "CLOSURE": ("NodeClosureInput", "NodeClosureOutput"),
      }
      
      ASSET_FILES = {
          "essentials": "geometry_nodes_essentials.blend",
          "dynamics": "geometry_nodes_dynamics_assets.blend",
          "hair": "procedural_hair_node_assets.blend",
      }
      
      
      def idname(kind):
          """Resolve an alias or bare name to a node bl_idname (raises with a hint if unknown)."""
          if kind in NODE_IDS:
              return NODE_IDS[kind]
          if hasattr(bpy.types, kind) and kind[0].isupper() and ("Node" in kind):
              return kind
          for p in _PREFIXES:
              if hasattr(bpy.types, p + kind):
                  return p + kind
          raise KeyError(f"unknown node kind '{kind}': pass a bl_idname such as 'GeometryNodeSetPosition'")
      
      
      def _item_type(t):
          t = t.upper()
          return _ITEM_TYPES.get(t, t)
      
      
      # --------------------------------------------------------------------------- sockets
      def _socks(node, out):
          return [s for s in (node.outputs if out else node.inputs) if s.identifier != "__extend__"]
      
      
      def sock(node, key=None, out=True):
          """Find a socket. key: int index, identifier (exact), or name.
      
          Names resolve to the ENABLED socket (Mix 'A' -> A_Vector when data_type is VECTOR).
          Two enabled sockets with the same name raise with their identifiers, so you pick one.
          key=None returns the first enabled socket.
          """
          if isinstance(node, bpy.types.NodeSocket):
              return node
          if isinstance(node, tuple):
              node, key = node
          socks = _socks(node, out)
          side = "output" if out else "input"
          if key is None:
              en = [s for s in socks if s.enabled]
              if not en:
                  raise KeyError(f"{node.name}: no enabled {side}")
              return en[0]
          if isinstance(key, int):
              return socks[key]
          exact = [s for s in socks if s.identifier == key]
          if exact:
              return exact[0]
          named = [s for s in socks if s.name == key]
          pick = [s for s in named if s.enabled] or named
          if len(pick) == 1:
              return pick[0]
          avail = [(s.name, s.identifier) for s in socks if s.enabled]
          if not pick:
              raise KeyError(f"{node.name} ({node.bl_idname}): no {side} '{key}'. Enabled {side}s: {avail}")
          raise KeyError(f"{node.name}: {side} name '{key}' is ambiguous, use an identifier: "
                         f"{[(s.name, s.identifier) for s in pick]}")
      
      
      def _is_source(v):
          return isinstance(v, (bpy.types.NodeSocket, bpy.types.Node)) or (
              isinstance(v, tuple) and len(v) == 2 and isinstance(v[0], bpy.types.Node))
      
      
      def _assign(s, value):
          if not hasattr(s, "default_value"):
              raise TypeError(f"{s.node.name}.{s.identifier} ({s.type}) has no value; link something into it")
          s.default_value = value
      
      
      # --------------------------------------------------------------------------- builder
      class Builder:
          """Declarative GN tree builder. Every link is checked; every zone is paired.
      
          Builder(name) reuses an existing tree of that name and rebuilds it in place (nodes and
          interface cleared), so modifiers keep pointing at it; their input values reset, so set
          them again after a rebuild.
          """
      
          def __init__(self, name, geometry_in=True, geometry_out=True, rebuild=True):
              t = bpy.data.node_groups.get(name)
              if t is not None and t.bl_idname == "GeometryNodeTree" and rebuild and t.library is None:
                  t.nodes.clear()
                  t.interface.clear()
              else:
                  t = bpy.data.node_groups.new(name, "GeometryNodeTree")
              self.tree = t
              self._panels = {}
              self.gi = t.nodes.new("NodeGroupInput")
              self.go = t.nodes.new("NodeGroupOutput")
              self.geo_in = self.input("Geometry", "GEOMETRY") if geometry_in else None
              if geometry_out:
                  self.output("Geometry", "GEOMETRY")
      
          # interface ---------------------------------------------------------------
          def panel(self, name, closed=True):
              if name not in self._panels:
                  self._panels[name] = self.tree.interface.new_panel(name, default_closed=closed)
              return self._panels[name]
      
          def input(self, name, type="FLOAT", default=None, min=None, max=None, subtype=None, panel=None,
                    description="", dimensions=None, hide_value=False, single_value=False,
                    default_attribute=None, hide_in_modifier=False):
              """Add a group input; returns the Group Input node's socket for linking."""
              item = self.tree.interface.new_socket(
                  name, description=description, in_out="INPUT", socket_type=SOCKET_TYPES[type.upper()],
                  parent=self.panel(panel) if panel else None)
              if subtype:
                  item.subtype = subtype                      # 'FACTOR', 'ANGLE', 'DISTANCE', 'PERCENTAGE', ...
              if dimensions:
                  item.dimensions = dimensions                # 2D vectors
              if default is not None:
                  item.default_value = default
              if min is not None:
                  item.min_value = min
              if max is not None:
                  item.max_value = max
              item.hide_value = hide_value
              item.hide_in_modifier = hide_in_modifier
              if single_value:
                  item.force_non_field = True
              if default_attribute:
                  item.default_attribute_name = default_attribute
              return self.gi.outputs[item.identifier]
      
          def output(self, name, type="GEOMETRY", domain=None):
              """Add a group output; returns the Group Output node's socket. domain: attribute output domain."""
              item = self.tree.interface.new_socket(name, in_out="OUTPUT", socket_type=SOCKET_TYPES[type.upper()])
              if domain:
                  item.attribute_domain = domain
              return self.go.inputs[item.identifier]
      
          # nodes and links -----------------------------------------------------------
          def node(self, kind, name=None, label=None, ins=None, **props):
              """Add a node. props (data_type, domain, operation, mode, node_tree...) are set BEFORE ins,
              because they change which sockets exist. ins: {key: value}; a socket, node or
              (node, output_key) value is linked, anything else becomes the socket's default_value."""
              n = self.tree.nodes.new(idname(kind))
              if name:
                  n.name = name
              if label:
                  n.label = label
              for k, v in props.items():
                  setattr(n, k, v)
              for k, v in (ins or {}).items():
                  self.set(n, k, v)
              return n
      
          def set(self, node, key, value):
              s = sock(node, key, out=False)
              if _is_source(value):
                  return self.link(value, s)
              _assign(s, value)
              return s
      
          def out(self, node, key=None):
              return sock(node, key, out=True)
      
          def inp(self, node, key):
              return sock(node, key, out=False)
      
          def link(self, src, dst):
              """Link and verify. src: socket | node (first enabled output) | (node, key). dst: socket | (node, key)."""
              a = sock(src, out=True) if not isinstance(src, bpy.types.NodeSocket) else src
              b = sock(dst, out=False) if not isinstance(dst, bpy.types.NodeSocket) else dst
              if a.id_data != self.tree or b.id_data != self.tree:
                  raise RuntimeError(f"socket from another tree: {a.id_data.name}.{a.node.name} -> "
                                     f"{b.id_data.name}.{b.node.name} (tree {self.tree.name})")
              if a.is_output is False or b.is_output is True:
                  raise RuntimeError(f"link direction wrong: {a.node.name}.{a.identifier} -> {b.node.name}.{b.identifier}")
              lk = self.tree.links.new(a, b)
              if lk is None:
                  raise RuntimeError(f"links.new returned None: {a.node.name}.{a.identifier} -> {b.node.name}.{b.identifier}")
              return lk
      
          def join(self, items, node=None):
              """Join Geometry whose output order equals the list order.
      
              Blender puts the last created link first, so links are created in reverse. This
              matters for simulations that append elements: existing elements must come first
              (Thommes, BCON26: hair dynamics needs new curves appended at the end)."""
              j = node or self.node("JoinGeometry")
              for it in reversed([i for i in items if i is not None]):
                  self.link(it, j.inputs[0])
              return j
      
          def output_geometry(self, src, name="Geometry"):
              return self.link(src, sock(self.go, name, out=False))
      
          # zones -------------------------------------------------------------------
          def zone(self, kind, items=(), iterations=None, domain=None, inputs=(), main=(), generation=(),
                   outputs=()):
              """Create and pair a zone. Item specs are tuples (TYPE, name[, domain]).
      
              SIMULATION: items = state items (domain = attribute domain). Default 'Geometry' item exists.
              REPEAT:     items = repeat items; iterations = int or a source to link.
              FOREACH:    domain = iterated domain; inputs = per-element values (field outside, single
                          value inside); main = values written back onto the input geometry;
                          generation = attributes of the generated geometry (domain must match it).
              CLOSURE:    inputs / outputs = the signature; names must match the Evaluate Closure.
              """
              kind = kind.upper()
              a, b = ZONES[kind]
              zi, zo = self.tree.nodes.new(a), self.tree.nodes.new(b)
              if not zi.pair_with_output(zo):
                  raise RuntimeError(f"pair_with_output failed for {kind}")
              if kind == "SIMULATION":
                  for spec in items:
                      it = zo.state_items.new(_item_type(spec[0]), spec[1])
                      if len(spec) > 2 and spec[2]:
                          it.attribute_domain = spec[2]
              elif kind == "REPEAT":
                  for spec in items:
                      zo.repeat_items.new(_item_type(spec[0]), spec[1])
                  if iterations is not None:
                      self.set(zi, "Iterations", iterations)
              elif kind == "FOREACH":
                  if domain:
                      zo.domain = domain
                  for spec in inputs:
                      zo.input_items.new(_item_type(spec[0]), spec[1])
                  for spec in main:
                      zo.main_items.new(_item_type(spec[0]), spec[1])
                  for spec in generation:
                      it = zo.generation_items.new(_item_type(spec[0]), spec[1])
                      if len(spec) > 2 and spec[2]:
                          it.domain = spec[2]
              elif kind == "CLOSURE":
                  for spec in inputs:
                      zo.input_items.new(_item_type(spec[0]), spec[1])
                  for spec in outputs:
                      zo.output_items.new(_item_type(spec[0]), spec[1])
              return zi, zo
      
          def items(self, node, collection, specs):
              """Add items to any dynamic-socket node: ('input_items'|'output_items') on Evaluate Closure,
              'bundle_items' on Combine/Separate Bundle, 'capture_items' on Capture Attribute."""
              coll = getattr(node, collection)
              return [coll.new(_item_type(s[0]), s[1]) for s in specs]
      
          def done(self, layout_tree=True):
              unpaired = [n.name for n in self.tree.nodes
                          if hasattr(n, "paired_output") and n.bl_idname in {v[0] for v in ZONES.values()}
                          and n.paired_output is None]
              if unpaired:
                  raise RuntimeError(f"unpaired zone inputs: {unpaired}")
              if layout_tree:
                  layout(self.tree)
              return self.tree
      
      
      # --------------------------------------------------------------------------- layout
      def _node_height(n):
          vis = sum(1 for s in n.inputs if s.enabled and s.identifier != "__extend__")
          vis += sum(1 for s in n.outputs if s.enabled and s.identifier != "__extend__")
          has_props = any(p in n.bl_rna.properties for p in ("data_type", "operation", "domain", "mode"))
          return 40 + 22 * vis + (30 if has_props else 0)
      
      
      def layout(tree, gap_x=80, gap_y=30):
          """Left-to-right layout by longest path from sources; zone outputs sit right of their body.
          Rows are ordered by the mean height of the predecessors (one barycenter sweep)."""
          nodes = [n for n in tree.nodes if n.bl_idname != "NodeFrame"]
          preds = {n: set() for n in nodes}
          for lk in tree.links:
              if lk.from_node in preds and lk.to_node in preds:
                  preds[lk.to_node].add(lk.from_node)
          for n in nodes:
              po = getattr(n, "paired_output", None)
              if po is not None and po in preds:
                  preds[po].add(n)
          rank, indeg = {}, {n: len(p) for n, p in preds.items()}
          succ = {n: [] for n in nodes}
          for n, ps in preds.items():
              for p in ps:
                  succ[p].append(n)
          queue = [n for n in nodes if indeg[n] == 0]
          for n in queue:
              rank[n] = 0
          while queue:
              n = queue.pop(0)
              for s in succ[n]:
                  rank[s] = max(rank.get(s, 0), rank[n] + 1)
                  indeg[s] -= 1
                  if indeg[s] == 0:
                      queue.append(s)
          for n in nodes:                                    # cycles (should not happen): park them at 0
              rank.setdefault(n, 0)
          cols = {}
          for n in nodes:
              cols.setdefault(rank[n], []).append(n)
          x, ypos = 0.0, {}
          for r in sorted(cols):
              col = cols[r]
              col.sort(key=lambda n: (statistics.mean([ypos[p] for p in preds[n] if p in ypos])
                                      if any(p in ypos for p in preds[n]) else 0.0))
              heights = [_node_height(n) for n in col]
              total = sum(heights) + gap_y * (len(col) - 1)
              y = total / 2
              for n, h in zip(col, heights):
                  n.location = (x, y)
                  ypos[n] = y - h / 2
                  y -= h + gap_y
              x += max(n.width for n in col) + gap_x
          return tree
      
      
      # --------------------------------------------------------------------------- modifier
      def attach(obj, tree, name=None, inputs=None):
          """Get-or-create a Nodes modifier by name, assign the tree, set inputs, tag for update."""
          name = name or tree.name
          md = obj.modifiers.get(name)
          if md is None or md.type != "NODES":
              md = obj.modifiers.new(name, "NODES")
          md.node_group = tree
          if inputs:
              set_inputs(md, inputs)
          else:
              obj.update_tag()
          return md
      
      
      def _input_item(tree, key):
          items = [i for i in tree.interface.items_tree if i.item_type == "SOCKET" and i.in_out == "INPUT"]
          for i in items:
              if i.identifier == key:
                  return i
          named = [i for i in items if i.name == key]
          if len(named) == 1:
              return named[0]
          if not named:
              raise KeyError(f"{tree.name}: no input '{key}'. Inputs: {[(i.name, i.identifier) for i in items]}")
          raise KeyError(f"{tree.name}: input name '{key}' is ambiguous, use one of {[(i.name, i.identifier) for i in named]}")
      
      
      def set_inputs(md, values):
          """Set modifier inputs by name or identifier (5.2 API: md.properties.inputs), then tag.
      
          "attr:NAME" switches a field input to attribute mode (vertex groups are attributes).
          Objects, collections and materials take the data-block. Menus take the item name."""
          obj = md.id_data
          for key, v in values.items():
              item = _input_item(md.node_group, key)
              p = getattr(md.properties.inputs, item.identifier)
              if isinstance(v, str) and v.startswith("attr:"):
                  p.type = "ATTRIBUTE"
                  p.attribute_name = v[5:]
                  continue
              if hasattr(p, "type") and p.type == "ATTRIBUTE":
                  p.type = "VALUE"
              p.value = v
          obj.update_tag()                                   # 5.2.1: without this nothing re-evaluates
          return md
      
      
      def get_inputs(md):
          out = {}
          for i in md.node_group.interface.items_tree:
              if i.item_type != "SOCKET" or i.in_out != "INPUT" or i.socket_type == "NodeSocketGeometry":
                  continue
              p = getattr(md.properties.inputs, i.identifier, None)
              if p is None:
                  continue
              if getattr(p, "type", "VALUE") == "ATTRIBUTE":
                  out[i.name] = "attr:" + p.attribute_name
              else:
                  v = getattr(p, "value", None)
                  out[i.name] = v.name if hasattr(v, "name") and not isinstance(v, str) else (
                      tuple(v) if hasattr(v, "__len__") and not isinstance(v, str) else v)
          return out
      
      
      def set_output_attribute(md, socket_name, attribute_name):
          """Name a field output of the group so it is written as an attribute (Output Attributes panel)."""
          items = [i for i in md.node_group.interface.items_tree
                   if i.item_type == "SOCKET" and i.in_out == "OUTPUT" and i.name == socket_name]
          getattr(md.properties.outputs, items[0].identifier).attribute_name = attribute_name
          md.id_data.update_tag()
      
      
      def append_group(name, library="essentials"):
          """Append a shipped node-group asset (local, editable copy). library: essentials|dynamics|hair."""
          have = bpy.data.node_groups.get(name)
          if have is not None and have.library is None:
              return have
          path = os.path.join(bpy.utils.system_resource("DATAFILES"), "assets", "nodes", ASSET_FILES[library])
          with bpy.data.libraries.load(path, link=False) as (src, dst):
              if name not in src.node_groups:
                  raise KeyError(f"'{name}' not in {ASSET_FILES[library]}: {[g for g in src.node_groups if not g.startswith('.')]}")
              dst.node_groups = [name]
          return dst.node_groups[0]
      
      
      def list_assets(library="essentials"):
          path = os.path.join(bpy.utils.system_resource("DATAFILES"), "assets", "nodes", ASSET_FILES[library])
          with bpy.data.libraries.load(path, link=False) as (src, dst):
              return [g for g in src.node_groups if not g.startswith(".")]
      
      
      # --------------------------------------------------------------------------- evaluation
      def evaluated(obj):
          return obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
      
      
      def _attr_summary(attrs):
          return {a.name: (a.domain, a.data_type) for a in attrs if not a.name.startswith(".")}
      
      
      _ATTR_WIDTH = {"FLOAT": 1, "INT": 1, "BOOLEAN": 1, "INT8": 1, "FLOAT_VECTOR": 3, "FLOAT2": 2, "INT32_2D": 2,
                     "INT16_2D": 2, "FLOAT_COLOR": 4, "BYTE_COLOR": 4, "QUATERNION": 4, "FLOAT4": 4, "FLOAT4X4": 16}
      
      
      def _read(attr):
          import numpy as np
          n = len(attr.data)
          w = _ATTR_WIDTH.get(attr.data_type)
          if w is None:
              return [d.value for d in attr.data]
          key = "vector" if attr.data_type in ("FLOAT_VECTOR", "FLOAT2") else (
              "color" if attr.data_type in ("FLOAT_COLOR", "BYTE_COLOR") else "value")
          dt = np.int32 if attr.data_type in ("INT", "INT8", "INT32_2D", "INT16_2D") else (
              bool if attr.data_type == "BOOLEAN" else np.float32)
          arr = np.zeros(n * w, dtype=dt)
          attr.data.foreach_get(key, arr)
          return arr.reshape(n, w) if w > 1 else arr
      
      
      def _components(gs):
          comps = {}
          if gs.mesh is not None:
              comps["mesh"] = gs.mesh
          if gs.curves is not None:
              comps["curves"] = gs.curves
          if gs.pointcloud is not None:
              comps["pointcloud"] = gs.pointcloud
          ipc = gs.instances_pointcloud()
          if ipc is not None and len(ipc.points):
              comps["instances"] = ipc
          return comps
      
      
      def attribute(obj, name, component="mesh"):
          """Evaluated attribute values as a numpy array (n,) or (n, width), plus its domain.
          component: mesh | curves | pointcloud | instances."""
          gs = evaluated(obj).evaluated_geometry()           # keep gs alive while reading its components
          comp = _components(gs).get(component)
          if comp is None:
              raise KeyError(f"{obj.name}: no evaluated {component} component")
          a = comp.attributes.get(name)
          if a is None:
              raise KeyError(f"{obj.name}.{component}: no attribute '{name}'. Have: {list(_attr_summary(comp.attributes))}")
          return _read(a), a.domain
      
      
      def positions(obj, component="mesh", world=False):
          """Evaluated positions (n, 3). For instances the translation of instance_transform is used:
          the instance point cloud's own 'position' attribute reads all zeros in 5.2.1."""
          import numpy as np
          gs = evaluated(obj).evaluated_geometry()
          comp = _components(gs).get(component)
          if comp is None:
              return np.zeros((0, 3), dtype=np.float32)
          if component == "instances":
              p = _read(comp.attributes["instance_transform"]).reshape(-1, 4, 4)
              # FLOAT4X4 comes out column-major: translation is the last 4-float block
              p = p[:, 3, :3].astype(np.float64)
          else:
              p = _read(comp.attributes["position"]).astype(np.float64)
          if world and len(p):
              m = np.array(obj.matrix_world)
              p = p @ m[:3, :3].T + m[:3, 3]
          return p
      
      
      def instance_transforms(obj, world=False):
          """Per-instance 4x4 matrices (object space, or world with world=True)."""
          gs = evaluated(obj).evaluated_geometry()
          ipc = gs.instances_pointcloud()
          if ipc is None or not len(ipc.points):
              return []
          mats = [Matrix(d.value) for d in ipc.attributes["instance_transform"].data]
          return [obj.matrix_world @ m for m in mats] if world else mats
      
      
      def _ref_bounds(ref):
          """Local-space (lo, hi) of an instance reference (GeometrySet, Object or Collection)."""
          import numpy as np
          pts = []
          try:
              if isinstance(ref, bpy.types.Object):
                  pts = [Vector(c) for c in ref.bound_box]
              elif isinstance(ref, bpy.types.Collection):
                  pts = [o.matrix_world @ Vector(c) for o in ref.all_objects for c in o.bound_box]
              else:
                  for comp in (ref.mesh, ref.curves, ref.pointcloud):
                      if comp is not None and "position" in comp.attributes and len(comp.attributes["position"].data):
                          p = _read(comp.attributes["position"])
                          pts += [Vector(p.min(0)), Vector(p.max(0))]
          except Exception:
              pts = []
          if not pts:
              return Vector((0, 0, 0)), Vector((0, 0, 0))
          a = np.array([tuple(p) for p in pts])
          return Vector(a.min(0)), Vector(a.max(0))
      
      
      def world_bbox(objs):
          """World-space (lo, hi) of evaluated objects INCLUDING instances (obj.bound_box ignores them)."""
          import numpy as np
          pts = []
          for o in objs:
              gs = evaluated(o).evaluated_geometry()
              mw = np.array(o.matrix_world)
              for name, comp in _components(gs).items():
                  if name == "instances":
                      refs = gs.instance_references()
                      bounds = [_ref_bounds(r) for r in refs]
                      ridx = _read(comp.attributes[".reference_index"]) if ".reference_index" in comp.attributes else None
                      for i, d in enumerate(comp.attributes["instance_transform"].data):
                          m = o.matrix_world @ Matrix(d.value)
                          lo, hi = bounds[int(ridx[i])] if (ridx is not None and bounds) else (Vector(), Vector())
                          for c in ((lo.x, lo.y, lo.z), (hi.x, hi.y, hi.z), (lo.x, hi.y, lo.z), (hi.x, lo.y, hi.z)):
                              pts.append(tuple(m @ Vector(c)))
                  elif "position" in comp.attributes and len(comp.attributes["position"].data):
                      p = _read(comp.attributes["position"]).astype(np.float64)
                      p = p @ mw[:3, :3].T + mw[:3, 3]
                      pts += [tuple(p.min(0)), tuple(p.max(0))]
          if not pts:
              return Vector((0, 0, 0)), Vector((0, 0, 0))
          a = np.array(pts)
          return Vector(a.min(0)), Vector(a.max(0))
      
      
      def stats(obj):
          """Counts per evaluated component, attributes per component {name: (domain, type)}, world bbox."""
          gs = evaluated(obj).evaluated_geometry()
          out = {"geometry_name": gs.name}
          for name, comp in _components(gs).items():
              if name == "mesh":
                  out["mesh"] = {"verts": len(comp.vertices), "edges": len(comp.edges), "faces": len(comp.polygons),
                                 "attributes": _attr_summary(comp.attributes)}
              elif name == "curves":
                  out["curves"] = {"curves": len(comp.curves), "points": len(comp.points),
                                   "attributes": _attr_summary(comp.attributes)}
              elif name == "pointcloud":
                  out["pointcloud"] = {"points": len(comp.points), "attributes": _attr_summary(comp.attributes)}
              else:
                  out["instances"] = {"count": len(comp.points), "references": len(gs.instance_references()),
                                      "attributes": _attr_summary(comp.attributes)}
          lo, hi = world_bbox([obj])
          out["bbox"] = (tuple(round(v, 4) for v in lo), tuple(round(v, 4) for v in hi))
          return out
      
      
      def _walk_trees(tree, seen=None):
          seen = seen if seen is not None else []
          if tree is None or tree in seen:
              return seen
          seen.append(tree)
          for n in tree.nodes:
              if n.bl_idname == "GeometryNodeGroup" and n.node_tree is not None:
                  _walk_trees(n.node_tree, seen)
          return seen
      
      
      def check(md, nested=True):
          """Evaluate once, then report what the node editor would show in red or yellow.
      
          invalid_links: link.is_valid False (field into single value, gizmo on non-invertible source,
                         incompatible types). Nested groups included (prefixed with their tree).
          warnings:      md.node_warnings (closure signature mismatch, Warning nodes, missing data).
          unpaired_zones, hard_refs (Object/Collection set inside the tree instead of exposed),
          output_unlinked. ok = no invalid link, no ERROR warning, no unpaired zone."""
          evaluated(md.id_data).evaluated_geometry()
          trees = _walk_trees(md.node_group) if nested else [md.node_group]
          zone_inputs = {v[0] for v in ZONES.values()}
          bad, unpaired, hard = [], [], []
          for t in trees:
              for lk in t.links:
                  if not lk.is_valid:
                      bad.append(f"{t.name}: {lk.from_node.name}.{lk.from_socket.identifier} -> "
                                 f"{lk.to_node.name}.{lk.to_socket.identifier}")
              for n in t.nodes:
                  if n.bl_idname in zone_inputs and getattr(n, "paired_output", None) is None:
                      unpaired.append(f"{t.name}: {n.name}")
                  if t == md.node_group or (t.library is None and t.asset_data is None):   # bpy wrappers: == not is
                      for s in n.inputs:
                          if s.type in ("OBJECT", "COLLECTION") and not s.is_linked and getattr(s, "default_value", None):
                              hard.append(f"{t.name}: {n.name}.{s.name} = {s.default_value.name}")
          warns = [(w.type, w.message) for w in md.node_warnings]
          go = [n for n in md.node_group.nodes if n.bl_idname == "NodeGroupOutput"]
          out_unlinked = not go or not any(s.is_linked for s in go[0].inputs if s.type == "GEOMETRY")
          ok = not bad and not unpaired and not any(t == "ERROR" for t, _ in warns)
          return {"ok": ok, "invalid_links": bad, "warnings": warns, "unpaired_zones": unpaired,
                  "hard_refs": hard, "output_unlinked": out_unlinked}
      
      
      def time_eval(obj, repeats=3):
          """Median seconds to re-evaluate obj (md.execution_time reads 0.0 headless in 5.2.1)."""
          dg = bpy.context.evaluated_depsgraph_get()
          ts = []
          for _ in range(repeats):
              obj.update_tag()
              t0 = time.perf_counter()
              dg.update()
              obj.evaluated_get(dg).evaluated_geometry()
              ts.append(time.perf_counter() - t0)
          return statistics.median(ts)
      
      
      # --------------------------------------------------------------------------- simulation
      def step(frames, fn=None, scene=None):
          """frame_set every frame IN ORDER (a jump computes one step only). fn(frame) results by frame."""
          sc = scene or bpy.context.scene
          res = {}
          for f in frames:
              sc.frame_set(f)
              if fn is not None:
                  res[f] = fn(f)
          return res
      
      
      class _Selected:
          def __init__(self, obj):
              self.obj = obj
      
          def __enter__(self):
              vl = bpy.context.view_layer
              self.prev_sel = [o for o in vl.objects if o.select_get()]
              self.prev_act = vl.objects.active
              for o in self.prev_sel:
                  o.select_set(False)
              self.obj.select_set(True)
              vl.objects.active = self.obj
      
          def __exit__(self, *a):
              vl = bpy.context.view_layer
              self.obj.select_set(False)
              for o in self.prev_sel:
                  if o.name in vl.objects:
                      o.select_set(True)
              vl.objects.active = self.prev_act
      
      
      def bake(obj):
          """Bake every simulation zone and Bake node of obj (packed in the .blend by default)."""
          with _Selected(obj):
              return bpy.ops.object.simulation_nodes_cache_bake(selected=True)
      
      
      def free_bake(obj):
          with _Selected(obj):
              return bpy.ops.object.simulation_nodes_cache_delete(selected=True)
      
      
      # --------------------------------------------------------------------------- look
      def material(name, color, roughness=0.5):
          """Get-or-create a material; color drives both Workbench (diffuse_color) and Principled."""
          m = bpy.data.materials.get(name) or bpy.data.materials.new(name)
          m.diffuse_color = (color[0], color[1], color[2], 1.0)
          m.roughness = roughness
          nt = getattr(m, "node_tree", None)
          if nt is not None:
              bsdf = nt.nodes.get("Principled BSDF")
              if bsdf is not None:
                  bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0)
                  bsdf.inputs["Roughness"].default_value = roughness
          return m
      
      
      _VIEWS = {
          "front": Vector((0, -1, 0)), "back": Vector((0, 1, 0)), "right": Vector((1, 0, 0)),
          "left": Vector((-1, 0, 0)), "top": Vector((0, 0.0001, 1)),
          "threequarter": Vector((-0.7, -1.0, 0.55)).normalized(),
          "low": Vector((0.8, -1.0, -0.3)).normalized(),
      }
      
      
      def _aim(cam, target, direction, dist):
          cam.location = target + direction.normalized() * dist
          cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
      
      
      def _tile(paths, rows, cols, out):
          import numpy as np
          imgs = []
          for p in paths:
              im = bpy.data.images.load(p, check_existing=False)
              w, h = im.size
              imgs.append(np.array(im.pixels[:], dtype=np.float32).reshape(h, w, 4))
              bpy.data.images.remove(im)
          h, w = imgs[0].shape[:2]
          sheet = np.ones((rows * h, cols * w, 4), dtype=np.float32)
          for i, a in enumerate(imgs):
              r, c = divmod(i, cols)
              y0 = (rows - 1 - r) * h                       # pixel rows are bottom-up
              sheet[y0:y0 + h, c * w:(c + 1) * w] = a
          img = bpy.data.images.new("BX_GN_sheet", cols * w, rows * h, alpha=True)
          img.pixels = sheet.ravel()
          img.filepath_raw = out
          img.file_format = "PNG"
          img.save()
          bpy.data.images.remove(img)
          return out
      
      
      def snapshot(objs, path, views=("threequarter",), frames=None, res=720, isolate=True, shadows=True,
                   color_type="MATERIAL", margin=1.08, lens=50):
          """Workbench render in the CURRENT scene, so simulation state and instances are exactly what
          the depsgraph evaluates now (bx_review renders in a temporary scene at its own frame).
      
          frames: list of frames for a strip; the timeline is first stepped IN ORDER from the lowest
          frame to the highest (fills the simulation cache and a common framing), then each frame is
          rendered. Rows = views, columns = frames (time reads left to right); without frames the
          views sit side by side. isolate hides other objects from render (restored).
          Returns the sheet path; a <path>_legend.txt lists the tiles."""
          sc = bpy.context.scene
          objs = [o for o in objs if o is not None]
          base, _ = os.path.splitext(path)
          os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
          r, sh = sc.render, sc.display.shading
          saved = dict(camera=sc.camera, engine=r.engine, rx=r.resolution_x, ry=r.resolution_y, pct=r.resolution_percentage,
                       fp=r.filepath, media=r.image_settings.media_type, fmt=r.image_settings.file_format,
                       vt=sc.view_settings.view_transform, light=sh.light, ct=sh.color_type, shadow=sh.show_shadows,
                       cavity=sh.show_cavity, frame=sc.frame_current,
                       world=tuple(sc.world.color) if sc.world else None)
          hidden = {}
          cam = bpy.data.objects.new("BX_GN_Cam", bpy.data.cameras.new("BX_GN_Cam"))
          sc.collection.objects.link(cam)
          try:
              if isolate:
                  keep = set(objs) | {cam}
                  for o in sc.objects:
                      if o not in keep and not o.hide_render:
                          hidden[o] = True
                          o.hide_render = True
              frame_list = sorted(set(frames)) if frames else [sc.frame_current]
              lo = hi = None
              if frames:
                  for f in range(min(frame_list), max(frame_list) + 1):
                      sc.frame_set(f)
                      if f in frame_list:
                          a, b = world_bbox(objs)
                          lo = a if lo is None else Vector(map(min, lo, a))
                          hi = b if hi is None else Vector(map(max, hi, b))
              else:
                  lo, hi = world_bbox(objs)
              center, radius = (lo + hi) / 2, max((hi - lo).length / 2, 1e-3)
              r.engine = "BLENDER_WORKBENCH"
              r.resolution_x = r.resolution_y = res
              r.resolution_percentage = 100
              r.image_settings.media_type = "IMAGE"
              r.image_settings.file_format = "PNG"
              sc.view_settings.view_transform = "Standard"
              sh.light, sh.color_type, sh.show_shadows, sh.show_cavity = "STUDIO", color_type, shadows, False
              if sc.world:
                  sc.world.color = (0.62, 0.64, 0.68)
              cam.data.lens = lens
              fov = 2 * math.atan(cam.data.sensor_width / (2 * lens))
              dist = radius / math.sin(fov / 2) * margin
              cam.data.clip_start, cam.data.clip_end = dist * 0.01, dist * 10
              sc.camera = cam
              tiles = {}
              for f in frame_list:
                  if frames:
                      sc.frame_set(f)
                  for v in views:
                      _aim(cam, center, _VIEWS[v], dist)
                      p = f"{base}_f{f:04d}_{v}.png"
                      r.filepath = p
                      bpy.ops.render.render(write_still=True)
                      tiles[(v, f)] = p
              order = [(v, f) for v in views for f in frame_list]          # rows = views, cols = frames
              rows, cols = (len(views), len(frame_list)) if frames else (1, len(views))
              _tile([tiles[k] for k in order], rows, cols, path)
              legend = [f"{v} / frame {f}" for v, f in order]
              with open(base + "_legend.txt", "w") as fh:
                  fh.write("rows x cols, top-left first:\n" + "\n".join(legend))
          finally:
              for o in hidden:
                  o.hide_render = False
              cd = cam.data
              bpy.data.objects.remove(cam)
              bpy.data.cameras.remove(cd)
              sc.camera = saved["camera"]
              r.engine, r.resolution_x, r.resolution_y = saved["engine"], saved["rx"], saved["ry"]
              r.resolution_percentage, r.filepath = saved["pct"], saved["fp"]
              r.image_settings.media_type = saved["media"]
              r.image_settings.file_format = saved["fmt"]
              sc.view_settings.view_transform = saved["vt"]
              sh.light, sh.color_type, sh.show_shadows, sh.show_cavity = saved["light"], saved["ct"], saved["shadow"], saved["cavity"]
              if saved["world"] is not None:
                  sc.world.color = saved["world"]
              if frames:
                  sc.frame_set(saved["frame"])
          return path
      
      
      # --------------------------------------------------------------------------- recipes
      def _world_up_local(b):
          """(0,0,1) world expressed in the modified object's space: Self Object > Object Info >
          Invert Matrix > Transform Direction (Thommes: everything in GN is object-local)."""
          info = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": b.node("SelfObject")})
          inv = b.node("InvertMatrix", ins={"Matrix": (info, "Transform")})
          return b.node("TransformDirection", ins={"Direction": (0.0, 0.0, 1.0), "Transform": (inv, "Matrix")}), info, inv
      
      
      def _seed_plus(b, seed, k):
          """Offset seeds per Random Value node: two nodes with the same ID and Seed return correlated values."""
          return b.node("IntegerMath", operation="ADD", ins={0: seed, 1: k})
      
      
      def scatter_tree(name="BX Scatter"):
          """Scatter on a surface: density x mask (attribute/vertex group), world-up slope limit,
          upright or normal-aligned rotation with random spin, random scale, pick from an object
          and/or a collection, per-instance 'scatter_rand' attribute for shaders, optional realize.
          Instances stay instances by default (cheap; shaders read instance attributes)."""
          b = Builder(name)
          inst_obj = b.input("Instance", "OBJECT", description="object instanced on the points")
          inst_col = b.input("Instances", "COLLECTION", description="pick randomly among the children")
          dens = b.input("Density", "FLOAT", 10.0, min=0.0, description="points per square metre (object space)")
          mask = b.input("Density Mask", "FLOAT", 1.0, min=0.0, max=1.0, subtype="FACTOR",
                         description="0 to 1; switch to an attribute or vertex group with attr:NAME")
          slope = b.input("Max Slope", "FLOAT", math.radians(35.0), min=0.0, max=math.pi, subtype="ANGLE",
                          description="faces steeper than this (vs world up) get nothing")
          seed = b.input("Seed", "INT", 0)
          smin = b.input("Scale Min", "FLOAT", 0.8, min=0.0, panel="Transform")
          smax = b.input("Scale Max", "FLOAT", 1.2, min=0.0, panel="Transform")
          align = b.input("Align to Normal", "BOOL", False, panel="Transform",
                          description="off: upright in world (trees); on: follow the surface (rocks, moss)")
          spin = b.input("Random Spin", "FLOAT", 1.0, min=0.0, max=1.0, subtype="FACTOR", panel="Transform")
          realize = b.input("Realize", "BOOL", False, panel="Output")
      
          up, _, _ = _world_up_local(b)
          flat = b.node("Compare", data_type="VECTOR", mode="DIRECTION", operation="LESS_EQUAL",
                        ins={"A": b.node("Normal"), "B": up, "Angle": slope})
          density = b.node("Math", operation="MULTIPLY", ins={0: dens, 1: mask})
          dist = b.node("DistributePointsOnFaces", ins={"Mesh": b.geo_in, "Selection": flat, "Density": density,
                                                        "Seed": seed})
          upright = b.node("AlignRotationToVector", axis="Z", ins={"Vector": up})
          base_rot = b.node("Switch", input_type="ROTATION", ins={"Switch": align, "False": upright,
                                                                   "True": (dist, "Rotation")})
          two_pi = b.node("Math", operation="MULTIPLY", ins={0: spin, 1: 2 * math.pi})
          ang = b.node("RandomValue", data_type="FLOAT", ins={"Min": 0.0, "Max": two_pi, "Seed": _seed_plus(b, seed, 1)})
          rot = b.node("RotateRotation", rotation_space="LOCAL",
                       ins={"Rotation": base_rot,
                            "Rotate By": b.node("EulerToRotation", ins={"Euler": b.node("CombineXYZ", ins={"Z": ang})})})
          scl = b.node("RandomValue", data_type="FLOAT", ins={"Min": smin, "Max": smax, "Seed": _seed_plus(b, seed, 2)})
          oi = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": inst_obj, "As Instance": True})
          ci = b.node("CollectionInfo", ins={"Collection": inst_col, "Separate Children": True, "Reset Children": True})
          pool = b.join([(oi, "Geometry"), ci])
          pick = b.node("RandomValue", data_type="INT", ins={"Min": 0, "Max": 9999, "Seed": _seed_plus(b, seed, 3)})
          iop = b.node("InstanceOnPoints", ins={"Points": dist, "Instance": pool, "Pick Instance": True,
                                                "Instance Index": pick, "Rotation": rot, "Scale": scl})
          rid = b.node("RandomValue", data_type="FLOAT", ins={"Seed": _seed_plus(b, seed, 4)})
          tagged = b.node("StoreNamedAttribute", data_type="FLOAT", domain="INSTANCE",
                          ins={"Geometry": iop, "Name": "scatter_rand", "Value": rid})
          real = b.node("RealizeInstances", ins={"Geometry": tagged})
          res = b.node("Switch", input_type="GEOMETRY", ins={"Switch": realize, "False": tagged, "True": real})
          b.output_geometry(b.join([b.geo_in, res]))
          return b.done()
      
      
      def curve_array_tree(name="BX Curve Array"):
          """Array an object along the modified curve at a fixed spacing: instance X along the tangent,
          Z along the curve normal, optional alternating twist around the tangent (chain links: 90
          degrees), random scale, keep or drop the curve, optional realize."""
          b = Builder(name)
          inst = b.input("Instance", "OBJECT")
          spacing = b.input("Spacing", "FLOAT", 0.25, min=0.001, subtype="DISTANCE")
          scale = b.input("Scale", "FLOAT", 1.0, min=0.0)
          rscale = b.input("Random Scale", "FLOAT", 0.0, min=0.0, max=1.0, subtype="FACTOR")
          twist = b.input("Alternate Twist", "FLOAT", 0.0, subtype="ANGLE", description="extra roll on every other instance")
          seed = b.input("Seed", "INT", 0)
          keep = b.input("Keep Curve", "BOOL", False, panel="Output")
          realize = b.input("Realize", "BOOL", False, panel="Output")
      
          pts = b.node("CurveToPoints", mode="LENGTH", ins={"Curve": b.geo_in, "Length": spacing})
          a1 = b.node("AlignRotationToVector", axis="X", ins={"Vector": (pts, "Tangent")})
          a2 = b.node("AlignRotationToVector", axis="Z", pivot_axis="X", ins={"Rotation": a1, "Vector": (pts, "Normal")})
          odd = b.node("Math", operation="FLOORED_MODULO", ins={0: b.node("Index"), 1: 2.0})
          roll = b.node("Math", operation="MULTIPLY", ins={0: odd, 1: twist})
          rot = b.node("RotateRotation", rotation_space="LOCAL",
                       ins={"Rotation": a2,
                            "Rotate By": b.node("EulerToRotation", ins={"Euler": b.node("CombineXYZ", ins={"X": roll})})})
          lo = b.node("Math", operation="SUBTRACT", ins={0: 1.0, 1: rscale})
          hi = b.node("Math", operation="ADD", ins={0: 1.0, 1: rscale})
          rnd = b.node("RandomValue", data_type="FLOAT", ins={"Min": lo, "Max": hi, "Seed": seed})
          s = b.node("Math", operation="MULTIPLY", ins={0: scale, 1: rnd})
          oi = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": inst, "As Instance": True})
          iop = b.node("InstanceOnPoints", ins={"Points": pts, "Instance": (oi, "Geometry"), "Rotation": rot, "Scale": s})
          real = b.node("RealizeInstances", ins={"Geometry": iop})
          arr = b.node("Switch", input_type="GEOMETRY", ins={"Switch": realize, "False": iop, "True": real})
          both = b.join([b.geo_in, arr])
          res = b.node("Switch", input_type="GEOMETRY", ins={"Switch": keep, "False": arr, "True": both})
          b.output_geometry(res)
          return b.done()
      
      
      def growth_tree(name="BX Growth"):
          """Repeat-zone growth: random faces extrude along (normal + upward bias) for N iterations,
          each step shorter (Taper) and each tip scaled down, then subdivided and smoothed.
          Face count grows linearly: faces = F0 + sides_per_face * selected * iterations."""
          b = Builder(name)
          iters = b.input("Iterations", "INT", 6, min=0, max=64)
          step0 = b.input("Step", "FLOAT", 0.25, min=0.0, subtype="DISTANCE")
          taper = b.input("Taper", "FLOAT", 0.8, min=0.05, max=1.0, subtype="FACTOR")
          prob = b.input("Probability", "FLOAT", 0.3, min=0.0, max=1.0, subtype="FACTOR")
          upward = b.input("Upward", "FLOAT", 0.5, description="bias of the growth direction toward +Z (object space)")
          seed = b.input("Seed", "INT", 0)
          level = b.input("Smooth Level", "INT", 1, min=0, max=3)
      
          start = b.node("RandomValue", data_type="BOOLEAN", ins={"Probability": prob, "Seed": seed})
          zi, zo = b.zone("REPEAT", items=[("BOOLEAN", "Tip"), ("FLOAT", "Step")], iterations=iters)
          b.set(zi, "Geometry", b.geo_in)
          b.set(zi, "Tip", start)
          b.set(zi, "Step", step0)
          bias = b.node("CombineXYZ", ins={"Z": upward})
          d = b.node("VectorMath", operation="NORMALIZE",
                     ins={0: b.node("VectorMath", operation="ADD", ins={0: b.node("Normal"), 1: bias})})
          ex = b.node("ExtrudeMesh", mode="FACES", ins={"Mesh": (zi, "Geometry"), "Selection": (zi, "Tip"), "Offset": d,
                                                         "Offset Scale": (zi, "Step"), "Individual": True})
          sc = b.node("ScaleElements", domain="FACE", ins={"Geometry": ex, "Selection": (ex, "Top"), "Scale": taper})
          nxt = b.node("Math", operation="MULTIPLY", ins={0: (zi, "Step"), 1: taper})
          b.link(sc, (zo, "Geometry"))
          b.link((ex, "Top"), (zo, "Tip"))
          b.link(nxt, (zo, "Step"))
          sub = b.node("SubdivisionSurface", ins={"Mesh": (zo, "Geometry"), "Level": level})
          smooth = b.node("SetShadeSmooth", ins={"Geometry": sub})
          b.output_geometry(smooth)
          return b.done()
      
      
      def particle_drop_tree(name="BX Particle Drop"):
          """Simulation zone: points emitted in a world-space box for N frames fall under world gravity,
          bounce on a world ground plane (restitution, friction) and settle. New points are appended
          at the END of the state (Join order), each with a stable 'pid'. Position and velocity are
          computed together in one Capture Attribute so neither reads the other's new value.
          Outputs the modified geometry plus icosphere instances (Material input)."""
          b = Builder(name)
          rate = b.input("Rate", "INT", 20, min=0, description="points emitted per frame")
          nframes = b.input("Emit Frames", "INT", 20, min=0)
          center = b.input("Emit Center", "VECTOR", (0.0, 0.0, 3.0), description="world space")
          size = b.input("Emit Size", "VECTOR", (2.0, 2.0, 0.5))
          grav = b.input("Gravity", "FLOAT", 9.81, panel="Physics")
          rest = b.input("Restitution", "FLOAT", 0.45, min=0.0, max=1.0, subtype="FACTOR", panel="Physics")
          fric = b.input("Friction", "FLOAT", 0.3, min=0.0, max=1.0, subtype="FACTOR", panel="Physics")
          ground = b.input("Ground Height", "FLOAT", 0.0, description="world Z", panel="Physics")
          rad = b.input("Radius", "FLOAT", 0.06, min=0.001, subtype="DISTANCE")
          seed = b.input("Seed", "INT", 0)
          mat = b.input("Material", "MATERIAL")
      
          info = b.node("ObjectInfo", transform_space="ORIGINAL", ins={"Object": b.node("SelfObject")})
          to_world = (info, "Transform")
          to_local = (b.node("InvertMatrix", ins={"Matrix": to_world}), "Matrix")
          zi, zo = b.zone("SIMULATION")        # nothing into zi Geometry: the state starts empty
      
          # emission, world-space box converted to object space
          frame = (b.node("SceneTime"), "Frame")
          emitting = b.node("Compare", data_type="FLOAT", operation="LESS_EQUAL", ins={"A": frame, "B": nframes})
          count = b.node("Switch", input_type="INT", ins={"Switch": emitting, "False": 0, "True": rate})
          fseed = b.node("Math", operation="MULTIPLY_ADD", ins={0: seed, 1: 7919.0, 2: frame})
          jitter = b.node("RandomValue", data_type="FLOAT_VECTOR",
                          ins={"Min": (-0.5, -0.5, -0.5), "Max": (0.5, 0.5, 0.5), "Seed": fseed})
          wpos = b.node("VectorMath", operation="MULTIPLY_ADD", ins={0: jitter, 1: size, 2: center})
          newp = b.node("Points", ins={"Count": count, "Position": b.node("TransformPoint", ins={"Vector": wpos,
                                                                                                 "Transform": to_local}),
                                       "Radius": rad})
          born = b.node("Math", operation="SUBTRACT", ins={0: frame, 1: 1.0})
          pid = b.node("Math", operation="MULTIPLY_ADD", ins={0: born, 1: rate, 2: b.node("Index")})
          newp = b.node("StoreNamedAttribute", data_type="INT", domain="POINT",
                        ins={"Geometry": newp, "Name": "pid", "Value": pid})
          state = b.join([(zi, "Geometry"), newp])          # existing points first, new ones appended
      
          # integrate in world space (semi-implicit Euler), collide with the ground plane
          dt = (zi, "Delta Time")
          vel = b.node("NamedAttribute", data_type="FLOAT_VECTOR", ins={"Name": "velocity"})
          g = b.node("CombineXYZ", ins={"Z": b.node("Math", operation="MULTIPLY", ins={0: grav, 1: -1.0})})
          v1 = b.node("VectorMath", operation="MULTIPLY_ADD", ins={0: g, 1: dt, 2: (vel, "Attribute")})
          pw = b.node("TransformPoint", ins={"Vector": b.node("Position"), "Transform": to_world})
          p1 = b.node("VectorMath", operation="MULTIPLY_ADD", ins={0: v1, 1: dt, 2: pw})
          sp1 = b.node("SeparateXYZ", ins={"Vector": p1})
          floor = b.node("Math", operation="ADD", ins={0: ground, 1: rad})
          hit = b.node("Compare", data_type="FLOAT", operation="LESS_THAN", ins={"A": (sp1, "Z"), "B": floor})
          p_hit = b.node("CombineXYZ", ins={"X": (sp1, "X"), "Y": (sp1, "Y"), "Z": floor})
          p2 = b.node("Switch", input_type="VECTOR", ins={"Switch": hit, "False": p1, "True": p_hit})
          sv = b.node("SeparateXYZ", ins={"Vector": v1})
          keep = b.node("Math", operation="SUBTRACT", ins={0: 1.0, 1: fric})
          bounce = b.node("Math", operation="MULTIPLY", ins={0: (sv, "Z"), 1: b.node("Math", operation="MULTIPLY",
                                                                                      ins={0: rest, 1: -1.0})})
          v_hit = b.node("CombineXYZ", ins={"X": b.node("Math", operation="MULTIPLY", ins={0: (sv, "X"), 1: keep}),
                                            "Y": b.node("Math", operation="MULTIPLY", ins={0: (sv, "Y"), 1: keep}),
                                            "Z": bounce})
          v2 = b.node("Switch", input_type="VECTOR", ins={"Switch": hit, "False": v1, "True": v_hit})
          cap = b.node("CaptureAttribute", domain="POINT")
          b.items(cap, "capture_items", [("VECTOR", "p"), ("VECTOR", "v")])
          b.set(cap, "Geometry", state)
          b.set(cap, "p", p2)
          b.set(cap, "v", v2)
          stv = b.node("StoreNamedAttribute", data_type="FLOAT_VECTOR", domain="POINT",
                       ins={"Geometry": (cap, "Geometry"), "Name": "velocity", "Value": (cap, "v")})
          setp = b.node("SetPosition", ins={"Geometry": stv, "Position": b.node("TransformPoint", ins={
              "Vector": (cap, "p"), "Transform": to_local})})
          b.link(setp, (zo, "Geometry"))
      
          ball = b.node("MeshIcoSphere", ins={"Radius": 1.0, "Subdivisions": 2})
          iop = b.node("InstanceOnPoints", ins={"Points": (zo, "Geometry"), "Instance": ball, "Scale": rad})
          shaded = b.node("SetMaterial", ins={"Geometry": iop, "Material": mat})
          b.output_geometry(b.join([b.geo_in, shaded]))
          return b.done()
      
  • SKILL.md 16 KB
    ---
    name: scenario-blender-geometry-nodes
    description: "Use when building or debugging Geometry Nodes, headless or live: procedural modeling, scattering objects on a surface, arrays along a curve, repeat or for-each loops, simulation zones, 5.2 physics (cloth, colliders, forces, effectors), closures, bundles, matrices, node-group assets with gizmos. Also when a GN script links nothing, shows red links, ignores modifier inputs, loses instances in a bounding box, or a simulation resets or jumps."
    license: MIT
    ---
    
    # Blender Geometry Nodes (procedural systems and simulations)
    
    Expert Geometry Nodes work means thinking in fields evaluated on domains, keeping systems declarative (data and instructions come in from outside), and shipping tools that artists drive from modifier inputs without opening the tree. For an agent it also means proving every tree with numbers read from the evaluated geometry and a render, because the node editor's red links and warnings are invisible from Python unless you ask for them. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    **REQUIRED BACKGROUND:** scenario-blender-expert (execution channel, review loop, 5.2 API changes). Hair node groups (Interpolate, Clump, Curl, Hair Dynamics grooms): scenario-blender-hair.
    
    ## Stance (the expert delta)
    
    - **A field is a deferred question, not data.** Thommes: it is evaluated by the node that consumes it, on that node's domain and geometry; values exist only once named or consumed. Consequence [added, verified]: two coupled updates (position and velocity) must be captured together in one Capture Attribute, or the second reads the first's new value.
    - **Domain changes interpolate; everything becomes a Boolean by a rule.** Erindale: float > 0 is true (vectors: any non-zero), so a subdivided vertex-group selection bleeds; decide with Float to Integer or Compare with epsilon. Prefer trait selections (position, normal, proximity, raycast) over index ones, unless you generated the geometry and its order is the design (the Blender Studio matrix video drives a stack from Index == 0).
    - **Simulation only when history matters.** Thommes: a simulation is rules applied to state; an animation is a function of time. If nothing depends on the previous frame, drive it from Scene Time and get scrubbing and sub-frames free.
    - **Loops are a last resort.** Thommes: built-in nodes working on all elements use acceleration structures, loops cannot. For-each is for black-box generators run once per element: field outside, single value inside.
    - **Everything is object-local.** Thommes: a (0, 0, -1) vector rotates with the object. World directions go through Self Object > Object Info > Invert Matrix > Transform Direction.
    - **Tools, not trees.** Thommes: expose every scene dependency and tweakable value as a group input, never hard-reference an object inside a reusable tree, and drive gizmos only from Group Input sockets, through invertible math, into the first input of each math node.
    - **5.2 physics is declarative.** Thommes (BCON26): forces, colliders and constraints travel as bundles and closures into a black-box solver. Customize from outside first (inputs, bundles, Custom Effector closures); make the asset local only to edit a built-in constraint. Any per-step custom attribute must be listed in Extra Sim Attributes, and topology-changing sims must append new elements at the end.
    
    ## Establish first
    
    | Question                                                             | Why it changes the plan                                                        | Default when silent                                          |
    | -------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ |
    | What consumes the result: shader, another modifier, export, physics? | Crossing a boundary needs a named attribute; export may need realized geometry | Named attributes for anything a shader reads; keep instances |
    | Does it depend on the previous frame?                                | Simulation zone and baking vs Scene Time function                              | Function of time                                             |
    | One-off authoring step or reusable asset?                            | One-off may be applied (Thommes' hair noise); assets never are                 | Reusable: expose inputs, panels, sensible min/max            |
    | Scale: element counts, viewport cost                                 | Realize, loops and simulations cost; instances are cheap                       | Instances, fields, no loops                                  |
    | World or object space for directions and ground                      | Object-local trap                                                              | World up, world gravity                                      |
    
    ## Workflow
    
    1. **Plan the data flow.** Name the component and domain each value lives on, where each field gets evaluated, and which primitive fits: plain fields first, Accumulate Field for per-group sums, repeat zone for N dependent passes, for-each for a black-box generator per element, simulation zone for state, closure to inject behavior, bundle to pass many named values. GATE: you can say for every field which node evaluates it.
    2. **Build with `bx_gn.Builder`** (headless or live, idempotent: a tree of the same name is rebuilt in place). `b.node(kind, prop=..., ins={...})` sets properties before inputs, links sources, assigns constants; `b.zone(...)` pairs zones and adds items; `b.join([...])` keeps list order. GATE: `b.done()` returns (zones paired, every link created).
    3. **Attach and set inputs** with `G.attach(obj, tree, inputs={...})` or `G.set_inputs(md, {...})`; `"attr:NAME"` switches a field input to an attribute or vertex group. GATE: `G.check(md)["ok"]`, no `hard_refs` on an asset.
    4. **Measure.** `G.stats(obj)` (counts, attributes per domain, world bbox with instances), `G.attribute`, `G.instance_transforms`, `G.positions`. Then the two robustness probes: rotate the object 90 degrees (world-space effects keep direction) and double the input resolution (trait selections scale, index ones jump). GATE: numbers match the analytic expectation.
    5. **Look.** `G.snapshot(objs, path, views=..., frames=...)` renders the current scene state with material colors and shadows (floating instances show); `bx_review.review` for realized meshes. Open every sheet. GATE: [`references/critique.md`](references/critique.md) visual items pass.
    6. **Simulations.** Step every frame in order with `G.step(range(...))`, or `G.bake(obj)` for random access; assert bounds, monotonic states, counts, no NaN. GATE: a direct jump to a late frame equals the stepped result only after a bake.
    7. **Package** (assets): inputs grouped in panels with defaults, min/max and subtypes; `tree.is_modifier = True; tree.asset_mark()`; drop it on a different object (Thommes' Suzanne test). GATE: works on the second object with no tree edit.
    
    Recipes in [`scripts/bx_gn.py`](scripts/bx_gn.py), tested and rendered: `scatter_tree` (density x mask, world-space slope limit, upright or aligned, pick from object and collection), `curve_array_tree` (tangent-aligned array, alternating twist makes a chain), `growth_tree` (repeat-zone extrusion), `particle_drop_tree` (simulation zone with emission, world gravity, ground bounce). Prefer the shipped Essentials assets (Scatter on Surface, Array, Instance on Elements, Randomize Transforms) when their inputs cover the brief; build your own when you need world-space rules, masks or custom attributes.
    
    ## Numbers
    
    | Value                                                                                                                     | What it is relative to                              | Source            |
    | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------- |
    | Melt sim: 300 K ambient, 2000 K source, heating 0.05, cooling 0.01 per frame                                              | per-step Mix factors; small because they accumulate | Thommes BCON23    |
    | Blur Attribute weight 0.5; smooth 5 iterations; melt mask 500 to 2000 K                                                   | heat diffusion and softening                        | Thommes BCON23    |
    | Drip 0.01 per frame along -Z; normal mix 0.1 (0.5 "looks like baking bread")                                              | per-step offset                                     | Thommes BCON23    |
    | Tearing threshold 1 = tears as soon as longer than rest (too sensitive); reinforce 10; base random 1 to 1.1; Voronoi mode | edge length ratio                                   | Thommes BCON26    |
    | Sewing ramp: rest length x factor 1 to 0 over 1 s of accumulated Delta Time                                               | avoids snapping                                     | Thommes BCON26    |
    | Threads on tears: about 2 cm, 3 points each                                                                               | performance cap                                     | Thommes BCON26    |
    | Cloth asset defaults: Substeps 5, Constraint Steps 15, Collision Radius 0.01, Tearing Threshold 1.2                       | read from the 5.2.1 asset                           | digest [verified] |
    | Gizmo position = size x 0.5 for a centered grid; random factor 0.2 to 1 on the SECOND input                               | keeps drags glued                                   | Thommes BCON24    |
    | Stack: box origin at its bottom; random scale XY 1 to 1.5, Z 0.05 to 0.3                                                  | Accumulate Field Trailing                           | Matrix video      |
    | Flatten regions: island min 5 faces; proximity falloff 0 to 0.2 m, Smoother Step; Volume to Mesh threshold about 0.5      | Erindale exercise                                   | Erindale          |
    | Realize Instances: Realize All off, Depth 1                                                                               | flattens one hierarchy level                        | Thommes BCON24    |
    
    ## Quality gates
    
    Measurable (code):
    
    - `G.check(md)`: `ok` (no invalid link, no ERROR warning, zones paired), `hard_refs` empty for assets, Warning nodes you added report as expected.
    - Counts match expectation: scatter count within about 12 percent of density x mask x eligible area [verified test]; curve array count = floor(length / spacing); repeat growth faces = F0 + sides x selected x iterations.
    - Attributes exist on the right domain and type, values in range (`G.attribute`).
    - Rotation probe and resolution probe (step 4).
    - Simulation: counts per frame, bounds (nothing below the ground), prefix-stable ids when appending, energy or state monotonic where it must be, baked jump equals stepped.
    - `G.time_eval(obj)` for hot trees; flag repeat or for-each zones in them.
    
    Visual (`G.snapshot`, `bx_review`): scatter density reads natural and nothing floats or sinks; arrays follow the curve without flips; growth has no self-intersection at the base; simulation strip shows motion then rest; shader-driven attributes checked at render resolution. Gizmo feel is GUI only.
    
    ## Common mistakes
    
    | Mistake                                                  | What it looks like                                            | Fix                                                                          |
    | -------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------- |
    | `tree.links.new` across trees                            | returns None, nothing links, no error                         | `Builder.link` raises; never reuse a bound `links.new` after switching trees |
    | Setting `md.properties.inputs.<id>.value` only           | result unchanged                                              | `obj.update_tag()` (or `G.set_inputs`)                                       |
    | Random Value with its implicit ID inside a for-each zone | red link, output silently wrong; `md.node_warnings` empty     | zone Index into ID; `G.check` lists `invalid_links`                          |
    | Same Seed on two Random Value nodes                      | scale and rotation move together (correlation 1.0 [verified]) | offset seeds (seed + k)                                                      |
    | State not wired Simulation Input to Output               | value resets each frame                                       | wire every state item through                                                |
    | Custom attribute inside Cloth or Hair Dynamics           | resets every step, no warning                                 | list it in Extra Sim Attributes                                              |
    | Hard-referenced controller object in an asset            | works once, breaks on reuse                                   | Object socket on the Group Input                                             |
    | Coupled fields updated by two nodes                      | velocity computed from the already-moved position             | one Capture Attribute for both                                               |
    | Join order assumed = link order                          | new elements inserted first, solver or ids break              | `b.join([state, new])`                                                       |
    | Local direction used as world                            | effect rotates with the object                                | Self Object matrix conversion                                                |
    | Closure signature by auto-population                     | ERROR "Closure does not have output"                          | create both item lists, same names                                           |
    
    ## Blender 5.2 notes
    
    - Modifier inputs: `md.properties.inputs.<Socket_N>.value` (old `md["Socket_N"]` raises TypeError), then `obj.update_tag()`.
    - Menus are input sockets: Transform Geometry `Mode` = 'Matrix', Resample Curve `Mode`, Custom Force `Mode`, Custom Effector `Stage` (menu `Socket_1`; a string input is also named Stage, `Socket_2`).
    - `node.inputs["A"]` on a Mix returns the available socket in 5.2.1; duplicate enabled names (Cloth's two `Gravity`) still need identifiers. `bx_gn.sock` raises with the identifiers.
    - Join Geometry puts the last created link first [verified].
    - Evaluated `obj.bound_box` ignores instances; the instance point cloud's `position` attribute reads zeros, use `instance_transform` [verified]. `md.execution_time` stays 0.0 headless.
    - Simulation: the first frame already runs the zone body; a jump past the cache computes one step; `simulation_nodes_cache_calculate_to_frame` returns PASS_THROUGH headless and computes nothing; `simulation_nodes_cache_bake` works (packed) [verified].
    - Lists (Field to List, Get List Item), Bundles, Closures and `GeometryNodeXPBDSolver` exist; modal node tools do not (experimental builds only).
    - Shipped assets: `<DATAFILES>/assets/nodes/geometry_nodes_essentials.blend`, `geometry_nodes_dynamics_assets.blend`, `procedural_hair_node_assets.blend` (`G.append_group(name, library)`).
    
    ## References
    
    - [`references/expert-notes.md`](references/expert-notes.md): Thommes, Erindale and Johnny Matthews principles with timestamps; load when designing a system or an asset.
    - [`references/procedures.md`](references/procedures.md): tested code: raw-bpy procedures A to H (attribute pipeline, float simulation, repeat expand, for-each, closures, matrix stacking, gizmos, cloth) and the bx_gn recipes and physics tests; load before writing any tree.
    - `references/critique.md`: the rubric to judge a tree, an asset and a simulation; load at every gate.
    - [`references/sources.md`](references/sources.md): the eight source videos, what each is best for, best timestamps.
    - `scripts/bx_gn.py`: builder, modifier, evaluation, simulation, snapshot helpers and recipes (docstrings list the traps).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related