#!/usr/bin/env python3 """Build the Blender-vs-in-game comparison page. python3 Tools/build_comparison_md.py Reads every in-game capture in `Screenshots/demo/` and every Blender render in `Screenshots/blender/`, pairs them by name, and writes `Screenshots/COMPARISON.md`: * one table with the two images **side by side per row**, so a mismatch is visible in a single glance rather than by opening two files; * one signoff table, one row per in-game screen, for the owner's verdict. Pairing is by name: `review-demo-vehicle.png` pairs with `vehicle-angle-*.png` because both mention `vehicle`; anything unmatched is shown with an em dash rather than being quietly dropped, since a missing Blender render is itself information. """ from __future__ import annotations import os import re import shutil import subprocess DEMO_DIR = "Screenshots/demo" BLENDER_DIR = "Screenshots/blender" OUT = "Screenshots/README.md" BEGIN = "" END = "" # Shots that are about a modelled asset pair with a Blender render; the street and # building shots have no Blender counterpart because those meshes come from the Geo # pipeline, not from a .blend. ASSET_KEYWORDS = ("vehicle",) # Shots are named after the camera, renders after the asset. Without this the character # shots never pair with the character renders, because "third-person-front" and # "character-angle-035" share no words. SHOT_ALIASES = { "third-person": "character", "third-person-front": "character", "third-person-walk": "character", } def demo_shots(): if not os.path.isdir(DEMO_DIR): return [] return sorted(f for f in os.listdir(DEMO_DIR) if f.endswith(".png")) def blender_renders(): if not os.path.isdir(BLENDER_DIR): return [] return sorted(f for f in os.listdir(BLENDER_DIR) if f.endswith(".png")) def shot_label(filename): name = os.path.splitext(filename)[0] name = re.sub(r"^review-demo-", "", name) return name.replace("-", " ") def best_blender_match(shot, renders): """Pick the Blender render whose name shares the most words with the shot.""" stem = os.path.splitext(shot)[0].lower().replace("review-demo-", "") words = set(re.split(r"[-_]", stem)) for alias, asset in SHOT_ALIASES.items(): if alias in stem: words.add(asset) scored = [] for render in renders: render_words = set(re.split(r"[-_]", os.path.splitext(render)[0].lower())) score = len(words & render_words) if score: scored.append((score, render)) if not scored: return None scored.sort(key=lambda item: (-item[0], item[1])) return scored[0][1] def relative_to_page(path): """Paths in the page are relative to Screenshots/, where COMPARISON.md lives.""" if not path: return path base = os.path.dirname(OUT) return os.path.relpath(path, base).replace(os.sep, "/") def image_cell(path, width=520): if not path: return "—" return f'' def build(): shots = demo_shots() renders = blender_renders() lines = [ "## Blender vs in-game", "", "Regenerated by `Tools/build_comparison_md.py`; everything between the markers is", "rewritten, so edit outside them.", "", f"- In-game captures: `{DEMO_DIR}/` ({len(shots)} files)", f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender_screenshots.py`", "", "Left is what the engine draws, right is what Blender has. A gap between them is the", "import problem, not a taste problem: scale, material colour, missing parts and split", "systems all show up here before they show up in a review.", "", "### Side by side", "", "| In-game | Blender | Shot |", "| --- | --- | --- |", ] paired = {} for shot in shots: match = best_blender_match(shot, renders) paired[shot] = match lines.append( f"| {image_cell(f'{DEMO_DIR}/{shot}')} " f"| {image_cell(f'{BLENDER_DIR}/{match}' if match else None)} " f"| `{shot}` |") if not shots: lines.append("| — | — | no captures |") matched = sum(1 for v in paired.values() if v) lines += [ "", f"{matched} of {len(shots)} in-game captures have a Blender counterpart; the rest are", "street and building shots whose meshes come from the Geo pipeline rather than a .blend.", "", "### Signoff", "", "One row per in-game screen. `—` in Blender means there is nothing to compare against.", "", "| # | In-game screen | Blender counterpart | Verdict | Notes |", "| --- | --- | --- | --- | --- |", ] for index, shot in enumerate(shots, start=1): match = paired[shot] lines.append( f"| {index} | `{shot}` | {f'`{match}`' if match else '—'} | ☐ | |") if not shots: lines.append("| — | — | — | ☐ | no captures |") lines += [ "", "Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays", "in [README.md](README.md); this page exists to compare Blender against the engine.", "", ] block = "\n".join([BEGIN, ""] + lines + ["", END]) if os.path.exists(OUT): with open(OUT) as handle: existing = handle.read() else: existing = "# Screenshots\n" # The comparison lives inside the main review README, between markers, so it is # regenerated in place: the rest of the README is hand-written and must not be # rewritten by a tool. if BEGIN in existing and END in existing: head = existing[:existing.index(BEGIN)] tail = existing[existing.index(END) + len(END):] updated = head + block + tail else: updated = existing.rstrip("\n") + "\n\n" + block + "\n" with open(OUT, "w") as handle: handle.write(updated) # The README is checked by `prettier --check` in the repo's gates, and a generated # markdown table is never byte-identical to what prettier produces. Format it here so # regenerating the section cannot leave the tree failing a format check. formatter = shutil.which("npx") if formatter: try: subprocess.run([formatter, "--no-install", "prettier", "--write", OUT], check=False, capture_output=True, timeout=120) except (OSError, subprocess.SubprocessError): pass print(f"updated {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)") return 0 if __name__ == "__main__": raise SystemExit(build())