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}")