diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7736cc5..8406623 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -60,8 +60,10 @@ ], "examples": [ "examples/depsgraph-export", + "examples/driver-wave", "examples/gn-sdf-remesh", "examples/swatch-grid", - "examples/turntable" + "examples/turntable", + "examples/wave-displace" ] } diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 532d03c..7e78cb8 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -155,3 +155,23 @@ jobs: # > base. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/depsgraph-export/depsgraph_export.py -- + + - name: Shipped example - wave displace (foreach bulk IO) + 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. + xvfb-run -a "$BLENDER" --background \ + --python examples/wave-displace/wave_displace.py -- + + - name: Shipped example - driver wave (driver_namespace + depsgraph) + run: | + set -euo pipefail + # Frame-independent check only (no render): sixteen SCRIPTED drivers call a + # driver_namespace function; asserts the driven values on the evaluated copy + # AND the flushed-back original both match the closed form. Exits non-zero + # on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/driver-wave/driver_wave.py -- diff --git a/README.md b/README.md index f24e155..b9b434b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,34 @@ A depsgraph-evaluated export — builds a cube with `SUBSURF`, measures the eval `evaluated_get().to_mesh()` / `to_mesh_clear()`, and asserts `wm.obj_export` ships the modifier-applied geometry (exported vertex count == evaluated > base). + + + + +Wave displace: a dense grid displaced into smooth standing-wave dunes, rendered with EEVEE + + + +### [wave-displace](examples/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. Asserts the count is unchanged, +the Z span matches the amplitude, and a probe vertex matches the closed-form wave. + + + + + +Driver wave: sixteen columns whose heights form a sine skyline, each driven by a driver_namespace function, rendered with EEVEE + + + +### [driver-wave](examples/driver-wave/) + +A `driver_namespace` function driving sixteen column heights through SCRIPTED drivers. +Witnesses the evaluation contract: driven values appear after a view-layer update on the +evaluated copy **and** the flushed-back original, and both must match the closed form. + diff --git a/docs/gallery/assets/driver-wave-hero.webp b/docs/gallery/assets/driver-wave-hero.webp new file mode 100644 index 0000000..da69c42 Binary files /dev/null and b/docs/gallery/assets/driver-wave-hero.webp differ diff --git a/docs/gallery/assets/wave-displace-hero.webp b/docs/gallery/assets/wave-displace-hero.webp new file mode 100644 index 0000000..192c620 Binary files /dev/null and b/docs/gallery/assets/wave-displace-hero.webp differ diff --git a/docs/gallery/driver-wave/index.html b/docs/gallery/driver-wave/index.html new file mode 100644 index 0000000..3bffc92 --- /dev/null +++ b/docs/gallery/driver-wave/index.html @@ -0,0 +1,417 @@ + + + + + + driver-wave — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub +
+
+
+

driver-wave

+

A driver_namespace function driving sixteen column heights through SCRIPTED drivers — the sine skyline is entirely driver-evaluated.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Driven values appear after a view-layer update in two places that must agree: the evaluated copy and the original datablock the animation system flushes for display.
+
+
blender --background --python examples/driver-wave/driver_wave.py --
+ +
+
+

A runnable example that drives sixteen column heights from a custom function registered in bpy.app.driver_namespace — the pattern from drivers-and-app-handlers. Each column gets a SCRIPTED driver on Z scale whose expression calls wave_scale(i), producing a sine skyline.

+

What it witnesses: the driver evaluation contract. Driven values appear only after a view-layer update, and they land in two places that must agree: the depsgraph-evaluated copy (evaluated_get(dg).scale) and the original datablock, which the animation system flushes for display. The check asserts both against the closed-form profile.

+

Note for real add-ons: driver_namespace entries do not persist in .blend files — re-register them from a load_post handler, or every driver that calls them fails on file open. Headless, registering before driver creation (as here) is enough.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python driver_wave.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python driver_wave.py -- --output driver.png
+blender --background --python driver_wave.py -- --output driver.png --engine cycles
+

It exits non-zero on failure (driven value wrong, or the flush-back disagreed). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/driver-wave/driver_wave.py + View on GitHub → +
+
"""Driver-namespace scale drivers, evaluated through the depsgraph — a runnable example.
+
+Witnesses the drivers-and-app-handlers contract end to end: a custom function
+is registered in `bpy.app.driver_namespace`, sixteen columns get a SCRIPTED
+driver on Z scale calling it, and the check reads the driven values back
+after a view-layer update — from the evaluated copy AND from the original
+(the animation system flushes driven values back to the original datablock
+for display, so both must agree). Asserts both against the closed-form
+profile. 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:
+
+    blender --background --python driver_wave.py --                 # check only
+    blender --background --python driver_wave.py -- --output d.png  # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+COUNT = 16
+SPACING = 0.72
+BASE = 0.28
+
+
+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
+
+
+def build_columns():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    # driver_namespace entries do not persist in .blend files; real add-ons
+    # re-register them from a load_post handler. Headless, registering before
+    # driver creation is enough.
+    bpy.app.driver_namespace["wave_scale"] = wave_scale
+
+    me = bpy.data.meshes.new("Column")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=1.0)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+
+    objs = []
+    x0 = -(COUNT - 1) * SPACING / 2
+    for i in range(COUNT):
+        obj = bpy.data.objects.new(f"Col.{i:02d}", me)
+        obj.location = (x0 + i * SPACING, 0.0, 0.0)
+        obj.scale = (BASE, BASE, 1.0)
+        fcu = obj.driver_add("scale", 2)
+        fcu.driver.type = 'SCRIPTED'
+        fcu.driver.expression = f"wave_scale({i})"
+        bpy.context.collection.objects.link(obj)
+        objs.append(obj)
+    return objs
+
+
+def check(objs):
+    bpy.context.view_layer.update()
+    dg = bpy.context.evaluated_depsgraph_get()
+    for i, obj in enumerate(objs):
+        expect = wave_scale(i)
+        driven = obj.evaluated_get(dg).scale[2]
+        if abs(driven - expect) > 1e-4:
+            print(f"ERROR: col {i} evaluated scale {driven:.4f} != wave_scale {expect:.4f}",
+                  file=sys.stderr)
+            return 3
+        # drivers flush back to the original datablock for display — both agree
+        if abs(obj.scale[2] - expect) > 1e-4:
+            print(f"ERROR: col {i} original scale {obj.scale[2]:.4f} not flushed "
+                  f"(expected {expect:.4f})", file=sys.stderr)
+            return 4
+    lo = min(wave_scale(i) for i in range(COUNT))
+    hi = max(wave_scale(i) for i in range(COUNT))
+    print(f"columns={COUNT} driven_range={lo:.3f}..{hi:.3f} flushed_to_original=True")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(objs, path, engine):
+    scene = bpy.context.scene
+    mat = bpy.data.materials.new("ColMat")
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = (0.62, 0.68, 0.78, 1.0)
+    bsdf.inputs["Roughness"].default_value = 0.45
+    objs[0].data.materials.append(mat)  # shared mesh -> all columns
+
+    # columns stand on the floor: lift each by its DRIVEN half-height
+    dg = bpy.context.evaluated_depsgraph_get()
+    for obj in objs:
+        obj.location.z = obj.evaluated_get(dg).scale[2] / 2
+
+    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 = bpy.data.objects.new("Floor", floor_me)
+    fmat = bpy.data.materials.new("FloorMat")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.55, 0.57, 0.62, 1.0)
+    fb.inputs["Roughness"].default_value = 0.9
+    floor_me.materials.append(fmat)
+    scene.collection.objects.link(floor)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.045, 0.05, 0.06, 1.0)
+    scene.world = world
+
+    key = bpy.data.lights.new("Key", 'AREA'); key.energy = 1000.0; key.size = 6.0
+    key_ob = bpy.data.objects.new("Key", key)
+    key_ob.location = (-4.5, -5.5, 6.5)
+    key_ob.rotation_euler = (math.radians(46), 0.0, math.radians(-33))
+    scene.collection.objects.link(key_ob)
+    fill = bpy.data.lights.new("Fill", 'AREA'); fill.energy = 350.0; fill.size = 8.0
+    fill_ob = bpy.data.objects.new("Fill", fill)
+    fill_ob.location = (5.5, -4.0, 3.5)
+    fill_ob.rotation_euler = (math.radians(62), 0.0, math.radians(48))
+    scene.collection.objects.link(fill_ob)
+
+    cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 45.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.0, -13.0, 2.4)
+    cam.rotation_euler = (math.radians(83), 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
+    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)")
+    args = p.parse_args(argv)
+
+    objs = build_columns()
+    code = check(objs)
+    if code:
+        return code
+
+    if args.output:
+        if not render_still(objs, 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("driver-wave OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)
+
+
+
+ + + + + diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 888d16c..5d4b39e 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -176,9 +176,12 @@

Examples Gallery

+ + +
@@ -227,6 +230,28 @@

depsgraph-export

View example +
+ + 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 + +
+

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.

+ View example +
+
+
+ + driver-wave — A driver_namespace function driving sixteen column heights through SCRIPTED drivers — the sine skyline is entirely driver-evaluated + +
+

driver-wave

+

A driver_namespace function driving sixteen column heights through SCRIPTED drivers — the sine skyline is entirely driver-evaluated.

+

witnesses Driven values appear after a view-layer update in two places that must agree: the evaluated copy and the original datablock the animation system flushes for display.

+ View example +
+