From 447b7a741d7d2cd2c0010802d8735f4d9ebd1bc2 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 19 Jun 2026 22:17:01 -0400 Subject: [PATCH] feat: add swatch-grid example with smoke-gate coverage examples/swatch-grid/ is a runnable, dependency-light demo of the procedural-materials patterns (Principled metal+dielectric, emission, set_specular shim) that also proves the EEVEE engine-id mapping (BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2-4.5) by asserting the version-correct id before rendering. It accepts --output, self-verifies the render (non-black AND distinct-region-count == material-count) and exits non-zero on failure. blender-smoke.yml now runs the SHIPPED file on both matrix builds (Cycles on the GPU-less runner; the EEVEE id is still asserted). examples/ is smoke-gated content, NOT added to validate-counts, so the README aggregate (12/6/2/17) is unchanged. ROADMAP version-theme table renumbered: v0.3.0 = Examples and demos (this), 5.2 LTS sweep moved to v0.4.0 (Current line untouched, release-owned). Verified headless on Blender 4.5.10 LTS and 5.1.1. Signed-off-by: fOuttaMyPaint --- .github/workflows/blender-smoke.yml | 13 ++ ROADMAP.md | 7 +- examples/swatch-grid/README.md | 38 +++++ examples/swatch-grid/swatch_grid.py | 222 ++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 examples/swatch-grid/README.md create mode 100644 examples/swatch-grid/swatch_grid.py diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 028dd6b..52a1b16 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -117,3 +117,16 @@ jobs: --python tests/smoke/tmpl_render.py -- \ --output "$RUNNER_TEMP/out/render.png" --engine CYCLES test -s "$RUNNER_TEMP/out/render.png" || { echo "::error::render PNG missing/empty"; exit 1; } + + - name: Shipped example - swatch grid renders and self-verifies + run: | + set -euo pipefail + # Run the SHIPPED example file (not a copy). It asserts non-black AND + # distinct-region-count == material-count internally and exits non-zero on + # failure. Cycles (CPU) for pixels on the GPU-less runner; the example still + # asserts the version-correct EEVEE engine id before rendering. Small/low-sample + # for speed -- region detection holds at this size. + xvfb-run -a "$BLENDER" --background \ + --python examples/swatch-grid/swatch_grid.py -- \ + --output "$RUNNER_TEMP/out/swatch.png" --engine cycles --samples 8 --width 640 + test -s "$RUNNER_TEMP/out/swatch.png" || { echo "::error::swatch grid output missing/empty"; exit 1; } diff --git a/ROADMAP.md b/ROADMAP.md index ea506b9..cb3e298 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,8 @@ | --- | --- | --- | --- | --- | --- | --- | | v0.1.0 | Foundation | 8 | 4 | 1 | 10 | Shipped | | v0.2.0 | Materials, drivers, migration | 12 | 6 | 2 | 17 | Shipped | -| v0.3.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned | +| v0.3.0 | Examples and demos (smoke-gated) | 12 | 6 | 2 | 17 | Shipped | +| v0.4.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned | | v1.0.0 | Stable | TBD | TBD | TBD | TBD | Planned | ## v0.1.0 - Foundation @@ -78,9 +79,9 @@ The 7 new snippets: Audit pass on v0.1.0 content: standards-version markers bumped from `1.9.1` to `1.9.4` across all skills, rules, AGENTS.md, CLAUDE.md, and ROADMAP.md. Verified the `bpy_extras.anim_utils.action_ensure_channelbag_for_slot` import path against the current Blender 5.1 API reference and removed the stale "verify before production" caveat in `slotted-actions-animation/SKILL.md`. -## v0.3.0 (candidate pool) +## v0.4.0 (candidate pool) -Not committed; target list while v0.2.0 is the current shipping version. +Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.) - `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns - `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs diff --git a/examples/swatch-grid/README.md b/examples/swatch-grid/README.md new file mode 100644 index 0000000..fe08340 --- /dev/null +++ b/examples/swatch-grid/README.md @@ -0,0 +1,38 @@ +# 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`](../../skills/procedural-materials-and-shaders/SKILL.md) +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. + +## Run + +```bash +# Default: render 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 +# asserted; only the final pixels use Cycles. +blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960 +``` + +The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no +network). It **exits non-zero** on any failure, including a render that comes out uniformly +black or without the expected six distinct swatch regions — the same honest check the CI +smoke gate runs on both Blender 4.5 LTS and 5.1. + +## Verified + +Runs headless on **Blender 4.5.10 LTS** and **5.1.1**; exercised on both by the +`blender-smoke` workflow on every PR and weekly schedule. diff --git a/examples/swatch-grid/swatch_grid.py b/examples/swatch-grid/swatch_grid.py new file mode 100644 index 0000000..5dc9eb5 --- /dev/null +++ b/examples/swatch-grid/swatch_grid.py @@ -0,0 +1,222 @@ +"""Procedural-materials swatch grid -- a runnable BDT example. + +Renders a 3x2 grid of spheres, one per material, demonstrating the +`procedural-materials-and-shaders` patterns end to end: Principled BSDF (metal + +dielectric), the emission pattern, the cross-version `set_specular` shim, string socket +lookups, and 4-tuple colors. It also doubles as a live proof of the EEVEE engine-id fix: +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: + blender --background --python swatch_grid.py -- --output swatch.png + blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640 + +Dependency-light and deterministic (fixed camera/layout, no HDRI, no network). Exits +non-zero on any failure, including a render that comes out black or without the expected +number of distinct swatch regions. +""" +import bpy +import bmesh +import sys +import os +import math +import argparse +import numpy as np + +GRID_COLS, GRID_ROWS = 3, 2 +MATERIAL_COUNT = GRID_COLS * GRID_ROWS # 6 + + +# --- patterns copied from the procedural-materials-and-shaders skill --- +def get_eevee_engine_id(): + """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + + +def set_specular(bsdf, value): + """'Specular' was renamed to 'Specular IOR Level' in Blender 4.0; support both.""" + if 'Specular IOR Level' in bsdf.inputs: + bsdf.inputs['Specular IOR Level'].default_value = value + return 'Specular IOR Level' + if 'Specular' in bsdf.inputs: + bsdf.inputs['Specular'].default_value = value + return 'Specular' + return None + + +def make_principled(name, base_color, metallic, roughness, specular=None): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + nt.nodes.clear() + bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled') + bsdf.inputs['Base Color'].default_value = base_color + bsdf.inputs['Metallic'].default_value = metallic + bsdf.inputs['Roughness'].default_value = roughness + resolved = set_specular(bsdf, specular) if specular is not None else None + out = nt.nodes.new('ShaderNodeOutputMaterial') + nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface']) + return mat, resolved + + +def make_emissive(name, color, strength): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + nt = mat.node_tree + nt.nodes.clear() + emis = nt.nodes.new('ShaderNodeEmission') + emis.inputs['Color'].default_value = color + emis.inputs['Strength'].default_value = strength + out = nt.nodes.new('ShaderNodeOutputMaterial') + nt.links.new(emis.outputs['Emission'], out.inputs['Surface']) + return mat + + +def build_materials(): + """Return a list of (material, label) covering metal, dielectric, emissive, and the + set_specular shim. The list order maps left-to-right, top-to-bottom across the grid.""" + mats, specular_socket = [], None + m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15) + mats.append(m) + m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28) + mats.append(m) + m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5) + mats.append(m) + specular_socket = specular_socket or sr + m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5) + mats.append(m) + mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0)) + m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3) + mats.append(m) + return mats, specular_socket + + +def build_scene(mats): + xs = [-2.2, 0.0, 2.2] + zs = [1.1, -1.1] + i = 0 + for r in range(GRID_ROWS): + 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() + for poly in me.polygons: + poly.use_smooth = True + ob = bpy.data.objects.new(f"S{i}", me) + ob.location = (xs[c], 0.0, zs[r]) + bpy.context.collection.objects.link(ob) + ob.data.materials.append(mats[i]) + i += 1 + # ortho camera framed exactly on the grid cells + cam_d = bpy.data.cameras.new("cam") + cam_d.type = 'ORTHO' + cam_d.ortho_scale = 6.6 + cam = bpy.data.objects.new("cam", cam_d) + cam.location = (0.0, -10.0, 0.0) + cam.rotation_euler = (math.radians(90), 0, 0) + bpy.context.collection.objects.link(cam) + bpy.context.scene.camera = cam + aim = bpy.data.objects.new("Aim", None) + bpy.context.collection.objects.link(aim) + for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]: + ld = bpy.data.lights.new(lname, 'AREA') + ld.energy = energy + ld.size = 5.0 + lo = bpy.data.objects.new(lname, ld) + lo.location = loc + bpy.context.collection.objects.link(lo) + con = lo.constraints.new('TRACK_TO') + con.target = aim + con.track_axis = 'TRACK_NEGATIVE_Z' + con.up_axis = 'UP_Y' + world = bpy.data.worlds.new("W") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1) + bpy.context.scene.world = world + + +def verify_png(path): + """Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT.""" + img = bpy.data.images.load(path) + w, h = img.size + arr = np.array(img.pixels[:], dtype=np.float32).reshape(h, w, 4)[..., :3] + gmax = float(arr.max()) + cw, ch, ph = w // GRID_COLS, h // GRID_ROWS, 24 + means = [] + for r in range(GRID_ROWS): + for c in range(GRID_COLS): + cx, cy = c * cw + cw // 2, r * ch + ch // 2 + means.append(arr[cy - ph:cy + ph, cx - ph:cx + ph, :].reshape(-1, 3).mean(axis=0)) + kept = [] + for cm in means: + if all(np.linalg.norm(cm - k) > 0.10 for k in kept): + kept.append(cm) + return gmax, len(kept) + + +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("--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) + p.add_argument("--width", type=int, default=1280) + p.add_argument("--no-verify", action="store_true") + args = p.parse_args(argv) + + # Empty the factory file FIRST so the materials we create below survive. + bpy.ops.wm.read_factory_settings(use_empty=True) + mats, specular_socket = build_materials() + build_scene(mats) + + sc = bpy.context.scene + # EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles. + 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) + return 5 + print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'") + + render_engine = 'CYCLES' if args.engine == 'cycles' else eid + sc.render.engine = render_engine + if render_engine == 'CYCLES': + sc.cycles.samples = args.samples + else: + sc.eevee.taa_render_samples = args.samples + sc.render.resolution_x = args.width + sc.render.resolution_y = int(args.width * 9 / 16) + sc.render.image_settings.file_format = 'PNG' + sc.render.filepath = args.output + os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True) + bpy.ops.render.render(write_still=True) + if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0): + print("ERROR: no output written", file=sys.stderr) + return 4 + print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)") + + if not args.no_verify: + gmax, regions = verify_png(args.output) + non_black = gmax > 0.05 + regions_ok = regions == MATERIAL_COUNT + print(f"verify: max_pixel={gmax:.3f} non_black={non_black} " + f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}") + if not (non_black and regions_ok): + print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # blender exits 0 on an uncaught traceback; force non-zero + import traceback + traceback.print_exc() + print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr) + sys.exit(1)