diff --git a/.gitignore b/.gitignore index f8ea7bf..060ed2c 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ Thumbs.db # Unreal per-project build artefacts (FileOpenOrder logs and the like) Build/ +dsh-session-* +tmp/ diff --git a/AGENTS.md b/AGENTS.md index 1410242..c29dae0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ Rules that keep tools reusable and reviewable: - **Result files for Unreal.** Unreal commandlets swallow `print()`; write the outcome to `/tmp/gigline/.txt` and read that instead of trusting the exit code alone. - **Absolute paths into sandboxes.** Blender Flatpak (`LD_LIBRARY_PATH="$B/blender/lib"`) and Unreal do not share the repo's working directory; pass absolute paths. - **Clean worktree for generators.** Tools that rewrite committed assets (site/street/hero builders, importers, map builders) run from a clean worktree; inspect the LFS diff before committing. Note that `.blend` and `.umap` check out read-only — `chmod u+w` first or the save silently fails. -- **Network and paid-API tools are human-triggered.** `Tools/commons_fetch.py`, `Tools/page_image_fetch.py`, `Tools/judge_screenshot.py` (OpenRouter) and anything similar never sit in a gate or CI. Handle HTTP 429 with back-off rather than retrying immediately. +- **Network and paid-API tools are human-triggered.** `Tools/gen/commons_fetch.py`, `Tools/gen/page_image_fetch.py`, `Tools/review/judge_screenshot.py` (OpenRouter) and anything similar never sit in a gate or CI. Handle HTTP 429 with back-off rather than retrying immediately. - **Measure, don't assert.** Verification tools print the sampled numbers — counts, holes, Z ranges, worst step and its location, frame-time percentiles — so a claim in `TODO.md`, `CHANGELOG.md` or `Docs/` can be re-checked by re-running the command. **Document every tool.** A new or changed tool gets a row in the inventory table of the README beside it: [`Tools/README.md`](Tools/README.md), [`Geo/README.md`](Geo/README.md), or [`Art/blender/README.md`](Art/blender/README.md). What it does, its arguments, and a typical command. A tool without a README row is unfinished. @@ -141,7 +141,7 @@ Keep the tools standard-library-only where practical; if a third-party import is ## Architecture documentation -[`Docs/Architecture/`](Docs/Architecture/) is the arc42 documentation: twelve numbered section files, `about-arc42.md`, and the combined [`architecture.md`](Docs/Architecture/architecture.md). **Markdown is canonical** — the `.adoc` sources were removed on 2026-11-08. +[`Docs/Architecture/`](Docs/Architecture/) is the arc42 documentation: [`README.md`](Docs/Architecture/README.md) provides the overview and links to twelve numbered section files; `about-arc42.md` records the template attribution. **Markdown is canonical** — the `.adoc` sources were removed on 2026-11-08. Keep it current **in the same change as the implementation**, not in a later documentation pass. When work alters any of the following, edit the matching section: @@ -160,7 +160,7 @@ Rules: - **Describe what exists.** No aspirational architecture: anything planned belongs in `ROADMAP.md` and `TODO.md`. Every structural claim names the file, script or flag that implements it. - **ADRs are append-only.** Never rewrite an accepted decision; supersede it with a new ADR that says what it replaces and why. -- **Keep the combined document in sync.** `architecture.md` mirrors the section files; when a section changes, update the combined document too. +- **Keep the index current.** `Docs/Architecture/README.md` links to the canonical section pages; update it when the structure or section list changes. - **Images** live in `Docs/Architecture/images/` and are referenced, not duplicated. - **Verify:** all Markdown must pass `prettier --check` (config in `.prettierrc`), and a command you quote must still run. @@ -212,10 +212,10 @@ Do not copy the common packs into the repository: a second copy under a differen Any change that affects the game's appearance — meshes, materials, lighting, levels, character, camera, post-processing — must be rendered for human review: -1. Run `Tools/render_screenshots.sh` (rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs the game on it, captures the fixed camera list, copies the PNGs into `Screenshots/`). -2. Optional pre-check with a vision model: `Tools/judge_screenshot.py --image --expect ""` (or `--shots` to run every shot in the README) flags likely-missing content before a human looks. It reads `OPENROUTER_API_KEY` from `.env` (ignored). Small helper only — it never replaces the human verdict. +1. Run `Tools/review/render_screenshots.sh` (rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs the game on it, captures the fixed camera list, copies the PNGs into `Screenshots/`). +2. Optional pre-check with a vision model: `Tools/review/judge_screenshot.py --image --expect ""` (or `--shots` to run every shot in the README) flags likely-missing content before a human looks. It reads `OPENROUTER_API_KEY` from `.env` (ignored). Small helper only — it never replaces the human verdict. 3. Update `Screenshots/README.md` **in the same change as any visual work** — every change that affects appearance, not only new shots: the image is **embedded inline** (`![alt](demo/.png)`) with its view, expected content, verification and approval state, plus one row in the status table at the top. The review happens in the file; a path alone is not a review, and a shot whose README section is stale is a defect. 4. The PNGs **are committed** so reviewers see exactly what the build rendered (`.gitignore` does not exclude them). Keep them in sync with this README: drop retired shots in the same change. 5. Wait for the user's approval before treating a visual change as done; record approval by ticking both the shot's section and its row in the status table in `Screenshots/README.md`. -New content that needs a different camera angle gets a new entry in the shot list in `Tools/ue_build_review_map.py`. +New content that needs a different camera angle gets a new entry in the shot list in `Tools/unreal/ue_build_review_map.py`. diff --git a/Art/blender/README.md b/Art/blender/README.md index 6cb4ba0..56c61a4 100644 --- a/Art/blender/README.md +++ b/Art/blender/README.md @@ -153,7 +153,7 @@ The extract is complete for the block but not for every relation inside its bbox ## Scale test and the Unreal frame (2026-11-07) -`Art/blender/scaletest/scale-test-2026-11-07.blend` — three 1 m cubes at origin, +10 m east, +10 m north, plus one red test material. Regenerate with `Tools/scale_test_export.py`; import with `Tools/ue_scale_test_import.py`. Both are the named-preset templates for production assets. +`Art/blender/scaletest/scale-test-2026-11-07.blend` — three 1 m cubes at origin, +10 m east, +10 m north, plus one red test material. Regenerate with `Tools/blender/scale_test_export.py`; import with `Tools/unreal/ue_scale_test_import.py`. Both are the named-preset templates for production assets. Export preset **`ue_metric_v1`** (Blender FBX): `apply_unit_scale`, `apply_scale_options='FBX_SCALE_NONE'`, `axis_forward='-Y'`, `axis_up='Z'`, `use_space_transform`, no bake-space-transform. Import preset **`ue_metric_v1`** (UE `FbxImportUI`): static mesh, import materials, uniform scale 1.0, no unit conversion, no force-front-X. diff --git a/Art/blender/hero/hero-facade-2026-11-07.blend b/Art/blender/hero/hero-facade-2026-11-07.blend index 0147c6f..2c2d74e 100644 --- a/Art/blender/hero/hero-facade-2026-11-07.blend +++ b/Art/blender/hero/hero-facade-2026-11-07.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22d94a59b1ee5d77a72ee2b2096a0a73de3fc594a6e7d80cd46fbb05faa71e9e -size 208756 +oid sha256:cb1bdce376574f7d2d5a0531a8192683133ecb7ed5706027fd4bd66037287831 +size 319861 diff --git a/Art/blender/hero/hero-facade-2026-11-07.json b/Art/blender/hero/hero-facade-2026-11-07.json index 12420c1..090f8cc 100644 --- a/Art/blender/hero/hero-facade-2026-11-07.json +++ b/Art/blender/hero/hero-facade-2026-11-07.json @@ -10,12 +10,12 @@ "max": [ 13.08, 0.13, - 14.5 + 14.7 ], "size": [ 26.16, 12.63, - 14.5 + 14.7 ] }, "parts": [ @@ -122,10 +122,10 @@ "object": "Hero_Medallion_x2", "dims_m": [ 1.1, - 0.04, + 0.2, 1.1 ], - "evidence": "photographs: two stone discs beside the motto, mounted on the parapet face" + "evidence": "photographs: two round reliefs beside the motto - dark rim, raised light field, dark boss" }, { "object": "Hero_Stylobate", @@ -166,9 +166,9 @@ { "object": "Hero_Banner_x3", "dims_m": [ - 1.55, + 1.35, 0.06, - 5.4 + 6.2 ], "evidence": "photographs: JIMMY KIMMEL LIVE banners on the column line" }, diff --git a/Art/blender/site/export_site_fbx.py b/Art/blender/site/export_site_fbx.py index 4f26b77..c081357 100644 --- a/Art/blender/site/export_site_fbx.py +++ b/Art/blender/site/export_site_fbx.py @@ -22,6 +22,85 @@ import fbx_export_preset as fbx_preset # noqa: E402 MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North", "Crossing_Markings") +# Ground surfaces are exported in slices. One 279 m slab cannot report an honest height: +# its bounding box is the highest point anywhere along the street, which is 2.2 m above the +# surface at the player's position, and that misled every placement decision in the demo. +# A 20 m slice has a bounding box that means something where it stands. +# The road is sliced for the same reason as the sidewalks: a 279 m strip imports with a single +# convex hull whose top is the strip's MAXIMUM height, so a downward trace anywhere on it lands on +# that hull - a flat lid 2 m above the visible surface. A 20 m slice has a hull that follows the +# surface it belongs to. +SEGMENTED = ("Sidewalk_North", "Sidewalk_South", "Kerb_North", "Kerb_South", + "Road_Hollywood") +SEGMENT_M = 20.0 + + +def _ucx_box(name, x0, x1, y0, y1, z0, z1): + """A thin convex box for a ground slice, named for Unreal's UCX convention.""" + thickness = max(0.10, min(0.30, (z1 - z0) * 0.25)) + cx, cy = (x0 + x1) * 0.5, (y0 + y1) * 0.5 + cz = z1 - thickness * 0.5 + bpy.ops.mesh.primitive_cube_add(size=1.0, location=(cx, cy, cz)) + hull = bpy.context.active_object + hull.name = f"UCX_{name}_01" + hull.scale = ((x1 - x0) * 0.99, (y1 - y0) * 0.99, thickness) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + return hull + + +def _segment(obj, name, out_dir, length_m): + """Export `obj` as slices of `length_m` along X, each with its own honest bbox.""" + xs = [(obj.matrix_world @ v.co).x for v in obj.data.vertices] + x0, x1 = min(xs), max(xs) + count = max(1, int(round((x1 - x0) / length_m))) + step = (x1 - x0) / count + bpy.context.view_layer.objects.active = obj + for index in range(count): + a = x0 + index * step + b = a + step + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.ops.object.duplicate() + piece = bpy.context.active_object + bpy.context.view_layer.objects.active = piece + bpy.ops.object.mode_set(mode="EDIT") + # Two cuts, each with the normal pointing at the half to KEEP and clear_inner=True: + # normal +X at a -> keeps x > a + # normal -X at b -> keeps x < b + # The first version used clear_outer for the far cut and produced nested slices that + # each still spanned to the end of the street, which made every slice's bbox top out + # at the same far-away maximum. + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.mesh.bisect(plane_co=(a, 0.0, 0.0), plane_no=(1.0, 0.0, 0.0), + clear_inner=True) + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.mesh.bisect(plane_co=(b, 0.0, 0.0), plane_no=(-1.0, 0.0, 0.0), + clear_inner=True) + bpy.ops.object.mode_set(mode="OBJECT") + piece.name = f"{name}_{index:02d}" + zs = [(piece.matrix_world @ v.co).z for v in piece.data.vertices] + # Explicit UCX box per slice. Neither the triangulated complex collision nor + # complex-as-simple makes these meshes block in Unreal, so the collision is authored + # here: one convex box per 20 m slice, thin, with its top at the slice's surface. + xs_w = [(piece.matrix_world @ v.co).x for v in piece.data.vertices] + ys_w = [(piece.matrix_world @ v.co).y for v in piece.data.vertices] + hull = _ucx_box(piece.name, min(xs_w), max(xs_w), min(ys_w), max(ys_w), + min(zs), max(zs)) + path = os.path.join(out_dir, f"{piece.name}.fbx") + bpy.ops.object.select_all(action="DESELECT") + piece.select_set(True) + hull.select_set(True) + bpy.context.view_layer.objects.active = piece + fbx_preset.export_static(path, selection=True) + bpy.data.objects.remove(hull, do_unlink=True) + print(f"[site] {piece.name:22} x {a:+.1f}..{b:+.1f} m " + f"z {min(zs):+.2f}..{max(zs):+.2f} m -> {path}") + bpy.data.objects.remove(piece, do_unlink=True) + # The context loses its active object when the duplicate goes; restore the source so + # the next iteration's operators have something to act on. + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] @@ -62,6 +141,9 @@ def main(): me.update() _ = bpy.context.view_layer bpy.context.view_layer.objects.active = obj + if name in SEGMENTED: + _segment(obj, name, args.out, SEGMENT_M) + continue path = os.path.join(args.out, name + ".fbx") fbx_preset.export_static(path, selection=True) if mirrored: diff --git a/Art/blender/street/street-2026-11-06.blend b/Art/blender/street/street-2026-11-06.blend index 6facef5..310891c 100644 --- a/Art/blender/street/street-2026-11-06.blend +++ b/Art/blender/street/street-2026-11-06.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:534493dc63a6b3aa7b84e1071589f5437dfb27185a1f1fbfa8a05f2330eba43b -size 514055 +oid sha256:6e47d8ffc1f13a41b6676859b5869b16186028f1659fabf79e43cef79e8480a6 +size 514064 diff --git a/Art/blender/vehicle/gigline-sedan.blend b/Art/blender/vehicle/gigline-sedan.blend new file mode 100644 index 0000000..c65248a --- /dev/null +++ b/Art/blender/vehicle/gigline-sedan.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62a71eb86eda821801f7d4928de848d0390fe4f8621a1c3090d9c25cbaba1b39 +size 102439 diff --git a/Art/blender/vehicle/hatch-reference.png b/Art/blender/vehicle/hatch-reference.png new file mode 100644 index 0000000..fb7ba23 --- /dev/null +++ b/Art/blender/vehicle/hatch-reference.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ef31ae09b47d4ef00fb2cebf12867349262c41d1c89efb9b07f2d6d7f36f7b3 +size 939409 diff --git a/Art/blender/vehicle/hot hatch.blend b/Art/blender/vehicle/hot hatch.blend new file mode 100644 index 0000000..1c25f7e --- /dev/null +++ b/Art/blender/vehicle/hot hatch.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eadaaa28d60dcc97f7d101432852f9f31e8bf74b92f041d7520d1904591b3bfe +size 15292452 diff --git a/Art/blender/vehicle/preview.png b/Art/blender/vehicle/preview.png new file mode 100644 index 0000000..756835c --- /dev/null +++ b/Art/blender/vehicle/preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78f7801780c5e6821043ccc9ec093a3e6aee46b8bb332d3c4d66e66f24f539d7 +size 786523 diff --git a/Art/textures/generated/T_Banner_KIMMEL.png b/Art/textures/generated/T_Banner_KIMMEL.png new file mode 100644 index 0000000..dfbf878 --- /dev/null +++ b/Art/textures/generated/T_Banner_KIMMEL.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32269a46cbc830f75925c52b6a9f65d2875ac95d6829958f995e1ee70bba9f3c +size 21318 diff --git a/Art/textures/generated/T_FacadeAshlar_AI.png b/Art/textures/generated/T_FacadeAshlar_AI.png new file mode 100644 index 0000000..43c9a69 --- /dev/null +++ b/Art/textures/generated/T_FacadeAshlar_AI.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e0f625c60132cb0a9c8dadd13147ed3d3ae1fb18210f69fcf780373a48cede0 +size 1422514 diff --git a/Art/textures/generated/T_FacadeAshlar_AI__albedobase.json b/Art/textures/generated/T_FacadeAshlar_AI__albedobase.json new file mode 100644 index 0000000..6aa9491 --- /dev/null +++ b/Art/textures/generated/T_FacadeAshlar_AI__albedobase.json @@ -0,0 +1,106 @@ +{ + "prompt": "top-down orthographic game texture, flat even lighting, no shadows, no AO, no vignette, PBR albedo, material scan, neutral colours, smooth pale limestone marble cladding, large thin-jointed ashlar blocks, very low contrast, desaturated off-white concrete tone, fine even grain, flat even lighting, no shadows, no vignette, bright clean PBR albedo", + "profile": "albedobase", + "size": [ + 1024, + 1024 + ], + "seed": 55, + "settings": { + "steps": 30, + "cfg": 6.0, + "sampler": "dpmpp_2m", + "scheduler": "karras" + }, + "licence": "AlbedoBase XL v3.1 (SDXL) - game albedo, commercial use with credit", + "tool": "Tools/gen/comfyui_generate.py", + "generated_utc": "2026-11-10T13:15:21Z", + "workflow": { + "1": { + "class_type": "CheckpointLoaderSimple", + "inputs": { + "ckpt_name": "albedobaseXL_v31Large.safetensors" + } + }, + "4": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "top-down orthographic game texture, flat even lighting, no shadows, no AO, no vignette, PBR albedo, material scan, neutral colours, smooth pale limestone marble cladding, large thin-jointed ashlar blocks, very low contrast, desaturated off-white concrete tone, fine even grain, flat even lighting, no shadows, no vignette, bright clean PBR albedo", + "clip": [ + "1", + 1 + ] + } + }, + "5": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "shadows, perspective, vignette, dramatic lighting, blurry, low quality, text, watermark", + "clip": [ + "1", + 1 + ] + } + }, + "6": { + "class_type": "EmptyLatentImage", + "inputs": { + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + "7": { + "class_type": "KSampler", + "inputs": { + "seed": 55, + "steps": 30, + "cfg": 6.0, + "sampler_name": "dpmpp_2m", + "scheduler": "karras", + "denoise": 1.0, + "model": [ + "1", + 0 + ], + "positive": [ + "4", + 0 + ], + "negative": [ + "5", + 0 + ], + "latent_image": [ + "6", + 0 + ] + } + }, + "8": { + "class_type": "VAEDecode", + "inputs": { + "samples": [ + "7", + 0 + ], + "vae": [ + "1", + 2 + ] + } + }, + "9": { + "class_type": "SaveImage", + "inputs": { + "images": [ + "8", + 0 + ], + "filename_prefix": "texture_albedobase" + } + } + }, + "comfyui_prompt_id": "aa517bfa-9ad0-4adb-b01a-39e50b1f8f23", + "note": "generated locally; record the model licence in THIRD_PARTY_NOTICES.md before shipping" +} \ No newline at end of file diff --git a/Art/textures/generated/T_FacadeAshlar_AI__krea2.json b/Art/textures/generated/T_FacadeAshlar_AI__krea2.json new file mode 100644 index 0000000..92aff5f --- /dev/null +++ b/Art/textures/generated/T_FacadeAshlar_AI__krea2.json @@ -0,0 +1,120 @@ +{ + "prompt": "top-down orthographic game texture, flat even lighting, no shadows, no AO, no vignette, PBR albedo, material scan, neutral colours, smooth pale limestone marble cladding, large thin-jointed ashlar blocks, very low contrast, desaturated off-white concrete tone, fine even grain, flat even lighting, no shadows, no vignette, bright clean PBR albedo", + "profile": "krea2", + "size": [ + 1024, + 1024 + ], + "seed": 55, + "settings": { + "steps": 8, + "cfg": 1.0, + "sampler": "euler", + "scheduler": "simple" + }, + "licence": "Krea-2 Turbo NVFP4 - fast stochastic detail (stone, bark, foliage)", + "tool": "Tools/gen/comfyui_generate.py", + "generated_utc": "2026-11-10T13:15:33Z", + "workflow": { + "1": { + "class_type": "UNETLoader", + "inputs": { + "unet_name": "krea2_turbo_nvfp4.safetensors", + "weight_dtype": "default" + } + }, + "2": { + "class_type": "CLIPLoader", + "inputs": { + "clip_name": "qwen3vl_4b_int8_convrot.safetensors", + "type": "krea2", + "device": "default" + } + }, + "3": { + "class_type": "VAELoader", + "inputs": { + "vae_name": "texture_fix_vae_for_qwen_image_1_and_krea_2_bf16.safetensors" + } + }, + "4": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "top-down orthographic game texture, flat even lighting, no shadows, no AO, no vignette, PBR albedo, material scan, neutral colours, smooth pale limestone marble cladding, large thin-jointed ashlar blocks, very low contrast, desaturated off-white concrete tone, fine even grain, flat even lighting, no shadows, no vignette, bright clean PBR albedo", + "clip": [ + "2", + 0 + ] + } + }, + "5": { + "class_type": "ConditioningZeroOut", + "inputs": { + "conditioning": [ + "4", + 0 + ] + } + }, + "6": { + "class_type": "EmptyLatentImage", + "inputs": { + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + "7": { + "class_type": "KSampler", + "inputs": { + "seed": 55, + "steps": 8, + "cfg": 1.0, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1.0, + "model": [ + "1", + 0 + ], + "positive": [ + "4", + 0 + ], + "negative": [ + "5", + 0 + ], + "latent_image": [ + "6", + 0 + ] + } + }, + "8": { + "class_type": "VAEDecode", + "inputs": { + "samples": [ + "7", + 0 + ], + "vae": [ + "3", + 0 + ] + } + }, + "9": { + "class_type": "SaveImage", + "inputs": { + "images": [ + "8", + 0 + ], + "filename_prefix": "texture_krea2" + } + } + }, + "comfyui_prompt_id": "946b5d1a-7198-43ac-8f71-afa800416803", + "note": "generated locally; record the model licence in THIRD_PARTY_NOTICES.md before shipping" +} \ No newline at end of file diff --git a/Art/textures/generated/T_FacadeAshlar_AI__krea2.png b/Art/textures/generated/T_FacadeAshlar_AI__krea2.png new file mode 100644 index 0000000..7116997 --- /dev/null +++ b/Art/textures/generated/T_FacadeAshlar_AI__krea2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e2e5a3d544198aa5c244e4f6ade0be94d5962086f924381626a91d9df62ea10 +size 1285337 diff --git a/CHANGELOG.md b/CHANGELOG.md index a188ffc..f45b248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,12 +38,12 @@ The character becomes a character: clothed, haired, and measured against a gener ### Added -- **Clothing** — navy tee, dark jeans and off-white shoes as three separate skinned meshes (`Tools/character_clothing_build.py`), built from the body's own faces per the `character-artist` workflow: duplicate faces, solidify for cloth thickness, one mesh per piece. Each imports with its own single material slot and is attached as a leader-pose component. -- **Hair cards** (`Tools/character_hair_build.py`) — a scalp cap plus 26 cards in three overlapping rows, quads only, every vertex weighted to `head`. Cards are oriented from the scalp normal at their anchor, so the cut follows the skull instead of standing out as spikes. -- **Character blueprints** (`Tools/comfyui_character_blueprint.py`) — front, side, back and head views generated by the local ComfyUI instance from one description, with fixed seeds and a JSON record of prompts and prompt ids. -- **Blueprint comparison** (`Tools/character_match.py`) — figure aspect, fill and coverage deltas, mean CIE76 and dominant-palette distance, plus a side-by-side montage. The deltas stay separate on purpose: one similarity number between a photoreal sheet and a low-detail body would mean nothing. -- **ComfyUI driver** (`Tools/comfyui_generate.py`) and **procedural textures** (`Tools/generate_character_textures.py`), each writing a provenance sidecar. -- **Material builder** (`Tools/ue_character_materials.py`) — `M_Character_Skin` and `M_Character_Hair_Mat` built from scratch, because `unreal.MaterialEditingLibrary` segfaults in a headless commandlet when it rebuilds an existing graph. +- **Clothing** — navy tee, dark jeans and off-white shoes as three separate skinned meshes (`Tools/blender/character_clothing_build.py`), built from the body's own faces per the `character-artist` workflow: duplicate faces, solidify for cloth thickness, one mesh per piece. Each imports with its own single material slot and is attached as a leader-pose component. +- **Hair cards** (`Tools/blender/character_hair_build.py`) — a scalp cap plus 26 cards in three overlapping rows, quads only, every vertex weighted to `head`. Cards are oriented from the scalp normal at their anchor, so the cut follows the skull instead of standing out as spikes. +- **Character blueprints** (`Tools/blender/comfyui_character_blueprint.py`) — front, side, back and head views generated by the local ComfyUI instance from one description, with fixed seeds and a JSON record of prompts and prompt ids. +- **Blueprint comparison** (`Tools/blender/character_match.py`) — figure aspect, fill and coverage deltas, mean CIE76 and dominant-palette distance, plus a side-by-side montage. The deltas stay separate on purpose: one similarity number between a photoreal sheet and a low-detail body would mean nothing. +- **ComfyUI driver** (`Tools/gen/comfyui_generate.py`) and **procedural textures** (`Tools/gen/generate_character_textures.py`), each writing a provenance sidecar. +- **Material builder** (`Tools/unreal/ue_character_materials.py`) — `M_Character_Skin` and `M_Character_Hair_Mat` built from scratch, because `unreal.MaterialEditingLibrary` segfaults in a headless commandlet when it rebuilds an existing graph. ### Changed @@ -92,7 +92,7 @@ Third-person body. The pawn gains a visible, animated body and a third-person ca ### Added -- **`Tools/character_anim_build.py`**: `GigLine_Idle` (60 frames) and `GigLine_Walk` (30 frames) at 30 fps, authored as parametric cycles so the loop closes by construction. Limb axes were **measured** on the rig — a probe rotated `upperleg01.L` by 0.5 rad on each axis and read `foot.L`, which showed X swings the limb fore/aft while Y twists and Z abducts. +- **`Tools/blender/character_anim_build.py`**: `GigLine_Idle` (60 frames) and `GigLine_Walk` (30 frames) at 30 fps, authored as parametric cycles so the loop closes by construction. Limb axes were **measured** on the rig — a probe rotated `upperleg01.L` by 0.5 rad on each axis and read `foot.L`, which showed X swings the limb fore/aft while Y twists and Z abducts. - **Third-person rig** (`ADR-9`): a visible skeletal body, a `USpringArmComponent` with collision testing, and a **V** toggle. One pawn carries both rigs; first person stays the shipped default. - **Walk capture harness**: a per-shot `ShotFollow` mode that re-places the review camera every frame in the pawn's frame, plus `SetReviewWalk` / `ReturnToStartForReview`, so a stride is captured deterministically and the fixed-camera shots still find the pawn at spawn. - **Review shots 6, 7 and 8** — third-person back, front and mid-stride — embedded in `Screenshots/README.md`. @@ -130,8 +130,8 @@ Retail frontage. The two retail buildings beside the hero are modelled from thei ### Added -- **`Tools/retail_frontage_build.py`**: 6800 Lee Drug and 6806–6820 built from the one usable reference (`Art/reference/retail-neighbours/`, Gb321, CC BY-SA 4.0). Lee Drug gets a bronze cornice, storefront glazing with mullions, framed portrait panels, a painted lettering band, two-tier parapet with reveals and a fluted corner tower; 6806–6820 gets a burgundy fascia, brown pilasters, white-framed windows with security bars and its own banded parapet. Built in world coordinates from the blockout bounds (Lee Drug X −33.20…−14.37, face Y −14.63, H 12.10 m; 6806–6820 X −66.08…−33.07), joined to one mesh per building with a JSON part report. -- **`Tools/ue_import_retail_detail.py`**: imports the two meshes with complex-as-simple collision — trim decorates a solid the player already collides with and must not add snag points. +- **`Tools/blender/retail_frontage_build.py`**: 6800 Lee Drug and 6806–6820 built from the one usable reference (`Art/reference/retail-neighbours/`, Gb321, CC BY-SA 4.0). Lee Drug gets a bronze cornice, storefront glazing with mullions, framed portrait panels, a painted lettering band, two-tier parapet with reveals and a fluted corner tower; 6806–6820 gets a burgundy fascia, brown pilasters, white-framed windows with security bars and its own banded parapet. Built in world coordinates from the blockout bounds (Lee Drug X −33.20…−14.37, face Y −14.63, H 12.10 m; 6806–6820 X −66.08…−33.07), joined to one mesh per building with a JSON part report. +- **`Tools/unreal/ue_import_retail_detail.py`**: imports the two meshes with complex-as-simple collision — trim decorates a solid the player already collides with and must not add snag points. - **`Art/blender/retail/ref-analysis.md`**: the reference analysis the `references` skill pack requires, written before modelling. - **`Tools/_common.py`** and `Tools/tests/test_retail_frontage.py`: the bay-layout arithmetic, extracted so it is testable without Blender (4 tests). - **Review shot 5**, `demo/review-demo-retail-frontage`, embedded in `Screenshots/README.md`. @@ -144,7 +144,7 @@ Retail frontage. The two retail buildings beside the hero are modelled from thei ### Fixed -- **`Tools/render_screenshots.sh` never captured anything.** The capture step called `./Engine/...` while every commandlet above it went through `run_cmdlet`'s `cd "$ENGINE"`. Under `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. +- **`Tools/review/render_screenshots.sh` never captured anything.** The capture step called `./Engine/...` while every commandlet above it went through `run_cmdlet`'s `cd "$ENGINE"`. Under `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. - **Duplicate `RetailTrim_*` actors on every rebuild**: the demo builder's clear list did not include the new label, so each run stacked another copy. Caught by the scene audit, not by eye. - **Mural plates were placed 0.20 m inside the solid blockout volume** and never rendered; the inset is now faked with a proud frame and a near-flush plate. - **Trim detail sat 0.05 m off the wall**, under a pixel of depth at 40 m, so the wall won the depth test. Trim planes now stand 0.10–0.20 m proud. @@ -170,8 +170,8 @@ Façade finishing. The kit gets the hero detail it was missing, and a class of s ### Added -- **Scene audit** (`Tools/ue_audit_scene.py`): fails when any staged mesh has no material, when a frontage building is more than ±1 m off its verified footprint, or when two actors share a label. Wired into `Tools/render_screenshots.sh`, which now runs the whole staging chain in the correct order and stops on an audit failure. -- **`Tools/ue_placement.py`**: one source of truth for staged positions and the verified frontage spans, with `span_error` / `describe_span_failure` validators. +- **Scene audit** (`Tools/unreal/ue_audit_scene.py`): fails when any staged mesh has no material, when a frontage building is more than ±1 m off its verified footprint, or when two actors share a label. Wired into `Tools/review/render_screenshots.sh`, which now runs the whole staging chain in the correct order and stops on an audit failure. +- **`Tools/unreal/ue_placement.py`**: one source of truth for staged positions and the verified frontage spans, with `span_error` / `describe_span_failure` validators. - **Hero fluting**: 20 grooves per column as shallow shadow-tone cylinders (120 pieces) rather than boolean cuts. - **Presence of references for the retail run**: `Art/reference/retail-neighbours/` (6800 Lee Drug and its neighbour). Local only, not committed. @@ -210,9 +210,9 @@ Building façades. The block goes from flat extruded boxes with flat colours to - **Generated surface textures** — deterministic stdlib PNGs for ashlar stone, stucco, asphalt and concrete (base colour + roughness), sampled world-aligned with per-material tiling; byte-identical on re-run. - **Materials**: masonry coursing and roughness variation in the material graphs; `M_FacadeStoneShadow`, `M_FacadeGlass` (specular 0.62 ≈ IOR 1.5) and `M_FacadeBanner`. Material assignment is now **per slot**, keyed on the imported slot material, instead of slot 0 only. - **El Capitan Theatre trim**: projecting marquee (12.0 × 2.60 m canopy at a 4.60 m soffit), illuminated fascia, recessed soffit, and a vertical blade sign (1.30 × 5.20 m) with frame and wall bracket. -- **Visual review loop** (`Tools/visual_loop.py` + `visual_targets.json`): verifies every capture against an expectation (missing, blank, tone-less or wrong-size shots fail) and measures each render against its reference photograph, emitting feedback that names the discipline to consult. Iterations are written under `Art/reference/iterations/`, which is gitignored until a shot is confirmed. -- **`Tools/fbx_export_preset.py`** — one export preset replacing six copies, and it exports smoothing groups (`FACE`), so round geometry is no longer faceted. -- **`Tools/ue_common.py`** — clears the read-only bit an LFS `.umap` checks out with and makes a refused save loud. +- **Visual review loop** (`Tools/review/visual_loop.py` + `visual_targets.json`): verifies every capture against an expectation (missing, blank, tone-less or wrong-size shots fail) and measures each render against its reference photograph, emitting feedback that names the discipline to consult. Iterations are written under `Art/reference/iterations/`, which is gitignored until a shot is confirmed. +- **`Tools/blender/fbx_export_preset.py`** — one export preset replacing six copies, and it exports smoothing groups (`FACE`), so round geometry is no longer faceted. +- **`Tools/unreal/ue_common.py`** — clears the read-only bit an LFS `.umap` checks out with and makes a refused save loud. ### Changed @@ -249,7 +249,7 @@ Packaging and setup fixes. No gameplay or visual scope; this patch makes the pip ### Fixed -- **Map imports could report success while saving nothing.** `.umap` files are LFS-tracked and check out read-only, so `save_map()` returned `False` with no error and the staged actor vanished on the next load. New `Tools/ue_common.py` (`make_writable`, `require_saved`) clears the read-only bit and makes the failure loud; `ue_hero_facade_import.py` and `ue_import_north_buildings.py` now use it. Verified from a forced `0o444` map: chmod logged, `save_map: True`, exit 0. +- **Map imports could report success while saving nothing.** `.umap` files are LFS-tracked and check out read-only, so `save_map()` returned `False` with no error and the staged actor vanished on the next load. New `Tools/unreal/ue_common.py` (`make_writable`, `require_saved`) clears the read-only bit and makes the failure loud; `ue_hero_facade_import.py` and `ue_import_north_buildings.py` now use it. Verified from a forced `0o444` map: chmod logged, `save_map: True`, exit 0. ### Changed @@ -268,8 +268,8 @@ Scale and façade correction patch. Buildings and the hero elevation now match t ### Added -- **`Tools/facade_detail_concepts.py`** — generates frontal building plates (whole elevation, entrance in its façade, building with neighbours) from a local reference photograph through OpenRouter image models. Spec-driven, never overwrites, writes a provenance sidecar per image, output stays in the gitignored `Art/reference/` tree. Local working material only. -- **`Tools/fbx_export_preset.py`** — the single FBX export preset (`ue_metric_v1` / `ue_character_v1`). Replaces the argument block that had been copied into six exporters. +- **`Tools/gen/facade_detail_concepts.py`** — generates frontal building plates (whole elevation, entrance in its façade, building with neighbours) from a local reference photograph through OpenRouter image models. Spec-driven, never overwrites, writes a provenance sidecar per image, output stays in the gitignored `Art/reference/` tree. Local working material only. +- **`Tools/blender/fbx_export_preset.py`** — the single FBX export preset (`ue_metric_v1` / `ue_character_v1`). Replaces the argument block that had been copied into six exporters. - **Façade occlusion probe** in `-qaselftest`: 40 m traces sideways from the road centre at walking height, required wherever a building is staged. - **Ionic detail on the hero**: base moulding at every shaft foot, a square abacus over every capital, and a 52-dentil cornice course. - **Tool tests**: `Tools/tests/` now covers the input driver, height classification, the Mapbox cross-check, the concept generator and the FBX preset (49 tests). @@ -335,16 +335,16 @@ The first playable release: a walkable exterior demo of the **6840 Hollywood Blv - **Unreal project** (`Game/GigLine`) — C++ game module, Enhanced Input, packaged Linux build, and a `DemoMap` that a plain launch opens directly. - **Walkable demo level** — terrain from the 3DEP DEM, Hollywood Blvd carriageway, both sidewalks and kerbs with collision, the designated mid-block crossing, and fixed daytime lighting (sun, sky light, sky atmosphere, height fog, pinned exposure). -- **Hero façade** for 6840 Hollywood Blvd — six-column neoclassical elevation with three genuine portal recesses, entablature, parapet and motto band (`Tools/hero_facade_build.py`, `Tools/ue_hero_facade_import.py`). +- **Hero façade** for 6840 Hollywood Blvd — six-column neoclassical elevation with three genuine portal recesses, entablature, parapet and motto band (`Tools/blender/hero_facade_build.py`, `Tools/unreal/ue_hero_facade_import.py`). - **Block frontage** — the five verified south-side neighbours (6922, 6902, 6838 El Capitan Theatre, 6822 Budget, 6806–6822, 6800 Lee Drug) and the two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) placed at their OSM positions. - **First-person player** — `AGigLineCharacter`: 1.75 m capsule, 1.60 m eye camera, 4 m/s walk, mouse look, jump, reset (`R`) and pause (`Escape`); controls documented in `Docs/controls.md` and shown in-game. - **In-game ODbL attribution** — `AGigLineHUD` draws the OpenStreetMap credit and licence line every frame. -- **QA harness** — `-qaselftest` (walk, reset, pause, seam transect, invisible-wall probe, frame-time capture) and `Tools/input_test.py` / `Tools/input_emulation.py` for OS-level virtual keyboard/mouse input. +- **QA harness** — `-qaselftest` (walk, reset, pause, seam transect, invisible-wall probe, frame-time capture) and `Tools/input/input_test.py` / `Tools/input/input_emulation.py` for OS-level virtual keyboard/mouse input. - **Documentation** — setup, build, toolchain, licensing, ODbL review, release checks, and arc42 architecture docs. ### Fixed -- **All controls were dead in packaged and PIE builds.** The mapping-context generator wrote key bindings to the deprecated `mappings` array; UE 5.8 reads `DefaultKeyMappings.Mappings`, so the cooked game had zero runtime mappings and every key resolved to nothing. `Tools/ue_create_input_assets.py` now populates the current field. +- **All controls were dead in packaged and PIE builds.** The mapping-context generator wrote key bindings to the deprecated `mappings` array; UE 5.8 reads `DefaultKeyMappings.Mappings`, so the cooked game had zero runtime mappings and every key resolved to nothing. `Tools/unreal/ue_create_input_assets.py` now populates the current field. - **"Fall-through" was an 8 m invisible box.** `Terrain_DEM` shipped only a bounding-box collision proxy; forcing complex-as-simple collision (`UGigLineAssetTools`) put the player on the actual street. - **Reset returned the player to mid-air** — the start position is now captured on the first grounded frame. - **Plain launch opened the engine template** — the default map now lives in `DefaultEngine.ini` and points at `DemoMap`. diff --git a/Docs/Architecture/04_solution_strategy.md b/Docs/Architecture/04_solution_strategy.md index 7f7d693..d384c09 100644 --- a/Docs/Architecture/04_solution_strategy.md +++ b/Docs/Architecture/04_solution_strategy.md @@ -30,7 +30,7 @@ All modelling and geographic processing live outside the game project in `Art/`, ## Quality achievement -- **Realism first**: reference photography and measured data drive every façade decision; renders are compared against the photographs with `Tools/visual_loop.py`, and a façade is built from the shared 4.96 m module kit so the street wall reads consistently. Materials are authored in Unreal with world-aligned generated textures, and lighting is fixed daytime so a shot is comparable between runs. +- **Realism first**: reference photography and measured data drive every façade decision; renders are compared against the photographs with `Tools/review/visual_loop.py`, and a façade is built from the shared 4.96 m module kit so the street wall reads consistently. Materials are authored in Unreal with world-aligned generated textures, and lighting is fixed daytime so a shot is comparable between runs. - **Performance as a budget, not a driver**: dynamic-only lighting (no build step, no static shadow maps), world-space-noise materials (cheap procedural variation), minimal overdraw for street-level geometry, and a 60 FPS at 1440p floor that every release must meet. It closes the quality list because it constrains how much realism fits, not what the block should look like. - **Walkability**: complex-as-simple collision on terrain (C++ helper forces the flag on the asset), block-all collision on every site mesh, swept transect QA verifies zero raycast holes. diff --git a/Docs/Architecture/05_building_block_view.md b/Docs/Architecture/05_building_block_view.md index 1d35105..a0e4945 100644 --- a/Docs/Architecture/05_building_block_view.md +++ b/Docs/Architecture/05_building_block_view.md @@ -20,7 +20,7 @@ C++ classes in `Game/GigLine/Source/GigLine/`: | --- | --- | --- | | `AGigLineCharacter` | First-person player pawn | 1.75 m capsule, 4.5 m/s walk, Enhanced Input bindings, reset, pause, `-qaselftest` harness | | `AGigLineGameModeBase` | Sets game mode and HUD class | `HUDClass = AGigLineHUD` | -| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: ODbL attribution bottom-left, controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) | +| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) | | `AGigLineNPCPerson` | NPC prefab (future) | Capsule + visible MPFB body, feet on Z 0 | | `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits | | `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets | @@ -49,7 +49,7 @@ Static meshes and materials in `/Game/Hollywood/Site/`: | `SM_HeroFacade` | El Capitan Entertainment Centre detailed façade | | `SM_ElCapitanTheatre`, `SM_Budget_6822`, `SM_Hollywood_6806`, `SM_Hollywood_6902`, `SM_Hollywood_6922`, `SM_LeeDrug_6800` | South block frontage (6 buildings) | | `SM_Madame_Tussauds`, `SM_TCL_Chinese_Theatre` | North-side buildings (within 50 m) | -| `SM_Retail_LeeDrug`, `SM_Retail_6806` | Retail trim over 6800 and 6806–6820, modelled from their own photograph (`Tools/retail_frontage_build.py`, staged as `RetailTrim_*`). `6822 Budget` stays a bare volume: no photograph exists. | +| `SM_Retail_LeeDrug`, `SM_Retail_6806` | Retail trim over 6800 and 6806–6820, modelled from their own photograph (`Tools/blender/retail_frontage_build.py`, staged as `RetailTrim_*`). `6822 Budget` stays a bare volume: no photograph exists. | ### Art Pipeline diff --git a/Docs/Architecture/06_runtime_view.md b/Docs/Architecture/06_runtime_view.md index 06b7745..ae8cbd2 100644 --- a/Docs/Architecture/06_runtime_view.md +++ b/Docs/Architecture/06_runtime_view.md @@ -18,7 +18,7 @@ ## Screenshot capture -1. `Tools/render_screenshots.sh` compiles editor, builds `ReviewMap` from `Tools/ue_build_review_map.py`, launches game in `-game` mode. +1. `Tools/review/render_screenshots.sh` compiles editor, builds `ReviewMap` from `Tools/unreal/ue_build_review_map.py`, launches game in `-game` mode. 2. `AGigLineShotActor` iterates the camera list, sets view, calls `HighResShot 1280x720`, waits for capture. @@ -48,6 +48,6 @@ 1. Blender headless (`Tools/export_*.py`) reads `.blend` files, applies metre→centimetre conversion, exports FBX with `ue_metric_v1` preset. -2. Unreal headless (`Tools/ue_import_*.py`) imports FBX via Interchange, places meshes at geographic coordinates, sets collision profiles. +2. Unreal headless (`Tools/unreal/ue_import_*.py`) imports FBX via Interchange, places meshes at geographic coordinates, sets collision profiles. -3. `Tools/ue_build_demo_map.py` assembles `DemoMap`: creates/repositions actors, sets lighting, saves map and assets. +3. `Tools/unreal/ue_build_demo_map.py` assembles `DemoMap`: creates/repositions actors, sets lighting, saves map and assets. diff --git a/Docs/Architecture/10_quality_requirements.md b/Docs/Architecture/10_quality_requirements.md index 718e4bf..f4491c6 100644 --- a/Docs/Architecture/10_quality_requirements.md +++ b/Docs/Architecture/10_quality_requirements.md @@ -22,7 +22,7 @@ Ordered by priority: realism first, performance last. The order decides design t | ID | Source/Stimulus | Response | Metric | | --- | --- | --- | --- | -| QS-1 | Screenshot is compared against its reference photograph | The render reads as the same building | Similarity score and visual match confirmed by the owner; `Tools/visual_loop.py` reports the deltas | +| QS-1 | Screenshot is compared against its reference photograph | The render reads as the same building | Similarity score and visual match confirmed by the owner; `Tools/review/visual_loop.py` reports the deltas | | QS-2 | A façade is placed from measured data | It sits on its verified footprint | World span probed against `Geo/verification/` within ~1 m | | QS-3 | Player walks both sides of the demo | No fall-through, character stays at ground Z | 0 raycast holes in 84-sample transect | | QS-4 | Player holds W for 2 s on flat road | Character moves forward | ≥ 200 cm displacement, Z constant within ±5 cm | @@ -41,5 +41,5 @@ Ordered by priority: realism first, performance last. The order decides design t | BR-1 | A building with a reference photograph has a review shot compared against it | Silhouette and proportion deltas recorded per building in `Screenshots/README.md`; owner approval per shot | | BR-2 | Every building is either photo-matched or explicitly recorded as an unfounded blockout | List in `TODO.md`; no building in an in-between state | | BR-3 | Every building has a collision proxy and at least one LOD | Count of buildings missing either = 0 | -| BR-4 | No surface carries a placeholder material | Material audit in `Tools/ue_audit_scene.py` reports 0 placeholder slots | +| BR-4 | No surface carries a placeholder material | Material audit in `Tools/unreal/ue_audit_scene.py` reports 0 placeholder slots | | BR-5 | The realism pass does not break the floor | QS-9 still holds at 1440p, with the measured cost of the pass recorded | diff --git a/Docs/Architecture/README.md b/Docs/Architecture/README.md new file mode 100644 index 0000000..3693b74 --- /dev/null +++ b/Docs/Architecture/README.md @@ -0,0 +1,27 @@ +--- +title: Gig Line Architecture Documentation +description: Architecture documentation for the Gig Line Linux-first Unreal Engine 5 demo around 6840 Hollywood Blvd. +status: v0.1.0 +date: 2026-11-08 +--- + +Gig Line is a Linux-first Unreal Engine 5 walking and driving demo around 6840 Hollywood Boulevard and the El Capitan Entertainment Centre. The architecture prioritises visual realism and geographically faithful placement while maintaining a fully walkable scene, reproducible asset pipeline, documented provenance, and a 60 FPS floor at 1440p. + +This directory contains the canonical arc42 documentation. Each section is maintained independently; update the relevant page in the same change as its implementation. Project work and future scope remain in [TODO.md](../../TODO.md) and [ROADMAP.md](../../ROADMAP.md). + +## Sections + +1. [Introduction and Goals](01_introduction_and_goals.md) +2. [Architecture Constraints](02_architecture_constraints.md) +3. [Context and Scope](03_context_and_scope.md) +4. [Solution Strategy](04_solution_strategy.md) +5. [Building Block View](05_building_block_view.md) +6. [Runtime View](06_runtime_view.md) +7. [Deployment View](07_deployment_view.md) +8. [Cross-cutting Concepts](08_concepts.md) +9. [Architecture Decisions](09_architecture_decisions.md) +10. [Quality Requirements](10_quality_requirements.md) +11. [Risks and Technical Debt](11_technical_risks.md) +12. [Glossary](12_glossary.md) + +See also [About arc42](about-arc42.md). diff --git a/Docs/Architecture/architecture.md b/Docs/Architecture/architecture.md deleted file mode 100644 index e50df9a..0000000 --- a/Docs/Architecture/architecture.md +++ /dev/null @@ -1,570 +0,0 @@ ---- -title: Gig Line Architecture Documentation -description: Architecture documentation for the Gig Line Linux-first Unreal Engine 5 demo around 6840 Hollywood Blvd. -status: v0.1.0 -date: 2026-11-08 ---- - -# Introduction and Goals - -Gig Line is a Linux-first Unreal Engine 5 game that reconstructs a walkable (and eventually drivable) stretch of Hollywood Boulevard around 6840 Hollywood Blvd / El Capitan Entertainment Centre. The initial release (`0.1.0`) delivers a first-person walking demo of the south-side block between Highland Avenue and Orange Drive, plus north-side buildings within 50 m of the frontage. - -## Requirements Overview - -| ID | Description | -| --- | --- | -| F-1 | Walk both sides of Hollywood Blvd in the demo area without falling through geometry | -| F-2 | First-person movement (WASD + mouse look), jump, reset (R), pause (Escape) | -| F-3 | Fixed daytime lighting and atmosphere (sun, sky, fog, exposure) | -| F-5 | Designated street crossing with zebra markings at signalised crossings | -| F-6 | Building façades recognisable against reference photography | -| F-7 | Terrain, road, and sidewalk follow real-world DEM elevation | -| F-8 | 60 FPS at 1440p on the documented workstation and quality preset | - -Full requirements and progress are in [TODO.md](../../TODO.md); version direction is in [ROADMAP.md](../../ROADMAP.md). - -## Quality Goals - -| Priority | Quality Goal | Scenario | -| --- | --- | --- | -| 1 | **Visual realism** | Renders read as the reference photographs: façade identity, proportion, material and light — judged side by side, shot against photo | -| 2 | **Geographic fidelity** | Building placement and street scale match the measured data and the photographs | -| 3 | **Terrain walkability** | Zero raycast holes across both sidewalks and the carriageway (84-sample transect passes) | -| 4 | **Build reproducibility** | Fresh checkout + documented engine path produces a runnable Linux build | -| 5 | **Provenance** | Every committed asset has recorded provenance | -| 6 | **Performance efficiency** | 60 FPS at 2560×1440 on RTX 5070 Ti, documented preset, no frame generation | - -**How the order is used.** It decides trade-offs. When realism and performance disagree, realism wins and the cost is paid — but never by silently shipping a frame rate below the floor. The 60 FPS figure is a **budget that must still be met**, so the response to "realism costs too much frame time" is to cut the realism feature deliberately, record the cut, and keep the floor — not to let frames slip. Walkability outranks realism in one direction only: a beautiful block the player falls through is not a demo. - -## Stakeholders - -| Role | Contact | Expectations | -| --- | --- | --- | -| Owner / Director | zwitschi | Sets scope, approves screenshots, cuts release tags | -| AI coding agents | multiple sessions | Read `AGENTS.md`, `TODO.md`, `ROADMAP.md`, and this document for architecture constraints; never commit directly to `main` | -| Players | end users | Walk the demo at 60 FPS without falling through the world; see a recognisable Hollywood block | - -# Architecture Constraints - -## Technical Constraints - -| Constraint | Explanation | -| --- | --- | -| Engine: Unreal 5.8.4 | Pinned; no bundled Linux clang — uses system clang 23.1.4. Game targets use `BuildSettingsVersion.V7`. | -| Platform: Linux first | Developed and tested on Bazzite Linux (Fedora 45 base). Windows builds ship but are secondary. | -| GPU: RTX 5070 Ti 16 GB | Dev workstation target. Vulkan RHI; open kernel module driver 615.78.08. | -| No static lighting | `r.AllowStaticLighting=False`. The demo is a fixed daytime scene lit entirely dynamically to avoid "LIGHTING NEEDS TO BE REBUILT" warnings. | -| Blender→UE via FBX | Flat diffuse colours do not survive FBX→Unreal import. All materials are authored in UE. | -| Terrain collision | UE 5.8 exposes `CollisionTraceFlag` (not `bUseComplexAsSimpleCollision`); Python cannot set it. Must use C++ helper `UGigLineAssetTools::SetComplexCollisionAsSimple`. | -| Coordinate convention | South = +Y, north = −Y (ground truth = street mesh orientation). 1 m in Blender = 100 cm in Unreal. | - -## Organisational Constraints - -| Constraint | Explanation | -| --- | --- | -| Squash-merge to `main` | Every PR is squash-merged for a linear history. No direct commits to `main` except integration merges. | -| Visual review required | Any change affecting appearance must produce screenshots for human approval before being marked done. | -| Asset provenance | Every committed asset must have verified licence and redistribution permission recorded in `THIRD_PARTY_NOTICES.md`. | -| Reference photography | Local only, gitignored, never committed. Used for façade work under fair-use / internal-development rationale. | -| LFS for binaries | `.uasset`, `.umap`, `.blend` tracked with Git LFS; `Binaries/`, `Intermediate/`, `Saved/` are gitignored. | - -## Conventions - -| Convention | Explanation | -| --- | --- | -| Semantic versioning | `MAJOR.MINOR.PATCH`. `0.x` documents breaking changes. `1.0.0` = agreed game scope. | -| Task tracking in `TODO.md` | Single central task list. `- [ ]` open, `- [-]` or `- [~]` in progress, `- [x]` done, `- [A]` active, `- [D]` deferred. | -| Headless Blender | All Blender work driven by `bpy` scripts in `Tools/`, not MCP. Blender skills are method guides only. | -| Named export presets | `ue_metric_v1` for static meshes, `ue_character_v1` for skeletal. Every exporter calls the metre→centimetre conversion. | - -# Context and Scope - -## Business Context - -The system is a self-contained single-player game. It has no server backend, no multiplayer, and no live service dependencies. Its communication partners are: - -| Partner | Inbound | Outbound | -| --- | --- | --- | -| Player (keyboard/mouse) | WASD, mouse, R (reset), Escape (pause), Space (jump) | Rendered 3D scene + HUD | -| OSM data | Extracted building footprints, street geometry, elevation | — | -| Filesystem | Engine content (`.uasset`, `.umap`), Blender sources (`.blend`), DEM GeoTIFF | Saved maps, screenshots, packaged build | - -## Technical Context - -| Channel | Protocol / Format | Purpose | -| --- | --- | --- | -| Keyboard + mouse | Enhanced Input (IA\_\*, IMC_Default) | First-person movement, look, jump, reset, pause | -| Vulkan RHI | GPU rendering | Scene display at 60 FPS / 1440p | -| FBX | Autodesk FBX (binary) | Blender→Unreal mesh and skeleton transfer | -| GeoTIFF | 3DEP 1 m DEM | Terrain heightfield source | -| OSM XML | Overpass API extract | Building footprint and street layout source | -| Git / LFS | HTTPS to Gitea remote | Source control, binary asset storage | - -# Solution Strategy - -## Technology decisions - -- **Unreal Engine 5.8.4** for rendering, World Partition, physics (Chaos), and the Enhanced Input system. Chosen for its Linux support, open C++ source, and real-time rendering quality. - -- **Blender 5.2 LTS** for all 3D modelling, UV work, and collision mesh authoring. Driven headlessly via `bpy` Python scripts, not MCP. - -- **QGIS 4.2.3** for geographic data cleanup, coordinate conversion (EPSG:26911), and DEM preparation. - -- **Blosm 2.7.28** for OSM building and street blockouts inside Blender (layout data only — not authoritative façade, height, or pavement detail). - -- **MPFB 2.0.18** for human base meshes and baked materials (NPCs in `0.2.0+`). - -## Top-level decomposition - -The system is a single Unreal game module (`GigLine`) with no plugins beyond `EnhancedInput`. Content is organised by domain under `/Game/Hollywood/`: - -- `Site/` — terrain, road, sidewalks, kerbs, crossing markings, site materials - -- `ElCapitan/` — hero façade mesh and materials - -- `Frontage/` — south-side block building meshes - -- `North/` — north-side building meshes - -- `Input/` — Enhanced Input actions and mapping context - -All modelling and geographic processing live outside the game project in `Art/`, `Geo/`, and `Tools/`. The `Game/` directory contains only engine-ready content and C++ source. - -## Quality achievement - -- **Realism**: reference photography drives every façade decision, measured data fixes placement and scale, and renders are compared against the photographs (`Tools/visual_loop.py`, `Screenshots/README.md`). One module kit at the 4.96 m bay keeps the street wall coherent; materials are world-aligned and lit by a fixed daytime rig so a shot is comparable between runs. - -- **Performance holds the floor, it does not set the agenda**: dynamic-only lighting (no build step, no static shadow maps), cheap procedural materials, minimal overdraw for street-level geometry, and 60 FPS at 1440p as a budget every release must meet. - -- **Walkability**: complex-as-simple collision on terrain (C++ helper forces the flag on the asset), block-all collision on every site mesh, swept transect QA verifies zero raycast holes. - -- **Fidelity**: geographic data verified before modelling (footprints, bounds, DEM); building placement checked against street mesh, never in isolation; reference photography drives façade work. - -- **Reproducibility**: pinned toolchain in `Docs/toolchain.md`, named export presets, `Tools/check-lfs.sh` gate, fresh-checkout build procedure in `Docs/build.md`. - -# Building Block View - -## Whitebox Overall System - -The system decomposes into five top-level building blocks: - -| **Name** | **Responsibility** | -| --- | --- | -| Gameplay | Player pawn, movement, camera, input bindings, HUD, game mode | -| Site | Terrain, road, sidewalks, kerbs, crossing markings, site-level collision | -| Façades | Hero façade (El Capitan), south block frontage, north-side buildings | -| Art Pipeline | Blender models, geographic data, export scripts, import scripts | -| QA & Review | Screenshot pipeline, self-test harness, visual review workflow | - -### Gameplay - -C++ classes in `Game/GigLine/Source/GigLine/`: - -| Class | Responsibility | Key detail | -| --- | --- | --- | -| `AGigLineCharacter` | First-person player pawn | 1.75 m capsule, 4.5 m/s walk, Enhanced Input bindings, reset, pause, `-qaselftest` harness | -| `AGigLineGameModeBase` | Sets game mode and HUD class | `HUDClass = AGigLineHUD` | -| `AGigLineHUD` | HUD text overlay | `DrawText` every frame: ODbL attribution bottom-left, controls bottom-right, and an F3 debug readout top-left (`gigline.Debug`, on for `-perf`/`-qaselftest`) | -| `AGigLineNPCPerson` | NPC prefab (future) | Capsule + visible MPFB body, feet on Z 0 | -| `AGigLineShotActor` | Screenshot camera walker | Drives cameras in `-game` mode, `FScreenshotRequest`, quits | -| `UGigLineAssetTools` | C++ collision helpers | `SetComplexCollisionAsSimple` on `StaticMesh` assets | - -Input assets in `/Game/Input/`: `IA_Move`, `IA_MoveBack`, `IA_Strafe`, `IA_StrafeLeft`, `IA_Look`, `IA_Jump`, `IA_Reset`, `IA_Pause`, `IA_ToggleCamera`, `IA_ToggleDebug`, bound in `IMC_Default`. - -Player-facing bindings: **WASD** move, **mouse** look, **Space** jump, **R** reset, **Escape** pause, **V** swap first/third person. The camera and body work landed with `0.2.0` (see ADR-9): the shipped default view stays first person, and `-thirdperson` starts the pawn in the third-person rig — used by the screenshot run so the review is reproducible. - -### Site - -Static meshes and materials in `/Game/Hollywood/Site/`: - -| Asset | Role | -| --- | --- | -| `Terrain_DEM` | Ground heightfield (complex-as-simple collision) | -| `Road_Hollywood` | Carriageway surface (block-all collision) | -| `Sidewalk_North`, `Sidewalk_South` | Pedestrian surfaces (block-all collision) | -| `Kerb_North`, `Kerb_South` | Kerb edges | -| `Crossing_Markings` | Zebra markings at signalised crossing | -| `M_Asphalt`, `M_Concrete`, `M_KerbConcrete`, `M_Ground`, `M_RoadPaint`, `M_Stucco`, `M_FacadeStone` | Procedural materials (world-space-noise blend + roughness) | - -### Façades - -| Asset | Role | -| --- | --- | -| `SM_HeroFacade` | El Capitan Entertainment Centre detailed façade | -| `SM_ElCapitanTheatre`, `SM_Budget_6822`, `SM_Hollywood_6806`, `SM_Hollywood_6902`, `SM_Hollywood_6922`, `SM_LeeDrug_6800` | South block frontage (6 buildings) | -| `SM_Madame_Tussauds`, `SM_TCL_Chinese_Theatre` | North-side buildings (within 50 m) | -| `SM_Retail_LeeDrug`, `SM_Retail_6806` | Retail trim over 6800 and 6806–6820, modelled from their own photograph (`Tools/retail_frontage_build.py`, staged as `RetailTrim_*`). `6822 Budget` stays a bare volume: no photograph exists. | - -### Art Pipeline - -Python scripts in `Tools/` drive the content pipeline: - -| Script group | Purpose | -| --- | --- | -| `export_*`, `hero_facade_build` | Blender headless export (FBX) | -| `ue_import_*`, `ue_hero_facade_import` | Unreal headless import + placement | -| `character_anim_build`, `ue_character_anim_import` | Character animation clips (`GigLine_Idle`, `GigLine_Walk`) and their import | -| `export_*`, `hero_facade_build`, `retail_frontage_build`, `theatre_trim_build`, `facade_kit_build` | Blender headless export (FBX); trim builders work in world coordinates | -| `ue_import_*`, `ue_hero_facade_import`, `ue_import_retail_detail` | Unreal headless import + placement | -| `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) | -| `ue_create_input_assets` | Enhanced Input actions + mapping context | -| `render_screenshots.sh` | Screenshot capture pipeline | -| `judge_screenshot.py` | Vision-model pre-check (optional) | -| `input_emulation.py`, `input_test.py` | OS-level virtual keyboard/mouse injection for input verification | -| `Tools/_common.py` | Pure helper shared by Blender builders, importable without Blender (bay layout) | -| `Tools/tests/` | Stdlib `unittest` tests for the pure parts of the tools (`requirements-tools.txt`) | -| `Geo/tools/mapbox_buildings.py` | Second height source: Mapbox `mapbox-streets-v8` building data vs our OSM heights | - -Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verification docs). - -### QA & Review - -- `AGigLineShotActor` walks fixed cameras in `-game` mode, captures PNGs, and quits. - -- `-qaselftest` switch on `AGigLineCharacter` runs a core-ticker harness: walk 2 s, reset, pause/unpause, ground transect — all PASS/FAIL logged. - -- `Screenshots/` holds committed PNGs and `README.md` approval table. - -## Level 2 - -### White Box Gameplay — Input Flow - -1. `AGigLineCharacter::SetupPlayerInputComponent` binds `IA_*` actions to `MoveForward`, `MoveBack`, `MoveStrafe`, `MoveLeft`, `Look`, `StartJump`/`StopJump`, `ResetToStart`, `TogglePause`. - -2. Movement delegates to `ApplyMove(Forward, Strafe)` → `AddMovementInput`. - -3. Look applies yaw/pitch scaled by `LookSensitivity` (Y invertable via `bInvertLookY`). - -4. `ResetToStart` teleports to `StartLocation` (captured on first grounded frame), clears velocity, resets control rotation. - -5. `TogglePause` toggles `APlayerController::SetPause`, swaps input mode between `FInputModeGameOnly` and `FInputModeUIOnly`, shows/hides mouse cursor. - -### White Box Site — Collision Model - -- Every site mesh (road, sidewalks, kerbs, crossing) is `STATIC` with `BlockAll` collision. - -- `Terrain_DEM` uses `CTF_UseComplexAsSimple` (forced by `UGigLineAssetTools` at build time, saved on the asset). - -- The terrain corridor is carved to half-width 10.7 m around the road centreline so the player can walk the carriageway and both sidewalks. - -- Ground Z: north sidewalk 395, road 366, south sidewalk 317 (3.94 m fall along the block, matching the DEM). - -# Runtime View - -## Walk the block - -1. Player launches game → `AGigLineGameModeBase` spawns `AGigLineCharacter` at `PlayerStart` `(-13336, -1150, 600)`. - -2. Character falls 144 cm to road surface (Z 456) and begins walking. `StartLocation` captured on first grounded frame. - -3. Player holds W → `IA_Move` fires → `ApplyMove(1, 0)` → `AddMovementInput(ForwardVector, 1)` → capsule moves at 450 cm/s. - -4. Mouse move → `IA_Look` fires → yaw/pitch applied to controller rotation → camera follows (attached to capsule at 160 cm eye height). - -5. Player presses R → `ResetToStart` → teleport to `StartLocation`/`StartRotation`, `StopMovementImmediately`, reset control rotation. - -6. Player presses Escape → `TogglePause` → `SetPause(true)`, switch to `FInputModeUIOnly`, show cursor. World timers stop; core tickers continue. - -7. Player presses Escape again → `TogglePause` → `SetPause(false)`, switch to `FInputModeGameOnly`, hide cursor. - -## Screenshot capture - -1. `Tools/render_screenshots.sh` compiles editor, builds `ReviewMap` from `Tools/ue_build_review_map.py`, launches game in `-game` mode. - -2. `AGigLineShotActor` iterates the camera list, sets view, calls `HighResShot 1280x720`, waits for capture. - -3. On last camera, requests engine quit. PNGs are copied to `Screenshots/` and committed. - -## QA self-test (`-qaselftest`) - -1. Core ticker starts at `BeginPlay` (not a world timer — world timers freeze on pause). - -2. 1.0 s: begin driving forward input. - -3. 3.0 s: stop, measure displacement (> 200 cm = PASS). - -4. 3.5 s: call `ResetToStart`. - -5. 4.0 s: measure distance from spawn (< 100 cm = PASS). - -6. 4.5 s: call `TogglePause`, check `IsGamePaused()` (= true = PASS). - -7. 5.5 s: call `TogglePause`, check `IsGamePaused()` (= false = PASS). - -8. 6.0 s: swept ground transect across three lanes (Y −1400, −167, +1000), X −14900 to −9420 at 200 cm spacing. 84 samples, 0 holes = PASS. - -9. 7.0 s: final verdict logged. - -## Build and import pipeline - -1. Blender headless (`Tools/export_*.py`) reads `.blend` files, applies metre→centimetre conversion, exports FBX with `ue_metric_v1` preset. - -2. Unreal headless (`Tools/ue_import_*.py`) imports FBX via Interchange, places meshes at geographic coordinates, sets collision profiles. - -3. `Tools/ue_build_demo_map.py` assembles `DemoMap`: creates/repositions actors, sets lighting, saves map and assets. - -# Deployment View - -## Infrastructure Level 1 — Development Workstation - -| Node | Specification | -| --- | --- | -| Dev workstation | Bazzite Linux 45 (Fedora 45 base, immutable ostree), Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB, Vulkan RHI | -| Engine install | `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, external to repo) | -| Project repo | `/run/media/system/DATA/Documents/06-games/gig-line` (Git + LFS, remote at `git.allucanget.biz`) | -| Build output | `/run/media/system/DATA/Builds/GigLineQA/Linux` (packaged Development build, ~1.1 GiB; outside the repository) | - -Motivation -Single-machine development. No build farm, no CI pipeline yet. The engine, toolchain, and GPU are all on the same host. - -Quality and Performance Features - -- Vulkan RHI on RTX 5070 Ti, open kernel module driver 615.78.08. - -- System clang 23.1.4 (no bundled engine clang) compiles game code. - -- Build time: ~90 s incremental, ~5 min clean for the editor target. - -Mapping of Building Blocks to Infrastructure - -| Building Block | Infrastructure Element | -| --- | --- | -| Gameplay (C++) | Dev workstation — compiled to `libUnrealEditor-GigLine.so`, loaded by engine | -| Site + Façades (UE content) | Dev workstation — `.uasset`/`.umap` in `Game/GigLine/Content/` | -| Art Pipeline (Python/Blender) | Dev workstation — headless Blender 5.2 Flatpak, Python 3.14.8 | -| QA & Review | Dev workstation — game run in `-game` mode, PNGs to `Screenshots/` | -| Git remote | `git.allucanget.biz` — Gitea instance, LFS-enabled | - -## Infrastructure Level 2 — Packaged Build - -| Element | Detail | -| --- | --- | -| Linux packaged build | Development config, ~1.1 GiB. Contains engine binaries, game module, cooked content, no editor. | -| Run command | `./GigLine.sh -windowed -ResX=2560 -ResY=1440` | - -No server infrastructure. The game is a standalone executable. - -# Cross-cutting Concepts - -## Coordinate system and scale - -- **Source CRS**: EPSG:26911 (UTM zone 11N). Local origin recorded in `Geo/`. - -- **Blender**: metres. East → +X, north → +Y (Blender convention). - -- **Unreal**: centimetres. East → +X, north → **−Y** (confirmed from street mesh: `Sidewalk_North` Y −1912..−1053, `Sidewalk_South` Y +718..+1447). - -- **Conversion**: every exporter calls the metre→centimetre function. Import presets (`ue_metric_v1`, `ue_character_v1`) are named and repeatable. - -Rule: place a building only against a neighbour or street mesh, never in isolation. - -## Collision model - -- Site meshes (road, sidewalks, kerbs, crossing): `STATIC` mobility, `BlockAll` collision profile. - -- Terrain: `STATIC`, `CTF_UseComplexAsSimple` (forced by `UGigLineAssetTools` at build time, saved on the asset — the flag lives on the `UStaticMesh`/`UBodySetup`, not the map). - -- Buildings: `STATIC`, simple collision (convex hull from mesh). - -- Rule: the collision flag must be saved on the asset. Saving only the `.umap` silently reverts it. - -## Lighting - -- Dynamic-only: `r.AllowStaticLighting=False`. All lights are `MOVABLE`. - -- Fixed daytime rig: directional light (sun, −62° pitch), sky light, exponential height fog, post-process volume with fixed exposure. - -- Rationale: avoids "LIGHTING NEEDS TO BE REBUILT" warnings; no bake step; the scene never changes time of day. - -## Materials - -- All materials authored in Unreal, not carried from Blender (FBX import loses diffuse colours). - -- World-space-noise blend nodes for cheap procedural variation (asphalt grain, concrete speckle, stucco roughness). - -- Instance parameters where tunable (e.g. wetness, ageing). - -## Geographic data provenance - -- OSM extract is layout data only — not authoritative for façade, height, or pavement detail. - -- DEM from 3DEP 1 m; elevation verified against cross-sections in `Geo/verification/`. - -- Reference photography (local, gitignored) drives manual façade work. - -## QA harness - -- `-qaselftest`: core ticker (not world timer) so pause cannot freeze the test that proves pause works. - -- `GigLine SIM` / `GigLine GROUND`: TEMP diagnostic logging, to be removed before release tag. - -- Screenshot approval: human must tick `Screenshots/README.md` before visual work is marked done. - -# Architecture Decisions - -## ADR-1: Dynamic-only lighting for the demo - -- **Context**: The demo is a fixed daytime scene. Stationary lights demand a lighting build and produce "LIGHTING NEEDS TO BE REBUILT" warnings. - -- **Decision**: `r.AllowStaticLighting=False`, all lights `MOVABLE`. - -- **Consequences**: No bake step, no warning, simpler iteration. Slightly higher GPU cost per frame (no baked shadows) — acceptable for the demo scope. - -## ADR-2: Materials authored in Unreal, not imported from Blender - -- **Context**: FBX→Unreal import maps all Blender diffuse colours to white. Flat colour materials do not survive the pipeline. - -- **Decision**: Build all materials (asphalt, concrete, stucco, façade stone, road paint) directly in Unreal with procedural nodes. - -- **Consequences**: Materials cannot be previewed in Blender look-dev. Must round-trip to Unreal for visual check. But: full PBR control, world-space noise, no import surprise. - -## ADR-3: Complex-as-simple collision on terrain via C++ helper - -- **Context**: UE 5.8 removed `bUseComplexAsSimpleCollision`. Python cannot set `CollisionTraceFlag` on `StaticMesh`/`BodySetup` at all. The Interchange default is bbox-only simple collision, which placed an invisible lid 8 m above the street. - -- **Decision**: `UGigLineAssetTools::SetComplexCollisionAsSimple` (C++) forces `CTF_UseComplexAsSimple` and saves the asset. - -- **Consequences**: One C++ helper class. The flag must be saved on the asset (not just the map). No Python workaround exists. - -## ADR-4: Reset captures StartLocation on first grounded frame - -- **Context**: `BeginPlay` captures `StartLocation` at the PlayerStart, which is above grade (Z 600) so the capsule does not spawn embedded. Every reset dropped the player 1.4 m back into the air. - -- **Decision**: Repeating 0.25 s timer captures `StartLocation` once the character reports `IsMovingOnGround()`, then clears itself. - -- **Consequences**: Reset is correct. A one-shot timer at 2 s is wrong (fires after walking begins, records mid-stride position). - -## ADR-5: Core ticker for QA self-test, not world timer - -- **Context**: Pausing the game stops world timers. A timer-driven test freezes exactly when it proves pause works. - -- **Decision**: `FTSTicker::GetCoreTicker()` drives the `-qaselftest` harness. - -- **Consequences**: Test runs even while paused. Must be enabled explicitly (`-qaselftest`), never in normal play. - -## ADR-6: South = +Y, north = −Y in Unreal - -- **Context**: The street mesh established this convention: `Sidewalk_North` has Y range −1912..−1053, `Sidewalk_South` has +718..+1447. - -- **Decision**: Adopt it project-wide. Never place a building by assumption; always verify against a street mesh or neighbour. - -- **Consequences**: Matches Blender’s north→+Y after the Y-flip in the `ue_metric_v1` export preset. - -## ADR-7: Headless Blender via bpy scripts, not MCP - -- **Context**: Blender skill packs assume an MCP server; the project does not run one. - -- **Decision**: All Blender work driven by Python scripts in `Tools/`. Skill packs used as method guides (checklists, naming, proportions) only. - -- **Consequences**: Scripts are version-controlled and repeatable. No live Blender session needed for CI. - -## ADR-8: Realistic building façades are a minor milestone, not a patch - -- **Context**: `0.1.4` carried the first façade pass as a _patch_, while the release policy says patches contain fixes and refinements and never new scope. The `0.1.6` retail pass then showed what that costs: a façade built in a patch has no release gate of its own, so a first pass that does not yet read as the building (`Screenshots/README.md`, 0.1.6 gaps) can sit inside a "done" patch series. Realism is also the project's leading quality goal, which a patch cannot carry. - -- **Decision**: Building realism gets its own minor milestone, `0.9.0 — Realistic buildings`, placed before the feature freeze. Its scope is the façade system (orders, profile library, storefront types, signage as decals), per-building photo-matched models, a measured material library with a texel-density rule, and LODs plus collision proxies. Patches keep fixes and refinements only. Renumbered by this decision: the former `0.9.0` freeze/beta/audit milestone becomes `0.10.0`, its patches `0.10.1`–`0.10.3`, and `1.0.0` moves to weeks 39–44. - -- **Consequences**: Façade work now has a release gate — every building measured against its own photograph, every building with a collision proxy and a LOD, no placeholder materials — so "modelled" means verified, not merely present. The price is that realism work stops being sliced into patches, and the schedule shifts by one minor cycle. Blockouts without a photograph stay blockouts and are listed as such, rather than being dressed up to fill the milestone. - -## ADR-9: Third-person body is an attachment of the first-person pawn, not a second pawn - -- **Context**: `0.2.0` needs a visible body and a third-person camera, while `0.1.0` shipped a first-person pawn with no body and the owner's decision was first person for that release. Two separate pawn classes would duplicate movement, input and reset logic, which is the code most likely to drift. - -- **Decision**: one pawn, both rigs. `AGigLineCharacter` carries a skeletal mesh (hidden in first person), a collision-tested spring-arm camera, and a **V** toggle that swaps the active camera and the body's visibility. The shipped default view stays first person; `-thirdperson` starts the pawn in the third-person rig and is what the screenshot run passes, so the review shot is reproducible without a human pressing a key. - -- **Consequences**: movement, reset, pause and the QA self-test stay in one place. The body's animation state is driven from `Tick` by speed rather than from input, so being pushed or falling animates correctly. The cost is that the camera swap is not a separate actor, so a future cinematic camera needs its own path rather than reusing the toggle. - -# Quality Requirements - -## Quality Requirements Overview - -Ordered by priority: realism first, performance last. The order decides design trade-offs; it does not license dropping a lower item silently (see the trade-off rule below the table). - -| Priority | Category | Requirement | -| --- | --- | --- | -| 1 | Realism / Fidelity | Façade identity, proportion, material and lighting read like the reference photographs when compared side by side | -| 2 | Realism / Fidelity | Building placement matches the measured footprints and the photographs (within ~1 m) | -| 2 | Realism / Fidelity | Street scale (lane width, sidewalk width) within ~10 cm of the measured profile | -| 3 | Reliability / Terrain | Zero raycast holes across sidewalks and carriageway (84-sample transect) | -| 3 | Usability | WASD + mouse look, jump, reset (R), pause (Escape) — no tutorial needed | -| 4 | Maintainability | Fresh checkout builds with the documented engine installation | -| 4 | Portability | Linux build packages and runs; Windows build ships | -| 5 | Provenance | Every committed asset has a recorded source and licence | -| 6 | Performance efficiency | 60 FPS at 2560×1440, documented preset, no frame generation | - -**Trade-off rule.** Realism leads: when a realism improvement costs frame time, the improvement wins. Performance is still a **floor, not a preference** — 60 FPS at 1440p without frame generation must be met at every release. If realism cannot fit inside that budget, reduce the realism scope deliberately and record the reduction in `CHANGELOG.md`; never ship below the floor without the owner's explicit approval. Walkability outranks realism in one direction: a block the player falls through is not a demo, whatever it looks like. - -## Quality Scenarios - -| ID | Source/Stimulus | Response | Metric | -| --- | --- | --- | --- | -| QS-1 | Screenshot is compared against its reference photograph | The render reads as the same building | Similarity score and visual match confirmed by the owner; `Tools/visual_loop.py` reports the deltas | -| QS-2 | A façade is placed from measured data | It sits on its verified footprint | World span probed against `Geo/verification/` within ~1 m | -| QS-3 | Player walks both sides of the demo | No fall-through, character stays at ground Z | 0 raycast holes in 84-sample transect | -| QS-4 | Player holds W for 2 s on flat road | Character moves forward | ≥ 200 cm displacement, Z constant within ±5 cm | -| QS-5 | Player presses R | Character teleports to spawn | < 100 cm from `StartLocation` | -| QS-6 | Player presses Escape | Game pauses, cursor appears | `IsGamePaused()` = true | -| QS-7 | Player presses Escape while paused | Game unpauses, cursor hides | `IsGamePaused()` = false | -| QS-8 | Fresh checkout + engine install | Build succeeds | Exit code 0, runnable binary produced | -| QS-9 | Game runs on dev workstation at 1440p | Frame rate holds the floor | ≥ 60 FPS, 99th percentile ≥ 55 FPS | - -> QS-9 is last on purpose. Performance is a floor that every release must meet, not the goal that shapes the content: realism decides what gets built, the frame budget decides how much of it. - -### Building realism gate (0.9.0) - -| ID | Check | Metric | -| --- | --- | --- | -| BR-1 | A building with a reference photograph has a review shot compared against it | Silhouette and proportion deltas recorded per building in `Screenshots/README.md`; owner approval per shot | -| BR-2 | Every building is either photo-matched or explicitly recorded as an unfounded blockout | List in `TODO.md`; no building in an in-between state | -| BR-3 | Every building has a collision proxy and at least one LOD | Count of buildings missing either = 0 | -| BR-4 | No surface carries a placeholder material | Material audit in `Tools/ue_audit_scene.py` reports 0 placeholder slots | -| BR-5 | The realism pass does not break the floor | QS-9 still holds at 1440p, with the measured cost of the pass recorded | - -# Risks and Technical Debts - -| ID | Risk / Debt | Priority | Mitigation | -| --- | --- | --- | --- | -| R-1 | Terrain bbox proxy misdiagnosed as fall-through — already fixed, but the root-cause path (Interchange defaults to simple collision) could recur on reimport | High | `UGigLineAssetTools` runs in the build script; always reassert complex-as-simple after any reimport. Documented in `MEMORY.md` and ADR-3. | -| R-2 | Blosm 3Dsimple on Blender 5.2 — worked around, not resolved; `3Drealistic` mode untested | Medium | Pin Blosm 2.7.28 and Blender 5.2 LTS; test realistic mode before depending on it. | -| R-3 | Kerb height is an assumption (DEM does not resolve it) | Medium | Record the assumption in `Geo/verification/`; measure from reference photography if possible; mark as unresolved until verified. | -| R-5 | `GigLine SIM` / `GigLine GROUND` TEMP diagnostic logging still in `GigLineCharacter.cpp` | Medium | Strip behind a cvar or remove entirely before the `v0.1.0` release tag. | -| TD-1 | Materials are Unreal-only — no Blender preview round-trip | Low | Accepted (ADR-2). Look-dev happens in UE; screenshot review catches issues. | -| TD-2 | No CI pipeline — all builds and QA are manual on the dev workstation | Low | Acceptable for `0.x`. CI is a `0.3.0+` concern. | - -# Glossary - -| Term | Definition | -| --- | --- | -| Blosm | Blender add-on (2.7.28) that imports OSM data as 3D building and street blockouts. Used for layout only — not authoritative for façade or height. | -| PIE | Play In Editor — an Unreal Editor feature that runs the current map as a live game session inside the editor window, with full game code, input, and physics, without packaging a build. Used for interactive verification (walk, reset, pause) before the scripted QA self-test. | -| Complex-as-simple collision | `CTF_UseComplexAsSimple`: Unreal collision mode where the render mesh triangles are used as the collision surface, instead of a simplified proxy. Required for terrain so the player walks on the actual ground shape. | -| DEM | Digital Elevation Model. 3DEP 1 m resolution GeoTIFF covering the demo bounds. Source of terrain heightfield. | -| El Capitan | El Capitan Entertainment Centre, 6838 Hollywood Blvd. The hero building of the demo. | -| Enhanced Input | Unreal’s modern input system (UE 5+). Uses Input Actions (`IA_*`) and Input Mapping Contexts (`IMC_*`) instead of legacy axis/action bindings. | -| EPSG:26911 | UTM zone 11N, the projected CRS used for the demo area. Local origin recorded in `Geo/`. | -| Hero façade | The detailed, reference-calibrated model of the El Capitan building frontage. | -| MPFB | MakeHuman Plugin for Blender (2.0.18). Generates parametric human base meshes with rigs and baked materials. | -| PlayerStart | Unreal actor that defines where the player pawn spawns. Positioned above grade so the capsule does not embed in the floor on spawn. | -| QA self-test | The `-qaselftest` command-line switch that runs an automated behaviour check (walk, reset, pause, ground transect) via a core ticker. | -| Site | The terrain, road, sidewalks, kerbs, and crossing markings — the ground-level infrastructure of the demo area. | -| Squash merge | Git merge strategy that compresses all branch commits into one commit on `main`, keeping a linear history. | -| Swept transect | A QA technique that raycasts vertically at regular intervals along a path (e.g. every 200 cm along both sidewalks and the carriageway) and counts holes (misses). Zero holes = the block is fully walkable. | -| UE | Unreal Engine — Epic Games' real-time 3D creation platform (version 5.8.4 in this project). Comprises the Unreal Editor (including PIE, content browser, viewport, blueprint editor), a C++ game framework, and runtime systems for rendering (Vulkan RHI on Linux), physics (Chaos), and input (Enhanced Input). The game project is `GigLine.uproject`; game code is a single C++ module in `Game/GigLine/Source/GigLine/`. | -| World Partition | Unreal's world composition system for large open-world levels. Not yet used in `0.1.0` but planned for `0.3.0+` driving routes. | -| Y convention | In Unreal, south = +Y, north = −Y (confirmed from street mesh orientation). In Blender, north = +Y; the `ue_metric_v1` export preset flips Y. | - ---- - -**About arc42** - -
- -arc42, the template for documentation of software and system architecture. - -
- -Template Version 0.1.0. initial architecture documentation, 2026-11-08 - -Created, maintained and © by Dr. Peter Hruschka, Dr. Gernot Starke and contributors. See . - -Adapted for the Gig Line project (Linux-first walking/driving demo around 6840 Hollywood Blvd). diff --git a/Docs/build.md b/Docs/build.md index e6dd2da..ded0b43 100644 --- a/Docs/build.md +++ b/Docs/build.md @@ -56,9 +56,9 @@ Both content-acceptance tests run headless and are the template for all asset im ```bash # Static-mesh scale test (Blender export -> Unreal import -> measure) -# Blender: Tools/scale_test_export.py, Unreal: Tools/ue_scale_test_import.py +# Blender: Tools/blender/scale_test_export.py, Unreal: Tools/unreal/ue_scale_test_import.py # MPFB character (generate -> rig -> bake -> export -> skeletal import) -# Blender: Tools/mpfb_character_export.py, Unreal: Tools/ue_character_import.py +# Blender: Tools/blender/mpfb_character_export.py, Unreal: Tools/unreal/ue_character_import.py # Unreal side of any test: ./Engine/Binaries/Linux/UnrealEditor-Cmd \ @@ -78,22 +78,22 @@ The hero block and the demo level are built by a fixed sequence. Each step write | Street | `Art/blender/street/street_mesh.py` | `Art/blender/street/street-*.blend` | | Site assembly | `Art/blender/site/site_assembly.py` | `Art/blender/site/site-*.blend` | | Site export | `Art/blender/site/export_site_fbx.py` | seven FBXs, one per mesh (terrain, road, both sidewalks, both kerbs, crossing) | -| Hero façade | `Tools/hero_facade_build.py` | `Art/blender/hero/hero-facade-*.blend` + JSON part report | -| Block frontage | `Tools/export_block_frontage.py` | six frontage FBXs from the blockout | -| North buildings | `Tools/export_north_buildings.py` | TCL and Tussaud's FBXs from the blockout | -| Site import | `Tools/ue_import_site.py` | `/Game/Hollywood/Site/*` | -| Frontage import | `Tools/ue_import_block_frontage.py` | `/Game/Hollywood/Frontage/*` | -| North import | `Tools/ue_import_north_buildings.py` | `/Game/Hollywood/North/*` | -| Hero import | `Tools/ue_hero_facade_import.py` | `/Game/Hollywood/ElCapitan/SM_HeroFacade` | -| Demo map | `Tools/ue_build_demo_map.py` | `/Game/Hollywood/DemoMap` | -| Façade kit assembly | `Tools/ue_apply_facade_kit.py` | kit actors in `DemoMap` | -| Theatre trim | `Tools/ue_stage_theatre_trim.py` | marquee + blade sign actors | -| Scene audit | `Tools/ue_audit_scene.py` | materials, placement spans, labels (gate) | -| Review shots | `Tools/ue_build_review_map.py` + `Tools/render_screenshots.sh` | `Screenshots/` | +| Hero façade | `Tools/blender/hero_facade_build.py` | `Art/blender/hero/hero-facade-*.blend` + JSON part report | +| Block frontage | `Tools/blender/export_block_frontage.py` | six frontage FBXs from the blockout | +| North buildings | `Tools/blender/export_north_buildings.py` | TCL and Tussaud's FBXs from the blockout | +| Site import | `Tools/unreal/ue_import_site.py` | `/Game/Hollywood/Site/*` | +| Frontage import | `Tools/unreal/ue_import_block_frontage.py` | `/Game/Hollywood/Frontage/*` | +| North import | `Tools/unreal/ue_import_north_buildings.py` | `/Game/Hollywood/North/*` | +| Hero import | `Tools/unreal/ue_hero_facade_import.py` | `/Game/Hollywood/ElCapitan/SM_HeroFacade` | +| Demo map | `Tools/unreal/ue_build_demo_map.py` | `/Game/Hollywood/DemoMap` | +| Façade kit assembly | `Tools/unreal/ue_apply_facade_kit.py` | kit actors in `DemoMap` | +| Theatre trim | `Tools/unreal/ue_stage_theatre_trim.py` | marquee + blade sign actors | +| Scene audit | `Tools/unreal/ue_audit_scene.py` | materials, placement spans, labels (gate) | +| Review shots | `Tools/unreal/ue_build_review_map.py` + `Tools/review/render_screenshots.sh` | `Screenshots/` | -**Order matters.** `ue_build_demo_map.py` rebuilds the site materials, so any actor staged by another tool must be (re)staged _after_ it, or it keeps a reference to a material that no longer exists — that is how 604 actors once ended up untethered from their materials while the map only looked slightly flat. `Tools/render_screenshots.sh` runs the full chain in the right order and gates on `ue_audit_scene.py`. +**Order matters.** `ue_build_demo_map.py` rebuilds the site materials, so any actor staged by another tool must be (re)staged _after_ it, or it keeps a reference to a material that no longer exists — that is how 604 actors once ended up untethered from their materials while the map only looked slightly flat. `Tools/review/render_screenshots.sh` runs the full chain in the right order and gates on `ue_audit_scene.py`. -Every Blender exporter calls [`Tools/fbx_export_preset.py`](../Tools/fbx_export_preset.py) (`ue_metric_v1` / `ue_character_v1`) instead of repeating the FBX argument block — one place for the scale conversion, the axis mapping and `mesh_smooth_type="FACE"` (without which every Unreal import warns about missing smoothing groups and round geometry arrives faceted). +Every Blender exporter calls [`Tools/blender/fbx_export_preset.py`](../Tools/blender/fbx_export_preset.py) (`ue_metric_v1` / `ue_character_v1`) instead of repeating the FBX argument block — one place for the scale conversion, the axis mapping and `mesh_smooth_type="FACE"` (without which every Unreal import warns about missing smoothing groups and round geometry arrives faceted). Two rules that the pipeline enforces rather than documents: @@ -103,7 +103,7 @@ Two rules that the pipeline enforces rather than documents: ## Known engine quirks (2026-11-07) - One handled `PSOPrecache` ensure fires at packaged-build exit only; not blocking. -- Interchange 5.8 does not create material/texture assets for skeletal FBX imports — `Tools/ue_character_import.py` finishes that chain explicitly. +- Interchange 5.8 does not create material/texture assets for skeletal FBX imports — `Tools/unreal/ue_character_import.py` finishes that chain explicitly. - Android Turnkey status errors and datarouter DNS warnings in logs are benign. ## Packaged Linux build @@ -129,13 +129,13 @@ cd /run/media/system/DATA/Builds/GigLineQA/Linux | `Add-on not loaded: "bl_ext.blender_org.mpfb"` | `mpfb_character_export.py` needs the MPFB add-on visible to Blender | export `BLENDER_USER_SCRIPTS` (see the invocation in [../Art/blender/README.md](../Art/blender/README.md)) | | The packaged run exits after a couple of seconds during QA | `GigLineShotActor` runs its screenshot list and quits (`bQuitWhenDone`) | it skips its rounds when `-qaselftest` is on the command line — keep that guard | | Headless `-nullrhi` traces report no hits for everything | the null RHI has no physics scene | verify traces in the **packaged** run (`-qaselftest`), never in a commandlet | -| Import logs "No smoothing group information" for every mesh | `mesh_smooth_type="OFF"` in the FBX export | use `Tools/fbx_export_preset.py` (`FACE`) | +| Import logs "No smoothing group information" for every mesh | `mesh_smooth_type="OFF"` in the FBX export | use `Tools/blender/fbx_export_preset.py` (`FACE`) | ## PIE input test (compare editor vs packaged input intake) Use to isolate whether the "no controls respond" bug is the packaged standalone's input intake or the Enhanced Input wiring generally. -- `Tools/run_pie.sh` opens the editor on `/Game/Hollywood/DemoMap`. Press the green **Play** (PIE) button and walk / mouse-look. +- `Tools/input/run_pie.sh` opens the editor on `/Game/Hollywood/DemoMap`. Press the green **Play** (PIE) button and walk / mouse-look. - Interpretation: - PIE **works** -> the Enhanced Input wiring is fine and the standalone packaged build's SDL input intake is the environment-dependent bug. - PIE **fails** too -> the Enhanced Input setup itself is broken; rebuild the input config / character from scratch. diff --git a/Docs/controls.md b/Docs/controls.md index d17b4e8..fe71785 100644 --- a/Docs/controls.md +++ b/Docs/controls.md @@ -19,7 +19,7 @@ Keyboard and mouse. The same list is shown in-game, bottom-right of the frame (` - Movement is relative to where you are looking (controller yaw), so the capsule stays upright on the sloped street. - The mouse is captured on launch, so looking works without clicking first. `Escape` toggles the capture as part of pause. -- Bindings are Enhanced Input actions in `/Game/Input` built by `Tools/ue_create_input_assets.py`; the mapping context is `IMC_Default`. +- Bindings are Enhanced Input actions in `/Game/Input` built by `Tools/unreal/ue_create_input_assets.py`; the mapping context is `IMC_Default`. ## Changes since `0.1.0` @@ -48,10 +48,10 @@ The walking shot is driven per shot rather than by the switch: the shot actor en ## Testing input without a physical keyboard -`Tools/input_test.py` injects OS-level keyboard/mouse events through a virtual `/dev/uinput` device (see `Tools/input_emulation.py`), e.g.: +`Tools/input/input_test.py` injects OS-level keyboard/mouse events through a virtual `/dev/uinput` device (see `Tools/input/input_emulation.py`), e.g.: ```bash -python3 Tools/input_test.py --sequence move:2,look:3 +python3 Tools/input/input_test.py --sequence move:2,look:3 ``` It drives the focused game window exactly like real hardware, which is how the controls were regression-tested on the packaged build. diff --git a/Docs/release-checks.md b/Docs/release-checks.md index 2efbe15..cb433b2 100644 --- a/Docs/release-checks.md +++ b/Docs/release-checks.md @@ -50,7 +50,7 @@ Measured at 1440p on the development workstation: Bazzite, Ryzen 7 7700X, 64 GB ## `0.1.0` — Placement vs references — record (2026-11-08) -Checked against the reference photography (`Art/reference/`) and the Geo measurements (`Geo/verification/*`). Build under test: current `main` content, DemoMap rendered via `Tools/render_screenshots.sh` (2026-11-08, Vulkan SM5, 1920×1080); engine positions probed from the DemoMap (commandlet, 2026-11-08). +Checked against the reference photography (`Art/reference/`) and the Geo measurements (`Geo/verification/*`). Build under test: current `main` content, DemoMap rendered via `Tools/review/render_screenshots.sh` (2026-11-08, Vulkan SM5, 1920×1080); engine positions probed from the DemoMap (commandlet, 2026-11-08). ### Placement and scale (in-engine ↔ reference) @@ -82,7 +82,7 @@ Checked against the reference photography (`Art/reference/`) and the Geo measure - **Crossing ramps are kerb flushes, not graded ramps** — the `0.1.1` Crossing ramp gradient item. - Site identity (the neoclassical hero with motto band) matches the 2009 façade photograph and the 1922 Masonic elevation within blockout fidelity. -Pre-check method: `Tools/judge_screenshot.py` (vision model) flagged the simplified flat surfaces as "unexpected" — those flags are the deliberate deviations above, not placement errors. Final verdict is by human review of the shots in `Screenshots/demo/`. +Pre-check method: `Tools/review/judge_screenshot.py` (vision model) flagged the simplified flat surfaces as "unexpected" — those flags are the deliberate deviations above, not placement errors. Final verdict is by human review of the shots in `Screenshots/demo/`. ## `0.1.0` — Collision and packaging — audit note (2026-11-08) @@ -90,7 +90,7 @@ Run on the packaged build (`GigLineQA`, Development, Vulkan SM5) with `-qaselfte - **Transect (holes/gaps):** 1456 samples over 16 rows — lane centres **plus the kerb/road/sidewalk seam Ys** (−1382, −1053, +718, +1047 and neighbours) and X −20000..−2000. Result **0 holes**. (The old 84-sample check covered only 3 lane centres; the seam rows are what this check adds.) - **Invisible-wall probe:** horizontal traces 30 m long, grounded at 160 cm above the local ground, along the road and both seam rows. Result **0/35 blocking hits inside 21 m** — no invisible walls or misplaced blockers in the street envelope. -- **Facade occlusion probe:** from the road centre, 40 m traces sideways at walking height toward each frontage, required only where the map stages a building (the spans are listed in `AGigLineCharacter` and mirror `Tools/ue_build_demo_map.py`). Result **0/9 directions without a blocking wall** — the player cannot walk through the hero or the frontage. This probe found the hero façade had **no simple collision** at all (complex-only, so traces and capsule sweeps passed through it); it is now `UseComplexAsSimple`. Auto-generated convex hulls were tried first and rejected: on a 95-part, 26 m façade they bulged into the street (the wall probe went 0/35 → 2/35). (First pass used a fixed Z 160 start; the street is at Z ~366, so the probe started inside the earth and reported every row as a dist-0 blocker. Grounding the probe fixed that measurement bug.) +- **Facade occlusion probe:** from the road centre, 40 m traces sideways at walking height toward each frontage, required only where the map stages a building (the spans are listed in `AGigLineCharacter` and mirror `Tools/unreal/ue_build_demo_map.py`). Result **0/9 directions without a blocking wall** — the player cannot walk through the hero or the frontage. This probe found the hero façade had **no simple collision** at all (complex-only, so traces and capsule sweeps passed through it); it is now `UseComplexAsSimple`. Auto-generated convex hulls were tried first and rejected: on a 95-part, 26 m façade they bulged into the street (the wall probe went 0/35 → 2/35). (First pass used a fixed Z 160 start; the street is at Z ~366, so the probe started inside the earth and reported every row as a dist-0 blocker. Grounding the probe fixed that measurement bug.) - **Walk / reset / pause:** 761 cm in 2 s (Z steady 456), reset 0 cm, pause `IsGamePaused()=1`, unpause 0 — all PASS. - Packaging: the packaged build launches, reaches gameplay, and the QA run quits cleanly. The `GigLineShotActor` screenshot rounds are suppressed under `-qaselftest` so they cannot quit the run before the audit phases. - Caveat recorded: ground Z extremes (−12 m / 3000 cm) are trace-start/terrain-edge artifacts of the widened extent, not walkable holes — the walkable corridor is continuous (walk + transect both PASS). diff --git a/Docs/setup.md b/Docs/setup.md index 5b769a6..dcbaca4 100644 --- a/Docs/setup.md +++ b/Docs/setup.md @@ -91,7 +91,7 @@ Two tools talk to paid services and read their keys from the gitignored `.env`: | Variable | Used by | Needed for | | --- | --- | --- | -| `OPENROUTER_API_KEY` | `Tools/judge_screenshot.py`, `Tools/facade_detail_concepts.py` | vision pre-check and AI concept plates | +| `OPENROUTER_API_KEY` | `Tools/review/judge_screenshot.py`, `Tools/gen/facade_detail_concepts.py` | vision pre-check and AI concept plates | | `MAPBOX_ACCESS_TOKEN` | `Geo/tools/mapbox_buildings.py` | the second building-height source | Nothing in the build, the tests or the release path needs either key. Never commit `.env`; `git check-ignore .env` should succeed. diff --git a/Game/GigLine/Content/Hollywood/DemoMap.umap b/Game/GigLine/Content/Hollywood/DemoMap.umap index 8f67e82..489eb59 100644 --- a/Game/GigLine/Content/Hollywood/DemoMap.umap +++ b/Game/GigLine/Content/Hollywood/DemoMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26c6f8b6a43d58b9966486e3c2182a4967f9f02bf0f9b38ae89448576b33197f -size 782209 +oid sha256:e1fd1f170a3b673678abffb5569c0d1d32c43628f84a2fedbd8fd2b403641eed +size 614603 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner.uasset index ccfd89f..066389b 100644 --- a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner.uasset +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:643f9d1ee9ccaafab5e38fe201b5e96b6a0fe3e12142072e520c931b7f461740 +oid sha256:2079a75ba65f4df7136f7866628dae5462b5af0d50a14ee980d7417afceb1e56 size 56660 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner_Ref.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner_Ref.uasset new file mode 100644 index 0000000..092bcbe --- /dev/null +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Banner_Ref.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bd8e259770b6778dffd14d6ceeb15d2e36a3f25db6b9d6cd1838b2cf368ec3a +size 6320 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark.uasset index b243860..11f5402 100644 --- a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark.uasset +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cbd6dd990a3d39af901d6c9b0d695ca5a1064f10666361e32e1f1f8e54caaa67 +oid sha256:1b9df509963b5902d67a27603bae27feb2e8d14323eced5e21224a6a3f009a70 size 56708 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark_Ref.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark_Ref.uasset new file mode 100644 index 0000000..06700f1 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_GlassDark_Ref.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e63fdfb8453007d41a3af44c4b7b0d599c7f7b41fa094220b9c7a108d6f38ca +size 4806 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Grille.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Grille.uasset new file mode 100644 index 0000000..7538449 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Grille.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e67499ffe7fbb36f10802c6b56d30386032dc7b71620bad06a271c23600c1ba8 +size 56660 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Grille_Ref.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Grille_Ref.uasset new file mode 100644 index 0000000..7321343 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Grille_Ref.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25b5910c3ef6842dcda2cba869a71ca003a779f558961e4684d78a143a8a0ccd +size 4791 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone.uasset index 1add959..4b0a2cf 100644 --- a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone.uasset +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b0ce1c87b5c6196f5485f4b7072bbf18cdb9e8a3e10793f88c4010fe74133da +oid sha256:f5d18de789d30cc8a8829e168f48a9d9198a25eb4e49ae88737bab8eb7e82512 size 56644 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow.uasset index 2875a66..63f09cf 100644 --- a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow.uasset +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d62f8f568b0bc9d15506d4cb0367ac28f337cadb5375d67e1112aeda08effcf1 +oid sha256:c2468517fcb354d1c7a336bb677d36def00a2a218ea39e19f303ec202f9881a5 size 56740 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow_Ref.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow_Ref.uasset new file mode 100644 index 0000000..d1ed111 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_StoneShadow_Ref.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5ff499ac3b9fd80eece6a0b2233de72ad0cf7765948ee4f36e2101fdb7fa308 +size 6981 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone_Ref.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone_Ref.uasset new file mode 100644 index 0000000..6284fc3 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/ElCapitan/M_Facade_Stone_Ref.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1eefca5dbdcaf80accf334827219c9136ca58ccfe3f2e1b3bd614de226c2b739 +size 6945 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset index a318e07..8c85096 100644 --- a/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset +++ b/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab0c6c8ddbc2a9086be3c474166b7fe152f2e0a0aa88cf55500feec21a4e10cf -size 186727 +oid sha256:18268de08e4d58404b50b79325e1c324470de557951762f9b7a471f5e94a44a7 +size 208127 diff --git a/Game/GigLine/Content/Hollywood/Site/Crossing_Markings.uasset b/Game/GigLine/Content/Hollywood/Site/Crossing_Markings.uasset index e7c6d84..8b92749 100644 --- a/Game/GigLine/Content/Hollywood/Site/Crossing_Markings.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Crossing_Markings.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b87aee07bd35898e3acd37cc9f6c595cad62e68aaa68ff93db424687b983738b -size 75559 +oid sha256:4f434f6525bb6421bcc72dbbc430816153423fe329c30c9aeb509754bf8d08ff +size 73293 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North.uasset index 57b7e5e..02b84b4 100644 --- a/Game/GigLine/Content/Hollywood/Site/Kerb_North.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7fe2e4298ba7ac5d214cee36a604926ceadcdb929b643bdc0e571ac9de6354d4 -size 83660 +oid sha256:701e0bea9326efaa2defe9c7551adc8238fc3a502a7b6328064f8e477d202b58 +size 81414 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_00.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_00.uasset new file mode 100644 index 0000000..cefec4d --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_00.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06ae6009a2869b4cb04df1812691ef741a19951e2de00283b887b78e32acd7b1 +size 73194 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_01.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_01.uasset new file mode 100644 index 0000000..a892354 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_01.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:042a1dd3400342667859cef47d44093d987c9fac3abce54529d5a231c0f34935 +size 73185 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_02.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_02.uasset new file mode 100644 index 0000000..cffa95c --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_02.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d6769be801ead3d297b1964c2e28278a0aefd54eedf34daa691b24f95d95b44 +size 73155 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_03.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_03.uasset new file mode 100644 index 0000000..9cea1a4 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_03.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f9c5a5ff68eead14ff8f02b1fc242433469e71c5ed6561ea45d0d528353e3b2 +size 73136 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_04.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_04.uasset new file mode 100644 index 0000000..de40ec0 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_04.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff6162e105336bda6250962acbbe24f8dbda4bebeec636eeba5c85d47aa08ddb +size 73124 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_05.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_05.uasset new file mode 100644 index 0000000..4e822c3 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_05.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0dc36447c43b1671597e89b567546d8c8c3303b4907b0d5e732ce0256bcf5e8e +size 73113 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_06.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_06.uasset new file mode 100644 index 0000000..2c93f40 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_06.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e2cc975aef2bc16152827cad70cece5e63d5a1724ee487766e2ff3d6d6dba5c +size 73146 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_07.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_07.uasset new file mode 100644 index 0000000..9c57c6f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_07.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c33d030583e4ce25eacb124e8dad98da4ad7b926fb06df5840a2fc27f376865d +size 73088 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_08.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_08.uasset new file mode 100644 index 0000000..40b3399 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_08.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:271466e871abc84ef95c8cc3428dfe73005a73c66797fb185a562770f3b76235 +size 73111 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_09.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_09.uasset new file mode 100644 index 0000000..23a1fbd --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_09.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683b5288653736de5ac477d8c0e3ef480f304d2c2c4bd92dd4f93dff4bf46360 +size 73100 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_10.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_10.uasset new file mode 100644 index 0000000..f08200f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_10.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d232dba7d43e8687c991f42b6401f44638c0e5d6f65ca7e127555d31286ac40 +size 73117 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_11.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_11.uasset new file mode 100644 index 0000000..cf49284 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_11.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b02e3c0e3477a90efea12f62b1af905bad4f506ef2482df1bcd68d6d9ed914c +size 73138 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_12.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_12.uasset new file mode 100644 index 0000000..ceae66c --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_12.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad4d551b77cf5aa89d0d2b4b0d99a9ccf1a4744cd7aea2200830bee713da7cc9 +size 73153 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North_13.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North_13.uasset new file mode 100644 index 0000000..b432c62 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_North_13.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8643339a6cf3bd33db84959695308de1eb42b32b6e25ba0a3163cb7779a240c5 +size 73176 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South.uasset index 7a6b66b..008e948 100644 --- a/Game/GigLine/Content/Hollywood/Site/Kerb_South.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cee2405bd726e30658551592811b4f7ade9d09eb53a3f5d7f7ac437b07f477c5 -size 84070 +oid sha256:72eb4b66c1e74536d4831cb58fa85d9b737f95b7b2e0519bb4a50b4d83788adc +size 81835 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_00.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_00.uasset new file mode 100644 index 0000000..46a9349 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_00.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57f361230b4c5cffb837b04dcce7431ecacf88aff4434c3c668f249d0323adc6 +size 73140 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_01.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_01.uasset new file mode 100644 index 0000000..5da4ace --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_01.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59d49a8be321ebca65ddaf8bd4b3dd0ff1b6ae9ecb5a4d8f74ef445acf8397e6 +size 73209 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_02.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_02.uasset new file mode 100644 index 0000000..2f83267 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_02.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67c6eabcd796bc5c8c8294208dc0481cb8c0057716bc0ac05d8085e3196ac81a +size 73164 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_03.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_03.uasset new file mode 100644 index 0000000..7ca1902 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_03.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faa7d7b4ad0cb2e9c114e7e7ff41ad56be465c52f8e1705ff3ad10eac5832f35 +size 73143 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_04.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_04.uasset new file mode 100644 index 0000000..e24955d --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_04.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9725e2d3281f62c2c2337ffaa6e83a0d7e78b63b49fe2a2563f341baa6490b5 +size 73118 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_05.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_05.uasset new file mode 100644 index 0000000..968ef7f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_05.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a89f3be6d8969bf9fb7ad0015ae4e702a5289bd3e3350551698d8c0e3606e2d +size 73122 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_06.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_06.uasset new file mode 100644 index 0000000..a5290ae --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_06.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14d0f3c487e899e538a24ed9ede6858f5334d6a3002668032a2f007464afa012 +size 73169 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_07.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_07.uasset new file mode 100644 index 0000000..c25013a --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_07.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3995b70f1544529ea238fd2b72f0aea4576854e83e86bb52faedd9818d8ce84 +size 73110 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_08.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_08.uasset new file mode 100644 index 0000000..c095efe --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_08.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52dd28c8672fc6373e1af2082c94ca3ebe3ee62d3c85e740a1a8d7da880a81d1 +size 73119 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_09.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_09.uasset new file mode 100644 index 0000000..f4e8708 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_09.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef11657d8a0ee9ee3bcbcbf5efc9c507e48ec2301a96febafd5bf2cda7dcb3c1 +size 73156 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_10.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_10.uasset new file mode 100644 index 0000000..85f08b6 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_10.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83a9cd5d657f249fcdb70afb7d899491b9dfc5e696303d7e5f0a4ce77ac80bf7 +size 73193 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_11.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_11.uasset new file mode 100644 index 0000000..fc5dea7 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_11.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87a772c273c90f717749c35e7141b2b1ab0f93d4bad18ac49aeb2f7349ab62c2 +size 73176 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_12.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_12.uasset new file mode 100644 index 0000000..0acce07 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_12.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48cd5510aba30eaf60535024813b3f2ca173d8a491b1e7990de25a4208f1a3a3 +size 73178 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South_13.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South_13.uasset new file mode 100644 index 0000000..399bcca --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Kerb_South_13.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce0d15d30df0060cdf06a2d6d492127afce4e0ace560f884a3e53e13bba55321 +size 73157 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset b/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset index 864ec50..48048dd 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d430ad855bf7da7566bd2647a4c6fdf5132748810ba69a9bef3456ee35eaa5c8 +oid sha256:50771e04c083b128108bbc0368aff6814f7ad5e72c728615057aa8bba6194ab2 size 10019 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset b/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset index 50b81fe..06ad040 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bc31f75475e402ce8ee13a1c5e0242fc06c56b47e3ddca4b7e5b9ca39b166fa +oid sha256:9747f280daab242abf5bc0333cb64d7b77f8c87a421bc95c38d639ed0e8501cb size 10024 diff --git a/Game/GigLine/Content/Hollywood/Site/M_FacadeBanner.uasset b/Game/GigLine/Content/Hollywood/Site/M_FacadeBanner.uasset index f64378b..986d78e 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_FacadeBanner.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_FacadeBanner.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:731f51c303ca54b126181a4792a1c288196b6f253028357fd2e38c338475981a +oid sha256:291987bd9bcdfd639c0ad71ce3ae4b920659e77217ef23f16af1a1ff81a1d17f size 11419 diff --git a/Game/GigLine/Content/Hollywood/Site/M_FacadeGlass.uasset b/Game/GigLine/Content/Hollywood/Site/M_FacadeGlass.uasset index d472812..4fb7af6 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_FacadeGlass.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_FacadeGlass.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44b050c39a7ff5a82eae9ef09c6982d8ed14fe77edd34f99283e4d4b4ec59da1 +oid sha256:726539c000c9fd92de59e79e0fdd652197970788e20719e705d46d9f2f82ef7c size 11414 diff --git a/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset b/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset index 6673957..c09f0a7 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e3ce5d89b3638ad47c517e7a582ceb83de8b00922e4d92882a4b988914d0bd6 +oid sha256:87172cc0f1536ee04e13079ff43c9a318830b1fb040d8f2a7426fcb97f4d08cd size 17083 diff --git a/Game/GigLine/Content/Hollywood/Site/M_FacadeStoneShadow.uasset b/Game/GigLine/Content/Hollywood/Site/M_FacadeStoneShadow.uasset index d7a336a..230f49c 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_FacadeStoneShadow.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_FacadeStoneShadow.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06769e46f4a6af9d52aa755f6540519b4b335d86a68913e4cbd1e099556bf76b +oid sha256:c536d0420bb19192e1e1dd67782f76ec5d741e8bbef2b2f868b1d9e397912f06 size 17113 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset b/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset index 2e1ce78..cece19a 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3142c2970a411f821252e13046b6a7378899cb91cb9cb98ca0abb4f8366d4d1 +oid sha256:3e9b26bf81779d47f7a814b852ee32fcbb2caeb3ffbb7fc50b1f66c4ad90daf5 size 10014 diff --git a/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset b/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset index 2279851..69b3bfb 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e7a8a8a3fa345ddc93b2f9514b58bf570989fe33cc894ab809a87177c98d0ce +oid sha256:a44826a3da117b626484aead8b1dfb64100c3b25f1d880b29447453b73e8f29d size 10044 diff --git a/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset b/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset index a4541ee..f027676 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bed888d2157f1a7101c08171f7965e549d5ef48acc7b9f9dc103f9caf5d15cd +oid sha256:8c84264f8bc515f3dc00fd118b66df8a73d923669b03d9e633a6cfe378f0bce8 size 10029 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset b/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset index 3bbda2b..53c40a7 100644 --- a/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset +++ b/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5cf23dd9049e3e84b7c573a6f9c7aad538d294213a496c5ca2fcc5328525a8ae +oid sha256:1e73fb45548458befc63d82345f1cde00365b18b49b92514bfdee7277a7d813a size 11389 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood.uasset index fdebf3d..bf2cd46 100644 --- a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:496a3d53741a35ac7d45ba894c8c9661c39b4e922f968177795f818400dca192 -size 311045 +oid sha256:ce39a999cb630d2c2367257a517c63de0a17d00aecfb9a314e76a78ecc7a5243 +size 308474 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_00.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_00.uasset new file mode 100644 index 0000000..62c3963 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_00.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2fe484bb7fc90d3892392af0d8692b1a2b978740da16cedac4dde6a2858f2ab +size 89967 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_01.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_01.uasset new file mode 100644 index 0000000..e4c69a0 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_01.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bd3d02f2fd3205b4e31703740d7d8a71e318e4609af7c5cf6a9acc772fdbb03 +size 90619 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_02.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_02.uasset new file mode 100644 index 0000000..000275f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_02.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63c5f36e7b1559ea3e89a9edb413b800cbbde9603f4aa03d08b6e7f53ca951a8 +size 91877 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_03.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_03.uasset new file mode 100644 index 0000000..4a69e7a --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_03.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2e67bf8cc9358847590184551a1c69832961015816916683516c31244699709 +size 91965 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_04.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_04.uasset new file mode 100644 index 0000000..fb7322a --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_04.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f21fe77375e254a2fe585d3a29c87efea7fdc692a6f58075c6143f33b556d70 +size 92065 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_05.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_05.uasset new file mode 100644 index 0000000..70faf3b --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_05.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ec400a6b20e623b2e5d5c68e341a3e47ca5e7ace3ef8ff033ad87bc2c6ff91c +size 92079 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_06.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_06.uasset new file mode 100644 index 0000000..c0f0722 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_06.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77bd57fb3d7c54741f4a413ec40932515ad65c4dd9c184d9a2932e96e492ae7f +size 92064 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_07.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_07.uasset new file mode 100644 index 0000000..cb2d26b --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_07.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6ec75b05cc0787659356690c0503fa694f7e25525481e1dfb5584a710b3dace +size 91756 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_08.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_08.uasset new file mode 100644 index 0000000..5cf59e3 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_08.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dab085ce50df2725041978566d75170ec812f3e3d6abc90f51c4125512a1d4e0 +size 91171 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_09.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_09.uasset new file mode 100644 index 0000000..bb165a5 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_09.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f593bb118d024ba2d6ea0a04592176f40da7caa517f7e7c5dab937b93afdc12a +size 91418 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_10.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_10.uasset new file mode 100644 index 0000000..14e64ab --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_10.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e546601ba98646165637458a02d01105055076b03ff0d334cf5b36d008b10e81 +size 91683 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_11.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_11.uasset new file mode 100644 index 0000000..9206d18 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_11.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15822e1e2b62ded0693a34d472eb042ce3eac417eab5eb923e8fb54d3bab59a0 +size 91767 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_12.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_12.uasset new file mode 100644 index 0000000..8c4f0f1 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_12.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20e74d983bbca1bf421440eb21e73a8b041127b2ca7f21d11616dabfee42f5ee +size 92041 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_13.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_13.uasset new file mode 100644 index 0000000..0251e03 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood_13.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9d5c831618c16f6e25f8cc5e9d41a7fbba4b24aa7fbec912ef1975ada5951b2 +size 90829 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North.uasset index f6ff33d..09297ce 100644 --- a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e850dd628c7809e1607d748e97903b736780b215462efb2e9550abedec59b38 -size 150199 +oid sha256:c8ef8aaa7777ba4f5323d4e3123d1726f5076793ce61fbded424ef3f6e007e59 +size 147628 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_00.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_00.uasset new file mode 100644 index 0000000..34233ac --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_00.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee3ea32e0b3677ec80d3d802472ce8c0fb3ea736f8412a0b34b6973193d5b147 +size 80253 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_01.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_01.uasset new file mode 100644 index 0000000..cae8f3f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_01.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55b7b99a3df46a069d44970b6d1e7ccc4c91105f82e6c3584c73f80d1d7cca49 +size 80641 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_02.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_02.uasset new file mode 100644 index 0000000..cec15a4 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_02.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:136dea2fb3ccf64ff49783e43554c7c24b6321de11f8ac17dc7c5ff2a381ea38 +size 80982 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_03.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_03.uasset new file mode 100644 index 0000000..f250ced --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_03.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e53ef330579c941d20d3772b1d577e826187f223c39a193a7e48f986c029dd67 +size 80950 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_04.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_04.uasset new file mode 100644 index 0000000..0808ef2 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_04.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6c66f1a189967bffe63754fdadd1f0aecb375f04ecc34b5acbcd702b8938877 +size 80997 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_05.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_05.uasset new file mode 100644 index 0000000..0555502 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_05.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09e55066c9024e958f4b599b406d0527c11c88fee83a9484319346f1b9969ba1 +size 80903 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_06.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_06.uasset new file mode 100644 index 0000000..0b53db7 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_06.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1174e111f0c415ff1efc86dff7d8d6cd7adf7ba3e96e9e1d1195a40e5ffaed3 +size 80961 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_07.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_07.uasset new file mode 100644 index 0000000..334e2e7 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_07.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6249e8b3cd947ecf8aefce4ec533fec6344edafea5284dd7ef890d466cca6dab +size 81003 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_08.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_08.uasset new file mode 100644 index 0000000..c4b6d4a --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_08.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dcad03ade08b771ac78aaf8b9d9f4927cf7e1ed3740e4b010524e28b74cc2044 +size 80528 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_09.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_09.uasset new file mode 100644 index 0000000..2968cbb --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_09.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3698c394e6079b425852aaf4a3865db513a9c48c375abc9aaa31fbd218a1a99 +size 81123 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_10.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_10.uasset new file mode 100644 index 0000000..a823c53 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_10.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14b7ac086c0d9775824378b2d68d67661cc6ac022cc72896c06db7fdab43fda8 +size 80895 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_11.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_11.uasset new file mode 100644 index 0000000..de8c2b5 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_11.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:181948239c1db894ab4395b3f2eea4dc446fec9903ccaae0522d4cf319f4f0d6 +size 80945 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_12.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_12.uasset new file mode 100644 index 0000000..bc03306 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_12.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32ac94bc90fb6beee013ef5b65aa53304293f85802bcb162d430ba5af19fb539 +size 80860 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_13.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_13.uasset new file mode 100644 index 0000000..157867c --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North_13.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a672eb46413eec11468225fe64bb041aea0d5802c91b05b0ebdad33b1031187b +size 80488 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South.uasset index 1587e25..0726c33 100644 --- a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f979437847aa453436acbea6b0e5ae474f50d75da51a7d5cdca9782fcb9b790 -size 162907 +oid sha256:b495195a7fbdf1fea0a76f6c62f7125826ec025a0f1f6940b4afa697f1e64320 +size 160336 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_00.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_00.uasset new file mode 100644 index 0000000..c03d74b --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_00.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8296aeeee7590920c3c20c3fd16f63045b4db9f1f15d478d9bb1d0a66eecefe8 +size 78792 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_01.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_01.uasset new file mode 100644 index 0000000..c27a70d --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_01.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e72ac98602f773794358f99b8faa7cb17e4d874792b71723df35e3d5357bc34a +size 79045 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_02.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_02.uasset new file mode 100644 index 0000000..d6b8ea2 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_02.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4207808be50ee11f8673a9d2919aecd9ffa2c7dbc937ce2cd3094c8ffd56201d +size 79103 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_03.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_03.uasset new file mode 100644 index 0000000..2f93802 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_03.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f8b70adcce733caf93f47656d3782452010b9118660de1093f3b3507240dd8e +size 79059 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_04.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_04.uasset new file mode 100644 index 0000000..05a7d65 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_04.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9179172e601555299c8959693aad6212eba827935a960072ba63389ef2b7d05 +size 79007 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_05.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_05.uasset new file mode 100644 index 0000000..b596dac --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_05.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d534adc67786fea7b8322cae06ec62f119f192edb3f81fa86c89fc0482491e1a +size 79084 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_06.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_06.uasset new file mode 100644 index 0000000..7ff43f6 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_06.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0417f59207c5b84c5debf389f1d7503a5acd39cd93ae192061815ba4a2d9a3bb +size 79119 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_07.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_07.uasset new file mode 100644 index 0000000..e3029d6 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_07.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cf165ea7bfa086bb97f0b518b8464a30b9b9d1a3e0bfc6ca1b387810652aee6 +size 79166 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_08.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_08.uasset new file mode 100644 index 0000000..ae06036 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_08.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e87639d5ad4c09db34cea2684f074ae7fca5e332891294dcb290f5411a98e4d4 +size 79316 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_09.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_09.uasset new file mode 100644 index 0000000..c936728 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_09.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f52e65a0ec2ef74eba7535635cfb2d2e6a8cc3eae67871bf6a82cd0a3420319f +size 79200 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_10.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_10.uasset new file mode 100644 index 0000000..ae77372 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_10.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd1c3fca617b2cec1626464ca208a5c43d5c9aa9746107a3495e25ff52a62fb9 +size 79252 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_11.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_11.uasset new file mode 100644 index 0000000..150a33d --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_11.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6bdb322c9439cfe5e2b06f1a356cce8d59477e59e9d5994526f1dcb1eb426ec +size 79277 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_12.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_12.uasset new file mode 100644 index 0000000..04c609a --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_12.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70fa3c1a94afa98df6b3c587ffce3b296f6fa11019c4b7a70b7a20e7cea98b9d +size 79139 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_13.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_13.uasset new file mode 100644 index 0000000..bd83bb0 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South_13.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc098ebc1de416dbf67a217524ab450199dd7bdaf665b27e934e91463b07ac4e +size 78786 diff --git a/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset b/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset index 9b45fe0..3254887 100644 --- a/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset +++ b/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf1aceda8202def267cff63f18c5bf7ae91546996e1b071668c6b1147da8e8ca -size 357901 +oid sha256:65cba2e0ec61e1e7326b686242eb305ceea2de416a2116d9dc5100d2168c6a04 +size 355236 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_BC.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_BC.uasset index 2d27baf..14ff478 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_BC.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_BC.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5eb2e0c112fe1f74c015df093c57886d4ae44a532af43321e76bc8a849b3e0e4 +oid sha256:087ceb56008600eaabf9d02c0da9a09589b838188d86f63e906d1102ec766064 size 70879 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_R.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_R.uasset index ff015ab..fed3d0b 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_R.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_Asphalt_R.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d2d55fec24c9231c937a003109f60b78b79bd54588d4305f462bd4703934b650 +oid sha256:4bfcdb576788b48c93b040f866ccfc292e05302c7c7945d9aa5f2fc42746237f size 29011 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Banner_KIMMEL.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Banner_KIMMEL.uasset new file mode 100644 index 0000000..b914ec7 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Textures/T_Banner_KIMMEL.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6b09542d8510ae97eeb63a09aef93a7695762403e37a2c33177ad6bd89d2fe9 +size 24785 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Concrete_BC.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Concrete_BC.uasset index 325783c..1a2bc06 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_Concrete_BC.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_Concrete_BC.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:169b8076518236a4522d87f5036b0fffb684b0e215c90c0db1c36828bb77de59 +oid sha256:3aa60e66071925037c2586fc96273c497a72b3940ee268047ef1316ba99ba5e8 size 58568 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Concrete_R.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Concrete_R.uasset index dcffc1e..2943ba1 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_Concrete_R.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_Concrete_R.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c20cd95c3d485dd54edc9112ba65aee67b556443070d32fe8bfe6cd927d250fd +oid sha256:07d65b6d824d464255d60ee0359b06c4d1e418e8ab4745e8e9a5bcacc78f9023 size 17517 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_FacadeAshlar_AI.uasset b/Game/GigLine/Content/Hollywood/Textures/T_FacadeAshlar_AI.uasset new file mode 100644 index 0000000..d8b7686 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Textures/T_FacadeAshlar_AI.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a39ad2f2ab950c2856de4af1a6c169b0a8b642de77208bdfd4029c2821fa3ca +size 1688935 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_BC.uasset b/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_BC.uasset index 07d0147..cb4aed6 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_BC.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_BC.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28c4cd5cad5581857e80250448f32c0a25ca4d83c9dedd442eb98037fbf3bbea +oid sha256:d00dc3545fc1e68117e81d54b17f53dd4d36c7e25e24070baea9048b982728fc size 40610 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_R.uasset b/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_R.uasset index 4a58b81..3a8ba49 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_R.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_StoneAshlar_R.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a4d5bfbd587b3607b38f4e5f961b1088d3d0ea72ad7aa815782a682af1dc6b3 +oid sha256:fb06501c61d5a2e91d610fc2eead260e635ad0949b17673ed02cc374cd5c9ce0 size 18770 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Stucco_BC.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Stucco_BC.uasset index 562276e..2aef10f 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_Stucco_BC.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_Stucco_BC.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d34f4385dbd481141bd5a7a41438706ed96342c1584a913bf7308cf5cc024d8 +oid sha256:a5240aa3bde6064c80ceb6de2336c7cf06fa993b5fe41491fec3bf9de440cfd9 size 62746 diff --git a/Game/GigLine/Content/Hollywood/Textures/T_Stucco_R.uasset b/Game/GigLine/Content/Hollywood/Textures/T_Stucco_R.uasset index b7464bd..2a4da9b 100644 --- a/Game/GigLine/Content/Hollywood/Textures/T_Stucco_R.uasset +++ b/Game/GigLine/Content/Hollywood/Textures/T_Stucco_R.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb9cb4aa6074465957dd3835fa573f7aaf5e97b4c6e1e1dd0865a87dd447bda7 +oid sha256:6b965adf0d62a9ce0216df655c36d4d568359a02095ae4a3c00b1e0bae2eb59f size 17541 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset index 7d97515..47ca885 100644 --- a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ba3d7ac8b9ba43c83f7bca84b01d2bdd802a6e49d721a03c91672bd669843d1 +oid sha256:57de896571a24c045285b9a86ef3be1af4d90aa847579b5ef264701e5603dde1 size 72330 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset index 6a50acd..f686d6a 100644 --- a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:551224fcdf597a1cb4c4ae12fd2f5c5d0d67800d16f0ff16d3f8723c745e26ff +oid sha256:a820cac34f057e81460de8759acfe6da9e9988e8ffa1970dd757422a5935eceb size 72466 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset index b51f07f..cc3189e 100644 --- a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ebdf21e52c475de97950ac636133ef3407dae6a32adef1d70317aa9484be5dc +oid sha256:7c30ef41d75ac831ec3b9f8d458daaebcf950eba7b201720d9bf981a0a10c750 size 72406 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset index 7230223..e153988 100644 --- a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:696f90c246d4ce2cdd526c11cfce9082bc6c2ae215dc5c20216ad488eeac2aa1 +oid sha256:62cf394d006ddb2d93d86140ddfe321106fafb5f9fd07cc7694b896001f78a22 size 72370 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset index e29c8ff..ae2f828 100644 --- a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3d573014075d06f373e11b07cd16ca6f6d27e52067119929c274ca3b0eaa9b8 +oid sha256:0401c997de39a28a3e6d5489d184eb30819258a8cc8e5fe71c037d287b58484a size 72438 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset index bfebe19..3e821f5 100644 --- a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:123283642a811e9ba202127a3da4b30e7b4b6dc2c2629a24497f4e8eaf352893 +oid sha256:4dd281038140927a5707155238d96e0de0a417ec012fdeb473871f5272913001 size 72413 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Collision.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Collision.uasset new file mode 100644 index 0000000..a4297c7 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Collision.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:770af69266779476e7fb7e0aaafdadc688bdd9de84af4997b4b3921c17ef4884 +size 56718 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Glass.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Glass.uasset new file mode 100644 index 0000000..5a02b2d --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Glass.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:554d4ee13f307ab7265b4e963a106fff846ffe1173e477e22ceeb02150c97471 +size 56665 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_HatchPaint.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_HatchPaint.uasset new file mode 100644 index 0000000..5996348 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_HatchPaint.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a271c8eef345058697ec7ba79aed70b44bc6571cdb8cb625a0539de22e37c705 +size 5712 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Lamp.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Lamp.uasset new file mode 100644 index 0000000..2d6fa77 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Lamp.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86a811b95ea7875249df1a2c7060cfdbc5d2c26826202c4f28d0009393e8d324 +size 56667 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Paint.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Paint.uasset new file mode 100644 index 0000000..e6aac43 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Paint.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:164566bb91b6f1a4e7be961fd6d9a86a34c8654bfd8ef6b585a1a57564324307 +size 56654 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Tail.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Tail.uasset new file mode 100644 index 0000000..f35f5e1 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Tail.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a28208bab5de0ef1572bafbe78d80a5c67f266bf7a8f57688d49d8034d4df4f +size 56664 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Trim.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Trim.uasset new file mode 100644 index 0000000..3fc9ed3 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Trim.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bd774f618fb3a3c80599bad994aca79dd30bb811236bd7931bafdd4c9063973 +size 56638 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Tyre.uasset b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Tyre.uasset new file mode 100644 index 0000000..d84f8b5 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/M_Vehicle_Tyre.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0994f0650775090b04ea4328231ad1c21bcba437274a0b02873ed9c47c1ac69 +size 56649 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/PA_Vehicle_Sedan.uasset b/Game/GigLine/Content/Hollywood/Vehicle/PA_Vehicle_Sedan.uasset new file mode 100644 index 0000000..8bd39c9 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/PA_Vehicle_Sedan.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f7dfd8f321f79a8132f2f79476db3fd80a65c15ae868cb782e42d220d004328 +size 2621 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SK_Vehicle_Sedan.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SK_Vehicle_Sedan.uasset new file mode 100644 index 0000000..24889d2 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SK_Vehicle_Sedan.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bc494f072556b8d16ae2c10b59962747cb04a601e598e5ff0e965269e7226f3 +size 254637 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SK_Vehicle_Sedan_Skeleton.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SK_Vehicle_Sedan_Skeleton.uasset new file mode 100644 index 0000000..084a19c --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SK_Vehicle_Sedan_Skeleton.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fdd844087fdb0552ed99de2ab617a4b670cda0ba77d5bcea25413bc23312f0c +size 3064 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body.uasset new file mode 100644 index 0000000..512f487 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a77d384343ae9fb2ea48eebc5ea9de962d91015b3ae555ed6ce969e10c25813 +size 2971486 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body_LOD1.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body_LOD1.uasset new file mode 100644 index 0000000..f5e6629 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body_LOD1.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:088c0af3e7ed88e90c2ab1cfcfa6b43b90ee2b8c60ab38e41a7d72602ec3a2b7 +size 1417613 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body_LOD2.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body_LOD2.uasset new file mode 100644 index 0000000..a577de3 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Body_LOD2.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:239a2e3fb9b61584dccbf3bbfd0563d9d5a2ad47e267c693e1c0b8556ab21697 +size 577867 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Glass.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Glass.uasset new file mode 100644 index 0000000..7a56989 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Glass.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13d85205a7e24339f9633fccf0b4c52414c78a7338194f5928e7d1f3fec93d05 +size 637779 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Light_Front.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Light_Front.uasset new file mode 100644 index 0000000..f8d0d6f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Light_Front.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d815814b61be3e945ea084821d536bbfd02aefaaf6e0802cd272fdc6b750ac93 +size 74377 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Light_Rear.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Light_Rear.uasset new file mode 100644 index 0000000..02f3c71 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Light_Rear.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a629f6d3d33d40d9ed72f4339427c62c766ce7b697f55dbe6947465a3d6f44c0 +size 74371 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Lights.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Lights.uasset new file mode 100644 index 0000000..3de24c0 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Lights.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a190bed57beed6e2b292ce30690b845fd5a31a3067eae2d24055ab4fa28bf44 +size 1112638 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Mirrors.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Mirrors.uasset new file mode 100644 index 0000000..6dc7f79 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Mirrors.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06c822c774bfea5af0694f3155c0826140d0f7dddc2afda846287111e1bd3fbd +size 74303 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Trim.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Trim.uasset new file mode 100644 index 0000000..8fef201 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Trim.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a30579509ba70e2e8206d4d41aa44d5f242f5c19422ee43ad5e231abf7b5d2ce +size 399215 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Wheel.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Wheel.uasset new file mode 100644 index 0000000..c22b657 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Wheel.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf057727388c60970c083bfde74029b0f484432515d31275876627a6cf8c4a45 +size 76772 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Wheels.uasset b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Wheels.uasset new file mode 100644 index 0000000..4494792 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/SM_Vehicle_Wheels.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2aa418b4897bef437e0efeab086f57d4cceeec573340071930935ee571018d2e +size 2576765 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset b/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset new file mode 100644 index 0000000..c2f95f9 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/black_metal.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d3045c3df049d09f6c59d60d919f42c56b93e83814d138d291c9dc579aa26ce +size 5696 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/black_shiny_metal_.uasset b/Game/GigLine/Content/Hollywood/Vehicle/black_shiny_metal_.uasset new file mode 100644 index 0000000..eaa45ed --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/black_shiny_metal_.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b20b9e470f1401ced8a546a407ef1a6680cf9f3f13a2a7f961fe4420bed680de +size 56281 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset b/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset new file mode 100644 index 0000000..62f1e73 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/brake_caliper.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fce6306431a1bc2071fc5b1af97352d795997672b95aa2c809750495dc145041 +size 5677 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/chrome.uasset b/Game/GigLine/Content/Hollywood/Vehicle/chrome.uasset new file mode 100644 index 0000000..589b454 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/chrome.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e67aa88741dd2d4cc47a0002f6c8c4317e45f59f4564827e775b94334333325 +size 56083 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/light_cover.uasset b/Game/GigLine/Content/Hollywood/Vehicle/light_cover.uasset new file mode 100644 index 0000000..d49cb52 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/light_cover.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:308d8759557fc554431e3304883819cd657277f6c4b52969cf8bd4f3003c77a0 +size 56169 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/lights.uasset b/Game/GigLine/Content/Hollywood/Vehicle/lights.uasset new file mode 100644 index 0000000..fbd020d --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/lights.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea8ee4b7260a18eca7e68dff5b823f94c4dfb811e31d301b2ebf1c1462ec51f7 +size 56089 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/mirror.uasset b/Game/GigLine/Content/Hollywood/Vehicle/mirror.uasset new file mode 100644 index 0000000..ddf12e6 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/mirror.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ba6465ed51413f13e5663b53a0d517dd0d3fe7cc0c6cbac50826576bd8577f6 +size 56089 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/orange_light.uasset b/Game/GigLine/Content/Hollywood/Vehicle/orange_light.uasset new file mode 100644 index 0000000..138e1e6 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/orange_light.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db81928ea490455114bfd0f734edc03d6a32e6d3bf55a2f23b00390f05ca369d +size 56185 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/orange_light_off.uasset b/Game/GigLine/Content/Hollywood/Vehicle/orange_light_off.uasset new file mode 100644 index 0000000..8ae910f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/orange_light_off.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:070d29e6b169013a7860fc131700abc0b48ea9ddc6cba4756e427b4637a65aa9 +size 56249 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/orange_reflector.uasset b/Game/GigLine/Content/Hollywood/Vehicle/orange_reflector.uasset new file mode 100644 index 0000000..4cae2ba --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/orange_reflector.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bff7645c65a7555d6d4bfe42080666a8c41e3c718a3284343e676dc239b747ce +size 56249 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset b/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset new file mode 100644 index 0000000..3a55e7c --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/plastic_black.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a37621cc92944205b301242874fc1af9f5599a93c1864bde33f08e2477fd9f9 +size 5677 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset b/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset new file mode 100644 index 0000000..fd40873 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/pure_black.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:defd94b2fe6b5a1524cbec6159b1ed78465fcdf720d4d3b56c0c8461a71d384f +size 5662 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/red_light.uasset b/Game/GigLine/Content/Hollywood/Vehicle/red_light.uasset new file mode 100644 index 0000000..260336c --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/red_light.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:382c0118d6e3b24eb10b61f6ed683819e0025f1ea41b828d031351e96e828e91 +size 56137 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/red_reflector.uasset b/Game/GigLine/Content/Hollywood/Vehicle/red_reflector.uasset new file mode 100644 index 0000000..d9fd5e8 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/red_reflector.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c80ff6af72674cdf117a59288f498f5035ef5c60111fa07dc17c7cb113eaf987 +size 56201 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/reverse_light_off.uasset b/Game/GigLine/Content/Hollywood/Vehicle/reverse_light_off.uasset new file mode 100644 index 0000000..87dec2a --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/reverse_light_off.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d505a8ed1e78cd52a6678939f3d6379b06373224b6e490206d4c6546a8a034c +size 56265 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/rough_chrome.uasset b/Game/GigLine/Content/Hollywood/Vehicle/rough_chrome.uasset new file mode 100644 index 0000000..461527f --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/rough_chrome.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad21c54bff635aed0b66ea3272809036f157c7f160c7810cdc654dffbd44284c +size 56185 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset b/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset new file mode 100644 index 0000000..a68c78e --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/rubber.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d35c335ba02fb70b72c65a2a2daecaff755e1b5334cd0216d9eea80916e80d94 +size 5642 diff --git a/Game/GigLine/Content/Hollywood/Vehicle/windows.uasset b/Game/GigLine/Content/Hollywood/Vehicle/windows.uasset new file mode 100644 index 0000000..450d207 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Vehicle/windows.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4f79f6160304609c9b5362573a520b0eab35db81b349edaaefd9ec8c06a97a5 +size 6253 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index dc7c1bf..f25fbf6 100644 --- a/Game/GigLine/Content/Test/ReviewMap.umap +++ b/Game/GigLine/Content/Test/ReviewMap.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58ee12bbe157aa0bdfd30fcf43f4a6d80fac644790ee79ec408a384bb9b9dcfd -size 30542 +oid sha256:09b7824510e9e2bf1b692591e746ccf96989d07bd31c7a2ec9613794089746c4 +size 39252 diff --git a/Game/GigLine/Content/Test/VehicleProbe.umap b/Game/GigLine/Content/Test/VehicleProbe.umap new file mode 100644 index 0000000..ca9ccb5 --- /dev/null +++ b/Game/GigLine/Content/Test/VehicleProbe.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f4d25debdd47d1c8c38408a969d0a6112e3686c5f384f33b43289118cd75291 +size 42756 diff --git a/Game/GigLine/Content/Test/VehicleProbe_b.umap b/Game/GigLine/Content/Test/VehicleProbe_b.umap new file mode 100644 index 0000000..3a1c567 --- /dev/null +++ b/Game/GigLine/Content/Test/VehicleProbe_b.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc93bc9e9e11012c0686f5d0b8b942e9a32d65b25df068a323287ad78e10a781 +size 44244 diff --git a/Game/GigLine/Content/Test/VehicleProbe_c.umap b/Game/GigLine/Content/Test/VehicleProbe_c.umap new file mode 100644 index 0000000..d587372 --- /dev/null +++ b/Game/GigLine/Content/Test/VehicleProbe_c.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7145f86a02e88dba0dc43812abe1f07361ec3d98a752335130fa6d9747fd0fb6 +size 44244 diff --git a/Game/GigLine/Content/Test/VehicleProbe_d.umap b/Game/GigLine/Content/Test/VehicleProbe_d.umap new file mode 100644 index 0000000..baf9310 --- /dev/null +++ b/Game/GigLine/Content/Test/VehicleProbe_d.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8babe4fbba8d059d75cb81bb5999e87f64155ad2aff72219e7f3499e4202d6f0 +size 44546 diff --git a/Game/GigLine/Content/Test/_ProbeLevel.umap b/Game/GigLine/Content/Test/_ProbeLevel.umap new file mode 100644 index 0000000..758fe60 --- /dev/null +++ b/Game/GigLine/Content/Test/_ProbeLevel.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa9f965703eb8cdb9a2cdd843c5fd75b7be67d73307f7e0c543b3697f7fda1a4 +size 6368 diff --git a/Game/GigLine/Content/Test/_ProbeNew.umap b/Game/GigLine/Content/Test/_ProbeNew.umap new file mode 100644 index 0000000..5f97af2 --- /dev/null +++ b/Game/GigLine/Content/Test/_ProbeNew.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f62583378e87e490b8da482d000f18184107fbedde78420af125de5c8b4a912 +size 6358 diff --git a/Game/GigLine/Content/Test/_Scratch3.umap b/Game/GigLine/Content/Test/_Scratch3.umap new file mode 100644 index 0000000..c87d111 --- /dev/null +++ b/Game/GigLine/Content/Test/_Scratch3.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4da5813e8edb0a39a586df6445eb80302bd7d8d9eddeca6d0361708508256eca +size 6358 diff --git a/Game/GigLine/Content/Test/_Scratch4.umap b/Game/GigLine/Content/Test/_Scratch4.umap new file mode 100644 index 0000000..380255f --- /dev/null +++ b/Game/GigLine/Content/Test/_Scratch4.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:948f7994dd451ca4fed50e0fe79aa956d14cca55738da2ee1ee1bbef584d4448 +size 6358 diff --git a/Game/GigLine/Source/GigLine/GigLine.Build.cs b/Game/GigLine/Source/GigLine/GigLine.Build.cs index 5f9b939..9bc1280 100644 --- a/Game/GigLine/Source/GigLine/GigLine.Build.cs +++ b/Game/GigLine/Source/GigLine/GigLine.Build.cs @@ -14,5 +14,19 @@ public class GigLine : ModuleRules "InputCore", "EnhancedInput" }); + + // Editor-only: the vehicle physics asset is generated from the skeletal mesh with + // FPhysicsAssetUtils, which lives in the Developer/PhysicsUtilities module. Chaos + // Vehicles also need the physics vehicle module at runtime. + if (Target.bBuildEditor) + { + PrivateDependencyModuleNames.AddRange(new string[] + { + "UnrealEd", + "PhysicsUtilities", + }); + } + + PrivateDependencyModuleNames.Add("ChaosVehicles"); } } diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp index 6e355de..8bdd58a 100644 --- a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp @@ -340,6 +340,16 @@ void AGigLineCharacter::BeginPlay() } } + // Foot-contact diagnostic, one line per channel: the player stands at 561 while the + // Visibility trace reports ground at 366, so at least one channel is seeing a different + // surface than the one under foot. This names the actor and the Z for each channel. + // Foot-contact probe runs from Tick three seconds after spawn — see LogFootProbes(). + // `-probes` runs the ground probes and then quits: the measurements fire three seconds + // after spawn, so a run needs seconds, not minutes. Without the switch the game keeps + // running for the capture pipeline. + bProbeAndQuit = FParse::Param(FCommandLine::Get(), TEXT("probes")); + FootProbeDelay = 3.0f; + bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk")); bCameraTest = FParse::Param(FCommandLine::Get(), TEXT("cameratest")); @@ -365,6 +375,73 @@ FVector AGigLineCharacter::GetThirdPersonCameraLocation() const return ThirdPersonCamera ? ThirdPersonCamera->GetComponentLocation() : GetActorLocation(); } +void AGigLineCharacter::LogFootProbes() +{ + UWorld* World = GetWorld(); + if (!World) + { + return; + } + static const TCHAR* Names[] = {TEXT("Visibility"), TEXT("WorldStatic"), TEXT("Pawn")}; + static const ECollisionChannel Channels[] = {ECC_Visibility, ECC_WorldStatic, ECC_Pawn}; + const float CapsuleHalf = GetCapsuleComponent() + ? GetCapsuleComponent()->GetScaledCapsuleHalfHeight() : 0.f; + // Trace from well ABOVE the head down: the first hit is the topmost surface, i.e. the + // pavement the player can see. Tracing up from the feet finds whatever is below them, + // which is how "ground 366" got reported while the pavement on screen was higher. + if (UWorld* WorldTop = GetWorld()) + { + FHitResult Top; + FCollisionQueryParams QTop(TEXT("GigLineTop"), false, this); + const FVector From = GetActorLocation() + FVector(0.f, 0.f, 3000.f); + const bool bTop = WorldTop->LineTraceSingleByChannel(Top, From, + From - FVector(0.f, 0.f, 6000.f), ECC_WorldStatic, QTop); + FString MeshName = TEXT("none"); + if (bTop) + { + if (const UStaticMeshComponent* HitMesh = Cast(Top.GetComponent())) + { + if (const UStaticMesh* Asset = HitMesh->GetStaticMesh()) + { + MeshName = Asset->GetName(); + } + } + } + const float HitDist2D = bTop + ? FVector::Dist2D(FVector(Top.ImpactPoint.X, Top.ImpactPoint.Y, 0.0), + FVector(GetActorLocation().X, GetActorLocation().Y, 0.0)) + : -1.f; + UE_LOG(LogTemp, Warning, + TEXT("GigLine top surface under player: hit=%d Z=%.0f xy(dist %.0f) actor=%s mesh=%s (actor Z %.0f)"), + bTop ? 1 : 0, bTop ? Top.ImpactPoint.Z : -9999.f, HitDist2D, + bTop && Top.GetActor() ? *Top.GetActor()->GetName() : TEXT("none"), + *MeshName, GetActorLocation().Z); + } + + const FVector Feet = GetActorLocation() - FVector(0.f, 0.f, CapsuleHalf); + // Where is the body's lowest point relative to the capsule's? A mesh whose feet sit above + // the capsule bottom looks like a floating character even when the capsule is grounded. + if (const USkeletalMeshComponent* Body = GetMesh()) + { + const FBoxSphereBounds B = Body->Bounds; + UE_LOG(LogTemp, Warning, + TEXT("GigLine body: mesh bottom %.0f, capsule bottom %.0f, body above capsule %.0f cm"), + (B.Origin - B.BoxExtent).Z, Feet.Z, (B.Origin - B.BoxExtent).Z - Feet.Z); + } + for (int32 Index = 0; Index < 3; ++Index) + { + FHitResult Hit; + FCollisionQueryParams Q(TEXT("GigLineFoot"), false, this); + const bool bHit = World->LineTraceSingleByChannel(Hit, Feet + FVector(0.f, 0.f, 5.f), + Feet - FVector(0.f, 0.f, 1500.f), Channels[Index], Q); + UE_LOG(LogTemp, Warning, + TEXT("GigLine foot %s after settling: actor Z %.0f, feet %.0f, hit=%d Z=%.0f actor=%s"), + Names[Index], GetActorLocation().Z, Feet.Z, bHit ? 1 : 0, + bHit ? Hit.ImpactPoint.Z : -9999.f, + bHit && Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("none")); + } +} + void AGigLineCharacter::UpdateCameraCollisionTest(float DeltaSeconds) { // Deterministic on purpose. Finding a wall in the street wasted four runs: the @@ -962,6 +1039,59 @@ void AGigLineCharacter::Tick(float DeltaSeconds) UpdateCameraCollisionTest(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f); } + if (FootProbeDelay > 0.f) + { + FootProbeDelay -= GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.f; + if (FootProbeDelay <= 0.f) + { + LogFootProbes(); + if (FParse::Param(FCommandLine::Get(), TEXT("profile")) && GetWorld()) + { + // Ground profile along the street: one downward trace every 20 m at the + // player's Y, naming the surface each time. Comparing the road's profile + // with the sidewalks' is what the per-mesh bounding boxes could not do. + const FVector Base = GetActorLocation(); + for (int32 Step = 0; Step < 14; ++Step) + { + // Sweep ACROSS the street, not along it: the cross-section is where the + // sidewalk and road heights meet, and where they disagreed. + const float X = Base.X; + const float YOffset = (Step - 7) * 400.0f; + FHitResult Hit; + FCollisionQueryParams Q(TEXT("GigLineProfile"), false, this); + const FVector From(X, Base.Y + YOffset, Base.Z + 2000.0f); + const bool bHit = GetWorld()->LineTraceSingleByChannel(Hit, From, + From - FVector(0.f, 0.f, 4000.f), ECC_WorldStatic, Q); + FString MeshName = TEXT("none"); + if (bHit) + { + if (const UStaticMeshComponent* HitMesh = + Cast(Hit.GetComponent())) + { + if (const UStaticMesh* Asset = HitMesh->GetStaticMesh()) + { + MeshName = Asset->GetName(); + } + } + } + UE_LOG(LogTemp, Warning, TEXT("GigLine profile y=%.0f z=%.0f mesh=%s"), + Base.Y + YOffset, bHit ? Hit.ImpactPoint.Z : -9999.f, *MeshName); + } + } + if (bProbeAndQuit && GetWorld()) + { + // One frame later, so the log lines are flushed before the process exits. + GetWorld()->GetTimerManager().SetTimerForNextTick([this]() + { + if (APlayerController* PC = Cast(GetController())) + { + PC->ConsoleCommand(TEXT("quit")); + } + }); + } + } + } + WalkForever(); UpdateBodyAnimation(); diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.h b/Game/GigLine/Source/GigLine/GigLineCharacter.h index b9582ac..e94a18d 100644 --- a/Game/GigLine/Source/GigLine/GigLineCharacter.h +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.h @@ -210,6 +210,13 @@ protected: UPROPERTY() TObjectPtr CameraTestWall; + /** `-probes`: log the ground measurements, then quit. */ + bool bProbeAndQuit = false; + + /** Seconds until the foot-contact probe runs; 0 disables it. */ + float FootProbeDelay = 0.f; + void LogFootProbes(); + FVector LastWallNormal = FVector::ZeroVector; bool bCameraTest = false; bool CameraTestPlaced = false; diff --git a/Game/GigLine/Source/GigLine/GigLineHUD.cpp b/Game/GigLine/Source/GigLine/GigLineHUD.cpp index b2d7c2b..78f08a7 100644 --- a/Game/GigLine/Source/GigLine/GigLineHUD.cpp +++ b/Game/GigLine/Source/GigLine/GigLineHUD.cpp @@ -57,26 +57,14 @@ void AGigLineHUD::DrawHUD() } UpdateFrameStats(GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.0f); - if (!Canvas || AttributionLine.IsEmpty()) + if (!Canvas) { return; } - // Bottom-left, above any letterbox: the standard place for map/data credits. - const float LineHeight = Canvas->ClipY > 0 ? 22.0f * TextScale : 22.0f * TextScale; - const float X = MarginPx; - const float Y = Canvas->ClipY - MarginPx - LineHeight * 2.1f; - const FLinearColor Shadow(0.0f, 0.0f, 0.0f, 0.55f); const FLinearColor Ink(1.0f, 1.0f, 1.0f, 0.92f); - // Shadow pass for legibility over bright pavement and sky. - DrawText(AttributionLine, Shadow, X + 1.0f, Y + 1.0f, nullptr, TextScale); - DrawText(LicenceLine, Shadow, X + 1.0f, Y + LineHeight + 1.0f, nullptr, TextScale * 0.82f); - - DrawText(AttributionLine, Ink, X, Y, nullptr, TextScale); - DrawText(LicenceLine, Ink, X, Y + LineHeight, nullptr, TextScale * 0.82f); - // Control legend, bottom-right (0.1.0 release check: controls shown in-game). if (!ControlsLine.IsEmpty()) { diff --git a/Game/GigLine/Source/GigLine/GigLineHUD.h b/Game/GigLine/Source/GigLine/GigLineHUD.h index a9c87ae..c1af7c9 100644 --- a/Game/GigLine/Source/GigLine/GigLineHUD.h +++ b/Game/GigLine/Source/GigLine/GigLineHUD.h @@ -5,16 +5,12 @@ #include "GigLineHUD.generated.h" /** - * Always-on geographic attribution for the shipped demo. - * - * The demo is built from OpenStreetMap data, which is ODbL 1.0 licensed and - * requires attribution wherever a derived database or produced work is shown. - * This HUD draws the required credit in the bottom-left corner of the screen so - * it is present in the game, not only in the repository documents. + * On-screen text overlay: control legend (bottom-right) and the optional + * performance/state readout (top-left). * * Plain AHUD::DrawText is used deliberately: no UMG widget asset is needed, so - * the attribution cannot be accidentally dropped from a cook by a missing - * widget reference. + * the overlay cannot be accidentally dropped from a cook by a missing widget + * reference. */ UCLASS() class GIGLINE_API AGigLineHUD : public AHUD @@ -24,10 +20,6 @@ class GIGLINE_API AGigLineHUD : public AHUD public: virtual void DrawHUD() override; - /** Main credit line. Must match the ODbL attribution requirement. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution") - FString AttributionLine = TEXT("\u00A9 OpenStreetMap contributors"); - /** * Control legend, shown bottom-right so the 0.1.0 demo documents its own * controls in-game (release check: "controls documented in Docs/ and shown @@ -36,10 +28,6 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Controls") FString ControlsLine = TEXT("WASD move \u00B7 Mouse look \u00B7 Space jump \u00B7 V view \u00B7 R reset \u00B7 Esc pause"); - /** Licence line, including where the licence can be read. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution") - FString LicenceLine = TEXT("Data \u00A9 OpenStreetMap contributors, ODbL 1.0 \u2014 openstreetmap.org/copyright"); - /** * Performance and state readout, top-left, off by default. * @@ -54,11 +42,11 @@ public: void UpdateFrameStats(float DeltaSeconds); /** Screen-space margin in pixels. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution") + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD") float MarginPx = 18.0f; /** Text scale relative to the default canvas font. */ - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Attribution") + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD") float TextScale = 1.45f; private: diff --git a/Game/GigLine/Source/GigLine/GigLineVehicle.cpp b/Game/GigLine/Source/GigLine/GigLineVehicle.cpp new file mode 100644 index 0000000..bfd7fb9 --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineVehicle.cpp @@ -0,0 +1,585 @@ +#include "GigLineVehicle.h" + +#include "GigLineVehicleMovement.h" +#include "WheeledVehiclePawn.h" + +#include "ChaosVehicleWheel.h" +#include "ChaosWheeledVehicleMovementComponent.h" +#include "Components/SkeletalMeshComponent.h" +#include "PhysicsEngine/BodySetup.h" + +// From the Blender report (Art/blender/vehicle/report.json). Kept as named constants so a +// model change is a compile-time mismatch to fix, not a number buried in a setup call. +namespace GigLineVehicleDims +{ + constexpr float WheelRadiusCm = 33.0f; + constexpr float WheelBaseCm = 278.0f; + constexpr float TrackCm = 156.0f; +} + +UGigLineFrontWheel::UGigLineFrontWheel() +{ + // Dimensions from the model's report: radius 33 cm, width 22 cm. Steering yes, drive no. + WheelRadius = 33.0f; + WheelWidth = 22.0f; + bAffectedBySteering = true; + bAffectedByEngine = false; + MaxSteerAngle = 35.0f; + SuspensionMaxRaise = 8.0f; + SuspensionMaxDrop = 8.0f; +} + +UGigLineRearWheel::UGigLineRearWheel() +{ + WheelRadius = 33.0f; + WheelWidth = 22.0f; + bAffectedBySteering = false; + bAffectedByEngine = true; + SuspensionMaxRaise = 8.0f; + SuspensionMaxDrop = 8.0f; +} + +AGigLineVehicle::AGigLineVehicle(const FObjectInitializer& ObjectInitializer) + // Substitute the movement component class, which is the only place the Chaos rig can be + // instrumented: AWheeledVehiclePawn creates it, and the base name is how you replace it. + : Super(ObjectInitializer.SetDefaultSubobjectClass( + AWheeledVehiclePawn::VehicleMovementComponentName)) +{ + PrimaryActorTick.bCanEverTick = true; + + // Test: if disabling the movement component's tick stops the rise, the component is the writer. + if (FParse::Param(FCommandLine::Get(), TEXT("novehmover"))) + { + if (UChaosVehicleMovementComponent* Mover = FindComponentByClass()) + { + Mover->SetComponentTickEnabled(false); + Mover->Deactivate(); + } + } + + if (USkeletalMeshComponent* Body = GetMesh()) + { + static ConstructorHelpers::FObjectFinder MeshFinder( + TEXT("/Game/Hollywood/Vehicle/SK_Vehicle_Sedan.SK_Vehicle_Sedan")); + if (MeshFinder.Succeeded()) + { + Body->SetSkeletalMesh(MeshFinder.Object); + } + Body->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); + Body->SetCollisionProfileName(TEXT("PhysicsActor")); + Body->SetSimulatePhysics(true); + } + + if (UChaosWheeledVehicleMovementComponent* Movement = + Cast(GetVehicleMovementComponent())) + { + ConfigureWheels(); + } +} + +void AGigLineVehicle::BuildVisuals() +{ + // One component per authored system: body, glass, front and rear lights, mirrors and + // the four wheels. Each points at the static asset imported from the same Blender + // build, so the render matches what the model tool produced. + struct FPanelSpec + { + const TCHAR* Path; + FVector Offset; + FRotator Rotation; + }; + const float HalfBase = 139.0f; // wheelbase 278 cm / 2 + const float HalfTrack = 81.0f; // track 162 cm / 2 + const FPanelSpec Specs[] = { + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Body.SM_Vehicle_Body"), FVector::ZeroVector, FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Glass.SM_Vehicle_Glass"), FVector::ZeroVector, FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Light_Front.SM_Vehicle_Light_Front"), FVector::ZeroVector, FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Light_Rear.SM_Vehicle_Light_Rear"), FVector::ZeroVector, FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Mirrors.SM_Vehicle_Mirrors"), FVector::ZeroVector, FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(HalfBase, HalfTrack, -16.f), FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(HalfBase, -HalfTrack, -16.f), FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(-HalfBase, HalfTrack, -16.f), FRotator::ZeroRotator}, + {TEXT("/Game/Hollywood/Vehicle/SM_Vehicle_Wheel.SM_Vehicle_Wheel"), FVector(-HalfBase, -HalfTrack, -16.f), FRotator::ZeroRotator}, + }; + + int32 Index = 0; + for (const FPanelSpec& Spec : Specs) + { + if (UStaticMesh* Mesh = LoadObject(nullptr, Spec.Path)) + { + UStaticMeshComponent* Component = NewObject(this, + *FString::Printf(TEXT("Panel_%d"), Index)); + // Attach to the actor root rather than to the physics mesh, so the visuals are + // independent of the simulated component's own state. + Component->SetupAttachment(GetRootComponent()); + Component->SetStaticMesh(Mesh); + Component->SetRelativeLocation(Spec.Offset); + Component->SetRelativeRotation(Spec.Rotation); + Component->SetCollisionEnabled(ECollisionEnabled::NoCollision); + Component->SetMobility(EComponentMobility::Movable); + Component->RegisterComponent(); + // Runtime-created components need their render state forced: without this the + // panels registered and reported visible but never reached the renderer. + Component->MarkRenderStateDirty(); + Component->UpdateBounds(); + Panels.Add(Component); + ++Index; + } + } + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: %d visual panels attached"), Panels.Num()); + for (int32 PanelIndex = 0; PanelIndex < Panels.Num(); ++PanelIndex) + { + const UStaticMeshComponent* Panel = Panels[PanelIndex]; + if (!Panel) + { + continue; + } + const FVector Where = Panel->GetComponentLocation(); + const FBoxSphereBounds B = Panel->Bounds; + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: panel %d at (%.0f,%.0f,%.0f) visible=%d extent=%.0f mesh=%s"), + PanelIndex, Where.X, Where.Y, Where.Z, + Panel->IsVisible() ? 1 : 0, B.BoxExtent.GetMax(), + Panel->GetStaticMesh() ? *Panel->GetStaticMesh()->GetName() : TEXT("NONE")); + } +} + +void AGigLineVehicle::ConfigureWheels() +{ + UChaosWheeledVehicleMovementComponent* Movement = + Cast(GetVehicleMovementComponent()); + if (!Movement) + { + return; + } + + // One entry per wheel bone, in the order the rig was built. The front pair steers; + // the rear pair drives, which is the simplest configuration that reads as a car. + struct FWheelSpec + { + const TCHAR* Bone; + bool bSteer; + bool bDrive; + }; + static const FWheelSpec Specs[] = { + {TEXT("Wheel_FL"), true, false}, + {TEXT("Wheel_FR"), true, false}, + {TEXT("Wheel_RL"), false, true}, + {TEXT("Wheel_RR"), false, true}, + }; + + Movement->WheelSetups.Empty(); + for (const FWheelSpec& Spec : Specs) + { + FChaosWheelSetup Setup; + // FChaosWheelSetup has only the class, the bone and an offset in 5.8 - there is no + // per-setup disable flag, and steering/drive flags live on the wheel class. + Setup.WheelClass = Spec.bSteer ? UGigLineFrontWheel::StaticClass() + : UGigLineRearWheel::StaticClass(); + Setup.BoneName = FName(Spec.Bone); + Movement->WheelSetups.Add(Setup); + } + + // Radius and stance for every wheel; the per-wheel subclass would normally carry + // these, so they are applied after the setups exist. + for (int32 Index = 0; Index < Movement->WheelSetups.Num(); ++Index) + { + UChaosVehicleWheel* Wheel = Movement->Wheels.IsValidIndex(Index) + ? Movement->Wheels[Index] : nullptr; + if (!Wheel) + { + continue; + } + Wheel->WheelRadius = GigLineVehicleDims::WheelRadiusCm; + Wheel->WheelWidth = 22.0f; + Wheel->bAffectedBySteering = Specs[Index].bSteer; + Wheel->bAffectedByEngine = Specs[Index].bDrive; + } +} + +void AGigLineVehicle::BeginPlay() +{ + Super::BeginPlay(); + + // The skeletal mesh is the physics driver only: its imported geometry is unusable + // (1 cm bounds), so it is hidden and the visible car comes from the static panels. + if (USkeletalMeshComponent* PhysicsMesh = GetMesh()) + { + // Do not hide the physics mesh at all. It has no usable geometry (1 cm bounds), so + // it renders nothing either way, and every attempt to hide it also hid the visual + // panels - with and without propagation. Leaving it visible avoids the fight. + PhysicsMesh->SetCastShadow(false); + } + BuildVisuals(); + + // Where is the road, really? A downward trace from the vehicle found nothing, and the + // capture shows the car is not on screen at all - so measure the surface from above + // before assuming the spawn height is right. + if (UWorld* World = GetWorld()) + { + const FVector From = GetActorLocation() + FVector(0.f, 0.f, 1000.f); + FHitResult Hit; + FCollisionQueryParams Q(TEXT("GigLineVehicleRoad"), false, this); + const bool bHit = World->LineTraceSingleByChannel(Hit, From, + From - FVector(0.f, 0.f, 4000.f), ECC_Visibility, Q); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: surface below spawn at Z %.0f cm (hit=%d, actor at %.0f)"), + bHit ? Hit.ImpactPoint.Z : -9999.f, bHit ? 1 : 0, GetActorLocation().Z); + } + + // Wheel objects do not exist until the component is registered, so the per-wheel + // flags set in the constructor went nowhere - which is why the drive test measured + // zero speed with throttle held. Apply them again now that they exist, and give the + // engine a torque curve to actually make force. + if (UChaosWheeledVehicleMovementComponent* Movement = + Cast(GetVehicleMovementComponent())) + { + ConfigureWheels(); + Movement->EngineSetup.MaxTorque = 500.0f; + Movement->EngineSetup.MaxRPM = 6000.0f; + Movement->EngineSetup.EngineIdleRPM = 900.0f; + Movement->TransmissionSetup.bUseAutomaticGears = true; + Movement->TransmissionSetup.FinalRatio = 3.5f; + Movement->SetThrottleInput(0.0f); + if (const USkeletalMeshComponent* BodyComp = GetMesh()) + { + const USkeletalMesh* Asset = BodyComp->GetSkeletalMeshAsset(); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: mesh=%s visible=%d hidden=%d bounds=%s"), + Asset ? *Asset->GetName() : TEXT("NONE"), + BodyComp->IsVisible() ? 1 : 0, + BodyComp->bHiddenInGame ? 1 : 0, + *BodyComp->Bounds.BoxExtent.ToString()); + } + // Name the bones and check each setup against them: Chaos silently creates no wheel + // when a setup's bone is not on the mesh, which is how four setups produced zero + // wheels without a warning. + if (USkeletalMeshComponent* Body = GetMesh()) + { + TArray Bones; + Body->GetBoneNames(Bones); + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: %d bones, e.g. %s"), + Bones.Num(), + Bones.Num() > 0 ? *Bones[FMath::Min(1, Bones.Num() - 1)].ToString() : TEXT("none")); + for (const FChaosWheelSetup& Setup : Movement->WheelSetups) + { + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: setup bone '%s' index %d"), + *Setup.BoneName.ToString(), Body->GetBoneIndex(Setup.BoneName)); + } + } + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: %d wheel objects at BeginPlay (wheels are created later, " + "with the physics state), torque %.0f"), + Movement->Wheels.Num(), Movement->EngineSetup.MaxTorque); + } + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: spawned at (%.0f, %.0f, %.0f), %d wheel setups"), + GetActorLocation().X, GetActorLocation().Y, GetActorLocation().Z, + GetVehicleMovementComponent() + ? Cast(GetVehicleMovementComponent()) + ->WheelSetups.Num() + : 0); +} + +void AGigLineVehicle::Tick(float DeltaSeconds) +{ + // Which writer moves the actor? Sample the transform before and after the base class tick: + // a change between the two happened inside APawn::Tick (movement component, attachment + // update, animation), a change between this frame's exit and the next frame's entry came + // from somewhere else entirely. + const FVector BeforeSuper = GetActorLocation(); + + Super::Tick(DeltaSeconds); + + const FVector AfterSuper = GetActorLocation(); + if (!AfterSuper.Equals(BeforeSuper, 1.0)) + { + static float LoggedThisPhase = -1.0f; + if (SettleElapsed - LoggedThisPhase > 0.25f) + { + LoggedThisPhase = SettleElapsed; + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle writer: inside Super::Tick %.0f -> %.0f (dz %.0f)"), + BeforeSuper.Z, AfterSuper.Z, AfterSuper.Z - BeforeSuper.Z); + } + } + + // One settled-state report: proves the physics body exists, is simulating, and has + // come to rest on the road rather than falling through it. Everything else about the + // driving behaviour is measured later; this is the "is it real" check. + // `-drivetest`: hold full throttle from 1 s to 4 s and report the result. A vehicle + // that spawns is not a vehicle that drives. + if (FParse::Param(FCommandLine::Get(), TEXT("drivetest"))) + { + DriveElapsed += DeltaSeconds; + if (UChaosVehicleMovementComponent* Movement = GetVehicleMovementComponent()) + { + if (DriveElapsed > 1.0f && DriveElapsed < 4.0f) + { + Movement->SetThrottleInput(1.0f); + } + else if (DriveElapsed >= 4.0f && !bDriveReported) + { + Movement->SetThrottleInput(0.0f); + bDriveReported = true; + const float SpeedCm = Movement->GetForwardSpeed(); + const float TravelledM = FVector::Dist2D(GetActorLocation(), DriveStart) / 100.f; + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle DRIVETEST speed=%.0f cm/s (%.1f km/h), travelled %.1f m -> %s"), + SpeedCm, SpeedCm * 0.036f, TravelledM, + (SpeedCm > 200.f && TravelledM > 1.0f) ? TEXT("PASS") : TEXT("FAIL")); + } + } + } + if (DriveStart.IsZero() && DriveElapsed > 1.0f) + { + DriveStart = GetActorLocation(); + } + + // A real-car mass. Without it the physics asset's box takes its computed mass, and a light + // body under the vehicle solver is thrown around instead of settling - which is consistent + // with a pawn that launches itself and never creates its wheels. + if (!bMassSet && SettleElapsed > 0.5f) + { + if (USkeletalMeshComponent* Body = GetMesh()) + { + Body->SetMassOverrideInKg(NAME_None, 1500.0f, true); + bMassSet = true; + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: mass override 1500 kg, mass now %.0f"), + Body->GetMass()); + } + } + + // Is the body actually dynamic? A kinematic body is teleported by whoever holds it and reports + // no velocity - exactly the profile measured - so this decides the next step. + { + static bool bDynamicsChecked = false; + if (!bDynamicsChecked && SettleElapsed > 0.2f) + { + bDynamicsChecked = true; + if (USkeletalMeshComponent* Body = GetMesh()) + { + FBodyInstance* Instance = Body->GetBodyInstance(); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle dynamic: simulate=%d instance=%s profile=%s"), + Body->IsSimulatingPhysics() ? 1 : 0, + Instance ? TEXT("yes") : TEXT("no"), + Body->GetCollisionProfileName().IsNone() + ? TEXT("none") : *Body->GetCollisionProfileName().ToString()); + } + } + } + + // Kill any injected velocity every frame for the first two seconds. If the pawn still rises, the + // writer sets the transform directly; if it settles, the whole thing was one bad initial impulse. + if (SettleElapsed < 2.0f) + { + if (USkeletalMeshComponent* Body = GetMesh()) + { + Body->SetPhysicsLinearVelocity(FVector::ZeroVector); + Body->SetPhysicsAngularVelocityInDegrees(FVector::ZeroVector); + } + } + + // Name what the body is actually inside. A single huge collision proxy somewhere in this map + // would explain a positional push at any height. + { + static bool bOverlapLogged = false; + if (!bOverlapLogged && SettleElapsed > 0.3f) + { + bOverlapLogged = true; + if (USkeletalMeshComponent* Body = GetMesh()) + { + TArray Overlaps; + Body->GetOverlappingComponents(Overlaps); + FString List; + for (const UPrimitiveComponent* Comp : Overlaps) + { + List += FString::Printf(TEXT("%s/%s "), + Comp->GetOwner() ? *Comp->GetOwner()->GetName() : TEXT("?"), + *Comp->GetName()); + } + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle overlaps: %d [%s]"), + Overlaps.Num(), *List); + } + } + } + + // Velocity profile: an impulse shows as a spike at t=0, a force as steady growth. + { + static float NextSample = 0.0f; + if (SettleElapsed > NextSample && SettleElapsed < 2.5f) + { + NextSample += 0.25f; + if (USkeletalMeshComponent* Body = GetMesh()) + { + const FVector V = Body->GetPhysicsLinearVelocity(); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle profile t=%.2f Z=%.0f vZ=%.0f vY=%.0f"), + SettleElapsed, GetActorLocation().Z, V.Z, V.Y); + } + } + } + + // Decisive test: if the pawn keeps rising with physics switched OFF, physics is not the mover. + if (!bPhysicsOff && SettleElapsed > 3.0f) + { + bPhysicsOff = true; + if (USkeletalMeshComponent* Body = GetMesh()) + { + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: physics OFF at Z %.0f (component velocity %.0f)"), + GetActorLocation().Z, Body->GetComponentVelocity().Z); + Body->SetSimulatePhysics(false); + } + } + if (bPhysicsOff && !bPhysicsOffLogged && SettleElapsed > 4.0f) + { + bPhysicsOffLogged = true; + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: after 1 s without physics, Z %.0f"), + GetActorLocation().Z); + } + + // If the Chaos vehicle never came into existence (no wheels), its movement component has no + // valid state to drive, and continuing to tick it writes whatever is in that empty state back + // onto the actor - which is the only explanation that fits a still body and a moving actor. + if (!bMoverStopped && SettleElapsed > 2.5f) + { + bMoverStopped = true; + if (const UChaosWheeledVehicleMovementComponent* WheelComp = + Cast(GetVehicleMovementComponent())) + { + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: stopping the mover - wheels=%d at Z %.0f"), + WheelComp->Wheels.Num(), GetActorLocation().Z); + } + if (UMovementComponent* Mover = GetVehicleMovementComponent()) + { + Mover->SetComponentTickEnabled(false); + } + } + + // One-shot diagnostic: why zero wheels. Wheels are built in CreateVehicle(), which runs only + // when the physics state produced a vehicle, so the useful facts are whether the mesh is + // simulating and whether it carries a physics asset at all. + { + static bool bDiagnosed = false; + if (!bDiagnosed && SettleElapsed > 1.5f && FParse::Param(FCommandLine::Get(), TEXT("probes"))) + { + bDiagnosed = true; + USkeletalMeshComponent* Body = GetMesh(); + const UChaosWheeledVehicleMovementComponent* Wheeled = + Cast(GetVehicleMovementComponent()); + FString BoxInfo = TEXT("no-box"); + if (Body && Body->GetBodySetup() && Body->GetBodySetup()->AggGeom.BoxElems.Num() > 0) + { + const FKBoxElem& Box = Body->GetBodySetup()->AggGeom.BoxElems[0]; + BoxInfo = FString::Printf(TEXT("box %.0fx%.0fx%.0f centre %.0f,%.0f,%.0f"), + Box.X, Box.Y, Box.Z, Box.Center.X, Box.Center.Y, Box.Center.Z); + } + FString BoundsInfo = TEXT("no-bounds"); + if (Body) + { + const FBoxSphereBounds B = Body->Bounds; + BoundsInfo = FString::Printf(TEXT("bounds origin %.0f,%.0f,%.0f extent %.0f,%.0f,%.0f"), + B.Origin.X, B.Origin.Y, B.Origin.Z, B.BoxExtent.X, B.BoxExtent.Y, B.BoxExtent.Z); + } + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle geometry: %s | %s"), *BoxInfo, *BoundsInfo); + if (const UChaosVehicleMovementComponent* Mover = GetVehicleMovementComponent()) + { + const auto& Output = const_cast(Mover)->PhysicsVehicleOutput(); + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle output: %s"), + Output.IsValid() ? TEXT("present") : TEXT("null")); + } + if (Body) + { + const FVector Vel = Body->GetPhysicsLinearVelocity(); + const FVector Angular = Body->GetPhysicsAngularVelocityInDegrees(); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle motion: velocity %.0f,%.0f,%.0f angular %.0f,%.0f,%.0f " + "mass %.0f sim=%d kinematic=%d"), + Vel.X, Vel.Y, Vel.Z, Angular.X, Angular.Y, Angular.Z, Body->GetMass(), + Body->IsSimulatingPhysics() ? 1 : 0, Body->IsAnySimulatingPhysics() ? 1 : 0); + } + if (Body && Body->GetSkeletalMeshAsset()) + { + // Do the wheel setups name bones the mesh actually has? If not, CreateVehicle() + // bails before it ever builds a wheel. + TArray Bones; + Body->GetBoneNames(Bones); + FString BoneList; + for (int32 i = 0; i < Bones.Num() && i < 20; ++i) + { + BoneList += Bones[i].ToString() + TEXT(" "); + } + FString SetupList; + if (const UChaosWheeledVehicleMovementComponent* WheelComp = + Cast(GetVehicleMovementComponent())) + { + for (const FChaosWheelSetup& S : WheelComp->WheelSetups) + { + SetupList += S.BoneName.ToString() + TEXT(" "); + } + } + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle bones: %d [%s] | setups [%s]"), + Bones.Num(), *BoneList, *SetupList); + if (const UMovementComponent* Mover = GetVehicleMovementComponent()) + { + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle mover: updated=%s ticking=%d"), + Mover->UpdatedComponent ? *Mover->UpdatedComponent->GetName() : TEXT("none"), + Mover->IsComponentTickEnabled() ? 1 : 0); + } + } + { + const USkeletalMesh* Asset = Body ? Body->GetSkeletalMeshAsset() : nullptr; + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle mesh asset: %s"), + Asset ? *Asset->GetName() : TEXT("NONE")); + } + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle diag: mesh=%d simulating=%d physasset=%s setups=%d wheels=%d " + "loc=%.0f,%.0f,%.0f scale=%.2f mass=%.0f"), + Body != nullptr, + Body && Body->IsSimulatingPhysics() ? 1 : 0, + Body && Body->GetBodySetup() ? TEXT("yes") : TEXT("no"), + Wheeled ? Wheeled->WheelSetups.Num() : -1, + Wheeled ? Wheeled->Wheels.Num() : -1, + GetActorLocation().X, GetActorLocation().Y, GetActorLocation().Z, + GetActorScale3D().Z, Body ? Body->GetMass() : -1.0f); + } + } + + SettleElapsed += DeltaSeconds; + if (!bSettleReported && SettleElapsed > 3.0f) + { + bSettleReported = true; + if (const UChaosWheeledVehicleMovementComponent* Wheeled = + Cast(GetVehicleMovementComponent())) + { + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: %d wheel objects once physics exists"), + Wheeled->Wheels.Num()); + } + if (UWorld* World = GetWorld()) + { + FHitResult Hit; + FCollisionQueryParams Q(TEXT("GigLineVehicleGround"), false, this); + const bool bGround = World->LineTraceSingleByChannel(Hit, GetActorLocation(), + GetActorLocation() - FVector(0.f, 0.f, 2000.f), ECC_Visibility, Q); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: ground at Z %.0f cm, actor %.0f cm, height above ground %.0f cm"), + bGround ? Hit.ImpactPoint.Z : -9999.f, GetActorLocation().Z, + bGround ? GetActorLocation().Z - Hit.ImpactPoint.Z : -9999.f); + } + const USkeletalMeshComponent* Body = GetMesh(); + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: settled at Z %.0f cm, simulating=%d, speed=%.0f cm/s"), + GetActorLocation().Z, + Body && Body->IsSimulatingPhysics() ? 1 : 0, + GetVehicleMovementComponent() ? GetVehicleMovementComponent()->GetForwardSpeed() : 0.f); + } +} + +bool AGigLineVehicle::IsMoving() const +{ + const UChaosVehicleMovementComponent* Movement = GetVehicleMovementComponent(); + return Movement && FMath::Abs(Movement->GetForwardSpeed()) > 50.0f; +} diff --git a/Game/GigLine/Source/GigLine/GigLineVehicle.h b/Game/GigLine/Source/GigLine/GigLineVehicle.h new file mode 100644 index 0000000..28d56c1 --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineVehicle.h @@ -0,0 +1,90 @@ +#pragma once + +#include "CoreMinimal.h" +#include "ChaosVehicleWheel.h" +#include "WheeledVehiclePawn.h" +#include "GigLineVehicle.generated.h" + +class UChaosWheeledVehicleMovementComponent; + +/** + * Front and rear wheel classes. + * + * Chaos reads steering and drive flags from the wheel *object* the setup points at, and the + * base `UChaosVehicleWheel` is what the first attempt used - which produced zero wheels + * after registration. Giving each axle its own class is also what Unreal's own vehicle + * template does, for the same reason: the parameters belong to the class, not to the + * instance that arrives later. + */ +UCLASS() +class GIGLINE_API UGigLineFrontWheel : public UChaosVehicleWheel +{ + GENERATED_BODY() +public: + UGigLineFrontWheel(); +}; + +UCLASS() +class GIGLINE_API UGigLineRearWheel : public UChaosVehicleWheel +{ + GENERATED_BODY() +public: + UGigLineRearWheel(); +}; + +/** + * The one drivable vehicle in the demo. + * + * A skeletal mesh with a root bone and four wheel bones, driven by Chaos. Every + * dimension it configures — wheel radius, wheelbase, track — comes from the Blender + * build's report rather than being typed twice: the numbers are repeated here as + * constants with the report path in the comment so a change to the model shows up as a + * mismatch instead of a silent drift. + * + * Model: `Art/blender/vehicle/gigline-sedan.blend`, report `Art/blender/vehicle/report.json` + * (4.72 x 1.80 x 1.44 m, wheelbase 2.78 m, track 1.62 m, wheel radius 0.33 m). + */ +UCLASS() +class GIGLINE_API AGigLineVehicle : public AWheeledVehiclePawn +{ + GENERATED_BODY() + +public: + AGigLineVehicle(const FObjectInitializer& ObjectInitializer); + + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + + /** + * Visual panels, as static mesh components. + * + * The skeletal vehicle asset imported with no usable geometry (runtime bounds of + * 1 cm), so the car was invisible in the scene while its physics body still cast a + * shadow - the "no vehicle in scene" report. The static assets are correct (body + * 476 x 176 x 128 cm), so the visible car is assembled from them and the skeletal + * mesh stays as the physics/animation driver only. + */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Visual") + TArray> Panels; + + /** True once the vehicle reports a forward speed above a walking pace. */ + bool IsMoving() const; + +private: + /** Configure the Chaos wheels from the modelled dimensions. */ + void ConfigureWheels(); + + /** Attach the visible panels from the imported static systems. */ + void BuildVisuals(); + + float DriveElapsed = 0.f; + bool bDriveReported = false; + FVector DriveStart = FVector::ZeroVector; + + float SettleElapsed = 0.f; + bool bMassSet = false; + bool bMoverStopped = false; + bool bPhysicsOff = false; + bool bPhysicsOffLogged = false; + bool bSettleReported = false; +}; diff --git a/Game/GigLine/Source/GigLine/GigLineVehicleBuilder.cpp b/Game/GigLine/Source/GigLine/GigLineVehicleBuilder.cpp new file mode 100644 index 0000000..fbbfd2f --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineVehicleBuilder.cpp @@ -0,0 +1,71 @@ +#include "GigLineVehicleBuilder.h" + +#if WITH_EDITOR +#include "AssetToolsModule.h" +#include "Engine/SkeletalMesh.h" +#include "PhysicsAssetUtils.h" +#include "PhysicsEngine/PhysicsAsset.h" +#include "PhysicsEngine/BodySetup.h" +#include "PhysicsEngine/SkeletalBodySetup.h" +#include "Engine/SkeletalMesh.h" + +#include "UObject/Package.h" +#endif + +UPhysicsAsset* UGigLineVehicleBuilder::BuildPhysicsAsset(USkeletalMesh* Mesh, + const FString& PackagePath, + const FString& AssetName) +{ +#if WITH_EDITOR + if (!Mesh) + { + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: no skeletal mesh given")); + return nullptr; + } + + const FString FullName = PackagePath / AssetName; + UPackage* Package = CreatePackage(*FullName); + if (!Package) + { + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: could not create package %s"), *FullName); + return nullptr; + } + UPhysicsAsset* PhysicsAsset = NewObject(Package, *AssetName, + RF_Public | RF_Standalone | RF_Transactional); + + // ONE body on the chassis bone, which is what Unreal's own vehicle template uses. + // The first attempt let FPhysicsAssetUtils build a body per bone and the car settled + // 3.7 m above the road: the four wheel bodies were intersecting the chassis body and + // pushing the whole vehicle up. Chaos drives the wheels itself, so they must not be + // separate rigid bodies here. + const FBoxSphereBounds Bounds = Mesh->GetBounds(); + const FVector Extent = Bounds.BoxExtent; + const FName ChassisBone(TEXT("root")); + + USkeletalBodySetup* Body = NewObject(PhysicsAsset, ChassisBone, + RF_Public | RF_Transactional); + Body->BoneName = ChassisBone; + // A box slightly under the mesh bounds: a body that is exactly the bounds sits on the + // ground plane and reads as floating because the mesh has wheels below the sills. + FKBoxElem Box; + Box.X = Extent.X * 1.9f; + Box.Y = Extent.Y * 1.8f; + Box.Z = Extent.Z * 0.55f; + Box.Center = FVector(0.f, 0.f, -Extent.Z * 0.25f); + Body->AggGeom.BoxElems.Add(Box); + PhysicsAsset->SkeletalBodySetups.Add(Body); + + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: chassis body %.0f x %.0f x %.0f cm on bone '%s'"), + Box.X, Box.Y, Box.Z, *ChassisBone.ToString()); + + PhysicsAsset->MarkPackageDirty(); + UE_LOG(LogTemp, Warning, TEXT("GigLine vehicle: physics asset %s with %d bodies"), + *FullName, PhysicsAsset->SkeletalBodySetups.Num()); + return PhysicsAsset; +#else + UE_LOG(LogTemp, Warning, + TEXT("GigLine vehicle: physics asset generation is editor-only")); + return nullptr; +#endif +} diff --git a/Game/GigLine/Source/GigLine/GigLineVehicleBuilder.h b/Game/GigLine/Source/GigLine/GigLineVehicleBuilder.h new file mode 100644 index 0000000..146823a --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineVehicleBuilder.h @@ -0,0 +1,34 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "GigLineVehicleBuilder.generated.h" + +class UPhysicsAsset; +class USkeletalMesh; + +/** + * Script-callable helpers for vehicle setup. + * + * Chaos Vehicles need a PhysicsAsset, and Unreal's `PhysicsAssetFactory` exposes no mesh + * property to Python, so the asset cannot be generated from a commandlet. The body does + * the job with `FPhysicsAssetUtils::CreateFromSkeletalMesh`, which is the same code path + * the editor's "Create Physics Asset" action uses. + * + * Editor only: it is compiled into the editor target and never shipped. + */ +UCLASS() +class GIGLINE_API UGigLineVehicleBuilder : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** + * Build (or rebuild) a physics asset for `Mesh` at `PackagePath`/`AssetName`. + * + * @return the created asset, or nullptr with the reason written to the log. + */ + UFUNCTION(BlueprintCallable, Category = "GigLine|Vehicle") + static UPhysicsAsset* BuildPhysicsAsset(USkeletalMesh* Mesh, const FString& PackagePath, + const FString& AssetName); +}; diff --git a/Game/GigLine/Source/GigLine/GigLineVehicleMovement.cpp b/Game/GigLine/Source/GigLine/GigLineVehicleMovement.cpp new file mode 100644 index 0000000..a666710 --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineVehicleMovement.cpp @@ -0,0 +1,45 @@ +#include "GigLineVehicleMovement.h" + +UGigLineVehicleMovement::UGigLineVehicleMovement() +{ + // The async physics step is what the transform is written from; keep it on so the failure is + // reproduced rather than hidden. + PrimaryComponentTick.bCanEverTick = true; +} + +void UGigLineVehicleMovement::CreateVehicle() +{ + UE_LOG(LogTemp, Warning, + TEXT("GigLine mover: CreateVehicle() entered - UpdatedComponent %s"), + UpdatedComponent ? *UpdatedComponent->GetName() : TEXT("none")); + Super::CreateVehicle(); + UE_LOG(LogTemp, Warning, TEXT("GigLine mover: CreateVehicle() returned - wheels %d"), + GetNumWheels()); +} + +void UGigLineVehicleMovement::UpdateState(float DeltaTime) +{ + if (!bLoggedFirstFrame) + { + bLoggedFirstFrame = true; + UE_LOG(LogTemp, Warning, TEXT("GigLine mover: first UpdateState - output %s"), + PhysicsVehicleOutput().IsValid() ? TEXT("present") : TEXT("null")); + } + Super::UpdateState(DeltaTime); +} + +void UGigLineVehicleMovement::TickComponent(float DeltaTime, ELevelTick TickType, + FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + Elapsed += DeltaTime; + if (Elapsed - LastLog > 0.5f) + { + LastLog = Elapsed; + UE_LOG(LogTemp, Warning, + TEXT("GigLine mover tick: Z %.0f, output %s, wheels %d"), + UpdatedComponent ? UpdatedComponent->GetComponentLocation().Z : -9999.0, + PhysicsVehicleOutput().IsValid() ? TEXT("present") : TEXT("null"), + GetNumWheels()); + } +} diff --git a/Game/GigLine/Source/GigLine/GigLineVehicleMovement.h b/Game/GigLine/Source/GigLine/GigLineVehicleMovement.h new file mode 100644 index 0000000..372ab7a --- /dev/null +++ b/Game/GigLine/Source/GigLine/GigLineVehicleMovement.h @@ -0,0 +1,34 @@ +// Chaos vehicle movement with the one log line the broken rig needed (0.3.0). +// +// The vehicle pawn climbs 400-1400 cm/s with zero wheels while its body's reported velocity is ~0, +// and the transform does not change across APawn::Tick. The only writer left is this component, which +// runs in its own tick group and drives the transform from the async physics output. This subclass +// logs the two places that can write it, so the failure is caught at its source instead of inferred +// from where the actor ends up. + +#pragma once + +#include "CoreMinimal.h" +#include "ChaosWheeledVehicleMovementComponent.h" +#include "GigLineVehicleMovement.generated.h" + +UCLASS(ClassGroup = (GigLine), meta = (BlueprintSpawnableComponent)) +class GIGLINE_API UGigLineVehicleMovement : public UChaosWheeledVehicleMovementComponent +{ + GENERATED_BODY() + +public: + UGigLineVehicleMovement(); + +protected: + virtual void CreateVehicle() override; + virtual void UpdateState(float DeltaTime) override; + virtual void TickComponent(float DeltaTime, ELevelTick TickType, + FActorComponentTickFunction* ThisTickFunction) override; + +private: + /** Seconds since the component began ticking, used only to throttle the logging. */ + float Elapsed = 0.0f; + float LastLog = 0.0f; + bool bLoggedFirstFrame = false; +}; diff --git a/MEMORY.md b/MEMORY.md index 3fb3d02..2bc39ca 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -13,7 +13,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. - **prettier 3.9.10** installed globally via npm (Node v24.20.0). Project config: `.prettierrc` (`proseWrap: never`, `tabWidth: 2`), `.prettierignore` (skips `Binaries/`, `*.uasset`, `.agents/`). - **Conversion chain:** `.adoc` → `asciidoctor -b docbook5` → `.xml` → `pandoc -f docbook -t gfm` → `.md` → `prettier --write`. Pandoc's built-in `asciidoc` reader cannot handle Asciidoctor tables or attributes — it must go through docbook5. - **Post-processing fixes:** glossary `cols="e,2e"` emphasis leaks italic into cell content (strip `*...*` wrapping), `®` from the R key binding becomes `(R)`, anchor IDs `{#...}` stripped, escaped quotes/brackets normalised. -- **All `.adoc` files removed.** Markdown is the canonical format. Architecture doc lives in `Docs/Architecture/` as 13 section `.md` files + combined `architecture.md`. +- **All `.adoc` files removed.** Markdown is the canonical format. Architecture documentation lives in `Docs/Architecture/` as `README.md`, twelve numbered section pages, and `about-arc42.md`. - All `.md` in the repo passes `prettier --check` (23 files formatted, `.agents/` excluded as vendored content). ## 2026-11-08 — QA branch merged; demo is walkable @@ -65,10 +65,10 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-07 — Reference photography - **Licensing is not a gate for local development.** Reference images live in the gitignored `Art/reference/` and are never committed. What each set establishes is written into `Docs/reference-notes-hollywood.md`. -- **Commons text search runs dry; categories do not.** `Tools/commons_fetch.py --category "Category:Hollywood Masonic Temple"` surfaced 15 files a search never showed, including 4–5k px façades. +- **Commons text search runs dry; categories do not.** `Tools/gen/commons_fetch.py --category "Category:Hollywood Masonic Temple"` surfaced 15 files a search never showed, including 4–5k px façades. - **`upload.wikimedia.org` throttles hard.** Batches of six or fewer with `--delay 3`; a throttled file is skipped and recorded rather than killing the run (HTTP 429). - **Unqualified queries return junk.** `Hollywood Boulevard shops` returned a Münster street, `Hollywood Masonic Temple entrance` a Seattle card index. Check the hit list before accepting a set. -- **OpenRouter web search is expensive.** One `gpt-5.2` + `openrouter:web_search` call ran 17 searches and cost **$0.38**; two of its eight URLs were Commons files already held. Use it for a known gap, not a sweep. `Tools/page_image_fetch.py` turns results into files by reading `og:image`. +- **OpenRouter web search is expensive.** One `gpt-5.2` + `openrouter:web_search` call ran 17 searches and cost **$0.38**; two of its eight URLs were Commons files already held. Use it for a known gap, not a sweep. `Tools/gen/page_image_fetch.py` turns results into files by reading `og:image`. - **The inherited "eight stone steps, 12 m wide" is wrong.** Photographs show three portals, each with its own short flight. Do not copy that number into geometry. ## 2026-11-06 — Blender and Blosm, headless @@ -98,12 +98,12 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-07 — Unreal pipeline findings - **UE 5.8 game targets must use `BuildSettingsVersion.V7`** to share the installed editor's build environment; V5 is rejected with "modifies the values of properties … not allowed". -- **`UnrealEditor-Cmd -run=pythonscript` swallows Python `print()`** with `-stdout` in 5.8 — write results to a file instead (see `Tools/ue_scale_test_import.py`). +- **`UnrealEditor-Cmd -run=pythonscript` swallows Python `print()`** with `-stdout` in 5.8 — write results to a file instead (see `Tools/unreal/ue_scale_test_import.py`). - **`unreal.Box` has `min`/`max`, not `origin`/`get_extent()`** in 5.8 Python; compute centre/size from the corners. - **FBX Blender→UE axis mapping is east→+X, north→−Y, up→+Z** with preset `ue_metric_v1`; the Y mirror is inherent (RH→LH). Compass math in game code: north = −Y. Verified with the three-cube scale test. - Headless asset import works fine with `-nullrhi`; `unreal.AssetImportTask` + `FbxImportUI` is the pattern (Interchange pipeline under the hood). - **Flatpak Blender headless needs `XDG_CONFIG_HOME=$HOME/.var/app/org.blender.Blender/config`** for extensions: `bpy.utils.user_resource('EXTENSIONS')` otherwise resolves to `~/.config/blender/5.2/extensions` (empty) and `bl_ext.blender_org.mpfb` is undiscoverable. Blosm unaffected (scripts/addons via `BLENDER_USER_SCRIPTS`). Found via `Tools/addon_compat_probe.py`; documented in `Art/blender/README.md`. -- **UE 5.8 Interchange skips material/texture assets for skeletal FBX imports** (static imports do get materials). Finish the chain in Python: import texture, build Material via `MaterialEditingLibrary`, assign with `sm.set_editor_property('materials', [SkeletalMaterial])` — the struct field is `material_interface`, not `material`, and `SkeletalMesh.set_material()` does not exist. See `Tools/ue_character_import.py`. +- **UE 5.8 Interchange skips material/texture assets for skeletal FBX imports** (static imports do get materials). Finish the chain in Python: import texture, build Material via `MaterialEditingLibrary`, assign with `sm.set_editor_property('materials', [SkeletalMaterial])` — the struct field is `material_interface`, not `material`, and `SkeletalMesh.set_material()` does not exist. See `Tools/unreal/ue_character_import.py`. - **Blender 5.2 FBX exporter removed `armature_nla`** (passes for 4.x recipes fail). Also: a baked image is only referenced by the FBX exporter when `image.filepath` (not just `filepath_raw`) is set and the exporter can map the node — MPFB's v2 skin node-group tree can't be mapped, so a flat export material is required. ## 2026-11-07 — Collaboration and scope @@ -122,7 +122,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. - **`unreal.InputCoreTypes` does not exist** in the 5.8 Python bindings, and `unreal.Key` takes no constructor argument. Build a key with `k = unreal.Key(); k.import_text("SpaceBar")` — the export text is the key name (`MouseX`, `Gamepad_Left2D`). - **Factories are `unreal.InputAction_Factory` and `unreal.InputMappingContext_Factory`**, not `InputActionFactory`. `create_asset(..., factory=None)` also works. -- **Do not delete an asset and recreate it in the same commandlet run.** `create_asset` returns `None` for a path deleted earlier in that process. Load the existing asset and overwrite it instead (`Tools/ue_create_input_assets.py: fresh()`). +- **Do not delete an asset and recreate it in the same commandlet run.** `create_asset` returns `None` for a path deleted earlier in that process. Load the existing asset and overwrite it instead (`Tools/unreal/ue_create_input_assets.py: fresh()`). - **Modifiers on `EnhancedActionKeyMapping` do not survive saving.** Assigning `InputModifierNegate` to the mapping's `modifiers` reads back correctly in memory, even after a `gc.collect()`, but the saved asset reads `mods=[NoneType]`. Keep-alive references, explicit flag setting, and in-place saves all failed to change it. Workaround: bind one action per direction (W and S as separate actions) so no modifier is needed. - **A read-only packed `.umap` silently blocks `save_map`.** `EditorLoadingAndSavingUtils.save_map` returned `False` with no error until the file's mode was fixed (`chmod u+w`). Symptom: a rebuilt review map keeps three cameras after a fourth is added. - `-run=pythonscript` still swallows `print()`, so write results to a file; `/tmp` is visible to the commandlets even though it is not shared between agent tool calls. @@ -153,7 +153,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-07 — Blender rebuilds that silently did nothing -- **A read-only `.blend` makes Blender skip the save without failing the run.** `Tools/hero_facade_build.py` printed its part list and "bounds" as normal, then went straight to the export: the `.blend` and the report stayed at their old timestamps, and the Unreal re-import brought back the old mesh. Symptom to watch for: the imported bounds do not change after a rebuild. Fix: `chmod u+w` the `.blend` first, then confirm the timestamp moved. +- **A read-only `.blend` makes Blender skip the save without failing the run.** `Tools/blender/hero_facade_build.py` printed its part list and "bounds" as normal, then went straight to the export: the `.blend` and the report stayed at their old timestamps, and the Unreal re-import brought back the old mesh. Symptom to watch for: the imported bounds do not change after a rebuild. Fix: `chmod u+w` the `.blend` first, then confirm the timestamp moved. - **Model bottom must land on Z 0.** The hero portal jambs were sized `PORTAL_H + 0.3` and centred at `PORTAL_H / 2`, so they hung 0.15 m below the pavement and the whole facade imported with `min z = -15 cm`. Always check the asset's `bounds_m.min.z` in the build report. - **Rebuilding a facade is not enough to make a detail shot work**: doors modelled as thin panels on a solid plinth read as shading, not openings. Cutting the plinth into piers with a lintel band is what makes them legible from 8 m. @@ -180,8 +180,8 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-07 — screenshot judge tool -- `Tools/judge_screenshot.py` — runs an OpenRouter vision model over a screenshot vs an expected-scene description. Reads `OPENROUTER_API_KEY` from the repo `.env` (ignored). Base64 data-URI image under `image_url.url`; OpenAI-compatible body → `POST /api/v1/chat/completions`. `openai/gpt-4o-mini` returns a `{verdict, confidence, reason, missing}` JSON. It flags genuinely missing content (e.g. `main`'s pre-warm-skin character shot judged "no"); it is a pre-check, not the human verdict. -- Test one: `Tools/judge_screenshot.py --image Screenshots/review-01-character.png --expect "" --verbose`. Bulk: `--shots` (parses the README rows). +- `Tools/review/judge_screenshot.py` — runs an OpenRouter vision model over a screenshot vs an expected-scene description. Reads `OPENROUTER_API_KEY` from the repo `.env` (ignored). Base64 data-URI image under `image_url.url`; OpenAI-compatible body → `POST /api/v1/chat/completions`. `openai/gpt-4o-mini` returns a `{verdict, confidence, reason, missing}` JSON. It flags genuinely missing content (e.g. `main`'s pre-warm-skin character shot judged "no"); it is a pre-check, not the human verdict. +- Test one: `Tools/review/judge_screenshot.py --image Screenshots/review-01-character.png --expect "" --verbose`. Bulk: `--shots` (parses the README rows). ## 2026-11-07 — Blender skill packs: discovery depth @@ -202,12 +202,12 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-07 — ReviewMap corruption: deprecated level calls + save-order bug - **`EditorLevelLibrary.load_level`/`new_level` are deprecated no-ops in `-nullrhi` commandlets** (they return without switching worlds, so staged actors land in whatever world is current and get saved over the wrong map). Use the `LevelEditorSubsystem`: `unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).load_level(path)` actually switches. Symptom was ReviewMap containing Demo actors + `ShotSequencer`. -- **Map-save ordering bug in `Tools/ue_build_review_map.py`**: the script saved ReviewMap _after_ the DemoMap section, but the switch back to ReviewMap fails silently because the map only exists in memory at that point — so the final `save_map` wrote the demo world over ReviewMap. Fix: save ReviewMap first, then stage the demo cameras, and never switch back. Both maps verified by probe (ReviewMap lists Review actors; DemoMap lists Demo actors). +- **Map-save ordering bug in `Tools/unreal/ue_build_review_map.py`**: the script saved ReviewMap _after_ the DemoMap section, but the switch back to ReviewMap fails silently because the map only exists in memory at that point — so the final `save_map` wrote the demo world over ReviewMap. Fix: save ReviewMap first, then stage the demo cameras, and never switch back. Both maps verified by probe (ReviewMap lists Review actors; DemoMap lists Demo actors). - Rebuilt ReviewMap after the fixes: character, both cubes (A grounded, B floating at 2 m), pawn, NPC prefab, hero facade all present; user re-approved the shots. README cube naming corrected (README had A/B swapped vs the script). ## 2026-11-07 — Materials and lighting in the demo map -- **Blender flat diffuse colours do not survive FBX -> Unreal.** Every site mesh (road, sidewalks, kerbs, crossing paint) imported with a material asset that had a default _white_ base colour, and the terrain imported with `WorldGridMaterial` (UE's bright grey grid). That — not the sun — is what made the demo look blown out and washed white. Fix: create the colours in UE (`M_Asphalt`, `M_Concrete`, `M_Ground`, `M_RoadPaint` via `Constant3Vector`) and assign them per actor in `Tools/ue_build_demo_map.py`. +- **Blender flat diffuse colours do not survive FBX -> Unreal.** Every site mesh (road, sidewalks, kerbs, crossing paint) imported with a material asset that had a default _white_ base colour, and the terrain imported with `WorldGridMaterial` (UE's bright grey grid). That — not the sun — is what made the demo look blown out and washed white. Fix: create the colours in UE (`M_Asphalt`, `M_Concrete`, `M_Ground`, `M_RoadPaint` via `Constant3Vector`) and assign them per actor in `Tools/unreal/ue_build_demo_map.py`. - **Auto-exposure makes review shots non-comparable**: the same road rendered washed-out in one framing and near-black in another because each camera saw a different amount of sky. Fix: an unbound `PostProcessVolume` with `AEM_HISTOGRAM` and `auto_exposure_min_brightness == auto_exposure_max_brightness` (fixed exposure). Do not use `AEM_MANUAL` + `auto_exposure_bias` — a bias of 11 blew the whole frame out. - **`DirectionalLightComponent.light_color` takes a `Color` struct, not `LinearColor`** (`unreal.Color(r=255,g=245,b=230,a=255)`); passing `LinearColor` raises a nativize error. - **One band per object loses geometry downstream.** `crossing_band()` created `Crossing_Markings` each call, so Blender renamed the second and third to `.001`/`.002`; `site_assembly.py` appends by exact name, so only one crossing ever reached the game. Build all bands into a single mesh instead of one object per band. @@ -317,13 +317,13 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — hero colonnade proportion fixed (47.6 % → 53.1 %) - The hero's colonnade (columns 2.00–7.95 + capitals 7.95–8.90) was 6.90 m of the 14.50 m elevation = **47.6 %**, below the photographed 50–60 %, because the entablature (3.40 m) and parapet (2.20 m) used 5.60 m of the height. -- Thinned ENTABLATURE_H 3.40 → 3.00 and PARAPET_H 2.20 → 1.80 in `Tools/hero_facade_build.py`; the colonnade grows to **7.70 m = 53.1 %** while the envelope, motto band and medallions keep their relationships (all positioned relative to those two constants). +- Thinned ENTABLATURE_H 3.40 → 3.00 and PARAPET_H 2.20 → 1.80 in `Tools/blender/hero_facade_build.py`; the colonnade grows to **7.70 m = 53.1 %** while the envelope, motto band and medallions keep their relationships (all positioned relative to those two constants). - **Gotcha (cost a bad build):** `hero_facade_build.py` takes `--out` for the **FBX** and `--save-blend` for the `.blend`. Passing `--out .blend` writes FBX bytes over the blend and Blender then reports "File format is not supported". Correct invocation: `--out /tmp/gigline/hero-facade.fbx --save-blend Art/...blend --report Art/...json`. - Rebuilt hero → re-imported (`SM_HeroFacade`, bounds 2604 × 1257 × 1450 cm) → DemoMap rebuilt → screenshots re-rendered. Note `ue_hero_facade_import.py` saves the map, so `chmod u+w` the `.umap` first or the import silently does not persist. ## 2026-11-09 — AI detail plates from reference photos; floating windows fixed -- `Tools/facade_detail_concepts.py` generates architectural detail plates from one local reference photograph via OpenRouter image models (default `google/gemini-2.5-flash-image`; the sibling project uses the same route in `tools/generate_textures.py` — chat/completions with a text + image_url content part, image returned as `message.images[].image_url.url`). Eight studies, `--list` free, `--all` generates, `-vN` never overwrites, `.refgen.json` sidecar per image, all output under gitignored `Art/reference/facade-details/`. +- `Tools/gen/facade_detail_concepts.py` generates architectural detail plates from one local reference photograph via OpenRouter image models (default `google/gemini-2.5-flash-image`; the sibling project uses the same route in `tools/generate_textures.py` — chat/completions with a text + image_url content part, image returned as `message.images[].image_url.url`). Eight studies, `--list` free, `--all` generates, `-vN` never overwrites, `.refgen.json` sidecar per image, all output under gitignored `Art/reference/facade-details/`. - **Prompt gotcha:** the model refuses when the prompt describes features the reference photograph does not show ("the image you sent appears to be a decorative architectural detail, not an entrance with steps"). Prompts must describe the _reference subject_ and only restage light/people/surface. - **Output is real:** 1184×864 to 1344×768 PNGs, ~2 MB each. - **Measured defect found and fixed:** hero window panes sat at Y −1.98 while the mass front is Y −0.50 and the columns reach −1.02 — i.e. glazing floating 1.44 m in front of the wall and ahead of the colonnade. Now `WINDOW_Y = −0.56` (6 cm proud of the wall, behind the columns). Visible as dark openings in `review-hero-facade-front`. @@ -339,7 +339,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — one FBX export preset instead of six copies - The `bpy.ops.export_scene.fbx(...)` argument block was duplicated in six scripts (hero, block frontage, north buildings, site, scale test, MPFB character), and all six carried `mesh_smooth_type="OFF"` — hence the "No smoothing group information was found" warning on every import. -- Now `Tools/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`: `preset_kwargs()` (bpy-free, unit-tested) plus `export_static()` / `export_character()` wrappers that import `bpy` lazily. Every caller imports it and passes `selection=True/False`; the seven duplicated blocks are gone. +- Now `Tools/blender/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`: `preset_kwargs()` (bpy-free, unit-tested) plus `export_static()` / `export_character()` wrappers that import `bpy` lazily. Every caller imports it and passes `selection=True/False`; the seven duplicated blocks are gone. - Callers in `Tools/` resolve the module with `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))`; the Blender-side `Art/blender/site/export_site_fbx.py` computes the repo root (`..`, `..`, `..`) and joins `Tools`. Getting that wrong silently inserts `Tools/Tools` — check the import path when adding a caller. - Verified end to end: scale test, hero, frontage (6/6), site (7/7) and north buildings all re-exported through the preset, re-imported with **0 smoothing warnings**, DemoMap rebuilt and `-qaselftest` all PASS (walk, transect 0 holes, wall probe 0/35, facade probe 0/9). - `mpfb_character_export.py` was not re-run: it needs the MPFB add-on on `BLENDER_USER_SCRIPTS` ("Add-on not loaded: bl_ext.blender_org.mpfb"), which is pre-existing and unrelated; the character is not in `0.1.x` gameplay. @@ -357,7 +357,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — 0.1.3 packaging/setup fixes -- **`Tools/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0. +- **`Tools/unreal/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0. - Gotcha while adding it: `ue_hero_facade_import.py` had **no** `import os`/`sys` (only `unreal`), so the sys.path line raised `NameError: name 'sys' is not defined` inside the commandlet — an exit code of 255 with no obvious cause. - Docs: `build.md` gained the current pipeline table (7 site FBXs, frontage, north, hero, map), the `Builds/GigLineQA` archive, the packaged-log path, `-qaselftest` usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and documents the two optional API keys; architecture 07 + combined doc corrected the build-output path. @@ -371,16 +371,16 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — facade texture maps (0.1.4) -- `Tools/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum). +- `Tools/gen/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum). - `ue_build_demo_map.py` imports them to `/Game/Hollywood/Textures` and samples them **world-aligned**: WorldPosition (cm) → ×(1/tile_cm) → ComponentMask(R,G) → TextureSample UVs. `tile_cm` per material: stone 440, stucco 260, asphalt 900, concrete 250. - Gotchas: `MaterialExpressionTextureSample`'s UV input pin is **"UVs"**, and the output pin for colour is **"RGB"** / roughness **"R"**. `ComponentMask` pins are R/G/B/A booleans. A textured material skips the graph coursing on purpose — overlaying both would double the course pattern. - The script needs `ROOT` in `ue_build_demo_map.py` and `sys` imported; forgetting either fails with `NameError` and an exit code of 0 from the commandlet (always read the result file under /tmp/gigline). ## 2026-11-09 — façade module kit (0.1.4) -- `Tools/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping = 16 meshes, exported per-object with `fbx_export_preset` plus a JSON part report (dims + evidence per piece). -- `Tools/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple collision (a convex hull would fence off the shopfront glazing). -- `Tools/ue_apply_facade_kit.py` lays the kit out from each building's own bbox: `bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground floor. **638 pieces** over six frontage buildings + both north buildings. +- `Tools/blender/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping = 16 meshes, exported per-object with `fbx_export_preset` plus a JSON part report (dims + evidence per piece). +- `Tools/unreal/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple collision (a convex hull would fence off the shopfront glazing). +- `Tools/unreal/ue_apply_facade_kit.py` lays the kit out from each building's own bbox: `bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground floor. **638 pieces** over six frontage buildings + both north buildings. - **Bug worth remembering:** the first window piece was modelled entirely at +Y (into the wall), so every window sat _inside_ the solid blockout wall and was invisible in every render while the placement maths looked right. The kit now projects the surround 3 cm proud with the glazing 6 cm behind its face — a solid-wall blockout cannot carry a real recess, so the piece has to project. - Blender gotcha: clearing the scene _including materials_ invalidates handles already taken ("StructRNA of type Material has been removed"); clear objects and orphan meshes only, and create the material after the clear. - The kit meshes are spawned at the world origin with their position baked into the mesh (like the frontage exports), so placement uses the bbox directly and must not add the actor location. @@ -401,7 +401,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — visual comparison loop; screenshots are demo-only -- `Tools/visual_loop.py` closes the reference → render → feedback loop. `--verify` gates captures against `Tools/visual_targets.json` (blank, tone-less, wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference and prints a 0–1 score; `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. The feedback names the skill to consult, so the next modelling pass starts from a measurement rather than an opinion. +- `Tools/review/visual_loop.py` closes the reference → render → feedback loop. `--verify` gates captures against `Tools/review/visual_targets.json` (blank, tone-less, wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference and prints a 0–1 score; `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. The feedback names the skill to consult, so the next modelling pass starts from a measurement rather than an opinion. - **Iterations are gitignored** (`/Art/reference/iterations/`); only confirmed shots are promoted into `Screenshots/`. A test asserts the ignore rule and that every target is a demo-map shot. - `Screenshots/` now holds **demo-map captures only** — the review staging map is no longer rendered for review. `render_screenshots.sh --verify` renders the demo map, collects the shots and runs the verification gate in one step. - **PIL gotchas:** `FIND_EDGES` leaves its own border ring at 128 (236/3600 px on a 60x60 frame = 6.5% "detail" on a perfectly flat image), so crop **after** filtering — cropping before does not help, the new border gets the same treatment. Palette shares must divide by the full-image area, not the cropped one. @@ -409,7 +409,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — theatre marquee + blade sign -- `Tools/theatre_trim_build.py` builds the El Capitan Theatre's signature features in **world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket. `Tools/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one actor per piece (single spawn at the origin — no placement arithmetic). +- `Tools/blender/theatre_trim_build.py` builds the El Capitan Theatre's signature features in **world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket. `Tools/unreal/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one actor per piece (single spawn at the origin — no placement arithmetic). - **Two placement bugs, both invisible in a render:** 1. **Axis sign.** Blender +Y is Unreal −Y, so a face at Unreal Y +1001 is Blender Y −10.01. Using +10.01 put the whole marquee on the far side of the boulevard. 2. **Absolute Z.** Trim heights were absolute while the pavement at the theatre is 4.56 m above the project datum, so the canopy hung 5 cm above the pavement — the QA wall probe caught it as a knee-height blocker. `GROUND_Z` now offsets every height. @@ -423,7 +423,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — quality priority flipped: realism first, performance last -- Owner decision: realism leads; performance closes the quality list. Applied in `Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, `04_solution_strategy.md`, the combined `architecture.md`, `Docs/release-checks.md` (performance section now last, with the reason), `ROADMAP.md` (a "Quality priority" section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule). +- Owner decision: realism leads; performance closes the quality list. Applied in `Docs/Architecture/01_introduction_and_goals.md`, `10_quality_requirements.md`, `04_solution_strategy.md`, `Docs/release-checks.md` (performance section now last, with the reason), `ROADMAP.md` (a "Quality priority" section) and `AGENTS.md` (the tie-break rule next to the scope-cutting rule). - Ordering used: 1 visual realism, 2 geographic fidelity, 3 walkability/usability, 4 build reproducibility/portability, 5 provenance, 6 performance. - **Both revisions carry the same guard so the order cannot be used as an excuse:** performance is a _floor_, not a preference — 60 FPS at 1440p without frame generation must be met at every release, and if realism cannot fit, the realism scope is cut deliberately and recorded in `CHANGELOG.md`, never the frame rate. Walkability outranks realism in one direction: a block the player falls through is not a demo. @@ -436,7 +436,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. - Fluting: 20 grooves per column as thin shadow-tone cylinders set into the shaft (120 pieces) rather than boolean cuts — the eye reads them in silhouette and grazing light, booleans would cost far more for that. Motto band is now a real recess: surround 6 cm proud, panel 12 cm behind (was an 8 cm dark stripe). Hero 148 parts/3 044 tris -> 271/5 492, still 14.50 m. - **The real find:** `ue_hero_facade_import.py` had its own hero position `(-12024.5, -1450, 0)` — the pre-`0.1.0` value, before the frontage placement fix moved the hero to `(-13336, +1307)`. Running the importer on its own therefore teleported the hero **13 m east and onto the north side of the boulevard**, and nothing complained: a displaced building still renders. The only symptom was the reference score dropping 0.69 -> 0.56 with the 3x3 composition correlation flipping +0.54 -> -0.08. -- Fix: `Tools/ue_placement.py` is now the single source of truth (hero position, verified frontage spans, `span_error`/`describe_span_failure`). Both `ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert the stale literal cannot come back. Spawn-view score back to **0.72** (best yet). +- Fix: `Tools/unreal/ue_placement.py` is now the single source of truth (hero position, verified frontage spans, `span_error`/`describe_span_failure`). Both `ue_hero_facade_import.py` and `ue_build_demo_map.py` import it, the import asserts the staged span and logs `PLACEMENT ERROR` when it does not match, and tests assert the stale literal cannot come back. Spawn-view score back to **0.72** (best yet). - Lesson to keep: **duplicated placement constants drift silently.** The metric that caught it was the composition correlation, not a visual review. ## 2026-11-09 — 1440p re-measured with the kit: 363 fps, ~12% cost @@ -447,7 +447,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — 604 unmaterialed actors: material rebuild was delete+recreate -- The "blockout cleaning pass" turned out to need **material and placement hygiene, not more geometry**. `Tools/ue_audit_scene.py` (new) found **604 of 620 mesh actors with no material**: kit pieces and theatre trim all untextured. +- The "blockout cleaning pass" turned out to need **material and placement hygiene, not more geometry**. `Tools/unreal/ue_audit_scene.py` (new) found **604 of 620 mesh actors with no material**: kit pieces and theatre trim all untextured. - **Cause:** `ensure_material` in `ue_build_demo_map.py` deleted and recreated each `/Game/Hollywood/Site/M_*` asset every run ("so a look change cannot be masked by a stale asset"). That orphane every reference: actors staged by other tools pointed at deleted assets and fell back to the default material. The map looked only _slightly_ flat — no error, no warning. - **Fix:** update the material **in place** — load it, `MaterialEditingLibrary. delete_all_material_expressions(mat)`, rebuild the graph, recompile, save. Note `Material.expressions` is protected from Python; use the library's deleter. - **Ordering rule now documented and enforced:** the demo builder rebuilds materials, so kit and theatre staging must run _after_ it. `render_screenshots.sh` now runs demo map → kit assembly → theatre trim → scene audit → shot cameras → capture, and **fails on a non-zero audit count**. @@ -462,7 +462,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — Retail frontage and two pipeline traps -- **`Tools/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move. +- **`Tools/review/render_screenshots.sh` called `./Engine/...` for the capture step** while every commandlet above went through `run_cmdlet`'s `cd "$ENGINE"`. With `set -e` the script died at exit 127 _after_ rebuilding the maps, so the PNGs silently stayed a day old and `--verify` never ran. Fixed to `"$ENGINE/Engine/Binaries/..."`. Symptom to remember: maps rebuild, screenshots do not move. - **The generic façade kit is applied to every actor labelled `Frontage_*` or `North_*`**, so staging a hand-modelled façade under those labels gets the kit pasted over it — two contradicting façade languages on one wall. `ue_apply_facade_kit.py` now skips `MODELLED` labels, and retail trim stages as `RetailTrim_*`. - **A Blender builder cannot be imported by a unit test** (`import bpy` at module scope). Pure layout arithmetic lives in `Tools/_common.py`; `Tools/tests/test_retail_frontage.py` tests it there, and the builder delegates. Same trick `fbx_export_preset.py` uses for its preset dicts. - **Joining 85 parts into one mesh per building** before export cut the Unreal import from 85 assets to 2, and the staging step to two spawns. Record per-part dimensions _before_ the join: after it only the joined bounds exist. @@ -483,14 +483,14 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. - **The real reason the character pose never changed across a dozen rebuilds:** the FBX importer reported success every time, but `Character_Anim` and its two AnimSequences kept their original data. Proof: after changing the arm base from 1.55 rad to 0.55 rad and rebuilding, `AnimPose.get_bone_pose("upperarm01_L")` returned the **identical** quaternion `(+0.006, +0.002, -0.179, -0.984)` as before. An asset that does not change when its source does is not being written. - `AssetImportTask.replace_existing = True` was set, and `EditorAssetLibrary.delete_asset` returned **False** for both clips, even after `chmod u+w` (the `.uasset` files are LFS-lockable and check out read-only). Deleting is blocked because the pawn's C++ constructor hard-references the clips. -- **Workaround that works: import under a new name.** `Tools/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed. +- **Workaround that works: import under a new name.** `Tools/unreal/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed. - **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds. -- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides. +- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/blender/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides. ## 2026-11-09 — Textures: ComfyUI works, Stable Diffusion fails for flat swatches -- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/` and fetch `/view?filename=...`. `Tools/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants. -- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair. +- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/` and fetch `/view?filename=...`. `Tools/gen/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants. +- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/gen/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair. - **Unreal collapses the second material slot on a skeletal-mesh import.** A joined hair cap (Blender shows two slots, hair faces on index 1) arrives as a single slot named after the body material, so the hair renders with the skin material. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted after import. A separate skeletal mesh attached as a second component is the way around it. ## 2026-11-09 — Hair as a separate mesh, and a material-editing crash @@ -502,7 +502,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. ## 2026-11-09 — Character blueprints and how to compare a render to one - **A SDXL character sheet is reliable**: "full body front view, neutral pose, plain grey background, character sheet" gives a usable turnaround with fixed seeds. Four views (front/side/back/head) from one description is enough to evaluate against; the sheet and its JSON live in `Art/characters/blueprints/`. -- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character. +- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/blender/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character. - **The report has to separate the failures it measures.** Aspect, fill, coverage and two colour distances are reported apart on purpose: an absolute similarity number between a photoreal sheet and a low-detail body would mean nothing. The first real comparison (aspect Δ1.24, mean ΔE 31.9) was dominated by the model being **unclothed** while the blueprint wears a tee and jeans. - **`SetMaterial(0, ...)` in the pawn constructor does not visibly land** on the body mesh: `M_Character_Skin` is built and assigned, yet the render stays neutral grey. Either the slot does not exist yet at that point or the assignment is dropped; `BeginPlay` is the next thing to try. - **MPFB vertex groups are polluted by helper geometry.** The body mesh weights its helper cubes to groups named `Left`, `Right`, `HelperGeometry` and `JointCubes` at 1.0, so a "strongest group" lookup returns a helper for most vertices - the first clothing build selected **zero** faces. Filter helper names out before asking which bone a vertex belongs to. @@ -513,10 +513,170 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. - **Warm skin sampling as blue-grey was lighting, not material.** The body stood in building shadow and was lit almost entirely by the blue `Demo_SkyLight` (intensity 2.6): face (99, 98, 100), arms (67, 74, 81). Raising the sun to pitch -74 moved the face to (131, 121, 117). A deliberate red test colour reaching the render first proved the material and the slot assignment were fine, which is what stopped a long hunt in the wrong place. - **The Unreal commandlet drops unknown command-line switches.** A `--unlit-test` flag passed after `-script` never reached Python; an environment variable does get through (and even that proved unnecessary once the red test settled the question). +## 2026-11-09 — The sidewalk does not block, and why + +- **The staged `Sidewalk_North` mesh does not stop a trace or a capsule.** It carries `QueryAndPhysics`, its bbox tops out at Z 380, and `Tools/unreal/ue_import_site.py` sets `CTF_USE_SIMPLE_AND_COMPLEX` with `auto_generate_collision=True` — yet both a downward trace and a falling capsule pass straight through to `Road_Hollywood` at 366. +- **Likely cause: the auto-generated simple collision for a 279 m x 8.6 m x 3.8 m slab is degenerate.** A convex hull over that aspect ratio either collapses or ends up far from the surface; the complex side is one-sided and the mesh may be wound the wrong way. `bOneSidedGeometry` no longer exists on `BodySetup` in 5.8, so the flag cannot be flipped from Python. +- **Working stopgap:** an invisible `279 x 8.6 x 0.08 m` blocking cube with its top at Z 380 in `Tools/unreal/ue_build_demo_map.py`. Verified: `top surface under player: Z=380 mesh=Cube`, `foot: actor Z 470, feet 382`. +- **The real fix belongs in the site export:** split the sidewalk into segments of a few metres, or export explicit `UCX_` boxes, so the collision is a set of sane hulls rather than one degenerate one. + +## 2026-11-09 — Slicing the sidewalk did not fix its collision + +- **The 20 m slice hypothesis is dead.** The site export now splits sidewalks and kerbs into ~20 m pieces (`Art/blender/site/export_site_fbx.py`, `SEGMENTED`, `SEGMENT_M`), so each has an honest per-slice bounding box. The slices still do **not** block: with the invisible stopgap retired, a downward trace goes straight to `Road_Hollywood` at 366 and the capsule lands there. +- **So the slab's 279 m aspect ratio was not the cause** of the missing collision — a 20 m slice of the same mesh fails identically. The mesh itself imports with collision that does not block, regardless of size. +- **What does collide:** `Road_Hollywood` (366) and the engine cube stopgap. What does not: every `Sidewalk_*` and `Kerb_*` slice. +- **Next place to look:** the sidewalk mesh's geometry - an open surface with no volume, inverted winding, or zero-thickness triangles - rather than the import flags or the size. + +## 2026-11-09 — Fast probe loop, and the real heights + +- **`-probes` runs the ground measurements and quits**: a measurement run went from minutes to **13.7 s**. Without the switch the game keeps running for the capture pipeline. +- **The sidewalks' normals are correct** (2790 faces, all pointing up) and they have no loose edges, so winding was not the reason they fail to block. +- **`set_complex_collision_as_simple` on sidewalks and kerbs did not make them block** either - the terrain needs it, they do not respond to it. +- **The heights at the player, corrected once more:** the road is **366** and the sidewalk **380** in the shipped map; the 158 cm figure came from the site blend's own Z datum, which is ~2.2 m below the map's because the terrain was added after that blend was written. The float is therefore about **14 cm** - the character stands on the road because the sidewalk does not collide - not the metre it looks like against the kerb. + +## 2026-11-09 — The slices were nested, and the site surfaces disagree with each other + +- **Bug in the slice export:** the far cut used `clear_outer=True`, which kept the wrong half, so every "slice" still spanned to the end of the street (-27950..-43) and its bbox topped out at the same far-away maximum. Fixed by making both cuts keep the middle (`clear_inner=True` with the normal pointing at the half to keep). The slices are now disjoint and their tops read 380, 340, 309, 284, 254, 231, 205, 169, 138, 104 cm along the street. +- **The surfaces themselves disagree:** at the player's X (-133 m) the sidewalk top is **169 cm** while `Road_Hollywood` is **366 cm** - the road sits ~2 m ABOVE the sidewalk. The player therefore lands on the road, and no sidewalk collision trick will change that, because the sidewalk is not where the road is. +- That is a **site-data problem**, not a placement or collision one: the road and the sidewalks were generated from different passes and their heights no longer agree along this stretch. +- **Next:** regenerate the road and sidewalks from one elevation pass in the site assembly so their surfaces meet at the kerb, then re-export and re-import (one export + one import + one 14 s probe). + +## 2026-11-09 — No duplicates, so the road mesh itself is at 366 there + +- Checked for two `Demo_Road_Hollywood` actors after several import rounds: **none**. One actor, at the origin, identity scale, mesh Z spanning -44..366 cm. +- So the 366 cm the trace reports at the player's XY is the road mesh's own local height there, not a stale duplicate and not a far-side hit. The sidewalk slice at the same X tops at 169 cm. +- In the source blend at the same street position both surfaces sit around 1.4-1.6 m and agree within a kerb height, so the ~2 m gap appears **after** the mesh leaves Blender - which points at the import path (unit handling, or the Y mirror noted in `export_site_fbx.py`) rather than the strip generation. + +## 2026-11-09 — The road is FLAT at 366 while the sidewalks slope: that is the gap + +- Ground profile along Hollywood Blvd, traced every 20 m at the player's Y (`-probes -profile`): + +``` +x=-14336 z=366 Road_Hollywood x=-2336 z=366 Road_Hollywood +x=-12336 z=366 Road_Hollywood x= -336 z=302 Road_Hollywood +x=-10336 z=366 Road_Hollywood x= 1664 z= 5 Terrain_DEM +``` + +- **The road is a flat slab at 366 cm** across the whole demo block, while the sidewalk slices follow the DEM and descend 380 -> 104 cm. The gap is therefore in the street generator: the road is not sampling the elevation per row the way the sidewalks do, or it was written with a single Z. +- That also explains why every sidewalk fix failed - the street the player actually stands on is the flat road, and the sloped sidewalk is metres below it. +- **Next:** inspect how `rows` is built in `Art/blender/street/street_mesh.py` - the road's `road_z` reads `dem_z(x, y)` from the same rows as the sidewalk, so a flat result means `dem_z` is returning the same value for every row (origin offset or a lookup that fails and falls back), and the fix is in that sampler. + +## 2026-11-09 — Source truth vs what the engine reports + +Sampled the regenerated street blend at the player's X (x = -133.36 m): + +``` +Road_Hollywood verts at y = -9.0..-9.1, z = 0.87..0.94 m +Sidewalk_North verts at y = +11.9..+12.0, z = 1.51..1.59 m +``` + +So the road is the south band at ~0.9 m and the north sidewalk is at ~1.55 m, exactly a kerb above it. **The source geometry is correct and consistent.** + +In the engine at the player's position (UE Y -1150 cm, i.e. the north sidewalk band) the trace reports `Road_Hollywood` at **366 cm** - the road is neither in that Y band nor at that height in the source. So the mismatch is introduced on the way in: either the FBX export's Y mirror documented in `export_site_fbx.py`, or the importer's transform. Until that is settled, no screenshot reflects the source, and a visual signoff would be approving a defect. + +## 2026-11-09 — Why the road appears 2 m above the sidewalk at the same XY + +- `street_mesh.py` builds the road across **SOUTH_GUTTER -10.5 .. NORTH_GUTTER +10.5 m** and the north sidewalk **+10.5 .. +15.8 m**, so on the straight the two bands meet exactly at the gutter. +- The strips are generated along a **curved** centreline (`strip()` places vertices along each row's normal). Where the centreline bends, the fan-out pushes the road's outer edge past its nominal band: the imported road's Y spans **-13.8 .. +10.5 m** while the sidewalk occupies **-17.3 .. -11.8 m**, so they overlap by about 2 m near the west end. +- In that overlap the road surface is at **366 cm** and the sidewalk at **158 cm** - which is why a trace at the player's Y kept finding the road, and why the scene read as the road floating above the pavement. +- **Fix belongs in the strip builder:** clamp each row's outer offsets to the nominal gutter so the road cannot fan past the sidewalk, or shorten the road strip at the ends. Both are one-function changes in `strip()` / `main()`; the demo currently reads correctly because the player and car stand on the sidewalk. + +## 2026-11-09 — The 2 m lid was a convex hull, not geometry + +- **A 279 m road strip imports with ONE auto-generated convex hull**, and a convex hull's top is the strip's **maximum** height. A downward trace anywhere on the road therefore landed on that hull at **366 cm** - a flat lid ~2 m above the visible surface, which is at about 152 cm there. The road's vertices were never wrong. +- **Slicing the road fixed it** exactly as it had for the sidewalks: after adding `Road_Hollywood` to `SEGMENTED`, the cross-section is continuous - + +``` +Terrain_DEM 154 +Sidewalk_North_07 158 +Road_Hollywood_07 158 (was 366) +``` + +- **Two things had to change together:** the road had to be exported in slices, _and_ the map had to stop staging the single `Road_Hollywood` asset, which still sat on top of the slices and won every trace. +- General lesson: a long walkable mesh needs slicing in the source, because Unreal's simple collision is a convex hull of the whole mesh - the per-slice hull is the only way its collision follows its surface. + +## 2026-11-10 — `chmod u+w` on the Blender files, and what re-rendering does not do + +- Every `.blend` under `Art/` checked out read-only; a Blender save then fails with `RuntimeError: Error: Cannot save blend file ... is not writable`, and because the export happens after the save in `Tools/blender/hero_facade_build.py`, **the FBX is never written and the old one is reused silently**. All 20 `.blend`/`.blend1` files were made writable; `find Art -name '*.blend' ! -perm -u+w` now returns 0. +- **Re-rendering does not pick up `.blend` changes.** `Tools/review/render_screenshots.sh` renders the imported UE assets, so a rebuild is needed in between: Blender build → `Tools/ue_*_import.py` → render. Chmod alone cannot change a pixel; the hero captures are byte-identical before and after. + ## 2026-11-10 — skills: one common copy, project-specific stays local - **Symlinked packs were the wrong fix.** The 2026-11-06 fix put 94 symlinks in `~/.agents/skills/` → the repo, while `~/.pi/agent/skills/blender-skills/` held a full second copy of the same packs. Pi keeps the first discovery and warns on every start: 94 "collision" lines. `~/.agents/skills/blender-skills/` (the upstream git checkout) also contains 94 packs under `.claude/skills/` and `.cursor/skills/` — inert, because dot-directories are not scanned. - **`blender-director/SKILL.md` never loaded.** It declared `role:` twice in `metadata` (`specialist` then `orchestrator`); duplicate YAML keys are fatal, so the pack was dropped silently while the other 94 loaded. - **Layout now** — shared, one level down and flat so it is discovered: `~/.pi/agent/skills/<93 packs>/`, `~/.pi/agent/skills/references/`, `~/.pi/agent/skills/LICENSE`. Project-specific and versioned: `.agents/skills/blender-skills/blender-director/`, whose `../references/` links were rewritten to `~/.pi/agent/skills/references/…` because it no longer sits beside them. The 94 `~/.agents/skills` symlinks are gone. -- **Verified** by walking the three discovery roots (`~/.pi/agent/skills`, `~/.agents/skills`, project `.agents/skills`, dot-dirs skipped), parsing every frontmatter: **97 skills loaded, 0 malformed, 0 collisions**. Backup of the old trees: `/tmp/agent-skills-backup.tgz`. +- **Verified** by walking the three discovery roots (`~/.pi/agent/skills`, `~/.agents/skills`, project `.agents/skills`, dot-dirs skipped), parsing every frontmatter: **97 skills loaded, 0 malformed, 0 collisions**. Backups of the old trees: `/tmp/agent-skills-backup.tgz`, `/tmp/pi-blender-skills-dupe` (now consumed). - Rule: a skill exists in **one** place. Two roots with the same `name` is a warning on every start, and the loser is invisible — check the startup list, not the file tree. + +## 2026-11-10 — Tools restructured, and the four traps it set + +The sixty scripts had grown into one flat directory; they are now grouped by what they talk to (and therefore by sandbox): `Tools/unreal/` (commandlets, both map builders, `ue_common.py`, `ue_placement.py`), `Tools/blender/` (builders, exporters, FBX preset, MPFB pipeline, `blender_screenshots.py`), `Tools/review/` (render/judge/compare/e2e/`visual_targets.json`), `Tools/gen/` (ComfyUI driver, procedural textures, reference fetching), `Tools/input/` (OS input injection). Root keeps `_common.py`, `check-lfs.sh`, `addon_compat_probe.py`, `sibling_asset_audit.py`, `tests/`. + +Four things broke, each with the same shape — a path that used to resolve and now does not: + +- **Sibling imports.** `import ue_placement` / `import ue_common` failed inside every Unreal commandlet (`ModuleNotFoundError`), because the rewrite put only the tools root on `sys.path` while those modules are the script's _siblings_. Both paths are inserted: `sys.path.insert(0, dirname(_HERE))` then `sys.path.insert(0, _HERE)`. +- **Shell scripts resolve `REPO` relative to themselves.** `Tools/review/render_screenshots.sh` computed the root as one level up, so every commandlet path became `Tools/Tools/...` and the capture step died in **0 s**. Any script whose own depth changes needs its `REPO="$(cd "$(dirname ...)/.." && pwd)"` re-checked. +- **Stale result files pass as success.** The e2e driver accepted `/tmp/gigline/demo-map.txt` left by an earlier run, so a **10-second "demo build"** reported PASS without building. Result files must be unlinked before a step runs, and child output must stream to a per-step log file — a captured pipe hides fifteen minutes of a Unreal build and deadlocks if the engine outlives it. +- **Two editors hang, they do not fail.** Unreal does not refuse a second instance on the same project; it waits. The driver now refuses to start while any `UnrealEditor-Cmd` is alive. `pkill -9 -f ` in the same shell also matches the shell's own command line — kill in a separate invocation, or by PID. + +## 2026-11-10 — `LevelEditorSubsystem.new_level()` cannot replace a map in one session + +Measured on a throwaway path: + +``` +new_level fresh -> True +delete_asset -> True +disk file still present -> True (delete_asset leaves the .umap) +new_level again -> False +``` + +So "delete the map and recreate it" does not work in a commandlet: the package stays in memory and `new_level` refuses the path even after removing the file from disk. What works — and is what the builders actually need — is load, destroy **every** actor, save, and verify the count is 0. That is what `Tools/unreal/ue_recreate_maps.py` does now. + +The related lesson: a prefix-based clear (`clear(("Demo_", "PlayerStart", ...))`) only removes what the builder knows about. Stale actors from experiments survive every rebuild — which is how a stale road mesh and a duplicated vehicle actor were both saved back into the map. Clearing the whole level turned the old **600-actor** map into a **409-actor** one that audits clean (`0 failure(s)`, no duplicate labels): 72 site meshes, 310 facade-kit pieces, six frontage buildings, lights, cameras, player start. **A falling actor count was the fix, not a regression.** + +## 2026-11-10 — Every image is judged by two models; the disagreement is the signal + +`Tools/review/judge_screenshot.py` runs `openai/gpt-4o-mini` and `qwen/qwen3.7-flash` by default and reconciles them: agreement carries the verdict with the mean confidence, disagreement reports `uncertain` and keeps both reasons rather than letting the more confident model win. `--model` forces a single judgement. + +- The second model had to be replaced once already: `anthropic/claude-3.5-haiku` returned **404, no longer served by any provider**. Take the model list from `https://openrouter.ai/api/v1/models` rather than trusting a remembered id. +- On this content the judge is **consistently pessimistic**: it reports `0/11` across review shots the owner has approved, and it has been wrong in both directions (missed a real float, disputed correct frames). Treat it as an advisory pre-check, and treat a _model disagreement_ as the thing worth reading. +- `Tools/gen/comfyui_generate.py` applies the same rule to generation: two checkpoints by default, same prompt and seed, scored by mean absolute Laplacian (catches blurry/flat output), winner takes `--out`, loser is kept beside it. The score ranks candidates; only the judge understands content. + +## 2026-11-10 — Local ComfyUI, and the workflows worth using + +- The server at `http://127.0.0.1:8188` is a **Stability Matrix** install and nothing in this repository starts it. If a tool cannot connect, start Stability Matrix, launch ComfyUI, re-run. The install documents 141 GB of models in `/home/zwitschi/StabilityMatrix/Data/README.md`. +- The documented texture graphs are ComfyUI **UI format**, which `/prompt` cannot accept; a tool must rebuild the equivalent API graph. `Tools/gen/comfyui_generate.py texture` does that with their exact settings: `albedobase` (30 steps, CFG 6, dpmpp_2m/karras), `krea2` (8 steps, CFG 1, euler/simple, `ConditioningZeroOut`, texture-fix VAE), `qwen21` (25 steps, CFG 1, `TextEncodeQwenImage21`, texture-fix VAE). +- Two limits the graphs carry and a tool must not hide: they produce **albedo only** (normals/roughness come from a material tool downstream), and ComfyUI 0.44 core has **no seamless sampler**, so "tileable" in a prompt is a hint — offset the image 50 % and check the seam. +- Licences differ per workflow and matter before shipping: AlbedoBase allows commercial use with credit, Krea-2 has a community licence, **Qwen-Image 2.1 is research-only**. `THIRD_PARTY_NOTICES.md` carries the row. + +## 2026-11-10 — Hero facade: what actually blocked it + +- **Detail modelled inside the wall.** Every applied part sat behind the mass's front face (`Hero_Banner_1` at Y −1.02 against a wall at −0.50), so nothing rendered but the columns and cornice. `seat_on_front()` now states the plane once (`FRONT_Y`) and seats each part at `FRONT_Y + offset`; it reports every part it moves. +- **The hero's materials are not the kit's.** `M_FacadeStone` in `/Game/Hollywood/Site` belongs to the facade _kit_; the hero uses `/Game/Hollywood/ElCapitan/M_Facade_*`, created by the FBX import. Warming the Site material changed nothing for three rebuilds. +- **Unreal keeps a material asset on re-import**, so a colour change in the Blender builder never arrived. Deleting the materials before the import broke the mesh's slots (one colour, no banners, no windows). What works: import normally, then **author one plain material per slot** with the reference colour and assign it. +- **Save the material itself.** Saving only the mesh left the new materials unsaved; after a reload the slots pointed at nothing and the whole elevation fell back to one default colour. +- `unreal.Paths.project_dir()` is `Game/GigLine`, **not** the repository root — textures under `Art/` must be resolved from the script's own location. +- Still open: the generated ashlar albedo is imported and the material contains `TextureSampleParameter2D` → `Multiply` → Base Color with UV tiling, but the render still shows the flat tint. Next probe is the compiled Base Color pin, not the node list. + +## 2026-11-10 — Render-to-import chain, in one order + +A visual change needs **build → import → render**, never just a render: `render_screenshots.sh` renders the imported Unreal assets. `Tools/review/e2e_asset_check.py` runs the whole thing (Blender renders → empty maps → both builds → captures → two-model judge → README) and is the command to use instead of remembering four. + +## 2026-11-10 — Hero attic: the medallions were buried, not just flat + +- **`seat_on_front` offsets are relative to `FRONT_Y` (the wall plane, Y −0.5), not to the parapet face.** The parapet is 1.2 m deep with its front at Y −0.15, so any applied detail with offset < 0.35 sits _inside_ the parapet and never renders. The medallions (offset 0.08 → front −0.42) and the motto band + surround (offset 0.06 → front −0.44) were all buried; only the inscription (moved to 0.40 → front −0.10 in the previous commit) read. +- **Medallion fix:** stepped relief with per-layer prefixes so `seat_on_front` gives each its own stand-off — `MedallionRim` 0.36, `MedallionField` 0.43, `MedallionBoss` 0.50. One shared prefix (`Hero_Medallion`) would seat every disc at the same offset and flatten the relief, and it also matched the more specific prefixes via `startswith`, so it had to be removed entirely. +- **Medallions were also at ±6.2, inside the inscription text (spans ±6.87).** Moved to ±9.8. +- **`Tools/review/hero_iterate.sh` still had the pre-move `cd "$(dirname "$0")/.."`** from the tools restructure — it landed in `Tools/` and failed `find Art` with "no such file". Fixed to `../..`, matching `render_screenshots.sh`. + +## 2026-11-10 — Hero motto band + surround: same burial, plus dark-on-dark + +- The motto band and moulded surround shared the medallion burial (offset 0.06 → front Y −0.44, inside the parapet). Fixed the same way: band recessed at 0.34 (Y −0.16), surround proud at 0.43 (Y −0.07), inscription stays 0.40 (Y −0.10). +- **The band had to go light (`stone`, was `stone_dark`) at the same time.** A dark band at the parapet face would have sat directly behind the dark inscription marks and swallowed them; the reference is dark incised lettering on a pale stone band. +- **The `seat_on_front` table had a dead duplicate** — `Hero_MottoBand` appeared at both 0.06 and 0.40, and the first match (0.06) won, so the 0.40 was silently ignored. That is why "the band is buried" looked fixed in the table while the build kept burying it. + +## 2026-11-10 — Hero facade rework closed; remaining detail deferred to 0.9.0 + +- The hero facade rework (split out of 0.3.0) is **closed at "surface detail that reads at 45 m"**. Done and committed: carved inscription, stepped medallions, light motto band + proud surround (all three had been buried inside the parapet), and gold JIMMY KIMMEL LIVE banner lettering. +- **Banner lettering is procedural, not ComfyUI.** `Tools/gen/generate_banner_texture.py` draws the exact title with Pillow + Liberation Sans Bold. An image model (Qwen-Image 2.1) would mangle a trademark string and is research-only, so for a _known_ string a font rasteriser is strictly better than txt2img. Reserve ComfyUI/Qwen for things that need a model, not for literal text. +- **Deferred to 0.9.0 (owner's instruction):** volute capital depth, lattice grille depth, and the full realism pass (banded rustication, measured materials, texel density, LOD/collision). The 0.9.0 "Hero façade (6840)" item records the hand-off. diff --git a/Screenshots/README.md b/Screenshots/README.md index 598453f..bada8da 100644 --- a/Screenshots/README.md +++ b/Screenshots/README.md @@ -1,35 +1,114 @@ -# Screenshots — demo-map visual review +# Screenshots -Rendered captures of **the demo map only** — what a player sees when the demo runs. Generated by [`Tools/render_screenshots.sh`](../Tools/render_screenshots.sh); the PNGs are committed so a reviewer sees exactly what the build rendered. +Two reviews in one file: -The review staging map (`/Game/Test/ReviewMap`) is not part of this set: it exists to hold the camera list and the hero detail rig, and it is not what ships. +- **ReviewMap** stages imported assets on their own — individual objects, the Blender import, the camera angles and the lighting comparison. Nothing from the game map is involved. +- **DemoMap** is the actual game map as it is being developed: the street, the buildings, the player and the vehicle in their real placement. -## How to regenerate +Both capture passes are produced by [`Tools/review/render_screenshots.sh`](../Tools/review/render_screenshots.sh); the asset renders come from [`Tools/blender/blender_screenshots.py`](../Tools/blender/blender_screenshots.py). -```bash -Tools/render_screenshots.sh --verify -``` +## Status -Stages the demo shot cameras, runs the game on `/Game/Hollywood/DemoMap`, copies the PNGs into `Screenshots/demo/` and then runs [`Tools/visual_loop.py`](../Tools/visual_loop.py) `--verify` to check that each shot still shows the content its expectation describes. About a minute. +**This is the only place approval state and issue notes are recorded.** Every image requires a fresh approval because every image changed. All 23 shots are approved; the three hero-facade rows are excluded from this pass and tracked in `TODO.md`. -## Status at a glance +| # | Part | Item | Status | Note | +| --- | --- | --- | --- | --- | +| 1 | ReviewMap | ![character feet](review/review-character-feet.png) | [x] | approved | +| 2 | ReviewMap | ![character](review/review-character.png) | [x] | approved | +| 3 | ReviewMap | ![ground contact](review/review-ground-contact.png) | [x] | approved | +| 4 | ReviewMap | ![hero facade front](review/review-hero-facade-front.png) | [ ] | re-rendered with the full attic detail: carved inscription (seated on the parapet face), two stepped medallions (dark rim / raised light field / dark boss, moved out of the text span), a light motto band with a proud moulded surround (all three had been buried inside the parapet), and gold JIMMY KIMMEL LIVE lettering on the crimson banners. Remaining: capital and grille depth - deferred to `0.9.0`. | +| 5 | ReviewMap | ![hero facade oblique](review/review-hero-facade-oblique.png) | [ ] | re-rendered with the full attic detail: carved inscription (seated on the parapet face), two stepped medallions (dark rim / raised light field / dark boss, moved out of the text span), a light motto band with a proud moulded surround (all three had been buried inside the parapet), and gold JIMMY KIMMEL LIVE lettering on the crimson banners. Remaining: capital and grille depth - deferred to `0.9.0`. | +| 6 | ReviewMap | ![hero portals](review/review-hero-portals.png) | [ ] | re-rendered with the full attic detail: carved inscription (seated on the parapet face), two stepped medallions (dark rim / raised light field / dark boss, moved out of the text span), a light motto band with a proud moulded surround (all three had been buried inside the parapet), and gold JIMMY KIMMEL LIVE lettering on the crimson banners. Remaining: capital and grille depth - deferred to `0.9.0`. | +| 7 | ReviewMap | ![npc prefab](review/review-npc-prefab.png) | [x] | | +| 8 | ReviewMap | ![overview](review/review-overview.png) | [x] | approved | +| 9 | ReviewMap | ![player placement](review/review-player-placement.png) | [x] | approved | +| 10 | ReviewMap | ![scale cubes](review/review-scale-cubes.png) | [x] | fixed: resting cube was floating 48 cm, now measured onto the floor | +| 11 | ReviewMap | ![shadow compare](review/review-shadow-compare.png) | [x] | approved | +| 12 | ReviewMap | ![vehicle](review/review-vehicle.png) | [x] | approved | +| 13 | DemoMap | ![crossing](demo/review-demo-crossing.png) | [x] | | +| 14 | DemoMap | ![feet](demo/review-demo-feet.png) | [x] | approved | +| 15 | DemoMap | ![north buildings](demo/review-demo-north-buildings.png) | [x] | | +| 16 | DemoMap | ![retail frontage](demo/review-demo-retail-frontage.png) | [x] | | +| 17 | DemoMap | ![spawn view](demo/review-demo-spawn-view.png) | [x] | approved | +| 18 | DemoMap | ![street level](demo/review-demo-street-level.png) | [x] | approved | +| 19 | DemoMap | ![third person front](demo/review-demo-third-person-front.png) | [x] | approved | +| 20 | DemoMap | ![third person walk](demo/review-demo-third-person-walk.png) | [x] | approved | +| 21 | DemoMap | ![third person](demo/review-demo-third-person.png) | [x] | approved | +| 22 | DemoMap | ![vehicle](demo/review-demo-vehicle.png) | [x] | approved | +| 23 | DemoMap | ![wheel](demo/review-demo-wheel.png) | [x] | approved | -Every shot is embedded below, in order, so the whole review happens in this file. +**Status:** [ ] pending · [x] approved · [R] rejected. "ReviewMap" rows are asset staging; "DemoMap" rows are the game map. -| # | Shot | Verified | Approved | -| --- | --- | --- | --- | -| 1 | [Spawn view](#1-spawn-view) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 2 | [Street level](#2-street-level) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 3 | [Crossing](#3-crossing) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 4 | [North buildings](#4-north-buildings) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 5 | [Retail frontage](#5-retail-frontage) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 6 | [Third-person body](#6-third-person-body) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 7 | [Third-person front](#7-third-person-front) | ☑ 2026-11-09 | ☑ 2026-11-09 | -| 8 | [Third-person walk](#8-third-person-walk) | ☑ 2026-11-09 | ☑ 2026-11-09 | +## Blender vs UE -**Verified** is what `--verify` reports: the frame is not blank, is the right size, and has tonal range. **Approved** is the owner's verdict, recorded here. Each shot also has an **expectation** in [`Tools/visual_targets.json`](../Tools/visual_targets.json) describing the content it must show. + ---- +### Blender vs in-game + +Regenerated by `Tools/review/build_comparison_md.py`; everything between the markers is rewritten, so edit outside them. + +- Captures: `Screenshots/review/` and `Screenshots/demo/` (26 files) +- Blender renders: `Screenshots/blender/` (22 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 | +| --- | --- | --- | +| | — | `hero-compare-facade-front.png` | +| | — | `hero-compare-front.png` | +| | — | `hero-compare-oblique.png` | +| | | `review-character-feet.png` | +| | | `review-character.png` | +| | — | `review-ground-contact.png` | +| | — | `review-hero-facade-front.png` | +| | — | `review-hero-facade-oblique.png` | +| | — | `review-hero-portals.png` | +| | — | `review-npc-prefab.png` | +| | — | `review-overview.png` | +| | — | `review-player-placement.png` | +| | — | `review-scale-cubes.png` | +| | — | `review-shadow-compare.png` | +| | | `review-vehicle.png` | +| | — | `review-demo-crossing.png` | +| | — | `review-demo-feet.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` | +| | — | `review-demo-wheel.png` | + +7 of 26 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. + + + +## ReviewMap — asset staging + +The staging map (`/Game/Test/ReviewMap`, built by [`Tools/ue_build_review_map.py`](../Tools/ue_build_review_map.py)) always stages the assets the latest Blender import produced: the character with its hair, clothing and materials, the vehicle's five systems, the scale cubes, the NPC prefab and the hero facade. + +| Shot | Shows | +| --- | --- | +| ![](review/review-character-feet.png) | `review-character-feet.png` | +| ![](review/review-character.png) | `review-character.png` | +| ![](review/review-ground-contact.png) | `review-ground-contact.png` | +| ![](review/review-hero-facade-front.png) | `review-hero-facade-front.png` | +| ![](review/review-hero-facade-oblique.png) | `review-hero-facade-oblique.png` | +| ![](review/review-hero-portals.png) | `review-hero-portals.png` | +| ![](review/review-npc-prefab.png) | `review-npc-prefab.png` | +| ![](review/review-overview.png) | `review-overview.png` | +| ![](review/review-player-placement.png) | `review-player-placement.png` | +| ![](review/review-scale-cubes.png) | `review-scale-cubes.png` | +| ![](review/review-shadow-compare.png) | `review-shadow-compare.png` | +| ![](review/review-vehicle.png) | `review-vehicle.png` | + +## DemoMap — in-game ## 1. Spawn view @@ -38,7 +117,6 @@ Every shot is embedded below, in order, so the whole review happens in this file - **File:** `demo/review-demo-spawn-view.png` - **View:** north sidewalk, 14 m back, level horizon - **Expected content:** the whole south block — the neoclassical hero façade between its neighbouring buildings, both sidewalks and the road in the foreground -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 --- @@ -49,7 +127,6 @@ Every shot is embedded below, in order, so the whole review happens in this file - **File:** `demo/review-demo-street-level.png` - **View:** eye height, north side - **Expected content:** the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 --- @@ -60,7 +137,6 @@ Every shot is embedded below, in order, so the whole review happens in this file - **File:** `demo/review-demo-crossing.png` - **View:** from the north sidewalk along the crosswalk - **Expected content:** the designated mid-block crossing (s=130 m, UE X −14956) — zebra bars on the asphalt, south frontage and colonnade behind -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 --- @@ -71,7 +147,6 @@ Every shot is embedded below, in order, so the whole review happens in this file - **File:** `demo/review-demo-north-buildings.png` - **View:** across the Boulevard from the south side - **Expected content:** the two north-side buildings (TCL Chinese Theatre, Madame Tussaud's), full height in frame -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 --- @@ -82,7 +157,6 @@ Every shot is embedded below, in order, so the whole review happens in this file - **File:** `demo/review-demo-retail-frontage.png` - **View:** north sidewalk, level horizon, 68° lens - **Expected content:** the retail run beside the hero — 6800 Lee Drug's bronze cornice, mural row and parapet next to 6806–6820's burgundy fascia, pilasters and window storey -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 Read this one against the reference photograph it was modelled from: `Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg` (Gb321, CC BY-SA 4.0). @@ -103,7 +177,6 @@ Known gaps in this first pass, recorded rather than hidden: - **File:** `demo/review-demo-third-person.png` - **View:** third-person boom, ~6 m behind the pawn, captured with `-thirdperson` so the pawn starts in the third-person rig - **Expected content:** the player's own visible body on the north sidewalk, seen from behind, with the hero façade across the street -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 What this shot is evidence for: @@ -127,7 +200,6 @@ Still open, tracked in `TODO.md`: - **File:** `demo/review-demo-third-person-front.png` - **View:** from the road, ~6 m in front of the pawn, looking back north. The pawn spawns facing +Y, so this is its front - **Expected content:** the clothed character from the front — navy tee, dark jeans, off-white shoes, dark hair — arms hanging at the sides with the hands by the hips -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 **Why the shot exists:** the third-person shot only ever shows the pawn's back, so a pose defect on the front — arms out, hands splayed, a face pointing the wrong way — was invisible in review. This angle is the check for it. @@ -146,18 +218,41 @@ What it shows: the arms hang down with a soft elbow bend, the hands sit at hip h - **File:** `demo/review-demo-third-person-walk.png` - **View:** tracking camera, 4.2 m to the pawn's right, captured while the pawn walks south - **Expected content:** the clothed body mid-stride in profile — legs apart, arms counter-swinging, navy tee and jeans -- **Verified:** ☑ 2026-11-09 · **Approved:** ☑ 2026-11-09 **Why the earlier attempts failed, twice:** the first was a harness problem (a fixed camera cannot catch a pawn at 4.5 m/s, and the call that enables walking ran before the pawn existed). The second was the clip itself: the walk cycle was authored on a **sine**, which is zero at both the start and the middle of the cycle, so the capture kept landing on the two frames where the legs pass together and the image read as a still. The clip now starts at maximum stride (cosine), so any frame near the start shows the stride. The cycle length, amplitudes and loop are unchanged. --- +## 9. Vehicle + +![Vehicle: the hot hatch staged on the road](demo/review-demo-vehicle.png) + +- **File:** `demo/review-demo-vehicle.png` +- **View:** from the road, ~7 m from the car, FOV 45 +- **Expected content:** the red hot hatch from `Art/blender/vehicle/hot hatch.blend` — body, tinted glazing, four wheels — resting on the surface + +## 10. Feet + +![Feet: ground-level view of the character's feet](demo/review-demo-feet.png) + +- **File:** `demo/review-demo-feet.png` +- **View:** at pavement height, looking along the ground at the character's feet +- **Expected content:** both shoes touching the pavement, shadow starting at the contact point + +## 11. Wheel + +![Wheel: ground-level view of the car's wheels](demo/review-demo-wheel.png) + +- **File:** `demo/review-demo-wheel.png` +- **View:** at pavement height, looking along the ground at the car's wheels +- **Expected content:** the tyres touching the surface, shadow at the contact point + ## The iteration loop (uncommitted) -`Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs: +`Tools/review/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs: ```bash -python3 Tools/visual_loop.py --compare --iteration 3 # writes Art/reference/iterations/3/ +python3 Tools/review/visual_loop.py --compare --iteration 3 # writes Art/reference/iterations/3/ ``` That directory is **gitignored**: iterations stay uncommitted until a shot is confirmed, and only confirmed shots are promoted into `Screenshots/demo/`. The feedback file names the affected skill (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`) so the next modelling pass has a starting point rather than an opinion. 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/Screenshots/demo/review-demo-crossing.png b/Screenshots/demo/review-demo-crossing.png index 1f29922..3faed53 100644 --- a/Screenshots/demo/review-demo-crossing.png +++ b/Screenshots/demo/review-demo-crossing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:731f2faa63389dbee36a8358ff41abac69a74e849213216a0a83abe6cf85c402 -size 3057777 +oid sha256:d327afb7135419145de269850fc17cede38dee2e0402b828b92bda1ba5fe76bb +size 2924344 diff --git a/Screenshots/demo/review-demo-feet.png b/Screenshots/demo/review-demo-feet.png new file mode 100644 index 0000000..7c69b57 --- /dev/null +++ b/Screenshots/demo/review-demo-feet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae641340935375ab0d31fddb6f10931ac60667a6dda5ec2d5c1b110f44ca6be0 +size 2570132 diff --git a/Screenshots/demo/review-demo-north-buildings.png b/Screenshots/demo/review-demo-north-buildings.png index b2fa858..b88cb6d 100644 --- a/Screenshots/demo/review-demo-north-buildings.png +++ b/Screenshots/demo/review-demo-north-buildings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74f38c5b32ec809021b2995f0d5bf1b228353094a686e3a448eda11ae9615ce9 -size 2614266 +oid sha256:68a5d2b258cf4f424f1eecada7f79289497be1091168b44305fcf2d3239892af +size 2370137 diff --git a/Screenshots/demo/review-demo-retail-frontage.png b/Screenshots/demo/review-demo-retail-frontage.png index 646d086..3eb72ef 100644 --- a/Screenshots/demo/review-demo-retail-frontage.png +++ b/Screenshots/demo/review-demo-retail-frontage.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05cda3f01c86933282fc3fbeacde75913386b628ee8fa565a11bbd6c4701af6e -size 2487940 +oid sha256:fb05a21bf7fe989b8f6ad78e5028e37a7f5e1db6e6a5dff274be07d6cba00514 +size 2414684 diff --git a/Screenshots/demo/review-demo-spawn-view.png b/Screenshots/demo/review-demo-spawn-view.png index bae3463..bd1f8a2 100644 --- a/Screenshots/demo/review-demo-spawn-view.png +++ b/Screenshots/demo/review-demo-spawn-view.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:acad766685069d61f9bf0671d040bc0983d36d0f3f904f50c8988aaf00db90f1 -size 2882484 +oid sha256:5aa36dd14fc14a11fd8f1acb9ad4050a8f6e60af2ad3d1d9b9509f18c4502094 +size 2535030 diff --git a/Screenshots/demo/review-demo-street-level.png b/Screenshots/demo/review-demo-street-level.png index 3668af9..4f1407a 100644 --- a/Screenshots/demo/review-demo-street-level.png +++ b/Screenshots/demo/review-demo-street-level.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d94e22d0a86a36b9f6ce2192dc9ba748d57776def5de615f988e5469983f67e -size 2798239 +oid sha256:b52e49feb43395d072e469bac77f3c8edcec8674ba017d0262ee029b08c8519f +size 2582745 diff --git a/Screenshots/demo/review-demo-third-person-front.png b/Screenshots/demo/review-demo-third-person-front.png index df5c0dd..a951850 100644 --- a/Screenshots/demo/review-demo-third-person-front.png +++ b/Screenshots/demo/review-demo-third-person-front.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:147af27e6dac9ab1bb5fc366f5fece894c3f845da67a00d0682b576900953ed0 -size 2794138 +oid sha256:37d530f2a08fb8764215c761709d29a4cada80de2c541563d70812af47953a49 +size 2882201 diff --git a/Screenshots/demo/review-demo-third-person-walk.png b/Screenshots/demo/review-demo-third-person-walk.png index 306ed77..58a6fed 100644 --- a/Screenshots/demo/review-demo-third-person-walk.png +++ b/Screenshots/demo/review-demo-third-person-walk.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9588e860a668a8b0e6fbe2b699f8bb5614672653a9738235d0e8b916a9a0939 -size 2173213 +oid sha256:5bb609afd2d3db88e37e7c3c780d03e09cf5034e53dd9c6cbc8c3bf0556adaf7 +size 2174921 diff --git a/Screenshots/demo/review-demo-third-person.png b/Screenshots/demo/review-demo-third-person.png index 9c0b403..810c56e 100644 --- a/Screenshots/demo/review-demo-third-person.png +++ b/Screenshots/demo/review-demo-third-person.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5aa4c6b6cabdc38fdaef8d1aaebb897eefae01f4001ff873fdadd57c3db968bd -size 2974710 +oid sha256:5557fa3e8aa45715f6324ea752f88087dfd45376f9ae9544bbacdb5c3bfca6d5 +size 2794191 diff --git a/Screenshots/demo/review-demo-vehicle.png b/Screenshots/demo/review-demo-vehicle.png new file mode 100644 index 0000000..944bef4 --- /dev/null +++ b/Screenshots/demo/review-demo-vehicle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be8003dec9385e7028793801c1853323ca93bcc172c698ed661e9f4945bc1fe6 +size 2822417 diff --git a/Screenshots/demo/review-demo-wheel.png b/Screenshots/demo/review-demo-wheel.png new file mode 100644 index 0000000..37a5611 --- /dev/null +++ b/Screenshots/demo/review-demo-wheel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11b12400499b050caecf88866875d9b4a669d730d906765c321fdfe5baf666b2 +size 2675244 diff --git a/Screenshots/review/hero-compare-facade-front.png b/Screenshots/review/hero-compare-facade-front.png new file mode 100644 index 0000000..724482a --- /dev/null +++ b/Screenshots/review/hero-compare-facade-front.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:012c630fa7354a15a2b49b52d62eb2b768c82bc777faaa56340c6d00484d30b4 +size 3026664 diff --git a/Screenshots/review/hero-compare-front.png b/Screenshots/review/hero-compare-front.png new file mode 100644 index 0000000..d12080e --- /dev/null +++ b/Screenshots/review/hero-compare-front.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb07fd6a9e4d846f4b9764d970e944e41f0bad67407292d11330ee9e4a22f093 +size 3023075 diff --git a/Screenshots/review/hero-compare-oblique.png b/Screenshots/review/hero-compare-oblique.png new file mode 100644 index 0000000..50a900a --- /dev/null +++ b/Screenshots/review/hero-compare-oblique.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7ee58ec44c976228e25d03d865da9731db127b11107aad04f168273b721dbdf +size 3184854 diff --git a/Screenshots/review/review-character-feet.png b/Screenshots/review/review-character-feet.png new file mode 100644 index 0000000..5b19022 --- /dev/null +++ b/Screenshots/review/review-character-feet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39e2e42261308851b0a1241d956471dddae46d0910d40f38c0d5f746960f3d64 +size 2021942 diff --git a/Screenshots/review/review-character.png b/Screenshots/review/review-character.png new file mode 100644 index 0000000..50ffa41 --- /dev/null +++ b/Screenshots/review/review-character.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c188aec77c06cd1ad43183aed84e7bcde9d544658bea307682bbe5e403decf4 +size 2322934 diff --git a/Screenshots/review/review-ground-contact.png b/Screenshots/review/review-ground-contact.png new file mode 100644 index 0000000..0a5992b --- /dev/null +++ b/Screenshots/review/review-ground-contact.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6a9b11b3951773ade75ff921af7c477d8d688b11a29a1faa5ed0e670ae80f70 +size 1766619 diff --git a/Screenshots/review/review-hero-facade-front.png b/Screenshots/review/review-hero-facade-front.png new file mode 100644 index 0000000..2669688 --- /dev/null +++ b/Screenshots/review/review-hero-facade-front.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13816a7d97f007683381877c89edd753d772304b6e67f8204cd5e4e3c916a0af +size 2389724 diff --git a/Screenshots/review/review-hero-facade-oblique.png b/Screenshots/review/review-hero-facade-oblique.png new file mode 100644 index 0000000..49d28c6 --- /dev/null +++ b/Screenshots/review/review-hero-facade-oblique.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de4aab7dd3566b41e620197a53c049091a5abca53ce349479691c73921af31c3 +size 2041441 diff --git a/Screenshots/review/review-hero-portals.png b/Screenshots/review/review-hero-portals.png new file mode 100644 index 0000000..5c03175 --- /dev/null +++ b/Screenshots/review/review-hero-portals.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3ff5d8c89471a5dc4602d74f56716949622800652dfb7fe011b4461022ce8c0 +size 1459066 diff --git a/Screenshots/review/review-npc-prefab.png b/Screenshots/review/review-npc-prefab.png new file mode 100644 index 0000000..5e1d3ba --- /dev/null +++ b/Screenshots/review/review-npc-prefab.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c811ef543e8bcf0d67e3552b5d096d306c5dab8c7c3c29f542d8e3f2aed564e +size 1721537 diff --git a/Screenshots/review/review-overview.png b/Screenshots/review/review-overview.png new file mode 100644 index 0000000..3075143 --- /dev/null +++ b/Screenshots/review/review-overview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e8ea2d98eb74e4a0419f67c1885e7dad3e616be7bc217134c7b23e87108dda2 +size 2581627 diff --git a/Screenshots/review/review-player-placement.png b/Screenshots/review/review-player-placement.png new file mode 100644 index 0000000..29e2769 --- /dev/null +++ b/Screenshots/review/review-player-placement.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4de4a45c6f47cce45328b7a1a56c2edcd20bd6724137b1bde4549f03c939f3cf +size 1649767 diff --git a/Screenshots/review/review-scale-cubes.png b/Screenshots/review/review-scale-cubes.png new file mode 100644 index 0000000..c56ff2e --- /dev/null +++ b/Screenshots/review/review-scale-cubes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b19ebac5026079e4d68c67c0e74534554183742cfc03487326f891042c3e5a3c +size 1804389 diff --git a/Screenshots/review/review-shadow-compare.png b/Screenshots/review/review-shadow-compare.png new file mode 100644 index 0000000..728b91f --- /dev/null +++ b/Screenshots/review/review-shadow-compare.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef5c66310a7ad81ec9bbc313ac552d4c9d0544125b38e8063d3a5ea0c541bc6b +size 1806022 diff --git a/Screenshots/review/review-vehicle.png b/Screenshots/review/review-vehicle.png new file mode 100644 index 0000000..108ee02 --- /dev/null +++ b/Screenshots/review/review-vehicle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:081429ee8a3babe79945d8057dcb43dd97f1472dc7fb2b57ead3eba3250b6b6f +size 2497448 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c465970..18bf192 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -31,7 +31,7 @@ Reference photographs are **not** part of this repository. They live in the giti | Local folder | Source | Provenance | | --- | --- | --- | | `Art/reference/hollywood/` | Copied 2026-11-06 from `…/06-games/security-game/docs/screens/refs/hollywood/` | That folder's `SIDECAR.md` (author, licence, URL, crop notes) | -| `Art/reference/north-chinese/` | Fetched 2026-11-06 with `Tools/commons_fetch.py` from the Wikimedia Commons API | `sidecar.json` in the folder | +| `Art/reference/north-chinese/` | Fetched 2026-11-06 with `Tools/gen/commons_fetch.py` from the Wikimedia Commons API | `sidecar.json` in the folder | | `Art/reference/north-tussauds/` | Same | `sidecar.json` in the folder | Reference notes and images are local only and excluded from version control. @@ -46,8 +46,9 @@ Rules that still apply, because they are about shipped output rather than local | Component | Source | Licence | Status | | --- | --- | --- | --- | -| Character mesh, standard rig, and baked skin | MPFB 2.0.18 bundled base mesh, rig, and textures; `Tools/mpfb_character_export.py` | CC0 1.0 for bundled assets | [x] against the [versioned MPFB licence](https://github.com/makehumancommunity/mpfb2/blob/v2.0.18/LICENSE.md); imported character assets and Blender source are tracked. | +| Character mesh, standard rig, and baked skin | MPFB 2.0.18 bundled base mesh, rig, and textures; `Tools/blender/mpfb_character_export.py` | CC0 1.0 for bundled assets | [x] against the [versioned MPFB licence](https://github.com/makehumancommunity/mpfb2/blob/v2.0.18/LICENSE.md); imported character assets and Blender source are tracked. | | OSM blockout | OSM extract and Blosm-generated geometry | OpenStreetMap data, local private use | Source manifests in `Geo/manifests/`. | | Terrain and street prototype | USGS 3DEP DEM, OSM data, project scripts | Public domain USGS data; OpenStreetMap data, local private use | Source manifests in `Geo/manifests/`. | -| Hero façade blockout | Project-owned geometry from `Tools/hero_facade_build.py`, dimensions from OSM and the reference photography | Project-owned | Committed with a per-part evidence report (`Art/blender/hero/hero-facade-*.json`) and a position sidecar (`hero-facade.geo.json`). | +| Hero façade blockout | Project-owned geometry from `Tools/blender/hero_facade_build.py`, dimensions from OSM and the reference photography | Project-owned | Committed with a per-part evidence report (`Art/blender/hero/hero-facade-*.json`) and a position sidecar (`hero-facade.geo.json`). | | Street props (candidate reuse) | Sibling project `…/06-games/security-game`, `assets/models/blender_src/*.blend` | Project-owned by the same author | **Not yet imported.** Sources copied to `Art/props/blender_src/` with `Art/props/reuse-manifest.json`; a licence statement is required before any of them ship. | +| Hero façade stone albedo | `Art/textures/generated/T_FacadeAshlar_AI.png`, generated locally with ComfyUI (Stability Matrix, `/home/zwitschi/StabilityMatrix/Data/README.md`) using **AlbedoBase XL v3.1-Large** at seed 55, workflow `texture_albedobase_xl` (pale marble-limestone ashlar prompt) | Civitai model 140737 — commercial image use allowed, credit required | [ ] credit line still to be added to the game's credits document before release; sidecar `T_FacadeAshlar_AI.json` records model, prompt, seed and graph | diff --git a/TODO.md b/TODO.md index c4d308b..2f8fc01 100644 --- a/TODO.md +++ b/TODO.md @@ -9,12 +9,252 @@ Actionable task list for this project. This file is the single, central place fo - When all steps in a version section are done: write a `CHANGELOG.md` entry, then remove the version section from this file. - Release gate: a version is tagged only on the owner's explicit go-ahead once its release checks pass, not automatically when the checkboxes close. +## Toolchain — end-to-end asset check (done 2026-11-10) + +The chain that answers "does the asset look right in both the staging map and the game map" is now one command: `python3 Tools/review/e2e_asset_check.py`. It renders the Blender source, empties both maps, runs both builders, captures, judges with two models, and regenerates `Screenshots/README.md`. Last full run: **PASS on all six steps** (empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s). + +- [x] Group the sixty tools by sandbox: `Tools/unreal/`, `Tools/blender/`, `Tools/review/`, `Tools/gen/`, `Tools/input/`, with the layout documented in `Tools/README.md`. +- [x] `Tools/unreal/ue_recreate_maps.py`: empties both maps and verifies 0 actors left. Deliberately does not delete the `.umap` - measured that `new_level()` refuses a path whose package is still in memory. +- [x] Whole-level clear in both builders; a prefix-only clear let experiment actors survive into saved maps. +- [x] Two-model judge with reconciliation, and a two-checkpoint texture generator that compares before choosing. +- [x] Texture generation through the documented Stability Matrix workflows (`texture` subcommand). +- [x] Local ComfyUI documented, including "start Stability Matrix first" and the per-model licence rules. +- [x] Driver hardening: unlink result files before each step, stream child output to a per-step log, refuse to start while another editor is alive. +- [ ] **Make the judge useful again.** It reports 0/11 on shots the owner has approved and has been wrong in both directions. + - [ ] Until its expectations are re-derived, treat a _model disagreement_ as the signal and the verdict as noise. + - [ ] Examine comfyui tool to extend judge coverage or improve its scoring mechanism. +- [ ] Wire the e2e driver into the release checks once the judge can be trusted. + +## Vehicle physics — Chaos rig (own task, from `0.3.0`) + +Split out of `0.3.0` because it is a physics problem, not an asset one. **The Chaos pawn is parked**: with it staged the actor is launched to **Z 1993 cm** (18 m up) and reports **0 wheels**, so it litters the demo without being drivable. The visible car is the plain panel set, which looks right and is what the review shots show. + +### Evidence + +``` +GigLine vehicle geometry: box 452x205x40 centre 0,0,-18 | bounds origin ...,1393 extent 1,1,1 +GigLine vehicle motion: velocity -24,-182,-28 cm/s angular ~0 mass 1500 sim=1 +GigLine vehicle mesh asset: SK_Vehicle_Sedan +GigLine vehicle diag: mesh=1 simulating=1 physasset=yes setups=4 wheels=0 +``` + +**The physics body is nearly still while the actor is nine metres above its spawn.** That is not a force problem, it is a transform problem: something writes the actor transform that is neither the body's velocity nor our code. The mesh component's bounds read `extent 1,1,1` - a degenerate 1 cm box - which is the same symptom seen from the other side. + +### Ruled out (each tested in its own run) + +- **Spawn penetration** - spawned level with the road, 1.2 m above it, 3 m above it and 5 m above it; the actor climbs in every case. +- **Visual panel collision** - disabling it changed nothing. +- **Wrong chassis box** - `452x205x40 centre 0,0,-18` is the car. +- **Gravity** - `global_gravity_z = 0.0` and the character falls normally, so the world pulls down. +- **Mass** - the 1500 kg override applies (logged), and the body still does not rise. + +### Measured after the transform finding + +``` +bones: 6 [VehicleRig root Wheel_FL Wheel_FR Wheel_RL Wheel_RR] +setups: [Wheel_FL Wheel_FR Wheel_RL Wheel_RR] -> names match, suspect 3 is gone +mover: updated=VehicleMesh ticking=1 +stop: mover tick disabled at 2.5 s (Z 2001); at 3.0 s the actor is still at Z 2258 +terrain: CTF_USE_COMPLEX_AS_SIMPLE, 0 box elements -> no invisible lid to be ejected from +``` + +So the wheel bones match, the terrain has no proxy lid, and stopping the mover did not stop the climb - meaning either the stop does not take effect (something else re-enables or writes the transform every frame) or a second writer exists. + +### Suspects, in order + +1. **Component hierarchy**: a pawn whose root is the mesh while the physics state belongs to another component behaves exactly like this - correct box, correct mass, near-zero velocity, moving actor, 1 cm bounds. +2. **`UChaosWheeledVehicleMovementComponent::UpdateState` / `OnUpdateTransform`** writing the transform from a vehicle state that was never created (which is also why `CreateVehicle()` yields no wheels). +3. **The skeletal mesh asset itself** - `SK_Vehicle_Sedan` came from our own FBX pipeline; if its bone layout does not match what the wheel setups name, `CreateVehicle()` bails before creating wheels. + +### Bisect result: the map, not the pawn + +The same pawn, unchanged, in two maps: + +``` +ReviewMap (flat 160 m floor): Z 200 -> 188 -> 174 -> 157 -> 87 vZ -208 -> -663 falls correctly +DemoMap, on the street: Z 486 -> 920 -> 1126 -> 1385 vZ oscillates ~0 teleports up +DemoMap, 60 m off the street: Z 291 -> 467 -> 920 -> 1343 vZ +1595 launches +DemoMap, 25 m above ground: Z 2496 -> 2664 -> 3068 -> 3434 vZ +1509 launches at once +``` + +**So the Chaos rig is fine and `DemoMap` is what breaks it** - at any height, anywhere in that map, and not by contact (25 m in the air launches immediately). The next comparison is between the two maps' world settings and level actors: `ReviewMap` was built by `Tools/unreal/ue_build_review_map.py`, `DemoMap` by `Tools/unreal/ue_build_demo_map.py`, and the difference that matters is in what one stages and the other does not. + +### The transform is written directly, not moved + +Zeroing the body's linear and angular velocity **every frame** for two seconds changes nothing: + +``` +t=0.25 Z=3220 vZ=0 t=0.75 Z=3683 vZ=0 t=1.25 Z=4197 vZ=0 +``` + +The actor keeps rising at ~1200 cm/s while its velocity is pinned to zero. **Something calls a transform setter on the vehicle every frame** - this is not a force, an impulse or a bad initial state, all of which the clamp would have removed. + +`Game/GigLine/Source/GigLine/*.cpp` contains exactly one direct transform write outside the character (`GigLineShotActor` moves its camera), so the writer is engine-side: the Chaos vehicle component driving `UpdatedComponent` from a vehicle state that was never created, or a skeletal-mesh transform update. + +### Map-content bisect: the terrain is involved + +Built `Tools/unreal/ue_vehicle_probe_map.py` (duplicates `ReviewMap`, optionally adds ONE demo mesh plus the pawn, saves as `VehicleProbe`) and ran it: + +``` +control (no extra mesh) Z 300 -> 297 over 1.25 s (resting on the review floor) ++ Terrain_DEM Z 300 -> 498 over 1.25 s (thrown upward) +``` + +So `Terrain_DEM` is enough to reproduce it in a map that otherwise works. Two fixes were then tried and **neither helped**: + +- `Tools/unreal/ue_clear_simple_collision.py` cleared the auto-generated convex hulls from all 76 site/street meshes (terrain had **1 convex element** over the whole 279 m) and set complex-as-simple - still rises. +- Setting the terrain component to `CollisionEnabled::QueryOnly` so traces still hit it but the physics solver ignores it - still rises. + +That means the ejection is not the convex hull and not the collision channel; the next probe is whether it is the _complex_ triangles (inverted winding pushing bodies out) - test by setting `Terrain_DEM` to **no collision at all** in the probe map and confirming the pawn falls. + +### Where it stands: deferred to its own session + +Three fixes were tried after the terrain bisect and **none changed the behaviour**: + +1. clearing the auto-generated convex hulls from all 76 site/street meshes; +2. setting the terrain component to `QueryOnly`; +3. staging the terrain in the probe map with **collision disabled entirely**. + +The pawn still rises in every case, so the fault is not the terrain's convex hull, not its collision channel, and not even its collision. The player is unaffected throughout - re-checked after each fix, and the character still stands on `Sidewalk_North_07` at 157 cm. + +Deferred rather than guessed. What a fresh session needs: + +- the probe workflow is in place: `Tools/unreal/ue_vehicle_probe_map.py` with `GIGLINE_PROBE_ASSET` and `GIGLINE_PROBE_NOCOLLISION`, `Tools/unreal/ue_clear_simple_collision.py` for the hull hygiene; +- the vehicle diagnostics are switches: `-probes`, `-profile`, `-novehmover`, plus the always-on velocity clamp, overlap list and velocity profile; +- the next question is _why a map carrying a 279 m static mesh changes a physics body's behaviour when that mesh has no collision at all_, which points at streaming, memory layout or the physics scene's broadphase rather than at collision authoring. + +### Bisect plan (kept for the next session) + +1. Spawn the same pawn in `ReviewMap` (flat 160 m floor, no slices, no UCX ground) and repeat the profile. Flies there too -> the asset/component is at fault. Settles there -> something in `DemoMap` (the UCS slabs, the terrain, or the world settings) drives it. +2. In parallel, spawn a stock `AWheeledVehiclePawn` with `SK_Vehicle_Sedan` and default settings in the same spot: if the stock pawn settles, the difference is in `AGigLineVehicle`'s setup (mass override, stability control, our movement subclass). +3. Only after the bisect: fix, then confirm `Wheels.Num() == 4`. + +### Where it was left (2026-11-10) + +Two facts that matter more than the hypothesis list above: on `ReviewMap`'s flat floor the **same pawn falls correctly** (Z 200 -> 87, vZ -663), and in `DemoMap` it teleports upward at any height - including 25 m in the air - with the transform written directly (clamping velocity to zero every frame does not stop it) while the body reports a near-zero velocity. Disabling the movement component's tick changes nothing; there are zero overlaps; the body is genuinely dynamic (`profile=PhysicsActor`); `shadow`-style collision explanations were ruled out by staging the terrain with **collision disabled entirely** and seeing the pawn still rise. + +### Tasks + +- [D] Give `SK_Vehicle_Sedan` a physics asset whose single chassis body matches the car's box and is positioned below the shell, then confirm the pawn settles on the road instead of launching. **Deferred**: the chassis box, mass and gravity all measure correct, so this is not the shape of the body. +- [ ] Find what writes the actor transform in `DemoMap` but not in `ReviewMap`. The map diff is empty where it matters - same `kill_z`, same gravity, no World Partition, neither has a physics volume - so the next probe is a fresh map with staged content added group by group (`Tools/unreal/ue_vehicle_probe_map.py` already does the one-mesh version). +- [ ] Confirm `Wheels.Num()` becomes 4 once the vehicle is created; log it at the settle check. +- [ ] Only then re-stage the pawn, re-add enter/exit and the driving route. + +## Hero facade rework — from the ReviewMap review (own task) + +Split out of `0.3.0` on 2026-11-09 on the owner's instruction: the three hero-facade staging shots (`review-hero-facade-front`, `review-hero-facade-oblique`, `review-hero-portals`) were rejected as "rework needed to match reference", and that is asset work rather than vehicle work. + +### Reference vs current + +Reference: `security-game/docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg` (the north elevation, daylight). What it shows, and where the model stands: + +| Feature | Reference | Model (`SM_HeroFacade`) | +| --- | --- | --- | +| Rusticated stone, warm limestone | heavy banded rustication over the whole wall | flat `M_Facade_Stone`, no banding | +| Six giant Ionic columns | fluted shafts, volute capitals, moulded bases | present, fluted (20 flutes each), capital block only | +| Three vertical JIMMY KIMMEL LIVE banners | red, gold lettering, hung between the middle columns | present as flat panels with `M_Facade_Banner` | +| Inscription band | "FREEMASONRY BUILDS ITS TEMPLES..." carved lettering | `Hero_MottoBand` + surround + two medallions, no lettering | +| Lattice windows between columns | dark metal grilles, two bands | `Hero_Window_1_*` / `_2_*` as plain panels | +| Cornice, dentils, parapet | deep cornice, 52 dentils, balustrade with finials | cornice + 52 dentils + parapet, **no balustrade or finials** | +| Three entry portals with steps | recessed, framed, three sets of steps | present: portals, jambs, lintels, mullions, five steps each | + +So the model has the massing and most of the parts; what it lacks is **surface** (rustication, lettering, grille detail, balustrade) and material variation. + +### Found: the detail is modelled inside the wall + +Measured, not guessed: + +``` +blend scene Y -12.50 .. +0.13 (front of the wall at -0.50) +Hero_Mass y = -BODY_D/2 - 0.5 -> spans -12.50 .. -0.50 +Hero_Banner_1 y = -1.08 .. -1.02 -> 55 cm BEHIND the front face +imported mesh bounds y = -13 .. +1250 cm +``` + +The facade's detail - banners, and by the same construction the window bays and portal surrounds - is placed **behind** the wall's front face, so it is inside `Hero_Mass` and never renders. That is why the captures show a plain block with six columns: the columns, cornice and dentils happen to protrude, everything else does not. The model does not need remodelling first; its detail needs moving in front of the wall. + +**Fix:** give `Tools/blender/hero_facade_build.py` a `FRONT_Y = -BODY_D / 2.0 - 0.5` constant (the mass's front face) and place every elevation detail relative to it (`FRONT_Y - 0.10` for applied detail, `FRONT_Y` for flush), instead of the hand-typed negative coordinates each call site uses now. Then re-export, re-import and re-render. + +**And check the review shot.** The three hero captures show a plain block with six columns and nothing else - no banners, no windows, no portals - while the mesh demonstrably carries all four materials (`M_Facade_Banner`, `M_Facade_Stone`, `M_Facade_StoneShadow`, `M_Facade_GlassDark`) and is staged at `(-13336, 1307, 0)` with yaw 0. That suggests the cameras are framing the wrong actor (`Frontage_SM_ElCapitanTheatre` sits at the origin) or the wrong side, and the "rework" note may be describing a camera error rather than the model. Verify that first: one render with a camera aimed at `Hero_Facade` from Y -4000 at 8 m height. + +### First pass against the folder references + +`Art/reference/hero-masonic/` (15 images, ignored by git as reference photography) is now the source of truth rather than one Wikimedia photo. `02-masonictemple01.jpg` and `06-el-capitan-theater-frontal-fachade.jpg` are the front elevations used for the first pass; `05-hollywoodmasonictemple-1922.jpg` records the original masonry. + +Done in this pass: + +- the detail is seated on the wall (previous step), so banners, windows and portals render; +- the blank `Hero_Stylobate_LintelBand` across the portals is gone - the reference has open doorways with steps, which now read correctly; +- `rustication()` cuts five ashlar courses from the pavement to the portal heads; +- `balustrade()` adds a bottom rail, a top rail and 38 balusters along the roof edge. + +Still missing, from the same references: warm sandstone instead of the flat pale stone, lattice grilles in the window bays, the carved inscription on the motto band, and the two medallions are not reading at distance. + +### The iteration loop + +`bash Tools/review/hero_iterate.sh` runs the whole chain in one command: chmod the Blender files, build the facade, import it, render the capture set, judge the shot with the vision model, and write a comparison plate at `Screenshots/review/hero-compare-front.png`. Each step fails loudly and logs to `/tmp/gigline/`. + +Iteration 1 verdict (judge, confidence 0.9): + +> "The scene lacks the warm yellow-grey sandstone texture and the detailed fluted Ionic columns. Additionally, the red banners do not match the expected design, and there are no visible entrance doorways or decorative elements like the inscription band or medallions." + +Measured against the plate, that splits into: + +| Finding | Reality | Next action | +| --- | --- | --- | +| not warm sandstone | true - the UE material `M_FacadeStone` is still near-white (0.62-0.76 grey) | warm the UE material spec in `Tools/unreal/ue_hero_facade_import.py`, not the Blender one | +| fluting missing | false - 120 flute strips exist and cast shadows; they are lost at 45 m | not a modelling problem; check the review camera distance | +| banners wrong | partly - proportions are now 1.35 x 6.2 m and hang correctly, the lettering is absent | needs a texture, not geometry | +| no doorways | false - three 1.2 m deep recesses with steps; they read dark because the reveal is glass-black | lighten the reveal material | +| no inscription/medallions | true - the band and two medallions exist but are flat and untextured | texture the motto band | + +### Tasks + +Done on 2026-11-09/10, each verified by a capture: + +- [x] Confirm the hero captures frame `Hero_Facade` - cameras at (-13336, 1307), hero actor there, frontage at the origin. +- [x] Seat the elevation detail in front of the mass (`FRONT_Y` + `seat_on_front()`, 99 parts moved) - banners, windows and portals render now. +- [x] Remove the blank lintel band across the portals; the three doorways are 1.2 m deep with five steps each. +- [x] Window grilles (7 vertical + 5 horizontal bars per bay), five ashlar courses, roof balustrade with 38 balusters. +- [x] Warm sandstone in the engine - `tint_imported_materials()` authors one material per slot from the reference. +- [x] Banner proportions 1.35 x 6.2 m hung from 7.2 m, matching the photographs. + +Open: + +- [x] **Make the generated ashlar albedo render.** Two blockers, both measured: the map's generic material pass was **overriding every hero slot** with the kit's flat stone (`comp.set_material` on `Hero_Facade`), and assigning authored materials to the _mesh's_ slot list does not persist because `get_editor_property("static_materials")` returns a copy. What works: exclude the hero from the generic pass and set **component-level overrides** to the authored `*_Ref` materials in both builders (`apply_hero_materials()`). The elevation now shows coursed, grainy limestone. +- [x] Carved inscription: `inscription()` in `Tools/blender/hero_facade_build.py` lays 112 letter marks in three lines across the attic, in the shadow stone so they read at 45 m. They had to be **seated on the parapet face** (`FRONT_Y + 0.40`): the parapet is 1.2 m deep and stands in front of the wall plane, so marks at the wall plane were hidden behind it. +- [x] Medallion relief that reads at 45 m. **Measured first, then fixed:** the discs were not just flat - they were **buried**. `seat_on_front` put `Hero_Medallion` at offset 0.08 → front face Y −0.42, while the parapet (1.2 m deep) stands at Y −0.15, so both medallions were inside the parapet and never rendered (the motto band and surround have the same bug at offset 0.06). Fixed: three stepped layers per medallion — `Hero_MedallionRim` (0.55 m, dark, offset 0.36 → Y −0.14), `Hero_MedallionField` (0.38 m, light, offset 0.43 → Y −0.07), `Hero_MedallionBoss` (0.15 m, dark, offset 0.50 → Y 0.00) — a 7 cm step each way so the rim/field/boss read as relief. Moved from ±6.2 (inside the inscription text, ±6.87) to ±9.8. Rendered and judged; the two-model judge is advisory-pessimistic (`no` 0.93) and does not flag medallions any more. Owner review pending. +- [x] Motto band and surround — same burial bug (offset 0.06 → front Y −0.44). The band is now **light stone** (was `stone_dark`, which would have swallowed the dark lettering once visible), recessed at offset 0.34 (Y −0.16), with the moulded surround proud at offset 0.43 (Y −0.07) and the inscription at 0.40 (Y −0.10); the dead duplicate `Hero_MottoBand 0.40` entry is removed. Rendered and judged; the judge (`no` 0.93) no longer flags the band, only the stone warmth/column fluting the owner has already directed lighter. Owner review pending. +- [x] Banner lettering (JIMMY KIMMEL LIVE). New `Tools/gen/generate_banner_texture.py` draws the exact title with Pillow + Liberation Sans Bold (an image model would mangle a trademark string, and Qwen-Image 2.1 is research-only). Gold `JIMMY` / `KIMMEL` / `LIVE` stacked on a crimson ground, 512 × 2304. `ue_hero_facade_import.py` generalised `import_albedo` → `import_texture` and wires the banner into `M_Facade_Banner_Ref` as Base Color (tiling 1.0). Owner review pending. +- [x] Crimson banner tone + lettering. The texture carries the red ground (186,24,24 — brighter than the old dark tint, per the owner's "less dark, more bright") and the gold lettering, so the flat tint is replaced outright. +- [x] Re-render the three ReviewMap staging shots and put them back in the status table for review. All three (`review-hero-facade-front`, `review-hero-facade-oblique`, `review-hero-portals`) regenerated 2026-11-10 with the full attic detail, and the `Screenshots/README.md` status table updated. Owner review pending. + +**Deferred to `0.9.0` (owner, 2026-11-10).** This pass is closed at "surface detail that reads at 45 m". The remaining elevation work is a `0.9.0` pass, not another patch here: **volute capital depth** (the capitals are block-only, the reference has strong shadow lines), **lattice grille depth** (the window bays are plain panels with bars, not the dark metal lattice), and the full realism pass (heavy banded rustication, measured material library, texel-density and LOD/collision per building). The `0.9.0` hero item records the hand-off. + ## 0.3.0 — Chaos vehicle, enter/exit, closed driving route (weeks 10–13) -### Vehicle production +### Vehicle production - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `vehicle-0.3.0`) -- [ ] Model one vehicle in Blender with collision meshes; export to Unreal. -- [ ] Set up Chaos Vehicle with wheel and physics configuration. +- [A] Model one vehicle in Blender with collision meshes; export to Unreal. **Model done** (`Tools/blender/vehicle_build.py` → `Art/blender/vehicle/gigline-sedan.blend`, preview in `Art/blender/vehicle/preview.png`): 4.72 x 1.80 x 1.44 m, 2.78 m wheelbase, 1.62 m track, 0.33 m wheels, 252 triangles, four `UCX_` convex hulls. Dimensions come from real sedan figures and are logged to the report, so the vehicle config can read them rather than restating them. - **Imported into Unreal** (`Tools/unreal/ue_vehicle_import.py` → `/Game/Hollywood/Vehicle`), split per the vehicle-artist object split: `SM_Vehicle_Body` (3 slots, 4.76 x 1.76 x 1.28 m), `SM_Vehicle_Glass`, `SM_Vehicle_Light_Front`, `SM_Vehicle_Light_Rear`, `SM_Vehicle_Mirrors`, and one `SM_Vehicle_Wheel` (0.22 x 0.66 x 0.66 m) reused four times. The body FBX carries the `UCX_` hulls as simple collision. + - **LODs cut for real:** decimating the duplicate gives LOD1 at 38 triangles and LOD2 at 14, against 132 for LOD0. The first attempt duplicated the shell unchanged and produced 84-triangle LODs that saved nothing - worth remembering as the failure mode of a "LOD" step nobody measures. + - **LOD group wiring is open:** `StaticMeshEditorSubsystem.import_lod` is unavailable in a headless commandlet (`get_editor_subsystem` returns None), so the LOD meshes import as their own assets and the group still needs an editor session. +- [D] Set up Chaos Vehicle with wheel and physics configuration. **Deferred to its own task** (`Vehicle physics — Chaos rig`): the pawn is parked, the visible car is the panel set, and the remaining question is what writes the actor transform in `DemoMap` but not in `ReviewMap`. + - **Fixed the floating:** the physics asset had a body per bone, and the four wheel bodies intersected the chassis body and pushed the car 3.7 m up. It is now a **single chassis body** on the `root` bone (452 x 205 x 40 cm), which is what Unreal's own vehicle template uses - Chaos simulates the wheels itself, they must not be separate rigid bodies. Settle check: **Z 119 cm** at a spawn of 120 (was 494). + - **Drive test fails, with a specific cause:** `-drivetest` holds full throttle for 3 s and reports speed and distance. Result: `speed=0 cm/s, travelled 2.2 m -> FAIL`, and BeginPlay logs **`0 wheel objects after register`** while the component reports 4 wheel setups. The setups exist but no wheels are created from them, so there is nothing to apply torque to. + - **Next:** confirm the skeleton's bone names match the setups (`Wheel_FL`, `Wheel_FR`, `Wheel_RL`, `Wheel_RR`) - a name mismatch silently produces zero wheels - and give the front and rear pairs their own `UChaosVehicleWheel` subclasses rather than the base class, which is what the setups point at today. + - **Also open:** LOD group wiring, enter/exit, driving route, handling. +- [x] Fix the vehicle not rendering in the scene. **Chased to a strong clue, not yet solved.** The vehicle is staged and runs (`spawned at (-12950,-900,120)`, `9 visual panels attached`, `panel 0 at (-12950,-900,120) visible=1 extent=238`, `settled at Z 119`), and the review capture shows an empty road with only a soft shadow. `Tools/review/visual_loop.py --verify` passes the frame; `Tools/review/judge_screenshot.py` returns `verdict=no confidence=1.0`. A downward trace from the vehicle finds **no ground at all** (`ground at Z -9999`), so the car is over a surface without collision or inside geometry - the shadow and the panels are not in the same place. **Fixed 2026-11-09** — the car renders, built from the owner's `hot hatch.blend`. **Fixed:** the vehicle was spawned 2.5 m under the road - a downward trace measured the surface at Z 366 cm against a spawn of Z 120, so the body was buried and only its shadow showed. The spawn is now Z 470 and it settles there. + - **Still open:** above ground, the capture still shows road plus a shadow and no car. Panels report `visible=1`, `mesh=SM_Vehicle_Body`, extent 238 cm, at the expected position; `MarkRenderStateDirty()` and `UpdateBounds()` run after registration. Next suspects: the pawn's own `SetActorHiddenInGame` path (a vehicle pawn is not a plain actor), or the panels being attached to a root that the physics simulation moves independently of the actor transform. +- [x] Fix the character and car floating. **The measurement was taken at the wrong moment.** The foot probe ran in `BeginPlay`, while the pawn is still at its spawn height and falling, so every trace reported a surface far below it (`ground 366` against an actor at 561) and read as a float that was not there. The probe now runs **three seconds after spawn**, from the capsule's feet, on three channels, and names the actor it hits. Character: the player start at Z 600 left the soles 39 cm in the air (the capsule settles at 561 on this pavement); the start is now the measured 561. Car: the template's lowest geometry is exhaust, so grounding on the whole model put the tyres 4 cm above the origin - it is now grounded on its wheels, staged at Z 342 after the traced road height (366) disagreed with the visible road by ~25 cm. +- [x] Fix the vehicle's material colour. The template's `car paint` is a legacy mix-shader graph that the FBX exporter cannot map, so `SM_Vehicle_Body` imported holding `WorldGridMaterial` and rendered black while Blender showed red. Legacy graphs are now converted to Principled before export (saturated colour wins over the grey diffuse next to it), and the staged body panel is assigned `M_Vehicle_HatchPaint`. Judge: `verdict=yes confidence=0.9`. +- [x] Fix the missing vehicle glazing. The `windows` material was metallic 1.0 with roughness 0.0 - a black void that read as an empty cabin. Now tinted translucent (opacity 0.85, roughness 0.08); opacity 0.45 was tried first and made the glass invisible. The vision judge still returns `verdict=no` for the frame, disagreeing with what is visibly tinted glass; it is advisory, and the human verdict decides. +- [x] Reconcile the road and sidewalk surfaces. **Solved: the road strip was a single 279 m mesh whose auto-generated convex hull sat 2 m above the visible surface.** + - The site export now slices sidewalks and kerbs into ~20 m pieces with explicit `UCX_` boxes, and the slice cuts are fixed (the far cut kept the wrong half, so every slice spanned the whole street). Slice tops descend honestly along the street: 380, 340, 309, 284, 254, 231, 205, **169 at the player's X**, 138, 104 cm. + - In the map at the same X: `Sidewalk_North_07` tops at **169 cm**, `Road_Hollywood` at **366 cm**. The road is ~2 m above its sidewalk, so the player lands on the road and no collision setting on the sidewalk can change that. + - Neither slicing, `UCX_` hulls, `set_complex_collision_as_simple`, nor winding (all 2790 sidewalk faces point up) changed the outcome - each of those was the wrong layer. + - **Cause found:** the road is built across -10.5..+10.5 m and the north sidewalk +10.5..+15.8 m, so on the straight they meet at the gutter - but the strips follow a curved centreline and the fan-out pushes the road's outer edge to **-13.8 m**, overlapping the sidewalk band (**-17.3..-11.8 m**). In that overlap the road sits at 366 cm and the sidewalk at 158 cm. + - **Resolved 2026-11-10:** the real cause was the **collision hull**, not the geometry. A 279 m strip imports with one auto-generated convex hull whose top is the mesh's _maximum_ height, so every downward trace landed on a flat lid at 366 cm. Slicing the road like the sidewalks, and removing the single `Road_Hollywood` asset from the map so the slices win, gives a continuous cross-section: `Terrain 154 / Sidewalk_North_07 158 / Road_Hollywood_07 158`. The player now stands on `Sidewalk_North_07` at 158 cm with feet at 161. + - **Superseded:** sample both source meshes at the player's XY in `Art/blender/street/street-2026-11-06.blend`. The road is built as `dem_z + 0.02` and the sidewalk as `dem_z + kerb * t` from the _same_ DEM sampler, so a 2 m gap cannot come from that code - the suspect is the DEM lookup or the mesh's coordinate frame, not the assembly logic. - [ ] Implement enter/exit interaction. - [ ] Build a closed driving route with boundaries. - [ ] Tune basic handling to a drivable state. @@ -247,7 +487,7 @@ Suggested blender skills: **archviz** (real-world scale, façade composition, el Each building needs its own reference set; where none exists the blockout stays and the item says so. -- [ ] Hero façade (6840): raise to this pass's detail level — capitals and fluting, window rhythm behind the colonnade, parapet motto band and medallions. +- [ ] Hero façade (6840): raise to this pass's detail level — **volute capital depth** and **lattice grille depth** (both deferred from the 2026-11-10 hero rework, which closed the inscription/medallions/motto-band/banner surface), window rhythm behind the colonnade, and the heavy banded rustication the elevation still lacks. The motto band, medallions and banner lettering are already done and carry over. - [ ] Hero façade (6840), entrance: **doors at street level left and right of the central bay**, as the photographs show, and correct positioning of the central-bay elements (column bases, entrance stairs, set-back entrance, door frames, banners and signage). Nothing may stand in front of the façade that blocks it — no awning, no temporary structure. Carried over from `Screenshots/README.md`, 2026-11-09. - [ ] El Capitan Theatre (6838): marquee, blade sign, entrance and the theatre's own elevation from its 18-frame set. - [ ] 6800 Lee Drug and 6806–6820: second pass on the `0.1.6` trim. Moved here from that patch because geometry cannot close them: **wall lettering as a decal** (a plate at 0.10 m still vanishes at 40 m), a **moulded parapet profile** instead of a two-tier box, richer **corner-tower stepping**, and the portrait panels reworked as textures rather than framed plates. diff --git a/Tools/README.md b/Tools/README.md index a5b331d..951a598 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -4,6 +4,21 @@ Repeatable helpers that drive Blender, Unreal, and the reference/asset sources. Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side builders live next to their output in `Art/blender/*/`; this folder holds everything that talks to Unreal, the network, or the repository. +## Layout + +Sixty scripts in one directory stopped being navigable, so they are grouped by what they talk to. The folder is also the sandbox boundary: everything in `unreal/` needs the engine, everything in `blender/` needs the Flatpak, and the rest is host Python. + +| Folder | Holds | Needs | +| --- | --- | --- | +| [`unreal/`](unreal/) | Unreal commandlet scripts and the two map builders; `ue_common.py` and `ue_placement.py` are the shared imports | the engine pin from `Docs/toolchain.md` | +| [`blender/`](blender/) | Blender-side builders and exporters, the FBX preset, the MPFB/MakeHuman character pipeline, `blender_screenshots.py` | Blender Flatpak | +| [`review/`](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` | engine + optional OpenRouter key | +| [`gen/`](gen/) | Image and texture generation: local ComfyUI driver, deterministic procedural texture writers, reference fetching | local ComfyUI / network | +| [`input/`](input/) | `input_emulation.py`, `input_test.py`, `run_pie.sh` — OS-level input injection | `/dev/uinput` | +| `_common.py`, `tests/` | Shared helpers and the stdlib test suite | none | + +Scripts resolve the repository root as `../..` from their own folder and put the tools root and their siblings on `sys.path`, so `import ue_common` keeps working from `unreal/`. + ## Conventions - One script, one job. Every input is an argument; no interactive prompts. @@ -16,19 +31,19 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | Tool | Purpose | | --- | --- | | `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob | -| `facade_kit_build.py` | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). | -| `ue_import_retail_detail.py` | Imports the two retail trim meshes into `/Game/Hollywood/Frontage` with complex-as-simple collision — the trim decorates a solid the player already collides with, so it must not add snag points. Staged by the demo builder as `RetailTrim_*`. | -| `ue_import_facade_kit.py` | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. | -| `retail_frontage_build.py` | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. | -| `retail_frontage_build.py` | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. | -| `theatre_trim_build.py` | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. | -| `ue_stage_theatre_trim.py` | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. | -| `ue_apply_facade_kit.py` | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. | -| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. | -| `ue_audit_scene.py` | Gate on the DemoMap: no unmaterialed actor, every verified frontage span within tolerance (`ue_placement`), no duplicate actor labels. Writes `/tmp/gigline/scene-audit.txt`; `render_screenshots.sh --verify` fails on a non-zero failure count. Catches the class of defect that still renders plausibly. | -| `ue_placement.py` | Single source of truth for where staged actors belong in the DemoMap (hero position, verified frontage spans) plus `span_error`/`describe_span_failure` validators. Exists because the hero position was written twice and drifted — the importer kept the pre-`0.1.0` value and moved the hero 13 m east onto the wrong side of the boulevard. No `unreal` import, so it is unit-tested. | -| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. | -| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. | +| [`blender/facade_kit_build.py`](blender/facade_kit_build.py) | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). | +| [`unreal/ue_import_retail_detail.py`](unreal/ue_import_retail_detail.py) | Imports the two retail trim meshes into `/Game/Hollywood/Frontage` with complex-as-simple collision — the trim decorates a solid the player already collides with, so it must not add snag points. Staged by the demo builder as `RetailTrim_*`. | +| [`unreal/ue_import_facade_kit.py`](unreal/ue_import_facade_kit.py) | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. | +| [`blender/retail_frontage_build.py`](blender/retail_frontage_build.py) | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. | +| [`blender/retail_frontage_build.py`](blender/retail_frontage_build.py) | Retail frontage trim for **6800 Lee Drug** and **6806–6820** (0.1.6): bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806–6820. Built in world coordinates from the blockout bounds, one joined mesh per building, JSON part report with the evidence behind every piece. `6822 Budget` is **not** built — no photograph exists. | +| [`blender/theatre_trim_build.py`](blender/theatre_trim_build.py) | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. | +| [`unreal/ue_stage_theatre_trim.py`](unreal/ue_stage_theatre_trim.py) | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. | +| [`unreal/ue_apply_facade_kit.py`](unreal/ue_apply_facade_kit.py) | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. | +| [`gen/generate_facade_textures.py`](gen/generate_facade_textures.py) | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. | +| [`unreal/ue_audit_scene.py`](unreal/ue_audit_scene.py) | Gate on the DemoMap: no unmaterialed actor, every verified frontage span within tolerance (`ue_placement`), no duplicate actor labels. Writes `/tmp/gigline/scene-audit.txt`; `render_screenshots.sh --verify` fails on a non-zero failure count. Catches the class of defect that still renders plausibly. | +| [`unreal/ue_placement.py`](unreal/ue_placement.py) | Single source of truth for where staged actors belong in the DemoMap (hero position, verified frontage spans) plus `span_error`/`describe_span_failure` validators. Exists because the hero position was written twice and drifted — the importer kept the pre-`0.1.0` value and moved the hero 13 m east onto the wrong side of the boulevard. No `unreal` import, so it is unit-tested. | +| [`unreal/ue_common.py`](unreal/ue_common.py) | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. | +| [`blender/fbx_export_preset.py`](blender/fbx_export_preset.py) | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. | | `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing | | `scale_test_export.py` / `ue_scale_test_import.py` | The 1 m = 100 uu scale test; the named presets `ue_metric_v1` live in these two files | @@ -36,38 +51,44 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | Tool | Purpose | | --- | --- | -| `commons_fetch.py` | Wikimedia Commons: search, exact `File:` titles, or a whole category; writes a provenance `sidecar.json` | -| `page_image_fetch.py` | Resolves a web page's `og:image` and downloads it (Flickr, Commons file pages, photo blogs) | +| [`gen/commons_fetch.py`](gen/commons_fetch.py) | Wikimedia Commons: search, exact `File:` titles, or a whole category; writes a provenance `sidecar.json` | +| [`gen/page_image_fetch.py`](gen/page_image_fetch.py) | Resolves a web page's `og:image` and downloads it (Flickr, Commons file pages, photo blogs) | | `addon_compat_probe.py` | Verifies MPFB/Blosm can load and generate headless on the pinned Blender | ## Blender content | Tool | Purpose | | --- | --- | -| `hero_facade_build.py` | Parametric hero façade; emits a JSON report naming the evidence behind every part | -| `character_anim_build.py` | Authors the basic animation set on the MPFB rig — `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) sampled from parametric cycles at 30 fps — and exports one animated FBX with the `ue_character_v1` preset. Rotation axes are measured on the rig, not assumed (probe in the docstring). `--out`, `--report`. | -| `mpfb_character_export.py` | MPFB human, standard rig, baked skin, FBX export | +| [`blender/hero_facade_build.py`](blender/hero_facade_build.py) | Parametric hero façade; emits a JSON report naming the evidence behind every part | +| [`blender/character_anim_build.py`](blender/character_anim_build.py) | Authors the basic animation set on the MPFB rig — `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) sampled from parametric cycles at 30 fps — and exports one animated FBX with the `ue_character_v1` preset. Rotation axes are measured on the rig, not assumed (probe in the docstring). `--out`, `--report`. | +| [`blender/mpfb_character_export.py`](blender/mpfb_character_export.py) | MPFB human, standard rig, baked skin, FBX export | ## Unreal import and level building | Tool | Purpose | | --- | --- | -| `ue_import_site.py` | Imports the six site FBXs into `/Game/Hollywood/Site`, setting collision complexity per mesh | -| `ue_hero_facade_import.py` | Imports the façade and places it on the measured building line | -| `ue_build_demo_map.py` | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start | -| `ue_build_review_map.py` | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views | -| `ue_create_input_assets.py` | Creates `/Game/Input` actions and the mapping context headlessly | -| `ue_character_anim_import.py` | Imports the animated FBX into `/Game/Test/Character` and **verifies an AnimSequence exists** — an FBX exported without `bake_anim` imports a mesh and no clips, with no error. Writes `VERDICT: PASS/FAIL` to `/tmp/gigline/character-anim-import.txt`. | -| `ue_character_import.py` | Skeletal import plus the material/texture chain Interchange skips | +| [`unreal/ue_import_site.py`](unreal/ue_import_site.py) | Imports the six site FBXs into `/Game/Hollywood/Site`, setting collision complexity per mesh | +| [`unreal/ue_hero_facade_import.py`](unreal/ue_hero_facade_import.py) | Imports the façade and places it on the measured building line | +| [`unreal/ue_build_demo_map.py`](unreal/ue_build_demo_map.py) | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start | +| [`review/hero_iterate.sh`](review/hero_iterate.sh) | Full hero-facade loop in one command: chmod the Blender files, build the facade, import it, render the capture set, judge the shot and write a comparison plate. `bash Tools/hero_iterate.sh [shot]`. | +| [`review/hero_compare.py`](review/hero_compare.py) | Side-by-side plate of a reference photograph and a rendered capture, scaled to one height and labelled. `--reference`, `--render`, `--out`. | +| [`review/e2e_asset_check.py`](review/e2e_asset_check.py) | End-to-end check in one command: Blender renders → both Unreal maps rebuilt → captures for **both** the game map and the staging map → two-model judge → `Screenshots/README.md` regenerated. `--skip-blender`, `--skip-judge`, `--skip-recreate`. Report in `/tmp/gigline/e2e-report.txt`; non-zero exit if any step failed. | +| [`unreal/ue_recreate_maps.py`](unreal/ue_recreate_maps.py) | Deletes `DemoMap` and `ReviewMap` and creates both as empty levels, so a run never inherits an actor from an experiment. Chmods the LFS-read-only `.umap` first and parks the editor on a scratch level when it has the map open. | +| [`unreal/ue_clear_simple_collision.py`](unreal/ue_clear_simple_collision.py) | Removes the FBX import's auto-generated convex hulls from the site and street meshes and sets them to complex-as-simple, so physics bodies are not pushed out of a 279 m hull. | +| [`unreal/ue_vehicle_probe_map.py`](unreal/ue_vehicle_probe_map.py) | Copies `ReviewMap` to `VehicleProbe`, optionally adds one demo mesh and the Chaos pawn, and saves it, to bisect which map content throws a physics body into the air. Set `GIGLINE_PROBE_ASSET` to stage one mesh; unset for the control. | +| [`unreal/ue_build_review_map.py`](unreal/ue_build_review_map.py) | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views | +| [`unreal/ue_create_input_assets.py`](unreal/ue_create_input_assets.py) | Creates `/Game/Input` actions and the mapping context headlessly | +| [`unreal/ue_character_anim_import.py`](unreal/ue_character_anim_import.py) | Imports the animated FBX into `/Game/Test/Character` and **verifies an AnimSequence exists** — an FBX exported without `bake_anim` imports a mesh and no clips, with no error. Writes `VERDICT: PASS/FAIL` to `/tmp/gigline/character-anim-import.txt`. | +| [`unreal/ue_character_import.py`](unreal/ue_character_import.py) | Skeletal import plus the material/texture chain Interchange skips | ## Visual review | Tool | Purpose | | --- | --- | | `render_screenshots.py` / `render_screenshots.sh` | Rebuilds the review map, runs the game, captures the shot list into `Screenshots/` | -| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional, advisory only — the model is unreliable on this content and never replaces a human verdict. | -| `visual_loop.py` | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. | -| `visual_targets.json` | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. | +| [`review/judge_screenshot.py`](review/judge_screenshot.py) | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Judged by **two models by default** (`--models a,b`), reconciled: agreement carries the verdict, disagreement reports `uncertain` with both reasons. Optional, advisory only — never replaces a human verdict. | `--image` + `--expect`; `--model` for a single judgement | +| [`review/visual_loop.py`](review/visual_loop.py) | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. | +| [`review/visual_targets.json`](review/visual_targets.json) | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. | ## Gameplay QA @@ -81,19 +102,61 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | Tool | Purpose | | --- | --- | -| `facade_detail_concepts.py` | Generates **frontal building plates** (whole elevation, entrance in its facade, or building with neighbours) from a local reference photograph with an image model (OpenRouter); close-up detail crops are deliberately not requested because they cannot be measured for proportion. `--list` is free and shows which studies have their reference present; `--all`/`--only ` generates. Never overwrites (writes `-v2`, `-v3`), writes a `.refgen.json` sidecar per image (model, prompt, source photo, SHA-256), and puts everything under `Art/reference/facade-details/` — gitignored, local-only, never shipped. Costs money per image, so it is never automatic. | -| `judge_screenshot.py` | (see Visual review) | +| [`gen/comfyui_generate.py`](gen/comfyui_generate.py) | Drives the **local** ComfyUI at `http://127.0.0.1:8188` (Stability Matrix install). `models` lists checkpoints; `txt2img` renders through the HTTP API; **`texture` renders material sheets with the project's documented texture workflows** (see below). Every run writes a provenance sidecar (model, prompt, seed, settings, full graph). Two candidates by default — same prompt and seed, both scored by high-frequency detail, the winner takes `--out`, the loser is kept beside it. Local and human-triggered; no credential. | +| [`gen/generate_facade_textures.py`](gen/generate_facade_textures.py) | Procedural, deterministic facade texture maps (ashlar base colour + roughness, stucco, asphalt, concrete) written with a pure-zlib PNG encoder — no model, no network, byte-identical on every run. | +| [`gen/generate_character_textures.py`](gen/generate_character_textures.py) | Procedural character texture maps, same deterministic approach as the facade set. | +| [`gen/generate_banner_texture.py`](gen/generate_banner_texture.py) | Draws the hero-facade banner (`JIMMY KIMMEL LIVE` in gold on crimson) with Pillow + a system font — exact text, deterministic, no model. An image model would mangle a trademark string, and Qwen-Image 2.1 is research-only, so this is the lettering path. `--out Art/textures/generated/T_Banner_KIMMEL.png` by default. | +| [`gen/facade_detail_concepts.py`](gen/facade_detail_concepts.py) | Generates **frontal building plates** (whole elevation, entrance in its facade, or building with neighbours) from a local reference photograph with an image model (OpenRouter); close-up detail crops are deliberately not requested because they cannot be measured for proportion. `--list` is free and shows which studies have their reference present; `--all`/`--only ` generates. Never overwrites (writes `-v2`, `-v3`), writes a `.refgen.json` sidecar per image (model, prompt, source photo, SHA-256), and puts everything under `Art/reference/facade-details/` — gitignored, local-only, never shipped. Costs money per image, so it is never automatic. | +| [`review/judge_screenshot.py`](review/judge_screenshot.py) | (see Visual review) | + +### Texture generation workflows (from the Stability Matrix install) + +The install documents four verified, core-node graphs in [`Workflows/texture_generation.md`](/home/zwitschi/StabilityMatrix/Data/Workflows/texture_generation.md). Those files are ComfyUI **UI format**, which `/prompt` cannot accept, so `comfyui_generate.py texture` rebuilds the equivalent API graphs with exactly their settings: + +| Profile | Model | Steps / CFG / sampler | VAE | Best for | Licence | +| --- | --- | --- | --- | --- | --- | +| `albedobase` | `albedobaseXL_v31Large.safetensors` | 30 / 6.0 / `dpmpp_2m` `karras` | baked | stone, brick, metal, wood albedo | Civitai — commercial, credit required | +| `krea2` | `krea2_turbo_nvfp4.safetensors` | 8 / 1.0 / `euler` `simple` | texture-fix (Qwen 1 / Krea-2) | fast stochastic detail: stone, bark, foliage, fabric | Krea-2 community licence | +| `qwen21` | `qwen_image_2.1_int8_convrot.safetensors` | 25 / 1.0 / `euler` `simple` | texture-fix (Qwen 2.1) | material sheets with readable labels/signage, 2K | **Qwen research — non-commercial** | + +```bash +python3 Tools/gen/comfyui_generate.py texture \ + --material "weathered grey limestone ashlar, fine grain" \ + --out Art/textures/generated/T_StoneAshlar_AI.png +``` + +The default pair is `albedobase` + `krea2` — both commercially usable — generated from one prompt and seed, scored, and compared before the winner takes `--out`. `--profile`/`--alt-profile` pick another pair, and `--alt-profile ""` runs one. + +**Limits this tool inherits from the workflows, and does not hide:** + +- The graphs produce a **colour/albedo sheet only**. Normal, roughness and metallic maps are derived downstream with a material tool. +- ComfyUI 0.44 core has **no seamless-tiling sampler**, so "seamless tileable" in the prompt is a hint, not a guarantee. Offset the image 50 % and check for a seam before using it on a tiled surface; the installed SDXL tile ControlNet is the fix-up path. +- A generated texture ships only once its model and licence are in [`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md). + +### Local image generation: start Stability Matrix first + +`comfyui_generate.py` talks to ComfyUI on `http://127.0.0.1:8188`. That server is **not** started by this repository — it lives in a [Stability Matrix](https://lykos.ai) install and must already be running. + +**If the tool reports a connection error, start Stability Matrix and launch ComfyUI**, then re-run. The install is documented in [`/home/zwitschi/StabilityMatrix/Data/README.md`](/home/zwitschi/StabilityMatrix/Data/README.md): package version, launch arguments, and the shared model library (`Models/`, ~80 GB, mapped in through `extra_model_paths.yaml`). Reading that file before writing a prompt is the cheap way to see which checkpoints actually exist — `python3 Tools/gen/comfyui_generate.py models` asks the running server the same question, and it is the authoritative list — the default pair is `juggernautXL_ragnarok.safetensors` and `epicrealismXL_pureFix.safetensors`, both verified installed. + +Checkpoints are **not** committed. A generated texture ships only after its model and licence are recorded in [`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md); the sidecar carries what that needs. ## Input injection | Tool | Purpose | | --- | --- | -| `input_emulation.py` | Pure-stdlib `ctypes`/`fcntl` driver that creates a virtual keyboard+mouse on `/dev/uinput`. The desktop treats it as real hardware, so it proves whether OS input actually reaches the game. Needs `/dev/uinput` writable (ACL on this host). | -| `input_test.py` | Injects a scripted sequence into the focused window: `--sequence move:2,look:3,jump:1,reset:1,pause:1,click`. | -| `comfyui_generate.py` | Local ComfyUI driver: list checkpoints, run txt2img through the HTTP API, and write a provenance sidecar (model, prompt, seed, full graph). Local, human-triggered. | `models`; `txt2img --prompt ... --out ` | `python3 Tools/comfyui_generate.py models` | -| `comfyui_character_blueprint.py` | Generates a character blueprint sheet - front, side, back and head views plus a contact sheet - from one description, with fixed seeds and a JSON record. | `--desc`, `--out`, `--model`, `--size` | `python3 Tools/comfyui_character_blueprint.py --desc "lean pedestrian, navy tee, jeans" --out Art/characters/blueprints` | -| `character_match.py` | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` | -| `generate_character_textures.py` | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` | +| [`input/input_emulation.py`](input/input_emulation.py) | Pure-stdlib `ctypes`/`fcntl` driver that creates a virtual keyboard+mouse on `/dev/uinput`. The desktop treats it as real hardware, so it proves whether OS input actually reaches the game. Needs `/dev/uinput` writable (ACL on this host). | +| [`input/input_test.py`](input/input_test.py) | Injects a scripted sequence into the focused window: `--sequence move:2,look:3,jump:1,reset:1,pause:1,click`. | +| [`gen/comfyui_generate.py`](gen/comfyui_generate.py) | Local ComfyUI driver: list checkpoints, run txt2img through the HTTP API, and write a provenance sidecar (model, prompt, seed, full graph). **Two checkpoints by default**: both are generated from the same prompt and seed, scored by high-frequency detail, and the better one takes `--out` while the loser is kept beside it. Local, human-triggered. | `models`; `txt2img --prompt ... --out [--alt-model ...]` | `python3 Tools/gen/comfyui_generate.py models` | +| [`blender/comfyui_character_blueprint.py`](blender/comfyui_character_blueprint.py) | Generates a character blueprint sheet - front, side, back and head views plus a contact sheet - from one description, with fixed seeds and a JSON record. | `--desc`, `--out`, `--model`, `--size` | `python3 Tools/comfyui_character_blueprint.py --desc "lean pedestrian, navy tee, jeans" --out Art/characters/blueprints` | +| [`blender/character_match.py`](blender/character_match.py) | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` | +| [`gen/generate_character_textures.py`](gen/generate_character_textures.py) | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` | +| [`blender/vehicle_build.py`](blender/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` | +| [`unreal/ue_vehicle_import.py`](unreal/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` | +| [`blender/vehicle_from_template.py`](blender/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/blender_screenshots.py`](blender/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/blender_screenshots.py -- --label vehicle --mode both` | +| [`review/build_comparison_md.py`](review/build_comparison_md.py) | Regenerates the `Blender vs in-game` section of `Screenshots/README.md` between `` markers: 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. Run it after every capture or Blender render. | (no arguments) | `python3 Tools/review/build_comparison_md.py` | +| [`review/shadow_gap.py`](review/shadow_gap.py) | Measures the vertical pixel gap between an object's lowest pixels and the top of its shadow, as a fraction of the object's height, and calls it `contact` or `floating`. Objective replacement for asking a vision model whether something floats. | `--image`, `--object-box`, `--gap-fraction`, `--out` | `python3 Tools/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png --object-box 0.44,0.48,0.60,0.99` | ## Tests diff --git a/Tools/blender/blender_screenshots.py b/Tools/blender/blender_screenshots.py new file mode 100644 index 0000000..a69b69a --- /dev/null +++ b/Tools/blender/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/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/review/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/character_anim_build.py b/Tools/blender/character_anim_build.py similarity index 98% rename from Tools/character_anim_build.py rename to Tools/blender/character_anim_build.py index 22a952d..45d5f97 100644 --- a/Tools/character_anim_build.py +++ b/Tools/blender/character_anim_build.py @@ -9,7 +9,7 @@ re-derived from the numbers below: GigLine_Walk 30 frames @ 30 fps — one full stride, left and right contact blender -b Art/blender/character/character-2026-11-07.blend \ - --python Tools/character_anim_build.py -- \ + --python Tools/blender/character_anim_build.py -- \ --out /tmp/gigline/character-anim.fbx \ --report /tmp/gigline/character-anim-report.json @@ -38,7 +38,9 @@ import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 FPS = 30 diff --git a/Tools/character_clothing_build.py b/Tools/blender/character_clothing_build.py similarity index 96% rename from Tools/character_clothing_build.py rename to Tools/blender/character_clothing_build.py index 61edaa3..9699281 100644 --- a/Tools/character_clothing_build.py +++ b/Tools/blender/character_clothing_build.py @@ -6,7 +6,7 @@ faces for the clothing base, solidify for cloth thickness, and keep each piece a separate mesh so it can be swapped or re-materialled on its own. blender -b Art/blender/character/character-2026-11-07.blend \ - --python Tools/character_clothing_build.py -- \ + --python Tools/blender/character_clothing_build.py -- \ --out /tmp/gigline/clothing --report /tmp/gigline/clothing.json Outputs one FBX per garment (`tee`, `jeans`, `shoes`), each carrying the character @@ -26,7 +26,9 @@ import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 GARMENTS = ( diff --git a/Tools/character_hair_build.py b/Tools/blender/character_hair_build.py similarity index 96% rename from Tools/character_hair_build.py rename to Tools/blender/character_hair_build.py index fec6c9b..ec29e22 100644 --- a/Tools/character_hair_build.py +++ b/Tools/blender/character_hair_build.py @@ -8,13 +8,13 @@ slot on a skeletal-mesh import, so the cap rendered with the skin material. A se object sidesteps that and is what the skill calls for anyway. blender -b Art/blender/character/character-2026-11-07.blend \ - --python Tools/character_hair_build.py -- \ + --python Tools/blender/character_hair_build.py -- \ --out /tmp/gigline/character-hair.fbx --report /tmp/gigline/character-hair.json What it builds: SM_Char_Hair scalp cap + 18 cards in three rings, UV-mapped to the strand sheet - from Tools/generate_character_textures.py, every vertex weighted + from Tools/gen/generate_character_textures.py, every vertex weighted to `head` at 1.0 so it follows the head without a second rig. Cards are built with a taper and a slight outward curl so the silhouette is not a @@ -35,7 +35,9 @@ import sys import bpy from mathutils import Vector -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 HAIR_NAME = "SM_Char_Hair" diff --git a/Tools/character_match.py b/Tools/blender/character_match.py similarity index 99% rename from Tools/character_match.py rename to Tools/blender/character_match.py index 85a96b3..07214af 100644 --- a/Tools/character_match.py +++ b/Tools/blender/character_match.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """Evaluate a rendered character against a generated blueprint. -The blueprint comes from `Tools/comfyui_character_blueprint.py`; the render comes from +The blueprint comes from `Tools/blender/comfyui_character_blueprint.py`; the render comes from the review pipeline. This tool isolates the figure in both, then reports how far the model has drifted from the intended character: - python3 Tools/character_match.py \ + python3 Tools/blender/character_match.py \ --render Screenshots/demo/review-demo-third-person-front.png \ --blueprint Art/characters/blueprints/front.png \ --out /tmp/gigline/character-match.json \ diff --git a/Tools/comfyui_character_blueprint.py b/Tools/blender/comfyui_character_blueprint.py similarity index 90% rename from Tools/comfyui_character_blueprint.py rename to Tools/blender/comfyui_character_blueprint.py index 905e6ca..4017f0f 100644 --- a/Tools/comfyui_character_blueprint.py +++ b/Tools/blender/comfyui_character_blueprint.py @@ -4,16 +4,16 @@ A "blueprint" here is an art-direction sheet for one character: a front, side and back view, plus a head detail, generated from a fixed prompt and seed set so the sheet can be regenerated and compared against later. It is the reference the modelled character -is evaluated against — see `Tools/character_match.py`. +is evaluated against — see `Tools/blender/character_match.py`. - python3 Tools/comfyui_character_blueprint.py \ + python3 Tools/blender/comfyui_character_blueprint.py \ --character "GigLine walker" \ --desc "lean adult pedestrian, short dark hair, olive skin, plain navy t-shirt, dark jeans, worn sneakers" \ --out Art/characters/blueprints Writes one PNG per view plus `contact-sheet.png` and `blueprint.json`, which records the prompt, model, seeds and the ComfyUI prompt ids. Everything is generated locally -by `Tools/comfyui_generate.py`; no network service and no credential is involved. +by `Tools/gen/comfyui_generate.py`; no network service and no credential is involved. The sheet is generated art, not photography, and it stays out of the reference photo folders on purpose: AGENTS.md forbids committing reference photography, and a @@ -27,7 +27,9 @@ import json import os import sys -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import comfyui_generate as comfy # noqa: E402 from PIL import Image # noqa: E402 @@ -106,7 +108,7 @@ def main(argv=None): sheet = contact_sheet(written, os.path.join(args.out, "contact-sheet.png")) record["contact_sheet"] = sheet - record["note"] = ("generated locally by Tools/comfyui_character_blueprint.py; " + record["note"] = ("generated locally by Tools/blender/comfyui_character_blueprint.py; " "record the model licence in THIRD_PARTY_NOTICES.md before shipping") with open(os.path.join(args.out, "blueprint.json"), "w") as handle: json.dump(record, handle, indent=2) diff --git a/Tools/export_block_frontage.py b/Tools/blender/export_block_frontage.py similarity index 92% rename from Tools/export_block_frontage.py rename to Tools/blender/export_block_frontage.py index a03d9df..be6ce9c 100644 --- a/Tools/export_block_frontage.py +++ b/Tools/blender/export_block_frontage.py @@ -1,7 +1,7 @@ # Export the south-side block frontage from the OSM blockout. # # blender -b --factory-startup Art/blender/blockout/blockout-2026-11-06.blend \ -# --python Tools/export_block_frontage.py -- --out /tmp/gigline/frontage +# --python Tools/blender/export_block_frontage.py -- --out /tmp/gigline/frontage # # The building set is the one verified in # `Geo/verification/2026-11-06-el-capitan-block.md` ("South frontage, west to @@ -19,7 +19,9 @@ import os import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 diff --git a/Tools/export_north_buildings.py b/Tools/blender/export_north_buildings.py similarity index 88% rename from Tools/export_north_buildings.py rename to Tools/blender/export_north_buildings.py index 06dfd50..992c90e 100644 --- a/Tools/export_north_buildings.py +++ b/Tools/blender/export_north_buildings.py @@ -1,7 +1,7 @@ # Export the two in-scope north-side background buildings from the OSM blockout. # # blender -b --factory-startup Art/blender/blockout/blockout-2026-11-06.blend \ -# --python Tools/export_north_buildings.py -- --out /tmp/gigline/north +# --python Tools/blender/export_north_buildings.py -- --out /tmp/gigline/north # # The blockout is already in the shared project frame (metres, X east / Y north / # Z up, origin Hollywood x Highland), so exporting each object at its own world @@ -13,7 +13,9 @@ import os import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 diff --git a/Tools/facade_kit_build.py b/Tools/blender/facade_kit_build.py similarity index 96% rename from Tools/facade_kit_build.py rename to Tools/blender/facade_kit_build.py index 30b8ff0..87787bb 100644 --- a/Tools/facade_kit_build.py +++ b/Tools/blender/facade_kit_build.py @@ -23,9 +23,9 @@ where it resolves them (window proportions, shopfront line); everything the photographs do not resolve is an assumption and is recorded as one in the part report, so model and evidence stay separable. - python3 Tools/facade_kit_build.py --out /tmp/gigline/kit \ + python3 Tools/blender/facade_kit_build.py --out /tmp/gigline/kit \ --report /tmp/gigline/kit-report.json - blender -b --factory-startup --python Tools/facade_kit_build.py -- --out /tmp/gigline/kit + blender -b --factory-startup --python Tools/blender/facade_kit_build.py -- --out /tmp/gigline/kit """ from __future__ import annotations @@ -38,7 +38,9 @@ import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 # --- module ------------------------------------------------------------------ diff --git a/Tools/fbx_export_preset.py b/Tools/blender/fbx_export_preset.py similarity index 100% rename from Tools/fbx_export_preset.py rename to Tools/blender/fbx_export_preset.py diff --git a/Tools/hero_facade_build.py b/Tools/blender/hero_facade_build.py similarity index 65% rename from Tools/hero_facade_build.py rename to Tools/blender/hero_facade_build.py index 415f453..56ea550 100644 --- a/Tools/hero_facade_build.py +++ b/Tools/blender/hero_facade_build.py @@ -4,7 +4,7 @@ Builds the north elevation in the project frame (X east, Y north, Z up, metres, origin Hollywood x Highland) and exports it with the named FBX preset `ue_metric_v1`, so it imports into Unreal at 1 m = 100 uu. - blender -b --factory-startup --python Tools/hero_facade_build.py -- \ + blender -b --factory-startup --python Tools/blender/hero_facade_build.py -- \ --out /tmp/gigline/hero-facade.fbx \ --save-blend Art/blender/hero/hero-facade-2026-11-07.blend \ --report Art/blender/hero/hero-facade-2026-11-07.json @@ -33,7 +33,9 @@ import os import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 @@ -129,8 +131,10 @@ def build(): for obj in list(bpy.data.objects): bpy.data.objects.remove(obj, do_unlink=True) - stone = make_material("M_Facade_Stone", (0.78, 0.74, 0.66, 1.0), 0.85) - stone_dark = make_material("M_Facade_StoneShadow", (0.62, 0.59, 0.53, 1.0), 0.85) + # Warm limestone, sampled from 02-masonictemple01.jpg: the reference is a yellow-grey sandstone, + # nowhere near the near-white the blockout used. + stone = make_material("M_Facade_Stone", (0.34, 0.29, 0.21, 1.0), 0.82) + stone_dark = make_material("M_Facade_StoneShadow", (0.22, 0.185, 0.135, 1.0), 0.85) glass = make_material("M_Facade_GlassDark", (0.05, 0.06, 0.07, 1.0), 0.25) banner = make_material("M_Facade_Banner", (0.66, 0.06, 0.05, 1.0), 0.9) @@ -147,6 +151,14 @@ def build(): # Inset so the entablature (1.5 m deep) and parapet (1.2 m deep) sit inside the # measured 25.75 m frontage instead of overhanging it. inset = 0.75 + # The wall's front face, and the only Y that matters for what is visible. + # +Y is the street side: anything with y < FRONT_Y is inside the solid mass and never renders. + # Hand-typed negative Y values throughout this file put the banners 55 cm, the portals 2 m and + # the windows 6 cm behind this plane, which is why the captures showed a plain block with six + # columns. `seat_on_front()` below re-seats every applied detail relative to FRONT_Y, so the + # depth is stated once instead of guessed at each call site. + global FRONT_Y + FRONT_Y = -BODY_D / 2.0 - 0.5 + BODY_D / 2.0 # = -0.5 with the current BODY_D cube("Hero_Mass", 0.0, -BODY_D / 2.0 - 0.5, HEIGHT / 2.0, FRONTAGE_W - 2 * inset, BODY_D, HEIGHT, stone_dark) note("Hero_Mass", (FRONTAGE_W - 2 * inset, BODY_D, HEIGHT), "measured frontage x assumption depth x measured height, inset behind the cornice") @@ -252,18 +264,32 @@ def build(): for z_off in (-0.575, 0.575): cube(f"Hero_MottoSurround_h{int(z_off > 0)}", 0.0, -1.13, motto_z + z_off, motto_w + 0.64, 0.26, 0.32, stone) - cube("Hero_MottoBand", 0.0, -1.06, motto_z, motto_w, 0.10, 0.85, stone_dark) + # Light stone band, not the dark panel the blockout used: the reference letters are + # dark incisions against a pale band, so a dark band would swallow the dark lettering. + # The band sits recessed behind its moulded surround while the surround and the + # lettering stand proud, so the frame and the text both read at 45 m. + cube("Hero_MottoBand", 0.0, -1.06, motto_z, motto_w, 0.10, 0.85, stone) + inscription(motto_w - 0.6, motto_z, stone_dark) note("Hero_MottoBand", (motto_w, 0.10, 0.85), "photographs: motto panel above the colonnade, now an inset panel with a " "moulded surround rather than a flat stripe") - # Medallions either side of the inscription: discs on the parapet face, not - # pancakes on the coping (see disc_on_wall). - for i, x in enumerate((-6.2, 6.2), start=1): - disc_on_wall(f"Hero_Medallion_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0, - 0.55, 0.04, stone_dark, vertices=20) - note("Hero_Medallion_x2", (1.1, 0.04, 1.1), - "photographs: two stone discs beside the motto, mounted on the parapet face") + # Medallions either side of the inscription: stepped round reliefs (dark rim, raised + # light field, dark central boss) so they read as round medallions at 45 m instead of + # flat, empty discs. Each layer has its own prefix so `seat_on_front` gives it its own + # stand-off; one shared prefix would seat every disc at the same offset and flatten the + # relief. They sit OUTSIDE the inscription text (which spans ~±6.9 m) on the parapet + # face, which is 0.35 m proud of FRONT_Y - at the old 0.08 offset they were buried + # inside the 1.2 m-deep parapet and never rendered. + for i, x in enumerate((-9.8, 9.8), start=1): + disc_on_wall(f"Hero_MedallionRim_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0, + 0.55, 0.10, stone_dark, vertices=28) + disc_on_wall(f"Hero_MedallionField_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0, + 0.38, 0.16, stone, vertices=28) + disc_on_wall(f"Hero_MedallionBoss_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0, + 0.15, 0.20, stone_dark, vertices=20) + note("Hero_Medallion_x2", (1.1, 0.20, 1.1), + "photographs: two round reliefs beside the motto - dark rim, raised light field, dark boss") # --- entrances --------------------------------------------------------- # Three portals between column pairs, each with its own short flight. The @@ -286,9 +312,9 @@ def build(): cube(f"Hero_Stylobate_{pi}", (x0 + x1) / 2.0, -1.2, PEDESTAL_H / 2.0, x1 - x0, 2.4, PEDESTAL_H, stone) # Band above the openings, closing the plinth to full height between the doors. - cube("Hero_Stylobate_LintelBand", 0.0, -1.2, PORTAL_H + (PEDESTAL_H - PORTAL_H) / 2.0, - (portal_xs[-1] + gap / 2.0) - (portal_xs[0] - gap / 2.0), 2.4, - PEDESTAL_H - PORTAL_H, stone) + # The reference has no blank band across the portals - the openings run up to the entablature + # with the banners hanging in front of them - so this filler is gone. What stays is the thin + # lintel over each doorway, modelled with the portals. note("Hero_Stylobate", (FRONTAGE_W, 2.4, PEDESTAL_H), "assumption: pedestal height; four piers and a lintel band leave three recesses") @@ -296,8 +322,11 @@ def build(): # Recessed doorway: a dark reveal set back inside the plinth, 1.2 m deep so # it reads as an opening from any angle. depth = 1.2 - cube(f"Hero_Portal_{i}", x, plinth_front + depth / 2.0 + 0.05, PORTAL_H / 2.0, - PORTAL_W, depth, PORTAL_H, glass) + # Door leaf set back inside the opening. A solid stone reveal box at the same volume z-fought + # with the leaf and read as stripes, so the recess is carried by the jambs, the lintel and the + # shadow instead. + cube(f"Hero_PortalLeaf_{i}", x, plinth_front + depth - 0.10, PORTAL_H / 2.0, + PORTAL_W - 0.10, 0.08, PORTAL_H - 0.10, glass) # Stone surround, standing 0.1 m proud of the plinth face. for side, sx in (("L", -1.0), ("R", 1.0)): # Jambs stop at the pavement: a 0.15 m overhang below Z 0 would sink the @@ -332,8 +361,10 @@ def build(): # --- banners ----------------------------------------------------------- # Three vertical event banners hung from the column line, as the recordings show. for i, x in enumerate(portal_xs, start=1): - cube(f"Hero_Banner_{i}", x, -1.05, 7.9, 1.55, 0.06, 5.4, banner) - note("Hero_Banner_x3", (1.55, 0.06, 5.4), "photographs: JIMMY KIMMEL LIVE banners on the column line") + # The reference banners hang from just below the capitals down to the portal heads: + # 6.2 m tall and 1.35 m wide, not the 5.4 x 1.55 blockout that sat too low and too fat. + cube(f"Hero_Banner_{i}", x, -1.05, 7.2, 1.35, 0.06, 6.2, banner) + note("Hero_Banner_x3", (1.35, 0.06, 6.2), "photographs: JIMMY KIMMEL LIVE banners on the column line") # --- windows ----------------------------------------------------------- # Two ranks of windows between the columns and behind the colonnade. @@ -350,13 +381,145 @@ def build(): cube(f"Hero_Window_{rank}_{i}", wx, WINDOW_Y, z, 2.0, 0.08, 1.9, glass) note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade") + # --- window grilles ------------------------------------------------------ + # The reference bays are closed with a dark metal trellis, not plain glass. Seven vertical and five + # horizontal bars per opening, 4 cm square, read as a grille at street distance and cost 60 cubes. + grille = make_material("M_Facade_Grille", (0.10, 0.10, 0.11, 1.0), 0.5) + barred = 0 + for obj in list(bpy.data.objects): + if not obj.name.startswith("Hero_Window_"): + continue + wx, wy, wz, wdx, wdy, wdz = (obj.location.x, obj.location.y, obj.location.z, + obj.dimensions.x, obj.dimensions.y, obj.dimensions.z) + for v in range(7): + x = wx - wdx / 2.0 + (v + 0.5) * wdx / 7.0 + cube(f"Hero_GrilleV_{barred:02d}", x, wy - 0.02, wz, 0.04, 0.03, wdz * 0.96, grille) + barred += 1 + for hcount in range(5): + z = wz - wdz / 2.0 + (hcount + 0.5) * wdz / 5.0 + cube(f"Hero_GrilleH_{barred:02d}", wx, wy - 0.02, z, wdx * 0.96, 0.03, 0.04, grille) + barred += 1 + print(f"[hero] window grilles: {barred} bars") + + # --- rustication and roof cresting -------------------------------------- + # The photographs show the ground storey in banded ashlar and a cresting with balusters along + # the roof edge; without them the elevation reads as a flat card. + # Courses run from the pavement to the portal heads, as the street-level photograph shows. + rustication(FRONTAGE_W, PORTAL_H, stone_dark) + balustrade(FRONTAGE_W, HEIGHT - PARAPET_H * 0.15, stone) + + # --- seat the applied detail on the wall --------------------------------- + # Depth is a property of the façade, not of each call site: state it once here. Parts whose + # front face is behind the wall plane are moved until it sits at FRONT_Y + offset. + seat_on_front(( + ("Hero_Architrave", 0.05), ("Hero_Cornice", 0.05), ("Hero_Dentil", 0.05), + ("Hero_Entablature", 0.03), ("Hero_MottoBand", 0.34), ("Hero_MottoSurround", 0.43), + ("Hero_MedallionRim", 0.36), ("Hero_MedallionField", 0.43), ("Hero_MedallionBoss", 0.50), + ("Hero_Banner", 0.08), ("Hero_Window", 0.03), + ("Hero_Portal", 0.05), ("Hero_PortalJamb", 0.08), ("Hero_PortalLintel", 0.08), + ("Hero_PortalMullion", 0.04), ("Hero_PortalSign", 0.10), + # The parapet is 1.2 m deep and stands in front of the wall plane, so the attic detail + # (inscription, motto band, surround) has to sit on ITS face, not on FRONT_Y - the old + # 0.06/0.08 offsets left the band and surround buried inside the parapet. The band is + # recessed (0.34) behind the proud surround (0.43) and the lettering (0.40). + ("Hero_Inscription", 0.40), + )) + return parts, lines + + +def inscription(width_m, z, material, lines=3): + """Three lines of carved lettering across the motto band, as marks rather than glyphs. + + The reference carries "FREEMASONRY BUILDS ITS TEMPLES / AMONG THE NATIONS / AND IN THE HEARTS OF MEN" + in incised capitals. At the 45 m review distance the words read as three bands of even letter marks, so + the geometry is a deterministic run of small raised blocks of varied width - it holds the silhouette and + the texture of carved text without pretending to spell the words. Legible lettering would need a baked + texture, which is the next step if the owner wants to read it. + """ + import random as _random + + rng = _random.Random(7) + rows = ((0.28, 0.62), (0.06, 0.40), (-0.16, 0.50)) + placed = 0 + for row_index in range(lines): + z_off, coverage = rows[row_index % len(rows)] + x = -width_m * coverage / 2.0 + limit = width_m * coverage / 2.0 + while x < limit: + w = rng.uniform(0.16, 0.30) + gap = rng.uniform(0.05, 0.10) + if x + w > limit: + break + cube(f"Hero_Inscription_{row_index}_{placed:03d}", x + w / 2.0, FRONT_Y - 0.03, + z + z_off, w, 0.05, 0.22, material) + placed += 1 + x += w + gap + print(f"[hero] inscription: {placed} letter marks in {lines} lines") + + +def rustication(width_m, height_m, material, course_m=0.55, joints=0): + """Horizontal ashlar joints cut across the ground storey. + + A real façade reads as stone because of the shadow in its joints, not because of its colour. + Grooves are 3 cm deep and 4 cm tall, spaced `course_m`, and sit just proud of the wall so they + catch the light the way the photographs show. + """ + z = course_m + count = 0 + while z < height_m - 0.2: + cube(f"Hero_Course_{count:02d}", 0.0, FRONT_Y + 0.015, z, + width_m - 1.5, 0.04, 0.04, material) + count += 1 + z += course_m + print(f"[hero] rustication: {count} courses at {course_m:.2f} m") + + +def balustrade(width_m, z, material, spacing_m=0.62): + """A row of balusters with a bottom and top rail along the roof edge.""" + half = width_m / 2.0 - 0.9 + count = int((2 * half) / spacing_m) + cube("Hero_BalustradeRailBottom", 0.0, FRONT_Y - 0.05, z - 0.06, 2 * half, 0.14, 0.10, material) + cube("Hero_BalustradeRailTop", 0.0, FRONT_Y - 0.05, z + 0.42, 2 * half, 0.16, 0.10, material) + for i in range(count): + x = -half + i * spacing_m + spacing_m / 2.0 + cube(f"Hero_Baluster_{i:02d}", x, FRONT_Y - 0.05, z + 0.18, 0.12, 0.12, 0.40, material) + print(f"[hero] balustrade: {count} balusters") + + +def seat_on_front(table): + """Move each named part so its street-facing face sits `offset` metres proud of FRONT_Y. + + `table` is (name_prefix, offset_m) pairs. Parts that name no prefix - the mass, the colonnade, + the steps and the stylobate - are left where the geometry put them, because they define the + plane rather than hang on it. + """ + moved = 0 + for obj in bpy.data.objects: + if obj.type != "MESH": + continue + for prefix, offset in table: + if not obj.name.startswith(prefix): + continue + world_ys = [(obj.matrix_world @ v.co).y for v in obj.data.vertices] + front = max(world_ys) + delta = (FRONT_Y + offset) - front + if abs(delta) < 1e-4: + moved += 1 + else: + obj.location.y += delta + print(f"[hero] seat {obj.name:22} y {front:+.2f} -> {front + delta:+.2f} (offset {offset:+.2f})") + moved += 1 + break + print(f"[hero] seated {moved} parts on the wall plane at Y {FRONT_Y:+.2f}") + + def export_fbx(path): - # Preset lives in Tools/fbx_export_preset.py (ue_metric_v1). + # Preset lives in Tools/blender/fbx_export_preset.py (ue_metric_v1). os.makedirs(os.path.dirname(path), exist_ok=True) - # Named export preset "ue_metric_v1" - same settings as Tools/scale_test_export.py. + # Named export preset "ue_metric_v1" - same settings as Tools/blender/scale_test_export.py. fbx_preset.export_static(path) print(f"[hero] exported FBX preset ue_metric_v1 -> {path}") diff --git a/Tools/mpfb_character_export.py b/Tools/blender/mpfb_character_export.py similarity index 95% rename from Tools/mpfb_character_export.py rename to Tools/blender/mpfb_character_export.py index a37dd4a..7cde91c 100644 --- a/Tools/mpfb_character_export.py +++ b/Tools/blender/mpfb_character_export.py @@ -4,7 +4,7 @@ # materializes the v2 skin, bakes its diffuse to a texture, and exports # FBX with the named export preset "ue_character_v1". # -# blender -b --factory-startup --python Tools/mpfb_character_export.py -- \ +# blender -b --factory-startup --python Tools/blender/mpfb_character_export.py -- \ # --out /tmp/gigline/character.fbx --pose idle \ # --save-blend Art/blender/character/character-.blend @@ -19,7 +19,9 @@ import sys import zlib import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 import numpy diff --git a/Tools/retail_frontage_build.py b/Tools/blender/retail_frontage_build.py similarity index 97% rename from Tools/retail_frontage_build.py rename to Tools/blender/retail_frontage_build.py index 5736dcd..fe846ce 100644 --- a/Tools/retail_frontage_build.py +++ b/Tools/blender/retail_frontage_build.py @@ -8,9 +8,9 @@ stepped Deco parapet on Lee Drug, and the pilaster-and-fascia rhythm on 6806–6 The trim is built **in world coordinates** in the shared frame (metres, X east, Y north, Z from the project datum), so the export carries its position and the Unreal staging step is a single spawn at the origin — the same convention as -`Tools/theatre_trim_build.py` and `Tools/export_block_frontage.py`. +`Tools/blender/theatre_trim_build.py` and `Tools/blender/export_block_frontage.py`. - blender -b --factory-startup --python Tools/retail_frontage_build.py -- \ + blender -b --factory-startup --python Tools/blender/retail_frontage_build.py -- \ --out /tmp/gigline/retail --report /tmp/gigline/retail-report.json Bounds are **measured**, not assumed: they are the blockout objects' world bounds @@ -32,7 +32,9 @@ import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import _common # noqa: E402 import fbx_export_preset as fbx_preset # noqa: E402 diff --git a/Tools/scale_test_export.py b/Tools/blender/scale_test_export.py similarity index 88% rename from Tools/scale_test_export.py rename to Tools/blender/scale_test_export.py index ffac49e..94d34a0 100644 --- a/Tools/scale_test_export.py +++ b/Tools/blender/scale_test_export.py @@ -8,7 +8,7 @@ # and exports them as one FBX with the named export preset "ue_metric_v1". # # Run headless (see Art/blender/README.md for the Flatpak invocation): -# blender -b --factory-startup --python Tools/scale_test_export.py -- \ +# blender -b --factory-startup --python Tools/blender/scale_test_export.py -- \ # --out /tmp/gigline/scaletest.fbx --save-blend Art/blender/scaletest/scale-test-.blend import argparse @@ -16,7 +16,9 @@ import os import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 diff --git a/Tools/theatre_trim_build.py b/Tools/blender/theatre_trim_build.py similarity index 96% rename from Tools/theatre_trim_build.py rename to Tools/blender/theatre_trim_build.py index 309bdfc..3842049 100644 --- a/Tools/theatre_trim_build.py +++ b/Tools/blender/theatre_trim_build.py @@ -10,7 +10,7 @@ convention as the block frontage and the north buildings. The building's verified span (from `Geo/verification/2026-11-06-el-capitan-block.md` and the staged mesh) is X −12082..−8210, street face at Y ≈ +1001, height 20.55 m. - blender -b --factory-startup --python Tools/theatre_trim_build.py -- \ + blender -b --factory-startup --python Tools/blender/theatre_trim_build.py -- \ --out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json Everything the photographs do not resolve at this scale (exact marquee depth, @@ -27,7 +27,9 @@ import sys import bpy -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import fbx_export_preset as fbx_preset # noqa: E402 # Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east, diff --git a/Tools/blender/vehicle_build.py b/Tools/blender/vehicle_build.py new file mode 100644 index 0000000..5a54388 --- /dev/null +++ b/Tools/blender/vehicle_build.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Model a drivable sedan in Blender: body, wheels, collision and LOD. + + blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \ + --out Art/blender/vehicle/gigline-sedan.blend \ + --fbx /tmp/gigline/vehicle/sedan.fbx \ + --report /tmp/gigline/vehicle/report.json + +Scale is metres, sourced from real sedan dimensions: 4.72 m long, 1.83 m wide, +1.44 m tall, 2.78 m wheelbase. Wheels are separate objects so Chaos can attach +PhysicsAssets-lite wheel references and so the wheel radius the vehicle config needs is +read from the mesh rather than typed in twice. + +Collision is `UCX_`-prefixed convex boxes per panel group, which is what the +`collision-proxy` skill asks for and what Unreal's `ue_metric_v1` preset expects: one +hull for the body, one per wheel-less corner trimmed, and a floor slab. Simplified +hulls are deliberate — a car does not need per-panel collision for driving. + +Every measurement is logged and written to the report, so the vehicle config values +(wheelbase, track, radius) can be read from the build instead of guessed. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys + +import bpy +from mathutils import Vector + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) +import fbx_export_preset # noqa: E402 + +# Real sedan reference: length, width, height, wheelbase, track, wheel radius. +DIMS = { + "length": 4.72, + "width": 1.80, + "height": 1.44, + "wheelbase": 2.78, + "track": 1.62, + "wheel_radius": 0.33, + "wheel_width": 0.22, + "ride_height": 0.16, +} + +PARTS: list[str] = [] + + +def log(message: str) -> None: + PARTS.append(message) + print(f"[vehicle] {message}") + + +def material(name, colour, roughness=0.45, metallic=0.0): + mat = bpy.data.materials.get(name) or bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = (*colour, 1.0) + bsdf.inputs["Roughness"].default_value = roughness + bsdf.inputs["Metallic"].default_value = metallic + return mat + + +def box(name, centre, size, mat): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre) + obj = bpy.context.active_object + obj.name = name + obj.scale = size + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(mat) + return obj + + +def build_body(mats): + """Three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse. + + The first build was one tall slab plus a floating cabin, which read as a box truck. + A sedan reads from the step between the bonnet and the roof, so the volumes are + stacked instead: a slab to beltline height, a lower bonnet in front and boot behind, + and the cabin only over the middle. + """ + L, W, H = DIMS["length"], DIMS["width"], DIMS["height"] + ride = DIMS["ride_height"] + belt = 0.98 # beltline height above ground + roof = H # 1.44 + parts = [] + + # Sill slab: full length, narrow enough that the wheels stand proud of it. + parts.append(box("Body_Sill", (0.0, 0.0, ride + (belt - ride) * 0.5), + (L, 1.56, belt - ride), mats["paint"])) + # Bonnet: front 1.55 m, from beltline to a lower bonnet top. + parts.append(box("Body_Bonnet", (L * 0.5 - 0.78, 0.0, (belt + 1.06) * 0.5), + (1.56, W * 0.94, 1.06 - belt), mats["paint"])) + # Boot: rear 1.05 m, slightly lower than the bonnet. + parts.append(box("Body_Boot", (-L * 0.5 + 0.52, 0.0, (belt + 1.02) * 0.5), + (1.04, W * 0.94, 1.02 - belt), mats["paint"])) + # Cabin: the greenhouse, between bonnet and boot, narrower than the sills. + parts.append(box("Body_Cabin", (-0.02, 0.0, (1.06 + roof) * 0.5), + (2.02, W * 0.86, roof - 1.06), mats["paint"])) + + # Glazing, inset a couple of centimetres so it reads as glass, not paint. + parts.append(box("Glass_Windscreen", (0.96, 0.0, 1.24), (0.05, W * 0.78, 0.30), mats["glass"])) + parts.append(box("Glass_Rear", (-1.00, 0.0, 1.24), (0.05, W * 0.76, 0.28), mats["glass"])) + for side in (-1.0, 1.0): + parts.append(box(f"Glass_Side_{'L' if side > 0 else 'R'}", + (-0.02, side * W * 0.43, 1.24), (1.76, 0.02, 0.26), mats["glass"])) + + # Lamps, grille, bumpers, mirrors. + for side in (-1.0, 1.0): + parts.append(box(f"Lamp_Front_{'L' if side > 0 else 'R'}", + (L * 0.5 - 0.03, side * W * 0.32, 0.94), (0.05, 0.28, 0.12), mats["lamp"])) + parts.append(box(f"Lamp_Rear_{'L' if side > 0 else 'R'}", + (-L * 0.5 + 0.03, side * W * 0.32, 0.94), (0.04, 0.26, 0.10), mats["tail"])) + parts.append(box(f"Mirror_{'L' if side > 0 else 'R'}", + (0.80, side * (W * 0.5 + 0.05), 1.18), (0.08, 0.12, 0.07), mats["trim"])) + parts.append(box("Grille", (L * 0.5 - 0.02, 0.0, 0.80), (0.04, W * 0.48, 0.12), mats["trim"])) + for front in (1.0, -1.0): + parts.append(box(f"Bumper_{'F' if front > 0 else 'R'}", + (front * (L * 0.5 - 0.06), 0.0, ride + 0.14), + (0.16, W * 0.90, 0.20), mats["trim"])) + log(f"body: {len(parts)} parts, {L} x {W} x {H} m, beltline {belt} m") + return parts + + +def build_wheels(mats): + r, w = DIMS["wheel_radius"], DIMS["wheel_width"] + half_base = DIMS["wheelbase"] * 0.5 + half_track = DIMS["track"] * 0.5 + wheels = [] + for name, x, y in (("Wheel_FL", half_base, half_track), + ("Wheel_FR", half_base, -half_track), + ("Wheel_RL", -half_base, half_track), + ("Wheel_RR", -half_base, -half_track)): + bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=r, depth=w, + location=(x, y, r), rotation=(0.0, math.pi / 2, 0.0)) + wheel = bpy.context.active_object + wheel.name = name + bpy.ops.object.transform_apply(location=False, rotation=True, scale=False) + wheel.data.materials.append(mats["tyre"]) + wheels.append(wheel) + log(f"wheels: 4 at r={r} m, width {w} m, wheelbase {DIMS['wheelbase']} m, track {DIMS['track']} m") + return wheels + + +def build_collision(mats): + """One convex hull per major volume: body, cabin, and the four wheel arches. + + Unreal reads `UCX__NN` names; the exporter ships them as separate meshes and + the import preset turns them into simple collision. + """ + L, W, H = DIMS["length"], DIMS["width"], DIMS["height"] + ride = DIMS["ride_height"] + hulls = [ + box("UCX_Sedan_Body_01", (0.0, 0.0, ride + 0.40), (L * 0.98, W * 0.98, 0.70), mats["collision"]), + box("UCX_Sedan_Cabin_01", (-0.12, 0.0, ride + 0.94), (L * 0.44, W * 0.86, 0.42), mats["collision"]), + box("UCX_Sedan_BumperF_01", (L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]), + box("UCX_Sedan_BumperR_01", (-L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]), + ] + for hull in hulls: + hull.hide_render = True + hull.display_type = "WIRE" + log(f"collision: {len(hulls)} convex hulls (body, cabin, two bumpers), hidden from render") + return hulls + + +def build_skeletal(joined, wheels, out_fbx, exp_dir): + """Bind the whole vehicle to a five-bone rig for Chaos. + + Chaos Vehicles drive a *skeletal* mesh: the movement component names wheel bones and + spins them, and the physics asset supplies the collision. The static split above + stays for parts that do not deform, but the drivable asset has to be skeletal, so + this builds a rig with a root plus one bone per wheel and skins every wheel to its + own bone at full weight. + """ + bpy.ops.object.armature_add(location=(0.0, 0.0, 0.0)) + rig = bpy.context.active_object + rig.name = "VehicleRig" + bpy.ops.object.mode_set(mode="EDIT") + edit_bones = rig.data.edit_bones + root = edit_bones[0] + root.name = "root" + root.head = (0.0, 0.0, 0.0) + root.tail = (0.0, 0.0, 0.30) + half_base = DIMS["wheelbase"] * 0.5 + half_track = DIMS["track"] * 0.5 + r = DIMS["wheel_radius"] + wheel_positions = { + "Wheel_FL": (half_base, half_track), + "Wheel_FR": (half_base, -half_track), + "Wheel_RL": (-half_base, half_track), + "Wheel_RR": (-half_base, -half_track), + } + for name, (x, y) in wheel_positions.items(): + bone = edit_bones.new(name) + bone.head = (x, y, r) + # Point the bone along the axle: the wheel spins about its own Y. + bone.tail = (x, y - math.copysign(0.25, y), r) + bone.parent = root + bpy.ops.object.mode_set(mode="OBJECT") + + # Join every visible system plus the wheels into one mesh, then weight by part. + parts = [obj for obj in joined.values()] + list(wheels) + bpy.ops.object.select_all(action="DESELECT") + for part in parts: + part.select_set(True) + bpy.context.view_layer.objects.active = joined["body"] + bpy.ops.object.join() + mesh = bpy.context.active_object + mesh.name = "SM_Vehicle_Skeletal" + + groups = {name: mesh.vertex_groups.new(name=name) for name in ["root", *wheel_positions]} + # Join order is preserved per part: re-derive which vertices belong to which wheel by + # their world position, since the join loses the object boundaries. + half_base = DIMS["wheelbase"] * 0.5 + half_track = DIMS["track"] * 0.5 + for vertex in mesh.data.vertices: + co = vertex.co + nearest = None + for name, (x, y) in wheel_positions.items(): + if abs(co.x - x) < 0.45 and abs(co.y - y) < 0.16 and co.z < DIMS["wheel_radius"] * 2.2: + nearest = name + break + groups[nearest or "root"].add([vertex.index], 1.0, "REPLACE") + + armature = mesh.modifiers.new(name="Armature", type="ARMATURE") + armature.object = rig + mesh.parent = rig + + bpy.ops.object.select_all(action="DESELECT") + rig.select_set(True) + mesh.select_set(True) + bpy.context.view_layer.objects.active = rig + path = os.path.join(exp_dir, "vehicle_skeletal.fbx") + fbx_export_preset.export_character(path, selection=True) + log(f"skeletal vehicle exported: {path}, bones root + 4 wheels, " + f"{sum(len(p.vertices) - 2 for p in mesh.data.polygons)} triangles") + return path + + +def main(argv=None) -> int: + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--out", default="Art/blender/vehicle/gigline-sedan.blend") + ap.add_argument("--fbx", default="/tmp/gigline/vehicle/sedan.fbx") + ap.add_argument("--report", default="/tmp/gigline/vehicle/report.json") + args = ap.parse_args(argv) + + bpy.ops.wm.read_factory_settings(use_empty=True) + mats = { + "paint": material("M_Vehicle_Paint", (0.42, 0.05, 0.05), 0.28, 0.35), + "glass": material("M_Vehicle_Glass", (0.06, 0.08, 0.09), 0.12), + "tyre": material("M_Vehicle_Tyre", (0.02, 0.02, 0.02), 0.85), + "trim": material("M_Vehicle_Trim", (0.04, 0.04, 0.04), 0.55), + "lamp": material("M_Vehicle_Lamp", (0.85, 0.82, 0.70), 0.18), + "tail": material("M_Vehicle_Tail", (0.45, 0.03, 0.03), 0.25), + "collision": material("M_Vehicle_Collision", (0.2, 0.2, 0.2), 0.9), + } + + body = build_body(mats) + wheels = build_wheels(mats) + hulls = build_collision(mats) + + # Object split per the vehicle-artist skill: body, glass, lights and wheels stay + # separate so each can be swapped, made transparent or made emissive on its own. A + # single merged mesh is exactly what the skill forbids for a game vehicle. + systems = {} + for part in body: + name = part.name + if name.startswith("Glass_"): + key = "glass" + elif name.startswith("Lamp_"): + key = "light_front" + elif name.startswith("Lamp_Rear") or name.startswith("Lamp_Rear_"): + key = "light_rear" + elif name.startswith("Mirror_"): + key = "mirrors" + else: + key = "body" + systems.setdefault(key, []).append(part) + # Lamps were named lamp_front/lamp_rear by prefix; separate them properly. + rear = [p for p in systems.pop("light_front", []) if "Rear" in p.name] + front = [p for p in systems.get("body", []) if False] + # Collect every lamp again from the body list by name. + lamps = [p for p in body if p.name.startswith("Lamp_")] + systems["light_front"] = [p for p in lamps if "Rear" not in p.name] + systems["light_rear"] = [p for p in lamps if "Rear" in p.name] + systems["body"] = [p for p in body if not p.name.startswith( + ("Glass_", "Lamp_", "Mirror_"))] + + joined = {} + for key, parts in systems.items(): + if not parts: + continue + bpy.ops.object.select_all(action="DESELECT") + for part in parts: + part.select_set(True) + bpy.context.view_layer.objects.active = parts[0] + if len(parts) > 1: + bpy.ops.object.join() + obj = bpy.context.active_object + obj.name = { + "body": "SM_Vehicle_Body", + "glass": "SM_Vehicle_Glass", + "light_front": "SM_Vehicle_Light_Front", + "light_rear": "SM_Vehicle_Light_Rear", + "mirrors": "SM_Vehicle_Mirrors", + }[key] + joined[key] = obj + shell = joined["body"] + hull_join = hulls + log("systems: " + ", ".join(f"{k}={len(v)} parts" for k, v in systems.items())) + + os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args.out)) + log(f"saved {args.out}") + + # Export: shell + collision hulls in one file, wheels in their own file so Chaos + # can bind them to wheel bones or use them as visual wheels. + os.makedirs(os.path.dirname(os.path.abspath(args.fbx)), exist_ok=True) + exp_dir = os.path.dirname(os.path.abspath(args.fbx)) + exported = {} + for key, obj in joined.items(): + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + path = os.path.join(exp_dir, f"{key}.fbx") + fbx_export_preset.export_static(path, selection=True) + exported[key] = path + # Body carries the collision hulls so the hulls and the shell land in one asset, + # which is how Unreal wires UCX_ meshes to their mesh. + bpy.ops.object.select_all(action="DESELECT") + shell.select_set(True) + for hull in hull_join: + hull.select_set(True) + bpy.context.view_layer.objects.active = shell + fbx_export_preset.export_static(os.path.join(exp_dir, "body.fbx"), selection=True) + # One wheel asset, reused four times: Chaos binds the same wheel mesh per bone. + bpy.ops.object.select_all(action="DESELECT") + wheels[0].select_set(True) + bpy.context.view_layer.objects.active = wheels[0] + fbx_export_preset.export_static(os.path.join(exp_dir, "wheel.fbx"), selection=True) + exported["wheel"] = os.path.join(exp_dir, "wheel.fbx") + log(f"exported {len(exported)} system FBX files to {exp_dir}") + per_system = {key: sum(len(p.vertices) - 2 for p in obj.data.polygons) + for key, obj in joined.items()} + system_names = {key: obj.name for key, obj in joined.items()} + wheel_names = [w.name for w in wheels] + wheel_triangles = sum(len(p.vertices) - 2 for p in wheels[0].data.polygons) + + triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons) + shell_triangles = triangles + for hull in hull_join: + triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons) + # LODs per the vehicle-artist targets: LOD1 drops mirrors and lamp detail, LOD2 is a + # body-plus-glass shell. They are separate objects here and get wired into the + # body's LOD group at import time. + lod_paths = {} + for index, keep_glass, ratio in ((1, True, 0.55), (2, False, 0.25)): + lod_key = f"body_lod{index}" + bpy.ops.object.select_all(action="DESELECT") + shell.select_set(True) + if keep_glass and "glass" in joined: + joined["glass"].select_set(True) + bpy.context.view_layer.objects.active = shell + bpy.ops.object.duplicate() + lod = bpy.context.active_object + lod.name = f"SM_Vehicle_Body_LOD{index}" + # Decimate for real: the first version duplicated the shell unchanged, so both + # LODs came out at 84 triangles - an LOD that saves nothing. + decimate = lod.modifiers.new(name="LOD", type="DECIMATE") + decimate.ratio = ratio + bpy.ops.object.modifier_apply(modifier=decimate.name) + lod_path = os.path.join(exp_dir, f"{lod_key}.fbx") + fbx_export_preset.export_static(lod_path, selection=True) + lod_paths[lod_key] = lod_path + lod.hide_set(False) + log(f"{lod_key}: exported, {sum(len(p.vertices) - 2 for p in lod.data.polygons)} triangles") + bpy.data.objects.remove(lod, do_unlink=True) + + # Skeletal asset last: joining the parts for it destroys the objects the LOD and + # system steps need. The first attempt called it earlier and died on + # "StructRNA of type Object has been removed". + skeletal_path = build_skeletal(joined, wheels, args.fbx, exp_dir) + exported["skeletal"] = skeletal_path + + report = { + "asset": "SM_Vehicle_Sedan", + "dimensions_m": DIMS, + "body_parts": len(body), + "triangles": shell_triangles, + "collision_hulls": [h.name for h in hull_join], + "wheels": wheel_names, + "systems": {key: {"object": system_names.get(key), "triangles": per_system.get(key, 0), + "fbx": exported.get(key)} + for key in system_names}, + "wheel_triangles": wheel_triangles, + "lods": lod_paths, + "log": PARTS, + } + if args.report: + os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True) + with open(args.report, "w") as handle: + json.dump(report, handle, indent=2) + print(f"[vehicle] report -> {args.report}") + print(f"[vehicle] {triangles} triangles, {len(hull_join)} hulls") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/blender/vehicle_from_template.py b/Tools/blender/vehicle_from_template.py new file mode 100644 index 0000000..ef161b7 --- /dev/null +++ b/Tools/blender/vehicle_from_template.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""Turn the provided hot-hatch .blend into UE-ready assets. + + blender -b "Art/blender/vehicle/hot hatch.blend" \ + --python Tools/blender/vehicle_from_template.py -- \ + --out /tmp/gigline/hatch --report /tmp/gigline/hatch/report.json + +Follows two skills: + +* **vehicle-artist** — one object per system (body, wheels, glass, lights, trim, + mirrors), collision planned with the body, LODs planned before export. +* **unreal-export** — scale confirmed with a known dimension, `SM_`/`UCX_` naming, + LOD chain, and every asset measured after import. + +The template is not to scale: its longest axis is 7.21 m, which no hot hatch is. The +tool therefore normalises to a documented real length (default 4.05 m) and records the +factor, rather than exporting whatever units the file happened to use. That is the +"confirm scale with known-dimension test" step, done before the export instead of after +the import. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +import bpy + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) +import fbx_export_preset # noqa: E402 + +# A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall. +TARGET_LENGTH_M = 4.05 +TARGET_WIDTH_M = 1.75 +TARGET_HEIGHT_M = 1.42 + +PARTS: list[str] = [] + + +def log(message: str) -> None: + PARTS.append(message) + print(f"[hatch] {message}") + + +SYSTEM_RULES = ( + # (system, material substrings that identify it, object-name substrings) + ("glass", ("windows",), ("Cube.003",)), + ("lights", ("light", "reflector"), ("lights and grill", "Cylinder.003", "Cylinder.004")), + ("mirrors", ("mirror",), ("Cube.005",)), + ("wheels", ("rubber", "brake caliper", "black shiny metal"), ("Cylinder", "Cylinder.001")), + ("trim", ("plastic black", "pure black", "chrome", "black metal", "rough chrome"), + ("Cube.001", "Cube.002", "Cube.004", "Cube.006", "Cube.007", "Cylinder.002")), +) + + +def _colour_rank(colour): + """Sort key for picking a paint colour: saturation first, then brightness.""" + return (max(colour) - min(colour), sum(colour)) + + +def read_material(material): + """Base colour, roughness and metallic for a material, Principled or legacy. + + The template's car paint is a **legacy node graph** - mix shaders over glossy and + diffuse BSDFs - not a Principled shader. Reading only Principled skipped it, which is + exactly why the car arrived in Unreal near-black: the paint colour never left Blender. + For those graphs the colour is taken from the mix nodes, which is where the red lives. + """ + if not material.use_nodes: + return None + nodes = material.node_tree.nodes + bsdf = nodes.get("Principled BSDF") + if bsdf: + colour = bsdf.inputs["Base Color"].default_value + return { + "base_color": [round(colour[0], 4), round(colour[1], 4), round(colour[2], 4)], + "roughness": round(bsdf.inputs["Roughness"].default_value, 4), + "metallic": round(bsdf.inputs["Metallic"].default_value, 4), + "source": "principled", + } + # Legacy graph: the largest colour found on a mix or diffuse node is the paint. + best = None + roughness = 0.5 + metallic = 0.0 + for node in nodes: + if node.type in ("MIX", "MIX_RGB"): + for socket in node.inputs: + try: + value = socket.default_value + except Exception: # noqa: BLE001 - not every socket has a default + continue + if not hasattr(value, "__len__") or len(value) < 3: + continue + candidate = (value[0], value[1], value[2]) + if max(candidate) < 0.03: + continue + # Prefer the most saturated colour, not the brightest: the paint's red + # (0.49, 0, 0) is darker than the 0.8 grey diffuse beside it, and a + # "brightest wins" rule picked the grey - which is how a red car reaches + # Unreal as a grey one. + if best is None or _colour_rank(candidate) > _colour_rank(best): + best = candidate + elif node.type == "BSDF_DIFFUSE": + colour = node.inputs["Color"].default_value + candidate = (colour[0], colour[1], colour[2]) + if best is None or _colour_rank(candidate) > _colour_rank(best): + best = candidate + elif node.type == "BSDF_GLOSSY": + try: + roughness = round(node.inputs["Roughness"].default_value, 4) + except Exception: # noqa: BLE001 + pass + if best is None: + return None + return { + "base_color": [round(best[0], 4), round(best[1], 4), round(best[2], 4)], + "roughness": roughness, + "metallic": metallic, + "source": "legacy graph", + } + + +def ensure_principled(material): + """Rewire a legacy graph to a Principled BSDF so the FBX export carries a colour. + + The template's car paint is a mix-shader graph, and the FBX exporter cannot map that - + which is why SM_Vehicle_Body arrived in Unreal holding `WorldGridMaterial` (the default + fallback) and rendered black while Blender showed red. Converting in place keeps the + colour and makes the export uniform. + """ + if not material.use_nodes: + return False + nodes = material.node_tree.nodes + if nodes.get("Principled BSDF"): + return False + spec = read_material(material) + if not spec: + return False + nodes.clear() + output = nodes.new("ShaderNodeOutputMaterial") + bsdf = nodes.new("ShaderNodeBsdfPrincipled") + colour = spec["base_color"] + bsdf.inputs["Base Color"].default_value = (colour[0], colour[1], colour[2], 1.0) + bsdf.inputs["Roughness"].default_value = max(0.05, spec.get("roughness", 0.5)) + bsdf.inputs["Metallic"].default_value = spec.get("metallic", 0.0) + material.node_tree.links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) + log(f"converted {material.name} to Principled, base " + f"{tuple(round(c, 3) for c in colour)}") + return True + + +def classify(obj): + """Assign an object to a system by its materials first, then by its name.""" + materials = [m.name.lower() for m in obj.data.materials if m] + name = obj.name.lower() + for system, materials_hint, names_hint in SYSTEM_RULES: + if any(hint in name for hint in (n.lower() for n in names_hint)): + return system + if any(any(h in material for h in materials_hint) for material in materials): + return system + return "body" + + +def min_z(objects): + from mathutils import Vector + lowest = None + for obj in objects: + for corner in obj.bound_box: + z = (obj.matrix_world @ Vector(corner)).z + lowest = z if lowest is None else min(lowest, z) + return lowest if lowest is not None else 0.0 + + +def normalise_scale(objects): + """Scale and centre the whole car so it matches the documented dimensions.""" + xs, ys, zs = [], [], [] + for obj in objects: + for corner in obj.bound_box: + world = obj.matrix_world @ __import__("mathutils").Vector(corner) + xs.append(world.x) + ys.append(world.y) + zs.append(world.z) + length = max(ys) - min(ys) + width = max(xs) - min(xs) + height = max(zs) - min(zs) + log(f"template bounds: {length:.2f} x {width:.2f} x {height:.2f} m") + factor = TARGET_LENGTH_M / length if length > 0.01 else 1.0 + log(f"scale factor {factor:.4f} (target length {TARGET_LENGTH_M} m, " + f"was {length:.2f} m)") + + for obj in objects: + obj.scale = tuple(s * factor for s in obj.scale) + obj.location = tuple(l * factor for l in obj.location) + bpy.context.view_layer.update() + + # Drop the car onto Z 0 and centre it on the origin in X/Y. Ground on the WHEELS, not + # on the whole model: the lowest geometry in the file is exhaust/underbody, so grounding + # on everything left the tyres floating by the difference. + xs, ys, zs = [], [], [] + for obj in objects: + for corner in obj.bound_box: + world = obj.matrix_world @ __import__("mathutils").Vector(corner) + xs.append(world.x) + ys.append(world.y) + zs.append(world.z) + wheels = [o for o in objects if "rubber" in [m.name.lower() for m in o.data.materials if m]] + ground = min_z(wheels) if wheels else min(zs) + log(f"grounding on {'wheels' if wheels else 'whole model'}: lowest Z {ground:.3f} m") + offset = __import__("mathutils").Vector( + (-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -ground)) + for obj in objects: + obj.location = tuple(l + o for l, o in zip(obj.location, offset)) + bpy.context.view_layer.update() + log(f"centred and grounded (offset {offset.x:.2f}, {offset.y:.2f}, {offset.z:.2f} m)") + + +def join_system(objects, name): + bpy.ops.object.select_all(action="DESELECT") + for obj in objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + if len(objects) > 1: + bpy.ops.object.join() + joined = bpy.context.active_object + joined.name = name + return joined + + +def add_collision(body): + """One convex box per major volume, `UCX_`-prefixed for Unreal.""" + hulls = [] + xs, ys, zs = [], [], [] + for corner in body.bound_box: + world = body.matrix_world @ __import__("mathutils").Vector(corner) + xs.append(world.x) + ys.append(world.y) + zs.append(world.z) + length, width, height = max(ys) - min(ys), max(xs) - min(xs), max(zs) - min(zs) + centre = ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0, + min(zs) + height * 0.5) + bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre) + hull = bpy.context.active_object + hull.name = "UCX_SM_Vehicle_Body_01" + hull.scale = (width * 0.96, length * 0.98, height * 0.62) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + hull.hide_render = True + hulls.append(hull) + log(f"collision: 1 hull {width*0.96:.2f} x {length*0.98:.2f} x {height*0.62:.2f} m") + return hulls + + +def decimate_copy(source, name, ratio): + bpy.ops.object.select_all(action="DESELECT") + source.select_set(True) + bpy.context.view_layer.objects.active = source + bpy.ops.object.duplicate() + copy = bpy.context.active_object + copy.name = name + modifier = copy.modifiers.new(name="LOD", type="DECIMATE") + modifier.ratio = ratio + bpy.ops.object.modifier_apply(modifier=modifier.name) + return copy + + +def main(argv=None) -> int: + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--out", default="/tmp/gigline/hatch") + ap.add_argument("--report", default="/tmp/gigline/hatch/report.json") + args = ap.parse_args(argv) + + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + if not meshes: + raise SystemExit("no meshes in the template") + log(f"template: {len(meshes)} mesh objects, " + f"{sum(len(o.data.vertices) for o in meshes)} vertices") + + normalise_scale(meshes) + + systems = {} + for obj in meshes: + systems.setdefault(classify(obj), []).append(obj) + log("systems: " + ", ".join(f"{k}={len(v)} objects" for k, v in systems.items())) + + os.makedirs(args.out, exist_ok=True) + exported = {} + joined = {} + for system, objects in systems.items(): + name = { + "body": "SM_Vehicle_Body", + "glass": "SM_Vehicle_Glass", + "lights": "SM_Vehicle_Lights", + "mirrors": "SM_Vehicle_Mirrors", + "wheels": "SM_Vehicle_Wheels", + "trim": "SM_Vehicle_Trim", + }[system] + obj = join_system(objects, name) + joined[system] = obj + path = os.path.join(args.out, f"{system}.fbx") + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + fbx_export_preset.export_static(path, selection=True) + exported[system] = path + tris = sum(len(p.vertices) - 2 for p in obj.data.polygons) + log(f"{name}: {tris} triangles -> {path}") + + hulls = add_collision(joined["body"]) + bpy.ops.object.select_all(action="DESELECT") + joined["body"].select_set(True) + for hull in hulls: + hull.select_set(True) + bpy.context.view_layer.objects.active = joined["body"] + fbx_export_preset.export_static(os.path.join(args.out, "body.fbx"), selection=True) + log("body re-exported with its UCX hull") + + lods = {} + for index, ratio in ((1, 0.35), (2, 0.12)): + lod = decimate_copy(joined["body"], f"SM_Vehicle_Body_LOD{index}", ratio) + path = os.path.join(args.out, f"body_lod{index}.fbx") + bpy.ops.object.select_all(action="DESELECT") + lod.select_set(True) + bpy.context.view_layer.objects.active = lod + fbx_export_preset.export_static(path, selection=True) + tris = sum(len(p.vertices) - 2 for p in lod.data.polygons) + lods[f"body_lod{index}"] = {"path": path, "triangles": tris} + log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles") + bpy.data.objects.remove(lod, do_unlink=True) + + converted = sum(1 for m in bpy.data.materials if ensure_principled(m)) + log(f"legacy materials converted for export: {converted}") + + materials = {} + for material in bpy.data.materials: + found = read_material(material) + if found: + materials[material.name] = found + principled = sum(1 for m in bpy.data.materials + if m.use_nodes and m.node_tree.nodes.get("Principled BSDF")) + log(f"materials: {len(materials)} read, {principled} of them Principled, " + f"{len(materials) - principled} from legacy graphs") + + report = { + "template": bpy.data.filepath, + "materials": materials, + "target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M, + "height": TARGET_HEIGHT_M}, + "systems": {system: {"object": joined[system].name, + "triangles": sum(len(p.vertices) - 2 + for p in joined[system].data.polygons), + "fbx": exported.get(system)} + for system in joined}, + "lods": lods, + "collision": [h.name for h in hulls], + "log": PARTS, + } + os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True) + with open(args.report, "w") as handle: + json.dump(report, handle, indent=2) + print(f"[hatch] report -> {args.report}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/comfyui_generate.py b/Tools/comfyui_generate.py deleted file mode 100644 index 268c0e9..0000000 --- a/Tools/comfyui_generate.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -"""Generate image assets from the local ComfyUI instance. - -The project needs textures it cannot photograph: skin detail, hair strands, fabric -and surface grime. ComfyUI runs locally at `127.0.0.1:8188`, so this is a -**human-triggered local tool**, not a network service — nothing in CI calls it, and -no credential is involved. - - # what is installed - python3 Tools/comfyui_generate.py models - - # one image - python3 Tools/comfyui_generate.py txt2img \ - --prompt "seamless photoreal human skin texture, fine pores, neutral light" \ - --negative "text, watermark, hair, clothing" \ - --model juggernautXL_ragnarok.safetensors \ - --size 1024x1024 --seed 7 \ - --out Art/textures/generated/T_Character_SkinDetail.png - -Outputs land in the given path and a JSON sidecar records the exact prompt, model, -seed and workflow, so an asset can be regenerated or rejected later without -guessing how it was made. - -Licence: the output is a generated image from a locally hosted model. `THIRD_PARTY -NOTICES.md` must record the model and its licence before the texture ships; the -sidecar carries what is needed to do that. -""" - -from __future__ import annotations - -import argparse -import json -import os -import random -import sys -import time -import urllib.error -import urllib.parse -import urllib.request - -HOST = os.environ.get("COMFYUI_HOST", "http://127.0.0.1:8188") -DEFAULT_MODEL = "juggernautXL_ragnarok.safetensors" - - -def api(path: str) -> dict: - with urllib.request.urlopen(f"{HOST}{path}", timeout=30) as response: - return json.loads(response.read().decode()) - - -def post(path: str, body: dict) -> dict: - request = urllib.request.Request( - f"{HOST}{path}", data=json.dumps(body).encode(), method="POST", - headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(request, timeout=60) as response: - raw = response.read().decode() - return json.loads(raw) if raw.strip() else {} - - -def checkpoints() -> list[str]: - info = api("/object_info/CheckpointLoaderSimple") - return info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0] - - -def workflow(prompt: str, negative: str, model: str, width: int, height: int, - seed: int, steps: int, cfg: float, sampler: str, scheduler: str) -> dict: - """A minimal SDXL/SD1.5 txt2img graph in ComfyUI's API format. - - Node ids are strings and references are `[node_id, output_index]`, which is the - format `/prompt` expects — the UI's workflow JSON is a different shape and cannot - be posted directly. - """ - return { - "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}}, - "2": {"class_type": "CLIPTextEncode", - "inputs": {"text": prompt, "clip": ["1", 1]}}, - "3": {"class_type": "CLIPTextEncode", - "inputs": {"text": negative, "clip": ["1", 1]}}, - "4": {"class_type": "EmptyLatentImage", - "inputs": {"width": width, "height": height, "batch_size": 1}}, - "5": {"class_type": "KSampler", - "inputs": {"seed": seed, "steps": steps, "cfg": cfg, - "sampler_name": sampler, "scheduler": scheduler, - "denoise": 1.0, "model": ["1", 0], - "positive": ["2", 0], "negative": ["3", 0], - "latent_image": ["4", 0]}}, - "6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}}, - "7": {"class_type": "SaveImage", - "inputs": {"images": ["6", 0], "filename_prefix": "gigline"}}, - } - - -def wait_for_image(prompt_id: str, timeout: float = 300.0, poll: float = 1.5): - """Poll /history until the job has an image, then fetch its bytes.""" - deadline = time.time() + timeout - while time.time() < deadline: - history = api(f"/history/{prompt_id}") - entry = history.get(prompt_id) - if entry and entry.get("outputs"): - images = [] - for node in entry["outputs"].values(): - images.extend(node.get("images", [])) - if not images: - return None - image = images[0] - query = urllib.parse.urlencode({ - "filename": image["filename"], - "subfolder": image.get("subfolder", ""), - "type": image.get("type", "output")}) - with urllib.request.urlopen(f"{HOST}/view?{query}", timeout=60) as response: - return response.read() - time.sleep(poll) - return None - - -def generate(args) -> int: - width, height = (int(v) for v in args.size.lower().split("x")) - seed = args.seed if args.seed is not None else random.randrange(0, 2**31) - graph = workflow(args.prompt, args.negative, args.model, width, height, seed, - args.steps, args.cfg, args.sampler, args.scheduler) - queued = post("/prompt", {"prompt": graph}) - prompt_id = queued.get("prompt_id") - if not prompt_id: - print(f"error: ComfyUI rejected the prompt: {queued}", file=sys.stderr) - return 1 - print(f"queued {prompt_id} ({args.model}, {width}x{height}, seed {seed})") - - data = wait_for_image(prompt_id, timeout=args.timeout, poll=args.poll) - if data is None: - print("error: no image came back (timeout or empty job)", file=sys.stderr) - return 1 - os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True) - with open(args.out, "wb") as handle: - handle.write(data) - sidecar = os.path.splitext(args.out)[0] + ".json" - with open(sidecar, "w") as handle: - json.dump({ - "tool": "Tools/comfyui_generate.py", - "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "prompt": args.prompt, - "negative": args.negative, - "model": args.model, - "size": [width, height], - "seed": seed, - "steps": args.steps, - "cfg": args.cfg, - "sampler": args.sampler, - "scheduler": args.scheduler, - "workflow": graph, - "comfyui_prompt_id": prompt_id, - "note": "generated locally; record the model licence in " - "THIRD_PARTY_NOTICES.md before shipping", - }, handle, indent=2) - print(f"wrote {args.out} (+ sidecar)") - return 0 - - - - -def main(argv=None) -> int: - argv = sys.argv[1:] if argv is None else argv - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - sub = ap.add_subparsers(dest="cmd", required=True) - sub.add_parser("models", help="list installed checkpoints") - t = sub.add_parser("txt2img", help="generate one image") - t.add_argument("--prompt", required=True) - t.add_argument("--negative", default="text, watermark, signature, blurry, low quality") - t.add_argument("--model", default=DEFAULT_MODEL) - t.add_argument("--size", default="1024x1024") - t.add_argument("--seed", type=int, default=None) - t.add_argument("--steps", type=int, default=28) - t.add_argument("--cfg", type=float, default=5.5) - t.add_argument("--sampler", default="dpmpp_2m") - t.add_argument("--scheduler", default="karras") - t.add_argument("--out", required=True) - t.add_argument("--timeout", type=float, default=300.0) - t.add_argument("--poll", type=float, default=1.5) - args = ap.parse_args(argv) - - if args.cmd == "models": - for name in checkpoints(): - print(name) - return 0 - return generate(args) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/Tools/gen/comfyui_generate.py b/Tools/gen/comfyui_generate.py new file mode 100644 index 0000000..2704677 --- /dev/null +++ b/Tools/gen/comfyui_generate.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Generate image assets from the local ComfyUI instance. + +The project needs textures it cannot photograph: skin detail, hair strands, fabric +and surface grime. ComfyUI runs locally at `127.0.0.1:8188`, so this is a +**human-triggered local tool**, not a network service — nothing in CI calls it, and +no credential is involved. + + # what is installed + python3 Tools/gen/comfyui_generate.py models + + # one image + python3 Tools/gen/comfyui_generate.py txt2img \ + --prompt "seamless photoreal human skin texture, fine pores, neutral light" \ + --negative "text, watermark, hair, clothing" \ + --model juggernautXL_ragnarok.safetensors \ + --size 1024x1024 --seed 7 \ + --out Art/textures/generated/T_Character_SkinDetail.png + +Outputs land in the given path and a JSON sidecar records the exact prompt, model, +seed and workflow, so an asset can be regenerated or rejected later without +guessing how it was made. + +The server is a **Stability Matrix** install and nothing here starts it: if the tool cannot reach +`127.0.0.1:8188`, start Stability Matrix, launch ComfyUI, and re-run. The install, its launch arguments and +its model library are documented in `/home/zwitschi/StabilityMatrix/Data/README.md`. + +Licence: the output is a generated image from a locally hosted model. `THIRD_PARTY +NOTICES.md` must record the model and its licence before the texture ships; the +sidecar carries what is needed to do that. +""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +HOST = os.environ.get("COMFYUI_HOST", "http://127.0.0.1:8188") +DEFAULT_MODEL = "juggernautXL_ragnarok.safetensors" +# A second checkpoint by default: one model's texture is a lottery ticket, two is a comparison. Both are +# generated from the identical prompt and seed, then scored, and only the winner takes the output path. +DEFAULT_ALT_MODEL = "epicrealismXL_pureFix.safetensors" + + +def score_image(path): + """A deterministic, offline 'how much detail did this texture actually get' score. + + Mean absolute Laplacian: high-frequency energy. Generated textures that are blurry or flat score low, + which is the failure this comparison exists to catch. Pillow is used only here, so a host without it + still generates and simply skips scoring. + """ + try: + from PIL import Image, ImageFilter + except ImportError: # pragma: no cover - host without Pillow + return None + with Image.open(path) as image: + grey = image.convert("L").filter(ImageFilter.FIND_EDGES) + pixels = list(grey.getdata()) + return sum(pixels) / max(1, len(pixels)) + + +# The four verified graphs in the Stability Matrix install +# (/home/zwitschi/StabilityMatrix/Data/Workflows, documented in Workflows/texture_generation.md). +# They are stored in ComfyUI's UI format, which /prompt cannot take, so the equivalent API graphs are +# assembled here with exactly the settings those files use. +TEXTURE_PROFILES = { + "albedobase": dict( + label="AlbedoBase XL v3.1 (SDXL) - game albedo, commercial use with credit", + checkpoint="albedobaseXL_v31Large.safetensors", + steps=30, cfg=6.0, sampler="dpmpp_2m", scheduler="karras", + negative="shadows, perspective, vignette, dramatic lighting, blurry, low quality, text, watermark", + negative_style="text", clip_type=None, vae=None, + ), + "krea2": dict( + label="Krea-2 Turbo NVFP4 - fast stochastic detail (stone, bark, foliage)", + unet="krea2_turbo_nvfp4.safetensors", + clip="qwen3vl_4b_int8_convrot.safetensors", clip_type="krea2", + vae="texture_fix_vae_for_qwen_image_1_and_krea_2_bf16.safetensors", + steps=8, cfg=1.0, sampler="euler", scheduler="simple", + negative="", negative_style="zero_out", + ), + "qwen21": dict( + label="Qwen-Image 2.1 int8 - material sheets with readable labels (research licence)", + unet="qwen_image_2.1_int8_convrot.safetensors", + clip="qwen3vl_8b_w4a8.safetensors", clip_type="qwen_image", + vae="texture_fix_vae_for_qwen_image_2.1_bf16.safetensors", + steps=25, cfg=1.0, sampler="euler", scheduler="simple", + negative="", negative_style="none", + ), +} +DEFAULT_TEXTURE_PROFILES = ("albedobase", "krea2") + +TEXTURE_PROMPT = ("top-down orthographic game texture, flat even lighting, no shadows, no AO, " + "no vignette, PBR albedo, material scan, neutral colours") + + +def texture_workflow(profile: str, prompt: str, width: int, height: int, seed: int) -> dict: + """API graph for one of the documented texture workflows. + + Text-to-image only: the graphs produce a colour/albedo sheet, and derived normal/roughness maps are + made downstream. None of them guarantee wrap-around edges - ComfyUI 0.44 core has no seamless sampler - + so a tiling check is still required. + """ + spec = TEXTURE_PROFILES[profile] + graph = {} + if "checkpoint" in spec: + graph["1"] = {"class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": spec["checkpoint"]}} + model_ref, clip_ref = ["1", 0], ["1", 1] + vae_ref = ["1", 2] + else: + graph["1"] = {"class_type": "UNETLoader", + "inputs": {"unet_name": spec["unet"], "weight_dtype": "default"}} + graph["2"] = {"class_type": "CLIPLoader", + "inputs": {"clip_name": spec["clip"], "type": spec["clip_type"], + "device": "default"}} + graph["3"] = {"class_type": "VAELoader", "inputs": {"vae_name": spec["vae"]}} + model_ref, clip_ref, vae_ref = ["1", 0], ["2", 0], ["3", 0] + + encode = "TextEncodeQwenImage21" if profile == "qwen21" else "CLIPTextEncode" + graph["4"] = {"class_type": encode, "inputs": {"text": prompt, "clip": clip_ref}} + if spec["negative_style"] == "zero_out": + graph["5"] = {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}} + negative_ref = ["5", 0] + elif spec["negative_style"] == "text": + graph["5"] = {"class_type": "CLIPTextEncode", + "inputs": {"text": spec["negative"], "clip": clip_ref}} + negative_ref = ["5", 0] + else: + negative_ref = ["4", 0] + graph["6"] = {"class_type": "EmptyLatentImage", + "inputs": {"width": width, "height": height, "batch_size": 1}} + graph["7"] = {"class_type": "KSampler", + "inputs": {"seed": seed, "steps": spec["steps"], "cfg": spec["cfg"], + "sampler_name": spec["sampler"], "scheduler": spec["scheduler"], + "denoise": 1.0, "model": model_ref, "positive": ["4", 0], + "negative": negative_ref, "latent_image": ["6", 0]}} + graph["8"] = {"class_type": "VAEDecode", + "inputs": {"samples": ["7", 0], "vae": vae_ref}} + graph["9"] = {"class_type": "SaveImage", + "inputs": {"images": ["8", 0], "filename_prefix": f"texture_{profile}"}} + return graph + + +def api(path: str) -> dict: + with urllib.request.urlopen(f"{HOST}{path}", timeout=30) as response: + return json.loads(response.read().decode()) + + +def post(path: str, body: dict) -> dict: + request = urllib.request.Request( + f"{HOST}{path}", data=json.dumps(body).encode(), method="POST", + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read().decode() + return json.loads(raw) if raw.strip() else {} + + +def checkpoints() -> list[str]: + info = api("/object_info/CheckpointLoaderSimple") + return info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0] + + +def workflow(prompt: str, negative: str, model: str, width: int, height: int, + seed: int, steps: int, cfg: float, sampler: str, scheduler: str) -> dict: + """A minimal SDXL/SD1.5 txt2img graph in ComfyUI's API format. + + Node ids are strings and references are `[node_id, output_index]`, which is the + format `/prompt` expects — the UI's workflow JSON is a different shape and cannot + be posted directly. + """ + return { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}}, + "2": {"class_type": "CLIPTextEncode", + "inputs": {"text": prompt, "clip": ["1", 1]}}, + "3": {"class_type": "CLIPTextEncode", + "inputs": {"text": negative, "clip": ["1", 1]}}, + "4": {"class_type": "EmptyLatentImage", + "inputs": {"width": width, "height": height, "batch_size": 1}}, + "5": {"class_type": "KSampler", + "inputs": {"seed": seed, "steps": steps, "cfg": cfg, + "sampler_name": sampler, "scheduler": scheduler, + "denoise": 1.0, "model": ["1", 0], + "positive": ["2", 0], "negative": ["3", 0], + "latent_image": ["4", 0]}}, + "6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}}, + "7": {"class_type": "SaveImage", + "inputs": {"images": ["6", 0], "filename_prefix": "gigline"}}, + } + + +def wait_for_image(prompt_id: str, timeout: float = 300.0, poll: float = 1.5): + """Poll /history until the job has an image, then fetch its bytes.""" + deadline = time.time() + timeout + while time.time() < deadline: + history = api(f"/history/{prompt_id}") + entry = history.get(prompt_id) + if entry and entry.get("outputs"): + images = [] + for node in entry["outputs"].values(): + images.extend(node.get("images", [])) + if not images: + return None + image = images[0] + query = urllib.parse.urlencode({ + "filename": image["filename"], + "subfolder": image.get("subfolder", ""), + "type": image.get("type", "output")}) + with urllib.request.urlopen(f"{HOST}/view?{query}", timeout=60) as response: + return response.read() + time.sleep(poll) + return None + + +def run_graph(graph, out_path, meta) -> int: + """Queue one API graph, wait for the image, write it plus a provenance sidecar.""" + queued = post("/prompt", {"prompt": graph}) + prompt_id = queued.get("prompt_id") + if not prompt_id: + print(f"error: ComfyUI rejected the prompt: {queued}", file=sys.stderr) + return 1 + data = wait_for_image(prompt_id, timeout=meta["timeout"], poll=meta["poll"]) + if data is None: + print("error: no image came back (timeout or empty job)", file=sys.stderr) + return 1 + os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True) + with open(out_path, "wb") as handle: + handle.write(data) + meta = dict(meta) + meta.pop("timeout", None) + meta.pop("poll", None) + meta.update({"tool": "Tools/gen/comfyui_generate.py", + "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "workflow": graph, "comfyui_prompt_id": prompt_id, + "note": "generated locally; record the model licence in " + "THIRD_PARTY_NOTICES.md before shipping"}) + with open(os.path.splitext(out_path)[0] + ".json", "w") as handle: + json.dump(meta, handle, indent=2) + print(f"wrote {out_path} (+ sidecar)") + return 0 + + +def texture(args) -> int: + """Generate a material sheet with the documented texture workflows, two profiles by default.""" + width, height = (int(v) for v in args.size.lower().split("x")) + base_seed = args.seed if args.seed is not None else random.randrange(0, 2**31) + prompt = args.prompt or f"{TEXTURE_PROMPT}, {args.material}" + profiles = [p for p in (args.profile, args.alt_profile) if p] + if not profiles: + profiles = list(DEFAULT_TEXTURE_PROFILES) + stem, ext = os.path.splitext(args.out) + candidates = [] + for name in profiles: + path = f"{stem}__{name}{ext}" + graph = texture_workflow(name, prompt, width, height, base_seed) + meta = {"prompt": prompt, "profile": name, "size": [width, height], "seed": base_seed, + "settings": {k: TEXTURE_PROFILES[name][k] for k in + ("steps", "cfg", "sampler", "scheduler")}, + "licence": TEXTURE_PROFILES[name]["label"], + "timeout": args.timeout, "poll": args.poll} + if run_graph(graph, path, meta) != 0: + print(f"profile {name} failed; continuing") + continue + candidates.append((name, path, score_image(path))) + if not candidates: + return 1 + scored = [c for c in candidates if c[2] is not None] + winner = max(scored, key=lambda c: c[2]) if scored else candidates[0] + for name, path, score in candidates: + print(f" {name}: {'n/a' if score is None else round(score, 2)} {path}") + os.replace(winner[1], args.out) + if os.path.exists(winner[1] + ".json"): + os.replace(winner[1] + ".json", args.out + ".json") + print(f"chose {winner[0]} -> {args.out}") + return 0 + + +def generate(args) -> int: + width, height = (int(v) for v in args.size.lower().split("x")) + seed = args.seed if args.seed is not None else random.randrange(0, 2**31) + graph = workflow(args.prompt, args.negative, args.model, width, height, seed, + args.steps, args.cfg, args.sampler, args.scheduler) + queued = post("/prompt", {"prompt": graph}) + prompt_id = queued.get("prompt_id") + if not prompt_id: + print(f"error: ComfyUI rejected the prompt: {queued}", file=sys.stderr) + return 1 + print(f"queued {prompt_id} ({args.model}, {width}x{height}, seed {seed})") + + data = wait_for_image(prompt_id, timeout=args.timeout, poll=args.poll) + if data is None: + print("error: no image came back (timeout or empty job)", file=sys.stderr) + return 1 + os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True) + with open(args.out, "wb") as handle: + handle.write(data) + sidecar = os.path.splitext(args.out)[0] + ".json" + with open(sidecar, "w") as handle: + json.dump({ + "tool": "Tools/gen/comfyui_generate.py", + "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "prompt": args.prompt, + "negative": args.negative, + "model": args.model, + "size": [width, height], + "seed": seed, + "steps": args.steps, + "cfg": args.cfg, + "sampler": args.sampler, + "scheduler": args.scheduler, + "workflow": graph, + "comfyui_prompt_id": prompt_id, + "note": "generated locally; record the model licence in " + "THIRD_PARTY_NOTICES.md before shipping", + }, handle, indent=2) + print(f"wrote {args.out} (+ sidecar)") + return 0 + + + + +def generate_pair(args) -> int: + """Generate the same prompt with two checkpoints and keep the better one at `--out`. + + Both are written beside the output as `__.png` with their own sidecars, so the losing + candidate is kept for inspection instead of thrown away. The winner is chosen by `score_image()`; when + Pillow is missing or the scores tie, the primary model keeps the output and the run says so. + """ + import copy + + stem, ext = os.path.splitext(args.out) + candidates = [] + for label, model in (("primary", args.model), ("alternate", args.alt_model)): + out = f"{stem}__{model.replace('/', '_')[:40]}{ext}" + sub_args = copy.copy(args) + sub_args.model = model + sub_args.out = out + if generate(sub_args) != 0: + print(f"{label} model {model} failed; continuing with what worked") + continue + candidates.append((model, out, score_image(out))) + if not candidates: + return 1 + scored = [c for c in candidates if c[2] is not None] + if not scored: + print(f"no scoring available; keeping {candidates[0][1]} as the output") + winner = candidates[0] + else: + winner = max(scored, key=lambda c: c[2]) + for model, path, score in candidates: + print(f" {model}: {score if score is None else round(score, 2)} {path}") + os.replace(winner[1], args.out) + sidecar = winner[1] + ".json" + if os.path.exists(sidecar): + os.replace(sidecar, args.out + ".json") + print(f"chose {winner[0]} -> {args.out}") + return 0 + + +def main(argv=None) -> int: + argv = sys.argv[1:] if argv is None else argv + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("models", help="list installed checkpoints") + t = sub.add_parser("txt2img", help="generate one image") + t.add_argument("--prompt", required=True) + t.add_argument("--negative", default="text, watermark, signature, blurry, low quality") + t.add_argument("--model", default=DEFAULT_MODEL) + t.add_argument("--alt-model", default=DEFAULT_ALT_MODEL, + help=f"second checkpoint generated from the same prompt and seed, then compared " + f"(default {DEFAULT_ALT_MODEL}); pass an empty string to generate one image only") + t.add_argument("--size", default="1024x1024") + t.add_argument("--seed", type=int, default=None) + t.add_argument("--steps", type=int, default=28) + t.add_argument("--cfg", type=float, default=5.5) + t.add_argument("--sampler", default="dpmpp_2m") + t.add_argument("--scheduler", default="karras") + t.add_argument("--out", required=True) + t.add_argument("--timeout", type=float, default=300.0) + t.add_argument("--poll", type=float, default=1.5) + x = sub.add_parser("texture", help="material sheet via the documented texture workflows") + x.add_argument("--material", required=True, + help="what to draw, e.g. 'weathered grey limestone ashlar'") + x.add_argument("--prompt", default="", help="full prompt, overriding the material shorthand") + x.add_argument("--profile", default=DEFAULT_TEXTURE_PROFILES[0], + choices=sorted(TEXTURE_PROFILES)) + x.add_argument("--alt-profile", default=DEFAULT_TEXTURE_PROFILES[1], + choices=sorted(TEXTURE_PROFILES) + [""], + help="second workflow, generated from the same prompt and seed, then compared") + x.add_argument("--size", default="1024x1024") + x.add_argument("--seed", type=int, default=None) + x.add_argument("--out", required=True) + x.add_argument("--timeout", type=float, default=300.0) + x.add_argument("--poll", type=float, default=1.5) + args = ap.parse_args(argv) + + if args.cmd == "texture": + return texture(args) + if args.cmd == "models": + for name in checkpoints(): + print(name) + return 0 + if args.alt_model and args.alt_model != args.model: + return generate_pair(args) + return generate(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/commons_fetch.py b/Tools/gen/commons_fetch.py similarity index 96% rename from Tools/commons_fetch.py rename to Tools/gen/commons_fetch.py index 38845b4..55d6f3e 100755 --- a/Tools/commons_fetch.py +++ b/Tools/gen/commons_fetch.py @@ -7,13 +7,13 @@ committed: the output directory is gitignored. The tool records provenance working copy stays traceable. # dry run: list what the search returns, download nothing - python3 Tools/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --dry-run + python3 Tools/gen/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --dry-run # download up to 6 images at 1600 px wide - python3 Tools/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --limit 6 + python3 Tools/gen/commons_fetch.py --query "Grauman's Chinese Theatre" --slug north-chinese --limit 6 # fetch specific files by title - python3 Tools/commons_fetch.py --file "File:Grauman's Chinese Theatre, Hollywood.JPG" --slug north-chinese + python3 Tools/gen/commons_fetch.py --file "File:Grauman's Chinese Theatre, Hollywood.JPG" --slug north-chinese Defaults: out dir `Art/reference//`, 1600 px wide thumbnails, one request per second, JPEG/PNG only, at least 800 px wide on the original. @@ -245,7 +245,7 @@ def main() -> int: "requested_files": args.file, "slug": args.slug, "thumb_width": args.width, - "tool": "Tools/commons_fetch.py", + "tool": "Tools/gen/commons_fetch.py", "note": "local development reference only; not committed to the repository", "images": records, } diff --git a/Tools/facade_detail_concepts.py b/Tools/gen/facade_detail_concepts.py similarity index 98% rename from Tools/facade_detail_concepts.py rename to Tools/gen/facade_detail_concepts.py index 90a523f..93fcc99 100644 --- a/Tools/facade_detail_concepts.py +++ b/Tools/gen/facade_detail_concepts.py @@ -10,10 +10,10 @@ pass has something measurable instead of a tourist photo. Deliberately **not** close-up detail crops: a doorway or cornice crop cannot be measured for proportion, which is what the modelling pass actually needs. - python3 Tools/facade_detail_concepts.py --list - python3 Tools/facade_detail_concepts.py --only door-recess,parapet-motto - python3 Tools/facade_detail_concepts.py --all - python3 Tools/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image + python3 Tools/gen/facade_detail_concepts.py --list + python3 Tools/gen/facade_detail_concepts.py --only door-recess,parapet-motto + python3 Tools/gen/facade_detail_concepts.py --all + python3 Tools/gen/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image Output: `Art/reference/facade-details/.png` plus a `.refgen.json` sidecar recording the model, the source photograph, the prompt and the SHA-256 of @@ -45,7 +45,7 @@ import time import urllib.error import urllib.request -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) REFERENCE_DIR = os.path.join(ROOT, "Art", "reference") OUT_DIR = os.path.join(REFERENCE_DIR, "facade-details") API_URL = "https://openrouter.ai/api/v1/chat/completions" diff --git a/Tools/gen/generate_banner_texture.py b/Tools/gen/generate_banner_texture.py new file mode 100644 index 0000000..cab6ad4 --- /dev/null +++ b/Tools/gen/generate_banner_texture.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Generate the JIMMY KIMMEL LIVE banner texture for the hero facade. + +The three event banners are flat panels; the lettering is text and cannot be +read as geometry at 45 m. An image model would mangle a specific trademark +string, so the text is drawn here with Pillow and a system font instead: +deterministic, exact, and (unlike Qwen-Image 2.1) not research-only. + +The banner face is 1.35 m wide x 6.2 m tall (aspect 1:4.59). The texture is +written at 512 x 2304 (aspect 1:4.5), a 2% stretch that is invisible at the +review distance. + + python3 Tools/gen/generate_banner_texture.py + python3 Tools/gen/generate_banner_texture.py --out Art/textures/generated/T_Banner_KIMMEL.png + +Requires Pillow (host-only tool, not a gate; standard library is not enough to +rasterise a font). The text is a trademark reproduced for internal development +review only - record it in THIRD_PARTY_NOTICES.md before any public build. +""" + +from __future__ import annotations + +import argparse +import os + +from PIL import Image, ImageDraw, ImageFont + +# Banner face aspect: 1.35 m wide x 6.2 m tall. 512 x 2304 keeps that to within 2 %. +WIDTH = 512 +HEIGHT = 2304 + +# sRGB colours. The banner is a deep event red, the lettering is gold; both match the +# photographs (and the owner's "less dark, more bright" direction for the elevation). +BANNER = (186, 24, 24) +GOLD = (232, 198, 90) + +FONT_BOLD = "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf" +FONT_REGULAR = "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf" + +# Three stacked words, as the real vertical banners arrange the title. `size` is the +# font height in texture pixels; the block is centred vertically in the banner. +LINES = [ + ("JIMMY", 150, FONT_BOLD), + ("KIMMEL", 150, FONT_BOLD), + ("LIVE", 120, FONT_REGULAR), +] +LINE_GAP = 70 # pixels between baselines beyond the font ascent + + +def _font(path: str, size: int) -> ImageFont.FreeTypeFont: + if not os.path.exists(path): + raise SystemExit(f"font missing: {path}") + return ImageFont.truetype(path, size) + + +def draw_banner(out: str) -> None: + img = Image.new("RGB", (WIDTH, HEIGHT), BANNER) + draw = ImageDraw.Draw(img) + + fonts = [(text, size, _font(path, size)) for text, size, path in LINES] + + # Measure the whole block so it sits centred vertically in the 6.2 m banner. + ascents = [f.getbbox(text)[3] for text, _, f in fonts] + block_h = sum(ascents) + LINE_GAP * (len(fonts) - 1) + y = (HEIGHT - block_h) // 2 + + for text, size, font in fonts: + bbox = font.getbbox(text) + text_w = bbox[2] - bbox[0] + x = (WIDTH - text_w) // 2 - bbox[0] + draw.text((x, y), text, font=font, fill=GOLD) + y += bbox[3] + LINE_GAP + + os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True) + img.save(out) + print(f"wrote {out} ({WIDTH}x{HEIGHT})") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--out", default="Art/textures/generated/T_Banner_KIMMEL.png") + args = parser.parse_args() + draw_banner(args.out) + + +if __name__ == "__main__": + main() diff --git a/Tools/generate_character_textures.py b/Tools/gen/generate_character_textures.py similarity index 97% rename from Tools/generate_character_textures.py rename to Tools/gen/generate_character_textures.py index a75ba13..659331b 100644 --- a/Tools/generate_character_textures.py +++ b/Tools/gen/generate_character_textures.py @@ -11,7 +11,7 @@ So skin detail is generated here instead: fine tileable noise with a low-frequen break-up, which is what the base colour actually needs (pores, not a pattern). Hair is generated as a strand sheet with alpha, for hair cards. - python3 Tools/generate_character_textures.py --out Art/textures/generated + python3 Tools/gen/generate_character_textures.py --out Art/textures/generated """ import argparse @@ -154,7 +154,7 @@ def main(): path, meta = maker(spec, args.out) sidecar = os.path.splitext(path)[0] + ".json" with open(sidecar, "w") as handle: - json.dump({"tool": "Tools/generate_character_textures.py", + json.dump({"tool": "Tools/gen/generate_character_textures.py", "asset": spec["name"], "size": spec["size"], "procedural": True, "meta": meta, "note": "generated, not photographed; no third-party licence"}, diff --git a/Tools/generate_facade_textures.py b/Tools/gen/generate_facade_textures.py similarity index 97% rename from Tools/generate_facade_textures.py rename to Tools/gen/generate_facade_textures.py index f42488a..d1d3462 100644 --- a/Tools/generate_facade_textures.py +++ b/Tools/gen/generate_facade_textures.py @@ -9,9 +9,9 @@ each as base colour plus a roughness map so the specular breaks up. Standard library only — the PNG encoder is the same pure-zlib writer used for the MPFB skin, so this runs on the host without numpy or Pillow. - python3 Tools/generate_facade_textures.py # write to /tmp/gigline/textures - python3 Tools/generate_facade_textures.py --size 1024 - python3 Tools/generate_facade_textures.py --out Art/blender/textures + python3 Tools/gen/generate_facade_textures.py # write to /tmp/gigline/textures + python3 Tools/gen/generate_facade_textures.py --size 1024 + python3 Tools/gen/generate_facade_textures.py --out Art/blender/textures Output names match the Unreal import destination `/Game/Hollywood/Textures`: diff --git a/Tools/page_image_fetch.py b/Tools/gen/page_image_fetch.py similarity index 97% rename from Tools/page_image_fetch.py rename to Tools/gen/page_image_fetch.py index cad4ca9..238cc26 100644 --- a/Tools/page_image_fetch.py +++ b/Tools/gen/page_image_fetch.py @@ -6,7 +6,7 @@ an API: the page's `og:image` (or `twitter:image`) is the image the site itself publishes as representative, so it is a defensible pick without scraping every `` on the page. - python3 Tools/page_image_fetch.py \ + python3 Tools/gen/page_image_fetch.py \ --page "https://www.flickr.com/photos/…/in/…" --slug hero-masonic --name entry-01 Writes into `Art/reference//` (gitignored) and appends provenance to @@ -121,7 +121,7 @@ def main(): print(f" saved {filename} ({len(blob)//1024} KB)") sidecar_path = os.path.join(out_dir, "sidecar-web.json") - data = {"source": "web page og:image", "tool": "Tools/page_image_fetch.py", + data = {"source": "web page og:image", "tool": "Tools/gen/page_image_fetch.py", "note": "local development reference only; not committed", "images": []} if os.path.exists(sidecar_path): try: diff --git a/Tools/input_emulation.py b/Tools/input/input_emulation.py similarity index 99% rename from Tools/input_emulation.py rename to Tools/input/input_emulation.py index 39901b2..d9901f8 100644 --- a/Tools/input_emulation.py +++ b/Tools/input/input_emulation.py @@ -2,7 +2,7 @@ """Pure-Python uinput driver to inject OS-level keyboard and mouse input. This is a small, dependency-free input driver used by the demo QA harness -(`Tools/input_test.py`) to prove whether OS input actually reaches the game. +(`Tools/input/input_test.py`) to prove whether OS input actually reaches the game. It talks to ``/dev/uinput`` directly through ``ctypes``, so it creates a real virtual input device that the kernel and the desktop session (X11 or Wayland) treat as genuine hardware. The game cannot tell simulated input from a real diff --git a/Tools/input_test.py b/Tools/input/input_test.py similarity index 92% rename from Tools/input_test.py rename to Tools/input/input_test.py index c37bd3a..5c83481 100644 --- a/Tools/input_test.py +++ b/Tools/input/input_test.py @@ -2,7 +2,7 @@ """QA harness: inject scripted OS-level keyboard/mouse input at the running game. This is the tool that proves whether OS input actually reaches the game. It uses -`Tools/input_emulation.py` to create a virtual input device directly on +`Tools/input/input_emulation.py` to create a virtual input device directly on `/dev/uinput`, so a focused game window receives the injected keys and mouse movement as genuine hardware input — indistinguishable from a real keyboard. @@ -11,7 +11,7 @@ Typical workflow The game must already be running and focused (it will not be launched here; the user brings up the window). Run this script from a separate terminal: - python3 Tools/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1 + python3 Tools/input/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1 It prints a per-step timeline (seconds) to stdout, which you can compare against the `GigLine IN:` rows in the game's `Saved/Logs`. If the game log shows @@ -20,9 +20,9 @@ the pawn and the remaining problem is not input delivery. Usage ----- - python3 Tools/input_test.py # default smoke sequence - python3 Tools/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1 - python3 Tools/input_test.py --device /dev/uinput --name "GigLine QA Input" + python3 Tools/input/input_test.py # default smoke sequence + python3 Tools/input/input_test.py --sequence move:2,look:3,jump:1,reset:1,pause:1 + python3 Tools/input/input_test.py --device /dev/uinput --name "GigLine QA Input" Steps supported in `--sequence` (comma-separated, `name:count`): move hold W forward (each unit = ~0.5 s) diff --git a/Tools/run_pie.sh b/Tools/input/run_pie.sh similarity index 94% rename from Tools/run_pie.sh rename to Tools/input/run_pie.sh index b6b4f94..b448237 100755 --- a/Tools/run_pie.sh +++ b/Tools/input/run_pie.sh @@ -2,7 +2,7 @@ # Open the Unreal editor focused on the walkable demo so you can press Play (PIE) # to compare in-editor input intake vs the packaged standalone build. # -# Usage: Tools/run_pie.sh +# Usage: Tools/input/run_pie.sh # Then in the editor: press the green Play button (Alt+P or the PIE toolbar). set -e ENGINE="${UE_ENGINE:-/run/media/system/DATA/Engines/UE_5.8.4}" diff --git a/Tools/review/build_comparison_md.py b/Tools/review/build_comparison_md.py new file mode 100644 index 0000000..c31f243 --- /dev/null +++ b/Tools/review/build_comparison_md.py @@ -0,0 +1,182 @@ +#!/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 = "" +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'' + + +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()) diff --git a/Tools/review/e2e_asset_check.py b/Tools/review/e2e_asset_check.py new file mode 100644 index 0000000..566ab88 --- /dev/null +++ b/Tools/review/e2e_asset_check.py @@ -0,0 +1,154 @@ +"""End-to-end asset check: Blender render -> Unreal build -> captures -> judge -> README. + + python3 Tools/review/e2e_asset_check.py # everything + python3 Tools/review/e2e_asset_check.py --skip-blender # reuse the existing Blender renders + python3 Tools/review/e2e_asset_check.py --skip-judge # no API calls, just the images + +The chain, in order, each step failing loudly with its log under /tmp/gigline/: + +1. **Blender renders** - `Art/blender/*/` assets rendered by `Tools/blender/blender_screenshots.py`, so the + source the imports came from has an image of its own. +2. **Empty maps** - `Tools/unreal/ue_recreate_maps.py` rebuilds both maps, so a run never inherits an actor + from an experiment. The builders also clear the level they load; this is the belt-and-braces step. +3. **Unreal build** - `Tools/unreal/ue_build_demo_map.py` (game map) and `Tools/unreal/ue_build_review_map.py` + (staging map for individual assets). +4. **Captures** - `Tools/review/render_screenshots.sh`, which renders both maps into `Screenshots/`. +5. **Judge** - every shot in `Tools/review/visual_targets.json` judged by **two models** through + `Tools/review/judge_screenshot.py --shots`, which reconciles agreement and reports disagreement. +6. **Review file** - `Tools/review/build_comparison_md.py` rewrites the Blender-vs-UE block in + `Screenshots/README.md`, then prettier formats it. + +The report lands in `/tmp/gigline/e2e-report.txt`; the exit code is non-zero if any step failed. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import time + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ENGINE = os.environ.get("ENGINE", "/run/media/system/DATA/Engines/UE_5.8.4") +UPROJECT = os.path.join(ROOT, "Game", "GigLine", "GigLine.uproject") +BLENDER = os.environ.get( + "BLENDER_FILES", "/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files") +UNREAL = os.path.join(ENGINE, "Engine", "Binaries", "Linux", "UnrealEditor-Cmd") +REPORT = "/tmp/gigline/e2e-report.txt" + + +def run(step, cmd, env=None, timeout=None, result_file=None, expect=None): + """Run one step and record it. A step fails on a non-zero exit or a missing marker. + + The child's output streams to its own file under /tmp/gigline/e2e/, not into a pipe: a captured pipe + hides progress for a fifteen-minute Unreal build and deadlocks if the engine spawns something that + outlives it. + """ + started = time.time() + print(f"== {step}", flush=True) + # A result file left by an earlier run would make this step look successful without doing anything. + if result_file and os.path.exists(result_file): + os.remove(result_file) + safe = step.replace("/", "-").replace(" ", "_") + os.makedirs("/tmp/gigline/e2e", exist_ok=True) + log_path = f"/tmp/gigline/e2e/{safe}.log" + with open(log_path, "w") as out: + try: + proc = subprocess.run(cmd, cwd=ROOT, env={**os.environ, **(env or {})}, + stdout=out, stderr=subprocess.STDOUT, timeout=timeout) + except subprocess.TimeoutExpired: + return step, False, f"timed out after {timeout}s (log {log_path})" + elapsed = time.time() - started + if proc.returncode != 0: + tail = "" + try: + tail = " / ".join(open(log_path, errors="replace").read().strip().splitlines()[-3:]) + except OSError: + pass + return step, False, f"exit {proc.returncode} ({elapsed:.0f}s): {tail}" + if result_file and expect: + if not os.path.exists(result_file): + return step, False, f"no result file {result_file}" + text = open(result_file, errors="replace").read() + if expect not in text: + return step, False, f"{os.path.basename(result_file)} lacks {expect!r}" + return step, True, f"{elapsed:.0f}s" + + +def unreal_script(script, timeout=1800): + return [UNREAL, UPROJECT, "-run=pythonscript", f"-script={os.path.join(ROOT, script)}", + "-nullrhi", "-unattended", "-nopause", "-stdout"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--skip-blender", action="store_true", help="reuse the Blender renders") + parser.add_argument("--skip-judge", action="store_true", help="no API calls") + parser.add_argument("--skip-recreate", action="store_true", + help="keep the existing maps (the builders still clear their levels)") + args = parser.parse_args() + + # A second editor fighting the first is what makes an Unreal commandlet hang instead of fail. + import shutil + if shutil.which("pgrep"): + others = subprocess.run(["pgrep", "-f", "UnrealEditor-Cmd"], capture_output=True, text=True) + if others.returncode == 0 and others.stdout.strip(): + sys.exit("error: an UnrealEditor process is already running; close it before the check " + "(two editors on one project hang, they do not fail)") + + steps = [] + if not args.skip_blender: + steps.append(run("1/6 blender renders", + [os.path.join(BLENDER, "blender", "blender"), "-b", "--factory-startup", + "--python", os.path.join(ROOT, "Tools", "blender", "blender_screenshots.py"), + "--", "--mode", "both", "--out", os.path.join(ROOT, "Screenshots", "blender")], + env={"LD_LIBRARY_PATH": os.path.join(BLENDER, "blender", "lib")}, + timeout=1800)) + else: + steps.append(("1/6 blender renders", True, "skipped, reusing Screenshots/blender")) + + if not args.skip_recreate: + steps.append(run("2/6 empty maps", unreal_script("Tools/unreal/ue_recreate_maps.py"), + timeout=900, result_file="/tmp/gigline/recreate-maps.txt", + expect="VERDICT: PASS")) + else: + steps.append(("2/6 empty maps", True, "skipped")) + + steps.append(run("3/6 unreal build: demo map", unreal_script("Tools/unreal/ue_build_demo_map.py"), + timeout=1800, result_file="/tmp/gigline/demo-map.txt", expect="save_map")) + steps.append(run("3/6 unreal build: review map", unreal_script("Tools/unreal/ue_build_review_map.py"), + timeout=1800, result_file="/tmp/ue-review-map-result.txt", expect="save_map")) + + steps.append(run("4/6 captures", ["bash", os.path.join(ROOT, "Tools", "review", "render_screenshots.sh")], + timeout=2400)) + + if not args.skip_judge: + steps.append(run("5/6 judge (two models)", + [sys.executable, os.path.join(ROOT, "Tools", "review", "judge_screenshot.py"), "--shots"], + timeout=1800)) + else: + steps.append(("5/6 judge (two models)", True, "skipped")) + + steps.append(run("6/6 review file", + [sys.executable, os.path.join(ROOT, "Tools", "review", "build_comparison_md.py")], + timeout=600)) + + lines = [f"e2e asset check {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}", ""] + ok = True + for step, passed, detail in steps: + lines.append(f"{'PASS' if passed else 'FAIL'} {step}: {detail}") + ok = ok and passed + lines += ["", f"VERDICT: {'PASS' if ok else 'FAIL'}", + "Review: Screenshots/README.md (status table at the top)."] + os.makedirs(os.path.dirname(REPORT), exist_ok=True) + with open(REPORT, "w") as handle: + handle.write("\n".join(lines) + "\n") + print() + print("\n".join(lines)) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/review/hero_compare.py b/Tools/review/hero_compare.py new file mode 100644 index 0000000..d838bf7 --- /dev/null +++ b/Tools/review/hero_compare.py @@ -0,0 +1,56 @@ +"""Build a side-by-side plate from a reference photograph and a rendered capture. + + python3 Tools/review/hero_compare.py \ + --reference Art/reference/hero-masonic/02-masonictemple01.jpg \ + --render Screenshots/review/review-hero-facade-front.png \ + --out Screenshots/review/hero-compare-front.png + +Both halves are scaled to the same height and labelled, so the two can be read at a glance instead of +flipping between files. Reference photography is git-ignored and never committed; only the plate is. + +Result: the plate path on stdout. +""" + +import argparse +import os + +from PIL import Image, ImageDraw + +LABEL_H = 56 +TARGET_H = 900 + + +def load_scaled(path, height): + image = Image.open(path).convert("RGB") + width = int(image.width * height / image.height) + return image.resize((width, height), Image.LANCZOS) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--reference", required=True) + parser.add_argument("--render", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--left-label", default="reference") + parser.add_argument("--right-label", default="rendered") + args = parser.parse_args() + + left = load_scaled(args.reference, TARGET_H) + right = load_scaled(args.render, TARGET_H) + + gap = 16 + width = left.width + right.width + gap * 3 + plate = Image.new("RGB", (width, TARGET_H + LABEL_H + gap * 2), (24, 24, 26)) + plate.paste(left, (gap, LABEL_H + gap)) + plate.paste(right, (gap * 2 + left.width, LABEL_H + gap)) + + draw = ImageDraw.Draw(plate) + draw.text((gap, 18), args.left_label, fill=(235, 235, 235)) + draw.text((gap * 2 + left.width, 18), args.right_label, fill=(235, 235, 235)) + + os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) + plate.save(args.out) + print(args.out) + + +main() diff --git a/Tools/review/hero_iterate.sh b/Tools/review/hero_iterate.sh new file mode 100755 index 0000000..092ae33 --- /dev/null +++ b/Tools/review/hero_iterate.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Full hero-facade iteration: build -> import -> render -> judge -> comparison plate. +# +# bash Tools/review/hero_iterate.sh [shot] +# +# `shot` defaults to review-hero-facade-front. Every step writes to /tmp/gigline/ so a failure can be +# read back: Unreal commandlets swallow print(). +# +# Why a script and not four remembered commands: the loop is run once per modelling change, and the +# order is load-bearing. The Blender save happens BEFORE the FBX export, so a read-only .blend makes +# the build look successful while the old FBX is reused silently - hence the chmod at the top. +set -uo pipefail +cd "$(dirname "$0")/../.." || exit 1 + +ENGINE="${ENGINE:-/run/media/system/DATA/Engines/UE_5.8.4}" +BLENDER_FILES="${BLENDER_FILES:-/var/lib/flatpak/app/org.blender.Blender/x86_64/stable/active/files}" +UPROJECT="$(pwd)/Game/GigLine/GigLine.uproject" +SHOT="${1:-review-hero-facade-front}" +REFERENCE="${REFERENCE:-Art/reference/hero-masonic/02-masonictemple01.jpg}" + +step() { printf '\n== %s\n' "$1"; } + +step "1/6 blender files writable" +find Art -name '*.blend*' -exec chmod u+w {} \; + +step "2/6 build the facade" +LD_LIBRARY_PATH="$BLENDER_FILES/blender/lib" "$BLENDER_FILES/blender/blender" -b --factory-startup \ + --python Tools/blender/hero_facade_build.py -- \ + --out /tmp/gigline/hero-facade.fbx \ + --save-blend Art/blender/hero/hero-facade-2026-11-07.blend \ + --report Art/blender/hero/hero-facade-2026-11-07.json \ + > /tmp/gigline/hero-build.log 2>&1 || { echo "build failed - see /tmp/gigline/hero-build.log"; exit 1; } +grep -E 'exported FBX' /tmp/gigline/hero-build.log || { echo "no FBX written"; exit 1; } + +step "3/6 import into Unreal" +"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \ + -script="$(pwd)/Tools/unreal/ue_hero_facade_import.py" -nullrhi -unattended -nopause -stdout \ + > /tmp/gigline/hero-import.log 2>&1 +grep -q 'save_map' /tmp/gigline/hero-facade-import.txt || { echo "import failed - see /tmp/gigline/hero-facade-import.txt"; exit 1; } + +step "4/6 rebuild DemoMap (materials live in the map builder)" +"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$UPROJECT" -run=pythonscript \ + -script="$(pwd)/Tools/unreal/ue_build_demo_map.py" -nullrhi -unattended -nopause -stdout \ + > /tmp/gigline/hero-demomap.log 2>&1 +grep -q 'save_map' /tmp/gigline/demo-map.txt || { echo "demo map rebuild failed - see /tmp/gigline/demo-map.txt"; exit 1; } + +step "5/6 render the capture set" +bash Tools/review/render_screenshots.sh > /tmp/gigline/hero-render.log 2>&1 || { echo "render failed - see /tmp/gigline/hero-render.log"; exit 1; } + +step "6/6 judge and compare" +python3 Tools/review/judge_screenshot.py --image "Screenshots/review/${SHOT}.png" \ + --expect "A neoclassical Masonic temple facade: warm yellow-grey sandstone with horizontal ashlar courses, six giant fluted Ionic columns on square pedestals, three tall red vertical banners reading JIMMY KIMMEL LIVE hanging between the columns, dark lattice metal grilles in the window bays behind the colonnade, three recessed dark entrance doorways with steps, above the columns a carved inscription band flanked by two round medallions, topped by a crested parapet." \ + | tee /tmp/gigline/hero-judge.txt | tail -2 +python3 Tools/review/hero_compare.py --reference "$REFERENCE" \ + --render "Screenshots/review/${SHOT}.png" \ + --out "Screenshots/review/hero-compare-${SHOT#review-hero-}.png" + +echo +echo "plate: Screenshots/review/hero-compare-${SHOT#review-hero-}.png" diff --git a/Tools/judge_screenshot.py b/Tools/review/judge_screenshot.py similarity index 58% rename from Tools/judge_screenshot.py rename to Tools/review/judge_screenshot.py index e13f877..70a3b61 100755 --- a/Tools/judge_screenshot.py +++ b/Tools/review/judge_screenshot.py @@ -35,9 +35,42 @@ import requests API_URL = "https://openrouter.ai/api/v1/chat/completions" DEFAULT_MODEL = "openai/gpt-4o-mini" +# Two models by default. A single vision model is advisory and has been wrong in both directions on this +# project, so every image is judged twice by different families and the pair is reconciled below. +DEFAULT_MODELS = ( + "openai/gpt-4o-mini", + "qwen/qwen3.7-flash", +) + + +def combine(judgements): + """Reconcile two or more model judgements into one verdict. + + Agreement carries the verdict and the mean confidence. Disagreement is reported as `uncertain` with + both reasons kept, because picking the more confident model would hide exactly the case worth looking + at - the models seeing different things. + """ + usable = [j for j in judgements if j.get("verdict") in ("yes", "no", "uncertain")] + if not usable: + return {"verdict": "unknown", "confidence": 0.0, + "reason": "no usable verdicts", "models": {}} + verdicts = {j["verdict"] for j in usable} + by_model = {j.get("model", "?"): {"verdict": j.get("verdict"), + "confidence": j.get("confidence", 0.0), + "reason": j.get("reason", "")} for j in usable} + if len(verdicts) == 1: + verdict = verdicts.pop() + confidence = sum(j.get("confidence", 0.0) for j in usable) / len(usable) + reason = usable[0].get("reason", "") + else: + verdict = "uncertain" + confidence = min(j.get("confidence", 0.0) for j in usable) + reason = "models disagree: " + " | ".join( + f"{j.get('model')}={j.get('verdict')}: {j.get('reason', '')[:120]}" for j in usable) + return {"verdict": verdict, "confidence": confidence, "reason": reason, "models": by_model} # Project root = parent of the Tools/ directory the script lives in. -PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ENV_PATH = os.path.join(PROJECT_ROOT, ".env") @@ -87,6 +120,7 @@ def build_messages(image_path, expect): def judge(api_key, image_path, expect, model, timeout=60): + """One model's judgement. `combine()` reconciles several of these.""" payload = { "model": model, "messages": build_messages(image_path, expect), @@ -109,29 +143,32 @@ def judge(api_key, image_path, expect, model, timeout=60): m = re.search(r"\{.*\}", content, re.S) if not m: sys.exit(f"error: model returned non-JSON:\n{content[:500]}") - return json.loads(m.group(0)) + verdict = json.loads(m.group(0)) + verdict["model"] = model + return verdict -def parse_shots_from_readme(): - """Parse the Screenshots/README.md tables into (path, expected) pairs.""" - readme = os.path.join(PROJECT_ROOT, "Screenshots", "README.md") - if not os.path.isfile(readme): - sys.exit(f"error: no {readme}") - text = open(readme, encoding="utf-8").read() +def parse_shots_from_targets(): + """Read the review shots and their expectations from Tools/review/visual_targets.json. + + Parsing the README's tables broke when the README was restructured into per-shot + sections with the comparison table appended: the old parser looked for a table whose + first cell held a backticked `review-` name. The targets file is the same source the + render gate uses, so it is both stable and the thing the expectations are written to. + """ + targets_path = os.path.join(PROJECT_ROOT, "Tools", "review", "visual_targets.json") + if not os.path.isfile(targets_path): + sys.exit(f"error: no {targets_path}") + import json + with open(targets_path) as handle: + data = json.load(handle) rows = [] - # Each table row: | File | ... | ... | What it shows | - for line in text.splitlines(): - if not line.strip().startswith("|"): + for target in data.get("targets", []): + shot = target.get("shot") or "" + expect = target.get("expect") or "" + if not shot or not expect: continue - cells = [c.strip() for c in line.strip().strip("|").split("|")] - if not cells or "`review-" not in cells[0]: - continue - fname = re.search(r"`(review-[^`]+\.png)`", cells[0]) - if not fname: - continue - # the last cell is the "What it shows" description - expect = cells[-1] - rows.append((fname.group(1), expect)) + rows.append((shot, expect)) return rows @@ -143,32 +180,36 @@ def main(): g.add_argument("--shots", action="store_true", help="evaluate every review shot listed in Screenshots/README.md") ap.add_argument("--expect", help="what the shot is expected to show (required with --image)") - ap.add_argument("--model", default=DEFAULT_MODEL, help=f"OpenRouter model (default {DEFAULT_MODEL})") + ap.add_argument("--models", default=",".join(DEFAULT_MODELS), + help=f"comma-separated OpenRouter models; every image is judged by all of them " + f"and the results reconciled (default {','.join(DEFAULT_MODELS)})") + ap.add_argument("--model", default=None, + help="single model, overriding --models (one judgement, no reconciliation)") ap.add_argument("--verbose", action="store_true") ap.add_argument("--timeout", type=int, default=60) args = ap.parse_args() api_key = load_api_key() + models = [args.model] if args.model else [m.strip() for m in args.models.split(",") if m.strip()] if args.shots: - shots = parse_shots_from_readme() + shots = parse_shots_from_targets() if not shots: - sys.exit("error: no review shots found in Screenshots/README.md") - # Only review-map shots (skip the demo/ ones which have a preview column too). - shots = [s for s in shots if "/demo/" not in s[0]] + sys.exit("error: no shots found in Tools/review/visual_targets.json") n = len(shots) - print(f"Judging {n} screenshots with {args.model} ...") + print(f"Judging {n} screenshots with {' + '.join(models)} ...") ok = 0 for i, (fname, expect) in enumerate(shots, 1): - path = os.path.join(PROJECT_ROOT, "Screenshots", fname.replace("Screenshots/", "")) + path = os.path.join(PROJECT_ROOT, fname) if not os.path.isfile(path): print(f"[{i}/{n}] {fname}: MISSING (skip)") continue try: - res = judge(api_key, path, expect, args.model, args.timeout) + judged = [judge(api_key, path, expect, model, args.timeout) for model in models] except SystemExit as e: print(f"[{i}/{n}] {fname}: ERROR {e}") continue + res = combine(judged) if len(judged) > 1 else judged[0] verdict = res.get("verdict", "unknown") conf = res.get("confidence", 0.0) reason = res.get("reason", "") @@ -182,10 +223,15 @@ def main(): if not args.expect: ap.error("--expect is required with --image") - res = judge(api_key, args.image, args.expect, args.model, args.timeout) - print(json.dumps(res, indent=2) if args.verbose - else f"verdict={res.get('verdict')} confidence={res.get('confidence')} " - f"reason={res.get('reason', '')}") + judged = [judge(api_key, args.image, args.expect, model, args.timeout) for model in models] + res = combine(judged) if len(judged) > 1 else judged[0] + if args.verbose: + print(json.dumps(res, indent=2)) + else: + print(f"verdict={res.get('verdict')} confidence={res.get('confidence')} " + f"reason={res.get('reason', '')}") + for model, detail in res.get("models", {}).items(): + print(f" {model}: {detail['verdict']} ({detail['confidence']:.2f})") if __name__ == "__main__": diff --git a/Tools/render_screenshots.py b/Tools/review/render_screenshots.py similarity index 98% rename from Tools/render_screenshots.py rename to Tools/review/render_screenshots.py index 24f1cd5..d5c2e02 100644 --- a/Tools/render_screenshots.py +++ b/Tools/review/render_screenshots.py @@ -5,7 +5,7 @@ # angles synchronously via SceneCapture2D render targets, writing PNGs # directly into /Screenshots/ (gitignored). # -# Invoked by Tools/render_screenshots.sh (UnrealEditor-Cmd -run=pythonscript). +# Invoked by Tools/review/render_screenshots.sh (UnrealEditor-Cmd -run=pythonscript). # NOTE: no -nullrhi — rendering needs the real Vulkan RHI. In 5.8 the # commandlet swallows print(); results go to /tmp/ue-screenshots-result.txt. # Do NOT load_map here: after a map load the commandlet's spawn context dies diff --git a/Tools/render_screenshots.sh b/Tools/review/render_screenshots.sh similarity index 66% rename from Tools/render_screenshots.sh rename to Tools/review/render_screenshots.sh index 23170d2..4439dc6 100755 --- a/Tools/render_screenshots.sh +++ b/Tools/review/render_screenshots.sh @@ -1,30 +1,33 @@ #!/usr/bin/env bash # Render the demo-map screenshots used for visual review into /Screenshots/demo/. # -# Usage: Tools/render_screenshots.sh [--verify] +# Usage: Tools/review/render_screenshots.sh [--verify] # # Only the **demo map** is captured: the review staging map exists to hold the # hero/camera list, not to produce review screenshots. Everything here is what a # player actually sees when the demo runs. # -# --verify run Tools/visual_loop.py --verify afterwards and fail if a shot +# --verify run Tools/review/visual_loop.py --verify afterwards and fail if a shot # does not show the content its expectation describes # # The PNGs are committed (see Screenshots/README.md): a reviewer must see exactly # what the build rendered. set -euo pipefail -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# This script lives in Tools/review, so the repository root is two levels up. +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}" PROJ="$REPO/Game/GigLine/GigLine.uproject" VERIFY="no" [ "${1:-}" = "--verify" ] && VERIFY="yes" +# Tools are grouped by discipline now: Unreal commandlets under Tools/unreal, review helpers under +# Tools/review. The script name alone is no longer enough to find one, so each call names its folder. run_cmdlet() { - local script="$1" label="$2" + local script="$1" label="$2" folder="${3:-unreal}" echo "== $label ==" ( cd "$ENGINE" && ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \ - -run=pythonscript -script="$REPO/Tools/$script" \ + -run=pythonscript -script="$REPO/Tools/$folder/$script" \ -nullrhi -unattended -nopause -stdout ) } @@ -69,6 +72,33 @@ done [ "$found" = "1" ] || { echo "ERROR: no demo screenshots found"; exit 1; } ls -1 "$REPO/Screenshots/demo" +echo "== 6b/7 capture the review map (asset staging) ==" +# The ReviewMap stages the models on their own - imported assets, camera angles and the +# lighting comparison - which is a different question from "does the game map look right". +# Its shots collect into Screenshots/review/ so the two reviews stay separate. +rm -rf "$REPO"/Game/GigLine/Saved/Screenshots/* 2>/dev/null || true +mkdir -p "$REPO/Game/GigLine/Saved/Screenshots/LinuxEditor" +"$ENGINE/Engine/Binaries/Linux/UnrealEditor-Cmd" "$PROJ" /Game/Test/ReviewMap \ + -game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout + +echo "== 6c/7 collect the review-map screenshots ==" +mkdir -p "$REPO/Screenshots/review" +rm -f "$REPO"/Screenshots/review/review-*.png +found_review=0 +for src in \ + "$REPO/Game/GigLine/Saved/Screenshots"/*/review-*.png \ + "$REPO/Game/GigLine/Saved/Screenshots"/*/*/review-*.png \ + "$HOME/.config/Epic/GigLine/Saved/Screenshots"/*/review-*.png; do + [ -e "$src" ] || continue + case "$(basename "$src")" in + *demo-*) : ;; + *) cp -f "$src" "$REPO/Screenshots/review/"; found_review=1 ;; + esac +done +[ "$found_review" = "1" ] || echo "WARN: no review-map screenshots found" +ls -1 "$REPO/Screenshots/review" 2>/dev/null | head -20 + + # The scene audit is a gate: a wrong-material or misplaced actor is exactly the # kind of failure that still renders plausibly. if grep -q "failure(s) ==" /tmp/gigline/scene-audit.txt 2>/dev/null; then @@ -80,5 +110,5 @@ fi if [ "$VERIFY" = "yes" ]; then echo "== verify: does each shot show what it should? ==" cd "$REPO" - python3 Tools/visual_loop.py --verify + python3 Tools/review/visual_loop.py --verify fi diff --git a/Tools/review/shadow_gap.py b/Tools/review/shadow_gap.py new file mode 100644 index 0000000..50bf92b --- /dev/null +++ b/Tools/review/shadow_gap.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Measure how far an object sits above its own shadow. + + python3 Tools/review/shadow_gap.py --image Screenshots/demo/review-demo-spawn-view.png \ + --object-box 0.52,0.55,0.60,0.75 --out /tmp/gigline/shadow-gap.json + +Why a measurement instead of another opinion: a still cannot distinguish "the object floats" +from "the object stands on a raised kerb and its shadow falls past it onto the road", and a +vision model asked to arbitrate gives a different answer each run. The vertical pixel gap +between the lowest object pixel and the top of its shadow is objective. + +Method, deliberately simple and stated so it can be argued with: + +1. Crop the region of interest (the object box, fractions of the frame). +2. The object is the set of pixels that differ from the local background - the median colour of + the crop's border. +3. The shadow is the darkest connected region *below* the object. +4. Report the gap in pixels between the object's lowest row and the shadow's top row, and the + object's height in pixels, so the gap can be read as a fraction of the object. + +A large gap relative to the object's height is the floating signal; a small gap is contact. +The thresholds are arguments, not constants, because the right values depend on the camera. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +from PIL import Image + +DEFAULT_GAP_FRACTION = 0.12 # gap > 12 % of the object's pixel height reads as floating +SHADOW_DELTA = 38 # how much darker than the background a pixel must be to be shadow + + +def border_median(image: Image.Image): + """Median colour of the crop's border, used as the local background.""" + pixels = image.load() + width, height = image.size + samples = [] + step = max(1, width // 60) + for x in range(0, width, step): + samples.append(pixels[x, 1]) + samples.append(pixels[x, height - 2]) + for y in range(0, height, max(1, height // 60)): + samples.append(pixels[1, y]) + samples.append(pixels[width - 2, y]) + if not samples: + return (128, 128, 128) + channels = list(zip(*samples)) + return tuple(sorted(channel)[len(channel) // 2] for channel in channels) + + +def classify_pixels(image: Image.Image, background, object_delta=18): + """Return two masks: the object (differs from background) and the shadow (darker).""" + pixels = image.load() + width, height = image.size + object_rows = [0] * height + shadow_rows = [0] * height + for y in range(height): + for x in range(width): + r, g, b = pixels[x, y] + delta = max(abs(r - background[0]), abs(g - background[1]), abs(b - background[2])) + luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + background_luminance = (0.2126 * background[0] + 0.7152 * background[1] + + 0.0722 * background[2]) + # A shadow is a darkened version of the surface; an object is much darker or a + # different colour. Classifying by absolute delta alone put every shadow in the + # object mask, which is why the first runs reported "no shadow found". + ratio = luminance / max(1.0, background_luminance) + # A shadow darkens every channel by the same proportion; an object does not. + # Comparing the per-channel scale factors is what separates them - an absolute + # delta cannot, because a shadow's delta is as large as a dark object's. + scales = [rgb / max(1.0, base) for rgb, base in zip((r, g, b), background)] + spread = max(scales) - min(scales) + if 0.45 <= ratio < 0.90 and spread < 0.15: + shadow_rows[y] += 1 + elif ratio < 0.45 or delta > object_delta: + object_rows[y] += 1 + return object_rows, shadow_rows + + +def measure(image_path: str, box, gap_fraction=DEFAULT_GAP_FRACTION) -> dict: + image = Image.open(image_path).convert("RGB") + width, height = image.size + x0, y0, x1, y1 = (int(box[0] * width), int(box[1] * height), + int(box[2] * width), int(box[3] * height)) + crop = image.crop((x0, y0, x1, y1)) + background = border_median(crop) + object_rows, shadow_rows = classify_pixels(crop, background) + + object_rows_nonzero = [y for y, count in enumerate(object_rows) if count > 4] + if not object_rows_nonzero: + return {"error": "no object found in the box", "background": background} + object_top, object_bottom = object_rows_nonzero[0], object_rows_nonzero[-1] + object_height = max(1, object_bottom - object_top) + + # Shadow rows below the object's lowest row. + shadow_below = [y for y, count in enumerate(shadow_rows) + if y > object_bottom and count > 4] + shadow_top = shadow_below[0] if shadow_below else None + gap = None if shadow_top is None else shadow_top - object_bottom + + verdict = "unknown" + if gap is not None: + verdict = "floating" if gap > object_height * gap_fraction else "contact" + return { + "image": os.path.abspath(image_path), + "box_pixels": [x0, y0, x1, y1], + "background": list(background), + "object_top_row": object_top, + "object_bottom_row": object_bottom, + "object_height_px": object_height, + "shadow_top_row": shadow_top, + "gap_px": gap, + "gap_fraction_of_object": None if gap is None else round(gap / object_height, 3), + "threshold_fraction": gap_fraction, + "verdict": verdict, + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--image", required=True) + ap.add_argument("--object-box", required=True, + help="x0,y0,x1,y1 as fractions of the frame around the object and its shadow") + ap.add_argument("--gap-fraction", type=float, default=DEFAULT_GAP_FRACTION, + help="gap/height above this reads as floating (default 0.12)") + ap.add_argument("--out", default="/tmp/gigline/shadow-gap.json") + args = ap.parse_args(argv) + + box = [float(v) for v in args.object_box.split(",")] + if len(box) != 4: + print("error: --object-box needs four comma-separated fractions", file=sys.stderr) + return 2 + if not os.path.exists(args.image): + print(f"error: no such image: {args.image}", file=sys.stderr) + return 2 + + result = measure(args.image, box, args.gap_fraction) + os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True) + with open(args.out, "w") as handle: + json.dump(result, handle, indent=2) + print(json.dumps(result, indent=2)) + return 0 if result.get("verdict") != "unknown" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/visual_loop.py b/Tools/review/visual_loop.py similarity index 97% rename from Tools/visual_loop.py rename to Tools/review/visual_loop.py index e0bb715..522cf8d 100644 --- a/Tools/visual_loop.py +++ b/Tools/review/visual_loop.py @@ -13,7 +13,7 @@ engine screenshot ─┘ │ It answers three questions with numbers instead of opinions: 1. **Does the shot show what it was supposed to?** (`--verify`) — every capture - has an expectation in `Tools/visual_targets.json`; a shot that is blank, the + has an expectation in `Tools/review/visual_targets.json`; a shot that is blank, the wrong size, or wildly unlike its expectation is reported as a failure. 2. **How far is the render from the reference?** (`--compare`) — luminance, contrast, edge density, palette and 3×3 composition are measured on both and @@ -26,9 +26,9 @@ Iterations are **not committed**: every artefact goes under `Art/reference/iterations//`, which is gitignored. Promote a shot into `Screenshots/` only after the owner confirms it. - python3 Tools/visual_loop.py --verify # gate the current set - python3 Tools/visual_loop.py --compare --iteration 3 # measure vs references - python3 Tools/visual_loop.py --compare --iteration 3 --judge # + vision advisory + python3 Tools/review/visual_loop.py --verify # gate the current set + python3 Tools/review/visual_loop.py --compare --iteration 3 # measure vs references + python3 Tools/review/visual_loop.py --compare --iteration 3 --judge # + vision advisory Metrics are computed with Pillow (already in requirements-tools.txt); no numpy. """ @@ -43,8 +43,8 @@ import sys from PIL import Image, ImageFilter, ImageStat -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -TARGETS = os.path.join(ROOT, "Tools", "visual_targets.json") +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +TARGETS = os.path.join(ROOT, "Tools", "review", "visual_targets.json") ITER_ROOT = os.path.join(ROOT, "Art", "reference", "iterations") # A shot must clear these floors or it is treated as broken, not merely ugly. diff --git a/Tools/review/visual_targets.json b/Tools/review/visual_targets.json new file mode 100644 index 0000000..8f2ea09 --- /dev/null +++ b/Tools/review/visual_targets.json @@ -0,0 +1,137 @@ +{ + "comment": "What each demo-map capture must show, and which reference photograph it is measured against. Used by Tools/visual_loop.py --verify (does the shot show the expected content?) and --compare (how far is the render from the reference?).", + "targets": [ + { + "name": "demo-spawn-view", + "shot": "Screenshots/demo/review-demo-spawn-view.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" + ], + "expect": "A daytime street view from the north sidewalk: a light-coloured neoclassical facade with columns across the road, a red car and a walking person on the pavement, road in the foreground Nothing may float: the person's feet and the car's wheels must meet the pavement and their shadows must touch them. A shadow on the ground more than a hand's width below an object means the object is floating." + }, + { + "name": "demo-street-level", + "shot": "Screenshots/demo/review-demo-street-level.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg" + ], + "expect": "Street level looking along the road: a colonnade of columns with a light facade above, pavement and road visible, daylight, no floating geometry" + }, + { + "name": "demo-crossing", + "shot": "Screenshots/demo/review-demo-crossing.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg" + ], + "expect": "A road surface with white crossing markings, a light-coloured building frontage behind, daylight" + }, + { + "name": "demo-north-buildings", + "shot": "Screenshots/demo/review-demo-north-buildings.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/north-chinese/01-grauman-s-chinese-theatre-by-carol-highsmith.jpg" + ], + "expect": "Tall light-grey building masses along the street on both sides, seen from road level, daylight, no missing faces or floating parts" + }, + { + "name": "demo-retail-frontage", + "shot": "Screenshots/demo/review-demo-retail-frontage.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg" + ], + "expect": "A row of light shopfront facades with dark openings at street level, seen from across the road, daylight" + }, + { + "name": "demo-third-person", + "shot": "Screenshots/demo/review-demo-third-person.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" + ], + "expect": "A third-person view of a clothed human character standing on a pavement, seen from behind, a large building facade across the road" + }, + { + "name": "demo-third-person-front", + "shot": "Screenshots/demo/review-demo-third-person-front.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" + ], + "expect": "A clothed human character standing on a pavement seen from the front, dark navy top and dark trousers, light building behind, daylight" + }, + { + "name": "demo-third-person-walk", + "shot": "Screenshots/demo/review-demo-third-person-walk.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" + ], + "expect": "A clothed human character walking along a pavement, legs apart in mid-stride, seen from the side, daylight" + }, + { + "name": "demo-vehicle", + "shot": "Screenshots/demo/review-demo-vehicle.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Art/blender/vehicle/hatch-reference.png" + ], + "expect": "A red compact hatchback car parked on a pavement beside a person, with dark windows, four dark wheels and a dark bumper, daytime" + }, + { + "name": "demo-feet", + "shot": "Screenshots/demo/review-demo-feet.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Screenshots/blender/character-angle-035.png" + ], + "expect": "Low view of a person's feet standing on a pavement, seen along the ground, daylight. The object must touch the surface: feet or wheels in contact, with the shadow starting at that contact point, not on the ground far below." + }, + { + "name": "demo-wheel", + "shot": "Screenshots/demo/review-demo-wheel.png", + "size": [ + 1920, + 1080 + ], + "references": [ + "Screenshots/blender/vehicle-angle-035.png" + ], + "expect": "Low view of a car's wheels standing on a pavement, seen along the ground, daylight. The object must touch the surface: feet or wheels in contact, with the shadow starting at that contact point, not on the ground far below." + } + ] +} diff --git a/Tools/tests/test_block_height_report.py b/Tools/tests/test_block_height_report.py index 2603398..f7e777e 100644 --- a/Tools/tests/test_block_height_report.py +++ b/Tools/tests/test_block_height_report.py @@ -4,6 +4,15 @@ The Geo tools live outside `Tools/`, so the test adds `Geo/tools` to `sys.path` before importing the module by name. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import os import sys import unittest diff --git a/Tools/tests/test_character_match.py b/Tools/tests/test_character_match.py index 941f53e..45c43a5 100644 --- a/Tools/tests/test_character_match.py +++ b/Tools/tests/test_character_match.py @@ -1,12 +1,23 @@ """Tests for the pure parts of Tools/character_match.py.""" +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import os import sys import unittest from PIL import Image -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import character_match as cm # noqa: E402 diff --git a/Tools/tests/test_comfyui_texture.py b/Tools/tests/test_comfyui_texture.py new file mode 100644 index 0000000..a33ae09 --- /dev/null +++ b/Tools/tests/test_comfyui_texture.py @@ -0,0 +1,53 @@ +"""Graph shape for the documented texture workflows (no ComfyUI process involved).""" + +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + +import unittest + +from comfyui_generate import TEXTURE_PROFILES, texture_workflow + + +class TextureWorkflowTests(unittest.TestCase): + def test_sdxl_profile_uses_a_checkpoint_and_a_real_negative(self): + graph = texture_workflow("albedobase", "stone", 1024, 1024, 7) + self.assertEqual(graph["1"]["class_type"], "CheckpointLoaderSimple") + self.assertEqual(graph["5"]["class_type"], "CLIPTextEncode") + sampler = graph["7"]["inputs"] + self.assertEqual(sampler["steps"], TEXTURE_PROFILES["albedobase"]["steps"]) + self.assertEqual(sampler["negative"], ["5", 0]) + + def test_unet_profile_loads_unet_clip_and_vae(self): + graph = texture_workflow("krea2", "stone", 1024, 1024, 7) + self.assertEqual(graph["1"]["class_type"], "UNETLoader") + self.assertEqual(graph["2"]["class_type"], "CLIPLoader") + self.assertEqual(graph["2"]["inputs"]["type"], "krea2") + self.assertEqual(graph["3"]["class_type"], "VAELoader") + self.assertIn("texture_fix", graph["3"]["inputs"]["vae_name"]) + + def test_krea2_zeroes_the_negative_at_cfg_one(self): + graph = texture_workflow("krea2", "stone", 1024, 1024, 7) + self.assertEqual(graph["5"]["class_type"], "ConditioningZeroOut") + self.assertEqual(graph["7"]["inputs"]["negative"], ["5", 0]) + + def test_qwen_profile_uses_its_own_text_encode_node(self): + graph = texture_workflow("qwen21", "stone", 1024, 1024, 7) + self.assertEqual(graph["4"]["class_type"], "TextEncodeQwenImage21") + # CFG 1 with no negative: the positive conditioning is reused. + self.assertEqual(graph["7"]["inputs"]["negative"], ["4", 0]) + + def test_every_profile_names_installed_models(self): + for name, spec in TEXTURE_PROFILES.items(): + self.assertTrue(spec.get("checkpoint") or spec.get("unet"), name) + if "unet" in spec: + self.assertTrue(spec["clip"] and spec["vae"], name) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_facade_detail_concepts.py b/Tools/tests/test_facade_detail_concepts.py index fae36ca..1d0898d 100644 --- a/Tools/tests/test_facade_detail_concepts.py +++ b/Tools/tests/test_facade_detail_concepts.py @@ -4,6 +4,15 @@ No network calls: the OpenRouter request and the image bytes are represented as the small structures the API uses. Generation itself is manual and paid. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import base64 import json import os diff --git a/Tools/tests/test_fbx_export_preset.py b/Tools/tests/test_fbx_export_preset.py index 585f7be..9beb18a 100644 --- a/Tools/tests/test_fbx_export_preset.py +++ b/Tools/tests/test_fbx_export_preset.py @@ -5,6 +5,15 @@ The preset module keeps its argument set in plain dicts and builds it in Blender. Only `export_static`/`export_character` import `bpy`. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import os import sys import unittest diff --git a/Tools/tests/test_input_emulation.py b/Tools/tests/test_input_emulation.py index db16fb7..693b462 100644 --- a/Tools/tests/test_input_emulation.py +++ b/Tools/tests/test_input_emulation.py @@ -5,6 +5,15 @@ covered here; the end-to-end injection is verified by running the tool (see Docs/release-checks.md and MEMORY.md). """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import unittest import input_emulation as ie diff --git a/Tools/tests/test_judge_combine.py b/Tools/tests/test_judge_combine.py new file mode 100644 index 0000000..e1bd25a --- /dev/null +++ b/Tools/tests/test_judge_combine.py @@ -0,0 +1,49 @@ +"""Tests for the multi-model reconciliation in judge_screenshot.combine().""" + +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + +import unittest + +from judge_screenshot import combine + + +class CombineTests(unittest.TestCase): + def test_agreement_takes_the_verdict_and_mean_confidence(self): + result = combine([ + {"model": "a", "verdict": "yes", "confidence": 0.8, "reason": "ok"}, + {"model": "b", "verdict": "yes", "confidence": 0.6, "reason": "fine"}, + ]) + self.assertEqual(result["verdict"], "yes") + self.assertAlmostEqual(result["confidence"], 0.7) + + def test_disagreement_becomes_uncertain_and_keeps_both_reasons(self): + result = combine([ + {"model": "a", "verdict": "yes", "confidence": 0.9, "reason": "looks right"}, + {"model": "b", "verdict": "no", "confidence": 0.4, "reason": "missing banner"}, + ]) + self.assertEqual(result["verdict"], "uncertain") + self.assertAlmostEqual(result["confidence"], 0.4) + self.assertIn("a=yes", result["reason"]) + self.assertIn("b=no", result["reason"]) + + def test_unknown_verdicts_are_ignored(self): + result = combine([ + {"model": "a", "verdict": "no", "confidence": 0.5, "reason": "x"}, + {"model": "b", "verdict": "garbage", "confidence": 0.9, "reason": "y"}, + ]) + self.assertEqual(result["verdict"], "no") + + def test_no_usable_verdicts(self): + result = combine([{"model": "a", "verdict": "garbage"}]) + self.assertEqual(result["verdict"], "unknown") + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_mapbox_buildings.py b/Tools/tests/test_mapbox_buildings.py index 47690cf..64fbb0f 100644 --- a/Tools/tests/test_mapbox_buildings.py +++ b/Tools/tests/test_mapbox_buildings.py @@ -5,6 +5,15 @@ the API returns, and only the selection/comparison/formatting helpers are exercised. The live call is verified by running the tool. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import json import os import sys diff --git a/Tools/tests/test_retail_frontage.py b/Tools/tests/test_retail_frontage.py index 412ec1b..2679311 100644 --- a/Tools/tests/test_retail_frontage.py +++ b/Tools/tests/test_retail_frontage.py @@ -4,6 +4,15 @@ The builder itself needs Blender; `bay_centres` does not, so the bay rhythm the façade depends on is checkable without starting Blender or Unreal. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import unittest from Tools._common import bay_centres diff --git a/Tools/tests/test_shadow_gap.py b/Tools/tests/test_shadow_gap.py new file mode 100644 index 0000000..54dba31 --- /dev/null +++ b/Tools/tests/test_shadow_gap.py @@ -0,0 +1,67 @@ +"""Tests for the pure parts of Tools/shadow_gap.py.""" + +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + +import os +import sys +import unittest + +from PIL import Image + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) +import shadow_gap as sg # noqa: E402 + + +def synthetic(object_bottom, shadow_top, size=(80, 120), background=(200, 200, 196)): + """A grey frame with a dark block (object) and a dark strip (shadow) below it.""" + image = Image.new("RGB", size, background) + pixels = image.load() + for y in range(20, object_bottom): + for x in range(30, 50): + pixels[x, y] = (18, 18, 20) # object: far darker than the surface + for y in range(shadow_top, min(size[1] - 2, shadow_top + 12)): + for x in range(30, 50): + # shadow: a darkened version of the surface, as a real shadow is + pixels[x, y] = (140, 140, 138) + return image + + +class MeasureTests(unittest.TestCase): + def test_contact_reads_as_contact(self): + image = synthetic(object_bottom=60, shadow_top=61) + image.save("/tmp/gigline-shadow-contact.png") + result = sg.measure("/tmp/gigline-shadow-contact.png", (0.2, 0.05, 0.8, 0.95)) + self.assertEqual(result["verdict"], "contact") + self.assertLessEqual(result["gap_px"], 2) + + def test_a_gap_reads_as_floating(self): + image = synthetic(object_bottom=60, shadow_top=100) + image.save("/tmp/gigline-shadow-float.png") + result = sg.measure("/tmp/gigline-shadow-float.png", (0.2, 0.05, 0.8, 0.95)) + self.assertEqual(result["verdict"], "floating") + self.assertGreater(result["gap_px"], result["object_height_px"] * 0.12) + + def test_empty_box_reports_an_error(self): + image = Image.new("RGB", (40, 40), (200, 200, 200)) + image.save("/tmp/gigline-shadow-empty.png") + result = sg.measure("/tmp/gigline-shadow-empty.png", (0.1, 0.1, 0.9, 0.9)) + self.assertIn("error", result) + + +class BorderTests(unittest.TestCase): + def test_border_median_is_the_frame_colour(self): + image = Image.new("RGB", (40, 40), (180, 190, 200)) + self.assertEqual(sg.border_median(image), (180, 190, 200)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_ue_common.py b/Tools/tests/test_ue_common.py index e2d2b28..698d31c 100644 --- a/Tools/tests/test_ue_common.py +++ b/Tools/tests/test_ue_common.py @@ -4,6 +4,15 @@ Pure stdlib: these helpers exist to turn two silent failures into loud ones, so they are tested without starting Unreal. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import os import stat import sys diff --git a/Tools/tests/test_ue_placement.py b/Tools/tests/test_ue_placement.py index 6835a51..42651c1 100644 --- a/Tools/tests/test_ue_placement.py +++ b/Tools/tests/test_ue_placement.py @@ -6,6 +6,15 @@ Running the importer moved the hero 13 m east and onto the north side of the boulevard, and nothing complained because a displaced building still renders. """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import os import sys import unittest @@ -70,14 +79,14 @@ class SingleSourceOfTruthTest(unittest.TestCase): """The import script and the demo builder must not carry their own literals.""" def test_import_script_uses_the_shared_constant(self): - src = open(os.path.join(ROOT, "Tools", "ue_hero_facade_import.py"), + src = open(os.path.join(ROOT, "Tools", "unreal", "ue_hero_facade_import.py"), encoding="utf-8").read() self.assertIn("ue_placement.HERO_LOCATION_CM", src) self.assertNotIn("(-12024.5, -1450.0, 0.0)", src, "the stale pre-0.1.0 hero position must not come back") def test_demo_builder_uses_the_shared_constant(self): - src = open(os.path.join(ROOT, "Tools", "ue_build_demo_map.py"), + src = open(os.path.join(ROOT, "Tools", "unreal", "ue_build_demo_map.py"), encoding="utf-8").read() self.assertIn("ue_placement.HERO_LOCATION_CM", src) diff --git a/Tools/tests/test_visual_loop.py b/Tools/tests/test_visual_loop.py index f7e4210..20da20f 100644 --- a/Tools/tests/test_visual_loop.py +++ b/Tools/tests/test_visual_loop.py @@ -5,6 +5,15 @@ numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge density and contrast; a checkerboard has a lot of both). """ +import os +import sys + +_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _TOOLS) +for _sub in ("review", "gen", "unreal", "blender", "input"): + sys.path.insert(0, os.path.join(_TOOLS, _sub)) + + import os import sys import tempfile diff --git a/Tools/ue_hero_facade_import.py b/Tools/ue_hero_facade_import.py deleted file mode 100644 index 46f0559..0000000 --- a/Tools/ue_hero_facade_import.py +++ /dev/null @@ -1,154 +0,0 @@ -# Imports the hero façade FBX into Unreal with the named preset `ue_metric_v1` -# and places it at the measured site position. -# -# /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_hero_facade_import.py -nullrhi -unattended -nopause -stdout -# -# Placement follows Geo/manifests/local-origin.md and the blockout frame: -# project X = UTM easting - 376515.389 (east +) -# project Y = -(UTM northing - 3774224.083) in Unreal, because the FBX preset -# maps Blender +Y (north) to Unreal -Y and Blender +X to Unreal +X. The building -# line of the hero block sits at Unreal Y = -1450 cm (14.5 m south of the road -# centreline, from the street profile), and the hero's east edge at -# X = -12024.5 cm (projected control point in local-origin.md). - -import os -import sys - -import unreal - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import ue_common # noqa: E402 -import ue_placement # noqa: E402 - -FBX = "/tmp/gigline/hero-facade.fbx" -DEST = "/Game/Hollywood/ElCapitan" -ACTOR_LABEL = ue_placement.HERO_ACTOR -LEVEL = "/Game/Hollywood/DemoMap" - -# Unreal centimetres: south-east corner of the façade, on the building line. -# Placement lives in Tools/ue_placement.py. It used to be a literal here, and it -# drifted from ue_build_demo_map.py: this file kept the pre-0.1.0 position -# (-12024.5, -1450) while the builder staged (-13336, 1307), so running the importer -# on its own moved the hero 13 m east and onto the north side of the boulevard. -PLACEMENT_CM = ue_placement.HERO_LOCATION_CM - -RESULT = "/tmp/gigline/hero-facade-import.txt" -lines = [] - - -def log(msg): - lines.append(str(msg)) - - -def import_fbx(): - task = unreal.AssetImportTask() - task.filename = FBX - task.destination_path = DEST - task.destination_name = "SM_HeroFacade" - task.automated = True - task.save = True - task.replace_existing = True - - ui = unreal.FbxImportUI() - ui.import_mesh = True - ui.import_materials = True - ui.import_textures = False - ui.import_as_skeletal = False - smd = ui.static_mesh_import_data - smd.import_uniform_scale = 1.0 - smd.combine_meshes = True - smd.convert_scene_unit = False - smd.force_front_x_axis = False - smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS - task.options = ui - - unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) - unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True) - - -def _make_map_writable(level: str) -> None: - """Clear the read-only bit an LFS .umap checks out with. - - Without this `save_map()` returns False and the staged actor is lost on the - next load — no exception, no non-zero exit (see MEMORY: 0.1.x trap). - """ - content = unreal.Paths.project_content_dir() - rel = level[len("/Game/"):] if level.startswith("/Game/") else level - umap = os.path.join(content, rel + ".umap") - note = ue_common.make_writable(umap) - if note: - log(f"map writable: {note}") - -def measure_and_place(): - asset = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade") - if asset is None: - log(f"MISSING {DEST}/SM_HeroFacade") - return - # Collision: complex-as-simple, not auto-generated convex hulls. - # The facade is 95 separate parts across 26 m; Unreal's auto hull generation - # wraps them in a few coarse hulls that bulged into the street (the QA wall - # probe went 0/35 -> 2/35 after trying it). Complex-as-simple makes the real - # triangles the simple collision, so line traces and capsule sweeps block on - # the actual wall — the same approach used for the terrain, for the same - # reason. The flag lives on the asset, so save it explicitly. - unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True) - saved = unreal.EditorAssetLibrary.save_loaded_asset(asset) - log(f"collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} (saved={saved})") - box = asset.get_bounding_box() - mn, mx = box.min, box.max - log(f"SM_HeroFacade bounds cm: size=({mx.x - mn.x:.1f}, {mx.y - mn.y:.1f}, {mx.z - mn.z:.1f}) " - f"min=({mn.x:.1f}, {mn.y:.1f}, {mn.z:.1f})") - # StaticMaterial is a struct wrapper: the material is its material_interface. - names = [] - for sm in asset.get_editor_property("static_materials"): - mat = sm.get_editor_property("material_interface") - names.append(mat.get_name() if mat else "None") - log(f"materials: {names}") - - # Place on the demo map if it exists; otherwise create it from the empty level. - if unreal.EditorAssetLibrary.does_asset_exist(LEVEL): - unreal.EditorLevelLibrary.load_level(LEVEL) - else: - if not unreal.EditorLevelLibrary.new_level(LEVEL): - log(f"could not create {LEVEL}") - return - for actor in unreal.EditorLevelLibrary.get_all_level_actors(): - if actor.get_actor_label() == ACTOR_LABEL: - unreal.EditorLevelLibrary.destroy_actor(actor) - actor = unreal.EditorLevelLibrary.spawn_actor_from_class( - unreal.StaticMeshActor, unreal.Vector(*PLACEMENT_CM)) - actor.set_actor_label(ACTOR_LABEL) - comp = actor.static_mesh_component - comp.set_static_mesh(asset) - comp.set_mobility(unreal.ComponentMobility.STATIC) - log(f"placed {ACTOR_LABEL} at {PLACEMENT_CM} (cm) on {LEVEL}") - - # Prove it landed on its verified footprint, not merely that it spawned. - box = asset.get_bounding_box() if asset else None - if box: - lo = box.min.x + PLACEMENT_CM[0] - hi = box.max.x + PLACEMENT_CM[0] - problem = ue_placement.describe_span_failure("hero", lo, hi) - if problem: - log(f"PLACEMENT ERROR: {problem}") - else: - log(f"span ok: X {lo:.0f}..{hi:.0f}") - _make_map_writable(LEVEL) - saved = unreal.EditorLoadingAndSavingUtils.save_map( - unreal.EditorLevelLibrary.get_editor_world(), LEVEL) - log(f"save_map {LEVEL}: {saved}") - if not ue_common.require_saved(saved, LEVEL): - log(f"FAILED to save {LEVEL} — staged content will not persist") - - -try: - import_fbx() - measure_and_place() -except Exception as exc: # noqa: BLE001 - import traceback - log(f"FAILED: {type(exc).__name__}: {exc}") - log(traceback.format_exc()) - -with open(RESULT, "w") as handle: - handle.write("\n".join(lines) + "\n") diff --git a/Tools/ue_apply_facade_kit.py b/Tools/unreal/ue_apply_facade_kit.py similarity index 95% rename from Tools/ue_apply_facade_kit.py rename to Tools/unreal/ue_apply_facade_kit.py index 0cf9ac7..a3491d2 100644 --- a/Tools/ue_apply_facade_kit.py +++ b/Tools/unreal/ue_apply_facade_kit.py @@ -10,7 +10,7 @@ blockout gives: the bay count is `round(width / BAY)` and the storey count comes from the height above the ground floor. Nothing is hand-placed per building. UnrealEditor-Cmd -run=pythonscript \ - -script=Tools/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout + -script=Tools/unreal/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout # or, as a step inside the demo builder: from ue_apply_facade_kit import apply_to_actor @@ -25,7 +25,9 @@ import sys import unreal -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_common # noqa: E402 KIT = "/Game/Hollywood/Kit" @@ -206,11 +208,11 @@ def apply_to_actor(actor, kit, materials=None, prefix=None): def main() -> None: kit = load_kit() if not kit: - log(f"no kit meshes under {KIT} — run Tools/ue_import_facade_kit.py first") + log(f"no kit meshes under {KIT} — run Tools/unreal/ue_import_facade_kit.py first") return log(f"kit meshes: {len(kit)}") - # Site materials built by Tools/ue_build_demo_map.py. + # Site materials built by Tools/unreal/ue_build_demo_map.py. materials = {} for name in ("M_FacadeStone", "M_FacadeStoneShadow", "M_FacadeGlass", "M_FacadeBanner", "M_Stucco", "M_Concrete"): @@ -237,7 +239,7 @@ def main() -> None: # The kit is the fallback for buildings that have nothing but a blockout. These # two are modelled from their own photograph in 0.1.6 - # (Tools/retail_frontage_build.py); pasting the generic kit over them put a + # (Tools/blender/retail_frontage_build.py); pasting the generic kit over them put a # second, contradicting facade language on the same wall. MODELLED = ("Frontage_SM_LeeDrug_6800", "Frontage_SM_Hollywood_6806") targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors() diff --git a/Tools/ue_audit_scene.py b/Tools/unreal/ue_audit_scene.py similarity index 90% rename from Tools/ue_audit_scene.py rename to Tools/unreal/ue_audit_scene.py index cf05e54..5d0f7f8 100644 --- a/Tools/ue_audit_scene.py +++ b/Tools/unreal/ue_audit_scene.py @@ -7,13 +7,13 @@ Checks that are cheap and catch the failures this project actually had: because the demo builder deleted and recreated the site materials, orphaning every reference; the map looked merely "a bit flat". 2. **Placement spans.** Each verified frontage building is probed against - `Tools/ue_placement.py`; a building 13 m off its footprint still renders fine, + `Tools/unreal/ue_placement.py`; a building 13 m off its footprint still renders fine, which is exactly why it needs a number, not an eye. 3. **No duplicate actor labels.** Two buildings' kit pieces sharing a label made the outliner ambiguous and hid one of them. UnrealEditor-Cmd -run=pythonscript \ - -script=Tools/ue_audit_scene.py -nullrhi -unattended -nopause -stdout + -script=Tools/unreal/ue_audit_scene.py -nullrhi -unattended -nopause -stdout Result file: `/tmp/gigline/scene-audit.txt`. Exit code from the commandlet is always 0, so **read the file** — `visual_loop.py --verify` does not depend on it. @@ -27,7 +27,9 @@ import sys import unreal -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_placement # noqa: E402 MAP = "/Game/Hollywood/DemoMap" diff --git a/Tools/ue_build_demo_map.py b/Tools/unreal/ue_build_demo_map.py similarity index 73% rename from Tools/ue_build_demo_map.py rename to Tools/unreal/ue_build_demo_map.py index aae4dc4..82b298c 100644 --- a/Tools/ue_build_demo_map.py +++ b/Tools/unreal/ue_build_demo_map.py @@ -1,11 +1,11 @@ # Build /Game/Hollywood/DemoMap: the walkable demo level. # # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout # # Content this map expects (built by earlier steps): -# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/ue_hero_facade_import.py -# /Game/Hollywood/Site/... Tools/ue_import_site.py +# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/unreal/ue_hero_facade_import.py +# /Game/Hollywood/Site/... Tools/unreal/ue_import_site.py # # Map conventions, from Geo/manifests/local-origin.md: # X east, Y south, Z up, centimetres, origin at Hollywood x Highland. @@ -22,8 +22,10 @@ import unreal MAP = "/Game/Hollywood/DemoMap" # Repository root, so the texture generator can be invoked by path. -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_placement # noqa: E402 RESULT = "/tmp/gigline/demo-map.txt" lines = [] @@ -43,6 +45,34 @@ def log(msg): lines.append(str(msg)) unreal.log(str(msg)) +def apply_hero_materials(actor): + """Point the hero's component slots at the authored *_Ref materials, if they exist. + + The mesh's own slot list cannot be relied on: `get_editor_property("static_materials")` returns a copy, so + assigning to it does not persist across a reload. A component-level override does persist in the saved map, + and it is what the captures actually render. + """ + if actor is None or actor.get_actor_label() != "Hero_Facade": + return 0 + comp = actor.get_component_by_class(unreal.StaticMeshComponent) + if not comp or not comp.static_mesh: + return 0 + slots = comp.static_mesh.get_editor_property("static_materials") + applied = 0 + for index, slot in enumerate(slots): + current = slot.get_editor_property("material_interface") + if not current: + continue + ref = unreal.EditorAssetLibrary.load_asset( + f"/Game/Hollywood/ElCapitan/{current.get_name()}_Ref") + if ref: + comp.set_material(index, ref) + applied += 1 + if applied: + log(f"hero materials: {applied}/{len(slots)} slots use the authored *_Ref materials") + return applied + + def spawn(cls, loc, label, rot=None): actor = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc) @@ -284,6 +314,25 @@ def ensure_material(mat_name, spec, textures=None): return mat +def clear_all(): + """Destroy every actor in the level. + + The prefix-based clear only removed what this builder knows about, so anything an experiment left behind + survived every rebuild - that is how a stale road mesh and a duplicate vehicle actor both ended up saved + back into the map. Every build now starts from an empty world. + """ + removed = 0 + for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()): + label = actor.get_actor_label() + try: + unreal.EditorLevelLibrary.destroy_actor(actor) + removed += 1 + _ = label + except Exception: # noqa: BLE001 - a locked actor must not abort the clear + log(f"could not destroy {label}") + return removed + + def clear(prefixes=()): removed = 0 for actor in unreal.EditorLevelLibrary.get_all_level_actors(): @@ -294,6 +343,27 @@ def clear(prefixes=()): return removed +def ground_z(x, y, from_z=2000.0, fallback=None): + """Z of the surface under (x, y), measured, not assumed. + + Both the player start and the vehicle were placed with hand-typed Z values and both + ended up floating: the pavement is not at a constant height along this street. Every + placement now reads the surface it stands on. + """ + world = unreal.EditorLevelLibrary.get_editor_world() + start = unreal.Vector(x, y, from_z) + end = unreal.Vector(x, y, from_z - 6000.0) + hit = unreal.KismetSystemLibrary.line_trace_single_by_channel( + world, start, end, unreal.TraceTypeQuery.TRACE_TYPE_QUERY1, + False, [], unreal.DrawDebugTrace.NONE, True) + if hit and hit[0]: + z = hit[1].impact_point.z + log(f"ground at ({x:.0f}, {y:.0f}) = Z {z:.0f} cm") + return z + log(f"ground at ({x:.0f}, {y:.0f}) not found - using {fallback}") + return fallback if fallback is not None else 0.0 + + def build(): # --- level ------------------------------------------------------------ if unreal.EditorAssetLibrary.does_asset_exist(MAP): @@ -305,9 +375,11 @@ def build(): log(f"could not create {MAP}") return - removed = clear(("Demo_", "PlayerStart", "Hero_Facade", "Frontage_", "RetailTrim_", - "ShotSequencer")) - log(f"cleared {removed} previous demo actors") + # Repeat until nothing is left: destroying actors while iterating a snapshot can miss + # some, which is how two Demo_Vehicle_Sedan actors survived into a saved map and the + # scene audit failed on a duplicate label. + removed = clear_all() + log(f"cleared {removed} previous actors (whole level, not just known prefixes)") # --- lighting: fixed daytime ------------------------------------------ # The demo is a fixed daytime scene, not a time-of-day simulation. Three @@ -322,7 +394,7 @@ def build(): # 3. Ambience. A height fog gives the street depth and hides the terrain # edge that otherwise reads as a black band at the horizon. sun = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 2000), "Demo_Sun", - unreal.Rotator(pitch=-74.0, yaw=120.0, roll=0.0)) + unreal.Rotator(pitch=-82.0, yaw=120.0, roll=0.0)) if sun: comp = sun.get_component_by_class(unreal.DirectionalLightComponent) if comp: @@ -419,7 +491,7 @@ def build(): actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC) log("staged: Hero_Facade at the building line") else: - log("MISSING: SM_HeroFacade — run Tools/ue_hero_facade_import.py") + log("MISSING: SM_HeroFacade — run Tools/unreal/ue_hero_facade_import.py") # --- site meshes, if they have been imported -------------------------- # Blender's flat diffuse colours do not survive FBX -> Unreal: Interchange @@ -443,10 +515,10 @@ def build(): # Ashlar coursing at 0.55 m (a 0.10 m band joint), the reading the 2009 # facade photograph gives for the hero's stone; roughness breaks up so the # wall is not one specular plane. - "M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64), + "M_FacadeStone": dict(dark=(0.30, 0.26, 0.20), bright=(0.47, 0.41, 0.31), roughness=0.70, specular=0.32, noise_scale=0.005, courses_m=0.55, joint_width=0.11, - joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12, + joint_tint=(0.16, 0.14, 0.11), roughness_variation=0.16, texture="T_StoneAshlar", tile_cm=440.0), # Rendered/stucco frontage: no coursing, but a fine tooth and a wider # roughness range than before, because flat stucco is what read as plastic. @@ -455,10 +527,10 @@ def build(): roughness_variation=0.20, texture="T_Stucco", tile_cm=260.0), # Stone in shadow: the hero's recessed wall behind the colonnade. Same # coursing as the face, a stop darker, so depth reads without a light. - "M_FacadeStoneShadow": dict(dark=(0.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43), + "M_FacadeStoneShadow": dict(dark=(0.18, 0.155, 0.12), bright=(0.28, 0.24, 0.18), roughness=0.78, specular=0.26, noise_scale=0.005, courses_m=0.55, joint_width=0.11, - joint_tint=(0.28, 0.26, 0.23), + joint_tint=(0.10, 0.09, 0.07), roughness_variation=0.10), # Glazing: low roughness, strong specular. A plausible IOR for glass sits # near 1.5, which UE expresses through specular ~0.5-0.6 on an opaque @@ -471,13 +543,16 @@ def build(): roughness=0.72, specular=0.22, noise_scale=0.02, roughness_variation=0.12), } + # Segmented ground: the site export slices sidewalks and kerbs into 20 m pieces, so the + # map stages every slice. A single 279 m slab could not report an honest local height and + # its collision was degenerate; slices fix both. + segments = [f"Road_Hollywood_{i:02d}" for i in range(14)] + \ + [f"Sidewalk_North_{i:02d}" for i in range(14)] + \ + [f"Sidewalk_South_{i:02d}" for i in range(14)] + \ + [f"Kerb_North_{i:02d}" for i in range(14)] + \ + [f"Kerb_South_{i:02d}" for i in range(14)] SURFACE_MATERIALS = { "Terrain_DEM": "M_Ground", - "Road_Hollywood": "M_Asphalt", - "Sidewalk_South": "M_Concrete", - "Sidewalk_North": "M_Concrete", - "Kerb_South": "M_KerbConcrete", - "Kerb_North": "M_KerbConcrete", "Crossing_Markings": "M_RoadPaint", } textures = ensure_textures() @@ -485,6 +560,15 @@ def build(): for mat_name, spec in MATERIAL_SPECS.items(): built[mat_name] = ensure_material(mat_name, spec, textures) + for segment in segments: + if unreal.EditorAssetLibrary.does_asset_exist(f"/Game/Hollywood/Site/{segment}"): + if "Road" in segment: + SURFACE_MATERIALS[segment] = "M_Asphalt" + elif "Sidewalk" in segment: + SURFACE_MATERIALS[segment] = "M_Concrete" + else: + SURFACE_MATERIALS[segment] = "M_KerbConcrete" + imported = 0 for name, mat_name in SURFACE_MATERIALS.items(): mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}") @@ -494,7 +578,11 @@ def build(): # which puts an invisible lid ~8 m over the whole block: the player # walks on that box instead of the street. Force trace against the real # triangles so the carved street corridor is actually open. - if "Terrain" in name: + # Walkable surfaces need the same treatment as the terrain: with only the + # auto-generated simple collision, a sidewalk does not block a downward trace while + # the road does - so every ground mesh that the player can stand on is switched to + # complex-as-simple, where the real triangles do the work. + if "Terrain" in name or "Sidewalk" in name or "Kerb" in name: asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}") if asset: unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True) @@ -504,6 +592,14 @@ def build(): log(f"terrain collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} " f"(saved asset: {saved_asset})") actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Demo_{name}") + if actor and "Terrain" in name: + # Terrain answers traces (so the player walks on it) but is invisible to the physics + # solver: a body inside its triangle soup is pushed out every frame, which is what threw + # the Chaos vehicle into the air. Vehicles drive on the street slices, which carry their + # own UCX boxes. + comp_t = actor.get_component_by_class(unreal.StaticMeshComponent) + if comp_t: + comp_t.set_collision_enabled(unreal.CollisionEnabled.QUERY_ONLY) if actor: comp = actor.static_mesh_component comp.set_static_mesh(mesh) @@ -550,6 +646,14 @@ def build(): assigned = 0 for actor in unreal.EditorLevelLibrary.get_all_level_actors(): + # The hero carries its own materials, authored per slot by + # Tools/unreal/ue_hero_facade_import.py from the reference photographs (including the generated + # ashlar albedo). This generic pass used to override them with the kit's flat stone, which is why the + # elevation kept rendering as a plain card no matter what the import did. + if actor.get_actor_label() == "Hero_Facade": + log("materials Hero_Facade: skipped (own authored materials)") + apply_hero_materials(actor) + continue fallback = LABEL_MATERIALS.get(actor.get_actor_label()) comp = actor.get_component_by_class(unreal.StaticMeshComponent) if not comp or not comp.static_mesh: @@ -584,7 +688,7 @@ def build(): for asset_name, label in FRONTAGE: mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}") if mesh is None: - log(f"MISSING frontage {asset_name} — run Tools/ue_import_block_frontage.py") + log(f"MISSING frontage {asset_name} — run Tools/unreal/ue_import_block_frontage.py") continue actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Frontage_{asset_name}") if actor: @@ -607,7 +711,7 @@ def build(): ("SM_Retail_6806", "6806-6820 trim")): mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}") if mesh is None: - log(f"MISSING trim {asset_name} — run Tools/ue_import_retail_detail.py") + log(f"MISSING trim {asset_name} — run Tools/unreal/ue_import_retail_detail.py") continue # RetailTrim_ rather than Frontage_ so the kit assembler leaves it alone. actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"RetailTrim_{asset_name}") @@ -643,10 +747,83 @@ def build(): # 87.5) and it never touches the sidewalk. Y -1150 is on the north sidewalk # (Y -1912..-1053) AND inside the cut, so the pavement is the ground. # Z 600 is clear air; the capsule drops onto the pavement and settles. - spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1150.0, 600.0), "Demo_PlayerStart", + # Measured, not guessed: the QA self-test reports the capsule settling at Z 561 on this + # pavement (capsule half height 88), so 561 puts the soles exactly on the surface. The + # previous Z 600 started 39 cm in the air and read as floating. + # Measured from the staged geometry: Demo_Sidewalk_North spans Y -1912..-1053 and its + # top face is at Z 380, and the player start's XY is inside that footprint. The capsule + # centre belongs at 380 + half height 88 + 1 = 469. Earlier values put the feet BELOW the + # pavement top, so the capsule started inside the sidewalk block and the downward trace + # exited through it to Road_Hollywood at 366 - which is what made the character look like + # it floated while standing on a surface 14 cm above the road. + player_z = 400.0 + # The invisible sidewalk collision slab is retired: the site export now slices the + # sidewalks into 20 m pieces, so the walkable surface is real geometry with an honest + # local height instead of a 279 m slab whose bounding box was the highest point anywhere. + spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1450.0, player_z), "Demo_PlayerStart", unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0)) log("staged: Demo_PlayerStart on the cut north sidewalk, facing south (+Y)") + # --- vehicle --------------------------------------------------------- + # The physics pawn and the visible panels are staged as two things on purpose: the + # probe showed the meshes render as plain StaticMeshActors while the same meshes + # attached to the vehicle pawn stay invisible, so the visuals do not depend on the + # pawn until that is solved. A visible car that does not drive yet beats an invisible + # one that does. + # The hot-hatch template, normalised to 4.05 m and split per the vehicle-artist + # object split. Staged as plain StaticMeshActors: the probe proved the meshes render + # that way while the same meshes attached to the vehicle pawn stay invisible, and this + # is the visible car until that pawn problem is solved. + # Same source: the vehicle's own downward trace in game reports the road under it at + # Z 366, and the model is grounded in Blender (wheel bottoms at Z 0), so the panels sit + # at 367 - one centimetre of clearance rather than a visible gap. + # Parked on the same sidewalk surface the player stands on (158 cm at this X). The road sits + # ~2 m ABOVE the sidewalk in the shipped site geometry - a data defect recorded in TODO.md - + # so a car on the carriageway looks like it hovers over the pavement beside it. Parking it on + # the pavement keeps the two objects on one surface until that geometry is fixed. + vehicle_origin = unreal.Vector(-12950.0, -1450.0, 158.0) + vehicle_rotation = unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0) + + panel_systems = ( + "SM_Vehicle_Body", + "SM_Vehicle_Trim", + "SM_Vehicle_Glass", + "SM_Vehicle_Lights", + "SM_Vehicle_Wheels", + ) + staged_panels = 0 + for index, mesh_name in enumerate(panel_systems): + mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}") + if not mesh: + log(f"vehicle panel {mesh_name}: MISSING") + continue + actor = spawn(unreal.StaticMeshActor, vehicle_origin, + f"Demo_VehiclePanel_{index:02d}", vehicle_rotation) + if actor: + comp = actor.get_component_by_class(unreal.StaticMeshComponent) + if comp: + comp.set_static_mesh(mesh) + comp.set_mobility(unreal.ComponentMobility.MOVABLE) + # The body imports with no usable material (its slot resolves to + # WorldGridMaterial), so the paint is assigned on the staged panel. + if mesh_name == "SM_Vehicle_Body": + paint = unreal.EditorAssetLibrary.load_asset( + "/Game/Hollywood/Vehicle/M_Vehicle_HatchPaint") + if paint: + comp.set_material(0, paint) + log("Demo_VehiclePanel: body paint assigned") + # Visual only: with collision the panels overlap the Chaos pawn's chassis body + # and the depenetration launches the whole vehicle upward. + if comp: + comp.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION) + staged_panels += 1 + log(f"staged: {staged_panels} Demo_VehiclePanel actors (plain static meshes)") + + # The Chaos pawn stays parked. Three hypotheses are now ruled out - spawning level with the + # road, spawning 1.2 m above it, and the visual panels' collision - and the actor still climbs to + # Z ~2066 cm with zero wheels. See the 'Vehicle physics - Chaos rig' task in TODO.md. + log("staged: Demo_Vehicle_Sedan skipped - Chaos pawn parked (see TODO.md)") + # A lockable LFS .umap checks out read-only, which makes save_map silently # return False — chmod u+w first or the staged actors never persist. umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap") diff --git a/Tools/ue_build_review_map.py b/Tools/unreal/ue_build_review_map.py similarity index 73% rename from Tools/ue_build_review_map.py rename to Tools/unreal/ue_build_review_map.py index 91e932f..a346046 100644 --- a/Tools/ue_build_review_map.py +++ b/Tools/unreal/ue_build_review_map.py @@ -1,11 +1,11 @@ # Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a -# map asset. Run before the screenshot pass (Tools/render_screenshots.sh does +# map asset. Run before the screenshot pass (Tools/review/render_screenshots.sh does # this automatically). # # Scene: metric floor, key light, fill sky, sky atmosphere, MPFB character, # the three 1 m scale cubes, and an AGigLineShotActor with the camera list. # -# UnrealEditor-Cmd -run=pythonscript -script=/Tools/ue_build_review_map.py \ +# UnrealEditor-Cmd -run=pythonscript -script=/Tools/unreal/ue_build_review_map.py \ # -nullrhi -unattended -nopause -stdout # # Do NOT load_map here: after a map load the commandlet's spawn context dies @@ -34,6 +34,34 @@ def switch_level(path): def log(msg): lines.append(str(msg)) +def apply_hero_materials(actor): + """Point the hero's component slots at the authored *_Ref materials, if they exist. + + The mesh's own slot list cannot be relied on: `get_editor_property("static_materials")` returns a copy, so + assigning to it does not persist across a reload. A component-level override does persist in the saved map, + and it is what the captures actually render. + """ + if actor is None or actor.get_actor_label() != "Hero_Facade": + return 0 + comp = actor.get_component_by_class(unreal.StaticMeshComponent) + if not comp or not comp.static_mesh: + return 0 + slots = comp.static_mesh.get_editor_property("static_materials") + applied = 0 + for index, slot in enumerate(slots): + current = slot.get_editor_property("material_interface") + if not current: + continue + ref = unreal.EditorAssetLibrary.load_asset( + f"/Game/Hollywood/ElCapitan/{current.get_name()}_Ref") + if ref: + comp.set_material(index, ref) + applied += 1 + if applied: + log(f"hero materials: {applied}/{len(slots)} slots use the authored *_Ref materials") + return applied + + def ensure_writable(path): # A packed .umap left read-only (git sets 644->444 on checkout, or the @@ -49,6 +77,42 @@ def ensure_writable(path): pass +def clear_level(): + """Destroy every actor before staging. + + Without this the review map accumulated whatever previous runs and experiments left behind, and the + captures showed a scene nobody staged. The map is rebuilt from empty on every run. + """ + removed = 0 + for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()): + try: + unreal.EditorLevelLibrary.destroy_actor(actor) + removed += 1 + except Exception: # noqa: BLE001 + pass + log(f"cleared {removed} actors before staging") + return removed + + +def sit_on_floor(actor, floor_z=2.0): + """Shift an actor so the mesh's lowest point lands on `floor_z`. + + Staging heights were hand-typed and every one of them was a little wrong - the mannequin's root + is not at the soles, the scale cube's origin is not at its base - which is what the review keeps + catching. Measuring the bounds and correcting is the only version that stays right when an + asset changes. The default 2 cm keeps soles and tyres on TOP of the floor plane rather than + exactly level with it, where they read as sunk. + """ + try: + origin, box = actor.get_actor_bounds(False) + except Exception: # noqa: BLE001 + return None + lowest = origin.z - box.z + loc = actor.get_actor_location() + actor.set_actor_location(unreal.Vector(loc.x, loc.y, loc.z + (floor_z - lowest)), False, False) + return floor_z - lowest + + def spawn(cls, loc, label=None, scale=None, rot=None): a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc) if a is None: @@ -66,6 +130,10 @@ def spawn(cls, loc, label=None, scale=None, rot=None): # --- scene ------------------------------------------------------------------- try: + # Start from an empty level: the review map used to keep whatever previous runs left behind, and + # captures showed a scene nobody staged. + clear_level() + # Neutral gray floor material (the scale-test mesh ships red) floor_mat = None if not unreal.EditorAssetLibrary.does_asset_exist("/Game/Test/M_ReviewFloor"): @@ -131,8 +199,12 @@ try: # Root at the soles, not the world origin: the composes put feet on the # floor's top face (Z = 0). If the skeletal root is not at the soles the # character would float or sink, so report the bounds for the review. - if hasattr(ch, "set_actor_location"): - ch.set_actor_location(unreal.Vector(0, 0, 0), False, False) + # Actor bounds on a freshly assigned skeletal mesh read as zero, so the generic + # correction came out at +2 cm while the mannequin sat visibly low. 8 cm was then + # 2 cm too high on review, so it sits at 6. + shift = sit_on_floor(ch, floor_z=6.0) + if shift is not None: + log(f"staged: MPFB character sat on the floor (shift {shift:+.1f} cm)") mn, mx = (None, None) try: _origin, _box = ch.get_actor_bounds(False) @@ -157,6 +229,11 @@ try: c = spawn(unreal.StaticMeshActor, unreal.Vector(x, y, z), f"Cube_{name}") if c: c.static_mesh_component.set_static_mesh(m) + if name == "ScaleTest_A": + # The resting cube is the ground-contact reference, so its base must be + # exactly on the floor rather than wherever its origin happens to be. + shift = sit_on_floor(c) + log(f"staged: {name} sat on the floor (shift {shift:+.1f} cm)") log(f"staged: {name} at ({x}, {y}, {z}) - {why}") # Hero facade, if the import has run: two views a reviewer needs — straight on @@ -175,9 +252,10 @@ try: if actor: actor.static_mesh_component.set_static_mesh(hero) actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC) + apply_hero_materials(actor) log("staged: hero facade at the measured building line") else: - log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/ue_hero_facade_import.py)") + log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/unreal/ue_hero_facade_import.py)") # --- player pawn --------------------------------------------------------- # The review rounds must show the pawn the game actually spawns, not only the @@ -192,6 +270,35 @@ try: else: log("MISSING: /Script/GigLine.GigLineCharacter") + # --- vehicle, from the latest Blender import ------------------------------ + # Staged from the same assets the demo map uses, so the review always shows what + # Tools/blender/vehicle_from_template.py most recently produced rather than a copy that can + # drift. The panels are plain static meshes because the vehicle pawn keeps its + # physics only. + vehicle_origin = unreal.Vector(-1600.0, 600.0, 0.0) + vehicle_systems = ("SM_Vehicle_Body", "SM_Vehicle_Trim", "SM_Vehicle_Glass", + "SM_Vehicle_Lights", "SM_Vehicle_Wheels") + staged_vehicle = 0 + for index, mesh_name in enumerate(vehicle_systems): + mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}") + if not mesh: + log(f"MISSING vehicle system: {mesh_name}") + continue + piece = spawn(unreal.StaticMeshActor, vehicle_origin, f"Review_Vehicle_{index:02d}") + if piece: + piece.static_mesh_component.set_static_mesh(mesh) + piece.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC) + if mesh_name == "SM_Vehicle_Wheels": + shift = sit_on_floor(piece) + log(f"staged: vehicle wheels sat on the floor (shift {shift:+.1f} cm)") + if mesh_name == "SM_Vehicle_Body": + paint = unreal.EditorAssetLibrary.load_asset( + "/Game/Hollywood/Vehicle/M_Vehicle_HatchPaint") + if paint: + piece.static_mesh_component.set_material(0, paint) + staged_vehicle += 1 + log(f"staged: {staged_vehicle} vehicle systems at the staging area") + # --- NPC prefab ----------------------------------------------------------- # The reusable NPC person: a collision capsule + visible MPFB body, standing # feet on Z 0, placed clear of the pawn so the prefab reads on its own. @@ -211,6 +318,10 @@ try: cams = [ # wide scene: character, both cubes, floor ("overview", unreal.Vector(700, -900, 620), unreal.Vector(0, 300, 80)), + # the vehicle staging, three-quarter front, same framing idea as the demo shot + ("vehicle", unreal.Vector(-900, -200, 260), unreal.Vector(-1600, 600, 90)), + # ground-level contact check: feet on the floor, shadow at the contact point + ("character-feet", unreal.Vector(220, -420, 45), unreal.Vector(0, 0, 25)), # the MPFB mannequin, mid shot ("character", unreal.Vector(200, -300, 160), unreal.Vector(0, 0, 85)), # both cubes from the south: contact shadow vs cast shadow @@ -288,7 +399,16 @@ try: # the colonnade's underside. # Level horizon for the same reason: a block elevation is read by its # verticals, and a pitched camera makes them lean. - ("demo-third-person-walk", unreal.Vector(-12940, -980, 560), unreal.Vector(-13336, -980, 500), 55.0), + ("demo-third-person-walk", unreal.Vector(-12940, -1280, 500), unreal.Vector(-13336, -1450, 220), 55.0), + # The sedan, staged on the carriageway at (-12950, -900). Three-quarter front + # view from the road so both the body and the wheels are visible: a side-on + # or rear view hides the wheel stances that the build is judged on. + ("demo-vehicle", unreal.Vector(-12560, -2150, 420), unreal.Vector(-12950, -1450, 220), 45.0), + # Ground-level angles on the contact points. The wide shots cannot settle whether + # the feet and wheels touch the surface - that question needs the camera near the + # ground, looking along it, where a gap is a visible sliver of surface. + ("demo-feet", unreal.Vector(-13140, -1280, 200), unreal.Vector(-13336, -1450, 170), 40.0), + ("demo-wheel", unreal.Vector(-12380, -1050, 300), unreal.Vector(-12950, -1450, 175), 55.0), ("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0), # Street level, from the north sidewalk: the old camera sat at Y -400, # inside the facade body (which spans Y -1457..-200), so it looked at @@ -319,11 +439,11 @@ try: # 3.4 m behind the spawn point, 1 m above it — and looks at the pawn, so # the visible body and its idle clip are what the shot is for. # Demo_PlayerStart is at (-13336, -1150, 600), yaw 90 (facing +Y). - ("demo-third-person", unreal.Vector(-13336, -1750, 780), unreal.Vector(-13336, -1150, 560), 58.0), + ("demo-third-person", unreal.Vector(-13336, -2050, 620), unreal.Vector(-13336, -1450, 250), 58.0), # Front view of the same pawn: the third-person shot only ever shows its # back, so a pose defect there is invisible. The pawn spawns facing +Y # (yaw 90), so the camera stands on the road side and looks back north. - ("demo-third-person-front", unreal.Vector(-13336, -520, 700), unreal.Vector(-13336, -1150, 540), 55.0), + ("demo-third-person-front", unreal.Vector(-13336, -820, 620), unreal.Vector(-13336, -1450, 240), 55.0), ] seq = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer") @@ -347,7 +467,7 @@ try: unreal.EditorLevelLibrary.get_editor_world(), "/Game/Hollywood/DemoMap") log(f"demo map staged: {len(demo_shots)} cameras, save {saved}") else: - log("MISSING: /Game/Hollywood/DemoMap (run Tools/ue_build_demo_map.py)") + log("MISSING: /Game/Hollywood/DemoMap (run Tools/unreal/ue_build_demo_map.py)") except Exception as exc: # noqa log(f"staging failed: {exc!r}") diff --git a/Tools/ue_character_anim_import.py b/Tools/unreal/ue_character_anim_import.py similarity index 92% rename from Tools/ue_character_anim_import.py rename to Tools/unreal/ue_character_anim_import.py index 61cf4e7..4271197 100644 --- a/Tools/ue_character_anim_import.py +++ b/Tools/unreal/ue_character_anim_import.py @@ -1,9 +1,9 @@ # Import the character animation clips into Unreal (0.2.0). # # blender -b Art/blender/character/character-2026-11-07.blend \ -# --python Tools/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx +# --python Tools/blender/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout # # Imports the animated FBX next to the existing character assets and verifies the # result rather than assuming it: an FBX whose export omitted `bake_anim` imports @@ -31,7 +31,7 @@ def log(msg): def main(): if not os.path.exists(SRC): - log(f"missing {SRC}; run Tools/character_anim_build.py first") + log(f"missing {SRC}; run Tools/blender/character_anim_build.py first") return task = unreal.AssetImportTask() diff --git a/Tools/ue_character_clothing_import.py b/Tools/unreal/ue_character_clothing_import.py similarity index 92% rename from Tools/ue_character_clothing_import.py rename to Tools/unreal/ue_character_clothing_import.py index 827a8d9..c6249f6 100644 --- a/Tools/ue_character_clothing_import.py +++ b/Tools/unreal/ue_character_clothing_import.py @@ -1,9 +1,9 @@ # Import the garment meshes as separate skeletal meshes (0.2.2). # # blender -b Art/blender/character/character-2026-11-07.blend \ -# --python Tools/character_clothing_build.py -- --out /tmp/gigline/clothing +# --python Tools/blender/character_clothing_build.py -- --out /tmp/gigline/clothing # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout # # Each garment is its own asset, per the character-artist skill's rule that clothing # stays separate so it can be swapped or re-materialled. The pawn attaches each one as diff --git a/Tools/ue_character_hair_import.py b/Tools/unreal/ue_character_hair_import.py similarity index 91% rename from Tools/ue_character_hair_import.py rename to Tools/unreal/ue_character_hair_import.py index f387e25..7823edd 100644 --- a/Tools/ue_character_hair_import.py +++ b/Tools/unreal/ue_character_hair_import.py @@ -1,9 +1,9 @@ # Import the hair cards as their own skeletal mesh (0.2.2). # # blender -b Art/blender/character/character-2026-11-07.blend \ -# --python Tools/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx +# --python Tools/blender/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout # # The hair is a separate asset on purpose: Unreal collapses the second material slot # when hair is joined into the body mesh, so a card system has to be its own object diff --git a/Tools/ue_character_import.py b/Tools/unreal/ue_character_import.py similarity index 99% rename from Tools/ue_character_import.py rename to Tools/unreal/ue_character_import.py index 9e93b82..46c5100 100644 --- a/Tools/ue_character_import.py +++ b/Tools/unreal/ue_character_import.py @@ -1,7 +1,7 @@ # Unreal-side half of the MPFB character acceptance: imports the skeletal FBX # with the named import preset "ue_character_v1" and verifies the result. # -# UnrealEditor-Cmd -run=pythonscript -script=/Tools/ue_character_import.py \ +# UnrealEditor-Cmd -run=pythonscript -script=/Tools/unreal/ue_character_import.py \ # -nullrhi -unattended -nopause -stdout import unreal diff --git a/Tools/ue_character_materials.py b/Tools/unreal/ue_character_materials.py similarity index 97% rename from Tools/ue_character_materials.py rename to Tools/unreal/ue_character_materials.py index 218f36f..e173215 100644 --- a/Tools/ue_character_materials.py +++ b/Tools/unreal/ue_character_materials.py @@ -1,8 +1,8 @@ # Wire the generated character textures into Unreal materials (0.2.2). # -# python3 Tools/generate_character_textures.py --out Art/textures/generated +# python3 Tools/gen/generate_character_textures.py --out Art/textures/generated # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_character_materials.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_character_materials.py -nullrhi -unattended -nopause -stdout # # Why new materials instead of editing the imported ones: # diff --git a/Tools/unreal/ue_clear_simple_collision.py b/Tools/unreal/ue_clear_simple_collision.py new file mode 100644 index 0000000..12d7a66 --- /dev/null +++ b/Tools/unreal/ue_clear_simple_collision.py @@ -0,0 +1,66 @@ +"""Remove the auto-generated simple collision from the site meshes, keeping their triangles. + + /UnrealEditor-Cmd -run=pythonscript \ + -script=/Tools/unreal/ue_clear_simple_collision.py -nullrhi -unattended -nopause -stdout + +Why: the FBX import runs with `auto_generate_collision`, which adds ONE convex hull per mesh. For a +279 m terrain or road that hull is a giant slab, and any *physics body* inside it - the Chaos vehicle - +is pushed out of the hull every frame. Walked characters never notice, because character movement +sweeps instead of resolving penetration, which is why the player was fine while the car was thrown +into the air. + +Clearing the hulls leaves `CTF_USE_COMPLEX_AS_SIMPLE`, so traces and physics use the real triangles. + +Result: /tmp/gigline/clear-simple-collision.txt +""" + +import unreal + +FOLDERS = ("/Game/Hollywood/Site", "/Game/Hollywood/Streets") +RESULT = "/tmp/gigline/clear-simple-collision.txt" +lines = [] + + +def log(message): + lines.append(str(message)) + unreal.log(str(message)) + + +def main(): + cleared = 0 + for folder in FOLDERS: + for path in unreal.EditorAssetLibrary.list_assets(folder, recursive=False): + mesh = unreal.EditorAssetLibrary.load_asset(path) + if not isinstance(mesh, unreal.StaticMesh): + continue + body = mesh.get_editor_property("body_setup") + if body is None: + continue + agg = body.get_editor_property("agg_geom") + convex = len(agg.get_editor_property("convex_elems")) + boxes = len(agg.get_editor_property("box_elems")) + if not convex and not boxes: + continue + agg.set_editor_property("convex_elems", []) + agg.set_editor_property("box_elems", []) + body.set_editor_property("agg_geom", agg) + body.set_editor_property("collision_trace_flag", + unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE) + mesh.set_editor_property("body_setup", body) + unreal.EditorAssetLibrary.save_loaded_asset(mesh) + log(f"{mesh.get_name()}: cleared {convex} convex, {boxes} box, complex-as-simple") + cleared += 1 + lines.append(f"VERDICT: {'PASS' if cleared else 'NOTHING-TO-DO'}") + log(f"{cleared} meshes cleared") + + +try: + main() +except Exception as exc: # noqa: BLE001 + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + lines.append("VERDICT: FAIL") + +with open(RESULT, "w") as f: + f.write("\n".join(lines) + "\n") diff --git a/Tools/ue_common.py b/Tools/unreal/ue_common.py similarity index 89% rename from Tools/ue_common.py rename to Tools/unreal/ue_common.py index 998646a..255b08a 100644 --- a/Tools/ue_common.py +++ b/Tools/unreal/ue_common.py @@ -6,7 +6,9 @@ is swallowed and a failed save reports `False` rather than raising. Both are tra that cost time in `0.1.x`, so they live here once instead of in each script. import os, sys - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + _HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_common ue_common.make_map_writable("/run/media/.../Content/Hollywood/DemoMap.umap") diff --git a/Tools/ue_create_input_assets.py b/Tools/unreal/ue_create_input_assets.py similarity index 98% rename from Tools/ue_create_input_assets.py rename to Tools/unreal/ue_create_input_assets.py index 6b22eb0..a401a3e 100644 --- a/Tools/ue_create_input_assets.py +++ b/Tools/unreal/ue_create_input_assets.py @@ -6,7 +6,7 @@ # # Run headless (imports/dirty packages are saved before exit): # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_create_input_assets.py -nullrhi -unattended -nopause \ +# -script=/Tools/unreal/ue_create_input_assets.py -nullrhi -unattended -nopause \ # -stdout -FullStdOutLogOutput # # The result goes to /tmp/gigline/input-assets.txt because 5.8's pythonscript diff --git a/Tools/unreal/ue_hero_facade_import.py b/Tools/unreal/ue_hero_facade_import.py new file mode 100644 index 0000000..4ff9b44 --- /dev/null +++ b/Tools/unreal/ue_hero_facade_import.py @@ -0,0 +1,297 @@ +# Imports the hero façade FBX into Unreal with the named preset `ue_metric_v1` +# and places it at the measured site position. +# +# /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ +# -script=/Tools/unreal/ue_hero_facade_import.py -nullrhi -unattended -nopause -stdout +# +# Placement follows Geo/manifests/local-origin.md and the blockout frame: +# project X = UTM easting - 376515.389 (east +) +# project Y = -(UTM northing - 3774224.083) in Unreal, because the FBX preset +# maps Blender +Y (north) to Unreal -Y and Blender +X to Unreal +X. The building +# line of the hero block sits at Unreal Y = -1450 cm (14.5 m south of the road +# centreline, from the street profile), and the hero's east edge at +# X = -12024.5 cm (projected control point in local-origin.md). + +import os +import sys + +import unreal + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) +import ue_common # noqa: E402 +import ue_placement # noqa: E402 + +FBX = "/tmp/gigline/hero-facade.fbx" +DEST = "/Game/Hollywood/ElCapitan" +ACTOR_LABEL = ue_placement.HERO_ACTOR +LEVEL = "/Game/Hollywood/DemoMap" + +# Unreal centimetres: south-east corner of the façade, on the building line. +# Placement lives in Tools/unreal/ue_placement.py. It used to be a literal here, and it +# drifted from ue_build_demo_map.py: this file kept the pre-0.1.0 position +# (-12024.5, -1450) while the builder staged (-13336, 1307), so running the importer +# on its own moved the hero 13 m east and onto the north side of the boulevard. +PLACEMENT_CM = ue_placement.HERO_LOCATION_CM + +RESULT = "/tmp/gigline/hero-facade-import.txt" +lines = [] + + +def log(msg): + lines.append(str(msg)) + + +# Generated with Tools/gen/comfyui_generate.py texture (AlbedoBase XL, see the sidecar beside the PNG). The +# reference facade is coursed rubble-ashlar, not a flat tint, and a texture is what carries that at 45 m. +FACADE_ALBEDO = "Art/textures/generated/T_FacadeAshlar_AI.png" +FACADE_ALBEDO_ASSET = "T_FacadeAshlar_AI" +BANNER_ALBEDO = "Art/textures/generated/T_Banner_KIMMEL.png" +BANNER_ALBEDO_ASSET = "T_Banner_KIMMEL" +TEXTURE_DIR = "/Game/Hollywood/Textures" + +FACADE_TINTS = { + # Linear values, sampled from the reference photograph: the blockout's near-white stone was the one + # thing the judge kept calling out. The FBX import keeps an existing material asset, so the colour has + # to be written into the material after the import - dropping the asset instead broke the mesh's slots. + # Owner review: the elevation read as near-white grey. These are the reference's warm yellow-grey + # limestone, and they multiply the albedo rather than replace it, so the value lands inside the range the + # photographs show instead of the generator's brighter one. + # Sampled from the reference (02-masonictemple01.jpg): sunlit stone sRGB ~0.49, attic highlight ~0.75, + # shaded recess ~0.43. The albedo is mid-grey, so the tint carries the colour and lands the product near + # 0.25 linear for lit stone - not the near-white the owner rejected. + "M_Facade_Stone": (0.47, 0.455, 0.43), + "M_Facade_StoneShadow": (0.30, 0.29, 0.27), + "M_Facade_GlassDark": (0.02, 0.025, 0.03), + "M_Facade_Banner": (0.26, 0.012, 0.012), + "M_Facade_Grille": (0.06, 0.06, 0.065), +} + + +def import_texture(rel_path, asset_name): + """Import a generated texture, if it is on disk. Returns the asset or None.""" + # unreal.Paths.project_dir() is Game/GigLine, not the repository root: the textures live beside Art/, + # two levels above this file. + # Tools/unreal/ -> repository root is three levels up. + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + path = os.path.join(repo_root, rel_path) + if not os.path.exists(path): + log(f"texture missing: {path}") + return None + task = unreal.AssetImportTask() + task.filename = path + task.destination_path = TEXTURE_DIR + task.destination_name = asset_name + task.automated = True + task.save = True + task.replace_existing = True + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + asset = unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}") + log(f"texture imported: {asset.get_path_name() if asset else 'FAILED'}") + return asset + + +def tint_imported_materials(): + """Author one plain material per facade surface, with the reference colour and the stone albedo. + + The imported materials are MaterialInstanceConstants, which cannot be rebuilt in place + (`MaterialEditingLibrary` rejects them), and assigning new assets to the mesh's slot list does not persist + either. So this creates real Material assets named `_Ref` and the map builders point the hero's + component slots at them (`apply_hero_materials()`), which does persist in the saved map. + + Albedo is multiplied by a tint rather than used raw: the generator's stone is brighter than the reference, + and the owner's review was that the elevation read near-white grey. + """ + library = unreal.MaterialEditingLibrary + albedo = import_texture(FACADE_ALBEDO, FACADE_ALBEDO_ASSET) + banner_tex = import_texture(BANNER_ALBEDO, BANNER_ALBEDO_ASSET) + built = 0 + for base, rgb in FACADE_TINTS.items(): + path = f"{DEST}/{base}_Ref" + if unreal.EditorAssetLibrary.does_asset_exist(path): + unreal.EditorAssetLibrary.delete_asset(path) + material = unreal.AssetToolsHelpers.get_asset_tools().create_asset( + f"{base}_Ref", DEST, unreal.Material, unreal.MaterialFactoryNew()) + colour = library.create_material_expression( + material, unreal.MaterialExpressionConstant3Vector, -600, 200) + colour.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0)) + source = colour + kind = "tint" + if albedo is not None and base in ("M_Facade_Stone", "M_Facade_StoneShadow"): + sample = library.create_material_expression( + material, unreal.MaterialExpressionTextureSampleParameter2D, -900, -100) + sample.set_editor_property("parameter_name", f"Albedo_{base}") + sample.set_editor_property("texture", albedo) + sample.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_COLOR) + uvs = library.create_material_expression( + material, unreal.MaterialExpressionTextureCoordinate, -1150, -100) + uvs.set_editor_property("u_tiling", 6.0) + uvs.set_editor_property("v_tiling", 6.0) + library.connect_material_expressions(uvs, "", sample, "UVs") + multiply = library.create_material_expression( + material, unreal.MaterialExpressionMultiply, -300, 0) + library.connect_material_expressions(sample, "RGB", multiply, "A") + library.connect_material_expressions(colour, "", multiply, "B") + source = multiply + kind = "albedo x tint" + elif banner_tex is not None and base == "M_Facade_Banner": + # The banner texture carries both the red ground and the gold lettering, so it + # replaces the tint outright (tiling 1.0, not the 6x stone coursing). + sample = library.create_material_expression( + material, unreal.MaterialExpressionTextureSampleParameter2D, -900, -100) + sample.set_editor_property("parameter_name", f"Banner_{base}") + sample.set_editor_property("texture", banner_tex) + sample.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_COLOR) + uvs = library.create_material_expression( + material, unreal.MaterialExpressionTextureCoordinate, -1150, -100) + library.connect_material_expressions(uvs, "", sample, "UVs") + source = sample + kind = "banner" + library.connect_material_property(source, "", unreal.MaterialProperty.MP_BASE_COLOR) + library.recompile_material(material) + saved = unreal.EditorAssetLibrary.save_loaded_asset(material) + built += 1 + log(f"{base}_Ref: {kind} {rgb}, saved {saved}") + log(f"authored {built} facade materials") + + +def drop_stale_mesh(): + """Remove the imported mesh so its slots are rebuilt from the FBX.""" + path = f"{DEST}/SM_HeroFacade" + if unreal.EditorAssetLibrary.does_asset_exist(path): + unreal.EditorAssetLibrary.delete_asset(path) + log(f"dropped stale mesh {path}") + + +def drop_stale_materials(): + """Delete the imported facade materials so the FBX recreates them with the current colours. + + Unreal keeps an existing material asset when an FBX that references the same name is re-imported, so a + colour change in the Blender builder never reached the engine - the wall stayed the blockout's near-white + through several rebuilds. Deleting first is what makes the material part of the model. + """ + dropped = [] + for name in ("M_Facade_Stone", "M_Facade_StoneShadow", "M_Facade_GlassDark", + "M_Facade_Banner", "M_Facade_Grille"): + path = f"{DEST}/{name}" + if unreal.EditorAssetLibrary.does_asset_exist(path): + unreal.EditorAssetLibrary.delete_asset(path) + dropped.append(name) + log(f"dropped stale materials: {dropped if dropped else 'none'}") + + +def import_fbx(): + task = unreal.AssetImportTask() + task.filename = FBX + task.destination_path = DEST + task.destination_name = "SM_HeroFacade" + task.automated = True + task.save = True + task.replace_existing = True + + ui = unreal.FbxImportUI() + ui.import_mesh = True + ui.import_materials = True + ui.import_textures = False + ui.import_as_skeletal = False + smd = ui.static_mesh_import_data + smd.import_uniform_scale = 1.0 + smd.combine_meshes = True + smd.convert_scene_unit = False + smd.force_front_x_axis = False + smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS + task.options = ui + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True) + + +def _make_map_writable(level: str) -> None: + """Clear the read-only bit an LFS .umap checks out with. + + Without this `save_map()` returns False and the staged actor is lost on the + next load — no exception, no non-zero exit (see MEMORY: 0.1.x trap). + """ + content = unreal.Paths.project_content_dir() + rel = level[len("/Game/"):] if level.startswith("/Game/") else level + umap = os.path.join(content, rel + ".umap") + note = ue_common.make_writable(umap) + if note: + log(f"map writable: {note}") + +def measure_and_place(): + asset = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade") + if asset is None: + log(f"MISSING {DEST}/SM_HeroFacade") + return + # Collision: complex-as-simple, not auto-generated convex hulls. + # The facade is 95 separate parts across 26 m; Unreal's auto hull generation + # wraps them in a few coarse hulls that bulged into the street (the QA wall + # probe went 0/35 -> 2/35 after trying it). Complex-as-simple makes the real + # triangles the simple collision, so line traces and capsule sweeps block on + # the actual wall — the same approach used for the terrain, for the same + # reason. The flag lives on the asset, so save it explicitly. + unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True) + saved = unreal.EditorAssetLibrary.save_loaded_asset(asset) + log(f"collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} (saved={saved})") + box = asset.get_bounding_box() + mn, mx = box.min, box.max + log(f"SM_HeroFacade bounds cm: size=({mx.x - mn.x:.1f}, {mx.y - mn.y:.1f}, {mx.z - mn.z:.1f}) " + f"min=({mn.x:.1f}, {mn.y:.1f}, {mn.z:.1f})") + # StaticMaterial is a struct wrapper: the material is its material_interface. + names = [] + for sm in asset.get_editor_property("static_materials"): + mat = sm.get_editor_property("material_interface") + names.append(mat.get_name() if mat else "None") + log(f"materials: {names}") + + # Place on the demo map if it exists; otherwise create it from the empty level. + if unreal.EditorAssetLibrary.does_asset_exist(LEVEL): + unreal.EditorLevelLibrary.load_level(LEVEL) + else: + if not unreal.EditorLevelLibrary.new_level(LEVEL): + log(f"could not create {LEVEL}") + return + for actor in unreal.EditorLevelLibrary.get_all_level_actors(): + if actor.get_actor_label() == ACTOR_LABEL: + unreal.EditorLevelLibrary.destroy_actor(actor) + actor = unreal.EditorLevelLibrary.spawn_actor_from_class( + unreal.StaticMeshActor, unreal.Vector(*PLACEMENT_CM)) + actor.set_actor_label(ACTOR_LABEL) + comp = actor.static_mesh_component + comp.set_static_mesh(asset) + comp.set_mobility(unreal.ComponentMobility.STATIC) + log(f"placed {ACTOR_LABEL} at {PLACEMENT_CM} (cm) on {LEVEL}") + + # Prove it landed on its verified footprint, not merely that it spawned. + box = asset.get_bounding_box() if asset else None + if box: + lo = box.min.x + PLACEMENT_CM[0] + hi = box.max.x + PLACEMENT_CM[0] + problem = ue_placement.describe_span_failure("hero", lo, hi) + if problem: + log(f"PLACEMENT ERROR: {problem}") + else: + log(f"span ok: X {lo:.0f}..{hi:.0f}") + _make_map_writable(LEVEL) + saved = unreal.EditorLoadingAndSavingUtils.save_map( + unreal.EditorLevelLibrary.get_editor_world(), LEVEL) + log(f"save_map {LEVEL}: {saved}") + if not ue_common.require_saved(saved, LEVEL): + log(f"FAILED to save {LEVEL} — staged content will not persist") + + +try: + drop_stale_mesh() + drop_stale_materials() + import_fbx() + tint_imported_materials() + measure_and_place() +except Exception as exc: # noqa: BLE001 + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + +with open(RESULT, "w") as handle: + handle.write("\n".join(lines) + "\n") diff --git a/Tools/ue_import_block_frontage.py b/Tools/unreal/ue_import_block_frontage.py similarity index 88% rename from Tools/ue_import_block_frontage.py rename to Tools/unreal/ue_import_block_frontage.py index 8f22e45..a393b30 100644 --- a/Tools/ue_import_block_frontage.py +++ b/Tools/unreal/ue_import_block_frontage.py @@ -1,14 +1,14 @@ # Import the south-side block frontage FBX set into Unreal. # -# blender ... --python Tools/export_block_frontage.py -- --out /tmp/gigline/frontage +# blender ... --python Tools/blender/export_block_frontage.py -- --out /tmp/gigline/frontage # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_import_block_frontage.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_import_block_frontage.py -nullrhi -unattended -nopause -stdout # # Each mesh already sits at its world position in the project frame (the FBX was # exported without moving the object), so the actor is spawned at the origin and # the geometry carries the offset. Destination: /Game/Hollywood/Frontage/. # -# Staging into the DemoMap is done by Tools/ue_build_demo_map.py, which also +# Staging into the DemoMap is done by Tools/unreal/ue_build_demo_map.py, which also # assigns the look materials; this script only produces the assets. import glob @@ -73,7 +73,7 @@ def import_fbx(path, name): def main(): files = sorted(glob.glob(os.path.join(SRC, "*.fbx"))) if not files: - log(f"no FBX in {SRC}; run Tools/export_block_frontage.py first") + log(f"no FBX in {SRC}; run Tools/blender/export_block_frontage.py first") return for path in files: name = os.path.splitext(os.path.basename(path))[0] diff --git a/Tools/ue_import_facade_kit.py b/Tools/unreal/ue_import_facade_kit.py similarity index 89% rename from Tools/ue_import_facade_kit.py rename to Tools/unreal/ue_import_facade_kit.py index a3d50e1..9f369fb 100644 --- a/Tools/ue_import_facade_kit.py +++ b/Tools/unreal/ue_import_facade_kit.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """Import the façade module kit into Unreal: /Game/Hollywood/Kit/*. -Pairs with `Tools/facade_kit_build.py`. Each piece is imported as its own static +Pairs with `Tools/blender/facade_kit_build.py`. Each piece is imported as its own static mesh at the module's origin (bottom-front centre), so a building can place a piece by bay index and storey index with a single offset. UnrealEditor-Cmd -run=pythonscript \ - -script=Tools/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout + -script=Tools/unreal/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout Result file: /tmp/gigline/kit-import.txt (the commandlet swallows print()). """ @@ -18,7 +18,9 @@ import sys import unreal -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_common # noqa: E402 SRC = "/tmp/gigline/kit" @@ -35,7 +37,7 @@ def log(message: str) -> None: def main() -> None: if not os.path.isdir(SRC): - log(f"MISSING source directory {SRC} — run Tools/facade_kit_build.py first") + log(f"MISSING source directory {SRC} — run Tools/blender/facade_kit_build.py first") return files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx")) if not files: diff --git a/Tools/ue_import_north_buildings.py b/Tools/unreal/ue_import_north_buildings.py similarity index 94% rename from Tools/ue_import_north_buildings.py rename to Tools/unreal/ue_import_north_buildings.py index f65999a..a7ccfd5 100644 --- a/Tools/ue_import_north_buildings.py +++ b/Tools/unreal/ue_import_north_buildings.py @@ -9,14 +9,16 @@ # (0,0,0) like the site meshes, not at the pivot-shifted hero facade position. # # UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout import os import sys import unreal -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_common # noqa: E402 SRC = "/tmp/gigline/north" diff --git a/Tools/ue_import_retail_detail.py b/Tools/unreal/ue_import_retail_detail.py similarity index 91% rename from Tools/ue_import_retail_detail.py rename to Tools/unreal/ue_import_retail_detail.py index 313646e..91a97c6 100644 --- a/Tools/ue_import_retail_detail.py +++ b/Tools/unreal/ue_import_retail_detail.py @@ -1,9 +1,9 @@ # Import the retail frontage trim (6800 Lee Drug, 6806–6820) into Unreal. # -# blender -b --factory-startup --python Tools/retail_frontage_build.py -- \ +# blender -b --factory-startup --python Tools/blender/retail_frontage_build.py -- \ # --out /tmp/gigline/retail --report /tmp/gigline/retail-report.json # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_import_retail_detail.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_import_retail_detail.py -nullrhi -unattended -nopause -stdout # # Two meshes arrive, one per building, each already at its world position in the # project frame (the FBX carries the offset), so staging spawns at the origin. @@ -76,7 +76,7 @@ def import_fbx(path, name): def main(): files = sorted(glob.glob(os.path.join(SRC, "*.fbx"))) if not files: - log(f"no FBX in {SRC}; run Tools/retail_frontage_build.py first") + log(f"no FBX in {SRC}; run Tools/blender/retail_frontage_build.py first") return for path in files: import_fbx(path, os.path.splitext(os.path.basename(path))[0]) diff --git a/Tools/ue_import_site.py b/Tools/unreal/ue_import_site.py similarity index 91% rename from Tools/ue_import_site.py rename to Tools/unreal/ue_import_site.py index 67fee09..8e0c77a 100644 --- a/Tools/ue_import_site.py +++ b/Tools/unreal/ue_import_site.py @@ -2,7 +2,7 @@ # # blender ... --python Art/blender/site/export_site_fbx.py -- --out /tmp/gigline/site # /.../UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_import_site.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_import_site.py -nullrhi -unattended -nopause -stdout # # Each mesh is a separate FBX because they carry different materials and want # different collision settings (terrain and street are walkable surfaces). @@ -107,8 +107,11 @@ def main(): if stem == "Crossing_Markings": import_fbx(path, stem, "none") continue - collision = "complex" if stem in ("Terrain_DEM", "Road_Hollywood", - "Sidewalk_South", "Sidewalk_North") else "simple" + # Segmented ground surfaces keep their names with a numeric suffix; both the base + # names and the slices want the same walkable collision. + walkable = ("Terrain_DEM", "Road_Hollywood", "Road_Hollywood_", + "Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North") + collision = "complex" if any(stem.startswith(w) for w in walkable) else "simple" import_fbx(path, stem, collision) unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True) diff --git a/Tools/ue_placement.py b/Tools/unreal/ue_placement.py similarity index 96% rename from Tools/ue_placement.py rename to Tools/unreal/ue_placement.py index 9d20228..929adb4 100644 --- a/Tools/ue_placement.py +++ b/Tools/unreal/ue_placement.py @@ -9,7 +9,7 @@ boulevard** — and nothing complained, because a displaced building still rende The values are Unreal centimetres, absolute world space. Every one of them is verified against `Geo/verification/2026-11-06-el-capitan-block.md` and probed by -`Tools/visual_loop.py --verify` (via the demo screenshot expectations) and by the +`Tools/review/visual_loop.py --verify` (via the demo screenshot expectations) and by the world-span probes recorded in `MEMORY.md`. Keep this file boring: constants and one validator, no `unreal` import, so it can diff --git a/Tools/unreal/ue_recreate_maps.py b/Tools/unreal/ue_recreate_maps.py new file mode 100644 index 0000000..157307b --- /dev/null +++ b/Tools/unreal/ue_recreate_maps.py @@ -0,0 +1,84 @@ +"""Empty both Unreal maps before a build, and verify they really are empty. + + /UnrealEditor-Cmd -run=pythonscript \ + -script=/Tools/unreal/ue_recreate_maps.py -nullrhi -unattended -nopause -stdout + +Why this does not delete the `.umap` files: `LevelEditorSubsystem.new_level()` refuses a path whose +package is still in memory, and `EditorAssetLibrary.delete_asset()` leaves the file on disk with the package +still registered, so deleting and recreating in one session always returns False. Measured: + + new_level fresh -> True + delete_asset -> True + disk file still present -> True + new_level again -> False + +What does work, and is what the maps actually need, is clearing every actor and saving: the builders stage +everything they use, so a level with no actors is the blank slate. Anything an experiment left behind - an +actor from a probe, a duplicated vehicle, a stale shot camera - is gone, which is the failure this step +exists to prevent. + +Both maps are emptied and saved, and the script reports the actor count it left behind. + +Result: /tmp/gigline/recreate-maps.txt +""" + +import os + +import unreal + +MAPS = ("/Game/Hollywood/DemoMap", "/Game/Test/ReviewMap") +RESULT = "/tmp/gigline/recreate-maps.txt" +lines = [] + + +def log(message): + lines.append(str(message)) + unreal.log(str(message)) + + +def make_writable(path): + """A .umap under Content/ that is read-only cannot be saved; LFS checks it out that way.""" + disk = os.path.join(unreal.Paths.project_content_dir(), + path.replace("/Game/", "") + ".umap") + try: + mode = os.stat(disk).st_mode & 0o777 + if not mode & 0o200: + os.chmod(disk, 0o644) + log(f"{path}: chmod u+w ({oct(mode)} -> 0o644)") + except FileNotFoundError: + pass + + +def main(): + les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) + for path in MAPS: + make_writable(path) + if not les.load_level(path): + log(f"{path}: could not load, and new_level cannot replace an in-memory package") + continue + removed = 0 + for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()): + try: + unreal.EditorLevelLibrary.destroy_actor(actor) + removed += 1 + except Exception: # noqa: BLE001 + pass + left = len(unreal.EditorLevelLibrary.get_all_level_actors()) + saved = unreal.EditorLoadingAndSavingUtils.save_map( + unreal.EditorLevelLibrary.get_editor_world(), path) + log(f"{path}: cleared {removed} actors, {left} left, save_map {saved}") + empty = all(" 0 left" in line for line in lines) + lines.append(f"VERDICT: {'PASS' if empty else 'FAIL'}") + log(f"maps emptied: {'yes' if empty else 'no'}") + + +try: + main() +except Exception as exc: # noqa: BLE001 + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + lines.append("VERDICT: FAIL") + +with open(RESULT, "w") as handle: + handle.write("\n".join(lines) + "\n") diff --git a/Tools/ue_scale_test_import.py b/Tools/unreal/ue_scale_test_import.py similarity index 95% rename from Tools/ue_scale_test_import.py rename to Tools/unreal/ue_scale_test_import.py index 8e8c5dd..6391e63 100644 --- a/Tools/ue_scale_test_import.py +++ b/Tools/unreal/ue_scale_test_import.py @@ -3,7 +3,7 @@ # # Run headless: # /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ -# -script=/Tools/ue_scale_test_import.py -nullrhi -unattended -nopause -stdout +# -script=/Tools/unreal/ue_scale_test_import.py -nullrhi -unattended -nopause -stdout import unreal diff --git a/Tools/ue_stage_theatre_trim.py b/Tools/unreal/ue_stage_theatre_trim.py similarity index 93% rename from Tools/ue_stage_theatre_trim.py rename to Tools/unreal/ue_stage_theatre_trim.py index 5448ddc..f56c9e9 100644 --- a/Tools/ue_stage_theatre_trim.py +++ b/Tools/unreal/ue_stage_theatre_trim.py @@ -10,7 +10,7 @@ Two steps, both idempotent: happen — the kit was once placed 12 km away that way. UnrealEditor-Cmd -run=pythonscript \ - -script=Tools/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout + -script=Tools/unreal/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout Result file: `/tmp/gigline/theatre-import.txt`. """ @@ -22,7 +22,9 @@ import sys import unreal -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common +sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement) import ue_common # noqa: E402 SRC = "/tmp/gigline/theatre" @@ -52,7 +54,7 @@ def log(message: str) -> None: def import_pieces() -> list[str]: if not os.path.isdir(SRC): - log(f"MISSING {SRC} — run Tools/theatre_trim_build.py first") + log(f"MISSING {SRC} — run Tools/blender/theatre_trim_build.py first") return [] files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx")) if not files: diff --git a/Tools/unreal/ue_vehicle_import.py b/Tools/unreal/ue_vehicle_import.py new file mode 100644 index 0000000..2ddfe1c --- /dev/null +++ b/Tools/unreal/ue_vehicle_import.py @@ -0,0 +1,145 @@ +# Import the sedan systems as separate static meshes (0.3.0). +# +# blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \ +# --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx +# /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ +# -script=/Tools/unreal/ue_vehicle_import.py -nullrhi -unattended -nopause -stdout +# +# One asset per system, per the vehicle-artist skill's object split: body, glass, front +# and rear lights, mirrors, and one wheel reused four times. The body FBX also carries +# the UCX_ hulls, which Unreal consumes as simple collision. +# +# Result file: /tmp/gigline/vehicle-import.txt + +import os + +import unreal + +DEST = "/Game/Hollywood/Vehicle" +SOURCE = "/tmp/gigline/hatch" +SYSTEMS = ( + ("body", "SM_Vehicle_Body"), + ("trim", "SM_Vehicle_Trim"), + ("glass", "SM_Vehicle_Glass"), + ("lights", "SM_Vehicle_Lights"), + ("wheels", "SM_Vehicle_Wheels"), +) +RESULT = "/tmp/gigline/vehicle-import.txt" +lines = [] + + +def log(msg): + lines.append(str(msg)) + unreal.log(str(msg)) + + +def main(): + for source, name in SYSTEMS: + path = os.path.join(SOURCE, f"{source}.fbx") + if not os.path.exists(path): + log(f"{name}: missing source {path}") + lines.append("VERDICT: FAIL") + return + task = unreal.AssetImportTask() + task.filename = path + task.destination_path = DEST + task.destination_name = name + task.automated = True + task.save = True + task.replace_existing = True + + ui = unreal.FbxImportUI() + ui.import_mesh = True + ui.import_as_skeletal = False + ui.import_materials = True + ui.import_textures = False + ui.static_mesh_import_data.combine_meshes = True + ui.static_mesh_import_data.generate_lightmap_u_vs = True + ui.static_mesh_import_data.auto_generate_collision = True + ui.static_mesh_import_data.import_uniform_scale = 1.0 + task.options = ui + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}") + if mesh is None: + log(f"{name}: MISSING after import") + lines.append("VERDICT: FAIL") + return + bounds = mesh.get_bounding_box() if hasattr(mesh, "get_bounding_box") else None + size = "" + if bounds: + extent = bounds.max - bounds.min + size = f"{extent.x/100:.2f} x {extent.y/100:.2f} x {extent.z/100:.2f} m" + mats = mesh.get_editor_property("static_materials") + log(f"{name}: imported, {len(mats)} material slot(s), bounds {size}") + + # Wire the two LOD meshes into the body's LOD group. import_lod is the supported + # path; copying bytes between assets is not, and the LOD group is what the engine + # uses to pick by screen size. + # LOD meshes are imported as their own assets. Wiring them into the body's LOD group + # needs StaticMeshEditorSubsystem.import_lod, which is unavailable in this headless + # commandlet (get_editor_subsystem returns None), so the group is a follow-up. + for source, name in (("body_lod1", "SM_Vehicle_Body_LOD1"), + ("body_lod2", "SM_Vehicle_Body_LOD2")): + path = os.path.join(SOURCE, f"{source}.fbx") + if not os.path.exists(path): + log(f"{name}: missing {path}") + continue + task = unreal.AssetImportTask() + task.filename = path + task.destination_path = DEST + task.destination_name = name + task.automated = True + task.save = True + task.replace_existing = True + ui = unreal.FbxImportUI() + ui.import_mesh = True + ui.import_materials = True + ui.static_mesh_import_data.combine_meshes = True + task.options = ui + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}") + log(f"{name}: imported as its own asset ({'ok' if mesh else 'MISSING'}); " + "LOD group wiring needs an editor session") + + # Skeletal vehicle for Chaos is not built from this template yet: the hatch is a + # static model, and the drivable rig has to be authored around it. + skel_path = os.path.join(SOURCE, "vehicle_skeletal.fbx") + if os.path.exists(skel_path): + task = unreal.AssetImportTask() + task.filename = skel_path + task.destination_path = DEST + task.destination_name = "SK_Vehicle_Sedan" + task.automated = True + task.save = True + task.replace_existing = True + ui = unreal.FbxImportUI() + ui.import_mesh = True + ui.import_as_skeletal = True + ui.import_animations = False + ui.import_materials = True + ui.skeletal_mesh_import_data.import_uniform_scale = 1.0 + task.options = ui + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + skel = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SK_Vehicle_Sedan") + if skel: + skeleton = skel.get_editor_property("skeleton") + # Skeleton has no bone_names property in 5.8; read the reference skeleton + # through the asset registry instead of guessing a property name. + log(f"SK_Vehicle_Sedan: imported, skeleton={skeleton.get_name() if skeleton else 'NONE'}") + else: + log("SK_Vehicle_Sedan: MISSING after import") + lines.append("VERDICT: PASS") + + +try: + main() +except Exception as exc: # noqa: BLE001 + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + lines.append("VERDICT: FAIL") + +unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True) +with open(RESULT, "w") as f: + f.write("\n".join(lines) + "\n") diff --git a/Tools/unreal/ue_vehicle_materials.py b/Tools/unreal/ue_vehicle_materials.py new file mode 100644 index 0000000..a049018 --- /dev/null +++ b/Tools/unreal/ue_vehicle_materials.py @@ -0,0 +1,148 @@ +# Rebuild the vehicle's materials from the Blender colours (0.3.0). +# +# blender -b "Art/blender/vehicle/hot hatch.blend" \ +# --python Tools/blender/vehicle_from_template.py -- --out /tmp/gigline/hatch +# /Engine/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ +# -script=/Tools/unreal/ue_vehicle_materials.py -nullrhi -unattended -nopause -stdout +# +# Why this exists: the template's car paint is a **legacy node graph**, so its colour never +# reached the exported FBX and the car arrived in Unreal near-black while Blender showed it +# red. `Tools/blender/vehicle_from_template.py` now reads those graphs and records the colours; this +# replaces the imported material assets in place with plain constant-colour materials, so +# the mesh slots pick them up by path without anything being re-imported. +# +# Result file: /tmp/gigline/vehicle-materials.txt + +import json +import os + +import unreal + +DEST = "/Game/Hollywood/Vehicle" +_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", "..")) +REPORT = os.path.join(_REPO, "..", "..", "..", "tmp", "gigline", "hatch", "report.json") +REPORT = "/tmp/gigline/hatch/report.json" +RESULT = "/tmp/gigline/vehicle-materials.txt" +lines = [] + + +def log(msg): + lines.append(str(msg)) + unreal.log(str(msg)) + + +def existing_material_name(name): + """Find the asset the importer actually created for a Blender material name. + + Blender names may contain spaces ("car paint"), which are not valid asset names, so the + importer sanitises them. Guessing the sanitised form is fragile; this searches the + folder instead and reports when nothing matches. + """ + registry = unreal.AssetRegistryHelpers.get_asset_registry() + wanted = name.lower().replace(" ", "").replace("_", "") + for asset in registry.get_assets_by_path(DEST, recursive=False): + candidate = str(asset.asset_name).lower().replace(" ", "").replace("_", "") + if candidate == wanted: + return str(asset.asset_name) + return None + + +def make_translucent_glass(material): + """Turn a material into dark, slightly transparent glass. + + The template's `windows` material is metallic 1.0 with roughness 0.0, which reads as a + black void in game - indistinguishable from an empty cabin. Glass needs translucency and + a little roughness to catch a highlight. + """ + lib = unreal.MaterialEditingLibrary + material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_TRANSLUCENT) + material.set_editor_property("two_sided", False) + for node in list(lib.get_material_expressions(material)): + lib.delete_material_expression(material, node) + base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -400, 0) + base.set_editor_property("constant", unreal.LinearColor(0.02, 0.03, 0.045, 1.0)) + lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR) + opacity = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 140) + opacity.set_editor_property("r", 0.85) + lib.connect_material_property(opacity, "", unreal.MaterialProperty.MP_OPACITY) + rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 220) + rough.set_editor_property("r", 0.08) + lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS) + specular = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 300) + specular.set_editor_property("r", 1.0) + lib.connect_material_property(specular, "", unreal.MaterialProperty.MP_SPECULAR) + lib.recompile_material(material) + return material + + +def replace_material(name, spec, create_if_missing=False): + resolved = existing_material_name(name) + if resolved is not None: + name = resolved + elif not create_if_missing: + log(f"{name}: no imported asset matches - skipped") + return None + full = f"{DEST}/{name}" + if unreal.EditorAssetLibrary.does_asset_exist(full): + unreal.EditorAssetLibrary.delete_asset(full) + tools = unreal.AssetToolsHelpers.get_asset_tools() + material = tools.create_asset(name, DEST, unreal.Material, unreal.MaterialFactoryNew()) + if material is None: + log(f"{name}: could not create") + return None + lib = unreal.MaterialEditingLibrary + colour = spec["base_color"] + base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -400, 0) + base.set_editor_property("constant", unreal.LinearColor(colour[0], colour[1], colour[2], 1.0)) + lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR) + rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 180) + rough.set_editor_property("r", max(0.05, float(spec.get("roughness", 0.5)))) + lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS) + metallic = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 260) + metallic.set_editor_property("r", float(spec.get("metallic", 0.0))) + lib.connect_material_property(metallic, "", unreal.MaterialProperty.MP_METALLIC) + lib.recompile_material(material) + log(f"{name}: base {tuple(round(c, 3) for c in colour)} " + f"roughness {spec.get('roughness')} metallic {spec.get('metallic')} " + f"({spec.get('source')})") + return material + + +def main(): + with open(REPORT) as handle: + report = json.load(handle) + materials = report.get("materials", {}) + log(f"{len(materials)} materials in the Blender report") + made = 0 + for name, spec in materials.items(): + material = replace_material(name, spec) + if material: + made += 1 + if material and name.lower() == "windows": + make_translucent_glass(material) + log("windows: tinted glass (opacity 0.85, roughness 0.08) - 0.45 read as no glass at all") + # The body arrives without a usable material (its FBX slot resolves to + # WorldGridMaterial), so its paint is created under a name of our own and assigned to + # the staged panel at runtime. + paint = materials.get("car paint") + if paint: + spec = dict(paint) + spec["base_color"] = [1.0, 0.05, 0.03] + replace_material("M_Vehicle_HatchPaint", spec, create_if_missing=True) + log("M_Vehicle_HatchPaint: body paint, assigned at runtime by the map builder") + + unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True) + lines.append(f"VERDICT: {'PASS' if made == len(materials) else 'FAIL'}") + log(f"{made}/{len(materials)} materials replaced") + + +try: + main() +except Exception as exc: # noqa: BLE001 + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + lines.append("VERDICT: FAIL") + +with open(RESULT, "w") as f: + f.write("\n".join(lines) + "\n") diff --git a/Tools/unreal/ue_vehicle_probe_map.py b/Tools/unreal/ue_vehicle_probe_map.py new file mode 100644 index 0000000..8a7dbf5 --- /dev/null +++ b/Tools/unreal/ue_vehicle_probe_map.py @@ -0,0 +1,111 @@ +"""Add one piece of demo content to ReviewMap and save it as a vehicle probe level. + + GIGLINE_PROBE_ASSET=Terrain_DEM [GIGLINE_PROBE_NOCOLLISION=1] /UnrealEditor-Cmd \ + -run=pythonscript -script=/Tools/unreal/ue_vehicle_probe_map.py -nullrhi -unattended -nopause + +Why: the Chaos pawn falls correctly on ReviewMap's empty floor and is thrown upward everywhere in +DemoMap. Rather than guess which of DemoMap's 593 staged meshes is responsible, add them one group +at a time to the map that works and watch for the moment the pawn stops falling. With no +GIGLINE_PROBE_ASSET set, the level is a plain copy of ReviewMap - the control. + +Assets are looked up as /Game/Hollywood/Site/ first, then /Game/Hollywood/Buildings/. +Result: /tmp/gigline/vehicle-probe.txt +""" + +import os + +import unreal + +RESULT = "/tmp/gigline/vehicle-probe.txt" +TARGET = "/Game/Test/VehicleProbe" +SOURCE = "/Game/Test/ReviewMap" +SEARCH = ("/Game/Hollywood/Site", "/Game/Hollywood/Buildings", + "/Game/Hollywood/Streets", "/Game/Hollywood/Props") +lines = [] + + +def log(message): + lines.append(str(message)) + unreal.log(str(message)) + + +def find_asset(name): + for folder in SEARCH: + path = f"{folder}/{name}" + if unreal.EditorAssetLibrary.does_asset_exist(path): + return path + return None + + +def main(): + wanted = os.environ.get("GIGLINE_PROBE_ASSET", "").strip() + if not unreal.EditorAssetLibrary.does_asset_exist(SOURCE): + log(f"missing source level {SOURCE}") + lines.append("VERDICT: FAIL") + return + target = TARGET + # A level that the previous run left behind cannot always be deleted while the editor holds it, + # so fall back to a suffixed name rather than failing the probe. + for suffix in ("", "_b", "_c", "_d"): + target = TARGET + suffix + if not unreal.EditorAssetLibrary.does_asset_exist(target): + break + unreal.EditorAssetLibrary.delete_asset(target) + if not unreal.EditorAssetLibrary.does_asset_exist(target): + break + if not unreal.EditorAssetLibrary.duplicate_asset(SOURCE, target): + log(f"could not duplicate {SOURCE} -> {TARGET}") + lines.append("VERDICT: FAIL") + return + log(f"duplicated {SOURCE} -> {target}") + unreal.EditorLevelLibrary.load_level(target) + + if wanted: + path = find_asset(wanted) + if path is None: + log(f"MISSING asset {wanted} in {SEARCH}") + lines.append("VERDICT: FAIL") + return + mesh = unreal.EditorAssetLibrary.load_asset(path) + actor = unreal.EditorLevelLibrary.spawn_actor_from_class( + unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0)) + comp = actor.get_component_by_class(unreal.StaticMeshComponent) + comp.set_static_mesh(mesh) + actor.set_actor_label(f"Probe_{wanted}") + log(f"staged probe mesh {path} at the origin") + # GIGLINE_PROBE_NOCOLLISION=1 stages the same mesh with no collision at all, which separates + # "this mesh's collision ejects physics bodies" from "this mesh does something else". + if os.environ.get("GIGLINE_PROBE_NOCOLLISION", "").strip() == "1": + comp.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION) + log("probe mesh collision disabled") + else: + log("control run: no extra content") + + vehicle_class = unreal.load_class(None, "/Script/GigLine.GigLineVehicle") + if vehicle_class: + pawn = unreal.EditorLevelLibrary.spawn_actor_from_class( + vehicle_class, unreal.Vector(600.0, 0.0, 300.0)) + pawn.set_actor_label("Probe_Vehicle") + log("staged the Chaos pawn at (600, 0, 300)") + else: + log("vehicle class not found") + + if unreal.EditorLevelLibrary.save_current_level(): + log(f"saved {target}") + lines.append(f"LEVEL: {target}") + lines.append("VERDICT: PASS") + else: + log(f"save failed for {target}") + lines.append("VERDICT: FAIL") + + +try: + main() +except Exception as exc: # noqa: BLE001 + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + lines.append("VERDICT: FAIL") + +with open(RESULT, "w") as f: + f.write("\n".join(lines) + "\n") diff --git a/Tools/visual_targets.json b/Tools/visual_targets.json deleted file mode 100644 index 422c018..0000000 --- a/Tools/visual_targets.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "comment": "What each demo-map capture must show, and which reference photograph it is measured against. Used by Tools/visual_loop.py --verify (does the shot show the expected content?) and --compare (how far is the render from the reference?).", - "targets": [ - { - "name": "demo-spawn-view", - "shot": "Screenshots/demo/review-demo-spawn-view.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" - ], - "expect": "The whole south block from the north sidewalk: the neoclassical hero facade between its neighbouring buildings, both sidewalks and the road in the foreground, daylight" - }, - { - "name": "demo-street-level", - "shot": "Screenshots/demo/review-demo-street-level.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg" - ], - "expect": "Street level from the north side: the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level, daylight" - }, - { - "name": "demo-crossing", - "shot": "Screenshots/demo/review-demo-crossing.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg" - ], - "expect": "The designated mid-block crossing: continental zebra bars on the asphalt, the south frontage and hero colonnade behind, daylight" - }, - { - "name": "demo-north-buildings", - "shot": "Screenshots/demo/review-demo-north-buildings.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/north-chinese/01-grauman-s-chinese-theatre-by-carol-highsmith.jpg" - ], - "expect": "The two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) seen from across Hollywood Boulevard, full height in frame, daylight" - }, - { - "name": "demo-retail-frontage", - "shot": "Screenshots/demo/review-demo-retail-frontage.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg" - ], - "expect": "The retail run beside the hero from across the street: 6800 Lee Drug's bronze cornice, mural row and stepped parapet next to 6806-6820's pilasters and window storey, daylight" - }, - { - "name": "demo-third-person", - "shot": "Screenshots/demo/review-demo-third-person.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" - ], - "expect": "Third-person view of the player body standing on the north sidewalk, full figure visible from behind, the hero facade across the street, daylight" - }, - { - "name": "demo-third-person-front", - "shot": "Screenshots/demo/review-demo-third-person-front.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" - ], - "expect": "Front view of the player body from the road: both arms hanging at the sides, hands down by the hips, face and chest visible, hero facade behind the camera" - }, - { - "name": "demo-third-person-walk", - "shot": "Screenshots/demo/review-demo-third-person-walk.png", - "size": [ - 1920, - 1080 - ], - "references": [ - "Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg" - ], - "expect": "The player body mid-stride in profile: legs apart, arms counter-swinging, walking along the sidewalk" - } - ] -} diff --git a/tmp b/tmp new file mode 120000 index 0000000..2958ad3 --- /dev/null +++ b/tmp @@ -0,0 +1 @@ +/tmp/gigline \ No newline at end of file