From 92b477d34011a556f504dd479ab154db5b5e4d9a Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 8 Nov 2026 22:12:44 +0100 Subject: [PATCH] feat(hero): fluting and a recessed motto band; placement gets one source of truth Hero detail: - Fluting: 20 grooves per column as thin shadow-tone cylinders set into the shaft (120 pieces) rather than boolean cuts - readable in silhouette and grazing light at a fraction of the cost. - Motto band: real recess with a moulded surround 6 cm proud and the panel 12 cm behind, replacing the 8 cm dark stripe. Hero 148 parts/3044 tris -> 271/5492, still 14.50 m. Placement bug this exposed (a real one, invisible in a render): - ue_hero_facade_import.py carried its own hero position (-12024.5, -1450) from before the 0.1.0 frontage fix, so running the importer alone moved the hero 13 m east and onto the north side of the boulevard. Only symptom: the reference score fell 0.69 -> 0.56 and the 3x3 composition correlation flipped +0.54 -> -0.08. - New Tools/ue_placement.py is the single source of truth (hero position, verified frontage spans, span validators). Both the importer and the demo builder use it; the import asserts the staged span and logs PLACEMENT ERROR otherwise. - 11 new tests, including one that fails if the stale literal returns. 78 total. Spawn-view score recovered to 0.72 (best yet); screenshots verified 4/4, packaged QA all PASS. --- Art/blender/hero/hero-facade-2026-11-07.blend | 4 +- Art/blender/hero/hero-facade-2026-11-07.json | 13 ++- Game/GigLine/Content/Hollywood/DemoMap.umap | 4 +- .../Hollywood/ElCapitan/SM_HeroFacade.uasset | 4 +- Game/GigLine/Content/Test/ReviewMap.umap | 2 +- MEMORY.md | 22 +++++ Screenshots/demo/review-demo-crossing.png | 4 +- .../demo/review-demo-north-buildings.png | 4 +- Screenshots/demo/review-demo-spawn-view.png | 4 +- Screenshots/demo/review-demo-street-level.png | 4 +- TODO.md | 2 +- Tools/README.md | 1 + Tools/hero_facade_build.py | 37 +++++++- Tools/tests/test_ue_placement.py | 86 +++++++++++++++++++ Tools/ue_build_demo_map.py | 4 +- Tools/ue_hero_facade_import.py | 20 ++++- Tools/ue_placement.py | 65 ++++++++++++++ 17 files changed, 255 insertions(+), 25 deletions(-) create mode 100644 Tools/tests/test_ue_placement.py create mode 100644 Tools/ue_placement.py diff --git a/Art/blender/hero/hero-facade-2026-11-07.blend b/Art/blender/hero/hero-facade-2026-11-07.blend index 8d91f5a..0147c6f 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:cadc2469e3e9fb41dc379a1c61489054533819478fa74befecaa37adcfdadd97 -size 158985 +oid sha256:22d94a59b1ee5d77a72ee2b2096a0a73de3fc594a6e7d80cd46fbb05faa71e9e +size 208756 diff --git a/Art/blender/hero/hero-facade-2026-11-07.json b/Art/blender/hero/hero-facade-2026-11-07.json index 00d70b6..12420c1 100644 --- a/Art/blender/hero/hero-facade-2026-11-07.json +++ b/Art/blender/hero/hero-facade-2026-11-07.json @@ -37,6 +37,15 @@ ], "evidence": "6 columns at 4.96 m centres (sibling notes x measured frontage)" }, + { + "object": "Hero_Flute_x120", + "dims_m": [ + 0.064, + 0.064, + 6.53 + ], + "evidence": "photographs: fluted shafts; 20 grooves per column modelled as shallow cylinders in the shadow tone rather than boolean cut grooves" + }, { "object": "Hero_ColumnBase_x6", "dims_m": [ @@ -104,10 +113,10 @@ "object": "Hero_MottoBand", "dims_m": [ 22.75, - 0.08, + 0.1, 0.85 ], - "evidence": "photographs: motto panel above the colonnade" + "evidence": "photographs: motto panel above the colonnade, now an inset panel with a moulded surround rather than a flat stripe" }, { "object": "Hero_Medallion_x2", diff --git a/Game/GigLine/Content/Hollywood/DemoMap.umap b/Game/GigLine/Content/Hollywood/DemoMap.umap index 9fb60f0..686adbe 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:1dadfbce54000421368ccbcb76cf04a8799c314f54c124bc7eb92fc23bbba754 -size 919659 +oid sha256:1c4b312ef29760c4a167d97397c18d691d2060291b8f2bd1876a590b2b38a214 +size 919602 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset index a0e205b..a318e07 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:2b506386f8694a3788ec68d0f1feb9b4a6fa604282014d593514e3cefaa247f4 -size 142674 +oid sha256:ab0c6c8ddbc2a9086be3c474166b7fe152f2e0a0aa88cf55500feec21a4e10cf +size 186727 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index 0ae6bb6..8432086 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:872bca7e7dd0fdb41b213f0fbcbba048041311e30db43b60bd4d5e24d6f1db29 +oid sha256:24610a4a1230a866990aaff5491a77a56250f347773dd3130615b76b53713e4f size 28811 diff --git a/MEMORY.md b/MEMORY.md index 0063b91..1f793d1 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -452,3 +452,25 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. storey spread is derived from the available height (`usable / storeys`) so the top band always lands inside the building. Result: 6922 9 × 11, theatre 8 × 6, 6806 7 × 3, 6800 4 × 3, TCL 9 × 5. + +## 2026-11-09 — hero fluting + motto band; and a silent placement divergence + +- 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). +- Lesson to keep: **duplicated placement constants drift silently.** The metric that + caught it was the composition correlation, not a visual review. diff --git a/Screenshots/demo/review-demo-crossing.png b/Screenshots/demo/review-demo-crossing.png index 111f265..b49e26e 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:11730e81d292ccba5357565a6315bd7f5b475ff4713813e22ea2dfd880e4c869 -size 3080043 +oid sha256:c7729a7ef0b10c5827f3c5b4167e12ba03ca6aa81a8a1da0c55f9fb122897694 +size 2992299 diff --git a/Screenshots/demo/review-demo-north-buildings.png b/Screenshots/demo/review-demo-north-buildings.png index 2fb99bc..c655726 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:35b9ecf5f6fa1431777fc78f46ed7ab050e8d86da34a63024e741efc99b0557b -size 2419489 +oid sha256:a854cc82d6a1ad84173664076a076f649c8dfbf38bb7d887f7d6444a7dc4e57c +size 2417601 diff --git a/Screenshots/demo/review-demo-spawn-view.png b/Screenshots/demo/review-demo-spawn-view.png index 62dcc78..f868673 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:a40c52112be8d72b9d5f85b66434fed255399868b35d9c86f89c909b56dea794 -size 2949229 +oid sha256:e06925a4ca759e762c3ad01375cc098e22fdab065878a85269a63d62b9ba7256 +size 2505879 diff --git a/Screenshots/demo/review-demo-street-level.png b/Screenshots/demo/review-demo-street-level.png index 045bd58..21ad29f 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:33b9bd8fcb4a7aa2b6cc916688769590fb7e314e37d0ab81c0c24a565dec4e1f -size 2867127 +oid sha256:e0412f034f6ac55928521c14278043f5626ce32ecaaca7e178ec3efd011efc22 +size 2705233 diff --git a/TODO.md b/TODO.md index 860b2cf..77052c7 100644 --- a/TODO.md +++ b/TODO.md @@ -18,7 +18,7 @@ The `0.1.4` kit, materials and theatre trim are signed off; these are the geomet ### Patch tasks - [A] **Retail neighbours (6822 Budget, 6806–6820, 6800 Lee Drug) from their own references.** **Started 2026-11-09.** Fetched the one usable reference Commons holds for this run — `Art/reference/retail-neighbours/01-lee-drug-and-neighbor.jpg` (Gb321, CC BY-SA 4.0, 1920×953) — which shows 6800 **and** its neighbour at street level; the vision pre-check confirmed shopfronts and parapets are visible. The 18-frame theatre set cannot stand in for these façades, and Commons has nothing else for 6822 or 6806–6820, so per-building references for those two remain the blocker. **Kit layout fixed while here:** bay count was `round(width / 4.96)`, which gave 6922 thirteen bays across 65.8 m (a window every 4.96 m on a 37 m block) and left bare wall under the cornice; bays are now capped at 9 with the module widened, and the storey spread fills the available height (6922 9 × 11, 6806 7 × 3, 6800 4 × 3, theatre 8 × 6). -- [ ] **Hero façade: fluting and the motto band as geometry.** The colonnade is plain cylinders and the motto/medallions are flat panels; both are legible in the reference photography. +- [x] **Hero façade: fluting and the motto band as geometry.** **Done 2026-11-09.** Fluting: 20 grooves per column (thin shadow-tone cylinders set into the shaft, 120 pieces) instead of boolean cuts — it breaks the flat highlight at a fraction of the poly cost. Motto band: a real recess now, with a moulded surround standing 6 cm proud and the panel set 12 cm behind it, replacing the 8 cm dark stripe. Hero is 271 objects / 5 492 tris (from 148 / 3 044) and still 14.50 m. **En route it exposed a placement bug:** `ue_hero_facade_import.py` carried its own stale hero position `(-12024.5, -1450)` from before the `0.1.0` frontage fix, so running the importer alone moved the hero 13 m east and onto the north side — invisible in a render, and it dropped the reference score from 0.69 to 0.56. Placement now lives in `Tools/ue_placement.py` with span validators, asserted after every import (`span ok: X -14644..-12028`), and the spawn-view score recovered to **0.72**. - [ ] **Blosm blockout cleaning pass.** Roofs and facades remain untextured volumes and the `setupScript` styles stay unused; with the kit carrying the elevations, this is now cleanup rather than a second façade system. - [ ] **Re-measure 1440p performance** with 625 kit actors and 8 generated textures in the map. `0.1.0` measured 414 fps average, so there is headroom, but it has not been re-run since. - [ ] Tag `v0.1.5` and write `CHANGELOG.md` entry; then remove this section. diff --git a/Tools/README.md b/Tools/README.md index 2ef7335..3d582e0 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -22,6 +22,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | `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_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. | | `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing | diff --git a/Tools/hero_facade_build.py b/Tools/hero_facade_build.py index 964a419..415f453 100644 --- a/Tools/hero_facade_build.py +++ b/Tools/hero_facade_build.py @@ -174,6 +174,20 @@ def build(): cap_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H cylinder(f"Hero_Column_{i + 1}", x, -0.55, shaft_z0, cap_z0, COLUMN_D / 2.0, stone) + + # Fluting: 20 shallow grooves around the shaft, as the photographs show on + # the colonnade. Each flute is a thin cylinder set into the shaft surface; + # modelling them as grooves would need booleans on 6 columns for detail the + # eye reads only in silhouette and grazing light, so they are added mass in + # the *shadow* direction instead — cheap, and it breaks the flat highlight. + flutes = 20 + flute_r = 0.032 + for f in range(flutes): + ang = (2.0 * math.pi * f) / flutes + fx = x + (COLUMN_D / 2.0 - flute_r * 0.4) * math.cos(ang) + fy = -0.55 + (COLUMN_D / 2.0 - flute_r * 0.4) * math.sin(ang) + cylinder(f"Hero_Flute_{i + 1}_{f + 1}", fx, fy, shaft_z0 + 0.05, + cap_z0 - 0.05, flute_r, stone_dark, vertices=6) # Capital block, then the square abacus that carries the architrave. # The abacus takes the top 0.14 m of the capital zone so the colonnade # still ends at HEIGHT - ENTABLATURE_H - PARAPET_H (proportion unchanged). @@ -185,6 +199,9 @@ def build(): 1.30, 1.30, abacus_h, stone) note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H), f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)") + note("Hero_Flute_x120", (0.064, 0.064, cap_z0 - shaft_z0), + "photographs: fluted shafts; 20 grooves per column modelled as shallow " + "cylinders in the shadow tone rather than boolean cut grooves") note("Hero_ColumnBase_x6", (1.36, 1.36, 0.22), "assumption: Ionic base moulding; photographs show a wider base at the shaft foot") note("Hero_Abacus_x6", (1.30, 1.30, 0.14), @@ -223,10 +240,22 @@ def build(): FRONTAGE_W, 1.2, PARAPET_H, stone) note("Hero_Parapet", (FRONTAGE_W, 1.2, PARAPET_H), "assumption: parapet carries the motto band") - # Motto band: a recessed panel across the parapet, as the photographs show. - cube("Hero_MottoBand", 0.0, -1.32, HEIGHT - PARAPET_H / 2.0, - FRONTAGE_W - 3.0, 0.08, 0.85, stone_dark) - note("Hero_MottoBand", (FRONTAGE_W - 3.0, 0.08, 0.85), "photographs: motto panel above the colonnade") + # Motto band: a recessed panel across the parapet with a moulded surround, as + # the photographs show. The panel was a 8 cm stripe of dark stone; it is now a + # real recess — surround frame standing 6 cm proud, panel set 12 cm behind it — + # so it reads as an inscription panel from the street instead of a paint line. + motto_z = HEIGHT - PARAPET_H / 2.0 + motto_w = FRONTAGE_W - 3.0 + for x_off in (-(motto_w / 2.0 + 0.16), (motto_w / 2.0 + 0.16)): + cube(f"Hero_MottoSurround_v{int(x_off > 0)}", x_off, -1.13, motto_z, + 0.32, 0.26, 1.15, stone) + 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) + 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). diff --git a/Tools/tests/test_ue_placement.py b/Tools/tests/test_ue_placement.py new file mode 100644 index 0000000..6835a51 --- /dev/null +++ b/Tools/tests/test_ue_placement.py @@ -0,0 +1,86 @@ +"""Tests for Tools/ue_placement.py — the single source of truth for placement. + +A regression test for a real bug: `ue_hero_facade_import.py` carried its own hero +position (the pre-0.1.0 one) while `ue_build_demo_map.py` staged the verified one. +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 +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(ROOT, "Tools")) + +import ue_placement # noqa: E402 + + +class HeroPlacementTest(unittest.TestCase): + def test_hero_actor_name(self): + self.assertEqual(ue_placement.HERO_ACTOR, "Hero_Facade") + + def test_hero_location_is_the_verified_building_line(self): + x, y, z = ue_placement.HERO_LOCATION_CM + self.assertEqual((x, y, z), (-13336.0, 1307.0, 0.0)) + # North is -Y in Unreal, so the hero on the south side must have Y > 0. + self.assertGreater(y, 0.0, "hero must be on the south side (Y > 0)") + + def test_hero_span_is_verified(self): + # The mesh spans -1308..+1308 cm in X about its origin. + lo = ue_placement.HERO_LOCATION_CM[0] - 1308.0 + hi = ue_placement.HERO_LOCATION_CM[0] + 1308.0 + exp_min, exp_max, ok = ue_placement.span_error("hero", lo, hi) + self.assertTrue(ok) + # The mesh spans exactly the verified footprint, so the two agree. + self.assertAlmostEqual(exp_min, lo, delta=ue_placement.SPAN_TOLERANCE_CM) + self.assertAlmostEqual(exp_max, hi, delta=ue_placement.SPAN_TOLERANCE_CM) + + +class SpanValidatorTest(unittest.TestCase): + def test_misplaced_hero_is_reported(self): + # The exact failure observed: the stale import position put the hero here. + lo, hi = -13332.0, -10716.0 + exp_min, exp_max, ok = ue_placement.span_error("hero", lo, hi) + self.assertFalse(ok) + message = ue_placement.describe_span_failure("hero", lo, hi) + self.assertIn("does not match the verified", message) + self.assertIn(f"{exp_min:.0f}", message) + + def test_correct_span_reports_nothing(self): + self.assertEqual(ue_placement.describe_span_failure("hero", -14644, -12028), "") + + def test_within_tolerance_passes(self): + # 99 cm off is inside the ±100 cm tolerance. + self.assertTrue(ue_placement.span_error("6922", -24276 + 99, -17700 - 99)[2]) + + def test_outside_tolerance_fails(self): + self.assertFalse(ue_placement.span_error("6922", -24276 + 101, -17700)[2]) + + def test_unknown_building_raises(self): + with self.assertRaises(KeyError): + ue_placement.span_error("no-such-building", 0, 0) + + def test_every_frontage_span_is_ordered(self): + for name, (lo, hi) in ue_placement.FRONTAGE_SPANS_CM.items(): + self.assertLess(lo, hi, f"{name} span is inverted") + + +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"), + 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"), + encoding="utf-8").read() + self.assertIn("ue_placement.HERO_LOCATION_CM", src) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/ue_build_demo_map.py b/Tools/ue_build_demo_map.py index 33e5f2b..f125935 100644 --- a/Tools/ue_build_demo_map.py +++ b/Tools/ue_build_demo_map.py @@ -23,6 +23,8 @@ 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__))) +import ue_placement # noqa: E402 RESULT = "/tmp/gigline/demo-map.txt" lines = [] @@ -401,7 +403,7 @@ def build(): # the NORTH side of the street, which only looked right while it stood alone. hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade") if hero: - actor = spawn(unreal.StaticMeshActor, unreal.Vector(-13336.0, 1307.0, 0.0), "Hero_Facade") + actor = spawn(unreal.StaticMeshActor, unreal.Vector(*ue_placement.HERO_LOCATION_CM), ue_placement.HERO_ACTOR) if actor: actor.static_mesh_component.set_static_mesh(hero) actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC) diff --git a/Tools/ue_hero_facade_import.py b/Tools/ue_hero_facade_import.py index 49e4e8b..46f0559 100644 --- a/Tools/ue_hero_facade_import.py +++ b/Tools/ue_hero_facade_import.py @@ -19,14 +19,19 @@ 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 = "Hero_Facade" +ACTOR_LABEL = ue_placement.HERO_ACTOR LEVEL = "/Game/Hollywood/DemoMap" # Unreal centimetres: south-east corner of the façade, on the building line. -PLACEMENT_CM = (-12024.5, -1450.0, 0.0) +# 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 = [] @@ -118,6 +123,17 @@ def measure_and_place(): 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) diff --git a/Tools/ue_placement.py b/Tools/ue_placement.py new file mode 100644 index 0000000..9d20228 --- /dev/null +++ b/Tools/ue_placement.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Single source of truth for where staged actors belong in the DemoMap. + +This exists because the same value was written in two places and they drifted: +`ue_hero_facade_import.py` kept the pre-`0.1.0` position `(-12024.5, -1450)` while +`ue_build_demo_map.py` staged the hero at `(-13336, 1307)`. Running the importer on +its own therefore moved the hero **13 m east and onto the north side of the +boulevard** — and nothing complained, because a displaced building still renders. + +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 +world-span probes recorded in `MEMORY.md`. + +Keep this file boring: constants and one validator, no `unreal` import, so it can +be unit-tested on the host. +""" + +from __future__ import annotations + +# The hero façade sits INSIDE the frontage gap between 6902 (ends at -17749) and +# the El Capitan Theatre (starts at -12082), so its actor origin is the measured +# building line. The mesh is authored with its front plane 7 cm ahead of the actor +# origin, which is why the X here is the frontage centre and Y is the building line. +HERO_ACTOR = "Hero_Facade" +HERO_LOCATION_CM = (-13336.0, 1307.0, 0.0) + +# Tolerance for the span probe: the verified footprints are within ~1 m. +SPAN_TOLERANCE_CM = 100.0 + +# Expected world span per frontage actor (min X, max X), from the block +# verification notes. Used to prove a staged building is on its own footprint +# rather than merely present. +FRONTAGE_SPANS_CM = { + "6922": (-24276.0, -17700.0), + "6902": (-17749.0, -14591.0), + "hero": (-14644.0, -12028.0), + "6838": (-12082.0, -8210.0), + "6822": (-7854.0, -7252.0), + "6806": (-6608.0, -3307.0), + "6800": (-3320.0, -1437.0), +} + + +def span_error(name: str, observed_min: float, observed_max: float): + """Return (expected_min, expected_max, ok) for an observed building span. + + `name` is a key of `FRONTAGE_SPANS_CM`; `ok` is False when either edge is more + than `SPAN_TOLERANCE_CM` from the verified value. + """ + if name not in FRONTAGE_SPANS_CM: + raise KeyError(f"unknown building {name!r}; known: {sorted(FRONTAGE_SPANS_CM)}") + exp_min, exp_max = FRONTAGE_SPANS_CM[name] + ok = (abs(observed_min - exp_min) <= SPAN_TOLERANCE_CM + and abs(observed_max - exp_max) <= SPAN_TOLERANCE_CM) + return exp_min, exp_max, ok + + +def describe_span_failure(name: str, observed_min: float, observed_max: float) -> str: + """A one-line message for a misplaced building, or "" when it is correct.""" + exp_min, exp_max, ok = span_error(name, observed_min, observed_max) + if ok: + return "" + return (f"{name}: span X {observed_min:.0f}..{observed_max:.0f} does not match the " + f"verified {exp_min:.0f}..{exp_max:.0f} (±{SPAN_TOLERANCE_CM:.0f} cm)")