diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index fb5ffff..7dc11e7 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -79,6 +79,7 @@ "examples/gn-instance-grid", "examples/gn-modifier-inputs", "examples/gn-sdf-remesh", + "examples/gn-zone-iterate", "examples/gp-lineart-contour", "examples/grease-pencil-rosette", "examples/image-pixels-testcard", diff --git a/AGENTS.md b/AGENTS.md index 613b3ed..7bb2278 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 47 +The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 48 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// # 47 runnable smoke-gated examples (+ gallery.json) + examples// # 48 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 07fd3a3..2e84c0a 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, 47 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 48 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 (47) +## Examples (48) 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 191720b..3a8e79e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -36,7 +36,7 @@ ## Overview -This repository ships **13 skills, 6 rules, 2 templates, 17 snippets, and 47 runnable examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **13 skills, 6 rules, 2 templates, 17 snippets, and 48 runnable examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -377,7 +377,7 @@ inside the effect. Per-frame sample renders assert the closed form (mid dips

-Geometry Nodes — 3 examples +Geometry Nodes — 4 examples @@ -420,6 +420,22 @@ One Geometry Nodes tree, three modifier copies. Writes a Float Scale input throu `mod.properties.inputs` on 5.2 and `mod[identifier]` on 4.5/5.1. Asserts readback and evaluated Z-extent equal 1 / 2 / 3. The 5.1 dict form raises TypeError on 5.2. + + + + +
+GN zone iterate: four brass cubes in a row (Repeat Zone) beside a tower of six teal cubes (For Each Element) on a dark studio floor, proving pair_with_output actually iterates + + +### [gn-zone-iterate](examples/gn-zone-iterate/) + +Repeat Zone and For Each Element only iterate after `pair_with_output`. Asserts +evaluated cubes against closed forms — Repeat `8×(1+N)` with X-centers at +`k×1.2`, For Each `8×P` with Z-centers at `i×0.6+0.21` — not that the zone +nodes exist. Unpaired evaluates empty; For Each's main Geometry socket is a +passthrough. +
diff --git a/ROADMAP.md b/ROADMAP.md index d5556bb..56d8850 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,6 +93,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) - 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/gallery/assets/gn-zone-iterate-hero.webp b/docs/gallery/assets/gn-zone-iterate-hero.webp new file mode 100644 index 0000000..7a8806e Binary files /dev/null and b/docs/gallery/assets/gn-zone-iterate-hero.webp differ diff --git a/docs/gallery/contact-sheets/gn-zone-iterate-contact-sheet.webp b/docs/gallery/contact-sheets/gn-zone-iterate-contact-sheet.webp new file mode 100644 index 0000000..e3d990b Binary files /dev/null and b/docs/gallery/contact-sheets/gn-zone-iterate-contact-sheet.webp differ diff --git a/docs/gallery/gn-zone-iterate/index.html b/docs/gallery/gn-zone-iterate/index.html new file mode 100644 index 0000000..07a80c9 --- /dev/null +++ b/docs/gallery/gn-zone-iterate/index.html @@ -0,0 +1,745 @@ + + + + + + gn-zone-iterate — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

gn-zone-iterate

+

Repeat Zone and For Each Element only iterate after pair_with_output. Evaluated cube counts follow 8 times (1+N) and 8 times P, not tree structure.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Repeat 32/24 verts/faces with X-centers at k*1.2; For Each 48/36 with Z-centers at i*0.6+0.21. Unpaired evaluates empty; For Each main Geometry passthrough is 6 verts.
+
+
blender --background --python examples/gn-zone-iterate/gn_zone_iterate.py --
+ +
+
+

A Repeat Zone row and a For Each Element tower that witness geometry-nodes-python zone pairing — not tree structure.

+

pair_with_output is load-bearing. Unpaired Repeat Input has no Geometry sockets; unpaired For Each evaluates empty (Cannot evaluate node group on 4.5). For Each's main Geometry output is the input mesh passthrough; generated cubes live on Generation_0.

+

Closed forms (cube = 8 verts / 6 faces):

+
  • Repeat: join one translated cube per iteration. verts = 8 × (1 + N) with N=3 → 32. X-centers at k × 1.2 for k = 0..3.
  • For Each: one cube per POINT, Z-offset by Index × 0.6. verts = 8 × P with P=6 → 48. Z-centers at i × 0.6 + 0.21.
+

Count alone is not enough. Joining N+1 cubes at the origin hits 32 verts with a single X-center (--no-offset). The center axis is the second witness.

+

Same on 4.5 LTS and 5.x (zones are 4.3+). No skip.

+

What failure each check would catch:

+
  • exit 3 — Repeat pairing broken or iterations wrong (--unpair-repeat → 0/0; --repeat-iterations 1 → 16/12)
  • exit 4 — Repeat count matches but cubes collapsed (--no-offset)
  • exit 5 — For Each pairing broken, element count wrong, or main-socket passthrough (--unpair-foreach → 0/0; --foreach-count 3 → 24/18; --foreach-main → 6/0)
  • exit 6 — For Each count matches but Z-centers do not
+

Run

+
blender --background --python gn_zone_iterate.py --
+blender --background --python gn_zone_iterate.py -- --output zones.png
+

The --output render path measures framing via examples/gallery_framing.py (exit 10 on violation).

+
+
+

Source

+
+ examples/gn-zone-iterate/gn_zone_iterate.py + View on GitHub → +
+
"""Geometry Nodes zone pairing — a runnable example.
+
+Witnesses Repeat Zone and For Each Element from
+``skills/geometry-nodes-python``: ``pair_with_output`` is load-bearing, and
+the evaluated mesh must match a closed form. Tree-structure checks are
+vacuous — a For Each whose Group Output reads the *main* Geometry socket
+passes "nodes exist and are linked" while shipping the unevaluated grid.
+
+Closed forms (cube = 8 verts / 6 faces):
+
+* Repeat: start with one cube, each iteration Joins another translated by
+  ``(Iteration + 1) * STEP``. verts = 8 * (1 + N), unique X-centers at
+  ``k * STEP`` for k = 0..N.
+* For Each Element: one cube per POINT, offset in Z by ``Index * STEP``.
+  verts = 8 * P, unique Z-centers at ``i * STEP`` for i = 0..P-1.
+
+Count alone is not enough: Joining N+1 cubes at the origin hits the vert
+count with one X-center. The center axis is the second witness.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python gn_zone_iterate.py --
+    blender --background --python gn_zone_iterate.py -- --output z.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True
+import gallery_framing
+
+CUBE_VERTS = 8
+CUBE_FACES = 6
+
+REPEAT_N = 3
+REPEAT_STEP = 1.2
+REPEAT_SIZE = 0.55
+REPEAT_VERTS = CUBE_VERTS * (1 + REPEAT_N)
+REPEAT_FACES = CUBE_FACES * (1 + REPEAT_N)
+REPEAT_CENTERS_X = [k * REPEAT_STEP for k in range(REPEAT_N + 1)]
+
+FOREACH_P = 6
+FOREACH_STEP = 0.60
+FOREACH_SIZE = 0.42
+FOREACH_VERTS = CUBE_VERTS * FOREACH_P
+FOREACH_FACES = CUBE_FACES * FOREACH_P
+FOREACH_CENTERS_Z = [i * FOREACH_STEP + FOREACH_SIZE / 2 for i in range(FOREACH_P)]
+
+
+def sock(node, collection, identifier):
+    for s in getattr(node, collection):
+        if s.identifier == identifier:
+            return s
+    raise RuntimeError(f"{node.bl_idname} has no {collection} {identifier!r}")
+
+
+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)
+    finally:
+        ev.to_mesh_clear()
+    return coords, faces
+
+
+def centers_ok(coords, centers, half, axis):
+    """Every vert on `axis` sits in exactly one center ± half; each center has 8 verts."""
+    counts = [0] * len(centers)
+    worst = 0.0
+    for co in coords:
+        val = co[axis]
+        dists = [abs(val - c) for c in centers]
+        i = min(range(len(centers)), key=lambda k: dists[k])
+        worst = max(worst, dists[i])
+        if dists[i] > half + 1e-3:
+            return False, counts, dists[i]
+        counts[i] += 1
+    return all(c == CUBE_VERTS for c in counts), counts, worst
+
+
+def build_repeat_tree(n, pair=True, offset=True, material=None):
+    tree = bpy.data.node_groups.new("RepeatZone", "GeometryNodeTree")
+    tree.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
+    go = tree.nodes.new("NodeGroupOutput")
+    cube0 = tree.nodes.new("GeometryNodeMeshCube")
+    cube0.inputs["Size"].default_value = (REPEAT_SIZE, REPEAT_SIZE, REPEAT_SIZE)
+    rin = tree.nodes.new("GeometryNodeRepeatInput")
+    rout = tree.nodes.new("GeometryNodeRepeatOutput")
+    if pair:
+        if not rin.pair_with_output(rout):
+            raise RuntimeError("RepeatInput.pair_with_output failed")
+    rin.inputs["Iterations"].default_value = n
+
+    cube1 = tree.nodes.new("GeometryNodeMeshCube")
+    cube1.inputs["Size"].default_value = (REPEAT_SIZE, REPEAT_SIZE, REPEAT_SIZE)
+    join = tree.nodes.new("GeometryNodeJoinGeometry")
+    shade = tree.nodes.new("GeometryNodeSetShadeSmooth")
+    shade.inputs["Shade Smooth"].default_value = False
+
+    added = cube1.outputs["Mesh"]
+    if offset and pair:
+        add = tree.nodes.new("ShaderNodeMath")
+        add.operation = "ADD"
+        add.inputs[1].default_value = 1.0
+        mul = tree.nodes.new("ShaderNodeMath")
+        mul.operation = "MULTIPLY"
+        mul.inputs[1].default_value = REPEAT_STEP
+        comb = tree.nodes.new("ShaderNodeCombineXYZ")
+        xf = tree.nodes.new("GeometryNodeTransform")
+        tree.links.new(rin.outputs["Iteration"], add.inputs[0])
+        tree.links.new(add.outputs["Value"], mul.inputs[0])
+        tree.links.new(mul.outputs["Value"], comb.inputs["X"])
+        tree.links.new(cube1.outputs["Mesh"], xf.inputs["Geometry"])
+        tree.links.new(comb.outputs["Vector"], xf.inputs["Translation"])
+        added = xf.outputs["Geometry"]
+
+    lift = tree.nodes.new("GeometryNodeTransform")
+    lift.inputs["Translation"].default_value = (0.0, 0.0, REPEAT_SIZE / 2)
+
+    if pair:
+        tree.links.new(cube0.outputs["Mesh"], rin.inputs["Geometry"])
+        tree.links.new(rin.outputs["Geometry"], join.inputs[0])
+        tree.links.new(added, join.inputs[0])
+        tree.links.new(join.outputs["Geometry"], rout.inputs["Geometry"])
+        tree.links.new(rout.outputs["Geometry"], lift.inputs["Geometry"])
+    else:
+        # Unpaired Repeat Input has no Geometry sockets. Leave the output empty
+        # so evaluation cannot silently pass the start cube through.
+        pass
+
+    tree.links.new(lift.outputs["Geometry"], shade.inputs["Geometry"])
+    out = shade.outputs["Geometry"]
+    if material is not None:
+        sm = tree.nodes.new("GeometryNodeSetMaterial")
+        sm.inputs["Material"].default_value = material
+        tree.links.new(out, sm.inputs["Geometry"])
+        out = sm.outputs["Geometry"]
+    tree.links.new(out, go.inputs["Geometry"])
+    return tree
+
+
+def build_foreach_tree(count, pair=True, use_generation=True, material=None):
+    tree = bpy.data.node_groups.new("ForEachZone", "GeometryNodeTree")
+    tree.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
+    go = tree.nodes.new("NodeGroupOutput")
+    line = tree.nodes.new("GeometryNodeMeshLine")
+    line.inputs["Count"].default_value = count
+    # degenerate line — elements exist; Index, not position, places the cubes
+    line.inputs["Offset"].default_value = (0.0, 0.0, 0.0)
+
+    fin = tree.nodes.new("GeometryNodeForeachGeometryElementInput")
+    fout = tree.nodes.new("GeometryNodeForeachGeometryElementOutput")
+    if pair:
+        if not fin.pair_with_output(fout):
+            raise RuntimeError("ForeachInput.pair_with_output failed")
+    fout.domain = "POINT"
+
+    cube = tree.nodes.new("GeometryNodeMeshCube")
+    cube.inputs["Size"].default_value = (FOREACH_SIZE, FOREACH_SIZE, FOREACH_SIZE)
+    mul = tree.nodes.new("ShaderNodeMath")
+    mul.operation = "MULTIPLY"
+    mul.inputs[1].default_value = FOREACH_STEP
+    comb = tree.nodes.new("ShaderNodeCombineXYZ")
+    sp = tree.nodes.new("GeometryNodeSetPosition")
+    shade = tree.nodes.new("GeometryNodeSetShadeSmooth")
+    shade.inputs["Shade Smooth"].default_value = False
+    lift = tree.nodes.new("GeometryNodeTransform")
+    lift.inputs["Translation"].default_value = (0.0, 0.0, FOREACH_SIZE / 2)
+
+    tree.links.new(line.outputs["Mesh"], fin.inputs["Geometry"])
+    tree.links.new(fin.outputs["Index"], mul.inputs[0])
+    tree.links.new(mul.outputs["Value"], comb.inputs["Z"])
+    tree.links.new(cube.outputs["Mesh"], sp.inputs["Geometry"])
+    tree.links.new(comb.outputs["Vector"], sp.inputs["Offset"])
+
+    if pair:
+        gen_in = sock(fout, "inputs", "Generation_0")
+        tree.links.new(sp.outputs["Geometry"], gen_in)
+        if use_generation:
+            body = sock(fout, "outputs", "Generation_0")
+        else:
+            body = fout.outputs["Geometry"]
+        tree.links.new(body, lift.inputs["Geometry"])
+    tree.links.new(lift.outputs["Geometry"], shade.inputs["Geometry"])
+    out = shade.outputs["Geometry"]
+    if material is not None:
+        sm = tree.nodes.new("GeometryNodeSetMaterial")
+        sm.inputs["Material"].default_value = material
+        tree.links.new(out, sm.inputs["Geometry"])
+        out = sm.outputs["Geometry"]
+    tree.links.new(out, go.inputs["Geometry"])
+    return tree
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def carrier(name):
+    me = bpy.data.meshes.new(name)
+    me.vertices.add(1)
+    ob = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(ob)
+    return ob
+
+
+def attach(obj, tree):
+    mod = obj.modifiers.new("GN", "NODES")
+    mod.node_group = tree
+    return mod
+
+
+def build(repeat_n, foreach_p, pair_repeat, pair_foreach, offset_repeat, foreach_generation):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    brass = principled("Brass", (0.86, 0.55, 0.16, 1.0), 0.85, 0.34)
+    teal = principled("Teal", (0.08, 0.55, 0.58, 1.0), 0.15, 0.32)
+    rpt = carrier("RepeatRow")
+    fee = carrier("ForEachTower")
+    rpt.location = (-2.80, 0.0, 0.0)
+    fee.location = (2.20, 0.0, 0.0)
+    attach(rpt, build_repeat_tree(repeat_n, pair=pair_repeat, offset=offset_repeat, material=brass))
+    attach(fee, build_foreach_tree(
+        foreach_p, pair=pair_foreach, use_generation=foreach_generation, material=teal,
+    ))
+    bpy.context.view_layer.update()
+    return rpt, fee
+
+
+def check(rpt, fee, expect_n, expect_p):
+    if len(rpt.data.vertices) != 1 or len(fee.data.vertices) != 1:
+        print("ERROR: carrier mesh was rewritten; zones must not apply in-place", file=sys.stderr)
+        return 2
+
+    r_coords, r_faces = eval_mesh(rpt)
+    f_coords, f_faces = eval_mesh(fee)
+    r_exp_v = CUBE_VERTS * (1 + expect_n)
+    r_exp_f = CUBE_FACES * (1 + expect_n)
+    f_exp_v = CUBE_VERTS * expect_p
+    f_exp_f = CUBE_FACES * expect_p
+    r_centers = [k * REPEAT_STEP for k in range(expect_n + 1)]
+    f_centers = [i * FOREACH_STEP + FOREACH_SIZE / 2 for i in range(expect_p)]
+
+    print(
+        f"repeat verts={len(r_coords)} faces={r_faces} "
+        f"foreach verts={len(f_coords)} faces={f_faces} "
+        f"expect repeat={r_exp_v}/{r_exp_f} foreach={f_exp_v}/{f_exp_f}"
+    )
+
+    if len(r_coords) != r_exp_v or r_faces != r_exp_f:
+        print(
+            f"ERROR: Repeat evaluated {len(r_coords)}/{r_faces}, "
+            f"closed form 8*(1+N)={r_exp_v}/{r_exp_f} with N={expect_n}",
+            file=sys.stderr,
+        )
+        return 3
+    ok, counts, worst = centers_ok(r_coords, r_centers, REPEAT_SIZE / 2, 0)
+    if not ok:
+        print(
+            f"ERROR: Repeat X-centers {counts} worst={worst:.4f} "
+            f"expected {CUBE_VERTS} verts at {r_centers}",
+            file=sys.stderr,
+        )
+        return 4
+
+    if len(f_coords) != f_exp_v or f_faces != f_exp_f:
+        print(
+            f"ERROR: For Each evaluated {len(f_coords)}/{f_faces}, "
+            f"closed form 8*P={f_exp_v}/{f_exp_f} with P={expect_p}",
+            file=sys.stderr,
+        )
+        return 5
+    ok, counts, worst = centers_ok(f_coords, f_centers, FOREACH_SIZE / 2, 2)
+    if not ok:
+        print(
+            f"ERROR: For Each Z-centers {counts} worst={worst:.4f} "
+            f"expected {CUBE_VERTS} verts at {f_centers}",
+            file=sys.stderr,
+        )
+        return 6
+
+    print(
+        f"repeat_N={expect_n} foreach_P={expect_p} "
+        f"x_centers={r_centers} z_centers={f_centers} pairing=ok"
+    )
+    return 0
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def render_still(rpt, fee, path, engine):
+    scene = bpy.context.scene
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    studio = principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)
+    floor_me.materials.append(studio)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.data.materials.clear()
+    wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.pi / 2, 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-4.0, -5.0, 6.0), 520.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35))
+    light("Fill", (5.0, -3.5, 3.0), 160.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+    light("Wedge", (2.5, 5.5, 4.0), 360.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+    light("Glint", (0.4, -5.2, 5.5), 700.0, 0.9, (1.0, 0.92, 0.75), (42, 0, 8))
+
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 1.50)
+    aim.hide_render = True
+    scene.collection.objects.link(aim)
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (6.6, -9.3, 3.55)
+    scene.collection.objects.link(cam)
+    scene.camera = cam
+    track = cam.constraints.new("TRACK_TO")
+    track.target = aim
+    track.track_axis = "TRACK_NEGATIVE_Z"
+    track.up_axis = "UP_Y"
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = "PNG"
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    hero = [rpt, fee]
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=hero, elements=hero, stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        print("ERROR: render produced no file", file=sys.stderr)
+        return 12
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument("--unpair-repeat", action="store_true")
+    p.add_argument("--unpair-foreach", action="store_true")
+    p.add_argument("--repeat-iterations", type=int, default=REPEAT_N)
+    p.add_argument("--foreach-count", type=int, default=FOREACH_P)
+    p.add_argument("--foreach-main", action="store_true",
+                   help="falsification: Group Output reads For Each main Geometry")
+    p.add_argument("--no-offset", action="store_true",
+                   help="falsification: Repeat Join without Iteration translation")
+    args = p.parse_args(argv)
+
+    rpt, fee = build(
+        args.repeat_iterations, args.foreach_count,
+        pair_repeat=not args.unpair_repeat,
+        pair_foreach=not args.unpair_foreach,
+        offset_repeat=not args.no_offset,
+        foreach_generation=not args.foreach_main,
+    )
+    code = check(rpt, fee, REPEAT_N, FOREACH_P)
+    if code:
+        return code
+
+    if args.output:
+        rcode = render_still(rpt, fee, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+
+    print("gn-zone-iterate 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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 19fae33..4ddcc55 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 47 examples + 48 examples
@@ -840,6 +840,17 @@

usd-export-evaluation-mode

View example
+
+ + gn-zone-iterate — Repeat Zone and For Each Element only iterate after pair_with_output. + +
+

gn-zone-iterate

+

Repeat Zone and For Each Element only iterate after pair_with_output. Evaluated cube counts follow 8 times (1+N) and 8 times P, not tree structure.

+

witnesses Repeat 32/24 verts/faces with X-centers at k*1.2; For Each 48/36 with Z-centers at i*0.6+0.21. Unpaired evaluates empty; For Each main Geometry passthrough is 6 verts.

+ View example +
+
diff --git a/docs/technical-audit.md b/docs/technical-audit.md index d181e27..eab1a00 100644 --- a/docs/technical-audit.md +++ b/docs/technical-audit.md @@ -33,6 +33,33 @@ Set scene resolution **before** building strips. `A.width, A.height == (96, 54)`). No separate example — that script already fails if the bake drifts. +### 2026-09-09 — USD `export_subdivision='BEST_MATCH'` silences `evaluation_mode` + +Default `wm.usd_export` `export_subdivision='BEST_MATCH'` writes the cage +plus `subdivisionScheme = catmullClark`. VIEWPORT and RENDER USDA files +are then identical, so `evaluation_mode` looks like a no-op. TESSELLATE +makes the mode observable. Catmull-Clark on a cube: verts = +`2 + 6 × 4^n` (n=1 → 26, n=2 → 98). Same on 4.5 LTS and 5.2 LTS; not a +version split, an exporter-default trap. Not listed in the python_api notes +as a behavior change because the RNA names did not move. + +- Taught: `skills/depsgraph-and-evaluated-data/SKILL.md` +- Witness: `examples/usd-export-evaluation-mode/` + +### 2026-09-09 — Geometry Nodes zone pairing is load-bearing + +Creating Repeat / For Each Element input and output nodes and linking +sockets is not enough. `pair_with_output` creates the Geometry items; +unpaired Repeat Input has only Iterations, unpaired For Each evaluates +empty. For Each's main `Geometry` output is the input-mesh passthrough; +generated meshes live on `Generation_0`. Tree-structure checks are +vacuous. Count-only is also insufficient: joining N+1 cubes at the origin +hits `8×(1+N)` verts with one X-center. Same on 4.5 LTS and 5.x (zones +are 4.3+). Not a version split. + +- Taught: `skills/geometry-nodes-python/SKILL.md` +- Witness: `examples/gn-zone-iterate/` + ### 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/gallery.json b/examples/gallery.json index 0ccaf0a..a567299 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -594,6 +594,17 @@ "depsgraph", "export" ] + }, + { + "name": "gn-zone-iterate", + "dir": "examples/gn-zone-iterate", + "teaches": "Repeat Zone and For Each Element only iterate after pair_with_output. Evaluated cube counts follow 8 times (1+N) and 8 times P, not tree structure.", + "witnessesFix": "Repeat 32/24 verts/faces with X-centers at k*1.2; For Each 48/36 with Z-centers at i*0.6+0.21. Unpaired evaluates empty; For Each main Geometry passthrough is 6 verts.", + "hero": "docs/gallery/assets/gn-zone-iterate-hero.webp", + "preview": "examples/gn-zone-iterate/preview.webp", + "tags": [ + "geometry-nodes" + ] } ] } diff --git a/examples/gn-zone-iterate/README.md b/examples/gn-zone-iterate/README.md new file mode 100644 index 0000000..14e329d --- /dev/null +++ b/examples/gn-zone-iterate/README.md @@ -0,0 +1,42 @@ +# Geometry Nodes zone iterate + +A Repeat Zone row and a For Each Element tower that witness +[`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md) +zone pairing — not tree structure. + +`pair_with_output` is load-bearing. Unpaired Repeat Input has no Geometry +sockets; unpaired For Each evaluates empty (`Cannot evaluate node group` on +4.5). For Each's main `Geometry` output is the input mesh passthrough; +generated cubes live on `Generation_0`. + +Closed forms (cube = 8 verts / 6 faces): + +- Repeat: join one translated cube per iteration. verts = `8 × (1 + N)` with + N=3 → 32. X-centers at `k × 1.2` for k = 0..3. +- For Each: one cube per POINT, Z-offset by `Index × 0.6`. verts = `8 × P` + with P=6 → 48. Z-centers at `i × 0.6 + 0.21`. + +**Count alone is not enough.** Joining N+1 cubes at the origin hits 32 verts +with a single X-center (`--no-offset`). The center axis is the second witness. + +Same on 4.5 LTS and 5.x (zones are 4.3+). No skip. + +**What failure each check would catch:** + +- exit 3 — Repeat pairing broken or iterations wrong (`--unpair-repeat` → 0/0; + `--repeat-iterations 1` → 16/12) +- exit 4 — Repeat count matches but cubes collapsed (`--no-offset`) +- exit 5 — For Each pairing broken, element count wrong, or main-socket + passthrough (`--unpair-foreach` → 0/0; `--foreach-count 3` → 24/18; + `--foreach-main` → 6/0) +- exit 6 — For Each count matches but Z-centers do not + +## Run + +```bash +blender --background --python gn_zone_iterate.py -- +blender --background --python gn_zone_iterate.py -- --output zones.png +``` + +The `--output` render path measures framing via `examples/gallery_framing.py` +(exit 10 on violation). diff --git a/examples/gn-zone-iterate/gn_zone_iterate.py b/examples/gn-zone-iterate/gn_zone_iterate.py new file mode 100644 index 0000000..fd7cea4 --- /dev/null +++ b/examples/gn-zone-iterate/gn_zone_iterate.py @@ -0,0 +1,425 @@ +"""Geometry Nodes zone pairing — a runnable example. + +Witnesses Repeat Zone and For Each Element from +``skills/geometry-nodes-python``: ``pair_with_output`` is load-bearing, and +the evaluated mesh must match a closed form. Tree-structure checks are +vacuous — a For Each whose Group Output reads the *main* Geometry socket +passes "nodes exist and are linked" while shipping the unevaluated grid. + +Closed forms (cube = 8 verts / 6 faces): + +* Repeat: start with one cube, each iteration Joins another translated by + ``(Iteration + 1) * STEP``. verts = 8 * (1 + N), unique X-centers at + ``k * STEP`` for k = 0..N. +* For Each Element: one cube per POINT, offset in Z by ``Index * STEP``. + verts = 8 * P, unique Z-centers at ``i * STEP`` for i = 0..P-1. + +Count alone is not enough: Joining N+1 cubes at the origin hits the vert +count with one X-center. The center axis is the second witness. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python gn_zone_iterate.py -- + blender --background --python gn_zone_iterate.py -- --output z.png +""" +import bpy, bmesh, sys, os, math, argparse + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True +import gallery_framing + +CUBE_VERTS = 8 +CUBE_FACES = 6 + +REPEAT_N = 3 +REPEAT_STEP = 1.2 +REPEAT_SIZE = 0.55 +REPEAT_VERTS = CUBE_VERTS * (1 + REPEAT_N) +REPEAT_FACES = CUBE_FACES * (1 + REPEAT_N) +REPEAT_CENTERS_X = [k * REPEAT_STEP for k in range(REPEAT_N + 1)] + +FOREACH_P = 6 +FOREACH_STEP = 0.60 +FOREACH_SIZE = 0.42 +FOREACH_VERTS = CUBE_VERTS * FOREACH_P +FOREACH_FACES = CUBE_FACES * FOREACH_P +FOREACH_CENTERS_Z = [i * FOREACH_STEP + FOREACH_SIZE / 2 for i in range(FOREACH_P)] + + +def sock(node, collection, identifier): + for s in getattr(node, collection): + if s.identifier == identifier: + return s + raise RuntimeError(f"{node.bl_idname} has no {collection} {identifier!r}") + + +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) + finally: + ev.to_mesh_clear() + return coords, faces + + +def centers_ok(coords, centers, half, axis): + """Every vert on `axis` sits in exactly one center ± half; each center has 8 verts.""" + counts = [0] * len(centers) + worst = 0.0 + for co in coords: + val = co[axis] + dists = [abs(val - c) for c in centers] + i = min(range(len(centers)), key=lambda k: dists[k]) + worst = max(worst, dists[i]) + if dists[i] > half + 1e-3: + return False, counts, dists[i] + counts[i] += 1 + return all(c == CUBE_VERTS for c in counts), counts, worst + + +def build_repeat_tree(n, pair=True, offset=True, material=None): + tree = bpy.data.node_groups.new("RepeatZone", "GeometryNodeTree") + tree.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + go = tree.nodes.new("NodeGroupOutput") + cube0 = tree.nodes.new("GeometryNodeMeshCube") + cube0.inputs["Size"].default_value = (REPEAT_SIZE, REPEAT_SIZE, REPEAT_SIZE) + rin = tree.nodes.new("GeometryNodeRepeatInput") + rout = tree.nodes.new("GeometryNodeRepeatOutput") + if pair: + if not rin.pair_with_output(rout): + raise RuntimeError("RepeatInput.pair_with_output failed") + rin.inputs["Iterations"].default_value = n + + cube1 = tree.nodes.new("GeometryNodeMeshCube") + cube1.inputs["Size"].default_value = (REPEAT_SIZE, REPEAT_SIZE, REPEAT_SIZE) + join = tree.nodes.new("GeometryNodeJoinGeometry") + shade = tree.nodes.new("GeometryNodeSetShadeSmooth") + shade.inputs["Shade Smooth"].default_value = False + + added = cube1.outputs["Mesh"] + if offset and pair: + add = tree.nodes.new("ShaderNodeMath") + add.operation = "ADD" + add.inputs[1].default_value = 1.0 + mul = tree.nodes.new("ShaderNodeMath") + mul.operation = "MULTIPLY" + mul.inputs[1].default_value = REPEAT_STEP + comb = tree.nodes.new("ShaderNodeCombineXYZ") + xf = tree.nodes.new("GeometryNodeTransform") + tree.links.new(rin.outputs["Iteration"], add.inputs[0]) + tree.links.new(add.outputs["Value"], mul.inputs[0]) + tree.links.new(mul.outputs["Value"], comb.inputs["X"]) + tree.links.new(cube1.outputs["Mesh"], xf.inputs["Geometry"]) + tree.links.new(comb.outputs["Vector"], xf.inputs["Translation"]) + added = xf.outputs["Geometry"] + + lift = tree.nodes.new("GeometryNodeTransform") + lift.inputs["Translation"].default_value = (0.0, 0.0, REPEAT_SIZE / 2) + + if pair: + tree.links.new(cube0.outputs["Mesh"], rin.inputs["Geometry"]) + tree.links.new(rin.outputs["Geometry"], join.inputs[0]) + tree.links.new(added, join.inputs[0]) + tree.links.new(join.outputs["Geometry"], rout.inputs["Geometry"]) + tree.links.new(rout.outputs["Geometry"], lift.inputs["Geometry"]) + else: + # Unpaired Repeat Input has no Geometry sockets. Leave the output empty + # so evaluation cannot silently pass the start cube through. + pass + + tree.links.new(lift.outputs["Geometry"], shade.inputs["Geometry"]) + out = shade.outputs["Geometry"] + if material is not None: + sm = tree.nodes.new("GeometryNodeSetMaterial") + sm.inputs["Material"].default_value = material + tree.links.new(out, sm.inputs["Geometry"]) + out = sm.outputs["Geometry"] + tree.links.new(out, go.inputs["Geometry"]) + return tree + + +def build_foreach_tree(count, pair=True, use_generation=True, material=None): + tree = bpy.data.node_groups.new("ForEachZone", "GeometryNodeTree") + tree.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + go = tree.nodes.new("NodeGroupOutput") + line = tree.nodes.new("GeometryNodeMeshLine") + line.inputs["Count"].default_value = count + # degenerate line — elements exist; Index, not position, places the cubes + line.inputs["Offset"].default_value = (0.0, 0.0, 0.0) + + fin = tree.nodes.new("GeometryNodeForeachGeometryElementInput") + fout = tree.nodes.new("GeometryNodeForeachGeometryElementOutput") + if pair: + if not fin.pair_with_output(fout): + raise RuntimeError("ForeachInput.pair_with_output failed") + fout.domain = "POINT" + + cube = tree.nodes.new("GeometryNodeMeshCube") + cube.inputs["Size"].default_value = (FOREACH_SIZE, FOREACH_SIZE, FOREACH_SIZE) + mul = tree.nodes.new("ShaderNodeMath") + mul.operation = "MULTIPLY" + mul.inputs[1].default_value = FOREACH_STEP + comb = tree.nodes.new("ShaderNodeCombineXYZ") + sp = tree.nodes.new("GeometryNodeSetPosition") + shade = tree.nodes.new("GeometryNodeSetShadeSmooth") + shade.inputs["Shade Smooth"].default_value = False + lift = tree.nodes.new("GeometryNodeTransform") + lift.inputs["Translation"].default_value = (0.0, 0.0, FOREACH_SIZE / 2) + + tree.links.new(line.outputs["Mesh"], fin.inputs["Geometry"]) + tree.links.new(fin.outputs["Index"], mul.inputs[0]) + tree.links.new(mul.outputs["Value"], comb.inputs["Z"]) + tree.links.new(cube.outputs["Mesh"], sp.inputs["Geometry"]) + tree.links.new(comb.outputs["Vector"], sp.inputs["Offset"]) + + if pair: + gen_in = sock(fout, "inputs", "Generation_0") + tree.links.new(sp.outputs["Geometry"], gen_in) + if use_generation: + body = sock(fout, "outputs", "Generation_0") + else: + body = fout.outputs["Geometry"] + tree.links.new(body, lift.inputs["Geometry"]) + tree.links.new(lift.outputs["Geometry"], shade.inputs["Geometry"]) + out = shade.outputs["Geometry"] + if material is not None: + sm = tree.nodes.new("GeometryNodeSetMaterial") + sm.inputs["Material"].default_value = material + tree.links.new(out, sm.inputs["Geometry"]) + out = sm.outputs["Geometry"] + tree.links.new(out, go.inputs["Geometry"]) + return tree + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def carrier(name): + me = bpy.data.meshes.new(name) + me.vertices.add(1) + ob = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(ob) + return ob + + +def attach(obj, tree): + mod = obj.modifiers.new("GN", "NODES") + mod.node_group = tree + return mod + + +def build(repeat_n, foreach_p, pair_repeat, pair_foreach, offset_repeat, foreach_generation): + bpy.ops.wm.read_factory_settings(use_empty=True) + brass = principled("Brass", (0.86, 0.55, 0.16, 1.0), 0.85, 0.34) + teal = principled("Teal", (0.08, 0.55, 0.58, 1.0), 0.15, 0.32) + rpt = carrier("RepeatRow") + fee = carrier("ForEachTower") + rpt.location = (-2.80, 0.0, 0.0) + fee.location = (2.20, 0.0, 0.0) + attach(rpt, build_repeat_tree(repeat_n, pair=pair_repeat, offset=offset_repeat, material=brass)) + attach(fee, build_foreach_tree( + foreach_p, pair=pair_foreach, use_generation=foreach_generation, material=teal, + )) + bpy.context.view_layer.update() + return rpt, fee + + +def check(rpt, fee, expect_n, expect_p): + if len(rpt.data.vertices) != 1 or len(fee.data.vertices) != 1: + print("ERROR: carrier mesh was rewritten; zones must not apply in-place", file=sys.stderr) + return 2 + + r_coords, r_faces = eval_mesh(rpt) + f_coords, f_faces = eval_mesh(fee) + r_exp_v = CUBE_VERTS * (1 + expect_n) + r_exp_f = CUBE_FACES * (1 + expect_n) + f_exp_v = CUBE_VERTS * expect_p + f_exp_f = CUBE_FACES * expect_p + r_centers = [k * REPEAT_STEP for k in range(expect_n + 1)] + f_centers = [i * FOREACH_STEP + FOREACH_SIZE / 2 for i in range(expect_p)] + + print( + f"repeat verts={len(r_coords)} faces={r_faces} " + f"foreach verts={len(f_coords)} faces={f_faces} " + f"expect repeat={r_exp_v}/{r_exp_f} foreach={f_exp_v}/{f_exp_f}" + ) + + if len(r_coords) != r_exp_v or r_faces != r_exp_f: + print( + f"ERROR: Repeat evaluated {len(r_coords)}/{r_faces}, " + f"closed form 8*(1+N)={r_exp_v}/{r_exp_f} with N={expect_n}", + file=sys.stderr, + ) + return 3 + ok, counts, worst = centers_ok(r_coords, r_centers, REPEAT_SIZE / 2, 0) + if not ok: + print( + f"ERROR: Repeat X-centers {counts} worst={worst:.4f} " + f"expected {CUBE_VERTS} verts at {r_centers}", + file=sys.stderr, + ) + return 4 + + if len(f_coords) != f_exp_v or f_faces != f_exp_f: + print( + f"ERROR: For Each evaluated {len(f_coords)}/{f_faces}, " + f"closed form 8*P={f_exp_v}/{f_exp_f} with P={expect_p}", + file=sys.stderr, + ) + return 5 + ok, counts, worst = centers_ok(f_coords, f_centers, FOREACH_SIZE / 2, 2) + if not ok: + print( + f"ERROR: For Each Z-centers {counts} worst={worst:.4f} " + f"expected {CUBE_VERTS} verts at {f_centers}", + file=sys.stderr, + ) + return 6 + + print( + f"repeat_N={expect_n} foreach_P={expect_p} " + f"x_centers={r_centers} z_centers={f_centers} pairing=ok" + ) + return 0 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def render_still(rpt, fee, path, engine): + scene = bpy.context.scene + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(floor_me) + finally: + bm.free() + studio = principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7) + floor_me.materials.append(studio) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.data.materials.clear() + wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7)) + wall.location = (0.0, 9.0, 0.0) + wall.rotation_euler = (math.pi / 2, 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-4.0, -5.0, 6.0), 520.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35)) + light("Fill", (5.0, -3.5, 3.0), 160.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50)) + light("Wedge", (2.5, 5.5, 4.0), 360.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190)) + light("Glint", (0.4, -5.2, 5.5), 700.0, 0.9, (1.0, 0.92, 0.75), (42, 0, 8)) + + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 1.50) + aim.hide_render = True + scene.collection.objects.link(aim) + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (6.6, -9.3, 3.55) + scene.collection.objects.link(cam) + scene.camera = cam + track = cam.constraints.new("TRACK_TO") + track.target = aim + track.track_axis = "TRACK_NEGATIVE_Z" + track.up_axis = "UP_Y" + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "PNG" + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + hero = [rpt, fee] + fcode = gallery_framing.check_framing( + scene, cam, hero=hero, elements=hero, stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 12 + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--unpair-repeat", action="store_true") + p.add_argument("--unpair-foreach", action="store_true") + p.add_argument("--repeat-iterations", type=int, default=REPEAT_N) + p.add_argument("--foreach-count", type=int, default=FOREACH_P) + p.add_argument("--foreach-main", action="store_true", + help="falsification: Group Output reads For Each main Geometry") + p.add_argument("--no-offset", action="store_true", + help="falsification: Repeat Join without Iteration translation") + args = p.parse_args(argv) + + rpt, fee = build( + args.repeat_iterations, args.foreach_count, + pair_repeat=not args.unpair_repeat, + pair_foreach=not args.unpair_foreach, + offset_repeat=not args.no_offset, + foreach_generation=not args.foreach_main, + ) + code = check(rpt, fee, REPEAT_N, FOREACH_P) + if code: + return code + + if args.output: + rcode = render_still(rpt, fee, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + + print("gn-zone-iterate 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/examples/gn-zone-iterate/preview.webp b/examples/gn-zone-iterate/preview.webp new file mode 100644 index 0000000..459b37b Binary files /dev/null and b/examples/gn-zone-iterate/preview.webp differ diff --git a/skills/depsgraph-and-evaluated-data/SKILL.md b/skills/depsgraph-and-evaluated-data/SKILL.md index 7626d9f..293dd22 100644 --- a/skills/depsgraph-and-evaluated-data/SKILL.md +++ b/skills/depsgraph-and-evaluated-data/SKILL.md @@ -148,15 +148,22 @@ For most read paths, prefer the depsgraph route. The cost is one indirection; th ## `evaluation_mode` for exporters -The USD, Alembic, and OBJ exporters in 5.x accept an `evaluation_mode` parameter that controls how Blender evaluates the scene before exporting: +The USD, Alembic, and OBJ exporters accept an `evaluation_mode` parameter that controls how Blender evaluates the scene before exporting: - `'RENDER'`: include render-only modifiers (subsurf at render levels, etc.). Use for final exports. - `'VIEWPORT'`: use viewport modifier levels. Use for fast preview exports. +Default `export_subdivision='BEST_MATCH'` writes the **cage** plus +`subdivisionScheme = catmullClark`. VIEWPORT and RENDER files are then +identical — `evaluation_mode` is silent. Pass `export_subdivision='TESSELLATE'` +to make the mode observable. Catmull-Clark on a cube is closed form: +verts = `2 + 6 × 4^n` (n=1 → 26, n=2 → 98). + ```python bpy.ops.wm.usd_export( filepath="/tmp/scene.usdc", evaluation_mode='RENDER', + export_subdivision='TESSELLATE', ) ``` @@ -171,6 +178,7 @@ When you build your own exporter on top of `evaluated_depsgraph_get()`, the deps - **Treating `obj.data` as identical to `obj_eval.data`**. They are different mesh datablocks. The first is the source; the second is post-evaluation. - **Using the raw object's `matrix_world` after evaluating**. `obj.matrix_world` and `obj_eval.matrix_world` may differ (parent constraints evaluate during depsgraph). Use `obj_eval.matrix_world` for world-space positions. - **Calling `to_mesh()` inside a tight loop without clearing**. Each iteration leaks a temp mesh. Even with the right intent, this exhausts memory fast. +- **USD `evaluation_mode` without `export_subdivision='TESSELLATE'`**. Default `BEST_MATCH` writes the cage plus `subdivisionScheme = catmullClark`, so RENDER and VIEWPORT files match and the mode looks like a no-op. ## Version correctness @@ -184,6 +192,7 @@ In 5.0 the underlying Animation 2025 work changed how armature evaluation intera - Skill `mesh-editing-and-bmesh`: bmesh has its own load-edit-free contract. - Snippet `depsgraph-evaluated-mesh.py` for the minimal copy-paste pattern. - Snippet `usd-export-evaluation-mode.py` for the exporter parameter. +- Example `usd-export-evaluation-mode` for the TESSELLATE closed form versus the BEST_MATCH cage. ## References diff --git a/skills/geometry-nodes-python/SKILL.md b/skills/geometry-nodes-python/SKILL.md index c51c441..7d939c3 100644 --- a/skills/geometry-nodes-python/SKILL.md +++ b/skills/geometry-nodes-python/SKILL.md @@ -243,6 +243,35 @@ You then build a bundle inside the tree using the `Combine Bundle` node (`Geomet 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 +and linking sockets is not enough: call `pair_with_output` on the input node. +Unpaired Repeat Input has no Geometry sockets (only Iterations). Unpaired +For Each evaluates to empty geometry (`Cannot evaluate node group` on 4.5). + +```python +rin = tree.nodes.new("GeometryNodeRepeatInput") +rout = tree.nodes.new("GeometryNodeRepeatOutput") +rin.pair_with_output(rout) # creates the Geometry items on both nodes +rin.inputs["Iterations"].default_value = 3 + +fin = tree.nodes.new("GeometryNodeForeachGeometryElementInput") +fout = tree.nodes.new("GeometryNodeForeachGeometryElementOutput") +fin.pair_with_output(fout) +fout.domain = "POINT" +``` + +For Each Element has two Geometry outputs. `outputs["Geometry"]` is the **main** +passthrough (the input mesh). Generated meshes live on `Generation_0`. Wiring +Group Output to the main socket is the vacuous tree: nodes exist and are +linked, the zone does not iterate into the result. + +Do not assert that the zone nodes exist. Assert evaluated topology against a +closed form (cube verts = `8 × (1 + N)` for a Repeat that joins one cube per +iteration; `8 × P` for a For Each over P points). Count alone can still pass +if N+1 cubes are Joined at the origin — also assert per-iteration positions. + ## Detecting Geometry Nodes feature support ```python @@ -285,6 +314,8 @@ def has_for_each_element(): 6. **Building the tree without group input/output nodes**. The tree's interface sockets only matter once you have `NodeGroupInput` and `NodeGroupOutput` instances connected to actual nodes inside the tree. +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. + ## 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 @@ -326,6 +357,7 @@ def build_remesh_via_sdf(voxel_size=0.05, threshold=0.0): ## Related - `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 - `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 9ada032..b83948f 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -55,5 +55,6 @@ {"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py"}, {"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": "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"} ]