Files
gig-line/Tools/build_comparison_md.py
T
zwitschi 046e2c8b9e docs(review): the Blender-vs-in-game comparison lives in Screenshots/README.md
Merged COMPARISON.md into the review README as a generated section between
<!-- blender-comparison:begin/end --> markers, so there is one place to look and one place
to sign off. Tools/build_comparison_md.py rewrites only that block - the hand-written
sections around it are untouched - and formats the file with prettier afterwards, because a
generated table is never byte-identical to prettier's output and the repo gates on
--check.

Run it after every capture or Blender render; it is the routine that keeps the page current
with new images rather than a one-off export.
2026-11-09 16:10:47 +01:00

193 lines
6.7 KiB
Python

#!/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 = "<!-- blender-comparison:begin -->"
END = "<!-- blender-comparison: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'<img src="{relative_to_page(path)}" width="{width}">'
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())