Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/blender-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
7 changes: 4 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions examples/swatch-grid/README.md
Original file line number Diff line number Diff line change
@@ -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.
222 changes: 222 additions & 0 deletions examples/swatch-grid/swatch_grid.py
Original file line number Diff line number Diff line change
@@ -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)
Loading