Skip to content

Commit 447b7a7

Browse files
committed
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 <TMhospitalitystrategies@gmail.com>
1 parent 577a300 commit 447b7a7

4 files changed

Lines changed: 277 additions & 3 deletions

File tree

.github/workflows/blender-smoke.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,16 @@ jobs:
117117
--python tests/smoke/tmpl_render.py -- \
118118
--output "$RUNNER_TEMP/out/render.png" --engine CYCLES
119119
test -s "$RUNNER_TEMP/out/render.png" || { echo "::error::render PNG missing/empty"; exit 1; }
120+
121+
- name: Shipped example - swatch grid renders and self-verifies
122+
run: |
123+
set -euo pipefail
124+
# Run the SHIPPED example file (not a copy). It asserts non-black AND
125+
# distinct-region-count == material-count internally and exits non-zero on
126+
# failure. Cycles (CPU) for pixels on the GPU-less runner; the example still
127+
# asserts the version-correct EEVEE engine id before rendering. Small/low-sample
128+
# for speed -- region detection holds at this size.
129+
xvfb-run -a "$BLENDER" --background \
130+
--python examples/swatch-grid/swatch_grid.py -- \
131+
--output "$RUNNER_TEMP/out/swatch.png" --engine cycles --samples 8 --width 640
132+
test -s "$RUNNER_TEMP/out/swatch.png" || { echo "::error::swatch grid output missing/empty"; exit 1; }

ROADMAP.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
| --- | --- | --- | --- | --- | --- | --- |
99
| v0.1.0 | Foundation | 8 | 4 | 1 | 10 | Shipped |
1010
| v0.2.0 | Materials, drivers, migration | 12 | 6 | 2 | 17 | Shipped |
11-
| v0.3.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned |
11+
| v0.3.0 | Examples and demos (smoke-gated) | 12 | 6 | 2 | 17 | Shipped |
12+
| v0.4.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned |
1213
| v1.0.0 | Stable | TBD | TBD | TBD | TBD | Planned |
1314

1415
## v0.1.0 - Foundation
@@ -78,9 +79,9 @@ The 7 new snippets:
7879

7980
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`.
8081

81-
## v0.3.0 (candidate pool)
82+
## v0.4.0 (candidate pool)
8283

83-
Not committed; target list while v0.2.0 is the current shipping version.
84+
Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.)
8485

8586
- `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns
8687
- `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs

examples/swatch-grid/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Swatch Grid
2+
3+
A runnable example that renders a 3×2 grid of spheres — one material per cell — to a single
4+
PNG. It demonstrates the [`procedural-materials-and-shaders`](../../skills/procedural-materials-and-shaders/SKILL.md)
5+
patterns end to end:
6+
7+
- **Principled BSDF** metals (gold, copper: high metallic, low roughness) and dielectrics
8+
(red/blue plastic, white rough), configured with **string socket lookups** and **4-tuple
9+
colors**.
10+
- The **emission** pattern (an emissive orange swatch).
11+
- The cross-version **`set_specular` shim** (`Specular``Specular IOR Level`, renamed in
12+
Blender 4.0).
13+
14+
It doubles as a live proof of the **EEVEE engine-id** behavior: the version-branch helper
15+
resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT` on 4.2–4.5, and the chosen
16+
id is asserted against the running build before rendering — so a regression in that mapping
17+
fails the example, not just the docs.
18+
19+
## Run
20+
21+
```bash
22+
# Default: render with the build's EEVEE engine (needs a GPU/display)
23+
blender --background --python swatch_grid.py -- --output swatch.png
24+
25+
# GPU-less / CI hosts: render the pixels with Cycles (CPU). The EEVEE id is still
26+
# asserted; only the final pixels use Cycles.
27+
blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960
28+
```
29+
30+
The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no
31+
network). It **exits non-zero** on any failure, including a render that comes out uniformly
32+
black or without the expected six distinct swatch regions — the same honest check the CI
33+
smoke gate runs on both Blender 4.5 LTS and 5.1.
34+
35+
## Verified
36+
37+
Runs headless on **Blender 4.5.10 LTS** and **5.1.1**; exercised on both by the
38+
`blender-smoke` workflow on every PR and weekly schedule.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Procedural-materials swatch grid -- a runnable BDT example.
2+
3+
Renders a 3x2 grid of spheres, one per material, demonstrating the
4+
`procedural-materials-and-shaders` patterns end to end: Principled BSDF (metal +
5+
dielectric), the emission pattern, the cross-version `set_specular` shim, string socket
6+
lookups, and 4-tuple colors. It also doubles as a live proof of the EEVEE engine-id fix:
7+
the version-branch helper resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT`
8+
on 4.2-4.5, and the chosen id is asserted against the build before rendering.
9+
10+
Run headless:
11+
blender --background --python swatch_grid.py -- --output swatch.png
12+
blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640
13+
14+
Dependency-light and deterministic (fixed camera/layout, no HDRI, no network). Exits
15+
non-zero on any failure, including a render that comes out black or without the expected
16+
number of distinct swatch regions.
17+
"""
18+
import bpy
19+
import bmesh
20+
import sys
21+
import os
22+
import math
23+
import argparse
24+
import numpy as np
25+
26+
GRID_COLS, GRID_ROWS = 3, 2
27+
MATERIAL_COUNT = GRID_COLS * GRID_ROWS # 6
28+
29+
30+
# --- patterns copied from the procedural-materials-and-shaders skill ---
31+
def get_eevee_engine_id():
32+
"""EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
33+
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
34+
35+
36+
def set_specular(bsdf, value):
37+
"""'Specular' was renamed to 'Specular IOR Level' in Blender 4.0; support both."""
38+
if 'Specular IOR Level' in bsdf.inputs:
39+
bsdf.inputs['Specular IOR Level'].default_value = value
40+
return 'Specular IOR Level'
41+
if 'Specular' in bsdf.inputs:
42+
bsdf.inputs['Specular'].default_value = value
43+
return 'Specular'
44+
return None
45+
46+
47+
def make_principled(name, base_color, metallic, roughness, specular=None):
48+
mat = bpy.data.materials.new(name)
49+
mat.use_nodes = True
50+
nt = mat.node_tree
51+
nt.nodes.clear()
52+
bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
53+
bsdf.inputs['Base Color'].default_value = base_color
54+
bsdf.inputs['Metallic'].default_value = metallic
55+
bsdf.inputs['Roughness'].default_value = roughness
56+
resolved = set_specular(bsdf, specular) if specular is not None else None
57+
out = nt.nodes.new('ShaderNodeOutputMaterial')
58+
nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
59+
return mat, resolved
60+
61+
62+
def make_emissive(name, color, strength):
63+
mat = bpy.data.materials.new(name)
64+
mat.use_nodes = True
65+
nt = mat.node_tree
66+
nt.nodes.clear()
67+
emis = nt.nodes.new('ShaderNodeEmission')
68+
emis.inputs['Color'].default_value = color
69+
emis.inputs['Strength'].default_value = strength
70+
out = nt.nodes.new('ShaderNodeOutputMaterial')
71+
nt.links.new(emis.outputs['Emission'], out.inputs['Surface'])
72+
return mat
73+
74+
75+
def build_materials():
76+
"""Return a list of (material, label) covering metal, dielectric, emissive, and the
77+
set_specular shim. The list order maps left-to-right, top-to-bottom across the grid."""
78+
mats, specular_socket = [], None
79+
m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15)
80+
mats.append(m)
81+
m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28)
82+
mats.append(m)
83+
m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5)
84+
mats.append(m)
85+
specular_socket = specular_socket or sr
86+
m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5)
87+
mats.append(m)
88+
mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0))
89+
m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3)
90+
mats.append(m)
91+
return mats, specular_socket
92+
93+
94+
def build_scene(mats):
95+
xs = [-2.2, 0.0, 2.2]
96+
zs = [1.1, -1.1]
97+
i = 0
98+
for r in range(GRID_ROWS):
99+
for c in range(GRID_COLS):
100+
me = bpy.data.meshes.new(f"S{i}")
101+
bm = bmesh.new()
102+
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92)
103+
bm.to_mesh(me)
104+
bm.free()
105+
for poly in me.polygons:
106+
poly.use_smooth = True
107+
ob = bpy.data.objects.new(f"S{i}", me)
108+
ob.location = (xs[c], 0.0, zs[r])
109+
bpy.context.collection.objects.link(ob)
110+
ob.data.materials.append(mats[i])
111+
i += 1
112+
# ortho camera framed exactly on the grid cells
113+
cam_d = bpy.data.cameras.new("cam")
114+
cam_d.type = 'ORTHO'
115+
cam_d.ortho_scale = 6.6
116+
cam = bpy.data.objects.new("cam", cam_d)
117+
cam.location = (0.0, -10.0, 0.0)
118+
cam.rotation_euler = (math.radians(90), 0, 0)
119+
bpy.context.collection.objects.link(cam)
120+
bpy.context.scene.camera = cam
121+
aim = bpy.data.objects.new("Aim", None)
122+
bpy.context.collection.objects.link(aim)
123+
for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]:
124+
ld = bpy.data.lights.new(lname, 'AREA')
125+
ld.energy = energy
126+
ld.size = 5.0
127+
lo = bpy.data.objects.new(lname, ld)
128+
lo.location = loc
129+
bpy.context.collection.objects.link(lo)
130+
con = lo.constraints.new('TRACK_TO')
131+
con.target = aim
132+
con.track_axis = 'TRACK_NEGATIVE_Z'
133+
con.up_axis = 'UP_Y'
134+
world = bpy.data.worlds.new("W")
135+
world.use_nodes = True
136+
world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1)
137+
bpy.context.scene.world = world
138+
139+
140+
def verify_png(path):
141+
"""Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT."""
142+
img = bpy.data.images.load(path)
143+
w, h = img.size
144+
arr = np.array(img.pixels[:], dtype=np.float32).reshape(h, w, 4)[..., :3]
145+
gmax = float(arr.max())
146+
cw, ch, ph = w // GRID_COLS, h // GRID_ROWS, 24
147+
means = []
148+
for r in range(GRID_ROWS):
149+
for c in range(GRID_COLS):
150+
cx, cy = c * cw + cw // 2, r * ch + ch // 2
151+
means.append(arr[cy - ph:cy + ph, cx - ph:cx + ph, :].reshape(-1, 3).mean(axis=0))
152+
kept = []
153+
for cm in means:
154+
if all(np.linalg.norm(cm - k) > 0.10 for k in kept):
155+
kept.append(cm)
156+
return gmax, len(kept)
157+
158+
159+
def main():
160+
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
161+
p = argparse.ArgumentParser(description="Render a procedural-materials swatch grid.")
162+
p.add_argument("--output", required=True, help="Output PNG path")
163+
p.add_argument("--engine", choices=["auto", "eevee", "cycles"], default="auto",
164+
help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts")
165+
p.add_argument("--samples", type=int, default=32)
166+
p.add_argument("--width", type=int, default=1280)
167+
p.add_argument("--no-verify", action="store_true")
168+
args = p.parse_args(argv)
169+
170+
# Empty the factory file FIRST so the materials we create below survive.
171+
bpy.ops.wm.read_factory_settings(use_empty=True)
172+
mats, specular_socket = build_materials()
173+
build_scene(mats)
174+
175+
sc = bpy.context.scene
176+
# EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles.
177+
eid = get_eevee_engine_id()
178+
expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
179+
sc.render.engine = eid
180+
if sc.render.engine != expected:
181+
print(f"ERROR: EEVEE id helper returned '{eid}', engine is '{sc.render.engine}', "
182+
f"expected '{expected}'", file=sys.stderr)
183+
return 5
184+
print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'")
185+
186+
render_engine = 'CYCLES' if args.engine == 'cycles' else eid
187+
sc.render.engine = render_engine
188+
if render_engine == 'CYCLES':
189+
sc.cycles.samples = args.samples
190+
else:
191+
sc.eevee.taa_render_samples = args.samples
192+
sc.render.resolution_x = args.width
193+
sc.render.resolution_y = int(args.width * 9 / 16)
194+
sc.render.image_settings.file_format = 'PNG'
195+
sc.render.filepath = args.output
196+
os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True)
197+
bpy.ops.render.render(write_still=True)
198+
if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0):
199+
print("ERROR: no output written", file=sys.stderr)
200+
return 4
201+
print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)")
202+
203+
if not args.no_verify:
204+
gmax, regions = verify_png(args.output)
205+
non_black = gmax > 0.05
206+
regions_ok = regions == MATERIAL_COUNT
207+
print(f"verify: max_pixel={gmax:.3f} non_black={non_black} "
208+
f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}")
209+
if not (non_black and regions_ok):
210+
print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr)
211+
return 3
212+
return 0
213+
214+
215+
if __name__ == "__main__":
216+
try:
217+
sys.exit(main())
218+
except Exception as exc: # blender exits 0 on an uncaught traceback; force non-zero
219+
import traceback
220+
traceback.print_exc()
221+
print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr)
222+
sys.exit(1)

0 commit comments

Comments
 (0)