Two tools for the comparison the review was missing. - Tools/blender_screenshots.py renders out of a .blend: one image per mesh object, and four turntable angles of the whole scene, framed from the scene's own bounds so adding geometry never silently crops a shot. Output lands in Screenshots/blender/, beside the in-game captures. - Tools/build_comparison_md.py writes Screenshots/COMPARISON.md: a table with the in-game capture and its Blender render side by side per row, then a signoff table with one row per in-game screen. Unmatched shots show an em dash rather than disappearing, because a missing Blender render is information too. The first run already earns its keep: the Blender render of the owner's hot hatch is red with clear glazing, while the in-game capture of the same asset is near-black, so the material colour is being lost on import - visible in one glance in the new page.
160 lines
5.4 KiB
Python
160 lines
5.4 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
|
|
|
|
DEMO_DIR = "Screenshots/demo"
|
|
BLENDER_DIR = "Screenshots/blender"
|
|
OUT = "Screenshots/COMPARISON.md"
|
|
|
|
# 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",
|
|
"",
|
|
"Generated by `Tools/build_comparison_md.py` — do not edit by hand.",
|
|
"",
|
|
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.",
|
|
"",
|
|
]
|
|
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
|
|
with open(OUT, "w") as handle:
|
|
handle.write("\n".join(lines))
|
|
print(f"wrote {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(build())
|