diff --git a/CLAUDE.md b/CLAUDE.md
index 3ddf6f4c..a458c0aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -159,7 +159,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
- `docs/VISUAL-STYLE.md` is the **binding** render standard; deviations are defects.
- **Framing gate:** Layer 1 framing is measured, not eyeballed — the example's `--output` render path calls the shared helper `examples/gallery_framing.py` (`check_framing`, exit 10 on violation) before writing the still: hero fill 0.70–0.90 of frame in at least one axis, every element that matters clearing all four edges by ≥ 0.02. The check-only path never invokes it, so smoke runtimes are unaffected. The helper measures; call sites enforce. Bleed compositions call `measure_framing_deviation` and assert their own cap at the call site — `check_framing` has no `deviation=` flag.
- **Contact-sheet gate:** composite the candidate hero beside the pinned calibration set — currently `armature-bend`, `damped-track-aim`, `bmesh-gear` — commit the composite under `docs/gallery/contact-sheets/`, link it in the PR body, and report per-criterion verdicts (stage darkness, wedge warmth, subject fill, saturation, thumbnail legibility) including mean luminance versus the calibration images. A claim without the committed composite is not acceptable evidence. **This list is the canonical home of the pinned set** — update it here when a new example outclasses a member; `docs/new-example-prompt.md` points here rather than naming members. The longer "calibration references" list in `docs/VISUAL-STYLE.md` is a style reference, not this contact-sheet set. Tooling: `python scripts/render_hero.py --blender PATH --only NAME` renders the README `--output` command and writes the hero and preview webps (the one committed PNG-to-webp path); `python scripts/contact_sheet.py NAME` builds the composite in the committed format and prints each tile's luma, stage, wedge warmth and saturation; `python scripts/measure_hero_drift.py` checks committed heroes against what the code renders now.
-- **Asset-sheet gate (asset-type examples — game props/kits):** composite the hero asset rendered alone (neutral three-quarter view, plain studio lighting, no staging tricks, no labels, no comparison props) beside the pinned asset-quality reference set — currently `collision-hull-proxy`, `custom-normals-shade`, `vertex-weight-limit`, `lod-decimate-chain` — rendered the same way; commit under `docs/gallery/asset-sheets/`, link it in the PR body, and report a verdict. The asset ships only if it is not identifiable as the least-designed object in that lineup — a strong scene can carry a weak model; this gate removes the scene. **This list is the canonical home of the reference set** — update it here when a new asset outclasses a member. The measurable floors behind the gate (naming, material variation, edge treatment) live in `examples/gallery_asset_quality.py` — render path only, same call pattern as `gallery_framing`, exit 11 on violation — with the calibration table and dropped-floor evidence in `docs/VISUAL-STYLE.md` § Asset quality.
+- **Asset-sheet gate (asset-type examples — game props/kits):** composite the hero asset rendered alone (neutral three-quarter view, plain studio lighting, no staging tricks, no labels, no comparison props) beside the pinned asset-quality reference set — currently `collision-hull-proxy`, `socket-attach-points`, `vertex-color-ao`, `wheelbarrow`, `apothecary-shelf` — rendered the same way; commit under `docs/gallery/asset-sheets/`, link it in the PR body, and report a verdict. The asset ships only if it is not identifiable as the least-designed object in that lineup — a strong scene can carry a weak model; this gate removes the scene. **This list is the canonical home of the reference set** — update it here when a new asset outclasses a member. A reference may be an example or a showcase piece. Tooling: `python scripts/asset_sheet.py --blender PATH NAME` renders the candidate and this set (read from this line) through `scripts/asset_sheet_panel.py` and writes the composite. The measurable floors behind the gate (naming, material variation, edge treatment) live in `examples/gallery_asset_quality.py` — render path only, same call pattern as `gallery_framing`, exit 11 on violation — with the calibration table and dropped-floor evidence in `docs/VISUAL-STYLE.md` § Asset quality.
- **Falsification:** every check must be proven to fail once — break the contract, observe the non-zero exit, restore — with the probe and the measured error reported in the PR body. An assertion that cannot fail witnesses nothing.
- **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `
` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery//index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68).
- **Playwright gallery captures:** gallery `
` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 45-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels.
diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md
index 8bce0848..69e0df80 100644
--- a/docs/VISUAL-STYLE.md
+++ b/docs/VISUAL-STYLE.md
@@ -130,14 +130,23 @@ violation; never imported by a check-only path):
(`bmesh-gear` measures 0.667); raw boxes (1.0) fail.
Calibrated empirically against the gallery's own assets, exactly as the
-0.02 margin floor was:
+0.02 margin floor was. The four new reference rows were measured on the
+isolated asset by `scripts/asset_sheet_panel.py` (the sheet renderer prints
+the floors per panel); compactness was not re-measured for them. The three
+former references passed every floor and were still demoted by eye in the
+2026-09 gallery review — the floors cannot tell a primitive-built rocket
+from a designed wheelbarrow, which is the Status section below in practice:
| asset | parts | materials | edge90 | compactness (info only) |
| --- | --- | --- | --- | --- |
| collision-hull-proxy (reference) | 16 | 6 | 0.044 | 65.9 |
-| custom-normals-shade (reference) | 39 | 2 | 0.288 | 31.0 |
-| vertex-weight-limit (reference) | 1 | 4 | 0.150 | 34.8 |
-| lod-decimate-chain (reference) | 3 | 4 | 0.019 | 79.1 |
+| socket-attach-points (reference) | 21 | 32 | 0.027 | — |
+| vertex-color-ao (reference) | 11 | 11 | 0.171 | — |
+| wheelbarrow (reference, showcase) | 1 | 2 | 0.216 | — |
+| apothecary-shelf (reference, showcase) | 1 | 6 | 0.055 | — |
+| custom-normals-shade (former reference) | 39 | 2 | 0.288 | 31.0 |
+| vertex-weight-limit (former reference) | 1 | 4 | 0.150 | 34.8 |
+| lod-decimate-chain (former reference) | 3 | 4 | 0.019 | 79.1 |
| depsgraph-export (weak) | 2 | 1 | 1.000 | 23.9 |
| text-version-stamp (weak) | 1 | 1 | 1.000 | 405.3 |
| bmesh-gear (simple, honest) | 1 | 1 | 0.667 | 18.3 |
diff --git a/docs/gallery/asset-sheets/lod-decimate-chain.webp b/docs/gallery/asset-sheets/lod-decimate-chain.webp
new file mode 100644
index 00000000..419cd047
Binary files /dev/null and b/docs/gallery/asset-sheets/lod-decimate-chain.webp differ
diff --git a/scripts/asset_sheet.py b/scripts/asset_sheet.py
new file mode 100644
index 00000000..31ac745c
--- /dev/null
+++ b/scripts/asset_sheet.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""Build the asset-sheet gate composite for one asset: candidate beside the references.
+
+The asset-sheet gate (CLAUDE.md § Quality Gates for Example Runs) renders the
+hero asset alone — neutral three-quarter view, plain studio lighting, no
+staging, no labels — beside the pinned asset-quality reference set rendered
+the same way, and asks one question: is the candidate identifiable as the
+least-designed object in the lineup?
+
+The reference set is read from CLAUDE.md, its canonical home, so this script
+never disagrees with the gate it serves. Panel layout is 3x2 at 640x360:
+candidate top-left, then the references in CLAUDE.md order.
+
+For each name the Blender side (``scripts/asset_sheet_panel.py``) stages the
+entry through the README's documented ``--output`` command, keeps only the
+mesh objects matched by ``SELECT`` below, and re-renders them on a grey
+sweep. Before this file the panel renderer lived in uncommitted scratch
+scripts, so a sheet could not be reproduced (the same gap #200 closed for
+heroes).
+
+Authoring tool, not CI: host Python with Pillow; one full staging render
+per panel.
+
+Usage:
+ python scripts/asset_sheet.py --blender PATH NAME [--out PATH] [--panels DIR]
+
+Writes ``docs/gallery/asset-sheets/NAME.webp`` unless ``--out`` is given.
+``--panels`` keeps the per-asset PNGs (and reuses any already there).
+
+Exit codes: 0 written, 2 usage, 3 a panel failed.
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+from PIL import Image
+
+from measure_hero_drift import REPO, entries, render_args
+
+PANEL = (640, 360)
+GUTTER = 8
+QUALITY = 88
+
+# Mesh objects that make up each entry's hero asset: (include, exclude)
+# regexes on object names. Comparison props, placards and stage are left out
+# by construction. A new asset-type entry adds its row here.
+SELECT = {
+ "collision-hull-proxy": (r"^(Body|Nut|PumperCap|Lug\d|SideCap\d)$", None),
+ "custom-normals-shade": (r"_byangle$", None),
+ "vertex-weight-limit": (r"^MechArm$", None),
+ "lod-decimate-chain": (r"^Rocket$", None),
+ "modular-kit-snap": (r"^Kit\.CorridorSeg\.", r"\.0\d\d$"),
+ "lightmap-uv-channel": (r"^Cart\.", None),
+ "socket-attach-points": (r"^Drone\.Survey\.", None),
+ "vertex-color-ao": (r"^Well\.Stone\.", None),
+ "wooden-yoke": (r"^YokeLow$", None),
+ "apothecary-shelf": (r"^ShelfLow$", None),
+ "brazier": (r"^BrazierLow$", None),
+ "grain-sacks": (r"^SacksLow$", None),
+ "rope-bridge": (r"^BridgeLow$", None),
+ "wheelbarrow": (r"^BarrowLow$", None),
+}
+
+_REF_LINE = re.compile(r"Asset-sheet gate.*?reference set — currently (.+?) — rendered", re.S)
+
+
+def reference_set() -> list[str]:
+ """The pinned reference set, parsed from its canonical home in CLAUDE.md."""
+ text = (REPO / "CLAUDE.md").read_text(encoding="utf-8")
+ m = _REF_LINE.search(text)
+ if not m:
+ raise SystemExit("could not find the asset-sheet reference set in CLAUDE.md")
+ return re.findall(r"`([a-z0-9-]+)`", m.group(1))
+
+
+def render_panel(blender: str, entry: dict, png: Path) -> bool:
+ inc, exc = SELECT[entry["name"]]
+ with tempfile.TemporaryDirectory() as tmp:
+ cmd = render_args(entry, Path(tmp) / "stage.png")
+ if cmd is None:
+ print(f"{entry['name']}: no --output command in README", flush=True)
+ return False
+ script = next((REPO / entry["dir"]).glob("*.py"))
+ env = dict(os.environ, BDT_SHEET_SCRIPT=str(script), BDT_SHEET_SELECT=inc,
+ BDT_SHEET_EXCLUDE=exc or "", BDT_SHEET_OUT=str(png))
+ proc = subprocess.run(
+ [blender, "--background", "--factory-startup", "--python",
+ str(REPO / "scripts" / "asset_sheet_panel.py"), "--", *cmd],
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
+ cwd=REPO, env=env,
+ )
+ for line in proc.stdout.splitlines():
+ if line.startswith("sheet:"):
+ print(f"{entry['name']}: {line}", flush=True)
+ if proc.returncode != 0 or not png.is_file():
+ tail = (proc.stdout + proc.stderr).strip().splitlines()[-3:]
+ print(f"{entry['name']}: panel failed, exit {proc.returncode}: {' | '.join(tail)}",
+ flush=True)
+ return False
+ return True
+
+
+def main(argv=None) -> int:
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
+ p.add_argument("--blender", required=True, help="Blender binary to render with")
+ p.add_argument("name", help="candidate entry (examples/ or showcase/ gallery name)")
+ p.add_argument("--out", help="composite path (default docs/gallery/asset-sheets/NAME.webp)")
+ p.add_argument("--panels", help="directory to keep and reuse per-asset panel PNGs")
+ args = p.parse_args(argv)
+
+ by_name = {e["name"]: e for e in entries()}
+ refs = reference_set()
+ lineup = [args.name] + [r for r in refs if r != args.name]
+ missing = [n for n in lineup if n not in by_name or n not in SELECT]
+ if missing:
+ print(f"no gallery entry or SELECT row for: {', '.join(missing)}", file=sys.stderr)
+ return 2
+
+ blender = str(Path(args.blender).resolve())
+ version = subprocess.run([blender, "--version"], capture_output=True, text=True,
+ encoding="utf-8", errors="replace").stdout.splitlines()[0]
+ print(f"# renderer: {blender}\n# version: {version}\n# references: {', '.join(refs)}",
+ flush=True)
+
+ keep = Path(args.panels) if args.panels else None
+ with tempfile.TemporaryDirectory() as tmp:
+ pdir = keep or Path(tmp)
+ pdir.mkdir(parents=True, exist_ok=True)
+ panels = []
+ for n in lineup:
+ png = pdir / f"{n}.png"
+ # the candidate is always re-rendered; references may be reused
+ if not (keep and png.is_file() and n != args.name):
+ if not render_panel(blender, by_name[n], png):
+ return 3
+ panels.append(Image.open(png).convert("RGB").resize(PANEL, Image.LANCZOS))
+
+ cols = 3
+ rows = -(-len(panels) // cols)
+ sheet = Image.new("RGB", (PANEL[0] * cols + GUTTER * (cols - 1),
+ PANEL[1] * rows + GUTTER * (rows - 1)), (18, 18, 20))
+ for i, im in enumerate(panels):
+ sheet.paste(im, ((i % cols) * (PANEL[0] + GUTTER), (i // cols) * (PANEL[1] + GUTTER)))
+ out = Path(args.out) if args.out else REPO / "docs/gallery/asset-sheets" / f"{args.name}.webp"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ sheet.save(out, "WEBP", quality=QUALITY)
+ print(f"wrote {out.relative_to(REPO) if out.is_relative_to(REPO) else out} "
+ f"({out.stat().st_size} B): {' | '.join(lineup)}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/asset_sheet_panel.py b/scripts/asset_sheet_panel.py
new file mode 100644
index 00000000..68886b58
--- /dev/null
+++ b/scripts/asset_sheet_panel.py
@@ -0,0 +1,165 @@
+"""Blender side of ``scripts/asset_sheet.py``: one neutral asset-sheet panel.
+
+Stages an example (or showcase piece) through its own documented ``--output``
+path, captures the scene that was rendered, keeps only the hero asset's mesh
+objects, then re-stages them alone: grey sweep, plain three-light studio,
+three-quarter view fitted to the asset's real extent, no labels, no comparison
+props. Renders one 640x360 panel. Isolating the asset from its staging is the
+point of the gate — a strong scene can carry a weak model.
+
+Not run directly. The host script passes everything through the environment
+so the argv after ``--`` stays exactly the example's documented flags:
+
+ BDT_SHEET_SCRIPT path to the example's .py
+ BDT_SHEET_SELECT regex matched against mesh object names (the asset)
+ BDT_SHEET_EXCLUDE optional regex of names to drop from that match
+ BDT_SHEET_OUT panel PNG to write
+"""
+import importlib.util
+import math
+import os
+import re
+import sys
+
+import bmesh
+import bpy
+from mathutils import Vector
+
+RES_X, RES_Y = 640, 360
+
+script = os.environ["BDT_SHEET_SCRIPT"]
+inc_re = os.environ["BDT_SHEET_SELECT"]
+exc_re = os.environ.get("BDT_SHEET_EXCLUDE") or None
+out_png = os.path.abspath(os.environ["BDT_SHEET_OUT"])
+
+# examples import their shared helpers through a __file__-relative shim; the
+# showcase ones do the same, so the module only needs to load from its path
+spec = importlib.util.spec_from_file_location("sheet_mod", script)
+mod = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(mod)
+
+captured = []
+
+
+@bpy.app.handlers.persistent
+def _pre(scene, depsgraph=None):
+ captured.append(scene.name)
+
+
+bpy.app.handlers.render_pre.append(_pre)
+code = mod.main()
+bpy.app.handlers.render_pre.remove(_pre)
+if code not in (None, 0):
+ print(f"sheet: staging run exited {code}", file=sys.stderr)
+ sys.exit(3)
+
+sc = bpy.data.scenes[captured[-1]] if captured else bpy.context.scene
+asset = [ob for ob in sc.objects
+ if ob.type == "MESH" and re.search(inc_re, ob.name)
+ and not (exc_re and re.search(exc_re, ob.name))]
+print(f"sheet: asset objects {[ob.name for ob in asset]}")
+if not asset:
+ print(f"sheet: no mesh object matches {inc_re!r}", file=sys.stderr)
+ sys.exit(4)
+
+# The measurable floors of the isolated asset, as information for the
+# calibration table in docs/VISUAL-STYLE.md § Asset quality. The gate itself
+# runs on each example's own render path; nothing is enforced here.
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "examples"))
+import gallery_asset_quality as aq # noqa: E402
+
+parts, _ = aq.measure_parts(asset)
+mats = aq.measure_materials(asset)[0]
+edge90 = aq.measure_edge90(asset)[0]
+print(f"sheet: floors parts={parts} materials={mats} edge90={edge90:.3f}")
+
+# Restage in place. Deleting the scene would wipe the datablocks the asset
+# needs, so unparent the asset (world transform kept) and remove the rest.
+for ob in asset:
+ mw = ob.matrix_world.copy()
+ ob.parent = None
+ ob.matrix_world = mw
+keep = set(asset)
+for ob in list(sc.objects):
+ if ob not in keep:
+ bpy.data.objects.remove(ob, do_unlink=True)
+bpy.context.view_layer.update()
+
+mn, mx = Vector((1e9,) * 3), Vector((-1e9,) * 3)
+for ob in asset:
+ for c in ob.bound_box:
+ w = ob.matrix_world @ Vector(c)
+ mn = Vector(map(min, mn, w))
+ mx = Vector(map(max, mx, w))
+center = (mn + mx) / 2
+dim = max((mx - mn).length, 0.1)
+
+sweep = bpy.data.materials.new("Sweep")
+sweep.use_nodes = True
+bsdf = sweep.node_tree.nodes["Principled BSDF"]
+bsdf.inputs["Base Color"].default_value = (0.09, 0.09, 0.095, 1.0)
+bsdf.inputs["Roughness"].default_value = 0.8
+floor_me = bpy.data.meshes.new("SweepFloor")
+bm = bmesh.new()
+try:
+ bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30)
+ bmesh.ops.translate(bm, verts=bm.verts, vec=(0.0, 0.0, mn.z))
+ bm.to_mesh(floor_me)
+finally:
+ bm.free()
+floor_me.materials.append(sweep)
+sc.collection.objects.link(bpy.data.objects.new("SweepFloor", floor_me))
+
+world = bpy.data.worlds.new("SheetWorld")
+world.use_nodes = True
+world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.035, 0.04, 1.0)
+sc.world = world
+
+
+def aim(ob, point):
+ ob.rotation_euler = (point - ob.location).normalized().to_track_quat("-Z", "Y").to_euler()
+
+
+def area(name, offset, energy, size, col):
+ ld = bpy.data.lights.new(name, "AREA")
+ ld.energy, ld.size, ld.color = energy * (dim / 3) ** 2, size * dim, col
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = center + Vector(offset) * dim * 1.5
+ sc.collection.objects.link(ob)
+ aim(ob, center)
+
+
+area("Key", (-0.5, -0.7, 1.0), 500, 1.2, (1.0, 1.0, 1.0))
+area("Fill", (0.9, -0.4, 0.5), 180, 1.6, (0.9, 0.95, 1.0))
+area("Rim", (0.3, 0.9, 0.8), 280, 1.0, (1.0, 1.0, 1.0))
+
+cam_data = bpy.data.cameras.new("SheetCam")
+cam_data.lens, cam_data.sensor_width = 50, 36.0
+cam = bpy.data.objects.new("SheetCam", cam_data)
+# Fit the true extent at the real FOV rather than a bbox-diagonal guess, so
+# tall and wide assets land at comparable scale across panels. Horizontal
+# extent is the worst case: a 3/4 view can present either face.
+hfov = 2.0 * math.atan(cam_data.sensor_width / (2.0 * cam_data.lens))
+vfov = 2.0 * math.atan(cam_data.sensor_width * (RES_Y / RES_X) / (2.0 * cam_data.lens))
+span = mx - mn
+dist = max(math.hypot(span.x, span.y) / (2.0 * math.tan(hfov / 2.0)),
+ max(span.z, 1e-3) / (2.0 * math.tan(vfov / 2.0))) * 1.18
+cam.location = center + Vector((-0.55, -0.77, 0.38)).normalized() * dist
+sc.collection.objects.link(cam)
+aim(cam, center)
+sc.camera = cam
+
+sc.render.engine = "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+sc.eevee.taa_render_samples = 32
+sc.render.resolution_x, sc.render.resolution_y = RES_X, RES_Y
+sc.render.resolution_percentage = 100
+sc.render.image_settings.file_format = "PNG"
+sc.render.filepath = out_png
+# Standard, as every gallery render: AgX would wash the panel toward grey
+sc.view_settings.view_transform = "Standard"
+sc.view_settings.look = "None"
+sc.view_settings.exposure = 0.0
+sc.render.use_compositing = False
+bpy.ops.render.render(write_still=True, scene=sc.name)
+print(f"sheet: panel written {out_png}")