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 bdf11a0..d85823f 100644 Binary files a/docs/gallery/assets/depsgraph-export-hero.webp and b/docs/gallery/assets/depsgraph-export-hero.webp differ 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 511652d..9504a0a 100644 Binary files a/examples/depsgraph-export/preview.webp and b/examples/depsgraph-export/preview.webp differ 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'