From 868c9c8a491db77dadcd2ad91ec26e3b1ea1c8cf Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:37:16 -0400 Subject: [PATCH] feat: witness Geometry Nodes bundle round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5.x RNA is NodeCombineBundle; 4.5 skips via min_version 5.0. Count-only is not enough — bbox and named attribute are the second and third axes. Standing scalar-second-axis rule added to the example prompt. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 + AGENTS.md | 4 +- CLAUDE.md | 4 +- README.md | 20 +- ROADMAP.md | 1 + docs/new-example-prompt.md | 6 + docs/technical-audit.md | 12 + examples/gn-bundle-roundtrip/README.md | 45 ++++ .../gn_bundle_roundtrip.py | 240 ++++++++++++++++++ skills/geometry-nodes-python/SKILL.md | 40 ++- tests/smoke/catalog.json | 3 +- 11 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 examples/gn-bundle-roundtrip/README.md create mode 100644 examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c6fd7ea..1fae905 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -76,6 +76,7 @@ "examples/driver-wave", "examples/gltf-export-roundtrip", "examples/gltf-skin-roundtrip", + "examples/gn-bundle-roundtrip", "examples/gn-instance-grid", "examples/gn-modifier-inputs", "examples/gn-sdf-remesh", diff --git a/AGENTS.md b/AGENTS.md index 7bb2278..73c9e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 48 +The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 49 examples (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; the render look is specified @@ -35,7 +35,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 48 runnable smoke-gated examples (+ gallery.json) + examples// # 49 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) diff --git a/CLAUDE.md b/CLAUDE.md index 95277d7..508f026 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 13 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 48 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 49 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -83,7 +83,7 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (48) +## Examples (49) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via diff --git a/README.md b/README.md index 3a8e79e..658144d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 13 skills  •  6 rules  •  2 templates  •  17 snippets  •  48 examples + 13 skills  •  6 rules  •  2 templates  •  17 snippets  •  49 examples

@@ -377,7 +377,7 @@ inside the effect. Per-frame sample renders assert the closed form (mid dips

-Geometry Nodes — 4 examples +Geometry Nodes — 5 examples @@ -436,6 +436,22 @@ evaluated cubes against closed forms — Repeat `8×(1+N)` with X-centers at nodes exist. Unpaired evaluates empty; For Each's main Geometry socket is a passthrough. + + + + +
+check-only, no gallery still — item-name round-trip is not thumbnail-legible + + +### [gn-bundle-roundtrip](examples/gn-bundle-roundtrip/) + +Combine / Separate Bundle on 5.x (`NodeCombineBundle`, not +`GeometryNodeCombineBundle`). Asserts evaluated 8/6 plus x-extent +`[0.5, 2.5]` and POINT `bundle_mark=0.314159` — count-only is a cube that +never entered the bundle. Skips 4.5 (`min_version` 5.0); `--force-run` +fails creating the 5.x RNA. +
diff --git a/ROADMAP.md b/ROADMAP.md index f6d92b4..83a0b30 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -94,6 +94,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Custom ID-property delete witness~~ **SHIPPED** as `examples/cross-version-property-delete/` — IDs built via `bpy.data.objects.new` (not `active_object`); `property_unset` is TypeError and leaves the key; `del` removes it on 4.5 LTS and 5.x - ~~USD export evaluation_mode witness~~ **SHIPPED** as `examples/usd-export-evaluation-mode/` — probed `wm.usd_export` on CI Linux portables 5.2.1 (`9e2066aef7ef`) and 4.5.13 (`daeeeca98fb0`); TESSELLATE+VIEWPORT 26/24, TESSELLATE+RENDER 98/96; BEST_MATCH writes the 8-vert catmullClark cage so the mode is silent - ~~Repeat Zone / For Each Element pairing witness~~ **SHIPPED** as `examples/gn-zone-iterate/` — `pair_with_output` is load-bearing (unpaired evaluates 0 verts); Repeat `8×(1+N)` with X-centers at `k×STEP`; For Each `8×P` with Z-centers at `i×STEP`; For Each main Geometry is a passthrough (6 verts); count-only is insufficient (`--no-offset` still 32 verts, one X-center) +- ~~Geometry Nodes bundle round-trip~~ **SHIPPED** as `examples/gn-bundle-roundtrip/` — 5.x RNA `NodeCombineBundle` (`GeometryNodeCombineBundle` undefined on 5.2); closed form 8/6 + x `[0.5, 2.5]` + `bundle_mark=0.314159`; 4.5 SKIP via catalog `min_version` 5.0 (experimental flag off evaluates empty; `--force-run` hits 5.x RNA undefined) - Tighten inverted smoke canaries: assert the expected `[FAIL]` marker text (`skipped on ... should run` / `missing post-exit sidecar`) alongside wrapper exit 1, so a canary that dies for the wrong reason does not satisfy the gate - ~~Fleet Pages facelift + examples support~~ **RESOLVED differently (2026-07-03)**: the meta-repo migration was dropped — the fleet template only scaffolds new tools, and each tool's site evolves independently after that. This repo vendored the site build into `scripts/site/`, redesigned landing + gallery as the Blender-viewport system (see `docs/gallery/DESIGN_NOTES.md`), added the examples grid, nav link, and full hero stats locally. `scripts/build_gallery.py` and `examples/gallery.json` are now permanent, not a prototype awaiting lift-and-shift. - `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns diff --git a/docs/new-example-prompt.md b/docs/new-example-prompt.md index 9121d45..2186a59 100644 --- a/docs/new-example-prompt.md +++ b/docs/new-example-prompt.md @@ -35,6 +35,12 @@ you write code: pose, swap the buffer, use the legacy path) and confirm the check exits non-zero, then restore it. An assertion that cannot fail witnesses nothing. State in the README what failure each check would catch. +- When the primary assertion is a scalar (a vert count, a file size, a single + float), check whether a broken construction can produce the same scalar. If + it can, the scalar is not a sufficient witness — add a second axis. Worked + example: `examples/gn-zone-iterate/` `--no-offset` still hits Repeat + `8×(1+N)` verts by joining N+1 cubes at the origin; X-centers are the + second axis. Count-only would have been green. - Its correctness is independently derivable. The best checks compare Blender's output against a closed-form or independently computed expectation (re-implemented math, a round-trip through a raw buffer, a known geometric diff --git a/docs/technical-audit.md b/docs/technical-audit.md index eab1a00..d6247aa 100644 --- a/docs/technical-audit.md +++ b/docs/technical-audit.md @@ -60,6 +60,18 @@ are 4.3+). Not a version split. - Taught: `skills/geometry-nodes-python/SKILL.md` - Witness: `examples/gn-zone-iterate/` +### 2026-09-09 — Bundle RNA renamed; 4.5 is experimental + +5.x Combine / Separate Bundle RNA is `NodeCombineBundle` / +`NodeSeparateBundle`. `GeometryNodeCombineBundle` is undefined on 5.2 +and is the 4.5 experimental id. 4.5 LTS evaluates empty unless +`preferences.experimental.use_bundle_and_closure_nodes` is on; with the +flag the same closed form lands. Official contract is 5.0+. Item names +on `bundle_items` are load-bearing. Vert count alone is not a witness. + +- Taught: `skills/geometry-nodes-python/SKILL.md` +- Witness: `examples/gn-bundle-roundtrip/` + ### Contrast — NodesModifier dict assignment (5.2, was in the notes) `mod[identifier] = value` raises `TypeError` on 5.2 (`id properties not diff --git a/examples/gn-bundle-roundtrip/README.md b/examples/gn-bundle-roundtrip/README.md new file mode 100644 index 0000000..6964e78 --- /dev/null +++ b/examples/gn-bundle-roundtrip/README.md @@ -0,0 +1,45 @@ +# Geometry Nodes bundle round-trip + +Combine Bundle / Separate Bundle packing a cube plus Scale, Offset, and +Mark — witnessing [`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md) +item-name round-trip, not tree structure. + +5.x RNA is `NodeCombineBundle` / `NodeSeparateBundle`. +`GeometryNodeCombineBundle` is the **4.5 experimental** id and is undefined +on 5.2. 4.5 LTS keeps the old names behind +`preferences.experimental.use_bundle_and_closure_nodes` (default **off**); +unflagged evaluation is empty. This example skips on 4.5 +(`SMOKE_SKIP: Bundles require Blender 5.0+`, exit 77, catalog +`min_version` 5.0). `--force-run` bypasses the skip and creates the 5.x +RNA so 4.5 fails for that reason — not a hidden pass. + +Closed form (1 m cube, Scale=2, Offset=`(1.5, 0, 0)`, Mark=`0.314159`): + +- verts/faces = 8/6 +- x-extent `[0.5, 2.5]` +- POINT `bundle_mark` = `0.314159` on all 8 verts + +**Count alone is not enough.** A cube that never entered the bundle is +still 8/6 (`--bypass`). Bbox from the unpacked Scale/Offset is the second +axis; the named attribute from the unpacked Float is the third. + +No gallery still. The contract is item names + typed round-trip; the +visible mesh is one cube and the attribute is invisible. Failure would +not read at thumbnail. + +**What failure each check would catch:** + +- exit 77 — Blender < 5.0 and not `--force-run` +- exit 2 — `GeometryNodeCombineBundle` on 5.x (`--legacy-rna`) +- exit 3 — pairing/names broken (`--mismatch` → 0/0; `--force-run` on 4.5 + raises `NodeCombineBundle` undefined, caught as exit 1) +- exit 4 — count matches but unpacked Scale/Offset did not (`--bypass`, + `--pack-scale 1`) +- exit 5 — bbox matches but `bundle_mark` did not travel through the bundle + +## Run + +```bash +blender --background --python gn_bundle_roundtrip.py -- +blender --background --python gn_bundle_roundtrip.py -- --force-run +``` diff --git a/examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py b/examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py new file mode 100644 index 0000000..e1d999e --- /dev/null +++ b/examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py @@ -0,0 +1,240 @@ +"""Geometry Nodes bundle round-trip — a runnable example. + +Witnesses Combine / Separate Bundle from ``skills/geometry-nodes-python``. +The load-bearing RNA on 5.x is ``NodeCombineBundle`` / ``NodeSeparateBundle``, +not ``GeometryNodeCombineBundle`` (that id is 4.5 experimental and +**undefined** on 5.2). Tree-structure checks are vacuous: the nodes can +exist and be linked while Separate looks up the wrong item names and +evaluates empty. + +Closed form (1 m cube packed with Scale=2, Offset=(1.5, 0, 0), Mark=0.314159): + +* verts / faces = 8 / 6 +* x-extent [0.5, 2.5] (scale then translate) +* POINT attribute ``bundle_mark`` = 0.314159 on every vert + +Count alone is not enough: a cube that never entered the bundle is still +8/6. Bbox from the unpacked Scale/Offset is the second axis; the named +attribute from the unpacked Float is the third. + +5.0+ official. 4.5 LTS has the older RNA behind +``preferences.experimental.use_bundle_and_closure_nodes`` (default off); +this example skips there via catalog ``min_version`` 5.0. ``--force-run`` +bypasses the skip and uses the 5.x RNA so 4.5 fails for that reason. + + blender --background --python gn_bundle_roundtrip.py -- +""" +import argparse +import sys + +import bpy + +COMBINE_ID = "NodeCombineBundle" +SEPARATE_ID = "NodeSeparateBundle" + +CUBE_VERTS = 8 +CUBE_FACES = 6 +PACK_SCALE = 2.0 +PACK_OFFSET = (1.5, 0.0, 0.0) +PACK_MARK = 0.314159 +XMIN = 0.5 +XMAX = 2.5 +EPS = 1e-4 +MARK_EPS = 1e-6 +ATTR = "bundle_mark" +SKIP_REASON = "Bundles require Blender 5.0+" + + +def eval_mesh(obj): + dg = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(dg) + me = ev.to_mesh() + try: + coords = [(v.co.x, v.co.y, v.co.z) for v in me.vertices] + faces = len(me.polygons) + marks = None + if ATTR in me.attributes: + data = me.attributes[ATTR].data + marks = [data[i].value for i in range(len(me.vertices))] + finally: + ev.to_mesh_clear() + return coords, faces, marks + + +def build_tree(pair=True, match_names=True, pack_scale=PACK_SCALE): + tree = bpy.data.node_groups.new("BundleRoundTrip", "GeometryNodeTree") + tree.interface.new_socket( + name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry", + ) + go = tree.nodes.new("NodeGroupOutput") + cube = tree.nodes.new("GeometryNodeMeshCube") + cube.inputs["Size"].default_value = (1.0, 1.0, 1.0) + + if not pair: + tree.links.new(cube.outputs["Mesh"], go.inputs["Geometry"]) + return tree + + comb = tree.nodes.new(COMBINE_ID) + sep = tree.nodes.new(SEPARATE_ID) + comb.bundle_items.new("GEOMETRY", "Mesh") + comb.bundle_items.new("FLOAT", "Scale") + comb.bundle_items.new("VECTOR", "Offset") + comb.bundle_items.new("FLOAT", "Mark") + sep_mesh = "Mesh" if match_names else "Geom" + sep.bundle_items.new("GEOMETRY", sep_mesh) + sep.bundle_items.new("FLOAT", "Scale") + sep.bundle_items.new("VECTOR", "Offset") + sep.bundle_items.new("FLOAT", "Mark") + + tree.links.new(cube.outputs["Mesh"], comb.inputs["Mesh"]) + comb.inputs["Scale"].default_value = pack_scale + comb.inputs["Offset"].default_value = PACK_OFFSET + comb.inputs["Mark"].default_value = PACK_MARK + tree.links.new(comb.outputs["Bundle"], sep.inputs["Bundle"]) + + xf = tree.nodes.new("GeometryNodeTransform") + xyz = tree.nodes.new("ShaderNodeCombineXYZ") + store = tree.nodes.new("GeometryNodeStoreNamedAttribute") + store.data_type = "FLOAT" + store.domain = "POINT" + store.inputs["Name"].default_value = ATTR + + tree.links.new(sep.outputs[sep_mesh], xf.inputs["Geometry"]) + tree.links.new(sep.outputs["Scale"], xyz.inputs["X"]) + tree.links.new(sep.outputs["Scale"], xyz.inputs["Y"]) + tree.links.new(sep.outputs["Scale"], xyz.inputs["Z"]) + tree.links.new(xyz.outputs["Vector"], xf.inputs["Scale"]) + tree.links.new(sep.outputs["Offset"], xf.inputs["Translation"]) + tree.links.new(xf.outputs["Geometry"], store.inputs["Geometry"]) + tree.links.new(sep.outputs["Mark"], store.inputs["Value"]) + tree.links.new(store.outputs["Geometry"], go.inputs["Geometry"]) + return tree + + +def build(pair, match_names, pack_scale): + bpy.ops.wm.read_factory_settings(use_empty=True) + me = bpy.data.meshes.new("Carrier") + me.vertices.add(1) + ob = bpy.data.objects.new("Carrier", me) + bpy.context.collection.objects.link(ob) + mod = ob.modifiers.new("GN", "NODES") + mod.node_group = build_tree(pair=pair, match_names=match_names, pack_scale=pack_scale) + bpy.context.view_layer.update() + return ob + + +def check(ob): + if len(ob.data.vertices) != 1: + print("ERROR: carrier mesh was rewritten", file=sys.stderr) + return 2 + + coords, faces, marks = eval_mesh(ob) + xs = [c[0] for c in coords] + print( + f"verts={len(coords)} faces={faces} " + f"xmin={min(xs) if xs else None} xmax={max(xs) if xs else None} " + f"mark={marks[0] if marks else None} nmark={len(marks) if marks else 0} " + f"expect verts={CUBE_VERTS}/{CUBE_FACES} x=[{XMIN},{XMAX}] mark={PACK_MARK}" + ) + + if len(coords) != CUBE_VERTS or faces != CUBE_FACES: + print( + f"ERROR: evaluated {len(coords)}/{faces}, closed form {CUBE_VERTS}/{CUBE_FACES}", + file=sys.stderr, + ) + return 3 + if abs(min(xs) - XMIN) > EPS or abs(max(xs) - XMAX) > EPS: + print( + f"ERROR: x-extent [{min(xs):.4f}, {max(xs):.4f}] " + f"closed form [{XMIN}, {XMAX}] from Scale={PACK_SCALE} Offset={PACK_OFFSET}", + file=sys.stderr, + ) + return 4 + if marks is None or len(marks) != CUBE_VERTS: + print("ERROR: missing POINT attribute bundle_mark", file=sys.stderr) + return 5 + worst = max(abs(m - PACK_MARK) for m in marks) + if worst > MARK_EPS: + print( + f"ERROR: bundle_mark worst={worst:.3e} expected {PACK_MARK}", + file=sys.stderr, + ) + return 5 + print( + f"scale={PACK_SCALE} offset={PACK_OFFSET} mark={PACK_MARK} " + f"xmin={min(xs):.4f} xmax={max(xs):.4f} pairing=ok" + ) + return 0 + + +def maybe_skip(force_run): + if bpy.app.version >= (5, 0, 0): + return 0 + if force_run: + return 0 + print(f"SMOKE_SKIP: {SKIP_REASON}", flush=True) + return 77 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument( + "--force-run", + action="store_true", + help="bypass the 5.0 skip; 4.5 fails creating NodeCombineBundle", + ) + p.add_argument( + "--bypass", + action="store_true", + help="falsification: Group Output reads the cube, no bundle", + ) + p.add_argument( + "--mismatch", + action="store_true", + help="falsification: Separate item name Geom vs Combine Mesh", + ) + p.add_argument("--pack-scale", type=float, default=PACK_SCALE) + p.add_argument( + "--legacy-rna", + action="store_true", + help="falsification: create GeometryNodeCombineBundle (undefined on 5.x)", + ) + args = p.parse_args(argv) + + skipped = maybe_skip(args.force_run) + if skipped: + return skipped + + if args.legacy_rna: + bpy.ops.wm.read_factory_settings(use_empty=True) + tree = bpy.data.node_groups.new("Legacy", "GeometryNodeTree") + try: + tree.nodes.new("GeometryNodeCombineBundle") + except RuntimeError as exc: + print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + print( + "ERROR: GeometryNodeCombineBundle existed; 5.x RNA should reject it", + file=sys.stderr, + ) + return 2 + + ob = build( + pair=not args.bypass, + match_names=not args.mismatch, + pack_scale=args.pack_scale, + ) + code = check(ob) + if code: + return code + print("gn-bundle-roundtrip OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/skills/geometry-nodes-python/SKILL.md b/skills/geometry-nodes-python/SKILL.md index 7d939c3..0518583 100644 --- a/skills/geometry-nodes-python/SKILL.md +++ b/skills/geometry-nodes-python/SKILL.md @@ -227,9 +227,38 @@ for item in tree.interface.items_tree: After writing a modifier input, tag the object and update the view layer before `evaluated_get`. Otherwise the depsgraph still holds the previous scale. -## Bundles (5.0+) +## Bundles (5.0+ official) -Bundles let one socket carry a set of typed values, similar to a struct. Useful for passing multiple related fields between subtrees: +Bundles let one socket carry a set of typed values, similar to a struct. Useful for passing multiple related fields between subtrees. + +The 5.x RNA names are `NodeCombineBundle` / `NodeSeparateBundle`. +`GeometryNodeCombineBundle` is the **4.5 experimental** id and raises +`RuntimeError: Node type GeometryNodeCombineBundle undefined` on 5.2. +Items live on `node.bundle_items` (not `items` — that is a dict method): + +```python +comb = tree.nodes.new("NodeCombineBundle") +sep = tree.nodes.new("NodeSeparateBundle") +comb.bundle_items.new("GEOMETRY", "Mesh") +comb.bundle_items.new("FLOAT", "Scale") +sep.bundle_items.new("GEOMETRY", "Mesh") +sep.bundle_items.new("FLOAT", "Scale") +tree.links.new(comb.outputs["Bundle"], sep.inputs["Bundle"]) +``` + +Separate item **names** must match Combine. A linked tree whose Separate +looks up `Geom` instead of `Mesh` evaluates empty. + +Do not assert that the bundle nodes exist. Assert evaluated geometry +against a closed form, and do not stop at a vert count — a cube that +never entered the bundle is still 8 verts. Second axis: unpacked Scale / +Offset bbox. Third: a packed Float stored as a named attribute. + +4.5 LTS: the old `GeometryNodeCombineBundle` RNA exists behind +`preferences.experimental.use_bundle_and_closure_nodes` (default **off**). +With the flag off, evaluation is empty. With it on, the same closed form +lands. Official / CI contract is 5.0+; skip 4.5 rather than flipping +experimental preferences. ```python bundle_socket = tree.interface.new_socket( @@ -239,10 +268,6 @@ bundle_socket = tree.interface.new_socket( ) ``` -You then build a bundle inside the tree using the `Combine Bundle` node (`GeometryNodeCombineBundle`) with named slots, and unpack it with `Separate Bundle` (`GeometryNodeSeparateBundle`). - -Bundles are 5.0+ only; on 4.5 LTS you fall back to multiple separate sockets. - ## Zone pairing (Repeat / For Each Element) Repeat and For Each Element are **paired** input/output nodes. Creating both @@ -316,6 +341,8 @@ def has_for_each_element(): 7. **Creating Repeat / For Each input and output nodes without `pair_with_output`**. Unpaired zones do not iterate. For Each: wiring Group Output to the main `Geometry` socket ships the input mesh and looks linked. +8. **`tree.nodes.new("GeometryNodeCombineBundle")` on 5.x**. That id is 4.5 experimental. 5.x is `NodeCombineBundle` / `NodeSeparateBundle`. `bundle_items.new("FLOAT", "Name")` — `node.items` is a dict method, not the collection. + ## Worked example: replicate the "Mesh to SDF then Grid to Mesh" pipeline An SDF grid is meshed with **Grid to Mesh** (`GeometryNodeGridToMesh`), not **Volume to @@ -358,6 +385,7 @@ def build_remesh_via_sdf(voxel_size=0.05, threshold=0.0): - `addon-scaffolding` for shipping a tree-building script as part of an extension - Example `gn-zone-iterate` for Repeat / For Each pairing versus evaluated closed forms +- Example `gn-bundle-roundtrip` for Combine / Separate item-name round-trip (5.0+; skip 4.5) - `mesh-editing-and-bmesh` for reading the modifier-applied result via depsgraph ## References diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index b83948f..4902bef 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -56,5 +56,6 @@ {"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py"}, {"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py"}, {"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py"}, - {"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py"} + {"name": "gn-zone-iterate", "script": "examples/gn-zone-iterate/gn_zone_iterate.py"}, + {"name": "gn-bundle-roundtrip", "script": "examples/gn-bundle-roundtrip/gn_bundle_roundtrip.py", "min_version": "5.0"} ]