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())