diff --git a/Screenshots/COMPARISON.md b/Screenshots/COMPARISON.md new file mode 100644 index 0000000..1aee619 --- /dev/null +++ b/Screenshots/COMPARISON.md @@ -0,0 +1,42 @@ +# Blender vs in-game + +Generated by `Tools/build_comparison_md.py` — do not edit by hand. + +- In-game captures: `Screenshots/demo/` (9 files) +- Blender renders: `Screenshots/blender/` (22 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 | +| --- | --- | --- | +| | — | `review-demo-crossing.png` | +| | — | `review-demo-north-buildings.png` | +| | — | `review-demo-retail-frontage.png` | +| | — | `review-demo-spawn-view.png` | +| | — | `review-demo-street-level.png` | +| | | `review-demo-third-person-front.png` | +| | | `review-demo-third-person-walk.png` | +| | | `review-demo-third-person.png` | +| | | `review-demo-vehicle.png` | + +4 of 9 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 | +| --- | --- | --- | --- | --- | +| 1 | `review-demo-crossing.png` | — | ☐ | | +| 2 | `review-demo-north-buildings.png` | — | ☐ | | +| 3 | `review-demo-retail-frontage.png` | — | ☐ | | +| 4 | `review-demo-spawn-view.png` | — | ☐ | | +| 5 | `review-demo-street-level.png` | — | ☐ | | +| 6 | `review-demo-third-person-front.png` | `character-angle-035.png` | ☐ | | +| 7 | `review-demo-third-person-walk.png` | `character-angle-035.png` | ☐ | | +| 8 | `review-demo-third-person.png` | `character-angle-035.png` | ☐ | | +| 9 | `review-demo-vehicle.png` | `vehicle-angle-035.png` | ☐ | | + +Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays in [README.md](README.md); this page exists to compare Blender against the engine. diff --git a/Screenshots/blender/character-angle-035.png b/Screenshots/blender/character-angle-035.png new file mode 100644 index 0000000..9b73843 --- /dev/null +++ b/Screenshots/blender/character-angle-035.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f1b16aa5108edd3f282e0834149b47fa62411f26aaaec5f23b299aa93e94a3a +size 957393 diff --git a/Screenshots/blender/character-angle-125.png b/Screenshots/blender/character-angle-125.png new file mode 100644 index 0000000..e950fea --- /dev/null +++ b/Screenshots/blender/character-angle-125.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac302a191465b9148a488f0c7b83ea4a0c8a4b95e66dbf7d802d5c7e8fcaa28b +size 949856 diff --git a/Screenshots/blender/character-angle-215.png b/Screenshots/blender/character-angle-215.png new file mode 100644 index 0000000..2fba088 --- /dev/null +++ b/Screenshots/blender/character-angle-215.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:401d719e746a84dbbd04aa343ba8a3adacb9801b061c046658ebf4a28b874328 +size 956580 diff --git a/Screenshots/blender/character-angle-305.png b/Screenshots/blender/character-angle-305.png new file mode 100644 index 0000000..b2dbf74 --- /dev/null +++ b/Screenshots/blender/character-angle-305.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0322962f26920461af4d9bda6d33c8dc129cb1202cfb3eba1a1bc13b2d84b11f +size 966363 diff --git a/Screenshots/blender/vehicle-angle-035.png b/Screenshots/blender/vehicle-angle-035.png new file mode 100644 index 0000000..34ca5fe --- /dev/null +++ b/Screenshots/blender/vehicle-angle-035.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33cbed4e973ad40e5de4d3d5bf016f1449fe8ddd22ce414dd2505194907639b1 +size 939944 diff --git a/Screenshots/blender/vehicle-angle-125.png b/Screenshots/blender/vehicle-angle-125.png new file mode 100644 index 0000000..8230942 --- /dev/null +++ b/Screenshots/blender/vehicle-angle-125.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1686fafd3efd162bedd122db4c3757d4d832099aeef70992e63eaa9007214424 +size 939794 diff --git a/Screenshots/blender/vehicle-angle-215.png b/Screenshots/blender/vehicle-angle-215.png new file mode 100644 index 0000000..1003883 --- /dev/null +++ b/Screenshots/blender/vehicle-angle-215.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:098463db7ca83683650c28fa17eb4465f10a5a953a4061b11cd76b92b1a127ea +size 938091 diff --git a/Screenshots/blender/vehicle-angle-305.png b/Screenshots/blender/vehicle-angle-305.png new file mode 100644 index 0000000..5a416fa --- /dev/null +++ b/Screenshots/blender/vehicle-angle-305.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0f2e74bd4082d700e32f6016c3afc75ac641a62efca3912359d6884c0291496 +size 951093 diff --git a/Screenshots/blender/vehicle-object-Cube.png b/Screenshots/blender/vehicle-object-Cube.png new file mode 100644 index 0000000..0ed7531 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ba63c44de30c7d37dc4d7c304bdaf0b5388d7a3029642929ba87dc8981317cc +size 906273 diff --git a/Screenshots/blender/vehicle-object-Cube_001.png b/Screenshots/blender/vehicle-object-Cube_001.png new file mode 100644 index 0000000..c720e1f --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_001.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77609df285c649e702b59d88c4530decdb07e795fceaa775f1f1d5d372e2b92a +size 838386 diff --git a/Screenshots/blender/vehicle-object-Cube_002.png b/Screenshots/blender/vehicle-object-Cube_002.png new file mode 100644 index 0000000..77daf8c --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_002.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1b5f7943874ce36c17f8a4a32d372c06412b7ef8989bbbdd3689ef590c31eaf +size 860078 diff --git a/Screenshots/blender/vehicle-object-Cube_003.png b/Screenshots/blender/vehicle-object-Cube_003.png new file mode 100644 index 0000000..e16769a --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_003.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e6adf6ca91342342a4bb00f323e9fcbc977a03eaa0c358794488448e8414a82 +size 880436 diff --git a/Screenshots/blender/vehicle-object-Cube_004.png b/Screenshots/blender/vehicle-object-Cube_004.png new file mode 100644 index 0000000..fedde35 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_004.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b26b81c40de9602e2af2e0b98b67cd942d9c131d81eb7f957d7835925ecca092 +size 853893 diff --git a/Screenshots/blender/vehicle-object-Cube_005.png b/Screenshots/blender/vehicle-object-Cube_005.png new file mode 100644 index 0000000..d5a42a5 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_005.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31a370e5e83d4fbdbf0aaaa9c28349c16f77589b624b79256d99ea289ac7960e +size 862262 diff --git a/Screenshots/blender/vehicle-object-Cube_006.png b/Screenshots/blender/vehicle-object-Cube_006.png new file mode 100644 index 0000000..c7f19b1 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_006.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4d1338a8aea4fdc132230e22c7c4e03854c299c50f1383010e371133d1f4468 +size 886422 diff --git a/Screenshots/blender/vehicle-object-Cube_007.png b/Screenshots/blender/vehicle-object-Cube_007.png new file mode 100644 index 0000000..2bdbe62 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cube_007.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e542c7b48bfb3350dcd9ee5caf11d02372bff973f7376ec5c69181b42559656 +size 858467 diff --git a/Screenshots/blender/vehicle-object-Cylinder.png b/Screenshots/blender/vehicle-object-Cylinder.png new file mode 100644 index 0000000..b94f490 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cylinder.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f86c304f30ee5bd4681f42274292a7544f2cf2b39ddf5b7bf82ef1812ee2598e +size 910440 diff --git a/Screenshots/blender/vehicle-object-Cylinder_001.png b/Screenshots/blender/vehicle-object-Cylinder_001.png new file mode 100644 index 0000000..aba6967 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cylinder_001.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51439864f39282971887b3f92eec38e6b1047cf340209826da6ffa90f23e35eb +size 885867 diff --git a/Screenshots/blender/vehicle-object-Cylinder_002.png b/Screenshots/blender/vehicle-object-Cylinder_002.png new file mode 100644 index 0000000..4f9654c --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cylinder_002.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c5c86e6c048cafc7e9de56c2fb48d9a9f54cb3e47425d9cda463473e9b16d3d +size 865048 diff --git a/Screenshots/blender/vehicle-object-Cylinder_003.png b/Screenshots/blender/vehicle-object-Cylinder_003.png new file mode 100644 index 0000000..cd30b51 --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cylinder_003.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2aa6b1f4c695fa748eaf6e40007ec0d76fe89ff9c24237c4514bc380e907e40 +size 856597 diff --git a/Screenshots/blender/vehicle-object-Cylinder_004.png b/Screenshots/blender/vehicle-object-Cylinder_004.png new file mode 100644 index 0000000..2444bef --- /dev/null +++ b/Screenshots/blender/vehicle-object-Cylinder_004.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2491fa7e7c2e98b919ac62f57942094e65f07b9def009f9893b3d967b02198b4 +size 852168 diff --git a/Screenshots/blender/vehicle-object-lights_and_grill.png b/Screenshots/blender/vehicle-object-lights_and_grill.png new file mode 100644 index 0000000..875ca0e --- /dev/null +++ b/Screenshots/blender/vehicle-object-lights_and_grill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15643695c23f6d933c99598ba4435d8a75b1d4bb822ab3867dcfd15d8317422c +size 862802 diff --git a/Tools/README.md b/Tools/README.md index 2ba579f..940982b 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -97,6 +97,8 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` | | `ue_vehicle_import.py` | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` | | `vehicle_from_template.py` | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` | +| `blender_screenshots.py` | Renders review screenshots out of a .blend: one per mesh object (`--mode objects`) and four turntable angles (`--mode scene`), framed from the scene's own bounds, into `Screenshots/blender/`. | `--label`, `--mode`, `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/blender_screenshots.py -- --label vehicle --mode both` | +| `build_comparison_md.py` | Writes `Screenshots/COMPARISON.md`: in-game captures beside their Blender renders in one table, plus a signoff table with one row per in-game screen. Pairs by name with an alias map for camera-named shots. | (no arguments) | `python3 Tools/build_comparison_md.py` | ## Tests diff --git a/Tools/blender_screenshots.py b/Tools/blender_screenshots.py new file mode 100644 index 0000000..1212d40 --- /dev/null +++ b/Tools/blender_screenshots.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Render review screenshots straight out of Blender. + + blender -b "Art/blender/vehicle/hot hatch.blend" \ + --python Tools/blender_screenshots.py -- \ + --label vehicle --mode both --out Screenshots/blender + +Two modes, because two questions get asked about a Blender asset: + +* **objects** - one render per mesh object, isolated. This is how a system split is + checked: if a panel lands in the wrong group, it shows up as the only thing in its + own picture. +* **scene** - four turntable angles of everything together, for silhouette and + proportion against the in-game shot. + +Outputs go beside the in-game captures (`Screenshots/blender/`), one folder layer +below, so `Tools/build_comparison_md.py` can pair them and nothing has to be copied by +hand. Every render is framed from the scene's own bounds rather than a hand-placed +camera, so adding geometry does not silently crop the shot. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys + +import bpy +from mathutils import Vector + +RESOLUTIONS = (1280, 720) +PARTS: list[str] = [] + + +def log(message: str) -> None: + PARTS.append(message) + print(f"[shots] {message}") + + +def scene_bounds(objects): + low = [1e9, 1e9, 1e9] + high = [-1e9, -1e9, -1e9] + for obj in objects: + for corner in obj.bound_box: + world = obj.matrix_world @ Vector(corner) + for axis in range(3): + low[axis] = min(low[axis], world[axis]) + high[axis] = max(high[axis], world[axis]) + return Vector(low), Vector(high) + + +def setup_scene(): + scene = bpy.context.scene + scene.render.engine = "BLENDER_EEVEE" + scene.render.resolution_x, scene.render.resolution_y = RESOLUTIONS + scene.render.film_transparent = False + world = bpy.data.worlds.new("shots") if not bpy.data.worlds else bpy.data.worlds[0] + scene.world = world + world.use_nodes = True + world.node_tree.nodes["Background"].inputs[0].default_value = (0.55, 0.58, 0.62, 1.0) + world.node_tree.nodes["Background"].inputs[1].default_value = 0.8 + if not any(o.type == "LIGHT" for o in bpy.data.objects): + bpy.ops.object.light_add(type="SUN", location=(6.0, -6.0, 12.0)) + bpy.context.active_object.data.energy = 4.0 + + +def frame_camera(low, high, azimuth_deg, elevation_deg=26.0, padding=1.9): + centre = (low + high) * 0.5 + size = max((high - low).length, 0.5) + distance = size * padding + azimuth = math.radians(azimuth_deg) + elevation = math.radians(elevation_deg) + offset = Vector(( + math.cos(elevation) * math.cos(azimuth), + math.cos(elevation) * math.sin(azimuth), + math.sin(elevation), + )) * distance + bpy.ops.object.camera_add(location=centre + offset) + camera = bpy.context.active_object + camera.data.lens = 50.0 + direction = (centre - camera.location).normalized() + camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + bpy.context.scene.camera = camera + return camera + + +def render(path): + bpy.context.scene.render.filepath = path + bpy.ops.render.render(write_still=True) + return os.path.exists(path) + + +def render_scene_angles(meshes, out_dir, label, angles=(35, 125, 215, 305)): + written = [] + low, high = scene_bounds(meshes) + for azimuth in angles: + camera = frame_camera(low, high, azimuth) + path = os.path.join(out_dir, f"{label}-angle-{azimuth:03d}.png") + if render(path): + written.append(path) + log(f"scene angle {azimuth}: {path}") + bpy.data.objects.remove(camera, do_unlink=True) + return written + + +def render_objects(meshes, out_dir, label): + written = [] + hidden = [] + for target in meshes: + for other in meshes: + if other is target: + continue + if other.hide_render: + continue + other.hide_render = True + hidden.append(other) + low, high = scene_bounds([target]) + camera = frame_camera(low, high, 40.0) + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in target.name) + path = os.path.join(out_dir, f"{label}-object-{safe}.png") + if render(path): + written.append(path) + tris = sum(len(p.vertices) - 2 for p in target.data.polygons) + log(f"object {target.name}: {tris} triangles -> {path}") + bpy.data.objects.remove(camera, do_unlink=True) + for other in hidden: + other.hide_render = False + hidden = [] + return written + + +def main(argv=None): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--label", required=True, help="prefix for the file names, e.g. 'vehicle'") + ap.add_argument("--mode", choices=("objects", "scene", "both"), default="both") + ap.add_argument("--out", default="Screenshots/blender") + ap.add_argument("--report", default=None) + args = ap.parse_args(argv) + + out_dir = os.path.abspath(args.out) + os.makedirs(out_dir, exist_ok=True) + setup_scene() + + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + if not meshes: + print("[shots] no meshes in the scene", file=sys.stderr) + return 1 + log(f"{len(meshes)} mesh objects in {bpy.data.filepath}") + + written = [] + if args.mode in ("scene", "both"): + written += render_scene_angles(meshes, out_dir, args.label) + if args.mode in ("objects", "both"): + written += render_objects(meshes, out_dir, args.label) + + report = {"blend": bpy.data.filepath, "label": args.label, "mode": args.mode, + "outputs": written, "log": PARTS} + if args.report: + with open(os.path.abspath(args.report), "w") as handle: + json.dump(report, handle, indent=2) + log(f"{len(written)} screenshots written to {out_dir}") + return 0 if written else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/build_comparison_md.py b/Tools/build_comparison_md.py new file mode 100644 index 0000000..bc75d44 --- /dev/null +++ b/Tools/build_comparison_md.py @@ -0,0 +1,159 @@ +#!/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'' + + +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())