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 @@ -158,7 +158,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.
- **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.
- **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).
Expand Down
Binary file modified assets/og-card.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/anvil-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/bake-normal-high-to-low-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/campfire-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/collision-hull-proxy-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/compositor-glare-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/custom-normals-shade-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/damped-track-aim-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/fence-kit-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/gn-modifier-inputs-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/grindstone-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/hand-pump-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/iron-cauldron-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/light-link-studio-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/park-bench-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/shipping-crate-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/signpost-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/tavern-stool-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/temp-override-join-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/terrain-scatter-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/text-version-stamp-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/treasure-chest-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/vse-cut-list-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/wall-torch-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/wave-displace-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/wheelbarrow-hero.webp
Binary file modified docs/gallery/contact-sheets/vse-cut-list-contact-sheet.webp
Binary file modified examples/bake-normal-high-to-low/preview.webp
Binary file modified examples/collision-hull-proxy/preview.webp
Binary file modified examples/compositor-glare/preview.webp
Binary file modified examples/custom-normals-shade/preview.webp
Binary file modified examples/damped-track-aim/preview.webp
Binary file modified examples/gn-modifier-inputs/preview.webp
Binary file modified examples/light-link-studio/preview.webp
Binary file modified examples/temp-override-join/preview.webp
Binary file modified examples/text-version-stamp/preview.webp
Binary file modified examples/vse-cut-list/preview.webp
Binary file modified examples/wave-displace/preview.webp
99 changes: 99 additions & 0 deletions scripts/contact_sheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Build a gallery contact sheet and print the numbers its verdict reports.

Composites a candidate hero beside the pinned calibration set (canonical
membership: CLAUDE.md, Quality Gates) in the committed format: four 640x360
tiles, candidate first, captioned, on the dark sheet background, written to
``docs/gallery/contact-sheets/<name>-contact-sheet.webp``.

It also prints what the gate's verdict needs measured: mean luminance
(Rec. 709) of each tile, the stage (mean luminance of the frame border, where
the backdrop is), the wedge warmth (red minus blue over the brightest tenth
of the lower half, where the warm floor pool sits), and mean saturation. The
verdict itself - "is this sortable as the odd one out?" - stays a judgment
made by looking at the sheet; this only makes the numbers repeatable.

Authoring tool: host Python with Pillow and numpy.

Usage:
python scripts/contact_sheet.py NAME [NAME ...]

Exit codes: 0 written, 2 usage.
"""
from __future__ import annotations

import sys

import numpy as np
from PIL import Image, ImageDraw

from measure_hero_drift import REPO, entries

CALIBRATION = ("armature-bend", "damped-track-aim", "bmesh-gear")
TILE = (640, 360)
GAP, CAPTION = 10, 32
BG = (22, 23, 26)
TEXT = (150, 152, 160)
OUT = REPO / "docs" / "gallery" / "contact-sheets"


def metrics(im: Image.Image) -> dict:
a = np.asarray(im.convert("RGB"), dtype=np.float64) / 255.0
luma = a @ np.array([0.2126, 0.7152, 0.0722])
h, w = luma.shape
b = max(1, h // 12)
border = np.concatenate([luma[:b].ravel(), luma[-b:].ravel(),
luma[:, :b].ravel(), luma[:, -b:].ravel()])
lower = a[h // 2:].reshape(-1, 3)
lower_l = luma[h // 2:].ravel()
hot = lower[lower_l >= np.quantile(lower_l, 0.9)]
mx, mn = a.max(axis=2), a.min(axis=2)
sat = np.where(mx > 0, (mx - mn) / np.maximum(mx, 1e-9), 0.0)
return {
"luma": float(luma.mean()),
"stage": float(border.mean()),
"warmth": float((hot[:, 0] - hot[:, 2]).mean()),
"sat": float(sat.mean()),
}


def build(name: str, heroes: dict[str, str]) -> list[tuple[str, dict]]:
names = [name] + [c for c in CALIBRATION if c != name]
tw, th = TILE
sheet = Image.new("RGB", (GAP + len(names) * (tw + GAP), GAP + th + CAPTION), BG)
draw = ImageDraw.Draw(sheet)
rows = []
for i, n in enumerate(names):
im = Image.open(REPO / heroes[n]).convert("RGB")
x = GAP + i * (tw + GAP)
sheet.paste(im.resize(TILE, Image.LANCZOS), (x, GAP))
label = f"{n} <- candidate" if i == 0 else f"{n} (calibration)"
draw.text((x + 4, GAP + th + 10), label, fill=TEXT)
rows.append((label, metrics(im)))
OUT.mkdir(parents=True, exist_ok=True)
sheet.save(OUT / f"{name}-contact-sheet.webp", "WEBP", quality=90)
return rows


def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if not argv:
print(__doc__.split("Usage:")[1].strip(), file=sys.stderr)
return 2
heroes = {e["name"]: e["hero"] for e in entries()}
unknown = [n for n in argv if n not in heroes]
if unknown:
print(f"unknown entries: {', '.join(unknown)}", file=sys.stderr)
return 2
for name in argv:
rows = build(name, heroes)
print(f"\n{name} -> docs/gallery/contact-sheets/{name}-contact-sheet.webp")
print(f" {'tile':34} {'luma':>6} {'stage':>6} {'warmth':>7} {'sat':>6}")
for label, m in rows:
print(f" {label:34} {m['luma']:6.4f} {m['stage']:6.4f} "
f"{m['warmth']:+7.4f} {m['sat']:6.4f}")
return 0


if __name__ == "__main__":
sys.exit(main())
99 changes: 99 additions & 0 deletions scripts/render_hero.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Regenerate gallery hero and preview webps from the code, one committed path.

For each named entry in ``examples/gallery.json`` / ``showcase/gallery.json``:

1. render the README's documented ``--output`` command to a lossless PNG,
keeping every other documented flag (``--engine cycles`` ...);
2. write the hero, ``docs/gallery/assets/<name>-hero.webp`` at 1280x720;
3. write the preview at the entry's ``preview`` path, 1200x675 (Lanczos).

Both are webp quality 90, the setting every showcase script uses when it
writes webp itself. Before this file the PNG -> webp step lived in uncommitted
scratch scripts, so nobody could regenerate a still and get the committed
result (#200). The script's own ``--output`` render path runs the framing
(exit 10) and asset-quality (exit 11) gates before it writes, so a
non-zero exit here leaves the committed files untouched.

Authoring tool, not CI: host Python with Pillow; one full render per entry.

Usage:
python scripts/render_hero.py --blender PATH --only NAME [--only NAME ...]

Exit codes: 0 all written, 2 usage, 3 at least one render failed.
"""
from __future__ import annotations

import argparse
import subprocess
import sys
import tempfile
from pathlib import Path

from PIL import Image

from measure_hero_drift import REPO, entries, render_args

HERO_SIZE = (1280, 720)
PREVIEW_SIZE = (1200, 675)
QUALITY = 90


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("--only", action="append", required=True,
help="entry to regenerate (repeatable)")
args = p.parse_args(argv)

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}", flush=True)

by_name = {e["name"]: e for e in entries()}
unknown = [n for n in args.only if n not in by_name]
if unknown:
print(f"unknown entries: {', '.join(unknown)}", file=sys.stderr)
return 2

failed = []
with tempfile.TemporaryDirectory() as tmp:
for name in args.only:
e = by_name[name]
png = Path(tmp) / f"{name}.png"
cmd = render_args(e, png)
if cmd is None:
print(f"{name}: no --output command in README", flush=True)
failed.append(name)
continue
script = next((REPO / e["dir"]).glob("*.py"))
proc = subprocess.run(
[blender, "--background", "--factory-startup", "--python", str(script),
"--", *cmd],
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=REPO,
)
if proc.returncode != 0 or not png.is_file():
tail = (proc.stdout + proc.stderr).strip().splitlines()[-3:]
print(f"{name}: render failed, exit {proc.returncode}: {' | '.join(tail)}",
flush=True)
failed.append(name)
continue
im = Image.open(png).convert("RGB")
if im.size != HERO_SIZE:
print(f"{name}: render is {im.size[0]}x{im.size[1]}, expected "
f"{HERO_SIZE[0]}x{HERO_SIZE[1]}", flush=True)
failed.append(name)
continue
hero, preview = REPO / e["hero"], REPO / e["preview"]
im.save(hero, "WEBP", quality=QUALITY)
im.resize(PREVIEW_SIZE, Image.LANCZOS).save(preview, "WEBP", quality=QUALITY)
print(f"{name}: wrote {e['hero']} ({hero.stat().st_size} B) and "
f"{e['preview']} ({preview.stat().st_size} B)", flush=True)

print(f"\n# {len(args.only) - len(failed)} regenerated, {len(failed)} failed")
return 3 if failed else 0


if __name__ == "__main__":
sys.exit(main())
Binary file modified showcase/anvil/preview.webp
Binary file modified showcase/campfire/preview.webp
Binary file modified showcase/fence-kit/preview.webp
Binary file modified showcase/grindstone/preview.webp
Binary file modified showcase/hand-pump/preview.webp
Binary file modified showcase/iron-cauldron/preview.webp
Binary file modified showcase/park-bench/preview.webp
Binary file modified showcase/shipping-crate/preview.webp
Binary file modified showcase/signpost/preview.webp
Binary file modified showcase/tavern-stool/preview.webp
Binary file modified showcase/terrain-scatter/preview.webp
Binary file modified showcase/treasure-chest/preview.webp
Binary file modified showcase/wall-torch/preview.webp
Binary file modified showcase/wheelbarrow/preview.webp
Loading