Skip to content

Commit 6f57e76

Browse files
TMHSDigitalclaude
andauthored
chore(gates): raise the asset-sheet reference bar and commit its renderer (#257)
A gallery review found three of the four asset references were among the least-designed assets in the gallery: lod-decimate-chain's primitive rocket, custom-normals-shade's jerry can and vertex-weight-limit's cylinder-stack arm. The gate asks "is the candidate the least-designed object in the lineup?", so a weak lineup lets weak candidates through. The references are now the hydrant, the survey drone, the stone well, and the wheelbarrow and apothecary shelf from the showcase. The panel renderer lived only in uncommitted scratch scripts, so no sheet could be reproduced. scripts/asset_sheet.py (host) and asset_sheet_panel.py (Blender side) commit it, read the reference set from CLAUDE.md so the tool cannot disagree with the gate, and print each isolated asset's floors for the calibration table. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 15ba0a3 commit 6f57e76

5 files changed

Lines changed: 338 additions & 5 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
159159
- `docs/VISUAL-STYLE.md` is the **binding** render standard; deviations are defects.
160160
- **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.
161161
- **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.
162-
- **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.
162+
- **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.
163163
- **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.
164164
- **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `<img alt>` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery/<name>/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).
165165
- **Playwright gallery captures:** gallery `<img>` 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.

‎docs/VISUAL-STYLE.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,23 @@ violation; never imported by a check-only path):
130130
(`bmesh-gear` measures 0.667); raw boxes (1.0) fail.
131131

132132
Calibrated empirically against the gallery's own assets, exactly as the
133-
0.02 margin floor was:
133+
0.02 margin floor was. The four new reference rows were measured on the
134+
isolated asset by `scripts/asset_sheet_panel.py` (the sheet renderer prints
135+
the floors per panel); compactness was not re-measured for them. The three
136+
former references passed every floor and were still demoted by eye in the
137+
2026-09 gallery review — the floors cannot tell a primitive-built rocket
138+
from a designed wheelbarrow, which is the Status section below in practice:
134139

135140
| asset | parts | materials | edge90 | compactness (info only) |
136141
| --- | --- | --- | --- | --- |
137142
| collision-hull-proxy (reference) | 16 | 6 | 0.044 | 65.9 |
138-
| custom-normals-shade (reference) | 39 | 2 | 0.288 | 31.0 |
139-
| vertex-weight-limit (reference) | 1 | 4 | 0.150 | 34.8 |
140-
| lod-decimate-chain (reference) | 3 | 4 | 0.019 | 79.1 |
143+
| socket-attach-points (reference) | 21 | 32 | 0.027 | — |
144+
| vertex-color-ao (reference) | 11 | 11 | 0.171 | — |
145+
| wheelbarrow (reference, showcase) | 1 | 2 | 0.216 | — |
146+
| apothecary-shelf (reference, showcase) | 1 | 6 | 0.055 | — |
147+
| custom-normals-shade (former reference) | 39 | 2 | 0.288 | 31.0 |
148+
| vertex-weight-limit (former reference) | 1 | 4 | 0.150 | 34.8 |
149+
| lod-decimate-chain (former reference) | 3 | 4 | 0.019 | 79.1 |
141150
| depsgraph-export (weak) | 2 | 1 | 1.000 | 23.9 |
142151
| text-version-stamp (weak) | 1 | 1 | 1.000 | 405.3 |
143152
| bmesh-gear (simple, honest) | 1 | 1 | 0.667 | 18.3 |
49 KB
Loading

‎scripts/asset_sheet.py‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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

Comments
 (0)