Files
gig-line/Tools/build_comparison_md.py
T
zwitschi 72d5bab427 docs(review): split into ReviewMap and DemoMap, with one status table
The review file now separates the two questions it was mixing:

  ReviewMap - assets staged on their own: individual objects, the Blender import, camera angles
  and the lighting comparison. Nothing from the game map is involved.
  DemoMap   - the actual game map in development: street, buildings, player, vehicle in place.

One status table at the top covers both parts, with a Part column, and remains the only place
approval and issue notes are recorded. Between them sits the Blender-vs-UE comparison, which now
pairs ReviewMap captures as well as DemoMap ones, followed by the ReviewMap image grid and the
existing in-game shot sections.

Tools/ue_build_review_map.py also stages the LATEST vehicle assets (five systems plus the hatch
paint) and two new cameras - a three-quarter view of the car and a ground-level look at the
character's feet - so the staging map never drifts from what the Blender tools last produced.
Tools/render_screenshots.sh gained a second capture pass that renders ReviewMap into
Screenshots/review/, after the demo collection because that pass clears the engine's screenshot
folder.
2026-11-09 17:34:12 +01:00

183 lines
6.3 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"
REVIEW_DIR = "Screenshots/review"
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 review_shots():
if not os.path.isdir(REVIEW_DIR):
return []
return sorted(f for f in os.listdir(REVIEW_DIR) if f.endswith(".png"))
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 = review_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"- Captures: `{REVIEW_DIR}/` and `{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'{REVIEW_DIR if shot in review_shots() else 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())