From ba68dcfdbc5e79d2dd39334ab426296763f3b9b7 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 8 Nov 2026 18:48:33 +0100 Subject: [PATCH] =?UTF-8?q?feat(hero):=20v0.1.2=20=E2=80=94=20scale=20cali?= =?UTF-8?q?bration,=20fa=C3=A7ade=20detail,=20export=20preset=20refactor?= =?UTF-8?q?=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged PR #28: v0.1.2 — scale calibration, façade detail, export preset Co-authored-by: Zwitschi --- Art/blender/hero/hero-facade-2026-11-07.blend | 4 +- Art/blender/hero/hero-facade-2026-11-07.json | 49 ++- Art/blender/site/export_site_fbx.py | 18 +- CHANGELOG.md | 162 ++++----- Docs/release-checks.md | 9 + Game/GigLine/Content/Hollywood/DemoMap.umap | 4 +- .../Hollywood/ElCapitan/SM_HeroFacade.uasset | 4 +- .../Hollywood/Frontage/SM_Budget_6822.uasset | 4 +- .../Frontage/SM_ElCapitanTheatre.uasset | 4 +- .../Frontage/SM_Hollywood_6806.uasset | 4 +- .../Frontage/SM_Hollywood_6902.uasset | 4 +- .../Frontage/SM_Hollywood_6922.uasset | 4 +- .../Hollywood/Frontage/SM_LeeDrug_6800.uasset | 4 +- .../Hollywood/North/SM_Madame_Tussauds.uasset | 4 +- .../North/SM_TCL_Chinese_Theatre.uasset | 4 +- .../Hollywood/Site/Crossing_Markings.uasset | 4 +- .../Content/Hollywood/Site/Kerb_North.uasset | 4 +- .../Content/Hollywood/Site/Kerb_South.uasset | 4 +- .../Content/Hollywood/Site/M_Asphalt.uasset | 2 +- .../Content/Hollywood/Site/M_Concrete.uasset | 2 +- .../Hollywood/Site/M_FacadeStone.uasset | 2 +- .../Content/Hollywood/Site/M_Ground.uasset | 2 +- .../Hollywood/Site/M_KerbConcrete.uasset | 2 +- .../Content/Hollywood/Site/M_RoadPaint.uasset | 2 +- .../Content/Hollywood/Site/M_Stucco.uasset | 2 +- .../Hollywood/Site/Road_Hollywood.uasset | 4 +- .../Hollywood/Site/Sidewalk_North.uasset | 4 +- .../Hollywood/Site/Sidewalk_South.uasset | 4 +- .../Content/Hollywood/Site/Terrain_DEM.uasset | 4 +- Game/GigLine/Content/Test/ReviewMap.umap | 2 +- .../Source/GigLine/GigLineCharacter.cpp | 75 ++++ MEMORY.md | 107 ++++++ Screenshots/README.md | 3 +- 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 +- Screenshots/review-character.png | 4 +- Screenshots/review-ground-contact.png | 4 +- Screenshots/review-hero-facade-front.png | 4 +- Screenshots/review-hero-facade-oblique.png | 4 +- Screenshots/review-hero-portals.png | 4 +- Screenshots/review-npc-prefab.png | 4 +- Screenshots/review-overview.png | 4 +- Screenshots/review-player-placement.png | 4 +- Screenshots/review-scale-cubes.png | 4 +- Screenshots/review-shadow-compare.png | 4 +- TODO.md | 12 - Tools/README.md | 10 +- Tools/export_block_frontage.py | 18 +- Tools/export_north_buildings.py | 18 +- Tools/facade_detail_concepts.py | 328 ++++++++++++++++++ Tools/fbx_export_preset.py | 94 +++++ Tools/hero_facade_build.py | 120 +++++-- Tools/mpfb_character_export.py | 20 +- Tools/scale_test_export.py | 18 +- Tools/tests/test_facade_detail_concepts.py | 108 ++++++ Tools/tests/test_fbx_export_preset.py | 70 ++++ Tools/ue_build_review_map.py | 12 +- Tools/ue_hero_facade_import.py | 10 + 60 files changed, 1114 insertions(+), 291 deletions(-) create mode 100644 Tools/facade_detail_concepts.py create mode 100644 Tools/fbx_export_preset.py create mode 100644 Tools/tests/test_facade_detail_concepts.py create mode 100644 Tools/tests/test_fbx_export_preset.py diff --git a/Art/blender/hero/hero-facade-2026-11-07.blend b/Art/blender/hero/hero-facade-2026-11-07.blend index 771f2b9..8d91f5a 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:eba62eb9928ddc52ca895280168f9db2b1627a86b777e7d34175c49ab0f8034f -size 134203 +oid sha256:cadc2469e3e9fb41dc379a1c61489054533819478fa74befecaa37adcfdadd97 +size 158985 diff --git a/Art/blender/hero/hero-facade-2026-11-07.json b/Art/blender/hero/hero-facade-2026-11-07.json index b56c429..00d70b6 100644 --- a/Art/blender/hero/hero-facade-2026-11-07.json +++ b/Art/blender/hero/hero-facade-2026-11-07.json @@ -3,18 +3,18 @@ "frame": "project frame, metres, X east Y north Z up", "bounds_m": { "min": [ - -13.02, + -13.08, -12.5, 0.0 ], "max": [ - 13.02, - 0.07, + 13.08, + 0.13, 14.5 ], "size": [ - 26.04, - 12.57, + 26.16, + 12.63, 14.5 ] }, @@ -33,10 +33,28 @@ "dims_m": [ 0.95, 0.95, - 7.45 + 8.25 ], "evidence": "6 columns at 4.96 m centres (sibling notes x measured frontage)" }, + { + "object": "Hero_ColumnBase_x6", + "dims_m": [ + 1.36, + 1.36, + 0.22 + ], + "evidence": "assumption: Ionic base moulding; photographs show a wider base at the shaft foot" + }, + { + "object": "Hero_Abacus_x6", + "dims_m": [ + 1.3, + 1.3, + 0.14 + ], + "evidence": "assumption: square abacus under the architrave, as Ionic capitals carry" + }, { "object": "Hero_Architrave", "dims_m": [ @@ -55,12 +73,21 @@ ], "evidence": "assumption: projecting cornice line" }, + { + "object": "Hero_Dentil_xN", + "dims_m": [ + 0.18, + 0.3, + 0.22 + ], + "evidence": "52 dentils at 0.45 m centres under the corona; assumption: photographed cornice reads as a dentilled course" + }, { "object": "Hero_Entablature", "dims_m": [ 25.75, 1.5, - 3.4 + 3.0 ], "evidence": "assumption: entablature depth" }, @@ -69,7 +96,7 @@ "dims_m": [ 25.75, 1.2, - 2.2 + 1.8 ], "evidence": "assumption: parapet carries the motto band" }, @@ -86,10 +113,10 @@ "object": "Hero_Medallion_x2", "dims_m": [ 1.1, - 1.1, - 0.02 + 0.04, + 1.1 ], - "evidence": "photographs: two stone discs beside the motto" + "evidence": "photographs: two stone discs beside the motto, mounted on the parapet face" }, { "object": "Hero_Stylobate", diff --git a/Art/blender/site/export_site_fbx.py b/Art/blender/site/export_site_fbx.py index 7680e32..4f26b77 100644 --- a/Art/blender/site/export_site_fbx.py +++ b/Art/blender/site/export_site_fbx.py @@ -15,6 +15,9 @@ import os import sys import bpy +sys.path.insert(0, os.path.join(os.path.join(os.path.dirname(__file__), "..", "..", ".."), "Tools")) +import fbx_export_preset as fbx_preset # noqa: E402 + MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North", "Crossing_Markings") @@ -60,20 +63,7 @@ def main(): _ = bpy.context.view_layer bpy.context.view_layer.objects.active = obj path = os.path.join(args.out, name + ".fbx") - bpy.ops.export_scene.fbx( - filepath=path, - use_selection=True, - object_types={"MESH"}, - apply_unit_scale=True, - apply_scale_options="FBX_SCALE_NONE", - axis_forward="-Y", - axis_up="Z", - use_space_transform=True, - use_mesh_modifiers=True, - mesh_smooth_type="OFF", - path_mode="AUTO", - bake_space_transform=False, - ) + fbx_preset.export_static(path, selection=True) if mirrored: # The flip is an export correction, not a change to the source: undo it. obj.scale.y *= -1.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1e3e4..5aa8adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,127 +8,109 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [ Nothing yet. -## [0.1.1] — 2026-11-08 +## [0.1.2] — 2026-11-09 -Collision-fidelity patch. No new scope: street geometry corrections and the QA -tooling that measures them. - -### Fixed - -- **Sidewalk outer edge ledge.** The sidewalk stood the full 0.15 m kerb height - above the DEM at both edges, leaving a 15.8 cm lip where the walk route crossed - from terrain onto the south sidewalk. The upstand now tapers from full at the - road-side edge to zero at the building-side edge; the north route's worst step - fell from 15.1 cm to 6.9 cm. -- **Crossing ramps too steep.** The kerb-flush run was a fixed 1.5 m, giving - ~8.7 % (measured 10–13 cm over 1.5 m). The run is now derived from a target - grade (`--max-ramp-grade`, default 8 % ⇒ 1.63 m); the crossing steps 3.4 cm at - 0.25 m sampling. +Scale and façade correction patch. Buildings and the hero elevation now match the reference photography and the cross-checked height data; no new gameplay scope. ### Added -- **Walk pre-check improvements** (`Art/blender/site/site_assembly.py`): sampling - coverage is independent of `--sample` (it previously shrank the route), and the - report now prints the grade and the arc-length/offset **location** of the worst - step, so a step can be located rather than guessed. +- **`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. +- **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). ### Changed -- **Kerb height recorded as final** at 0.15 m modelled / 0.13 m as built — a - standard 6 in LA kerb; neither the 3DEP DEM nor the reference photography can - resolve it further (±2 cm), which is far below the character's 45 cm step - height. +- **Storey height 3.2 → 3.1 m**, calibrated against Mapbox building data, with explicit overrides for three rear outbuildings; the block cross-check now reports 66 matches and no disagreements. +- **Hero colonnade 47.6 % → 53.1 %** of the 14.50 m elevation, inside the photographed 50–60 % band (thinner entablature and parapet). +- Blockout regenerated at the new storey height; frontage re-exported and re-imported (6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m). +- FBX exports now carry smoothing groups (`FACE` instead of `OFF`), so round geometry is no longer faceted and imports stop warning. + +### Fixed + +- **Windows floated 1.44 m in front of the wall**, ahead of the colonnade; they now sit on the wall plane behind the columns. +- **Parapet medallions were flat discs lying on the coping** instead of discs on the parapet face. +- **The hero façade had no usable collision** — the player could walk through it. It is now complex-as-simple; auto-generated convex hulls were tried and rejected because they bulged into the street. +- **The three "guessed" block heights were never buildings**: two `building=roof` canopies and a transit shelter. They are classified as non-building volumes and the block now reports zero guessed heights. ### Verified -- Site meshes rebuilt and re-imported (7/7, collision flags intact), DemoMap - rebuilt, packaged Development build `EXIT=0`. -- `-qaselftest` on the packaged build: walk 761 cm in 2 s at steady Z 456, reset - 0 cm, pause/unpause, transect 1456 samples across 16 rows / **0 holes**, wall - probe **0/35** blocking hits. +- Packaged build `-qaselftest`: walk, reset and pause pass; seam transect 1456 samples / 0 holes; wall probe 0/35; façade probe 0/9. +- Screenshots re-rendered for every change and approved by the owner. ### Known limitations -- One residual **13.4 cm surface step at s≈198 m** on the south sidewalk (8.0 cm - at s≈251 m north). It is not a gap and is below the 45 cm `MaxStepHeight`, so it - does not block traversal; the cause is a sidewalk-strip discontinuity near a - centreline vertex, recorded in `Art/blender/README.md` as a smoothness - refinement. -- Kerb radii at junctions, driveway aprons, drainage inlets and pavement joints - remain unmodelled (unchanged from `0.1.0`). +- Cornice dentil spacing, the base and abacus dimensions are recorded assumptions: the reference photography does not resolve them at our scale. +- Façade surfaces remain flat blockout materials; the textured pass is `0.1.4`. + +## [0.1.1] — 2026-11-08 + +Collision-fidelity patch. No new scope: street geometry corrections and the QA tooling that measures them. + +### Fixed + +- **Sidewalk outer edge ledge.** The sidewalk stood the full 0.15 m kerb height above the DEM at both edges, leaving a 15.8 cm lip where the walk route crossed from terrain onto the south sidewalk. The upstand now tapers from full at the road-side edge to zero at the building-side edge; the north route's worst step fell from 15.1 cm to 6.9 cm. +- **Crossing ramps too steep.** The kerb-flush run was a fixed 1.5 m, giving ~8.7 % (measured 10–13 cm over 1.5 m). The run is now derived from a target grade (`--max-ramp-grade`, default 8 % ⇒ 1.63 m); the crossing steps 3.4 cm at 0.25 m sampling. + +### Added + +- **Walk pre-check improvements** (`Art/blender/site/site_assembly.py`): sampling coverage is independent of `--sample` (it previously shrank the route), and the report now prints the grade and the arc-length/offset **location** of the worst step, so a step can be located rather than guessed. + +### Changed + +- **Kerb height recorded as final** at 0.15 m modelled / 0.13 m as built — a standard 6 in LA kerb; neither the 3DEP DEM nor the reference photography can resolve it further (±2 cm), which is far below the character's 45 cm step height. + +### Verified + +- Site meshes rebuilt and re-imported (7/7, collision flags intact), DemoMap rebuilt, packaged Development build `EXIT=0`. +- `-qaselftest` on the packaged build: walk 761 cm in 2 s at steady Z 456, reset 0 cm, pause/unpause, transect 1456 samples across 16 rows / **0 holes**, wall probe **0/35** blocking hits. + +### Known limitations + +- One residual **13.4 cm surface step at s≈198 m** on the south sidewalk (8.0 cm at s≈251 m north). It is not a gap and is below the 45 cm `MaxStepHeight`, so it does not block traversal; the cause is a sidewalk-strip discontinuity near a centreline vertex, recorded in `Art/blender/README.md` as a smoothness refinement. +- Kerb radii at junctions, driveway aprons, drainage inlets and pavement joints remain unmodelled (unchanged from `0.1.0`). Nothing yet. ## [0.1.0] — 2026-11-08 -The first playable release: a walkable exterior demo of the **6840 Hollywood Blvd** -(El Capitan Entertainment Centre) block in Unreal Engine, built on OpenStreetMap -layout data and USGS elevation, with the hero façade and the verified block -frontage staged on a walkable street. +The first playable release: a walkable exterior demo of the **6840 Hollywood Blvd** (El Capitan Entertainment Centre) block in Unreal Engine, built on OpenStreetMap layout data and USGS elevation, with the hero façade and the verified block frontage staged on a walkable street. ### Added -- **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`). -- **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. -- **Documentation** — setup, build, toolchain, licensing, ODbL review, release - checks, and arc42 architecture docs. +- **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`). +- **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. +- **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. -- **"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`. -- **Game target would not compile** — removed editor-only API use - (`UStaticMesh::PostEditChange`, `AActor::GetActorLabel`). -- **`demo-north-buildings` screenshot** framed 85 % sky (camera 9.5 km west of the - buildings); re-pointed to the building mass. +- **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. +- **"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`. +- **Game target would not compile** — removed editor-only API use (`UStaticMesh::PostEditChange`, `AActor::GetActorLabel`). +- **`demo-north-buildings` screenshot** framed 85 % sky (camera 9.5 km west of the buildings); re-pointed to the building mass. ### Verified -- Both sides of the demo are traversable: 1456-sample seam transect, 0 holes, - no blocking collision or invisible walls (`-qaselftest` on the packaged build). -- Placement and street scale match the reference photography and Geo - measurements (road 24.3 m, sidewalk offsets 12.5/13.2 m, hero frontage 26.0 m). -- **414 fps average / p99 3.01 ms at 2560×1440** (Epic preset, TSR, VSync off, no - frame generation) — far above the 60 fps target. -- Fresh checkout builds and packages with the documented external engine - (UE 5.8.4). +- Both sides of the demo are traversable: 1456-sample seam transect, 0 holes, no blocking collision or invisible walls (`-qaselftest` on the packaged build). +- Placement and street scale match the reference photography and Geo measurements (road 24.3 m, sidewalk offsets 12.5/13.2 m, hero frontage 26.0 m). +- **414 fps average / p99 3.01 ms at 2560×1440** (Epic preset, TSR, VSync off, no frame generation) — far above the 60 fps target. +- Fresh checkout builds and packages with the documented external engine (UE 5.8.4). ### Known limitations -- Buildings other than the hero are flat blockout volumes; façade detail, - materials and Blosm style scripts are planned for `0.1.4`. -- Kerb height (0.13 m as built vs 0.15 m assumed), kerb radii, driveway aprons and - graded crossing ramps are deferred to `0.1.1`; crossing ramps are kerb flushes. +- Buildings other than the hero are flat blockout volumes; façade detail, materials and Blosm style scripts are planned for `0.1.4`. +- Kerb height (0.13 m as built vs 0.15 m assumed), kerb radii, driveway aprons and graded crossing ramps are deferred to `0.1.1`; crossing ramps are kerb flushes. - One block only; no vehicles, NPCs, audio, or settings UI yet. -[Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.1...main +[Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.2...main +[0.1.2]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.2 [0.1.1]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.1 [0.1.0]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.0 diff --git a/Docs/release-checks.md b/Docs/release-checks.md index 328f44d..fae4af0 100644 --- a/Docs/release-checks.md +++ b/Docs/release-checks.md @@ -95,6 +95,15 @@ Run on the packaged build (`GigLineQA`, Development, Vulkan SM5) with `-qaselfte - **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.) diff --git a/Game/GigLine/Content/Hollywood/DemoMap.umap b/Game/GigLine/Content/Hollywood/DemoMap.umap index 1822e71..0041601 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:f1d90ad9f218d9f1e29f78b600b19a42cc63e99753f74da94adcbec8e32c7d2f -size 54508 +oid sha256:bcda8f7eacbbc8c20ff768177dc1c35863fd1bf403df142722880ca426e8a8f1 +size 54509 diff --git a/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset b/Game/GigLine/Content/Hollywood/ElCapitan/SM_HeroFacade.uasset index d66dd6f..466c4da 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:ce9572e7344bb02ea61c2cc1a3b0966125c4a2a01d99acd012a658519df037ac -size 117081 +oid sha256:76cdd4770b34206e7b53c27d1902fde238efde555580ce08cf87270882613b85 +size 142674 diff --git a/Game/GigLine/Content/Hollywood/Frontage/SM_Budget_6822.uasset b/Game/GigLine/Content/Hollywood/Frontage/SM_Budget_6822.uasset index 29c9048..fd8d792 100644 --- a/Game/GigLine/Content/Hollywood/Frontage/SM_Budget_6822.uasset +++ b/Game/GigLine/Content/Hollywood/Frontage/SM_Budget_6822.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cc532260823c0b6dfd943744300fb5cbeff8ab87f63ceb092717641759b014a -size 78278 +oid sha256:04eea883885521fadfc2675c4ee93c7aba61cbbf84e464d34a634f1b778bc161 +size 78284 diff --git a/Game/GigLine/Content/Hollywood/Frontage/SM_ElCapitanTheatre.uasset b/Game/GigLine/Content/Hollywood/Frontage/SM_ElCapitanTheatre.uasset index c93c236..7182b89 100644 --- a/Game/GigLine/Content/Hollywood/Frontage/SM_ElCapitanTheatre.uasset +++ b/Game/GigLine/Content/Hollywood/Frontage/SM_ElCapitanTheatre.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97b279689536abc54f2dae5f78b9a82a1fe2bd138ee76557de279e9c7a48d7f3 -size 79377 +oid sha256:92b4a9e1ffebdec61f477c62228b02074d1afed6e76288b9cac7cfc21479bf94 +size 79348 diff --git a/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6806.uasset b/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6806.uasset index 7fcfeb4..f8f5611 100644 --- a/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6806.uasset +++ b/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6806.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4b522b03d10b7c5c98627f199c426143122f6c0e1d1d6d82938174152ca42d6 -size 79018 +oid sha256:21faaa28e97235717b973e3827604805edd88195fb0997c1ff374e8e04f4186a +size 78998 diff --git a/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6902.uasset b/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6902.uasset index 6799faf..49ee421 100644 --- a/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6902.uasset +++ b/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6902.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60f3a08ac1493fe918da2e38ad7b4355763a39cf90c7e9e6016911cd3505c46c -size 78339 +oid sha256:86082f721699b618c0989d250861bd6a079c45bc321f9472c191344ad3a9fef3 +size 78349 diff --git a/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6922.uasset b/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6922.uasset index 324bac4..8ffa7e4 100644 --- a/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6922.uasset +++ b/Game/GigLine/Content/Hollywood/Frontage/SM_Hollywood_6922.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1704908dd0d66a25672fdf6b1c8f3b8b731e361e38b33077b67dce3e6dd747e -size 78896 +oid sha256:2c06ffd414b54a3c8383344da54baf05938ba3aa2a6322f3d6801b2f7cc1ccfc +size 78881 diff --git a/Game/GigLine/Content/Hollywood/Frontage/SM_LeeDrug_6800.uasset b/Game/GigLine/Content/Hollywood/Frontage/SM_LeeDrug_6800.uasset index f683ef8..a0948ea 100644 --- a/Game/GigLine/Content/Hollywood/Frontage/SM_LeeDrug_6800.uasset +++ b/Game/GigLine/Content/Hollywood/Frontage/SM_LeeDrug_6800.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c3c8df33dd9aee5d6175c9f3ad50e4b453461750bdb3ea5543f61e4a63fa19c -size 78091 +oid sha256:ad59e28c16e965ddd814e69d9510233f93b44410bf42d5602e36609124879465 +size 78097 diff --git a/Game/GigLine/Content/Hollywood/North/SM_Madame_Tussauds.uasset b/Game/GigLine/Content/Hollywood/North/SM_Madame_Tussauds.uasset index dfc957a..08c9aa6 100644 --- a/Game/GigLine/Content/Hollywood/North/SM_Madame_Tussauds.uasset +++ b/Game/GigLine/Content/Hollywood/North/SM_Madame_Tussauds.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e62e32b685cecfc0d5a9071e4fc1a2fa634768f5f68be73eb8b52b3011960a0 -size 79860 +oid sha256:6916ce5e1856eaefccce5faa3d688f0bdb6398a2dcc56a872ceb00080db7993b +size 79982 diff --git a/Game/GigLine/Content/Hollywood/North/SM_TCL_Chinese_Theatre.uasset b/Game/GigLine/Content/Hollywood/North/SM_TCL_Chinese_Theatre.uasset index 823a712..d8f0531 100644 --- a/Game/GigLine/Content/Hollywood/North/SM_TCL_Chinese_Theatre.uasset +++ b/Game/GigLine/Content/Hollywood/North/SM_TCL_Chinese_Theatre.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2ee1d5bd60aa0368f9ec110efdd4618c8b0ac924bbd06af34e31ce5d302ef17 -size 80261 +oid sha256:6ba67436b139db9375a1e43d05ea5fdd9c4770f4f778f3ace12cc7a47426222f +size 80253 diff --git a/Game/GigLine/Content/Hollywood/Site/Crossing_Markings.uasset b/Game/GigLine/Content/Hollywood/Site/Crossing_Markings.uasset index 2f48750..e7c6d84 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:90e9c5586b43bd8a5073fdd98b48e477c0369dbfb935752a2e832b046c4ac9f1 -size 75501 +oid sha256:b87aee07bd35898e3acd37cc9f6c595cad62e68aaa68ff93db424687b983738b +size 75559 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_North.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_North.uasset index e451175..57b7e5e 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:096d4e05ce88191ce821b1987b973fdeed6f30ac67414ba50d40dc74e0341c41 -size 83558 +oid sha256:7fe2e4298ba7ac5d214cee36a604926ceadcdb929b643bdc0e571ac9de6354d4 +size 83660 diff --git a/Game/GigLine/Content/Hollywood/Site/Kerb_South.uasset b/Game/GigLine/Content/Hollywood/Site/Kerb_South.uasset index e1c291a..7a6b66b 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:c611c9f32b52c569b3e4c9a9cd337a2227f3d678b69e7033123a678bb9aa8d3f -size 83802 +oid sha256:cee2405bd726e30658551592811b4f7ade9d09eb53a3f5d7f7ac437b07f477c5 +size 84070 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset b/Game/GigLine/Content/Hollywood/Site/M_Asphalt.uasset index 9c2c426..ee81ce5 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:188cf5bd1547c1bec869a194b79ba60548ac28e8414289bf9ea32b827f8eaecc +oid sha256:8b3b5a17f3a916bb27858051e5ba88b4266a420898128e3fb26577b4ca92ff15 size 7876 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset b/Game/GigLine/Content/Hollywood/Site/M_Concrete.uasset index 77fa9fc..30f1d14 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:d2313642be9c8dfcdd0439527b0b6f031887d61598f34511be1bd75182a9e855 +oid sha256:35ef03e01d6cc6aee00d7bdea4063213b1b3f4ec717838f6694dcdfe85e593a8 size 7881 diff --git a/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset b/Game/GigLine/Content/Hollywood/Site/M_FacadeStone.uasset index 7ba9917..99136e1 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:e9385d8c1460375dd52aee6106a8802457c9c8e6bab63277229c0b094d2528a0 +oid sha256:092d4108e04ea0a90902249a2ff29480a61ce0001963fdbb7fffa7788facee46 size 7896 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset b/Game/GigLine/Content/Hollywood/Site/M_Ground.uasset index 5a7a5f0..fea4b51 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:22ef2675f4979297f9cf77f75ab1d94ddf064fa51d89af2006686b154802b8be +oid sha256:6bb3fc7e0ba1c40897838d8ab01b053f467da130e54c19a91e46b61a15b09e2b size 7871 diff --git a/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset b/Game/GigLine/Content/Hollywood/Site/M_KerbConcrete.uasset index 9bea9fe..a3c6748 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:ff080d67c3e222453f879666e18a8ae74f1332f4f5f34159c58d61c06f09c1d5 +oid sha256:ef17b5d0ed660a13a60b294a9d36ab99787336b2655e4f350fcc2780e5fc5d04 size 7901 diff --git a/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset b/Game/GigLine/Content/Hollywood/Site/M_RoadPaint.uasset index 7e06e1f..7e38022 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:9ea19c5e9e2399de65497b46afc2b0d0453e85e230e719f4c3b134a3254e01b7 +oid sha256:60d614ddb19422a8fcca5474f731ef5640f7d7d60c6ef59780914c15f8367388 size 7886 diff --git a/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset b/Game/GigLine/Content/Hollywood/Site/M_Stucco.uasset index b586dd8..de5a3bb 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:01438cb78cf286d3a097fbcdb5406c8a37c872d9eafe5211d9755fb420371fad +oid sha256:38bbe13637e5a27860b2b0cb2d75d9802446a8ee481825de46bd04bbf0b6f586 size 7871 diff --git a/Game/GigLine/Content/Hollywood/Site/Road_Hollywood.uasset b/Game/GigLine/Content/Hollywood/Site/Road_Hollywood.uasset index 2c299df..fdebf3d 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:a48b4b61a9a062c19d1f604634fb349eb5ea59def82803574b3b3376f7241f09 -size 309812 +oid sha256:496a3d53741a35ac7d45ba894c8c9661c39b4e922f968177795f818400dca192 +size 311045 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_North.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_North.uasset index 5ae5e2d..f6ff33d 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:3534e2ba61975bb8c350cb0b3a78624b6ca90b8c92e0acb725c053883dcfaf29 -size 150179 +oid sha256:6e850dd628c7809e1607d748e97903b736780b215462efb2e9550abedec59b38 +size 150199 diff --git a/Game/GigLine/Content/Hollywood/Site/Sidewalk_South.uasset b/Game/GigLine/Content/Hollywood/Site/Sidewalk_South.uasset index fe14cd8..1587e25 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:4b3113c472a0b83f6ba369efc8cdec91c0f7aa08c0b859c00661a624303253bb -size 164880 +oid sha256:1f979437847aa453436acbea6b0e5ae474f50d75da51a7d5cdca9782fcb9b790 +size 162907 diff --git a/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset b/Game/GigLine/Content/Hollywood/Site/Terrain_DEM.uasset index 5e04895..263ef74 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:d12a66df59e4d32dc19240ff025aa156569c6c6c7290f4a076211c1728044ad6 -size 335528 +oid sha256:22e56a42763de7a73b4d8974a3e8709d89ebdc666bde2ce5d21dea699cee3198 +size 357901 diff --git a/Game/GigLine/Content/Test/ReviewMap.umap b/Game/GigLine/Content/Test/ReviewMap.umap index ad8ae57..a36c204 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:df31c3e291c05b111ad898efcf9aba0acf70866210aff82c981aa896c2adc63c +oid sha256:3c34637f8b929cab7a68725f550f65ebdfac790cb5af5e2349ef661dccd8faa0 size 28706 diff --git a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp index 9ab855a..aafab92 100644 --- a/Game/GigLine/Source/GigLine/GigLineCharacter.cpp +++ b/Game/GigLine/Source/GigLine/GigLineCharacter.cpp @@ -416,6 +416,81 @@ void AGigLineCharacter::StartQASelfTest() TEXT("GigLine QA: wall probe %d/%d traces with blocking hit inside 21 m -> %s"), Walls, WallSamples, Walls == 0 ? TEXT("PASS") : TEXT("FAIL")); + + // Facade occlusion probe: walkable street is only half the story — + // the player must also NOT be able to walk through the buildings. + // From the road centre, fire 40 m sideways at walking height toward + // each frontage and require a blocking hit. A miss means the + // building has no collision (or only non-blocking collision) and the + // player would pass through the wall. + // Expect a wall only where the map actually stages a building. + // Spans below mirror the actors placed by Tools/ue_build_demo_map.py + // (frontage + hero) and the two north buildings; update them if + // that list changes. Gaps between buildings are legitimate misses. + struct FSpan { float Min, Max; }; + static const FSpan SouthSpans[] = { + {-24276.f, -17700.f}, // 6922 + {-17749.f, -14591.f}, // 6902 + {-14638.f, -12034.f}, // 6840 hero + {-12082.f, -8210.f}, // 6838 El Capitan Theatre + {-7854.f, -7252.f}, // 6822 Budget + {-6608.f, -3307.f}, // 6806-6820 + {-3320.f, -1437.f}, // 6800 Lee Drug + }; + static const FSpan NorthSpans[] = { + {-27071.f, -22799.f}, // Madame Tussaud's + {-22889.f, -18442.f}, // TCL Chinese Theatre + }; + auto InSpans = [](const FSpan* Spans, int32 Count, float X) + { + for (int32 i = 0; i < Count; ++i) + { + if (X >= Spans[i].Min && X <= Spans[i].Max) { return true; } + } + return false; + }; + int32 Facades = 0, FacadeSamples = 0; + for (float X0 = -19000.0f; X0 <= -3000.0f; X0 += 2000.0f) + { + for (float Dir : {1.0f, -1.0f}) // +Y south frontage, -Y north side + { + const bool bExpectWall = (Dir > 0.0f) + ? InSpans(SouthSpans, UE_ARRAY_COUNT(SouthSpans), X0) + : InSpans(NorthSpans, UE_ARRAY_COUNT(NorthSpans), X0); + if (!bExpectWall) + { + continue; + } + FHitResult GroundHit2; + FCollisionQueryParams GQ2(TEXT("GigLineFacadeGround"), false, this); + if (!W->LineTraceSingleByChannel(GroundHit2, + FVector(X0, 0.0f, 3000.f), FVector(X0, 0.0f, -1000.f), + ECC_Visibility, GQ2)) + { + continue; + } + const float ProbeZ2 = GroundHit2.ImpactPoint.Z + 160.f; + FHitResult Hit2; + FCollisionQueryParams Q2(TEXT("GigLineFacadeProbe"), false, this); + const bool bHit2 = W->LineTraceSingleByChannel(Hit2, + FVector(X0, 0.0f, ProbeZ2), + FVector(X0, Dir * 4000.f, ProbeZ2), + ECC_Visibility, Q2); + ++FacadeSamples; + const bool bBlocking = bHit2 && Hit2.GetActor() != nullptr; + if (!bBlocking) + { + ++Facades; + UE_LOG(LogTemp, Log, + TEXT("GigLine QA: facade probe miss at X %.0f dir %.0f (no blocking hit in 40 m)"), + X0, Dir); + } + } + } + UE_LOG(LogTemp, Log, + TEXT("GigLine QA: facade probe %d/%d directions without a blocking wall -> %s"), + Facades, FacadeSamples, + Facades == 0 ? TEXT("PASS") : TEXT("FAIL")); } // 7.0: verdict, then stop ticking. diff --git a/MEMORY.md b/MEMORY.md index a890e0f..1b9bd8d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -400,3 +400,110 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO. height error; the table is the tagged height, the mesh is storey + roof. - Gotcha: regenerating the blockout is safe for names, but any future change to the import must re-verify the 6/6 frontage export and the `element.*` mapping. + +## 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). +- **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/`. +- **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`. +- Reminder: `.blend` is LFS read-only on checkout — `chmod u+w` or Blender fails + with "Cannot save blend file … not writable" (this bit again here). + +## 2026-11-09 — UE export hygiene: smoothing groups + facade collision + +- **All our FBX exporters used `mesh_smooth_type="OFF"`**, which is why every Unreal + import logged "No smoothing group information was found for this mesh". The hero + now exports `"FACE"` and its round parts are shaded with + `shade_smooth_by_angle(35°)` before export: cylinders read as round instead of + faceted, and the import warning is gone (6 → 0). +- **The hero façade had no simple collision** (complex-only), so line traces and + capsule sweeps passed straight through it — the player could walk through the + wall of the demo. Caught by a new `-qaselftest` **facade occlusion probe** (40 m + sideways traces from the road centre at walking height). +- **Auto-generated convex collision on a 95-part 26 m façade is unusable**: it + produced coarse hulls that bulged into the street and the wall probe went + 0/35 → 2/35. Rejected in favour of `UseComplexAsSimple` via + `UGigLineAssetTools.set_complex_collision_as_simple` (same reason the terrain + uses it), saved on the asset. +- Probe expectations must mirror the staged buildings: the facade probe now carries + explicit X spans per frontage (and the TCL/Tussaud's spans on the north side) so + a gap between buildings is not reported as a defect. + +## 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. +- 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. + +## 2026-11-09 — v0.1.2 released + +- Cornice finished: projecting corona over a **52-dentil course** (0.18 × 0.30 × + 0.22 m at 0.45 m centres, z 12.12–12.34, 0.25 m clear of the corona). Hero is now + 3 044 tris across 148 parts, still 14.50 m. Verified: packaged `-qaselftest` all + PASS (transect 0 holes, wall probe 0/35, facade probe 0/9). +- `CHANGELOG.md` gets the `0.1.2` entry (added/changed/fixed/verified/limitations), + the `0.1.2` section is removed from `TODO.md`, `ROADMAP.md` status updated, tag + `v0.1.2` cut after the merge. +- Assumptions recorded rather than hidden: dentil spacing, base and abacus + dimensions are not resolvable in our reference photography. + +## 2026-11-09 — elevation cameras levelled (straight verticals) + +- archviz MUST: an elevation must not lean. `hero-facade-front` looked up ~4° from + 4.2 m at a 7.6 m target; `demo-spawn-view` and `demo-street-level` were pitched + too. All three now put the camera at the target's height, so + `FindLookAtRotation` yields pitch 0 and roll 0 and verticals are parallel by + construction. +- Do not try to *measure* verticality from the rendered PNG with a naive edge + scan: banners, windows and the parapet produce different edge sets at different + heights and the pairwise match is meaningless (it reported a 418 px "drift"). + Verify it geometrically in the camera list instead. diff --git a/Screenshots/README.md b/Screenshots/README.md index 2a5f6fc..04c3f64 100644 --- a/Screenshots/README.md +++ b/Screenshots/README.md @@ -58,5 +58,6 @@ Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs t - Floor speckle from the `Constant3Vector` wiring in `Tools/ue_build_review_map.py`. - Hero façade renders pale — Blender flat colours with no normal/roughness, so it reads as untextured stone (the `0.1.4` façade patch, not a lighting bug). -- Banners and window panels float detached from the colonnade wall (next modelling pass). +- Banners sit on the column line rather than the wall (acceptable: they hang on the columns in the photographs). +- ~~Banners and window panels float detached from the colonnade wall~~ — **window panes fixed 2026-11-09**: they sat at Y −1.98, 1.44 m in _front_ of the wall and ahead of the column line; they now sit on the wall plane at Y −0.56, behind the columns, and read as dark openings (see `review-hero-facade-front`). - Between kerb and building line, the walkable surface is the terrain mesh, not a modelled pavement. diff --git a/Screenshots/demo/review-demo-crossing.png b/Screenshots/demo/review-demo-crossing.png index d207434..72ccf7a 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:ed6b566b47e6332461dea5f1c786e458ae6a7a83e9f77fde960ab516be860707 -size 2625467 +oid sha256:380647ec83dcdc1d1796a3f6a033dfcb86d634c8312faf0c4332c795a12dcc81 +size 2592475 diff --git a/Screenshots/demo/review-demo-north-buildings.png b/Screenshots/demo/review-demo-north-buildings.png index d2a2eef..05d5445 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:a668af9982a5237f8b1212a42e75762522e5fda2cbd81c3dda41e6e1f2c08dbf -size 2472516 +oid sha256:f01bd1af993306a5de88397e6f0bcc79f64f0167b06353e54b8cc0719ff4881a +size 2261865 diff --git a/Screenshots/demo/review-demo-spawn-view.png b/Screenshots/demo/review-demo-spawn-view.png index 59d4675..ad2ad70 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:e3dea97c4a2b1e5055585f5d185ad3726a45009c98bb17c04a11734222015570 -size 2583278 +oid sha256:63d49011bcf0ab52b96c4ec7b27eeba3d27de3bbbf951a8f8e0ac09117995f3e +size 2628687 diff --git a/Screenshots/demo/review-demo-street-level.png b/Screenshots/demo/review-demo-street-level.png index fc6b0da..f07d483 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:30d2432eb2dbe611761ecf756c819fc03d56a9b672dd2ef937cc395b0446688f -size 2406661 +oid sha256:d87896644e71e7a66b18fb9997d76d51a05cce5fa6fc97e3f698a45258d0b306 +size 2468639 diff --git a/Screenshots/review-character.png b/Screenshots/review-character.png index ed80072..82f9181 100644 --- a/Screenshots/review-character.png +++ b/Screenshots/review-character.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2b94be4fb2264ba612a3ff697e3fab6b328965eff86478a352c043890b226197 -size 2755469 +oid sha256:44df56de845bde433eb1d6de2294462c9933d8f6a800a4f3cdc01793c5b1497b +size 2528497 diff --git a/Screenshots/review-ground-contact.png b/Screenshots/review-ground-contact.png index 86fb8b0..4dbada6 100644 --- a/Screenshots/review-ground-contact.png +++ b/Screenshots/review-ground-contact.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:784d70d8ecdf66e2ec254510b2ea44dee2e738b2788e3c0310d2bc95a2413a59 -size 1805142 +oid sha256:9043e2e90ffead898262bae5c6f8ad0bbc700c5f52aa398ae6f486a858ac4af0 +size 1749002 diff --git a/Screenshots/review-hero-facade-front.png b/Screenshots/review-hero-facade-front.png index ac578a8..d523315 100644 --- a/Screenshots/review-hero-facade-front.png +++ b/Screenshots/review-hero-facade-front.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d20f402ee2f0adbb5ddfff3532f9f2fd3b8a4101bd235a1211a9ad7772761d4 -size 1744237 +oid sha256:c8c70fa7806c12a0cdbf2196b5f52f82f402e1b806f067e48a98970a94393e41 +size 1436443 diff --git a/Screenshots/review-hero-facade-oblique.png b/Screenshots/review-hero-facade-oblique.png index 904f152..6428303 100644 --- a/Screenshots/review-hero-facade-oblique.png +++ b/Screenshots/review-hero-facade-oblique.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08f49feb0e7fa2bb39dda28aef933173fa4b89a5e8280d5353ed7d26a7410d90 -size 1402754 +oid sha256:c53e905de74ffb3783f2895ed667e352179b7bc411246b3b5bcdae88a5b299f9 +size 1306447 diff --git a/Screenshots/review-hero-portals.png b/Screenshots/review-hero-portals.png index 21fac11..c54cf5b 100644 --- a/Screenshots/review-hero-portals.png +++ b/Screenshots/review-hero-portals.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b87d31270f6233b9bd345041e9a63be274a2843405139d5c6e629c429aca0529 -size 1364154 +oid sha256:f7455b6cc2a5a81ed1848956c41555a7775b705c24a34a0cbd8e0d0717e8d499 +size 1321848 diff --git a/Screenshots/review-npc-prefab.png b/Screenshots/review-npc-prefab.png index d120ac8..74c6464 100644 --- a/Screenshots/review-npc-prefab.png +++ b/Screenshots/review-npc-prefab.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:204920e7d8ab884d47848fa6a811ca7cc17f9cdc6a84cdccc6d3889cd773ab80 -size 1797175 +oid sha256:99501ac2edfa22a64a7156fbc298930db64997008d7534189f0ee9a208b9506a +size 1735511 diff --git a/Screenshots/review-overview.png b/Screenshots/review-overview.png index c494541..0da3826 100644 --- a/Screenshots/review-overview.png +++ b/Screenshots/review-overview.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aec644b1926c6bf7333de00c22e34d44e18e2dbcdc061b7be9c006dbdb160c60 -size 2416002 +oid sha256:f40eda489a19e01f3e0e6428940e4da7ee6865c7397e4e9bbe64f6e7a84b4af0 +size 2488592 diff --git a/Screenshots/review-player-placement.png b/Screenshots/review-player-placement.png index 84ed21d..102d17f 100644 --- a/Screenshots/review-player-placement.png +++ b/Screenshots/review-player-placement.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85c4cb94ddcdf1fa214b267e4b3e11add02af22224e4a3b7ea5a918725b72963 -size 2205324 +oid sha256:f66e9a9e157c4663b971d15dcdfc8d3b67d64584c9f92a0d5ff207aac7637d37 +size 2019699 diff --git a/Screenshots/review-scale-cubes.png b/Screenshots/review-scale-cubes.png index 09645eb..3d27366 100644 --- a/Screenshots/review-scale-cubes.png +++ b/Screenshots/review-scale-cubes.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc335d11b76a319eac8528f5fe0a5ac03945e69739f49f9339f5572a22aa3faf -size 2134125 +oid sha256:be833e2571fa2311499bbd806d32984c4a0b7bcaa33af2ff362fc6c94f553bff +size 1937905 diff --git a/Screenshots/review-shadow-compare.png b/Screenshots/review-shadow-compare.png index 3ecc241..3888105 100644 --- a/Screenshots/review-shadow-compare.png +++ b/Screenshots/review-shadow-compare.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81e9ffc8ff8f124f9b5f9c57595e399444adca2614fb424a48f51fdbca9bc741 -size 1853523 +oid sha256:75c80b08b7905b9e6c9df673151c2b0d11362c3a433b402e22240b66c647181b +size 1726246 diff --git a/TODO.md b/TODO.md index 64a68eb..432bcfa 100644 --- a/TODO.md +++ b/TODO.md @@ -9,18 +9,6 @@ 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. -## 0.1.2 — Scale and façade corrections (patch) - -Suggested blender skills: **references** (reference-image-match and visual-match-checklist templates — measure the guessed heights and colonnade proportions against the photographs), **archviz** (real-world scale and façade composition discipline), **camera-cinematography** (reproduce the reference camera angles when comparing renders to photos). - -### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `scale-facade-0.1.2`) - -- [x] Correct building scale and street proportions flagged against references. **Mapbox adopted 2026-11-09.** `Geo/tools/mapbox_buildings.py` merges our OSM heights with Mapbox's building data for the same footprints; after adopting its two findings the cross-check reports **66 match, 6 non-building, 0 disagreements**. Adopted: **`LEVEL_HEIGHT` 3.2 → 3.1 m** (`block_height_report.py`, and the Blosm `--level-height` default), plus explicit `HEIGHT_OVERRIDES` (3.0 m) for the three rear `building=yes`/`apartments` structures Mapbox gives as single-storey. Blockout regenerated, frontage re-exported/re-imported, DemoMap rebuilt; packaged `-qaselftest` all PASS. Measured effect: 6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m (both now ~1.9 m above their table height = roof mass). -- [ ] Refine hero façade details where reference photography shows discrepancies. -- [x] **Replace the placeholder heights.** **Resolved 2026-11-08 — they were never unknown buildings.** The three "guessed" ways are not building volumes at all: `1360755125` and `1360755126` are `building=roof` at `layer=1` (elevated canopies) and `1417995292` is `amenity=shelter` / `shelter_type=public_transport` (a transit shelter). The report defaulted them to "2 levels × 3.2 m" and produced the bogus 6.4 m. `Geo/tools/block_height_report.py` now classifies roof/shelter explicitly and reports **0 guessed heights**: all 15 block volumes have a real `height` tag, a `building:levels` derivation, or are correctly excluded as non-building volumes. No photograph measurement was needed, and no frontage building was affected. -- [ ] Resolve the remaining unknown on the hero: the colonnade currently occupies about 45 % of the height against roughly 50–60 % in the photographs. Fix the vertical proportions. **Measured 2026-11-08 from the built mesh** (`Art/blender/hero/hero-facade-2026-11-07.blend`): envelope 0–14.50 m; stylobate 0–2.00; columns 2.00–7.95; capitals 7.95–8.90; entablature 8.90–12.30; parapet 12.30–14.50. So the colonnade (columns + capitals) is **6.90 m = 47.6 %** of the height — inside the range only at the low end. Bringing it to ~55 % needs ~+1.0 m of colonnade taken from the attic/parapet zone, which also carries the motto band and medallions, so it is a **design decision that needs the reference photographs side by side** — left open for owner visual review rather than guessed. -- [ ] Tag `v0.1.2` and write `CHANGELOG.md` entry; then remove this section. - ## 0.1.3 — Packaging and setup fixes (patch) Suggested blender skills: **export-pipeline** and **unreal-export** (scale/pivot/normals/materials/collision verification) if any packaging defect traces back to the Blender export presets. diff --git a/Tools/README.md b/Tools/README.md index d11e50f..efd3dca 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -16,6 +16,7 @@ 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 | +| `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 | @@ -57,9 +58,16 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b | Command | Purpose | | --- | --- | | `UnrealEditor /Game/Hollywood/DemoMap -game -qaselftest` | Headless behaviour check: drives forward input for 2 s, then resets and pauses. Logs `GigLine QA: ... PASS/FAIL` lines and a final `RESULT`. Runs only with `-qaselftest`, so normal play is unaffected. | -| `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes) and a ground-referenced wall probe (0/35), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. | +| `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes), a ground-referenced wall probe (0/35) and a **facade occlusion probe** that fires sideways from the road centre and requires a blocking wall wherever a building is staged (0/9), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. | | `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. | +## Reference and concept generation + +| 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) | + ## Input injection | Tool | Purpose | diff --git a/Tools/export_block_frontage.py b/Tools/export_block_frontage.py index 50e7670..a03d9df 100644 --- a/Tools/export_block_frontage.py +++ b/Tools/export_block_frontage.py @@ -19,6 +19,9 @@ 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 + # blockout object name -> (asset name, note). Addresses from the verification doc. BUILDINGS = { @@ -69,20 +72,7 @@ def main(): bpy.context.view_layer.objects.active = new_obj path = os.path.join(args.out, asset_name + ".fbx") - bpy.ops.export_scene.fbx( - filepath=path, - use_selection=True, - object_types={"MESH"}, - apply_unit_scale=True, - apply_scale_options="FBX_SCALE_NONE", - axis_forward="-Y", - axis_up="Z", - use_space_transform=True, - use_mesh_modifiers=True, - mesh_smooth_type="OFF", - path_mode="AUTO", - bake_space_transform=False, - ) + fbx_preset.export_static(path, selection=True) world = new_obj.matrix_world zs = [(world @ v.co).z for v in new_mesh.vertices] ys = [(world @ v.co).y for v in new_mesh.vertices] diff --git a/Tools/export_north_buildings.py b/Tools/export_north_buildings.py index 6224772..06dfd50 100644 --- a/Tools/export_north_buildings.py +++ b/Tools/export_north_buildings.py @@ -13,6 +13,9 @@ 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 + BUILDINGS = ("TCL Chinese Theatre", "Madame Tussaud's") @@ -50,20 +53,7 @@ def main(): bpy.context.view_layer.objects.active = new_obj path = os.path.join(args.out, name.replace(" ", "_").replace("'", "") + ".fbx") - bpy.ops.export_scene.fbx( - filepath=path, - use_selection=True, - object_types={"MESH"}, - apply_unit_scale=True, - apply_scale_options="FBX_SCALE_NONE", - axis_forward="-Y", - axis_up="Z", - use_space_transform=True, - use_mesh_modifiers=True, - mesh_smooth_type="OFF", - path_mode="AUTO", - bake_space_transform=False, - ) + fbx_preset.export_static(path, selection=True) world = new_obj.matrix_world zs = [(world @ v.co).z for v in new_mesh.vertices] print(f"[north] {name:22} verts {len(new_mesh.vertices):5} z {min(zs):+.2f}..{max(zs):+.2f} m " diff --git a/Tools/facade_detail_concepts.py b/Tools/facade_detail_concepts.py new file mode 100644 index 0000000..90a523f --- /dev/null +++ b/Tools/facade_detail_concepts.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Generate frontal building plates from the local reference photography. + +Each study takes one photograph we already hold (`Art/reference/`, gitignored) as +the geometric anchor and asks an image model to re-stage it as a clean **frontal +view of the building** — the whole elevation, the entrance in its facade, or the +building with its neighbours — evenly lit, no crowd, no staging, so the modelling +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 + +Output: `Art/reference/facade-details/.png` plus a `.refgen.json` +sidecar recording the model, the source photograph, the prompt and the SHA-256 of +the result. `Art/reference/` is gitignored, so **neither the images nor the +sidecars are committed** — they are local working material. The study list and +the tool are the committed part. + +Design notes +------------ +* Standard library only: the OpenRouter chat-completions call is `urllib`, and + the returned `data:` URL is decoded with `base64` and written straight to disk. +* An existing output is never overwritten; `-v2`, `-v3` … are used instead, so + re-running after a prompt edit keeps both studies comparable. +* Generation costs money, so it is never automatic: `--list` is free, and every + generating run prints the model and the study count first. +* The token is read from `OPENROUTER_API_KEY` or the ignored `.env` and is never + written to a sidecar or a log line. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request + +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" +DEFAULT_MODEL = "google/gemini-2.5-flash-image" + +# The instruction appended to every study. Kept identical to the sibling +# project's convention so the two sets stay comparable: the photograph fixes the +# geometry, the prompt only restages light, weather, people and surface. +ANCHOR_SUFFIX = ( + "Use the attached reference photograph as the source of the architecture: keep " + "the building's proportions, storey count, column rhythm, band heights and " + "signage placement exactly as photographed. You may REFRAME — step back across " + "the street, zoom out or widen the field of view — so that the whole building " + "fits in the frame from the pavement to the parapet; the reference is often " + "cropped and the plate must not be. Keep the view exterior and frontal (or " + "three-quarter where asked). Restage only lighting, weather, people and surface " + "condition. Do not invent extra storeys, wings or ornaments, and do not change " + "the architecture to fit the frame." +) + +# name -> (reference photo relative to Art/reference/, what the plate must show) +# +# Verified framing (vision pre-check, 2026-11-09): the model follows the reference +# crop closely, so a plate is only as wide as its source photo. `building-entrance` +# and `building-street-front` come back as full-elevation frontal views; the studies +# anchored on the tightly cropped 2009 facade photo stay cropped, because the model +# will not invent the parapet and sky that the photograph does not contain. A full +# building plate for 6840 needs a full-elevation photograph, which the local set +# does not have (see Docs/reference-notes-hollywood.md). +# +# Every study asks for a *frontal view of a building*, not a crop of one detail: +# the whole elevation, the entrance in its facade, or the building together with +# its neighbours. Close-up framing is deliberately absent — a detail crop cannot +# be measured for proportion, which is what the modelling pass needs. +STUDIES: dict[str, tuple[str, str]] = { + "building-front": ( + "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "A frontal view of the complete building shown in the reference " + "photograph: the whole elevation from the pavement to the parapet fits in " + "the frame with clear sky above the roofline and pavement below the ground " + "floor, shot straight on and centred. Nothing may be cut off at the top or " + "bottom. Even daylight, no crowd, no vehicles.", + ), + "building-entrance": ( + "el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg", + "A frontal view of the building in the reference photograph, framed so its " + "street entrance is clearly visible as part of the facade — the doors and " + "the wall around them in proportion, not a close-up of the doorway. " + "Include the pavement in front. Even daylight, no crowd.", + ), + "building-with-neighbours": ( + "hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg", + "A straight-on view of the street wall in the reference photograph: the " + "building and its neighbouring buildings either side, each at full height " + "from pavement to parapet with sky above, the joins between them visible, " + "nothing cut off. Shot from across the road, parallel to the frontage. " + "Even daylight, no crowd.", + ), + "building-street-front": ( + "hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg", + "A frontal view along the pavement of the building in the reference " + "photograph: the building's frontage seen from street level with its " + "neighbours continuing either side and the pavement in the foreground. " + "Straight on, even daylight, no crowd.", + ), + "building-front-oblique": ( + "hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "A three-quarter view of the complete building in the reference " + "photograph: the frontal elevation and one side visible together so the " + "building's depth and mass read, with its neighbouring building at one " + "edge. The whole building stays in frame. Even daylight, no crowd.", + ), + "theatre-front-with-neighbours": ( + "el-capitan-theater-detail/01-el-capitan-marquee.jpg", + "A frontal view of the theatre in the reference photograph together with " + "the buildings either side of it, the whole frontage and its full height " + "in frame, shot from across the road. Even daylight, no crowd, marquee " + "lettering blocked out as unreadable texture.", + ), +} + + +def load_token() -> str: + token = os.environ.get("OPENROUTER_API_KEY") + if token: + return token.strip() + env_path = os.path.join(ROOT, ".env") + if os.path.exists(env_path): + with open(env_path, encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if line.startswith("OPENROUTER_API_KEY="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + raise SystemExit("no OpenRouter key: set OPENROUTER_API_KEY or add it to .env") + + +def build_prompt(study: str) -> str: + """The full prompt for a study: its request plus the fixed anchor suffix.""" + if study not in STUDIES: + raise KeyError(f"unknown study {study!r}; known: {sorted(STUDIES)}") + return f"{STUDIES[study][1]}\n\n{ANCHOR_SUFFIX}" + + +def reference_path(study: str) -> str: + return os.path.join(REFERENCE_DIR, STUDIES[study][0]) + + +def missing_references() -> list[str]: + """Studies whose source photograph is not present locally.""" + return [name for name in STUDIES if not os.path.isfile(reference_path(name))] + + +def next_free_path(study: str, directory: str = OUT_DIR) -> str: + """`.png`, or `-v2.png`, `-v3` … if the name is taken.""" + base = os.path.join(directory, f"{study}.png") + if not os.path.exists(base): + return base + index = 2 + while True: + candidate = os.path.join(directory, f"{study}-v{index}.png") + if not os.path.exists(candidate): + return candidate + index += 1 + + +def data_url(path: str) -> str: + suffix = os.path.splitext(path)[1].lower() + mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}.get(suffix) + if not mime: + raise SystemExit(f"unsupported reference type: {path}") + with open(path, "rb") as handle: + return f"data:{mime};base64,{base64.b64encode(handle.read()).decode('ascii')}" + + +def decode_image(payload: dict) -> bytes: + """Extract the returned image bytes from a chat-completions payload.""" + message = (payload.get("choices") or [{}])[0].get("message", {}) + urls = [item.get("image_url", {}).get("url", "") + for item in (message.get("images") or []) if isinstance(item, dict)] + if not urls: + match = re.search(r"data:image/[a-z]+;base64,[A-Za-z0-9+/=]+", + str(message.get("content") or "")) + if match: + urls.append(match.group(0)) + if not urls or not urls[0]: + raise SystemExit(f"model returned no image data: {json.dumps(payload)[:400]}") + url = urls[0] + if url.startswith("data:"): + return base64.b64decode(url.split(",", 1)[1]) + with urllib.request.urlopen(urllib.request.Request(url), timeout=120) as response: + return response.read() + + +def generate(study: str, token: str, model: str, force: bool = False) -> str: + """Generate one study; returns the written path. Never overwrites by default.""" + path = next_free_path(study) + if force and os.path.exists(os.path.join(OUT_DIR, f"{study}.png")): + path = os.path.join(OUT_DIR, f"{study}.png") + # Write to a temp name then replace, so a failed call cannot destroy the + # previous result. + target = path + ".new" + else: + target = path + + body = { + "model": model, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": build_prompt(study)}, + {"type": "image_url", "image_url": {"url": data_url(reference_path(study))}}, + ], + }], + } + request = urllib.request.Request( + API_URL, + data=json.dumps(body).encode("utf-8"), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "HTTP-Referer": "https://git.allucanget.biz/zwitschi/gig-line", + "X-Title": "gig-line facade details", + }, + method="POST", + ) + + payload = None + for attempt in range(3): + try: + with urllib.request.urlopen(request, timeout=180) as response: + payload = json.load(response) + break + except urllib.error.HTTPError as exc: + if exc.code == 429 or exc.code >= 500: + wait = 5 * (attempt + 1) + sys.stderr.write(f" HTTP {exc.code}; retrying in {wait}s\n") + time.sleep(wait) + continue + detail = exc.read().decode("utf-8", "replace")[:300] + raise SystemExit(f"{study}: HTTP {exc.code} — {detail}") + except urllib.error.URLError as exc: + sys.stderr.write(f" network error ({exc.reason}); retrying\n") + time.sleep(5 * (attempt + 1)) + if payload is None: + raise SystemExit(f"{study}: no response after retries") + + image = decode_image(payload) + os.makedirs(OUT_DIR, exist_ok=True) + with open(target, "wb") as handle: + handle.write(image) + if target != path: + os.replace(target, path) + + import hashlib + sidecar = { + "study": study, + "generated": os.path.basename(path), + "generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "model": model, + "prompt": build_prompt(study), + "reference": { + "file": STUDIES[study][0], + "note": "local reference photography; Art/reference is gitignored and never shipped", + }, + "sha256": hashlib.sha256(image).hexdigest(), + "bytes": len(image), + "redistribution": "local development reference only; not committed, not shipped", + } + sidecar_path = os.path.splitext(path)[0] + ".refgen.json" + with open(sidecar_path, "w", encoding="utf-8") as handle: + json.dump(sidecar, handle, indent=2) + if token in json.dumps(sidecar): + raise SystemExit("refusing to write a sidecar containing the token") + return path + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--list", action="store_true", help="list studies and exit (free)") + ap.add_argument("--all", action="store_true", help="generate every study") + ap.add_argument("--only", help="comma-separated study names") + ap.add_argument("--model", default=DEFAULT_MODEL) + ap.add_argument("--force", action="store_true", + help="replace the existing .png instead of writing a -vN") + args = ap.parse_args(argv) + + if args.list or not (args.all or args.only): + missing = set(missing_references()) + print(f"{len(STUDIES)} studies; model {args.model}") + for name, (ref, _prompt) in STUDIES.items(): + state = "MISSING reference" if name in missing else "ready" + print(f" {name:22} {state:18} <- {ref}") + if missing: + print(f"\n{len(missing)} study(ies) cannot run without their reference photograph.") + return 0 + + names = list(STUDIES) if args.all else [s.strip() for s in args.only.split(",") if s.strip()] + unknown = [n for n in names if n not in STUDIES] + if unknown: + raise SystemExit(f"unknown study(ies): {unknown}; known: {sorted(STUDIES)}") + blocked = [n for n in names if n in set(missing_references())] + if blocked: + raise SystemExit(f"missing reference photograph for: {blocked}") + token = load_token() + + print(f"generating {len(names)} study(ies) with {args.model} -> {OUT_DIR}") + for index, name in enumerate(names, 1): + print(f"[{index}/{len(names)}] {name}") + try: + path = generate(name, token, args.model, force=args.force) + except SystemExit as exc: + sys.stderr.write(f" FAILED: {exc}\n") + continue + print(f" wrote {os.path.relpath(path, ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Tools/fbx_export_preset.py b/Tools/fbx_export_preset.py new file mode 100644 index 0000000..d36ede5 --- /dev/null +++ b/Tools/fbx_export_preset.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""The one FBX export preset for this project ("ue_metric_v1"/"ue_character_v1"). + +Before this module the same `bpy.ops.export_scene.fbx(...)` argument block was +copied into six scripts (hero, frontage, north buildings, site, scale test, MPFB +character). They had already drifted only by accident, and every one of them +carried `mesh_smooth_type="OFF"` — which is why Unreal logged "No smoothing group +information was found for this mesh" on every import and round parts rendered +faceted. One preset, one place to fix that. + +Use from inside Blender: + + import sys, os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "Tools")) + import fbx_export_preset as preset + + preset.export_static(path) # whole scene, MESH only + preset.export_static(path, selection=True) # selected objects only + preset.export_character(path) # armature + mesh, character flags + +`preset_kwargs()` is pure (no `bpy`), so the argument set can be unit-tested +without Blender — see Tools/tests/test_fbx_export_preset.py. + +Why these values: +* `FBX_SCALE_NONE` + `apply_unit_scale` keeps Blender metres → Unreal centimetres + at exactly ×100, verified by the three-cube scale test (1 m → 100.00 uu). +* `axis_forward="-Y"`, `axis_up="Z"` is the project's recorded mapping: east → +X, + north → −Y, up → +Z. +* `mesh_smooth_type="FACE"` exports smoothing groups. `"OFF"` (the old value) + drops them, so Unreal rebuilds flat normals and every cylinder is faceted. +* `use_mesh_modifiers=True` bakes the bevel/modifier stack at export; the source + `.blend` stays non-destructive. +""" + +from __future__ import annotations + +# Shared by every mesh export. Values are the recorded ue_metric_v1 preset. +_METRIC = { + "object_types": {"MESH"}, + "apply_unit_scale": True, + "apply_scale_options": "FBX_SCALE_NONE", + "axis_forward": "-Y", + "axis_up": "Z", + "use_space_transform": True, + "use_mesh_modifiers": True, + "mesh_smooth_type": "FACE", + "path_mode": "AUTO", + "bake_space_transform": False, +} + +# Character exports differ only in the object set and the armature/path flags. +_CHARACTER = dict( + _METRIC, + object_types={"ARMATURE", "MESH"}, + add_leaf_bones=False, + path_mode="COPY", + embed_textures=False, +) + +PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER} + + +def preset_kwargs(preset: str = "ue_metric_v1", selection: bool = False, **overrides) -> dict: + """Return the `bpy.ops.export_scene.fbx` argument dict (no `bpy` needed). + + `preset` selects the base set, `selection` decides whether only selected + objects are exported, and `overrides` wins last for the rare special case. + """ + if preset not in PRESETS: + raise KeyError(f"unknown preset {preset!r}; known: {sorted(PRESETS)}") + kwargs = dict(PRESETS[preset]) + kwargs["use_selection"] = bool(selection) + kwargs.update(overrides) + if "filepath" in kwargs: + raise ValueError("pass the path to export_static/export_character, not as an override") + return kwargs + + +def _export(path: str, preset: str, selection: bool, overrides: dict) -> str: + import bpy # imported here so `preset_kwargs` stays usable without Blender + + kwargs = preset_kwargs(preset, selection=selection, **overrides) + bpy.ops.export_scene.fbx(filepath=path, **kwargs) + return path + + +def export_static(path: str, selection: bool = False, **overrides) -> str: + """Export meshes with the ue_metric_v1 preset. Returns the path.""" + return _export(path, "ue_metric_v1", selection, overrides) + + +def export_character(path: str, selection: bool = False, **overrides) -> str: + """Export armature + mesh with the ue_character_v1 preset. Returns the path.""" + return _export(path, "ue_character_v1", selection, overrides) diff --git a/Tools/hero_facade_build.py b/Tools/hero_facade_build.py index 4003a98..964a419 100644 --- a/Tools/hero_facade_build.py +++ b/Tools/hero_facade_build.py @@ -33,6 +33,9 @@ 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 + # --- measured / derived ------------------------------------------------------ FRONTAGE_W = 25.75 @@ -50,8 +53,12 @@ FRONTAGE_Y = 0.0 # front plane sits at the building line of the block fra PEDESTAL_H = 1.45 # column pedestal top; entrance platform sits on it COLUMN_D = 0.95 # Ionic shaft diameter at the base CAPITAL_H = 0.95 -ENTABLATURE_H = 3.40 # band between the capitals and the motto panel -PARAPET_H = 2.20 # motto panel and cornice above the entablature +ENTABLATURE_H = 3.00 # band between the capitals and the motto panel + # 3.40 -> 3.00 on 2026-11-09: the photographs put the + # colonnade at 50-60 % of the 14.5 m elevation, and our + # 3.40/2.20 entablature+parapet left it at 47.6 %. Thinning + # both lifts the colonnade to 7.70 m = 53.1 %, inside the band. +PARAPET_H = 1.80 # motto panel and cornice above the entablature (see above) PORTAL_W = 3.10 # each of the three door openings PORTAL_H = 3.40 STEP_TREAD = 0.34 @@ -70,7 +77,7 @@ def cube(name, x, y, z, dx, dy, dz, material=None): return obj -def cylinder(name, x, y, z0, z1, radius, material, vertices=24): +def cylinder(name, x, y, z0, z1, radius, material, vertices=24, smooth=True): bpy.ops.mesh.primitive_cylinder_add( vertices=vertices, radius=radius, @@ -81,6 +88,30 @@ def cylinder(name, x, y, z0, z1, radius, material, vertices=24): obj.name = name if material: obj.data.materials.append(material) + if smooth: + # Round parts must shade smoothly *around* the axis while keeping the + # caps creased. Without this the columns read as faceted prisms, and the + # FBX carries no smoothing groups at all (Unreal warns about it). + bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0)) + return obj + + +def disc_on_wall(name, x, y, z, radius, thickness, material=None, vertices=24): + """A round disc mounted vertically on a wall face. + + `cylinder()` builds along Z, so a "medallion" made with it is a pancake lying + flat on top of the parapet rather than a disc on its face — which is what the + first build did (1.1 m across in Y, 0.02 m thick in Z, sitting at the coping). + This rotates the cylinder 90 degrees about X so the axis points along Y and + the disc faces the street. + """ + obj = cylinder(name, x, y, z - thickness / 2.0, z + thickness / 2.0, + radius, material, vertices=vertices) + obj.rotation_euler = (math.radians(90.0), 0.0, 0.0) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) return obj @@ -132,14 +163,32 @@ def build(): col_xs.append(x) cube(f"Hero_Pedestal_{i + 1}", x, -0.62, PEDESTAL_H + 0.27, 1.24, 1.24, 0.54, stone) - cylinder(f"Hero_Column_{i + 1}", x, -0.55, PEDESTAL_H + 0.55, - PEDESTAL_H + 0.55 + (HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H - 0.55 - CAPITAL_H), + # Base moulding: a taller, wider cylinder where the shaft meets the + # pedestal. Without it the shaft starts straight off a square plinth, + # which is not how an Ionic base reads in silhouette. + base_z0 = PEDESTAL_H + 0.55 + base_h = 0.22 + cylinder(f"Hero_ColumnBase_{i + 1}", x, -0.55, base_z0, base_z0 + base_h, + 0.68, stone, vertices=20) + shaft_z0 = base_z0 + base_h + 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) - cylinder(f"Hero_Capital_{i + 1}", x, -0.55, - HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H, - HEIGHT - ENTABLATURE_H - PARAPET_H, 0.62, stone, vertices=16) + # 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). + abacus_h = 0.14 + cylinder(f"Hero_Capital_{i + 1}", x, -0.55, cap_z0, + HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h, 0.62, stone, vertices=16) + cube(f"Hero_Abacus_{i + 1}", x, -0.55, + HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h / 2.0, + 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_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), + "assumption: square abacus under the architrave, as Ionic capitals carry") # --- entablature and parapet ------------------------------------------ ent_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H @@ -147,9 +196,25 @@ def build(): # elevation reads as layered stone rather than one flat plane. cube("Hero_Architrave", 0.0, -1.55, ent_z0 + 0.42, FRONTAGE_W, 1.65, 0.84, stone) note("Hero_Architrave", (FRONTAGE_W, 1.65, 0.84), "assumption: stacked entablature bands") - # Cornice under the parapet. - cube("Hero_Cornice", 0.0, -1.6, HEIGHT - PARAPET_H - 0.18, FRONTAGE_W, 1.75, 0.36, stone) + # Cornice under the parapet: a projecting corona carried on a dentil course, + # so the top of the order reads as layered stone with a shadow line rather + # than one flat box. Dentils hang under the corona and stand 0.25 m clear of + # it, which is what gives the elevation its cornice shadow at street level. + cornice_z = HEIGHT - PARAPET_H - 0.18 + cube("Hero_Cornice", 0.0, -1.6, cornice_z, FRONTAGE_W, 1.75, 0.36, stone) note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line") + DENTIL_W, DENTIL_D, DENTIL_H, DENTIL_GAP = 0.18, 0.30, 0.22, 0.27 + dentil_y = -2.30 + dentil_z = cornice_z - 0.18 - DENTIL_H / 2.0 + dentil_span = FRONTAGE_W - 2.0 + dentil_count = int(dentil_span / (DENTIL_W + DENTIL_GAP)) + for i in range(dentil_count): + dx = -dentil_span / 2.0 + (DENTIL_W + DENTIL_GAP) * i + DENTIL_W / 2.0 + cube(f"Hero_Dentil_{i + 1}", dx, dentil_y, dentil_z, + DENTIL_W, DENTIL_D, DENTIL_H, stone) + note("Hero_Dentil_xN", (DENTIL_W, DENTIL_D, DENTIL_H), + f"{dentil_count} dentils at {DENTIL_W + DENTIL_GAP:.2f} m centres under the corona; " + "assumption: photographed cornice reads as a dentilled course") cube("Hero_Entablature", 0.0, -0.75, ent_z0 + ENTABLATURE_H / 2.0, FRONTAGE_W, 1.5, ENTABLATURE_H, stone) note("Hero_Entablature", (FRONTAGE_W, 1.5, ENTABLATURE_H), "assumption: entablature depth") @@ -163,11 +228,13 @@ def build(): 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") - # Medallions either side of the inscription. + # 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): - cylinder(f"Hero_Medallion_{i}", x, -1.36, HEIGHT - PARAPET_H / 2.0 + 0.42, - HEIGHT - PARAPET_H / 2.0 + 0.44, 0.55, stone_dark, vertices=20) - note("Hero_Medallion_x2", (1.1, 1.1, 0.02), "photographs: two stone discs beside the motto") + 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") # --- entrances --------------------------------------------------------- # Three portals between column pairs, each with its own short flight. The @@ -241,32 +308,27 @@ def build(): # --- windows ----------------------------------------------------------- # Two ranks of windows between the columns and behind the colonnade. + # Y must sit on the wall plane: the mass front face is at Y -0.50 and the + # columns stand proud to -1.02. These were at -1.98, i.e. 1.44 m in FRONT of + # the wall and ahead of the column line, so the glazing floated in the + # colonnade instead of reading as openings behind it (visible in the + # review-hero-facade-* shots). WINDOW_Y puts the pane 6 cm proud of the wall, + # safely behind the columns. + WINDOW_Y = -0.56 for rank, z in enumerate((4.4, 8.2), start=1): for i, x in enumerate(col_xs[:-1], start=1): wx = x + COLUMN_BAY / 2.0 - cube(f"Hero_Window_{rank}_{i}", wx, -1.98, z, 2.0, 0.08, 1.9, glass) + 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") return parts, lines def export_fbx(path): + # Preset lives in Tools/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. - bpy.ops.export_scene.fbx( - filepath=path, - use_selection=False, - object_types={"MESH"}, - apply_unit_scale=True, - apply_scale_options="FBX_SCALE_NONE", - axis_forward="-Y", - axis_up="Z", - use_space_transform=True, - use_mesh_modifiers=True, - mesh_smooth_type="OFF", - path_mode="AUTO", - bake_space_transform=False, - ) + 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/mpfb_character_export.py index 37d15f5..a37dd4a 100644 --- a/Tools/mpfb_character_export.py +++ b/Tools/mpfb_character_export.py @@ -19,6 +19,9 @@ import sys import zlib import bpy +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import fbx_export_preset as fbx_preset # noqa: E402 + import numpy from mathutils import Euler @@ -149,22 +152,7 @@ def make_diffuse(human, size=1024): def export_fbx(human, path): os.makedirs(os.path.dirname(path), exist_ok=True) # Named export preset "ue_character_v1" — change only via a new preset name. - bpy.ops.export_scene.fbx( - filepath=path, - use_selection=False, - object_types={"ARMATURE", "MESH"}, - apply_unit_scale=True, - apply_scale_options="FBX_SCALE_NONE", - axis_forward="-Y", - axis_up="Z", - use_space_transform=True, - use_mesh_modifiers=True, - add_leaf_bones=False, - path_mode="COPY", - embed_textures=False, - mesh_smooth_type="OFF", - bake_space_transform=False, - ) + fbx_preset.export_character(path) log(f"export: FBX preset ue_character_v1 -> {path}") diff --git a/Tools/scale_test_export.py b/Tools/scale_test_export.py index bf46f0a..ffac49e 100644 --- a/Tools/scale_test_export.py +++ b/Tools/scale_test_export.py @@ -16,6 +16,9 @@ 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 + def build_scene(): @@ -44,20 +47,7 @@ def build_scene(): def export_fbx(path): os.makedirs(os.path.dirname(path), exist_ok=True) # Named export preset "ue_metric_v1" - change only via a new preset name. - bpy.ops.export_scene.fbx( - filepath=path, - use_selection=False, - object_types={"MESH"}, - apply_unit_scale=True, - apply_scale_options="FBX_SCALE_NONE", - axis_forward="-Y", - axis_up="Z", - use_space_transform=True, - use_mesh_modifiers=True, - mesh_smooth_type="OFF", - path_mode="AUTO", - bake_space_transform=False, - ) + fbx_preset.export_static(path) print(f"[scale_test] exported FBX preset ue_metric_v1 -> {path}") diff --git a/Tools/tests/test_facade_detail_concepts.py b/Tools/tests/test_facade_detail_concepts.py new file mode 100644 index 0000000..fae36ca --- /dev/null +++ b/Tools/tests/test_facade_detail_concepts.py @@ -0,0 +1,108 @@ +"""Tests for Tools/facade_detail_concepts.py — the pure parts. + +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 base64 +import json +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 facade_detail_concepts as fc # noqa: E402 + + +class StudyTableTest(unittest.TestCase): + def test_every_study_has_a_reference_and_a_prompt(self): + self.assertTrue(fc.STUDIES) + for name, (ref, prompt) in fc.STUDIES.items(): + self.assertTrue(ref, f"{name} has no reference") + self.assertTrue(prompt.strip(), f"{name} has no prompt") + # References are relative to Art/reference and never escape it. + self.assertFalse(os.path.isabs(ref), f"{name} reference is absolute") + self.assertNotIn("..", ref, f"{name} reference escapes the directory") + + def test_prompt_always_carries_the_anchor_suffix(self): + for name in fc.STUDIES: + prompt = fc.build_prompt(name) + self.assertIn(fc.ANCHOR_SUFFIX, prompt) + self.assertIn(fc.STUDIES[name][1], prompt) + + def test_unknown_study_raises(self): + with self.assertRaises(KeyError): + fc.build_prompt("no-such-study") + + +class NextFreePathTest(unittest.TestCase): + def test_uses_the_plain_name_when_free(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x.png") + + def test_never_overwrites(self): + import tempfile + with tempfile.TemporaryDirectory() as tmp: + open(os.path.join(tmp, "x.png"), "w").close() + self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x-v2.png") + open(os.path.join(tmp, "x-v2.png"), "w").close() + self.assertEqual(os.path.basename(fc.next_free_path("x", tmp)), "x-v3.png") + + +class DecodeImageTest(unittest.TestCase): + PAYLOAD_BYTES = b"\x89PNG\r\n\x1a\nfake" + + def _data_url(self): + return "data:image/png;base64," + base64.b64encode(self.PAYLOAD_BYTES).decode() + + def test_reads_message_images(self): + payload = {"choices": [{"message": { + "images": [{"image_url": {"url": self._data_url()}}]}}]} + self.assertEqual(fc.decode_image(payload), self.PAYLOAD_BYTES) + + def test_reads_a_data_url_embedded_in_content(self): + payload = {"choices": [{"message": {"content": f"here: {self._data_url()}"}}]} + self.assertEqual(fc.decode_image(payload), self.PAYLOAD_BYTES) + + def test_refusal_raises_with_the_text(self): + payload = {"choices": [{"message": {"content": "I cannot do that."}}]} + with self.assertRaises(SystemExit): + fc.decode_image(payload) + + +class DataUrlTest(unittest.TestCase): + def test_encodes_a_jpeg(self): + import tempfile + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as handle: + handle.write(b"jpegbytes") + path = handle.name + try: + url = fc.data_url(path) + self.assertTrue(url.startswith("data:image/jpeg;base64,")) + finally: + os.unlink(path) + + def test_rejects_an_unsupported_type(self): + import tempfile + with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as handle: + path = handle.name + try: + with self.assertRaises(SystemExit): + fc.data_url(path) + finally: + os.unlink(path) + + +class SidecarShapeTest(unittest.TestCase): + def test_studies_and_outputs_are_local_only(self): + # The output directory lives under the gitignored Art/reference tree, so + # neither the images nor the sidecars can be committed. + self.assertIn(os.path.join("Art", "reference"), fc.OUT_DIR) + self.assertIn("gitignored", fc.__doc__) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/tests/test_fbx_export_preset.py b/Tools/tests/test_fbx_export_preset.py new file mode 100644 index 0000000..e86013e --- /dev/null +++ b/Tools/tests/test_fbx_export_preset.py @@ -0,0 +1,70 @@ +"""Tests for Tools/fbx_export_preset.py. + +The preset module keeps its argument set in plain dicts and builds it in +`preset_kwargs()`, so the export contract can be checked without starting +Blender. Only `export_static`/`export_character` import `bpy`. +""" + +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 fbx_export_preset as preset # noqa: E402 + + +class MetricPresetTest(unittest.TestCase): + def test_scale_and_axis_contract(self): + # These three are the project's recorded conversion contract: metres -> + # centimetres by a factor of exactly 100, east +X, north -Y, up +Z. + kwargs = preset.preset_kwargs() + self.assertTrue(kwargs["apply_unit_scale"]) + self.assertEqual(kwargs["apply_scale_options"], "FBX_SCALE_NONE") + self.assertEqual(kwargs["axis_forward"], "-Y") + self.assertEqual(kwargs["axis_up"], "Z") + + def test_smoothing_groups_are_exported(self): + # "OFF" (the pre-refactor value) drops smoothing groups and every Unreal + # import warns about it, with round parts faceted. + self.assertEqual(preset.preset_kwargs()["mesh_smooth_type"], "FACE") + + def test_selection_flag(self): + self.assertFalse(preset.preset_kwargs()["use_selection"]) + self.assertTrue(preset.preset_kwargs(selection=True)["use_selection"]) + + def test_object_types_are_meshes_only(self): + self.assertEqual(preset.preset_kwargs()["object_types"], {"MESH"}) + + +class CharacterPresetTest(unittest.TestCase): + def test_character_exports_armature_and_mesh(self): + kwargs = preset.preset_kwargs("ue_character_v1") + self.assertEqual(kwargs["object_types"], {"ARMATURE", "MESH"}) + self.assertFalse(kwargs["add_leaf_bones"]) + self.assertEqual(kwargs["path_mode"], "COPY") + self.assertFalse(kwargs["embed_textures"]) + + def test_character_keeps_the_scale_contract(self): + kwargs = preset.preset_kwargs("ue_character_v1") + self.assertEqual(kwargs["apply_scale_options"], "FBX_SCALE_NONE") + self.assertEqual(kwargs["mesh_smooth_type"], "FACE") + + +class OverrideTest(unittest.TestCase): + def test_overrides_win(self): + kwargs = preset.preset_kwargs(mesh_smooth_type="OFF") + self.assertEqual(kwargs["mesh_smooth_type"], "OFF") + + def test_unknown_preset_raises(self): + with self.assertRaises(KeyError): + preset.preset_kwargs("ue_imperial_v9") + + def test_filepath_must_not_be_an_override(self): + with self.assertRaises(ValueError): + preset.preset_kwargs(filepath="/tmp/x.fbx") + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/ue_build_review_map.py b/Tools/ue_build_review_map.py index 8c4aacb..583c037 100644 --- a/Tools/ue_build_review_map.py +++ b/Tools/ue_build_review_map.py @@ -236,7 +236,11 @@ try: # the plinth, which is what "portal detail" means here. ("hero-portals", unreal.Vector(hero_x + 100, -2250, 170), unreal.Vector(hero_x + 100, hero_y, 230)), # straight on from the north sidewalk; the building body spans Y -1443..+250 - ("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 420), unreal.Vector(hero_x, hero_y + 400, 760)), + # Level horizon: camera height == target height, so the elevation's + # verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a + # 7.6 m target, which converged the columns — the one thing an + # architectural elevation must not do (archviz: "straight verticals"). + ("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760)), # oblique from the north-west, reading the colonnade in depth ("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)), ] @@ -276,12 +280,14 @@ try: demo_shots = [ # 14 m back so the 14.5 m elevation fits: at 2 m the camera sees only # the colonnade's underside. - ("demo-spawn-view", unreal.Vector(-13336, -3100, 420), unreal.Vector(-13336, 1300, 620)), + # Level horizon for the same reason: a block elevation is read by its + # verticals, and a pitched camera makes them lean. + ("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620)), # 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 # the unlit back face. Y -2600 is on the sidewalk in front of the lit # elevation. - ("demo-street-level", unreal.Vector(-13420, -1650, 170), unreal.Vector(-13336, 1300, 400)), + ("demo-street-level", unreal.Vector(-13420, -1650, 170), unreal.Vector(-13336, 1300, 170)), # The designated mid-block crossing. Street arc length runs from the # west end (s=0 at X -27966 cm), so s=130.1 m is UE X -14956, not the # -24291 the old camera used (that is the s=36.6 crossing). Camera sits diff --git a/Tools/ue_hero_facade_import.py b/Tools/ue_hero_facade_import.py index 455aab0..b36fd6c 100644 --- a/Tools/ue_hero_facade_import.py +++ b/Tools/ue_hero_facade_import.py @@ -61,6 +61,16 @@ def measure_and_place(): 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}) "