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
- 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
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"}
]
@@ -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.
+