One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
183 lines
6.3 KiB
Python
183 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the Blender-vs-in-game comparison page.
|
|
|
|
python3 Tools/review/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/review/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/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())
|