|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Build the asset-sheet gate composite for one asset: candidate beside the references. |
| 3 | +
|
| 4 | +The asset-sheet gate (CLAUDE.md § Quality Gates for Example Runs) renders the |
| 5 | +hero asset alone — neutral three-quarter view, plain studio lighting, no |
| 6 | +staging, no labels — beside the pinned asset-quality reference set rendered |
| 7 | +the same way, and asks one question: is the candidate identifiable as the |
| 8 | +least-designed object in the lineup? |
| 9 | +
|
| 10 | +The reference set is read from CLAUDE.md, its canonical home, so this script |
| 11 | +never disagrees with the gate it serves. Panel layout is 3x2 at 640x360: |
| 12 | +candidate top-left, then the references in CLAUDE.md order. |
| 13 | +
|
| 14 | +For each name the Blender side (``scripts/asset_sheet_panel.py``) stages the |
| 15 | +entry through the README's documented ``--output`` command, keeps only the |
| 16 | +mesh objects matched by ``SELECT`` below, and re-renders them on a grey |
| 17 | +sweep. Before this file the panel renderer lived in uncommitted scratch |
| 18 | +scripts, so a sheet could not be reproduced (the same gap #200 closed for |
| 19 | +heroes). |
| 20 | +
|
| 21 | +Authoring tool, not CI: host Python with Pillow; one full staging render |
| 22 | +per panel. |
| 23 | +
|
| 24 | +Usage: |
| 25 | + python scripts/asset_sheet.py --blender PATH NAME [--out PATH] [--panels DIR] |
| 26 | +
|
| 27 | +Writes ``docs/gallery/asset-sheets/NAME.webp`` unless ``--out`` is given. |
| 28 | +``--panels`` keeps the per-asset PNGs (and reuses any already there). |
| 29 | +
|
| 30 | +Exit codes: 0 written, 2 usage, 3 a panel failed. |
| 31 | +""" |
| 32 | +from __future__ import annotations |
| 33 | + |
| 34 | +import argparse |
| 35 | +import os |
| 36 | +import re |
| 37 | +import subprocess |
| 38 | +import sys |
| 39 | +import tempfile |
| 40 | +from pathlib import Path |
| 41 | + |
| 42 | +from PIL import Image |
| 43 | + |
| 44 | +from measure_hero_drift import REPO, entries, render_args |
| 45 | + |
| 46 | +PANEL = (640, 360) |
| 47 | +GUTTER = 8 |
| 48 | +QUALITY = 88 |
| 49 | + |
| 50 | +# Mesh objects that make up each entry's hero asset: (include, exclude) |
| 51 | +# regexes on object names. Comparison props, placards and stage are left out |
| 52 | +# by construction. A new asset-type entry adds its row here. |
| 53 | +SELECT = { |
| 54 | + "collision-hull-proxy": (r"^(Body|Nut|PumperCap|Lug\d|SideCap\d)$", None), |
| 55 | + "custom-normals-shade": (r"_byangle$", None), |
| 56 | + "vertex-weight-limit": (r"^MechArm$", None), |
| 57 | + "lod-decimate-chain": (r"^Rocket$", None), |
| 58 | + "modular-kit-snap": (r"^Kit\.CorridorSeg\.", r"\.0\d\d$"), |
| 59 | + "lightmap-uv-channel": (r"^Cart\.", None), |
| 60 | + "socket-attach-points": (r"^Drone\.Survey\.", None), |
| 61 | + "vertex-color-ao": (r"^Well\.Stone\.", None), |
| 62 | + "wooden-yoke": (r"^YokeLow$", None), |
| 63 | + "apothecary-shelf": (r"^ShelfLow$", None), |
| 64 | + "brazier": (r"^BrazierLow$", None), |
| 65 | + "grain-sacks": (r"^SacksLow$", None), |
| 66 | + "rope-bridge": (r"^BridgeLow$", None), |
| 67 | + "wheelbarrow": (r"^BarrowLow$", None), |
| 68 | +} |
| 69 | + |
| 70 | +_REF_LINE = re.compile(r"Asset-sheet gate.*?reference set — currently (.+?) — rendered", re.S) |
| 71 | + |
| 72 | + |
| 73 | +def reference_set() -> list[str]: |
| 74 | + """The pinned reference set, parsed from its canonical home in CLAUDE.md.""" |
| 75 | + text = (REPO / "CLAUDE.md").read_text(encoding="utf-8") |
| 76 | + m = _REF_LINE.search(text) |
| 77 | + if not m: |
| 78 | + raise SystemExit("could not find the asset-sheet reference set in CLAUDE.md") |
| 79 | + return re.findall(r"`([a-z0-9-]+)`", m.group(1)) |
| 80 | + |
| 81 | + |
| 82 | +def render_panel(blender: str, entry: dict, png: Path) -> bool: |
| 83 | + inc, exc = SELECT[entry["name"]] |
| 84 | + with tempfile.TemporaryDirectory() as tmp: |
| 85 | + cmd = render_args(entry, Path(tmp) / "stage.png") |
| 86 | + if cmd is None: |
| 87 | + print(f"{entry['name']}: no --output command in README", flush=True) |
| 88 | + return False |
| 89 | + script = next((REPO / entry["dir"]).glob("*.py")) |
| 90 | + env = dict(os.environ, BDT_SHEET_SCRIPT=str(script), BDT_SHEET_SELECT=inc, |
| 91 | + BDT_SHEET_EXCLUDE=exc or "", BDT_SHEET_OUT=str(png)) |
| 92 | + proc = subprocess.run( |
| 93 | + [blender, "--background", "--factory-startup", "--python", |
| 94 | + str(REPO / "scripts" / "asset_sheet_panel.py"), "--", *cmd], |
| 95 | + capture_output=True, text=True, encoding="utf-8", errors="replace", |
| 96 | + cwd=REPO, env=env, |
| 97 | + ) |
| 98 | + for line in proc.stdout.splitlines(): |
| 99 | + if line.startswith("sheet:"): |
| 100 | + print(f"{entry['name']}: {line}", flush=True) |
| 101 | + if proc.returncode != 0 or not png.is_file(): |
| 102 | + tail = (proc.stdout + proc.stderr).strip().splitlines()[-3:] |
| 103 | + print(f"{entry['name']}: panel failed, exit {proc.returncode}: {' | '.join(tail)}", |
| 104 | + flush=True) |
| 105 | + return False |
| 106 | + return True |
| 107 | + |
| 108 | + |
| 109 | +def main(argv=None) -> int: |
| 110 | + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) |
| 111 | + p.add_argument("--blender", required=True, help="Blender binary to render with") |
| 112 | + p.add_argument("name", help="candidate entry (examples/ or showcase/ gallery name)") |
| 113 | + p.add_argument("--out", help="composite path (default docs/gallery/asset-sheets/NAME.webp)") |
| 114 | + p.add_argument("--panels", help="directory to keep and reuse per-asset panel PNGs") |
| 115 | + args = p.parse_args(argv) |
| 116 | + |
| 117 | + by_name = {e["name"]: e for e in entries()} |
| 118 | + refs = reference_set() |
| 119 | + lineup = [args.name] + [r for r in refs if r != args.name] |
| 120 | + missing = [n for n in lineup if n not in by_name or n not in SELECT] |
| 121 | + if missing: |
| 122 | + print(f"no gallery entry or SELECT row for: {', '.join(missing)}", file=sys.stderr) |
| 123 | + return 2 |
| 124 | + |
| 125 | + blender = str(Path(args.blender).resolve()) |
| 126 | + version = subprocess.run([blender, "--version"], capture_output=True, text=True, |
| 127 | + encoding="utf-8", errors="replace").stdout.splitlines()[0] |
| 128 | + print(f"# renderer: {blender}\n# version: {version}\n# references: {', '.join(refs)}", |
| 129 | + flush=True) |
| 130 | + |
| 131 | + keep = Path(args.panels) if args.panels else None |
| 132 | + with tempfile.TemporaryDirectory() as tmp: |
| 133 | + pdir = keep or Path(tmp) |
| 134 | + pdir.mkdir(parents=True, exist_ok=True) |
| 135 | + panels = [] |
| 136 | + for n in lineup: |
| 137 | + png = pdir / f"{n}.png" |
| 138 | + # the candidate is always re-rendered; references may be reused |
| 139 | + if not (keep and png.is_file() and n != args.name): |
| 140 | + if not render_panel(blender, by_name[n], png): |
| 141 | + return 3 |
| 142 | + panels.append(Image.open(png).convert("RGB").resize(PANEL, Image.LANCZOS)) |
| 143 | + |
| 144 | + cols = 3 |
| 145 | + rows = -(-len(panels) // cols) |
| 146 | + sheet = Image.new("RGB", (PANEL[0] * cols + GUTTER * (cols - 1), |
| 147 | + PANEL[1] * rows + GUTTER * (rows - 1)), (18, 18, 20)) |
| 148 | + for i, im in enumerate(panels): |
| 149 | + sheet.paste(im, ((i % cols) * (PANEL[0] + GUTTER), (i // cols) * (PANEL[1] + GUTTER))) |
| 150 | + out = Path(args.out) if args.out else REPO / "docs/gallery/asset-sheets" / f"{args.name}.webp" |
| 151 | + out.parent.mkdir(parents=True, exist_ok=True) |
| 152 | + sheet.save(out, "WEBP", quality=QUALITY) |
| 153 | + print(f"wrote {out.relative_to(REPO) if out.is_relative_to(REPO) else out} " |
| 154 | + f"({out.stat().st_size} B): {' | '.join(lineup)}") |
| 155 | + return 0 |
| 156 | + |
| 157 | + |
| 158 | +if __name__ == "__main__": |
| 159 | + sys.exit(main()) |
0 commit comments