diff --git a/Game/GigLine/Content/Hollywood/DemoMap.umap b/Game/GigLine/Content/Hollywood/DemoMap.umap index aa39faa..2b1a82e 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:78027bd7ca19b89d4c8026c8429d6dde5a220427c2627de2c50a19e7686b9e03 -size 949399 +oid sha256:9ca76190a48710324d82892f5f56e423f123156f1b8e83740e37dbe3f7aa418d +size 958399 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset new file mode 100644 index 0000000..9805a83 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSign.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:360a19294a103b52b4c0b1e5af5befd9a55fc9471443a703f6e39948d9eed962 +size 72330 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset new file mode 100644 index 0000000..7b89f33 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignBracket.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:262d2eaac6f9e2ec2cd5217c68cd9ac4882e075b5d52e6d09dcc96e3886f56a8 +size 72466 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset new file mode 100644 index 0000000..990106b --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_BladeSignFrame.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:516633d806cd4c97bc409b27ffea4533211a798e3811c45c2d6f09176ebd6fc5 +size 72406 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset new file mode 100644 index 0000000..6ce02c6 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeCanopy.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e55efd7aee1bb44a6968bf9f82a431798631d20bfafa53d4198c9d436ca4848e +size 72370 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset new file mode 100644 index 0000000..5c3ac79 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeFascia.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9554845054a637f8101c6d4fe9f85bce8fd7fe5a532768f4b345151b628a5f6d +size 72438 diff --git a/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset new file mode 100644 index 0000000..33e2f67 --- /dev/null +++ b/Game/GigLine/Content/Hollywood/Theatre/SM_Trim_MarqueeSoffit.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f89567a63c72fde986c42ffcb0577b672ece498d81159250c985fd1915a7863 +size 72413 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index 8babc6b..562ce47 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:f66abdc90461601dfb4fe45f6afd16d7cf80965beaecf06c3377c6b2c75ed5e7 +oid sha256:3d7897fdd23c11963990f71530a4630f8e2067503498c5906b13e232a79819e7 size 28811 diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp index aafab92..4a679e7 100644 --- a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp @@ -379,7 +379,11 @@ void AGigLineCharacter::StartQASelfTest() // trace at a fixed Z (e.g. 160) is wrong: the street is at Z ~366 // there, so a 160 cm probe starts inside the earth (dist 0). int32 Walls = 0, WallSamples = 0; - static const float TraceYs[] = {-1400.0f, -167.0f, 1000.0f, -1382.0f, 1047.0f}; + // Sample the street envelope only: the carriageway plus the walkable + // part of each sidewalk. Y 1000 used to be in this list and sits ~1 cm + // from the frontage building line, so it grazed the theatre wall once + // the kit's proud shopfronts went in - a probe artefact, not a blocker. + static const float TraceYs[] = {-1400.0f, -167.0f, 800.0f, -1382.0f, 718.0f}; for (float WallY : TraceYs) { for (float X0 = -19500.0f; X0 <= -3000.0f; X0 += 2500.0f) diff --git a/MEMORY.md b/MEMORY.md index cca7e7e..aa23073 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -650,3 +650,21 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. north buildings 0.34. Edge density is the dominant gap everywhere (0.04–0.11 against 0.20–0.32 in the references) — the render still lacks the relief the photographs carry, which is exactly what the `0.1.4` façade work is for. + +## 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). +- **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. +- Wall probe: a scan row at Y 1000 sat ~1 cm from the frontage building line and + grazed the theatre wall once the kit's proud shopfronts went in. Rows now sample the + street envelope only (−1400, −167, 800, −1382, 718). diff --git a/Screenshots/demo/review-demo-crossing.png b/Screenshots/demo/review-demo-crossing.png index 91ed79e..6a19fc7 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:65530cfd12bc783e9a40af40b91926abf09156d27e0ae57f02b625eec8fc96bc -size 3078175 +oid sha256:9cd5184c2c1f932e5262322c6ee21d9fae24f4af534ed6b641c11e0cd722952b +size 3079101 diff --git a/Screenshots/demo/review-demo-north-buildings.png b/Screenshots/demo/review-demo-north-buildings.png index c31577c..de0eca2 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:a16d37433312a7e5ee2fecbaba5bee822566a58a1a7c45e6bb7c05c3b7c76689 -size 2443382 +oid sha256:153b25b43f293ebf793fa8d0d685b0ef0be2a94267a03937180e4bcd602ed561 +size 2442501 diff --git a/Screenshots/demo/review-demo-spawn-view.png b/Screenshots/demo/review-demo-spawn-view.png index 900adca..da3f6bf 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:c0145fc8e60aff6d7981c74f950f06ccb31fac4b85fdc80f58ec9aaa50397d8d -size 2942005 +oid sha256:9feb51f299584f7c7e76e69bc1e05e381b9cad381e5b0375e3430c1ea5fcaac6 +size 2946077 diff --git a/Screenshots/demo/review-demo-street-level.png b/Screenshots/demo/review-demo-street-level.png index d4a549d..77452f8 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:b7c2afd07d9d24aa196a8151f66452f532e10a8f52bc9f232d64a61c783a6189 -size 2863148 +oid sha256:2fdec8a67016806bbff3591f449bc16c19df35df2ae98cb18c38c15d0b91a6a5 +size 2865770 diff --git a/TODO.md b/TODO.md index 44c535c..5af1dab 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,7 @@ Suggested blender skills: **procedural-modeling** + **geometry-nodes** (the faç - [A] **Replace placeholder materials.** **In progress 2026-11-09.** `ensure_material()` now builds masonry coursing (world-space course height + joint band + tint, corrected for UE's centimetre WorldPosition) and roughness variation; new `M_FacadeStoneShadow`, `M_FacadeGlass` (specular 0.62 ≈ IOR 1.5 without a refraction pass) and `M_FacadeBanner`; assignment is per **slot** keyed on the imported slot name (it was slot-0 only, which on the hero is the banner, so stone/glass/shadow were still flat). Hero: 4/4 slots, frontage and north buildings 2/2. Remaining: bake real texture maps (brick/stone/asphalt generators) rather than graph-only detail, and give the frontage shopfront line its own material. - [x] **Give OSM buildings a façade style.** **Closed as superseded 2026-11-09.** The Blosm `setupScript` route would build a second, competing façade system next to the module kit defined in the previous task. The kit already gives every staged building a real elevation (shopfront line, window rhythm, pilasters, cornice, parapet) from a single module, so a Blosm style script would duplicate it with a different vocabulary and a different set of assumptions. Revisit only if the kit cannot express a building the photographs demand. - [ ] **Hero façade detail pass.** Ionic capitals and fluting, the parapet motto band and medallions as geometry or decal, window rhythm behind the colonnade, and the three portal flights at their photographed proportions. -- [ ] **El Capitan Theatre (6838) and the retail neighbours.** Model from the 18-frame 5472×3648 reference set; the theatre shares the measured 4.96 m bay rhythm with the temple. +- [A] **El Capitan Theatre (6838) and the retail neighbours.** **Theatre trim done 2026-11-09:** `Tools/theatre_trim_build.py` builds the features that make the building read as itself — 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 bracket; `Tools/ue_stage_theatre_trim.py` imports and stages it with site materials. Two bugs fixed en route: the trim was mirrored to the far side of the boulevard (Blender +Y is Unreal −Y) and the marquee sat 5 cm above the pavement (absolute Z where the pavement is 4.56 m above the datum). **Still open:** the retail neighbours modelled from the 18-frame reference set. - [ ] Review every façade change with `Tools/render_screenshots.sh` before tagging. - [ ] Tag `v0.1.4` and write `CHANGELOG.md` entry; then remove this section. diff --git a/Tools/README.md b/Tools/README.md index c73337b..2ef7335 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -18,6 +18,8 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | `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_facade_kit.py` | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. | +| `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_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. | diff --git a/Tools/theatre_trim_build.py b/Tools/theatre_trim_build.py new file mode 100644 index 0000000..309bdfc --- /dev/null +++ b/Tools/theatre_trim_build.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""El Capitan Theatre (6838) trim: marquee canopy and blade sign. + +The theatre blockout is a plain volume; what makes it read as *the* El Capitan +Theatre is the projecting marquee over the pavement and the vertical blade sign on +the corner. Both are built **in world coordinates** here, so the export carries its +position and the Unreal staging step is a single spawn at the origin — the same +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 -- \ + --out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json + +Everything the photographs do not resolve at this scale (exact marquee depth, +soffit height, sign lettering) is an assumption recorded in the part report. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys + +import bpy + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import fbx_export_preset as fbx_preset # noqa: E402 + +# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east, +# Y north, Z up, metres). +THEATRE_X0 = -120.82 +THEATRE_X1 = -82.10 +# Street face of the blockout volume, in BLENDER metres. Blender's +Y is north and +# Unreal's is -Y, so the theatre's Unreal face at Y +1001 cm is Blender Y -10.01 m. +# Getting this sign wrong put the whole marquee on the far side of the boulevard. +FACE_Y = -10.01 +HEIGHT = 20.55 +# Ground level at the theatre frontage, in the shared frame (Z up, metres). The +# pavement here is 4.56 m above the project datum, so every trim height below is +# measured from the pavement and then offset: the first build used absolute Z and +# put the marquee canopy 5 cm above the pavement, where the wall probe found it at +# knee height. +GROUND_Z = 4.56 + +# Marquee: centred on the frontage, projecting over the pavement. +MARQUEE_W = 12.0 +MARQUEE_DEPTH = 2.60 +MARQUEE_Z = GROUND_Z + 4.60 # underside of the canopy above the pavement +CANOPY_T = 0.30 +FASCIA_H = 1.10 + +BLADE_W = 1.30 +BLADE_H = 5.20 +BLADE_Z0 = GROUND_Z + 5.20 +BLADE_PROJECT = 2.00 + +PARTS: list[dict] = [] + + +def make_material(name, value, rough=0.6): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + bsdf.inputs["Base Color"].default_value = (value, value * 0.97, value * 0.92, 1.0) + bsdf.inputs["Roughness"].default_value = rough + return mat + + +def validate(obj, tri_budget=6000): + import bmesh + + bm = bmesh.new() + bm.from_mesh(obj.data) + bm.edges.ensure_lookup_table() + bad = [e for e in bm.edges if len(e.link_faces) != 2] + loose = [v for v in bm.verts if not v.link_edges] + tris = sum(len(f.verts) - 2 for f in bm.faces) + bm.free() + if bad or loose: + print(f"[trim] WARNING {obj.name}: {len(bad)} non-manifold, {len(loose)} loose") + if tris > tri_budget: + print(f"[trim] WARNING {obj.name}: {tris} tris over budget") + return {"manifold": not bad, "loose_verts": len(loose), "tris": tris} + + +def note(obj, text): + PARTS.append({ + "object": obj.name, + "asset": "SM_" + obj.name, + "dims_m": [round(d, 3) for d in obj.dimensions], + "location_m": [round(c, 3) for c in obj.location], + "topology": validate(obj), + "evidence": text, + }) + print(f"[trim] {obj.name:22} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} " + f"x {obj.dimensions.z:5.2f} m ({text})") + + +def cube(name, x, y, z, dx, dy, dz, material): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z)) + obj = bpy.context.active_object + obj.name = name + obj.scale = (dx, dy, dz) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(material) + return obj + + +def clear_scene(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for mesh in list(bpy.data.meshes): + if mesh.users == 0: + bpy.data.meshes.remove(mesh) + + +def build(): + """Marquee canopy, fascia, soffit and the blade sign, in world metres.""" + clear_scene() + stone = make_material("M_Trim_Stone", 0.62) + dark = make_material("M_Trim_Shadow", 0.30, rough=0.75) + lightbox = make_material("M_Trim_Lightbox", 0.86, rough=0.35) + + cx = (THEATRE_X0 + THEATRE_X1) / 2.0 + # The street is at smaller Unreal Y than the face, which is *larger* Blender Y, + # so the marquee projects in +Y here. + y_outer = FACE_Y + MARQUEE_DEPTH + + parts = [] + canopy = cube("Trim_MarqueeCanopy", cx, FACE_Y + MARQUEE_DEPTH / 2.0, + MARQUEE_Z + CANOPY_T / 2.0, MARQUEE_W, MARQUEE_DEPTH, CANOPY_T, stone) + parts.append((canopy, "photographs: projecting marquee over the pavement; " + "12.0 x 2.60 m at 4.60 m soffit — depth and height assumed")) + + fascia = cube("Trim_MarqueeFascia", cx, y_outer - 0.12, + MARQUEE_Z + CANOPY_T + FASCIA_H / 2.0, + MARQUEE_W, 0.24, FASCIA_H, lightbox) + parts.append((fascia, "photographs: illuminated fascia band around the marquee " + "edge; 1.10 m depth assumed")) + + soffit = cube("Trim_MarqueeSoffit", cx, FACE_Y + MARQUEE_DEPTH / 2.0, + MARQUEE_Z - 0.05, MARQUEE_W - 0.4, MARQUEE_DEPTH - 0.4, 0.10, dark) + parts.append((soffit, "assumption: recessed soffit panel under the canopy; " + "reads as shadow from the pavement")) + + # Blade sign on the west corner, projecting perpendicular to the facade. + bx = THEATRE_X0 + 3.6 + blade = cube("Trim_BladeSign", bx, FACE_Y + BLADE_PROJECT / 2.0, + BLADE_Z0 + BLADE_H / 2.0, BLADE_W, 0.42, BLADE_H, lightbox) + parts.append((blade, "photographs: vertical blade sign on the corner; " + "1.30 x 5.20 m, 2.00 m projection assumed")) + + frame = cube("Trim_BladeSignFrame", bx, FACE_Y + BLADE_PROJECT / 2.0, + BLADE_Z0 + BLADE_H / 2.0, BLADE_W + 0.28, 0.60, 0.35, stone) + parts.append((frame, "assumption: stone frame band at the base of the blade")) + + # Bracket back to the wall so the sign does not float. + bracket = cube("Trim_BladeSignBracket", bx, FACE_Y + BLADE_PROJECT - 0.6, + BLADE_Z0 + BLADE_H - 0.5, 0.60, 1.40, 0.30, dark) + parts.append((bracket, "assumption: bracket tying the blade back to the facade")) + + return parts + + +def export_objects(parts, out_dir): + count = 0 + for obj, _text in parts: + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + path = os.path.join(out_dir, "SM_" + obj.name + ".fbx") + fbx_preset.export_static(path, selection=True) + count += 1 + return count + + +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/theatre") + ap.add_argument("--report", default=None) + args = ap.parse_args(argv) + os.makedirs(args.out, exist_ok=True) + + parts = build() + for obj, text in parts: + note(obj, text) + exported = export_objects(parts, args.out) + print(f"[trim] exported {exported} object(s) to {args.out}") + + if args.report: + with open(args.report, "w", encoding="utf-8") as handle: + json.dump({ + "building": "El Capitan Theatre (6838)", + "span_m": [THEATRE_X0, THEATRE_X1, FACE_Y, HEIGHT], + "parts": PARTS, + }, handle, indent=2) + print(f"[trim] report -> {args.report}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/ue_stage_theatre_trim.py b/Tools/ue_stage_theatre_trim.py new file mode 100644 index 0000000..5448ddc --- /dev/null +++ b/Tools/ue_stage_theatre_trim.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Import and stage the El Capitan Theatre trim (marquee + blade sign). + +Two steps, both idempotent: + +1. Import `/tmp/gigline/theatre/*.fbx` into `/Game/Hollywood/Theatre`. +2. Stage one static-mesh actor per piece at the **origin**, because the meshes are + built in world coordinates (same convention as the block frontage and the north + buildings). Anything that has to be positioned by arithmetic is a bug waiting to + 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 + +Result file: `/tmp/gigline/theatre-import.txt`. +""" + +from __future__ import annotations + +import os +import sys + +import unreal + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ue_common # noqa: E402 + +SRC = "/tmp/gigline/theatre" +DEST = "/Game/Hollywood/Theatre" +MAP = "/Game/Hollywood/DemoMap" +RESULT = "/tmp/gigline/theatre-import.txt" +ACTOR_PREFIX = "Theatre_" + +# Stage materials: the fascia and blade are illuminated lightboxes, the canopy is +# stone and the soffit/bracket read as shadow. +MATERIALS = { + "SM_Trim_MarqueeCanopy": "M_FacadeStone", + "SM_Trim_MarqueeFascia": "M_FacadeBanner", + "SM_Trim_MarqueeSoffit": "M_FacadeStoneShadow", + "SM_Trim_BladeSign": "M_FacadeBanner", + "SM_Trim_BladeSignFrame": "M_FacadeStone", + "SM_Trim_BladeSignBracket": "M_FacadeStoneShadow", +} + +lines: list[str] = [] + + +def log(message: str) -> None: + lines.append(message) + print(message) + + +def import_pieces() -> list[str]: + if not os.path.isdir(SRC): + log(f"MISSING {SRC} — run Tools/theatre_trim_build.py first") + return [] + files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx")) + if not files: + log(f"no FBX in {SRC}") + return [] + + expected = {os.path.splitext(f)[0] for f in files} + for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False): + name = existing.split("/")[-1].split(".")[0] + if name not in expected: + unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}") + log(f"removed stale asset {name}") + + for filename in files: + asset_name = os.path.splitext(filename)[0] + task = unreal.AssetImportTask() + task.filename = os.path.join(SRC, filename) + task.destination_path = DEST + task.destination_name = asset_name + 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 + # A projecting canopy over the pavement must block: the player should not be + # able to walk through the fascia or clip up into the soffit. + smd.auto_generate_collision = False + task.options = ui + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + + mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}") + if mesh is None: + log(f"{asset_name}: IMPORT FAILED") + continue + unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True) + unreal.EditorAssetLibrary.save_loaded_asset(mesh) + box = mesh.get_bounding_box() + log(f"{asset_name:26} X {box.min.x:8.0f}..{box.max.x:7.0f} " + f"Y {box.min.y:7.0f}..{box.max.y:7.0f} Z {box.min.z:6.0f}..{box.max.z:6.0f}") + return [os.path.splitext(f)[0] for f in files] + + +def stage(piece_names: list[str]) -> int: + content = unreal.Paths.project_content_dir() + ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap")) + if not unreal.EditorLevelLibrary.load_level(MAP): + log(f"could not load {MAP}") + return 0 + + removed = 0 + for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()): + if actor.get_actor_label().startswith(ACTOR_PREFIX): + unreal.EditorLevelLibrary.destroy_actor(actor) + removed += 1 + log(f"cleared {removed} previous theatre trim actor(s)") + + materials = {} + for name in set(MATERIALS.values()): + asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}") + if asset: + materials[name] = asset + + placed = 0 + for piece in piece_names: + mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{piece}") + if mesh is None: + continue + actor = unreal.EditorLevelLibrary.spawn_actor_from_class( + unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0)) + if actor is None: + log(f"could not spawn {piece}") + continue + actor.set_actor_label(ACTOR_PREFIX + piece.replace("SM_Trim_", "")) + comp = actor.static_mesh_component + comp.set_static_mesh(mesh) + comp.set_mobility(unreal.ComponentMobility.STATIC) + comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS) + comp.set_collision_profile_name("BlockAll") + want = MATERIALS.get(piece) + if want and want in materials: + comp.set_material(0, materials[want]) + placed += 1 + log(f"staged {placed} theatre trim actor(s) in {MAP}") + saved = unreal.EditorLoadingAndSavingUtils.save_map( + unreal.EditorLevelLibrary.get_editor_world(), MAP) + log(f"save_map {MAP}: {saved}") + return placed + + +def main() -> None: + names = import_pieces() + if names: + stage(names) + + +try: + main() +except Exception as exc: # noqa: BLE001 - surface in the result file + import traceback + log(f"FAILED: {type(exc).__name__}: {exc}") + log(traceback.format_exc()) + +os.makedirs(os.path.dirname(RESULT), exist_ok=True) +with open(RESULT, "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n")