Screenshots/README.md now opens with a single table that is the only place approval state and issue notes live: | # | Shot | Status | Note | 1 spawn-view, 2 street-level, 6 third-person, 7 third-person-front, 8 third-person-walk carry 'character and car float'; the rest are blank. Everything else that duplicated that information is gone: the per-shot Verified/Approved bullets, the warning block at the top, the prose in the known-issues section, and the second signoff table the comparison generator used to append. Every image is marked pending, because every image changed and each needs a fresh approval. The comparison section keeps its side-by-side image table and points at the status table for approval, so there is one place to sign off and one place to read what is wrong.
176 lines
6.0 KiB
Python
176 lines
6.0 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.",
|
|
"",
|
|
"Approval and issue notes are recorded only in the status table at the top of this file.",
|
|
"",
|
|
]
|
|
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())
|