From 09819bf17094e26aa52452d4aff9576ec6742f80 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 18 Jul 2026 17:35:26 -0400 Subject: [PATCH] fix: harden example checks and close review findings across eight examples A full review pass over the sixteen examples found the oldest four predate the bmesh-gear anatomy and carried real debt; this closes everything actionable: - depsgraph-export: replace the shipped scratch docstring ("Candidate B ... (SCRATCH)") with the standard anatomy, and add the missing --output render path (base cube beside its evaluated form, resting the subsurf ball on the floor via its own evaluated bounds) so the gallery assets now come from the shipped script; OBJ path moves to --obj. - swatch-grid: add the check-only default mode CI conventions promise (--output was required), and make the EEVEE engine-id assertion real. - swatch-grid / turntable / gn-sdf-remesh: the old engine-id check compared a value to itself and could never fire; it now witnesses the inversion by asserting the WRONG era's id is rejected (TypeError) and the helper's id accepted - live-verified on 4.5.11 and 5.1.2. - wave-displace: the vertex-count guard was unreachable (foreach_set cannot resize); replaced with a strictly stronger every-vertex closed-form check that a stride/interleave bug cannot hide from. - damped-track-aim: delete the inert legacy EEVEE bloom block (use_bloom exists on neither supported version and contradicted compositor-glare), fix the orphaned wall mesh copy, and correct the cos(3.6deg) comment. - driver-wave: remove the unreachable else branch in the driver function. - Backport the always-free-bmesh try/finally and the taa_render_samples shim to the early examples that lacked them; sync READMEs, gallery.json, the smoke workflow comment, and regenerate docs/gallery. All six touched checks pass check-only on Blender 4.5.11 LTS and 5.1.2; swatch-grid's CI render invocation and the new depsgraph-export render were run and visually inspected on both. Co-Authored-By: Claude Fable 5 --- .github/workflows/blender-smoke.yml | 5 +- .../gallery/assets/depsgraph-export-hero.webp | Bin 11936 -> 12226 bytes docs/gallery/damped-track-aim/index.html | 19 +- docs/gallery/depsgraph-export/index.html | 183 ++++++++++++++++-- docs/gallery/driver-wave/index.html | 7 +- docs/gallery/gn-sdf-remesh/index.html | 18 +- docs/gallery/index.html | 2 +- docs/gallery/swatch-grid/index.html | 47 +++-- docs/gallery/turntable/index.html | 19 +- docs/gallery/wave-displace/index.html | 34 ++-- examples/damped-track-aim/README.md | 2 +- examples/damped-track-aim/damped_track_aim.py | 17 +- examples/depsgraph-export/README.md | 6 +- examples/depsgraph-export/depsgraph_export.py | 177 +++++++++++++++-- examples/depsgraph-export/preview.webp | Bin 10630 -> 9236 bytes examples/driver-wave/driver_wave.py | 7 +- examples/gallery.json | 2 +- examples/gn-sdf-remesh/gn_sdf_remesh.py | 18 +- examples/swatch-grid/README.md | 12 +- examples/swatch-grid/swatch_grid.py | 40 ++-- examples/turntable/turntable.py | 19 +- examples/wave-displace/README.md | 7 +- examples/wave-displace/wave_displace.py | 28 +-- 23 files changed, 510 insertions(+), 159 deletions(-) diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 0a65ca3..e7f02c5 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -160,9 +160,8 @@ jobs: run: | set -euo pipefail # Frame-independent check only (no render): 9409 verts displaced via one - # foreach_get + one foreach_set; asserts count unchanged, Z span matches the - # amplitude, and a probe vertex matches the closed-form wave. Exits non-zero - # on failure. + # foreach_get + one foreach_set; asserts the Z span matches the amplitude + # and every vertex matches the closed-form wave. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/wave-displace/wave_displace.py -- diff --git a/docs/gallery/assets/depsgraph-export-hero.webp b/docs/gallery/assets/depsgraph-export-hero.webp index bdf11a00320a9ebd883e7533c383c7eb0462d5ac..d85823f12cb6cdeba2749563a3d09308bd7f7a8a 100644 GIT binary patch literal 12226 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 diff --git a/docs/gallery/damped-track-aim/index.html b/docs/gallery/damped-track-aim/index.html index 286d3ed..14c2812 100644 --- a/docs/gallery/damped-track-aim/index.html +++ b/docs/gallery/damped-track-aim/index.html @@ -184,7 +184,7 @@

damped-track-aim

A runnable example that aims twelve brass spikes at an ember core with Object.constraints.new('DAMPED_TRACK') — the data-API path, not bpy.ops.object.constraint_add (which needs an active object and fails in headless loops). Damped Track is the twist-stable aim constraint: it points one local axis at a target without the roll fights Track To is known for.

-

What it witnesses: every spike carries exactly one unmuted DAMPED_TRACK bound to the core on TRACK_Z. After a depsgraph update, each evaluated local +Z aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3°). A missing constraint, a muted one, a TRACK_TO stand-in, or a flipped axis fails the check.

+

What it witnesses: every spike carries exactly one unmuted DAMPED_TRACK bound to the core on TRACK_Z. After a depsgraph update, each evaluated local +Z aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3.6°). A missing constraint, a muted one, a TRACK_TO stand-in, or a flipped axis fails the check.

Run

# Cheap correctness check (no render) — the CI check:
 blender --background --python damped_track_aim.py --
@@ -228,7 +228,7 @@ 

Source

CORE_RADIUS = 0.32 NEEDLE_DEPTH = 1.05 NEEDLE_RADIUS = 0.055 -# Local +Z must face the core; cos(3°) ≈ 0.9986 — leave a little room for +# Local +Z must face the core; 0.998 = cos(3.6°) — leave a little room for # cone tessellation / float noise while still catching a flipped axis. MIN_AIM_DOT = 0.998 LIFT = 1.55 @@ -481,7 +481,6 @@

Source

scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) - wall.data = floor_me.copy() wall.data.materials.clear() wall.data.materials.append( make_dielectric("StudioWall", (0.012, 0.013, 0.016), roughness=0.55) @@ -555,18 +554,8 @@

Source

scene.eevee.taa_render_samples = 128 except AttributeError: pass - # Bloom helps the ember core read without a compositor tree. - for attr, val in ( - ("use_bloom", True), - ("bloom_intensity", 0.12), - ("bloom_threshold", 0.85), - ("bloom_radius", 4.5), - ): - if hasattr(scene.eevee, attr): - try: - setattr(scene.eevee, attr, val) - except Exception: - pass + # No bloom here on purpose: EEVEE has no use_bloom on 4.2+ or 5.x — + # glow lives in the compositor (see examples/compositor-glare). scene.render.resolution_x = 1280 scene.render.resolution_y = 720 diff --git a/docs/gallery/depsgraph-export/index.html b/docs/gallery/depsgraph-export/index.html index 49537b7..6de2046 100644 --- a/docs/gallery/depsgraph-export/index.html +++ b/docs/gallery/depsgraph-export/index.html @@ -189,8 +189,12 @@

Run

# Cheap correctness check (writes an OBJ to a temp path, asserts the counts) — the CI check:
 blender --background --python depsgraph_export.py --
 
+# Also render a still of base vs evaluated (EEVEE on a GPU host; cycles on GPU-less hosts):
+blender --background --python depsgraph_export.py -- --output depsgraph.png
+blender --background --python depsgraph_export.py -- --output depsgraph.png --engine cycles
+
 # Write the exported OBJ to a specific path:
-blender --background --python depsgraph_export.py -- --output remeshed.obj
+blender --background --python depsgraph_export.py -- --obj exported.obj

It exits non-zero on failure (modifier not applied, or exported count ≠ evaluated count). The blender-smoke workflow runs this check on Blender 4.5 LTS and 5.1: base 8 → evaluated/exported 98 vertices with a 2-level SUBSURF.

@@ -199,25 +203,39 @@

Source

examples/depsgraph-export/depsgraph_export.py View on GitHub → -
"""Candidate B: depsgraph-evaluated export (SCRATCH).
+      
"""Depsgraph-evaluated export — a runnable example.
+
+Witnesses the depsgraph lifetime contract AND that modifiers actually ship in
+exports. Builds a cube with a SUBSURF modifier, measures the evaluated mesh via
+evaluated_get().to_mesh() (paired with to_mesh_clear()), exports through
+wm.obj_export, and asserts the exported vertex count equals the EVALUATED
+(modifier-applied) count and is strictly greater than the base.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
 
-Witnesses the depsgraph lifetime contract AND that modifiers actually ship in exports. Builds
-a cube with a SUBSURF modifier, measures the evaluated mesh via evaluated_get().to_mesh()
-(paired with to_mesh_clear()), exports through wm.obj_export, and asserts the exported vertex
-count equals the EVALUATED (modifier-applied) count and is strictly greater than the base.
+    blender --background --python depsgraph_export.py --                 # check only
+    blender --background --python depsgraph_export.py -- --output d.png  # + render
 """
-import bpy, bmesh, sys, os, argparse
+import bpy, bmesh, sys, os, math, argparse, tempfile
 
-def main():
-    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
-    p = argparse.ArgumentParser()
-    p.add_argument("--output", default=None, help="optional: write the exported OBJ here (else a temp path)")
-    args = p.parse_args(argv)
 
+def build():
     bpy.ops.wm.read_factory_settings(use_empty=True)
-    me = bpy.data.meshes.new("Cube"); bm = bmesh.new(); bmesh.ops.create_cube(bm, size=2.0); bm.to_mesh(me); bm.free()
-    obj = bpy.data.objects.new("Cube", me); bpy.context.collection.objects.link(obj)
+    me = bpy.data.meshes.new("Cube")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=2.0)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new("Cube", me)
+    bpy.context.collection.objects.link(obj)
     obj.modifiers.new("ss", 'SUBSURF').levels = 2
+    return obj
+
+
+def check(obj, obj_path):
     base = len(obj.data.vertices)
 
     # depsgraph lifetime contract: evaluate, read, then release with to_mesh_clear
@@ -227,27 +245,152 @@ 

Source

eval_vcount = len(em.vertices) ev.to_mesh_clear() # must be paired; releases the temporary mesh - import tempfile - out = args.output or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj") + out = obj_path or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj") os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True) # obj_export writes the evaluated (modifier-applied) geometry by default bpy.ops.wm.obj_export(filepath=out, export_selected_objects=False) if not (os.path.exists(out) and os.path.getsize(out) > 0): - print("ERROR: no OBJ written", file=sys.stderr); return 4 + print("ERROR: no OBJ written", file=sys.stderr) + return 4 exported = 0 with open(out) as f: for line in f: - if line.startswith("v "): exported += 1 + if line.startswith("v "): + exported += 1 print(f"base_vcount={base} eval_vcount={eval_vcount} exported_vcount={exported}") if not (eval_vcount > base): - print("ERROR: evaluated mesh did not apply the modifier", file=sys.stderr); return 3 + print("ERROR: evaluated mesh did not apply the modifier", file=sys.stderr) + return 3 if exported != eval_vcount: print(f"ERROR: export ({exported}) != evaluated ({eval_vcount}); modifier did not ship", - file=sys.stderr); return 5 + file=sys.stderr) + return 5 + return 0 + + +def eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + + +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 render_still(obj, path, engine): + """Base cube beside its evaluated form — the two counts the check compares.""" + scene = bpy.context.scene + + # left: the base mesh, modifier-free (a plain copy of the datablock) + base_obj = bpy.data.objects.new("Base", obj.data.copy()) + base_obj.location = (-1.7, 0.0, 1.0) + base_obj.data.materials.append( + principled("Graphite", (0.16, 0.17, 0.19, 1.0), 0.0, 0.7)) + bpy.context.collection.objects.link(base_obj) + + # right: the SUBSURF object — what the depsgraph evaluates and the OBJ ships. + # Rest it on the floor by measuring its own EVALUATED bounds (the subsurf + # limit surface shrinks, so the base-mesh -1.0 is not where it ends). + dg = bpy.context.evaluated_depsgraph_get() + em = obj.evaluated_get(dg).to_mesh() + bottom = min(v.co.z for v in em.vertices) + obj.evaluated_get(dg).to_mesh_clear() + obj.location = (1.8, 0.0, -bottom) + obj.data.materials.append( + principled("EvalGreen", (0.03, 0.32, 0.10, 1.0), 0.0, 0.15)) + for poly in obj.data.polygons: + poly.use_smooth = True + + 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() + floor_me.materials.append(principled("Studio", (0.24, 0.24, 0.25, 1.0), 0.0, 0.6)) + 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.045, 0.05, 0.055, 1.0), 0.0, 0.6)) + 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.035, 0.04, 0.05, 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), 1000.0, 8.0, (1.0, 0.99, 0.96), (46, 0, -35)) + light("Fill", (5.0, -3.5, 3.0), 350.0, 9.0, (0.85, 0.9, 1.0), (62, 0, 50)) + light("Rim", (1.5, 5.0, 4.0), 120.0, 6.0, (1.0, 0.85, 0.65), (-70, 0, 165)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 42.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (0.0, -9.0, 3.1) + cam.rotation_euler = (math.radians(76), 0.0, 0.0) + scene.collection.objects.link(cam) + scene.camera = cam + + 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 + bpy.ops.render.render(write_still=True) + return os.path.exists(path) and os.path.getsize(path) > 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), + help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--obj", default=None, + help="optional: write the exported OBJ here (else a temp path)") + args = p.parse_args(argv) + + obj = build() + code = check(obj, args.obj) + if code: + return code + + if args.output: + if not render_still(obj, os.path.abspath(args.output), args.engine): + print("ERROR: render produced no file", file=sys.stderr) + return 6 + print(f"rendered still {args.output}") + print("depsgraph-export OK") return 0 + if __name__ == "__main__": try: sys.exit(main()) diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html index dfb2590..4670034 100644 --- a/docs/gallery/driver-wave/index.html +++ b/docs/gallery/driver-wave/index.html @@ -226,7 +226,7 @@

Source

def wave_scale(i): """The driver function: column height profile, 0.4..2.4.""" - return 1.4 + math.sin(i * 0.6) if i >= 0 else 1.0 + return 1.4 + math.sin(i * 0.6) def build_columns(): @@ -351,6 +351,11 @@

Source

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' diff --git a/docs/gallery/gn-sdf-remesh/index.html b/docs/gallery/gn-sdf-remesh/index.html index 29bd3b5..3481ba5 100644 --- a/docs/gallery/gn-sdf-remesh/index.html +++ b/docs/gallery/gn-sdf-remesh/index.html @@ -261,7 +261,10 @@

Source

import bmesh sc = bpy.context.scene fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() - bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme) + finally: + bm.free() fmat = bpy.data.materials.new("Studio"); fmat.use_nodes = True fb = fmat.node_tree.nodes.get('Principled BSDF') fb.inputs['Base Color'].default_value = (0.055, 0.06, 0.07, 1) # dark graphite studio @@ -302,11 +305,16 @@

Source

p.add_argument("--engine", choices=["auto", "cycles"], default="auto") args = p.parse_args(argv) + # EEVEE-id inversion witnessed for real: the OTHER era's id must be + # rejected by this build, the helper's accepted eid = get_eevee_engine_id() - expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' - bpy.context.scene.render.engine = eid - if bpy.context.scene.render.engine != expected: - print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 + wrong = 'BLENDER_EEVEE_NEXT' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE' + try: + bpy.context.scene.render.engine = wrong + print(f"ERROR: wrong-era EEVEE id '{wrong}' was accepted", file=sys.stderr); return 5 + except TypeError: + pass # correctly rejected + bpy.context.scene.render.engine = eid # raises TypeError if the helper's id is invalid obj = build() base = len(obj.data.vertices) diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 7a1ce57..a24b72a 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -250,7 +250,7 @@

depsgraph-export

wave-displace

Bulk vertex IO at real scale — 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.

-

witnesses The bulk path is correct, not just fast: vertex count unchanged, Z span matches the wave amplitude, probe vertex matches the closed form exactly.

+

witnesses The bulk path is correct, not just fast: the Z span matches the wave amplitude and every vertex matches the closed-form wave, so a stride bug in the flat buffer cannot hide.

View example
diff --git a/docs/gallery/swatch-grid/index.html b/docs/gallery/swatch-grid/index.html index 103950e..7815bda 100644 --- a/docs/gallery/swatch-grid/index.html +++ b/docs/gallery/swatch-grid/index.html @@ -185,9 +185,12 @@

swatch-grid

A runnable example that renders a 3×2 grid of spheres — one material per cell — to a single PNG. It demonstrates the procedural-materials-and-shaders patterns end to end:

  • Principled BSDF metals (gold, copper: high metallic, low roughness) and dielectrics (red/blue plastic, white rough), configured with string socket lookups and 4-tuple colors.
  • The emission pattern (an emissive orange swatch).
  • The cross-version set_specular shim (SpecularSpecular IOR Level, renamed in Blender 4.0).
-

It doubles as a live proof of the EEVEE engine-id behavior: the version-branch helper resolves BLENDER_EEVEE on Blender 5.x and BLENDER_EEVEE_NEXT on 4.2–4.5, and the chosen id is asserted against the running build before rendering — so a regression in that mapping fails the example, not just the docs.

+

It doubles as a live proof of the EEVEE engine-id behavior: the version-branch helper resolves BLENDER_EEVEE on Blender 5.x and BLENDER_EEVEE_NEXT on 4.2–4.5, and the check witnesses the inversion for real — the *other* era's id must be rejected by the running build (assignment raises TypeError) and the helper's id accepted — so a regression in that mapping fails the example, not just the docs.

Run

-
# Default: render with the build's EEVEE engine (needs a GPU/display)
+
# Cheap correctness check (materials + engine-id witness, no render):
+blender --background --python swatch_grid.py --
+
+# Render and pixel-verify with the build's EEVEE engine (needs a GPU/display):
 blender --background --python swatch_grid.py -- --output swatch.png
 
 # GPU-less / CI hosts: render the pixels with Cycles (CPU). The EEVEE id is still
@@ -212,7 +215,10 @@ 

Source

the version-branch helper resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT` on 4.2-4.5, and the chosen id is asserted against the build before rendering. -Run headless: +By default it runs only the correctness check (no render) — the CI smoke check. +Pass --output to also render and pixel-verify a still: + + blender --background --python swatch_grid.py -- # check only blender --background --python swatch_grid.py -- --output swatch.png blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640 @@ -304,9 +310,11 @@

Source

for c in range(GRID_COLS): me = bpy.data.meshes.new(f"S{i}") bm = bmesh.new() - bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92) - bm.to_mesh(me) - bm.free() + try: + bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92) + bm.to_mesh(me) + finally: + bm.free() for poly in me.polygons: poly.use_smooth = True ob = bpy.data.objects.new(f"S{i}", me) @@ -364,7 +372,7 @@

Source

def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser(description="Render a procedural-materials swatch grid.") - p.add_argument("--output", required=True, help="Output PNG path") + p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", choices=["auto", "eevee", "cycles"], default="auto", help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts") p.add_argument("--samples", type=int, default=32) @@ -378,15 +386,25 @@

Source

build_scene(mats) sc = bpy.context.scene - # EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles. + # EEVEE engine-id proof: frame-independent, must hold even when we render with + # Cycles. Witness the inversion for real: the OTHER era's id must be rejected + # by this build, and the helper's id must be accepted. eid = get_eevee_engine_id() - expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' - sc.render.engine = eid - if sc.render.engine != expected: - print(f"ERROR: EEVEE id helper returned '{eid}', engine is '{sc.render.engine}', " - f"expected '{expected}'", file=sys.stderr) + wrong = 'BLENDER_EEVEE_NEXT' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE' + try: + sc.render.engine = wrong + print(f"ERROR: wrong-era EEVEE id '{wrong}' was accepted by this build — " + "the engine-id inversion this example witnesses is gone", file=sys.stderr) return 5 - print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'") + except TypeError: + pass # correctly rejected + sc.render.engine = eid # the helper's id must exist (raises TypeError if not) + print(f"eevee_engine_id={eid} accepted, '{wrong}' rejected OK; " + f"set_specular resolved '{specular_socket}'") + + if not args.output: + print("swatch-grid OK") + return 0 render_engine = 'CYCLES' if args.engine == 'cycles' else eid sc.render.engine = render_engine @@ -414,6 +432,7 @@

Source

if not (non_black and regions_ok): print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr) return 3 + print("swatch-grid OK") return 0 diff --git a/docs/gallery/turntable/index.html b/docs/gallery/turntable/index.html index 14340fc..c59753c 100644 --- a/docs/gallery/turntable/index.html +++ b/docs/gallery/turntable/index.html @@ -273,7 +273,10 @@

Source

import bmesh sc = bpy.context.scene fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() - bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme) + finally: + bm.free() floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) w = bpy.data.worlds.new("W"); w.use_nodes = True w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w @@ -305,12 +308,16 @@

Source

p.add_argument("--engine", choices=["auto", "cycles"], default="auto") args = p.parse_args(argv) - # the EEVEE-id mapping is asserted regardless of whether we render + # the EEVEE-id mapping is asserted regardless of whether we render: the + # OTHER era's id must be rejected by this build, the helper's accepted eid = get_eevee_engine_id() - expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' - bpy.context.scene.render.engine = eid - if bpy.context.scene.render.engine != expected: - print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 + wrong = 'BLENDER_EEVEE_NEXT' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE' + try: + bpy.context.scene.render.engine = wrong + print(f"ERROR: wrong-era EEVEE id '{wrong}' was accepted", file=sys.stderr); return 5 + except TypeError: + pass # correctly rejected + bpy.context.scene.render.engine = eid # raises TypeError if the helper's id is invalid obj = build() if not correctness(obj): diff --git a/docs/gallery/wave-displace/index.html b/docs/gallery/wave-displace/index.html index 2968fbd..6f4fecb 100644 --- a/docs/gallery/wave-displace/index.html +++ b/docs/gallery/wave-displace/index.html @@ -177,14 +177,14 @@

wave-displace

wave-displace render

Rendered headless by the example itself — click to zoom.

-
witnesses The bulk path is correct, not just fast: vertex count unchanged, Z span matches the wave amplitude, probe vertex matches the closed form exactly.
+
witnesses The bulk path is correct, not just fast: the Z span matches the wave amplitude and every vertex matches the closed-form wave, so a stride bug in the flat buffer cannot hide.
blender --background --python examples/wave-displace/wave_displace.py --

A runnable example that displaces a 96×96 grid (9,409 vertices) into a standing wave using one foreach_get and one foreach_set — the bulk-IO pattern from use-foreach-set-for-bulk-data and the mesh-editing-and-bmesh skill — instead of 9,409 individual mesh.vertices[i].co accesses.

-

What it witnesses: the bulk path is not just faster, it is *correct* — the check asserts the vertex count is unchanged, the flat grid gained the expected Z span (the write actually landed), and a probe vertex matches the closed-form wave exactly.

+

What it witnesses: the bulk path is not just faster, it is *correct* — the check asserts the flat grid gained the expected Z span (the write actually landed) and that every vertex matches the closed-form wave, so a stride or interleave bug in the flat buffer cannot hide behind a lucky probe.

Run

# Cheap correctness check (no render) — the CI check:
 blender --background --python wave_displace.py --
@@ -192,7 +192,7 @@ 

Run

# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python wave_displace.py -- --output wave.png blender --background --python wave_displace.py -- --output wave.png --engine cycles
-

It exits non-zero on failure (count changed, span wrong, or probe mismatch). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+

It exits non-zero on failure (span wrong, or any vertex off the closed form). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

Source

@@ -205,9 +205,10 @@

Source

Witnesses the use-foreach-set rule at real scale: a 96x96 grid (9409 verts) is displaced into a standing wave by reading every coordinate with one `foreach_get`, rewriting Z in Python, and writing back with one `foreach_set` -— no per-vertex `mesh.vertices[i].co` access. Asserts the vertex count is -unchanged, the flat grid gained the expected Z span, and a probe vertex -matches the closed-form wave. Exits non-zero on failure. +— no per-vertex `mesh.vertices[i].co` access. Asserts the flat grid gained +the expected Z span and that EVERY vertex matches the closed-form wave (a +stride or interleave bug in the flat buffer cannot hide). Exits non-zero on +failure. By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: @@ -244,7 +245,7 @@

Source

def displace(me): n = len(me.vertices) - buf = array("f", [0.0]) * (n * 3) + buf = array("f", [0.0] * (n * 3)) me.vertices.foreach_get("co", buf) # ONE bulk read for i in range(n): x, y = buf[i * 3], buf[i * 3 + 1] @@ -256,20 +257,18 @@

Source

def check(obj, n_before): me = obj.data - if len(me.vertices) != n_before: - print(f"ERROR: vertex count changed ({n_before} -> {len(me.vertices)})", file=sys.stderr) - return 3 zs = [v.co.z for v in me.vertices] span = max(zs) - min(zs) if not (1.6 * AMP < span <= 2.0 * AMP + 1e-4): print(f"ERROR: z-span {span:.4f} not in ({1.6 * AMP:.4f}, {2 * AMP:.4f}]", file=sys.stderr) return 4 - probe = me.vertices[0].co - expect = wave_z(probe.x, probe.y) - if abs(probe.z - expect) > 1e-5: - print(f"ERROR: probe z {probe.z:.6f} != wave {expect:.6f}", file=sys.stderr) + # every vertex must match the closed form, read back per-vertex — a stride + # or interleave bug in the flat foreach buffer cannot hide behind one probe + worst = max(abs(v.co.z - wave_z(v.co.x, v.co.y)) for v in me.vertices) + if worst > 1e-5: + print(f"ERROR: worst vertex is {worst:.6f} off the closed-form wave", file=sys.stderr) return 5 - print(f"verts={n_before} z_span={span:.4f} probe_ok=True") + print(f"verts={n_before} z_span={span:.4f} max_err={worst:.2e}") return 0 @@ -317,6 +316,11 @@

Source

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' diff --git a/examples/damped-track-aim/README.md b/examples/damped-track-aim/README.md index ad50552..a9e07b2 100644 --- a/examples/damped-track-aim/README.md +++ b/examples/damped-track-aim/README.md @@ -8,7 +8,7 @@ local axis at a target without the roll fights Track To is known for. **What it witnesses:** every spike carries exactly one unmuted `DAMPED_TRACK` bound to the core on `TRACK_Z`. After a depsgraph update, each evaluated local -`+Z` aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3°). A missing +`+Z` aligns with the world vector toward the core (dot ≥ 0.998 ≈ 3.6°). A missing constraint, a muted one, a `TRACK_TO` stand-in, or a flipped axis fails the check. diff --git a/examples/damped-track-aim/damped_track_aim.py b/examples/damped-track-aim/damped_track_aim.py index d6e4d1d..d3c19f1 100644 --- a/examples/damped-track-aim/damped_track_aim.py +++ b/examples/damped-track-aim/damped_track_aim.py @@ -26,7 +26,7 @@ CORE_RADIUS = 0.32 NEEDLE_DEPTH = 1.05 NEEDLE_RADIUS = 0.055 -# Local +Z must face the core; cos(3°) ≈ 0.9986 — leave a little room for +# Local +Z must face the core; 0.998 = cos(3.6°) — leave a little room for # cone tessellation / float noise while still catching a flipped axis. MIN_AIM_DOT = 0.998 LIFT = 1.55 @@ -279,7 +279,6 @@ def render_still(core, needles, path, engine): scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) - wall.data = floor_me.copy() wall.data.materials.clear() wall.data.materials.append( make_dielectric("StudioWall", (0.012, 0.013, 0.016), roughness=0.55) @@ -353,18 +352,8 @@ def light(name, loc, energy, size, col): scene.eevee.taa_render_samples = 128 except AttributeError: pass - # Bloom helps the ember core read without a compositor tree. - for attr, val in ( - ("use_bloom", True), - ("bloom_intensity", 0.12), - ("bloom_threshold", 0.85), - ("bloom_radius", 4.5), - ): - if hasattr(scene.eevee, attr): - try: - setattr(scene.eevee, attr, val) - except Exception: - pass + # No bloom here on purpose: EEVEE has no use_bloom on 4.2+ or 5.x — + # glow lives in the compositor (see examples/compositor-glare). scene.render.resolution_x = 1280 scene.render.resolution_y = 720 diff --git a/examples/depsgraph-export/README.md b/examples/depsgraph-export/README.md index 3565a7d..593c14c 100644 --- a/examples/depsgraph-export/README.md +++ b/examples/depsgraph-export/README.md @@ -17,8 +17,12 @@ export) rather than the unmodified base mesh. # Cheap correctness check (writes an OBJ to a temp path, asserts the counts) — the CI check: blender --background --python depsgraph_export.py -- +# Also render a still of base vs evaluated (EEVEE on a GPU host; cycles on GPU-less hosts): +blender --background --python depsgraph_export.py -- --output depsgraph.png +blender --background --python depsgraph_export.py -- --output depsgraph.png --engine cycles + # Write the exported OBJ to a specific path: -blender --background --python depsgraph_export.py -- --output remeshed.obj +blender --background --python depsgraph_export.py -- --obj exported.obj ``` It exits non-zero on failure (modifier not applied, or exported count ≠ evaluated count). The diff --git a/examples/depsgraph-export/depsgraph_export.py b/examples/depsgraph-export/depsgraph_export.py index 8e007fd..d0cbcf6 100644 --- a/examples/depsgraph-export/depsgraph_export.py +++ b/examples/depsgraph-export/depsgraph_export.py @@ -1,22 +1,36 @@ -"""Candidate B: depsgraph-evaluated export (SCRATCH). +"""Depsgraph-evaluated export — a runnable example. -Witnesses the depsgraph lifetime contract AND that modifiers actually ship in exports. Builds -a cube with a SUBSURF modifier, measures the evaluated mesh via evaluated_get().to_mesh() -(paired with to_mesh_clear()), exports through wm.obj_export, and asserts the exported vertex -count equals the EVALUATED (modifier-applied) count and is strictly greater than the base. +Witnesses the depsgraph lifetime contract AND that modifiers actually ship in +exports. Builds a cube with a SUBSURF modifier, measures the evaluated mesh via +evaluated_get().to_mesh() (paired with to_mesh_clear()), exports through +wm.obj_export, and asserts the exported vertex count equals the EVALUATED +(modifier-applied) count and is strictly greater than the base. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python depsgraph_export.py -- # check only + blender --background --python depsgraph_export.py -- --output d.png # + render """ -import bpy, bmesh, sys, os, argparse +import bpy, bmesh, sys, os, math, argparse, tempfile -def main(): - argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] - p = argparse.ArgumentParser() - p.add_argument("--output", default=None, help="optional: write the exported OBJ here (else a temp path)") - args = p.parse_args(argv) +def build(): bpy.ops.wm.read_factory_settings(use_empty=True) - me = bpy.data.meshes.new("Cube"); bm = bmesh.new(); bmesh.ops.create_cube(bm, size=2.0); bm.to_mesh(me); bm.free() - obj = bpy.data.objects.new("Cube", me); bpy.context.collection.objects.link(obj) + me = bpy.data.meshes.new("Cube") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=2.0) + bm.to_mesh(me) + finally: + bm.free() + obj = bpy.data.objects.new("Cube", me) + bpy.context.collection.objects.link(obj) obj.modifiers.new("ss", 'SUBSURF').levels = 2 + return obj + + +def check(obj, obj_path): base = len(obj.data.vertices) # depsgraph lifetime contract: evaluate, read, then release with to_mesh_clear @@ -26,27 +40,152 @@ def main(): eval_vcount = len(em.vertices) ev.to_mesh_clear() # must be paired; releases the temporary mesh - import tempfile - out = args.output or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj") + out = obj_path or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj") os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True) # obj_export writes the evaluated (modifier-applied) geometry by default bpy.ops.wm.obj_export(filepath=out, export_selected_objects=False) if not (os.path.exists(out) and os.path.getsize(out) > 0): - print("ERROR: no OBJ written", file=sys.stderr); return 4 + print("ERROR: no OBJ written", file=sys.stderr) + return 4 exported = 0 with open(out) as f: for line in f: - if line.startswith("v "): exported += 1 + if line.startswith("v "): + exported += 1 print(f"base_vcount={base} eval_vcount={eval_vcount} exported_vcount={exported}") if not (eval_vcount > base): - print("ERROR: evaluated mesh did not apply the modifier", file=sys.stderr); return 3 + print("ERROR: evaluated mesh did not apply the modifier", file=sys.stderr) + return 3 if exported != eval_vcount: print(f"ERROR: export ({exported}) != evaluated ({eval_vcount}); modifier did not ship", - file=sys.stderr); return 5 + file=sys.stderr) + return 5 + return 0 + + +def eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + + +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 render_still(obj, path, engine): + """Base cube beside its evaluated form — the two counts the check compares.""" + scene = bpy.context.scene + + # left: the base mesh, modifier-free (a plain copy of the datablock) + base_obj = bpy.data.objects.new("Base", obj.data.copy()) + base_obj.location = (-1.7, 0.0, 1.0) + base_obj.data.materials.append( + principled("Graphite", (0.16, 0.17, 0.19, 1.0), 0.0, 0.7)) + bpy.context.collection.objects.link(base_obj) + + # right: the SUBSURF object — what the depsgraph evaluates and the OBJ ships. + # Rest it on the floor by measuring its own EVALUATED bounds (the subsurf + # limit surface shrinks, so the base-mesh -1.0 is not where it ends). + dg = bpy.context.evaluated_depsgraph_get() + em = obj.evaluated_get(dg).to_mesh() + bottom = min(v.co.z for v in em.vertices) + obj.evaluated_get(dg).to_mesh_clear() + obj.location = (1.8, 0.0, -bottom) + obj.data.materials.append( + principled("EvalGreen", (0.03, 0.32, 0.10, 1.0), 0.0, 0.15)) + for poly in obj.data.polygons: + poly.use_smooth = True + + 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() + floor_me.materials.append(principled("Studio", (0.24, 0.24, 0.25, 1.0), 0.0, 0.6)) + 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.045, 0.05, 0.055, 1.0), 0.0, 0.6)) + 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.035, 0.04, 0.05, 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), 1000.0, 8.0, (1.0, 0.99, 0.96), (46, 0, -35)) + light("Fill", (5.0, -3.5, 3.0), 350.0, 9.0, (0.85, 0.9, 1.0), (62, 0, 50)) + light("Rim", (1.5, 5.0, 4.0), 120.0, 6.0, (1.0, 0.85, 0.65), (-70, 0, 165)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 42.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (0.0, -9.0, 3.1) + cam.rotation_euler = (math.radians(76), 0.0, 0.0) + scene.collection.objects.link(cam) + scene.camera = cam + + 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 + bpy.ops.render.render(write_still=True) + return os.path.exists(path) and os.path.getsize(path) > 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), + help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--obj", default=None, + help="optional: write the exported OBJ here (else a temp path)") + args = p.parse_args(argv) + + obj = build() + code = check(obj, args.obj) + if code: + return code + + if args.output: + if not render_still(obj, os.path.abspath(args.output), args.engine): + print("ERROR: render produced no file", file=sys.stderr) + return 6 + print(f"rendered still {args.output}") + print("depsgraph-export OK") return 0 + if __name__ == "__main__": try: sys.exit(main()) diff --git a/examples/depsgraph-export/preview.webp b/examples/depsgraph-export/preview.webp index 511652d4781c4c4dfe869b0773647bc95c8ec1b3..9504a0a55b5c9244202d167fad6bce22d4b3d1b0 100644 GIT binary patch literal 9236 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 diff --git a/examples/driver-wave/driver_wave.py b/examples/driver-wave/driver_wave.py index f17fa86..1528d78 100644 --- a/examples/driver-wave/driver_wave.py +++ b/examples/driver-wave/driver_wave.py @@ -23,7 +23,7 @@ def wave_scale(i): """The driver function: column height profile, 0.4..2.4.""" - return 1.4 + math.sin(i * 0.6) if i >= 0 else 1.0 + return 1.4 + math.sin(i * 0.6) def build_columns(): @@ -148,6 +148,11 @@ def render_still(objs, path, engine): 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' diff --git a/examples/gallery.json b/examples/gallery.json index faf0f57..e41bfb5 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -56,7 +56,7 @@ "name": "wave-displace", "dir": "examples/wave-displace", "teaches": "Bulk vertex IO at real scale \u2014 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.", - "witnessesFix": "The bulk path is correct, not just fast: vertex count unchanged, Z span matches the wave amplitude, probe vertex matches the closed form exactly.", + "witnessesFix": "The bulk path is correct, not just fast: the Z span matches the wave amplitude and every vertex matches the closed-form wave, so a stride bug in the flat buffer cannot hide.", "hero": "docs/gallery/assets/wave-displace-hero.webp", "preview": "examples/wave-displace/preview.webp", "tags": [ diff --git a/examples/gn-sdf-remesh/gn_sdf_remesh.py b/examples/gn-sdf-remesh/gn_sdf_remesh.py index 071b57f..102dc9f 100644 --- a/examples/gn-sdf-remesh/gn_sdf_remesh.py +++ b/examples/gn-sdf-remesh/gn_sdf_remesh.py @@ -58,7 +58,10 @@ def render_still(obj, path, engine): import bmesh sc = bpy.context.scene fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() - bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme) + finally: + bm.free() fmat = bpy.data.materials.new("Studio"); fmat.use_nodes = True fb = fmat.node_tree.nodes.get('Principled BSDF') fb.inputs['Base Color'].default_value = (0.055, 0.06, 0.07, 1) # dark graphite studio @@ -99,11 +102,16 @@ def main(): p.add_argument("--engine", choices=["auto", "cycles"], default="auto") args = p.parse_args(argv) + # EEVEE-id inversion witnessed for real: the OTHER era's id must be + # rejected by this build, the helper's accepted eid = get_eevee_engine_id() - expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' - bpy.context.scene.render.engine = eid - if bpy.context.scene.render.engine != expected: - print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 + wrong = 'BLENDER_EEVEE_NEXT' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE' + try: + bpy.context.scene.render.engine = wrong + print(f"ERROR: wrong-era EEVEE id '{wrong}' was accepted", file=sys.stderr); return 5 + except TypeError: + pass # correctly rejected + bpy.context.scene.render.engine = eid # raises TypeError if the helper's id is invalid obj = build() base = len(obj.data.vertices) diff --git a/examples/swatch-grid/README.md b/examples/swatch-grid/README.md index fe08340..7d4bbe8 100644 --- a/examples/swatch-grid/README.md +++ b/examples/swatch-grid/README.md @@ -12,14 +12,18 @@ patterns end to end: Blender 4.0). It doubles as a live proof of the **EEVEE engine-id** behavior: the version-branch helper -resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT` on 4.2–4.5, and the chosen -id is asserted against the running build before rendering — so a regression in that mapping -fails the example, not just the docs. +resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT` on 4.2–4.5, and the check +witnesses the inversion for real — the *other* era's id must be **rejected** by the running +build (assignment raises `TypeError`) and the helper's id accepted — so a regression in +that mapping fails the example, not just the docs. ## Run ```bash -# Default: render with the build's EEVEE engine (needs a GPU/display) +# Cheap correctness check (materials + engine-id witness, no render): +blender --background --python swatch_grid.py -- + +# Render and pixel-verify with the build's EEVEE engine (needs a GPU/display): blender --background --python swatch_grid.py -- --output swatch.png # GPU-less / CI hosts: render the pixels with Cycles (CPU). The EEVEE id is still diff --git a/examples/swatch-grid/swatch_grid.py b/examples/swatch-grid/swatch_grid.py index 5dc9eb5..85fc274 100644 --- a/examples/swatch-grid/swatch_grid.py +++ b/examples/swatch-grid/swatch_grid.py @@ -7,7 +7,10 @@ the version-branch helper resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT` on 4.2-4.5, and the chosen id is asserted against the build before rendering. -Run headless: +By default it runs only the correctness check (no render) — the CI smoke check. +Pass --output to also render and pixel-verify a still: + + blender --background --python swatch_grid.py -- # check only blender --background --python swatch_grid.py -- --output swatch.png blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640 @@ -99,9 +102,11 @@ def build_scene(mats): for c in range(GRID_COLS): me = bpy.data.meshes.new(f"S{i}") bm = bmesh.new() - bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92) - bm.to_mesh(me) - bm.free() + try: + bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92) + bm.to_mesh(me) + finally: + bm.free() for poly in me.polygons: poly.use_smooth = True ob = bpy.data.objects.new(f"S{i}", me) @@ -159,7 +164,7 @@ def verify_png(path): def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser(description="Render a procedural-materials swatch grid.") - p.add_argument("--output", required=True, help="Output PNG path") + p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", choices=["auto", "eevee", "cycles"], default="auto", help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts") p.add_argument("--samples", type=int, default=32) @@ -173,15 +178,25 @@ def main(): build_scene(mats) sc = bpy.context.scene - # EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles. + # EEVEE engine-id proof: frame-independent, must hold even when we render with + # Cycles. Witness the inversion for real: the OTHER era's id must be rejected + # by this build, and the helper's id must be accepted. eid = get_eevee_engine_id() - expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' - sc.render.engine = eid - if sc.render.engine != expected: - print(f"ERROR: EEVEE id helper returned '{eid}', engine is '{sc.render.engine}', " - f"expected '{expected}'", file=sys.stderr) + wrong = 'BLENDER_EEVEE_NEXT' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE' + try: + sc.render.engine = wrong + print(f"ERROR: wrong-era EEVEE id '{wrong}' was accepted by this build — " + "the engine-id inversion this example witnesses is gone", file=sys.stderr) return 5 - print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'") + except TypeError: + pass # correctly rejected + sc.render.engine = eid # the helper's id must exist (raises TypeError if not) + print(f"eevee_engine_id={eid} accepted, '{wrong}' rejected OK; " + f"set_specular resolved '{specular_socket}'") + + if not args.output: + print("swatch-grid OK") + return 0 render_engine = 'CYCLES' if args.engine == 'cycles' else eid sc.render.engine = render_engine @@ -209,6 +224,7 @@ def main(): if not (non_black and regions_ok): print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr) return 3 + print("swatch-grid OK") return 0 diff --git a/examples/turntable/turntable.py b/examples/turntable/turntable.py index 7924dd7..9c062dd 100644 --- a/examples/turntable/turntable.py +++ b/examples/turntable/turntable.py @@ -71,7 +71,10 @@ def render_still(obj, path, engine): import bmesh sc = bpy.context.scene fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() - bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme) + finally: + bm.free() floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) w = bpy.data.worlds.new("W"); w.use_nodes = True w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w @@ -103,12 +106,16 @@ def main(): p.add_argument("--engine", choices=["auto", "cycles"], default="auto") args = p.parse_args(argv) - # the EEVEE-id mapping is asserted regardless of whether we render + # the EEVEE-id mapping is asserted regardless of whether we render: the + # OTHER era's id must be rejected by this build, the helper's accepted eid = get_eevee_engine_id() - expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' - bpy.context.scene.render.engine = eid - if bpy.context.scene.render.engine != expected: - print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 + wrong = 'BLENDER_EEVEE_NEXT' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE' + try: + bpy.context.scene.render.engine = wrong + print(f"ERROR: wrong-era EEVEE id '{wrong}' was accepted", file=sys.stderr); return 5 + except TypeError: + pass # correctly rejected + bpy.context.scene.render.engine = eid # raises TypeError if the helper's id is invalid obj = build() if not correctness(obj): diff --git a/examples/wave-displace/README.md b/examples/wave-displace/README.md index ade151d..f45e480 100644 --- a/examples/wave-displace/README.md +++ b/examples/wave-displace/README.md @@ -7,8 +7,9 @@ A runnable example that displaces a 96×96 grid (9,409 vertices) into a standing 9,409 individual `mesh.vertices[i].co` accesses. **What it witnesses:** the bulk path is not just faster, it is *correct* — the check asserts -the vertex count is unchanged, the flat grid gained the expected Z span (the write actually -landed), and a probe vertex matches the closed-form wave exactly. +the flat grid gained the expected Z span (the write actually landed) and that **every** +vertex matches the closed-form wave, so a stride or interleave bug in the flat buffer +cannot hide behind a lucky probe. ## Run @@ -21,5 +22,5 @@ blender --background --python wave_displace.py -- --output wave.png blender --background --python wave_displace.py -- --output wave.png --engine cycles ``` -It exits non-zero on failure (count changed, span wrong, or probe mismatch). The +It exits non-zero on failure (span wrong, or any vertex off the closed form). The `blender-smoke` workflow runs the check on Blender 4.5 LTS and 5.1. diff --git a/examples/wave-displace/wave_displace.py b/examples/wave-displace/wave_displace.py index aafc292..677918f 100644 --- a/examples/wave-displace/wave_displace.py +++ b/examples/wave-displace/wave_displace.py @@ -3,9 +3,10 @@ Witnesses the use-foreach-set rule at real scale: a 96x96 grid (9409 verts) is displaced into a standing wave by reading every coordinate with one `foreach_get`, rewriting Z in Python, and writing back with one `foreach_set` -— no per-vertex `mesh.vertices[i].co` access. Asserts the vertex count is -unchanged, the flat grid gained the expected Z span, and a probe vertex -matches the closed-form wave. Exits non-zero on failure. +— no per-vertex `mesh.vertices[i].co` access. Asserts the flat grid gained +the expected Z span and that EVERY vertex matches the closed-form wave (a +stride or interleave bug in the flat buffer cannot hide). Exits non-zero on +failure. By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: @@ -42,7 +43,7 @@ def build_grid(): def displace(me): n = len(me.vertices) - buf = array("f", [0.0]) * (n * 3) + buf = array("f", [0.0] * (n * 3)) me.vertices.foreach_get("co", buf) # ONE bulk read for i in range(n): x, y = buf[i * 3], buf[i * 3 + 1] @@ -54,20 +55,18 @@ def displace(me): def check(obj, n_before): me = obj.data - if len(me.vertices) != n_before: - print(f"ERROR: vertex count changed ({n_before} -> {len(me.vertices)})", file=sys.stderr) - return 3 zs = [v.co.z for v in me.vertices] span = max(zs) - min(zs) if not (1.6 * AMP < span <= 2.0 * AMP + 1e-4): print(f"ERROR: z-span {span:.4f} not in ({1.6 * AMP:.4f}, {2 * AMP:.4f}]", file=sys.stderr) return 4 - probe = me.vertices[0].co - expect = wave_z(probe.x, probe.y) - if abs(probe.z - expect) > 1e-5: - print(f"ERROR: probe z {probe.z:.6f} != wave {expect:.6f}", file=sys.stderr) + # every vertex must match the closed form, read back per-vertex — a stride + # or interleave bug in the flat foreach buffer cannot hide behind one probe + worst = max(abs(v.co.z - wave_z(v.co.x, v.co.y)) for v in me.vertices) + if worst > 1e-5: + print(f"ERROR: worst vertex is {worst:.6f} off the closed-form wave", file=sys.stderr) return 5 - print(f"verts={n_before} z_span={span:.4f} probe_ok=True") + print(f"verts={n_before} z_span={span:.4f} max_err={worst:.2e}") return 0 @@ -115,6 +114,11 @@ def render_still(obj, path, engine): 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'