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 @@
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.
# 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.
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 (Specular → Specular 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
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'