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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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).
- **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.
Expand Down
17 changes: 13 additions & 4 deletions docs/VISUAL-STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
159 changes: 159 additions & 0 deletions scripts/asset_sheet.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading