1 Commits
Author SHA1 Message Date
zwitschi 5e2c5e29c0 feat(facades): 0.1.4 — module kit, textured materials, theatre trim (#30)
Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
2026-11-08 20:29:11 +01:00
90 changed files with 2592 additions and 413 deletions

No files matched your search

+4
View File
@@ -47,6 +47,10 @@ Thumbs.db
/Geo/raw/mapbox-cache/ /Geo/raw/mapbox-cache/
/Geo/verification/*mapbox* /Geo/verification/*mapbox*
# Visual-loop iterations: generated comparisons stay uncommitted until the owner
# confirms a shot. Only Screenshots/ is committed.
/Art/reference/iterations/
# Local, private reference material — never committed (see Docs/reference-notes-hollywood.md) # Local, private reference material — never committed (see Docs/reference-notes-hollywood.md)
/Art/reference/ /Art/reference/
/Docs/data-inventory.md /Docs/data-inventory.md
Binary file not shown.
+45 -1
View File
@@ -8,6 +8,49 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
Nothing yet. Nothing yet.
## [0.1.4] — 2026-11-09
Building façades. The block goes from flat extruded boxes with flat colours to coursed, textured surfaces assembled from a reusable module kit, and the El Capitan Theatre gets the marquee and blade sign that make it read as itself.
### Added
- **Façade module kit** at the measured **4.96 m bay** / **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet and coping — 16 `SM_` meshes, each validated for manifold topology (12 tris each) and assembled onto **eight buildings, 625 pieces**, from each building's own world bounds.
- **Generated surface textures** — deterministic stdlib PNGs for ashlar stone, stucco, asphalt and concrete (base colour + roughness), sampled world-aligned with per-material tiling; byte-identical on re-run.
- **Materials**: masonry coursing and roughness variation in the material graphs; `M_FacadeStoneShadow`, `M_FacadeGlass` (specular 0.62 ≈ IOR 1.5) and `M_FacadeBanner`. Material assignment is now **per slot**, keyed on the imported slot material, instead of slot 0 only.
- **El Capitan Theatre trim**: projecting marquee (12.0 × 2.60 m canopy at a 4.60 m soffit), illuminated fascia, recessed soffit, and a vertical blade sign (1.30 × 5.20 m) with frame and wall bracket.
- **Visual review loop** (`Tools/visual_loop.py` + `visual_targets.json`): verifies every capture against an expectation (missing, blank, tone-less or wrong-size shots fail) and measures each render against its reference photograph, emitting feedback that names the discipline to consult. Iterations are written under `Art/reference/iterations/`, which is gitignored until a shot is confirmed.
- **`Tools/fbx_export_preset.py`** — one export preset replacing six copies, and it exports smoothing groups (`FACE`), so round geometry is no longer faceted.
- **`Tools/ue_common.py`** — clears the read-only bit an LFS `.umap` checks out with and makes a refused save loud.
### Changed
- **Elevation cameras** are levelled (pitch 0, so verticals stay parallel) and use a 42–55° lens to fill the frame from across the street.
- **`Screenshots/` holds demo-map captures only**; the review staging map is no longer rendered for review. Each shot carries its expected content and an approval state.
### Fixed
- **Blockout frame**: the `0.1.2` regeneration had run without `--origin`, shifting the whole frontage ≈119 m in X and 2.4 m in Y and dropping the hero out of the 6902/6838 gap. Regenerated with the affine fit (residual 0.0 cm) and every span re-verified against the block verification notes.
- **Kit placed ~12 km from its buildings** — it used the mesh-local bounding box, which excludes the actor offset.
- **Marquee mirrored across the boulevard** (Blender +Y is Unreal −Y) and **hanging 5 cm above the pavement** (absolute Z instead of pavement-relative).
- **Window bays invisible** inside the solid wall; the surround now projects with glazing inset.
- **Glazing was not glass**: kit pieces kept the grey material the FBX import creates.
- **Hero windows floated 1.44 m in front of the wall**; parapet medallions were flat discs on the coping.
- **Imports could report success while saving nothing** (read-only LFS map), and the north-buildings import failed silently on a missing `import sys`.
### Verified
- Packaged build `-qaselftest`: walk 761 cm, reset, pause/unpause, seam transect 1456 samples / 0 holes, wall probe 0/35, façade occlusion probe 0/9 — all PASS. Run twice; the harness is time-based and one loaded run reported a false failure.
- All eight buildings probed against the verified world spans.
- 67 tool tests pass.
### Known limitations
- Kit pieces are boxes; cornice dentil spacing, abacus and plinth dimensions are recorded assumptions.
- Textures are generated, not authored: no decals, signage text or weathering maps.
- The vision pre-check remains advisory — it is unreliable on this content.
- Retail neighbours, hero fluting and the blockout cleaning pass move to `0.1.5`.
- Performance has not been re-measured since `0.1.0`; 625 kit actors are unmeasured.
## [0.1.3] — 2026-11-09 ## [0.1.3] — 2026-11-09
Packaging and setup fixes. No gameplay or visual scope; this patch makes the pipeline and its documentation tell the truth about what `0.1.x` taught. Packaging and setup fixes. No gameplay or visual scope; this patch makes the pipeline and its documentation tell the truth about what `0.1.x` taught.
@@ -129,7 +172,8 @@ The first playable release: a walkable exterior demo of the **6840 Hollywood Blv
- 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. - 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. - One block only; no vehicles, NPCs, audio, or settings UI yet.
[Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.3...main [Unreleased]: https://git.allucanget.biz/zwitschi/gig-line/compare/v0.1.4...main
[0.1.4]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.4
[0.1.3]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.3 [0.1.3]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.3
[0.1.2]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.2 [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.1]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.1
+80
View File
@@ -0,0 +1,80 @@
# `0.1.4` façade review — signoff
Four demo-map captures from the `facades-0.1.4` branch, after the module kit, the textured materials and the theatre marquee went in. **All seven items signed off 2026-11-09** (owner review of the local renders).
Reference photographs to compare against: `Art/reference/hollywood/` and `Art/reference/el-capitan-*` (local, not committed). Regenerate the set with `Tools/render_screenshots.sh --verify`; measure it with `python3 Tools/visual_loop.py --compare --iteration 5`.
## Signoff
- [x] **1. Block from the north sidewalk** — hero between its neighbours, sidewalk and road read correctly
- [x] **2. Street level** — colonnade, shopfronts and window rhythm hold up at eye height
- [x] **3. Crossing** — zebra bars, kerb line and the frontage behind
- [x] **4. North buildings** — TCL and Tussaud's framed across the boulevard
- [x] **5. Materials** — stone reads as coursed stone, glazing as glass, not flat colour
- [x] **6. Theatre trim** — marquee and blade sign proportions believable at street level
- [x] **7. Approve** `v0.1.4` (kit + materials + theatre trim); remaining geometry moves to the next version
## 1. Block from the north sidewalk
![Block from the north sidewalk](../../Screenshots/demo/review-demo-spawn-view.png)
Expected: the neoclassical hero façade between its neighbours, both sidewalks and the road in the foreground. _Metric: 0.68 similarity, edges 0.28× reference._
## 2. Street level
![Street level](../../Screenshots/demo/review-demo-street-level.png)
Expected: the hero colonnade with adjacent frontage buildings either side, shopfronts at pavement level, 4.96 m bay rhythm. _Metric: 0.33, edges 0.27×._
## 3. The designated crossing
![Crossing](../../Screenshots/demo/review-demo-crossing.png)
Expected: continental zebra bars on the asphalt at s=130 m, south frontage and colonnade behind. _Metric: 0.42, edges 0.34×._
## 4. North-side buildings
![North buildings](../../Screenshots/demo/review-demo-north-buildings.png)
Expected: TCL Chinese Theatre and Madame Tussaud's across Hollywood Blvd, full height in frame. _Metric: 0.34, edges 0.14×._
---
## Appendix — what was checked, and what was broken
### Verified
- **Behaviour**, packaged build, `-qaselftest`: walk 761 cm in 2 s (Z steady 456), reset 0 cm, pause/unpause, seam transect 1456 samples / **0 holes**, wall probe **0/35**, façade occlusion probe **0/9** — all PASS. Run twice: the harness is time-based and one loaded run reported a false failure (`walk 246 cm`, `pause FAIL`), an immediate re-run passed — re-run before believing a single FAIL.
- **Placement**, probed from the live map against `Geo/verification/2026-11-06-el-capitan-block.md`: 6922 −24276…−17700, 6902 −17749…−14591, **hero −14644…−12028 between them**, theatre −12082…−8210, 6822 / 6806 / 6800 −7854…−1437, TCL −22889…−18442, Tussaud's −27071…−22799. Blockout affine residual **0.0 cm**.
- **Budgets**: hero 148 parts / 3 044 tris · kit 16 meshes × 12 tris · 625 kit actors ≈ 7 500 tris · 8 × 512² textures (196 KB) · 67 tool tests pass.
- **Topology**: every kit piece manifold, no loose geometry.
### Defects found this cycle
Every one of these rendered convincingly; the placement ones were caught by coordinate probes, not by looking.
| Sev | Defect | Root cause |
| --- | --- | --- |
| Blocker | Frontage shifted ≈119 m X / 2.4 m Y, hero outside its gap | Blockout regenerated without `--origin` |
| Blocker | Kit placed ~12 km from its buildings | Used mesh-local bbox; actors carry the offset |
| Blocker | Marquee on the far side of the boulevard | Blender +Y is Unreal −Y |
| Major | Marquee canopy 5 cm above the pavement | Absolute Z; pavement is 4.56 m above datum |
| Major | Window bays invisible, inside the solid wall | Piece modelled entirely at +Y |
| Major | Glazing wasn't glass | Grey `M_Kit_*` from the FBX import, never overridden |
| Major | Hero windows floated 1.44 m in front of the wall | Wrong Y in the builder |
| Major | Parapet medallions were discs on the coping | Built with the Z-axis cylinder helper |
| Major | Hero had no usable collision | Complex-only; traces use simple |
| Minor | "No smoothing group information" on every import | `mesh_smooth_type="OFF"` in six exporters |
| Minor | `save_map()` returned False silently | LFS `.umap` read-only, importer never chmod'd |
| Minor | North-buildings import silently no-op | Missing `import sys` |
| Minor | Kit folder held 24 assets (8 orphan materials) | No stale-asset sweep |
| Minor | Edge-density metric counted PIL's border ring | `FIND_EDGES` leaves a 128 ring |
| Minor | QA wall probe grazed the theatre wall | Scan row 1 cm from the building line |
### Limitations worth knowing
- Kit pieces are boxes; dentil spacing, abacus and plinth dimensions are recorded assumptions, not measurements.
- Textures are generated, not authored — no decals, signage text or weathering.
- The vision pre-check stays advisory: it has rejected shots the owner approved.
- The low edge ratios in the metrics are partly structural — a wide block view against a tight façade crop always scores low. Weigh the images, not the scores.
- Performance has not been re-measured since `0.1.0`; the 625 kit actors are unmeasured (tracked in `TODO.md` under `0.1.5`).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -379,7 +379,11 @@ void AGigLineCharacter::StartQASelfTest()
// trace at a fixed Z (e.g. 160) is wrong: the street is at Z ~366 // trace at a fixed Z (e.g. 160) is wrong: the street is at Z ~366
// there, so a 160 cm probe starts inside the earth (dist 0). // there, so a 160 cm probe starts inside the earth (dist 0).
int32 Walls = 0, WallSamples = 0; int32 Walls = 0, WallSamples = 0;
static const float TraceYs[] = {-1400.0f, -167.0f, 1000.0f, -1382.0f, 1047.0f}; // Sample the street envelope only: the carriageway plus the walkable
// part of each sidewalk. Y 1000 used to be in this list and sits ~1 cm
// from the frontage building line, so it grazed the theatre wall once
// the kit's proud shopfronts went in - a probe artefact, not a blocker.
static const float TraceYs[] = {-1400.0f, -167.0f, 800.0f, -1382.0f, 718.0f};
for (float WallY : TraceYs) for (float WallY : TraceYs)
{ {
for (float X0 = -19500.0f; X0 <= -3000.0f; X0 += 2500.0f) for (float X0 = -19500.0f; X0 <= -3000.0f; X0 += 2500.0f)
@@ -1,6 +1,7 @@
#include "GigLineShotActor.h" #include "GigLineShotActor.h"
#include "Camera/CameraActor.h" #include "Camera/CameraActor.h"
#include "Camera/CameraComponent.h"
#include "Engine/World.h" #include "Engine/World.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "UnrealClient.h" #include "UnrealClient.h"
@@ -83,6 +84,15 @@ void AGigLineShotActor::NextShot()
Params.ObjectFlags |= RF_Transient; Params.ObjectFlags |= RF_Transient;
CurrentCamera = GetWorld()->SpawnActor<ACameraActor>( CurrentCamera = GetWorld()->SpawnActor<ACameraActor>(
ACameraActor::StaticClass(), ShotLocations[CurrentShot], ShotRotations[CurrentShot], Params); ACameraActor::StaticClass(), ShotLocations[CurrentShot], ShotRotations[CurrentShot], Params);
if (ShotFieldOfView.IsValidIndex(CurrentShot) && ShotFieldOfView[CurrentShot] > 0.0f)
{
if (UCameraComponent* Cam = CurrentCamera->GetCameraComponent())
{
// Long lens for elevations: fills the frame from across the street
// without pitching the camera, so verticals stay parallel.
Cam->SetFieldOfView(ShotFieldOfView[CurrentShot]);
}
}
PlayerController->SetViewTarget(CurrentCamera); PlayerController->SetViewTarget(CurrentCamera);
SettleCountdown = SettleSeconds; SettleCountdown = SettleSeconds;
@@ -36,6 +36,16 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite) UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FString> ShotNames; TArray<FString> ShotNames;
/**
* Optional per-shot field of view (degrees), same index as ShotLocations.
* Zero or missing entries keep the engine default (90 deg). Elevation shots
* taken from across the street need a long lens: a level camera 45 m out with
* the default FOV leaves the building small in frame, and tilting it up to fill
* the frame is what breaks the verticals.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<float> ShotFieldOfView;
/** Seconds to let the view target settle before the capture. Resolution comes from the runner's -ResX/-ResY. */ /** Seconds to let the view target settle before the capture. Resolution comes from the runner's -ResX/-ResY. */
UPROPERTY(EditAnywhere, BlueprintReadWrite) UPROPERTY(EditAnywhere, BlueprintReadWrite)
float SettleSeconds = 0.35f; float SettleSeconds = 0.35f;
+126 -231
View File
@@ -239,289 +239,184 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
## 2026-11-08 — reference photography gaps closed (local-only) ## 2026-11-08 — reference photography gaps closed (local-only)
- Closed the two missing reference angles for the hero façade, both from Wikimedia - Closed the two missing reference angles for the hero façade, both from Wikimedia Commons (gitignored deliverables under `Art/reference/`, provenance in sidecars):
Commons (gitignored deliverables under `Art/reference/`, provenance in sidecars): - `el-capitan-entrance/` — `File:Hollywood El Capitan entrance.jpg` (+ detail), Andreas Praefcke, CC BY 3.0. This is the isolated hero (6840) entrance/door recess that the notes doc had claimed Commons didn't hold. Query that found it: `--query "El Capitan entrance"`.
- `el-capitan-entrance/` — `File:Hollywood El Capitan entrance.jpg` (+ detail), - `el-capitan-night/` — `File:El Capitan theater with Tron marquee.jpg` (CC BY-SA 3.0) + West Side Story premiere (Adam Jones, CC BY 2.0). The theatre is the building NEXT to 6840; treat as proximity evidence only, not the hero.
Andreas Praefcke, CC BY 3.0. This is the isolated hero (6840) entrance/door - Commons throttles hard: HTTP 429 after a batch. Fetch fewer files with `--delay`/separate calls and wait ~15-20s between runs (`queue.py` tool default 1 req/s is insufficient once rate limited).
recess that the notes doc had claimed Commons didn't hold. Query that found it: - Task was claimed on branch `ref-photo-0.1.2`; delivers only the TODO closure (all actual reference files are gitignored, so no code branch content). Reported to coordinator for merge/cleanup.
`--query "El Capitan entrance"`.
- `el-capitan-night/` — `File:El Capitan theater with Tron marquee.jpg`
(CC BY-SA 3.0) + West Side Story premiere (Adam Jones, CC BY 2.0). The theatre is
the building NEXT to 6840; treat as proximity evidence only, not the hero.
- Commons throttles hard: HTTP 429 after a batch. Fetch fewer files with
`--delay`/separate calls and wait ~15-20s between runs (`queue.py` tool default
1 req/s is insufficient once rate limited).
- Task was claimed on branch `ref-photo-0.1.2`; delivers only the TODO closure (all
actual reference files are gitignored, so no code branch content). Reported to
coordinator for merge/cleanup.
## 2026-11-08 — security-game scan: concepts copied, licensing ignored (owner) ## 2026-11-08 — security-game scan: concepts copied, licensing ignored (owner)
- Scanned `/run/media/system/DATA/Documents/06-games/security-game` for reference - Scanned `/run/media/system/DATA/Documents/06-games/security-game` for reference material vs our `Art/reference/`. Result: its **photo set** (`docs/screens/refs/ hollywood/`, 9 files) is already fully duplicated in our `Art/reference/hollywood/` (with SIDECAR/BOARD copies) — nothing new there.
material vs our `Art/reference/`. Result: its **photo set** (`docs/screens/refs/ - What we lacked: its **AI concept renders** (`docs/screens/concepts/ hollywood_boulevard/`, 12 PNGs + `.refgen.json`). Copied into `Art/reference/el-capitan-concepts/` with a folder README. **Owner explicitly said ignore licensing completely** — they are gitignored local development reference, never committed or shipped; `.refgen.json` still records the model and source photo per file for traceability.
hollywood/`, 9 files) is already fully duplicated in our `Art/reference/hollywood/` - Use concepts for mood/lighting intent only; geometry truth stays with the photo refs (facade, entrance, night marquee, TCL/Tussauds, Masonic 1922, etc.).
(with SIDECAR/BOARD copies) — nothing new there. - The security-game `realism/` A/B screenshots and `concepts/joke_shop/` are its own engine/venue renders — not relevant to our 6840 modeling.
- What we lacked: its **AI concept renders** (`docs/screens/concepts/
hollywood_boulevard/`, 12 PNGs + `.refgen.json`). Copied into
`Art/reference/el-capitan-concepts/` with a folder README. **Owner explicitly
said ignore licensing completely** — they are gitignored local development
reference, never committed or shipped; `.refgen.json` still records the model
and source photo per file for traceability.
- Use concepts for mood/lighting intent only; geometry truth stays with the photo
refs (facade, entrance, night marquee, TCL/Tussauds, Masonic 1922, etc.).
- The security-game `realism/` A/B screenshots and `concepts/joke_shop/` are its
own engine/venue renders — not relevant to our 6840 modeling.
## 2026-11-08 — ODbL review closed; remaining active TODO items finished ## 2026-11-08 — ODbL review closed; remaining active TODO items finished
- Wrote `Docs/odbl-review.md` (decision 2026-11-08): the shipped block geometry - Wrote `Docs/odbl-review.md` (decision 2026-11-08): the shipped block geometry (streets/footprints/sidewalks) is a Produced Work of the OpenStreetMap database (ODbL 1.0). Obligations held: on-screen attribution via AGigLineHUD ("© OpenStreetMap contributors" + "Data © OSM, ODbL 1.0 — openstreetmap.org/copyright"), ODbL link, and the raw extract committed in Geo/ with a manifest (date, bbox, SHA-256). Share-alike is limited to the data layer; project code/art remain CC0 (Docs/licensing.md decision). 3DEP DEM is US Government public domain, unrelated to ODbL.
(streets/footprints/sidewalks) is a Produced Work of the OpenStreetMap database - Closed both [A] items in TODO: (1) sidewalks/roads/collision — collision verified; remaining kerb radii/aprons/kerb-height explicitly tracked as 0.1.1 patch tasks (Sidewalk outer edge, Crossing ramp gradient, Kerb height); (2) ODbL review — recorded. The [D] Blosm-import (blockout cleaning) item stays deferred (tracked separately).
(ODbL 1.0). Obligations held: on-screen attribution via AGigLineHUD ("©
OpenStreetMap contributors" + "Data © OSM, ODbL 1.0 — openstreetmap.org/copyright"),
ODbL link, and the raw extract committed in Geo/ with a manifest (date, bbox,
SHA-256). Share-alike is limited to the data layer; project code/art remain CC0
(Docs/licensing.md decision). 3DEP DEM is US Government public domain, unrelated
to ODbL.
- Closed both [A] items in TODO: (1) sidewalks/roads/collision — collision
verified; remaining kerb radii/aprons/kerb-height explicitly tracked as 0.1.1
patch tasks (Sidewalk outer edge, Crossing ramp gradient, Kerb height);
(2) ODbL review — recorded. The [D] Blosm-import (blockout cleaning) item stays
deferred (tracked separately).
- Branch: odbl-review-0.1.0 (docs + TODO closure only). - Branch: odbl-review-0.1.0 (docs + TODO closure only).
## 2026-11-08 — placement release check; north-buildings camera was 9.5 km off ## 2026-11-08 — placement release check; north-buildings camera was 9.5 km off
- Release check 2 (placement vs references): rendered demo, probed DemoMap world - Release check 2 (placement vs references): rendered demo, probed DemoMap world positions, compared to Geo references. Matches: road 24.3 m (ref crossings 20.9-25.9), sidewalk centreline offsets 12.5/13.2 m (ref 12.6/13.6), hero frontage 26.0 m / setback ~13 m (ref 25.75/14.5), six south-frontage buildings
positions, compared to Geo references. Matches: road 24.3 m (ref crossings - TCL/Tussaud's in verified OSM positions.
20.9-25.9), sidewalk centreline offsets 12.5/13.2 m (ref 12.6/13.6), hero - **Bug found:** the `demo-north-buildings` camera sat at X -22800 (the buildings' own X), aiming at its own position -> 85%-sky screenshot. Probed real geometry: TCL bbox X -22889..-18442, Y -9431..-1802; Tussaud's X -27071..-22799, Y -7586..-1888; hero at X -13336, Y +1307 (frontage 26 m). Camera now at (X -22750, Y +1600) looking north; sky fraction 85% -> 41%.
frontage 26.0 m / setback ~13 m (ref 25.75/14.5), six south-frontage buildings - Getting actor world bounds: spawn at loc(0,0,0) means static mesh bbox IS the world footprint (FBX carries the geo offset). Probe by loading the level explicitly (load_level) — a fresh commandlet starts on Untitled otherwise.
+ TCL/Tussaud's in verified OSM positions.
- **Bug found:** the `demo-north-buildings` camera sat at X -22800 (the buildings'
own X), aiming at its own position -> 85%-sky screenshot. Probed real geometry:
TCL bbox X -22889..-18442, Y -9431..-1802; Tussaud's X -27071..-22799,
Y -7586..-1888; hero at X -13336, Y +1307 (frontage 26 m). Camera now at
(X -22750, Y +1600) looking north; sky fraction 85% -> 41%.
- Getting actor world bounds: spawn at loc(0,0,0) means static mesh bbox IS the
world footprint (FBX carries the geo offset). Probe by loading the level
explicitly (load_level) — a fresh commandlet starts on Untitled otherwise.
## 2026-11-08 — collision/seam audit done; QA self-test extended ## 2026-11-08 — collision/seam audit done; QA self-test extended
- `-qaselftest` transect widened from 84 samples (3 lane centres) to **1456 - `-qaselftest` transect widened from 84 samples (3 lane centres) to **1456 samples / 16 rows** including kerb/road/sidewalk seam Ys (probed: N side -1912..-1053, kerb -1382..-1053, road -1382..+1047, kerb +718..+1047, S side +718..+1447). 0 holes.
samples / 16 rows** including kerb/road/sidewalk seam Ys (probed: N side - **New wall probe:** ground-referenced horizontal 30 m trace at +160 cm. First attempt used a fixed Z 160 and started INSIDE the earth (street is at Z ~366) -> every row reported a dist-0 blocker. Always ground the probe first.
-1912..-1053, kerb -1382..-1053, road -1382..+1047, kerb +718..+1047, S side - **Guard:** GigLineShotActor must skip rounds under `-qaselftest`, else bQuitWhenDone closes the game before the transect phase (observed: game exited at t=2s with "ShotActor: done (4 shots)" + RequestExit).
+718..+1447). 0 holes. - Results on packaged build: walk 761cm/2s, reset 0cm, pause/unpause, transect 0 holes, wall 0/35 -> all PASS. Evidence recorded in Docs/release-checks.md.
- **New wall probe:** ground-referenced horizontal 30 m trace at +160 cm. First
attempt used a fixed Z 160 and started INSIDE the earth (street is at Z ~366)
-> every row reported a dist-0 blocker. Always ground the probe first.
- **Guard:** GigLineShotActor must skip rounds under `-qaselftest`, else
bQuitWhenDone closes the game before the transect phase (observed: game exited
at t=2s with "ShotActor: done (4 shots)" + RequestExit).
- Results on packaged build: walk 761cm/2s, reset 0cm, pause/unpause, transect
0 holes, wall 0/35 -> all PASS. Evidence recorded in Docs/release-checks.md.
## 2026-11-08 — setup logs gated behind a cvar (diagnostics item) ## 2026-11-08 — setup logs gated behind a cvar (diagnostics item)
- The remaining spawn/input setup logs (settle-timer "start captured", "GigLine - The remaining spawn/input setup logs (settle-timer "start captured", "GigLine IN:" pushed/bound/errors) are now gated behind `gigline.SetupLogs` (TAutoConsoleVariable, default **0** via `DefaultEngine.ini [ConsoleVariables]`). Re-enable at runtime: `gigline.SetupLogs 1`.
IN:" pushed/bound/errors) are now gated behind `gigline.SetupLogs` - The `-qaselftest` QA logs stay ungated (deliberate release-check feature per Docs/release-checks.md). Verified packaged build: 0 setup-log lines, QA suite still all PASS (walk/reset/pause/transect 0 holes/wall 0-35).
(TAutoConsoleVariable, default **0** via `DefaultEngine.ini [ConsoleVariables]`). - Branch qa-diagnostics-0.1.0. SIM/GROUND probes were removed earlier in the DefaultKeyMappings fix (PR #8).
Re-enable at runtime: `gigline.SetupLogs 1`.
- The `-qaselftest` QA logs stay ungated (deliberate release-check feature per
Docs/release-checks.md). Verified packaged build: 0 setup-log lines, QA suite
still all PASS (walk/reset/pause/transect 0 holes/wall 0-35).
- Branch qa-diagnostics-0.1.0. SIM/GROUND probes were removed earlier in the
DefaultKeyMappings fix (PR #8).
## 2026-11-08 — v0.1.0 released ## 2026-11-08 — v0.1.0 released
- All six `0.1.0` release checks pass and are recorded in `Docs/release-checks.md`: - All six `0.1.0` release checks pass and are recorded in `Docs/release-checks.md`: traversal (seam transect 0 holes), placement vs references, collision+packaging (wall probe 0/35), 60 fps at 1440p (2.42 ms avg; TSR on, VSync off, no FG), fresh-checkout build, packaged Linux build with controls shown in-game.
traversal (seam transect 0 holes), placement vs references, collision+packaging - `CHANGELOG.md` gets the `0.1.0` entry; the completed `0.1.0` section was removed from `TODO.md` (goal stays in `ROADMAP.md`); tag `v0.1.0` cut on `main`.
(wall probe 0/35), 60 fps at 1440p (2.42 ms avg; TSR on, VSync off, no FG),
fresh-checkout build, packaged Linux build with controls shown in-game.
- `CHANGELOG.md` gets the `0.1.0` entry; the completed `0.1.0` section was removed
from `TODO.md` (goal stays in `ROADMAP.md`); tag `v0.1.0` cut on `main`.
- Owner approved the regenerated screenshot set (PR #15) on 2026-11-08. - Owner approved the regenerated screenshot set (PR #15) on 2026-11-08.
- THIRD_PARTY_NOTICES switched to checkbox statuses (`[x]`/`[P]`/`[ ]`); all tools - THIRD_PARTY_NOTICES switched to checkbox statuses (`[x]`/`[P]`/`[ ]`); all tools incl. Blosm, QGIS and Git LFS are now verified as tool-only.
incl. Blosm, QGIS and Git LFS are now verified as tool-only. - Next work: the `0.1.1` collision-detail patch (sidewalk outer edge 15.8 cm step, crossing ramp gradient 10 %, kerb height assumption, traversal regression).
- Next work: the `0.1.1` collision-detail patch (sidewalk outer edge 15.8 cm step,
crossing ramp gradient 10 %, kerb height assumption, traversal regression).
## 2026-11-08 — v0.1.1 released (collision patch) ## 2026-11-08 — v0.1.1 released (collision patch)
- Sidewalk upstand now tapers full-at-kerb → zero at the building edge (removes the - Sidewalk upstand now tapers full-at-kerb → zero at the building edge (removes the 15.8 cm outer ledge). Crossing ramp run derives from `--max-ramp-grade` (8 % ⇒ 1.63 m) instead of a fixed 1.5 m/8.7 %. Street + site rebuilt, site re-imported, DemoMap rebuilt, packaged build verified.
15.8 cm outer ledge). Crossing ramp run derives from `--max-ramp-grade` (8 % ⇒ - Pre-check tool fixes: coverage no longer shrinks with `--sample` (it used `range(int(block_len))`), and it reports grade + location of the worst step.
1.63 m) instead of a fixed 1.5 m/8.7 %. Street + site rebuilt, site re-imported, - Residual, documented not hidden: 13.4 cm surface step at s≈198 (south) / 8.0 cm at s≈251 (north) — a strip discontinuity near a centreline vertex, below the 45 cm MaxStepHeight, so not blocking.
DemoMap rebuilt, packaged build verified. - Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM and photos can't resolve finer).
- Pre-check tool fixes: coverage no longer shrinks with `--sample` (it used
`range(int(block_len))`), and it reports grade + location of the worst step.
- Residual, documented not hidden: 13.4 cm surface step at s≈198 (south) / 8.0 cm
at s≈251 (north) — a strip discontinuity near a centreline vertex, below the
45 cm MaxStepHeight, so not blocking.
- Kerb height finalised at 0.15 m modelled / 0.13 m built (6 in LA standard; DEM
and photos can't resolve finer).
## 2026-11-09 — Mapbox building data as a second height source ## 2026-11-09 — Mapbox building data as a second height source
- `Geo/tools/mapbox_buildings.py` (new, stdlib `urllib`) Tilequeries - `Geo/tools/mapbox_buildings.py` (new, stdlib `urllib`) Tilequeries `mapbox.mapbox-streets-v8` layer `building` at every building centroid in the extract and merges the answer with our OSM height + source. Token comes from `MAPBOX_ACCESS_TOKEN` in the ignored `.env`; responses cache to `Geo/raw/mapbox-cache/`.
`mapbox.mapbox-streets-v8` layer `building` at every building centroid in the - **Mapbox terms restrict persisting/redistributing their data**, so `.gitignore` now covers `/Geo/raw/mapbox-cache/` and `/Geo/verification/*mapbox*`. The tool also refuses to cache a response that contains the token.
extract and merges the answer with our OSM height + source. Token comes from
`MAPBOX_ACCESS_TOKEN` in the ignored `.env`; responses cache to
`Geo/raw/mapbox-cache/`.
- **Mapbox terms restrict persisting/redistributing their data**, so `.gitignore`
now covers `/Geo/raw/mapbox-cache/` and `/Geo/verification/*mapbox*`. The tool
also refuses to cache a response that contains the token.
- **First run (72 buildings): 58 match, 5 close, 3 disagree, 6 non-building.** - **First run (72 buildings): 58 match, 5 close, 3 disagree, 6 non-building.**
- It independently classifies our roof/canopy/shelter volumes as `roof`/ - It independently classifies our roof/canopy/shelter volumes as `roof`/ `shelter` — corroborating the `0.1.2` finding that they are not buildings.
`shelter` — corroborating the `0.1.2` finding that they are not buildings. - Remaining disagreements are untyped `building=yes`/`apartments` rear structures where our fallback guess (2 × 3.2 m = 6.4 m) meets Mapbox's 3.0 m.
- Remaining disagreements are untyped `building=yes`/`apartments` rear - Every "close" case is a `building:levels × 3.2 m` building where Mapbox is **0.6 m lower per 6 levels** → Mapbox implies **3.1 m per level**, not 3.2. Worth revisiting `LEVEL_HEIGHT` if a measured storey height ever lands.
structures where our fallback guess (2 × 3.2 m = 6.4 m) meets Mapbox's 3.0 m.
- Every "close" case is a `building:levels × 3.2 m` building where Mapbox is
**0.6 m lower per 6 levels** → Mapbox implies **3.1 m per level**, not 3.2.
Worth revisiting `LEVEL_HEIGHT` if a measured storey height ever lands.
- Its query API surface: `height`, `min_height`, `type`, `extrude` per polygon. - Its query API surface: `height`, `min_height`, `type`, `extrude` per polygon.
## 2026-11-09 — Mapbox calibration adopted into the blockout ## 2026-11-09 — Mapbox calibration adopted into the blockout
- Owner decision: adopt Mapbox. Applied: - Owner decision: adopt Mapbox. Applied:
- `LEVEL_HEIGHT` 3.2 → **3.1 m** in `Geo/tools/block_height_report.py`, and the - `LEVEL_HEIGHT` 3.2 → **3.1 m** in `Geo/tools/block_height_report.py`, and the Blosm `--level-height` default in `Art/blender/blockout/blosm_blockout.py`.
Blosm `--level-height` default in `Art/blender/blockout/blosm_blockout.py`. - `HEIGHT_OVERRIDES` in block_height_report: ways 1490910971, 1492058875, 1492747608 → 3.0 m (Mapbox single-storey; the 2-level guess gave 6.4 m).
- `HEIGHT_OVERRIDES` in block_height_report: ways 1490910971, 1492058875, - `classify_height(tags, level_height, way_id)` now takes the way id so overrides apply; `mapbox_buildings.py` passes it, so the cross-check agrees with the height table.
1492747608 → 3.0 m (Mapbox single-storey; the 2-level guess gave 6.4 m).
- `classify_height(tags, level_height, way_id)` now takes the way id so
overrides apply; `mapbox_buildings.py` passes it, so the cross-check agrees
with the height table.
- Re-running the cross-check: **66 match, 6 non-building, 0 close/disagree**. - Re-running the cross-check: **66 match, 6 non-building, 0 close/disagree**.
- Geometry: blockout regenerated at 3.1 (object names preserved — the frontage - Geometry: blockout regenerated at 3.1 (object names preserved — the frontage exporter maps `element.020`/`element`/`element.019`/named buildings, all still present, 6/6 exported), frontage re-imported, DemoMap rebuilt, packaged build `-qaselftest` all PASS.
exporter maps `element.020`/`element`/`element.019`/named buildings, all still - **Blosm adds roof mass** on top of `levels × levelHeight`: 6922 39.1 m vs table 37.2, El Capitan Theatre 20.5 m vs 18.6 — both ~+1.9 m. That is a roof, not a height error; the table is the tagged height, the mesh is storey + roof.
present, 6/6 exported), frontage re-imported, DemoMap rebuilt, packaged build - 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.
`-qaselftest` all PASS.
- **Blosm adds roof mass** on top of `levels × levelHeight`: 6922 39.1 m vs table
37.2, El Capitan Theatre 20.5 m vs 18.6 — both ~+1.9 m. That is a roof, not a
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 %) ## 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 - 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.
14.50 m elevation = **47.6 %**, below the photographed 50–60 %, because the - 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).
entablature (3.40 m) and parapet (2.20 m) used 5.60 m of the height. - **Gotcha (cost a bad build):** `hero_facade_build.py` takes `--out` for the **FBX** and `--save-blend` for the `.blend`. Passing `--out <file>.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`.
- Thinned ENTABLATURE_H 3.40 → 3.00 and PARAPET_H 2.20 → 1.80 in - 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.
`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 <file>.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 ## 2026-11-09 — AI detail plates from reference photos; floating windows fixed
- `Tools/facade_detail_concepts.py` generates architectural detail plates from one - `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/`.
local reference photograph via OpenRouter image models (default - **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.
`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. - **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 - **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`.
mass front is Y −0.50 and the columns reach −1.02 — i.e. glazing floating 1.44 m - 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).
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 ## 2026-11-09 — UE export hygiene: smoothing groups + facade collision
- **All our FBX exporters used `mesh_smooth_type="OFF"`**, which is why every Unreal - **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).
import logged "No smoothing group information was found for this mesh". The hero - **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).
now exports `"FACE"` and its round parts are shaded with - **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.
`shade_smooth_by_angle(35°)` before export: cylinders read as round instead of - 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.
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 ## 2026-11-09 — one FBX export preset instead of six copies
- The `bpy.ops.export_scene.fbx(...)` argument block was duplicated in six scripts - 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.
(hero, block frontage, north buildings, site, scale test, MPFB character), and - 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.
all six carried `mesh_smooth_type="OFF"` — hence the "No smoothing group - 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.
information was found" warning on every import. - 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).
- Now `Tools/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`: - `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.
`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 ## 2026-11-09 — v0.1.2 released
- Cornice finished: projecting corona over a **52-dentil course** (0.18 × 0.30 × - 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).
0.22 m at 0.45 m centres, z 12.12–12.34, 0.25 m clear of the corona). Hero is now - `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.
3 044 tris across 148 parts, still 14.50 m. Verified: packaged `-qaselftest` all - Assumptions recorded rather than hidden: dentil spacing, base and abacus dimensions are not resolvable in our reference photography.
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) ## 2026-11-09 — elevation cameras levelled (straight verticals)
- archviz MUST: an elevation must not lean. `hero-facade-front` looked up ~4° from - 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.
4.2 m at a 7.6 m target; `demo-spawn-view` and `demo-street-level` were pitched - 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.
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.
## 2026-11-09 — 0.1.3 packaging/setup fixes ## 2026-11-09 — 0.1.3 packaging/setup fixes
- **`Tools/ue_common.py`**: `make_writable()` clears the read-only bit an LFS - **`Tools/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0.
`.umap` checks out with (otherwise `save_map()` returns False with no error and - Gotcha while adding it: `ue_hero_facade_import.py` had **no** `import os`/`sys` (only `unreal`), so the sys.path line raised `NameError: name 'sys' is not defined` inside the commandlet — an exit code of 255 with no obvious cause.
the staged actors vanish), and `require_saved()` makes that failure visible. - Docs: `build.md` gained the current pipeline table (7 site FBXs, frontage, north, hero, map), the `Builds/GigLineQA` archive, the packaged-log path, `-qaselftest` usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and documents the two optional API keys; architecture 07 + combined doc corrected the build-output path.
`ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this —
which is why importing the hero appeared to succeed while saving nothing. ## 2026-11-09 — 0.1.4 materials + architecture-camera pass
Verified by forcing `chmod u-w` on DemoMap first: the run logged
`map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0. - **Material graphs**: `ensure_material()` now supports masonry coursing (`courses_m`) and roughness variation. **UE's `WorldPosition` is in CENTIMETRES**, so a course height in metres must be ×100 — the first attempt divided by 0.55 and produced coursing 100× too fine to see.
- Gotcha while adding it: `ue_hero_facade_import.py` had **no** `import os`/`sys` - **`connect_material_expressions` returns False instead of raising**, so an unknown pin name leaves an input unconnected and the material silently compiles with a default (a mask of 0 is invisible). All links now go through a `link()` helper that logs failures. There is also **no `MaterialExpressionLess`**; use `Frac → Divide → OneMinus → Clamp` for a soft threshold band, or `MaterialExpressionStep`/`SmoothStep` if a hard one is wanted.
(only `unreal`), so the sys.path line raised `NameError: name 'sys' is not - **Material assignment was slot-0-only**, which on the hero is the _banner_ — the stone, shadowed stone and glazing kept their flat Blender materials. Assignment is now per slot, keyed on the imported slot material's name (`glass`/`banner`/`stoneshadow`/`stone`/`roof`/`wall`). Hero: 4/4 slots.
defined` inside the commandlet — an exit code of 255 with no obvious cause. - New materials: `M_FacadeStoneShadow` (same coursing, a stop darker), `M_FacadeGlass` (roughness 0.14, specular 0.62 ≈ IOR 1.5 expressed without a refraction pass), `M_FacadeBanner` (the one saturated surface).
- Docs: `build.md` gained the current pipeline table (7 site FBXs, frontage, north, - **Camera FOV**: levelling the elevation cameras left the building small in frame (39 % sky). Added optional per-shot `ShotFieldOfView` to `AGigLineShotActor` and a 4th element on the shot tuples; a long lens (42–55°) fills the frame from across the street while the camera stays level, so verticals stay parallel. `review-hero-facade-front` sky 39 → 20 %, `demo-spawn-view` 23 → 16 %.
hero, map), the `Builds/GigLineQA` archive, the packaged-log path, `-qaselftest`
usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and ## 2026-11-09 — facade texture maps (0.1.4)
documents the two optional API keys; architecture 07 + combined doc corrected the
build-output path. - `Tools/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum).
- `ue_build_demo_map.py` imports them to `/Game/Hollywood/Textures` and samples them **world-aligned**: WorldPosition (cm) → ×(1/tile_cm) → ComponentMask(R,G) → TextureSample UVs. `tile_cm` per material: stone 440, stucco 260, asphalt 900, concrete 250.
- Gotchas: `MaterialExpressionTextureSample`'s UV input pin is **"UVs"**, and the output pin for colour is **"RGB"** / roughness **"R"**. `ComponentMask` pins are R/G/B/A booleans. A textured material skips the graph coursing on purpose — overlaying both would double the course pattern.
- The script needs `ROOT` in `ue_build_demo_map.py` and `sys` imported; forgetting either fails with `NameError` and an exit code of 0 from the commandlet (always read the result file under /tmp/gigline).
## 2026-11-09 — façade module kit (0.1.4)
- `Tools/facade_kit_build.py` builds the kit at the measured **4.96 m bay** and **3.10 m storey**: pilaster, sill, lintel, window bay, shopfront bay, door bay, cornice, parapet + coping = 16 meshes, exported per-object with `fbx_export_preset` plus a JSON part report (dims + evidence per piece).
- `Tools/ue_import_facade_kit.py` → `/Game/Hollywood/Kit/*`, complex-as-simple collision (a convex hull would fence off the shopfront glazing).
- `Tools/ue_apply_facade_kit.py` lays the kit out from each building's own bbox: `bays = round(width / 496 cm)`, storeys from height minus the 310 cm ground floor. **638 pieces** over six frontage buildings + both north buildings.
- **Bug worth remembering:** the first window piece was modelled entirely at +Y (into the wall), so every window sat _inside_ the solid blockout wall and was invisible in every render while the placement maths looked right. The kit now projects the surround 3 cm proud with the glazing 6 cm behind its face — a solid-wall blockout cannot carry a real recess, so the piece has to project.
- Blender gotcha: clearing the scene _including materials_ invalidates handles already taken ("StructRNA of type Material has been removed"); clear objects and orphan meshes only, and create the material after the clear.
- The kit meshes are spawned at the world origin with their position baked into the mesh (like the frontage exports), so placement uses the bbox directly and must not add the actor location.
## 2026-11-09 — CRITICAL: blockout must be regenerated with --origin
- Regenerating the blockout in 0.1.2 (for the 3.1 m storey height) was run **without `--origin`**, so Blosm centred on the extract instead of fitting the project frame. The whole frontage shifted about **119 m in X and 2.4 m in Y**, and the hero ended up outside the 6902/6838 gap — the "verified placement" was undone silently, and every render still looked like a plausible block.
- Correct invocation (README documents it, and the affine fit prints `frame correction residual: max 0.0 cm`): `blosm_blockout.py --osm … --bounds 34.1006,-118.3422,34.1020,-118.3370 --level-height 3.1 --origin 34.101545,-118.338718 --assets-dir … --out …`
- After re-export + re-import, the frontage spans are back to the verified values: 6922 −24276..−17700, 6902 −17749..−14591, **hero −14644..−12028 between them**, theatre −12082..−8210, and the north buildings at −22889..−18442 / −27071..−22799.
- **`ue_import_north_buildings.py` also lacked `import sys`**, so its sys.path line raised NameError and the import silently did nothing (exit 255). Same trap as the hero importer: these scripts imported only `unreal`.
- Lesson: after any blockout regeneration, probe the staged buildings' world X spans and compare them with `Geo/verification/2026-11-06-el-capitan-block.md` before trusting a render. A shift of this size is invisible in a screenshot.
## 2026-11-09 — kit naming + topology validation (blender-modeler checklist)
- Kit assets now follow the Unreal convention: the Blender object keeps its readable name (`Kit_Pilaster`), the exported asset and file are `SM_Kit_Pilaster`. The kit loader strips the `SM_` prefix so the layout code still addresses pieces by short name.
- `facade_kit_build.py::validate()` runs the blender-modeler cleanup checklist on every piece before handoff — non-manifold edges, loose vertices, triangle count — and records the result in the part report. Current kit: 16 pieces, **all manifold, no loose geometry, 12 tris each**.
- `ue_import_facade_kit.py` now deletes kit assets that the current build no longer produces, so a rename cannot leave two generations of the same piece in the folder (the rename from `Kit_*` to `SM_Kit_*` did exactly that until it did).
## 2026-11-09 — visual comparison loop; screenshots are demo-only
- `Tools/visual_loop.py` closes the reference → render → feedback loop. `--verify` gates captures against `Tools/visual_targets.json` (blank, tone-less, wrong-size fail); `--compare` measures luma, RMS contrast, edge density, palette distance and a 3x3 luma grid against the reference and prints a 0–1 score; `--iteration N` writes `Art/reference/iterations/N/{metrics.json,feedback.md}`. The feedback names the skill to consult, so the next modelling pass starts from a measurement rather than an opinion.
- **Iterations are gitignored** (`/Art/reference/iterations/`); only confirmed shots are promoted into `Screenshots/`. A test asserts the ignore rule and that every target is a demo-map shot.
- `Screenshots/` now holds **demo-map captures only** — the review staging map is no longer rendered for review. `render_screenshots.sh --verify` renders the demo map, collects the shots and runs the verification gate in one step.
- **PIL gotchas:** `FIND_EDGES` leaves its own border ring at 128 (236/3600 px on a 60x60 frame = 6.5% "detail" on a perfectly flat image), so crop **after** filtering — cropping before does not help, the new border gets the same treatment. Palette shares must divide by the full-image area, not the cropped one.
- First comparison (iteration 2): spawn view 0.70, street level 0.35, crossing 0.41, north buildings 0.34. Edge density is the dominant gap everywhere (0.04–0.11 against 0.20–0.32 in the references) — the render still lacks the relief the photographs carry, which is exactly what the `0.1.4` façade work is for.
## 2026-11-09 — theatre marquee + blade sign
- `Tools/theatre_trim_build.py` builds the El Capitan Theatre's signature features in **world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket. `Tools/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one actor per piece (single spawn at the origin — no placement arithmetic).
- **Two placement bugs, both invisible in a render:**
1. **Axis sign.** Blender +Y is Unreal −Y, so a face at Unreal Y +1001 is Blender Y −10.01. Using +10.01 put the whole marquee on the far side of the boulevard.
2. **Absolute Z.** Trim heights were absolute while the pavement at the theatre is 4.56 m above the project datum, so the canopy hung 5 cm above the pavement — the QA wall probe caught it as a knee-height blocker. `GROUND_Z` now offsets every height.
- Wall probe: a scan row at Y 1000 sat ~1 cm from the frontage building line and grazed the theatre wall once the kit's proud shopfronts went in. Rows now sample the street envelope only (−1400, −167, 800, −1382, 718).
## 2026-11-09 — 0.1.4 signed off; 0.1.5 created for the carried geometry
- Owner reviewed the four demo captures locally and approved the set. Signoff recorded in `Docs/reviews/0.1.4-review.md` (7/7 items) and in the per-shot `Approved` column of `Screenshots/README.md`.
- The three geometry items that `0.1.4` did not finish — retail neighbours from their own references, hero fluting/motto band, blockout cleaning — move to a new **`0.1.5` Façade finishing** patch rather than growing this one, plus a much-needed **1440p performance re-measure** (625 kit actors have never been measured; `0.1.0` measured 414 fps).
- Decision recorded because it sets a precedent: finish and sign off a version when its _stated_ scope is done, and carry leftover scope into the next patch — do not let a patch accumulate unmeasured geometry.
+33 -45
View File
@@ -1,63 +1,51 @@
# Screenshots — visual review # Screenshots — demo-map visual review
Rendered in-game captures of the current project content, generated by [`Tools/render_screenshots.sh`](../Tools/render_screenshots.sh). The PNGs are committed alongside this README so a reviewer sees exactly what the build rendered. Rendered captures of **the demo map only** — what a player sees when the demo runs. Generated by [`Tools/render_screenshots.sh`](../Tools/render_screenshots.sh); the PNGs are committed so a reviewer sees exactly what the build rendered.
The review staging map (`/Game/Test/ReviewMap`) is not part of this set: it exists to hold the camera list and the hero detail rig, and it is not what ships.
## How to regenerate ## How to regenerate
```bash ```bash
Tools/render_screenshots.sh Tools/render_screenshots.sh --verify
``` ```
Rebuilds `Game/GigLine/Content/Test/ReviewMap.umap` from current content, runs the game on it (and on the DemoMap for the walkable views), walks the fixed camera list via `AGigLineShotActor`, captures each shot, copies the PNGs here and exits — about a minute. The camera list lives in `Tools/ue_build_review_map.py`; add new shots there, remove retired ones here. Stages the demo shot cameras, runs the game on `/Game/Hollywood/DemoMap`, copies the PNGs into `Screenshots/demo/` and then runs [`Tools/visual_loop.py`](../Tools/visual_loop.py) `--verify` to check that each shot still shows the content its expectation describes. About a minute.
> Naming: shots use their stable camera name (no index prefix), so adding or reordering cameras never renumbers files or orphans the old ones. Click an image for the full-size PNG. ## The shot list
## Shot list Each shot has an **expectation** in [`Tools/visual_targets.json`](../Tools/visual_targets.json) — the content the capture must show — and a verification state produced by `--verify`: it fails if the frame is blank, the wrong size, or has no tonal range.
**Status: whole set ☑ approved 2026-11-09** (owner review of PR #27). Every shot was re-rendered from current `main` content after the building-height calibration was adopted (`v0.1.2`): Mapbox-derived **3.1 m per level** (`Geo/tools/block_height_report.py`), explicit overrides for three rear outbuildings, and the regenerated blockout and frontage that follow — 6922 is now 39.1 m (was 42.1 m) and the El Capitan Theatre 20.5 m (was 20.0 m). | # | File | View | Expected content | Verified | Approved |
| --- | --- | --- | --- | --- | --- |
| 1 | `demo/review-demo-spawn-view.png` | north sidewalk | The whole south block: the neoclassical hero façade between its neighbours, both sidewalks and the road in the foreground | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 2 | `demo/review-demo-street-level.png` | eye height, north side | Street level: the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 3 | `demo/review-demo-crossing.png` | along the crosswalk | The designated mid-block crossing (s=130 m, UE X −14956): zebra bars on the asphalt, south frontage and colonnade behind | ☑ 2026-11-09 | ☑ 2026-11-09 |
| 4 | `demo/review-demo-north-buildings.png` | across the Boulevard | The two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) from the south side, full height in frame | ☑ 2026-11-09 | ☑ 2026-11-09 |
| Round | What changed | Approval is per shot, in the table above (owner review of the `0.1.4` set, recorded in [`Docs/reviews/0.1.4-review.md`](../Docs/reviews/0.1.4-review.md)). Add a row when a shot is added; remove the file in the same change when a shot is retired.
| --- | --- |
| 2026-11-07 | First approved set; block frontage pass corrected hero and north-building placement |
| 2026-11-08 | Re-rendered after `0.1.1` street geometry (sidewalk upstand flush at the building edge, crossing ramps graded ≤ 8 %); `demo-north-buildings` camera re-pointed (was 9.5 km west, 85 % sky) |
| 2026-11-09 | Re-rendered after the `0.1.2` building-height calibration (3.1 m per level). 12 of 14 PNGs changed; `review-demo-spawn-view` and `review-demo-street-level` are byte-identical to the `0.1.1` round, so the height change reads mostly on the frontage silhouettes |
| # | File | Preview | Map | View | What it shows | ☑ | ## The iteration loop (uncommitted)
| --- | --- | --- | --- | --- | --- | --- |
| 1 | `review-overview.png` | [<img src="review-overview.png" alt="overview" width="180">](review-overview.png) | Review | 7 m up, NE | Whole review layout: character + both cubes on the floor | ☑ 2026-11-09 | `Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs:
| 2 | `review-character.png` | [<img src="review-character.png" alt="character" width="180">](review-character.png) | Review | eye height, south | MPFB mannequin, idle pose + warm skin; feet meet the plane | ☑ 2026-11-09 |
| 3 | `review-scale-cubes.png` | [<img src="review-scale-cubes.png" alt="scale cubes" width="180">](review-scale-cubes.png) | Review | south | Floating vs grounded cube: cast vs contact shadow | ☑ 2026-11-09 | ```bash
| 4 | `review-player-placement.png` | [<img src="review-player-placement.png" alt="player placement" width="180">](review-player-placement.png) | Review | spawn | The `AGigLineCharacter` pawn, capsule height and landing | ☑ 2026-11-09 | python3 Tools/visual_loop.py --compare --iteration 3 # writes Art/reference/iterations/3/
| 5 | `review-npc-prefab.png` | [<img src="review-npc-prefab.png" alt="npc prefab" width="180">](review-npc-prefab.png) | Review | mid, west | The `AGigLineNPCPerson` prefab: collision capsule + visible MPFB body, feet on Z 0 | ☑ 2026-11-09 | ```
| 6 | `review-ground-contact.png` | [<img src="review-ground-contact.png" alt="ground contact" width="180">](review-ground-contact.png) | Review | knee height, feet | Floating/sinking character is obvious here | ☑ 2026-11-09 |
| 7 | `review-shadow-compare.png` | [<img src="review-shadow-compare.png" alt="shadow compare" width="180">](review-shadow-compare.png) | Review | low angle | Detached (floating) vs touching (grounded) shadows | ☑ 2026-11-09 | That directory is **gitignored**: iterations stay uncommitted until a shot is confirmed, and only confirmed shots are promoted into `Screenshots/demo/`. The feedback file names the affected skill (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`) so the next modelling pass has a starting point rather than an opinion.
| 8 | `review-hero-portals.png` | [<img src="review-hero-portals.png" alt="hero portals" width="180">](review-hero-portals.png) | Review | 8 m, eye height | Three genuine 1.2 m portal recesses with flights and plinth | ☑ 2026-11-09 |
| 9 | `review-hero-facade-front.png` | [<img src="review-hero-facade-front.png" alt="hero facade front" width="180">](review-hero-facade-front.png) | Review | road side, straight-on | Hero façade elevation and column rhythm | ☑ 2026-11-09 | Note the comparison is only meaningful when the shot and the reference frame the same subject — a wide block view measured against a tight façade crop will always report low edge similarity.
| 10 | `review-hero-facade-oblique.png` | [<img src="review-hero-facade-oblique.png" alt="hero facade oblique" width="180">](review-hero-facade-oblique.png) | Review | north-west | Body depth and column line standing proud of the mass | ☑ 2026-11-09 |
| 11 | `demo/review-demo-spawn-view.png` | [<img src="demo/review-demo-spawn-view.png" alt="spawn view" width="180">](demo/review-demo-spawn-view.png) | Demo | north sidewalk | The whole block: hero façade between its neighbours, both sidewalks and the road | ☑ 2026-11-09 |
| 12 | `demo/review-demo-street-level.png` | [<img src="demo/review-demo-street-level.png" alt="street level" width="180">](demo/review-demo-street-level.png) | Demo | eye height, north side | The hero colonnade with the adjacent frontage buildings either side | ☑ 2026-11-09 |
| 13 | `demo/review-demo-crossing.png` | [<img src="demo/review-demo-crossing.png" alt="crossing" width="180">](demo/review-demo-crossing.png) | Demo | along the crosswalk | The designated mid-block crossing at s=130 m (UE X -14956): three zebra bars on the asphalt, with the south frontage and the hero colonnade behind | ☑ 2026-11-09 |
| 14 | `demo/review-demo-north-buildings.png` | [<img src="demo/review-demo-north-buildings.png" alt="north buildings" width="180">](demo/review-demo-north-buildings.png) | Demo | across the Blvd | North-side buildings (TCL, Tussaud's) seen from the south side across the road. **2026-11-08:** camera re-pointed to the buildings' mass centre (was 9.5 km west → 85% sky); now frames both north buildings. Flat surfaces = `0.1.4` façade scope. | ☑ 2026-11-09 |
## Scene conventions ## Scene conventions
- **Review map** (`/Game/Test/ReviewMap`): 160 m floor, top face at Z 0; the character measures Z 0..83 cm. One warm key light plus sky fill. - **Demo map** (`/Game/Hollywood/DemoMap`): fixed daytime — sun pitch −62 / yaw 120 (intensity 4, warm), sky light 2.6, sky atmosphere, height fog, fixed exposure.
- **Cubes**: `Cube_ScaleTest_A` rests on the floor, `Cube_ScaleTest_B` floats at Z 2 m; both are exactly 1 m — the scale reference. - **Cameras**: elevation shots sit at their target height (pitch 0, so verticals stay parallel) and use a 42–55° lens to fill the frame from across the street.
- **Demo map** (`/Game/Hollywood/DemoMap`): fixed daytime — sun at pitch −50 / yaw 120 (intensity 4, warm), sky light 2.6, sky atmosphere, height fog, fixed exposure. - **Materials**: every surface carries a look-developed material from `Tools/ue_build_demo_map.py` — noise-blended base colour, masonry coursing, and a generated texture set (`Tools/generate_facade_textures.py`) sampled world-aligned. Explicit roughness and specular per surface type.
- **Materials**: every exported surface carries a look-developed material built in `Tools/ue_build_demo_map.py` (`ensure_material`) because Blender's flat diffuse colours do not survive the FBX → Unreal import. Each has an explicit roughness and a subtle world-space noise blend between two shades: `M_Asphalt` (0.92), `M_Concrete` (0.88), `M_KerbConcrete` (0.85), `M_Ground` (0.98), `M_RoadPaint` (0.62), `M_FacadeStone` (0.72) and `M_Stucco` (0.86) for the north-side buildings.
- **Lighting is dynamic-only** (`r.AllowStaticLighting=False`, all lights Movable), so no lighting build is ever required.
## What to look for
- **Ground contact**: feet meet the plane with no gap and no shadow seam.
- **Scale**: everything checks against the 1 m cubes.
- **Façade rhythm**: six columns at 4.96 m across 25.75 m should read as the photographs in silhouette.
- **Frontage heights (new in this round)**: 6922 and the El Capitan Theatre were re-cut to the 3.1 m storey height. Check they still read as the taller west end and the lower cinema respectively against `Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg`, and that the ordering of the six frontage buildings is unchanged.
## Known cosmetic issues ## Known cosmetic issues
- Floor speckle from the `Constant3Vector` wiring in `Tools/ue_build_review_map.py`. - Hero façade renders pale: the stone material is a graph and a texture set, not a scanned surface (the `0.1.4` façade pass is in progress).
- 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 sit on the column line rather than flush to the wall (they hang on the columns in the photographs, so this is acceptable).
- Banners sit on the column line rather than the wall (acceptable: they hang on the columns in the photographs). - Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement.
- ~~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`). - The El Capitan Theatre has no marquee or blade sign yet — the feature that makes it read as itself.
- Between kerb and building line, the walkable surface is the terrain mesh, not a modelled pavement.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -10
View File
@@ -9,20 +9,19 @@ 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. - 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. - 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.4 — Proper building façades (patch) ## 0.1.5 — Façade finishing (patch)
Suggested blender skills: **procedural-modeling** + **geometry-nodes** (the façade module kit — bay, pilaster, cornice, parapet, window, shopfront, door — as reusable node groups), **archviz** (real-world scale and façade composition), **materials** (procedural brick, concrete, stone, render, glass — the ported generators), **texture-workflow** (AO/curvature/normal bakes and decals for the motto band and medallions), **uv-workflow** (texel density and modular UV layouts for the kit), **scene-assembly** (assembling kit pieces onto the fifteen block buildings), **lookdev** (screenshot-comparison loop until shading reads correctly), **historical-worlds** or **realistic-style** (1920s Hollywood Blvd neoclassical direction), **asset-optimization** + **unreal-export** (pre-export validation, `ue_metric_v1` preset), **qa-review** (final gate before the `Tools/render_screenshots.sh` review). Suggested blender skills: **references** (reference-image-match against the photographs), **historical-worlds** / **archviz** (1920s Hollywood Blvd neoclassical detail), **materials** and **lookdev** for the remaining surface work.
The `0.1.4` kit, materials and theatre trim are signed off; these are the geometry items that were carried forward rather than grown inside that patch.
### Patch tasks ### Patch tasks
- [ ] **Finish the Blosm blockout cleaning/detailing (moved from the deferred `0.1.0` item).** Import state: `Art/blender/blockout/blockout-2026-11-06.blend` (86 meshes, EPSG:26911, 0.0 cm affine residual) with cleaning tooling in `Geo/tools/` and `Art/blender/blockout/blosm_blockout.py`. To do here: (a) untextured roofs/facades → **Replace placeholder materials** below; (b) Blosm `setupScript` façade styles → **Give OSM buildings a façade style** below; (c) the 3 of 15 buildings lacking height data are the `0.1.2` placeholder-heights item. - [ ] **Retail neighbours (6822 Budget, 6806–6820, 6800 Lee Drug) from their own references.** The 18-frame set is the El Capitan Theatre, so it cannot stand in for these façades. Fetch or shoot per-building references first, then model the shopfront line and upper storeys rather than relying on the kit alone.
- [ ] **Define the façade module.** One set of reusable pieces (bay, pilaster, cornice, parapet, window, shopfront, door) at a fixed module, exported with the `ue_metric_v1` preset so every building assembles from the same kit. - [ ] **Hero façade: fluting and the motto band as geometry.** The colonnade is plain cylinders and the motto/medallions are flat panels; both are legible in the reference photography.
- [ ] **Replace placeholder materials.** Every blockout building currently carries Blender flat colours. Port the procedural generators from `Tools/sibling_asset_audit.py`'s sibling project (`brick_facade`, `seamless_concrete`, `asphalt`) and add stone, render, and glass, then wire them through Unreal material instances. - [ ] **Blosm blockout cleaning pass.** Roofs and facades remain untextured volumes and the `setupScript` styles stay unused; with the kit carrying the elevations, this is now cleanup rather than a second façade system.
- [ ] **Give OSM buildings a façade style.** Blosm exposes `scene.blosm.setupScript` (`setup/neoclassical.py` is the example) so a building can carry a real elevation instead of a plain extrusion. Build one style script for the south frontage and one for the north. - [ ] **Re-measure 1440p performance** with 625 kit actors and 8 generated textures in the map. `0.1.0` measured 414 fps average, so there is headroom, but it has not been re-run since.
- [ ] **Hero façade detail pass.** Ionic capitals and fluting, the parapet motto band and medallions as geometry or decal, window rhythm behind the colonnade, and the three portal flights at their photographed proportions. - [ ] Tag `v0.1.5` and write `CHANGELOG.md` entry; then remove this section.
- [ ] **El Capitan Theatre (6838) and the retail neighbours.** Model from the 18-frame 5472×3648 reference set; the theatre shares the measured 4.96 m bay rhythm with the temple.
- [ ] Review every façade change with `Tools/render_screenshots.sh` before tagging.
- [ ] Tag `v0.1.4` and write `CHANGELOG.md` entry; then remove this section.
## 0.2.0 — MPFB player body, third-person camera, basic animation (weeks 7–9) ## 0.2.0 — MPFB player body, third-person camera, basic animation (weeks 7–9)
+9 -1
View File
@@ -16,6 +16,12 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Tool | Purpose | | Tool | Purpose |
| --- | --- | | --- | --- |
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob | | `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
| `facade_kit_build.py` | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). |
| `ue_import_facade_kit.py` | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. |
| `theatre_trim_build.py` | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. |
| `ue_stage_theatre_trim.py` | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. |
| `ue_apply_facade_kit.py` | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. |
| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. | | `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
| `fbx_export_preset.py` | The **single** FBX export preset (`ue_metric_v1` / `ue_character_v1`): metres→centimetres via `FBX_SCALE_NONE`, axis mapping east +X / north −Y / up +Z, `mesh_smooth_type="FACE"` (smoothing groups survive the import). Every Blender exporter imports it instead of copying the argument block; `preset_kwargs()` is bpy-free and unit-tested. | | `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 | | `sibling_asset_audit.py` | Reads the sibling project's prop library from GLB headers into `Art/props/reuse-manifest.json`; copies nothing |
@@ -52,7 +58,9 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| Tool | Purpose | | Tool | Purpose |
| --- | --- | | --- | --- |
| `render_screenshots.py` / `render_screenshots.sh` | Rebuilds the review map, runs the game, captures the shot list into `Screenshots/` | | `render_screenshots.py` / `render_screenshots.sh` | Rebuilds the review map, runs the game, captures the shot list into `Screenshots/` |
| `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional helper — a human must still approve. | | `judge_screenshot.py` | Vision-model pre-check: judges a screenshot against an expected-scene description (`--image` + `--expect`, or `--shots` for every shot in the README). Reads `OPENROUTER_API_KEY` from `.env` (ignored). Optional, advisory only — the model is unreliable on this content and never replaces a human verdict. |
| `visual_loop.py` | The reference-vs-render loop. `--verify` gates each capture against its expectation in `visual_targets.json` (blank/short/wrong-size/tone-less shots fail); `--compare` measures luminance, contrast, edge density, palette and 3×3 composition against the reference photograph and turns the deltas into modelling actions naming the skill to consult (`blender-modeler`, `archviz`, `materials`, `lookdev`, `camera-cinematography`); `--iteration N` writes the artefacts under `Art/reference/iterations/N/`, which is **gitignored until the owner confirms a shot**. |
| `visual_targets.json` | Per-shot expectation and reference photograph for `visual_loop.py`. Every entry must be a demo-map shot. |
## Gameplay QA ## Gameplay QA
+315
View File
@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Build the reusable façade module kit (0.1.4).
One module, measured: the **4.96 m bay** (the hero's column rhythm, which the
El Capitan Theatre next door shares per the reference notes). Every block building
should assemble from the same pieces instead of being a bare extrusion, so the
street wall gets a consistent window rhythm, shopfront line, cornice and parapet.
Pieces (`ue_metric_v1`, metres, origin at the module's bottom-front centre so a
building can place them by bay index and storey index):
Kit_Pilaster vertical pier on a bay boundary
Kit_Sill window sill band
Kit_Lintel window head / lintel band
Kit_WindowBay recessed window opening in one bay (glass + reveal)
Kit_ShopfrontBay ground-floor shopfront: piers, glass, fascia
Kit_DoorBay entrance bay: recessed door, jambs, threshold
Kit_Cornice projecting cornice band, one module long
Kit_Parapet parapet wall + coping, one module long
Dimensions come from the measured storey height and the reference photography
where it resolves them (window proportions, shopfront line); everything the
photographs do not resolve is an assumption and is recorded as one in the part
report, so model and evidence stay separable.
python3 Tools/facade_kit_build.py --out /tmp/gigline/kit \
--report /tmp/gigline/kit-report.json
blender -b --factory-startup --python Tools/facade_kit_build.py -- --out /tmp/gigline/kit
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# --- module ------------------------------------------------------------------
BAY = 4.96 # m, measured: hero column rhythm, shared by the theatre
STOREY = 3.10 # m, the calibrated level height (see block_height_report)
PARAPET_H = 0.90 # m, coping above the top storey
CORNICE_H = 0.55 # m, projecting band
CORNICE_OUT = 0.35 # m, how far the cornice projects
PILASTER_W = 0.60
PILASTER_D = 0.28
WINDOW_W = 1.60
WINDOW_H = 1.85
WINDOW_REVEAL = 0.18 # how far the glass sits behind the wall face
SILL_H = 0.14
LINTEL_H = 0.34
SHOP_GLASS_H = 2.30 # shopfront glazing height from the pavement
SHOP_FASCIA_H = 0.55 # sign band above the glazing
DOOR_W = 1.90
DOOR_H = 2.45
STONE = None # materials are assigned in Unreal; Blender carries one grey
PARTS: list[dict] = []
def make_material(name, value):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (value, value * 0.98, value * 0.94, 1.0)
bsdf.inputs["Roughness"].default_value = 0.75
return mat
def validate(obj, tri_budget=4000):
"""Topology check before handoff (blender-modeler cleanup checklist).
Reports non-manifold edges, loose geometry and the triangle count. Kit pieces
are primitive solids, so any non-manifold edge means a modelling slip rather
than an intentional hole.
"""
import bmesh
me = obj.data
bm = bmesh.new()
bm.from_mesh(me)
bm.edges.ensure_lookup_table()
non_manifold = [e for e in bm.edges if len(e.link_faces) != 2]
loose_verts = [v for v in bm.verts if not v.link_edges]
tri_count = sum(len(f.verts) - 2 for f in bm.faces)
bm.free()
if non_manifold or loose_verts:
print(f"[kit] WARNING {obj.name}: {len(non_manifold)} non-manifold edge(s), "
f"{len(loose_verts)} loose vertex/vertices")
if tri_count > tri_budget:
print(f"[kit] WARNING {obj.name}: {tri_count} tris exceeds the {tri_budget} budget")
return {"manifold": not non_manifold, "loose_verts": len(loose_verts),
"tris": tri_count}
def note(obj, note_text):
PARTS.append({
"object": obj.name,
"asset": "SM_" + obj.name,
"dims_m": [round(d, 3) for d in obj.dimensions],
"location_m": [round(c, 3) for c in obj.location],
"topology": validate(obj),
"evidence": note_text,
})
print(f"[kit] {obj.name:22} {obj.dimensions.x:.2f} x {obj.dimensions.y:.2f} "
f"x {obj.dimensions.z:.2f} m ({note_text})")
def cube(name, x, y, z, dx, dy, dz, material):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def cylinder(name, x, y, z0, z1, radius, material, vertices=16):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=(z1 - z0), location=(x, y, (z0 + z1) / 2.0))
obj = bpy.context.active_object
obj.name = name
obj.data.materials.append(material)
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
return obj
def clear_scene():
"""Remove objects and orphan meshes. Materials are left alone: deleting them
invalidates handles the previous piece still holds ("StructRNA of type
Material has been removed")."""
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for mesh in list(bpy.data.meshes):
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
# --- pieces ------------------------------------------------------------------
# Each builder returns [(object, note)] for one module. Origin: bottom-front
# centre of the module, +Y into the wall depth, +Z up, X across the bay.
def build_pilaster(mat):
o = cube("Kit_Pilaster", 0.0, PILASTER_D / 2.0, STOREY / 2.0,
PILASTER_W, PILASTER_D, STOREY, mat)
return [(o, "assumption: 0.60 x 0.28 m pier on the bay boundary")]
def build_sill(mat):
o = cube("Kit_Sill", 0.0, 0.10, SILL_H / 2.0, WINDOW_W + 0.30, 0.24, SILL_H, mat)
return [(o, "assumption: projecting stone sill under each opening")]
def build_lintel(mat):
o = cube("Kit_Lintel", 0.0, 0.10, LINTEL_H / 2.0, WINDOW_W + 0.30, 0.24, LINTEL_H, mat)
return [(o, "assumption: lintel band over each opening")]
def build_window_bay(mat):
"""One storey of window: a surround standing proud of the wall, glazing inset.
The blockout wall is a solid box, so a real recess cannot be cut. The piece is
therefore a shallow surround that projects 3 cm and a glazing plane set 6 cm
behind its front, which reads as a window from any street angle. The first
version modelled everything at +Y (into the wall), so the whole bay sat inside
the solid wall and was invisible in every render.
"""
parts = []
surround_d = 0.22
surround = cube("Kit_WindowBay", 0.0, -0.03 + surround_d / 2.0, WINDOW_H / 2.0,
WINDOW_W, surround_d, WINDOW_H, mat)
parts.append((surround, "photographs: window proportions 1.60 x 1.85 m; surround "
"projects 3 cm so it reads against a solid blockout wall"))
glass = cube("Kit_WindowGlass", 0.0, 0.09, WINDOW_H / 2.0,
WINDOW_W - 0.16, 0.04, WINDOW_H - 0.16, mat)
parts.append((glass, "glazing plane set 6 cm behind the surround face; "
"material assigned in Unreal"))
return parts
def build_shopfront_bay(mat):
"""Ground floor: two piers, glazing between, fascia above."""
parts = []
for side in (-1, 1):
x = side * (BAY / 2.0 - PILASTER_W / 2.0)
o = cube(f"Kit_ShopPier_{'L' if side < 0 else 'R'}", x, PILASTER_D / 2.0,
SHOP_GLASS_H / 2.0, PILASTER_W, PILASTER_D, SHOP_GLASS_H, mat)
parts.append((o, "shopfront pier, same 0.60 m module as the pilaster"))
glass = cube("Kit_ShopGlass", 0.0, 0.06, SHOP_GLASS_H / 2.0,
BAY - 2 * PILASTER_W, 0.05, SHOP_GLASS_H - 0.25, mat)
parts.append((glass, "photographs: continuous shopfront glazing to a "
"2.30 m head"))
fascia = cube("Kit_ShopFascia", 0.0, 0.14, SHOP_GLASS_H + SHOP_FASCIA_H / 2.0,
BAY, 0.26, SHOP_FASCIA_H, mat)
parts.append((fascia, "sign band over the shopfront; 0.55 m assumption"))
return parts
def build_door_bay(mat):
parts = []
for side in (-1, 1):
x = side * (DOOR_W / 2.0 + 0.35)
o = cube(f"Kit_DoorJamb_{'L' if side < 0 else 'R'}", x, 0.14, STOREY / 2.0,
0.35, 0.30, STOREY, mat)
parts.append((o, "entrance jamb standing 0.30 m proud of the wall"))
door = cube("Kit_Door", 0.0, 0.06, DOOR_H / 2.0, DOOR_W, 0.09, DOOR_H, mat)
parts.append((door, "assumption: 1.90 x 2.45 m recessed door"))
threshold = cube("Kit_Threshold", 0.0, 0.22, 0.05, DOOR_W + 0.70, 0.44, 0.10, mat)
parts.append((threshold, "threshold slab, flush with the pavement"))
return parts
def build_cornice(mat):
o = cube("Kit_Cornice", 0.0, -CORNICE_OUT / 2.0, CORNICE_H / 2.0,
BAY, CORNICE_OUT * 2.0, CORNICE_H, mat)
return [(o, "projecting cornice band, one bay long; 0.35 m projection assumption")]
def build_parapet(mat):
parts = []
wall = cube("Kit_Parapet", 0.0, -0.15, PARAPET_H / 2.0, BAY, 0.55, PARAPET_H, mat)
parts.append((wall, "assumption: 0.90 m parapet above the top storey"))
coping = cube("Kit_Coping", 0.0, -0.15, PARAPET_H - 0.06, BAY, 0.65, 0.12, mat)
parts.append((coping, "coping slab over the parapet"))
return parts
BUILDERS = {
"pilaster": build_pilaster,
"sill": build_sill,
"lintel": build_lintel,
"window": build_window_bay,
"shopfront": build_shopfront_bay,
"door": build_door_bay,
"cornice": build_cornice,
"parapet": build_parapet,
}
def build_piece(name):
"""Build one piece group in a clean scene. The material is created *after*
the clear so its handle stays valid."""
clear_scene()
mat = make_material(f"M_Kit_{name}", 0.6)
return BUILDERS[name](mat)
def export_group(objects, out_dir):
"""Export each object to its own FBX with the shared preset."""
exported = []
for obj in objects:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
# Unreal naming convention for static meshes is SM_<Name>; the Blender
# object keeps its readable name, the asset the importable one.
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
fbx_preset.export_static(path, selection=True)
exported.append(path)
return exported
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/kit")
ap.add_argument("--report", default=None)
ap.add_argument("--only", default=None, help="comma-separated piece names")
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
names = [n.strip() for n in args.only.split(",")] if args.only else list(BUILDERS)
unknown = [n for n in names if n not in BUILDERS]
if unknown:
raise SystemExit(f"unknown piece(s): {unknown}; known: {sorted(BUILDERS)}")
total = 0
for name in names:
made = build_piece(name)
if name == "window":
made = made # window bay exports two objects (reveal + glass)
objects = [o for o, _ in made]
for obj, note_text in made:
note(obj, note_text)
exported = export_group(objects, args.out)
total += len(exported)
print(f"[kit] {name}: {len(exported)} object(s) exported")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"module_m": BAY,
"storey_m": STOREY,
"pieces": sorted(BUILDERS),
"parts": PARTS,
}, handle, indent=2)
print(f"[kit] report -> {args.report}")
print(f"[kit] exported {total} object(s) to {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Generate the facade texture maps the demo materials sample.
The materials started as procedural graphs only (noise + coursing), which reads
as tinted stone but has no grain. These maps add the grain: an ashlar stone
course pattern, a rendered stucco tooth, asphalt aggregate and concrete pores,
each as base colour plus a roughness map so the specular breaks up.
Standard library only — the PNG encoder is the same pure-zlib writer used for the
MPFB skin, so this runs on the host without numpy or Pillow.
python3 Tools/generate_facade_textures.py # write to /tmp/gigline/textures
python3 Tools/generate_facade_textures.py --size 1024
python3 Tools/generate_facade_textures.py --out Art/blender/textures
Output names match the Unreal import destination `/Game/Hollywood/Textures`:
T_StoneAshlar_BC.png T_StoneAshlar_R.png
T_Stucco_BC.png T_Stucco_R.png
T_Asphalt_BC.png T_Asphalt_R.png
T_Concrete_BC.png T_Concrete_R.png
Determinism: every pattern is a deterministic hash of the pixel coordinate, so a
re-run reproduces the same files byte for byte (no RNG state, no seam drift).
"""
from __future__ import annotations
import argparse
import math
import os
import struct
import zlib
# --- PNG -------------------------------------------------------------------
def write_png(path, width, height, rgb_rows):
"""Write 8-bit RGB rows (bytes) to a PNG. `rgb_rows` is one bytes per row."""
raw = bytearray()
for y in range(height):
raw.append(0) # filter type 0
raw += rgb_rows[y]
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
c += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
return c
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # colour type 2 = RGB
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
png += chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "wb") as handle:
handle.write(png)
return path
# --- deterministic value noise --------------------------------------------
def _hash2(x, y, seed=0):
"""Deterministic 0..1 hash of an integer cell. No RNG, fully reproducible."""
n = (x * 374761393 + y * 668265263 + seed * 1442695040888963407) & 0xFFFFFFFF
n = (n ^ (n >> 13)) * 1274126177 & 0xFFFFFFFF
return ((n ^ (n >> 16)) & 0xFFFF) / 65535.0
def value_noise(x, y, seed=0):
"""Smooth bilinear value noise at continuous (x, y) in cell units."""
xi, yi = math.floor(x), math.floor(y)
fx, fy = x - xi, y - yi
# smoothstep weights so cells blend without visible lattice lines
sx = fx * fx * (3.0 - 2.0 * fx)
sy = fy * fy * (3.0 - 2.0 * fy)
n00 = _hash2(xi, yi, seed)
n10 = _hash2(xi + 1, yi, seed)
n01 = _hash2(xi, yi + 1, seed)
n11 = _hash2(xi + 1, yi + 1, seed)
return (n00 * (1 - sx) + n10 * sx) * (1 - sy) + (n01 * (1 - sx) + n11 * sx) * sy
def fractal(x, y, octaves=4, seed=0):
"""Fractal sum of value noise, normalised to 0..1."""
total, amp, freq, norm = 0.0, 1.0, 1.0, 0.0
for _ in range(octaves):
total += value_noise(x * freq, y * freq, seed) * amp
norm += amp
amp *= 0.5
freq *= 2.0
return total / norm
def clamp8(value):
return max(0, min(255, int(value * 255.0 + 0.5)))
# --- patterns --------------------------------------------------------------
# Each returns (base_rgb_function, roughness_function) over u, v in 0..1.
def stone_ashlar(courses=8, per_course=4):
"""Coursed ashlar: horizontal beds, staggered vertical joints, stone grain."""
course_h = 1.0 / courses
def block_uv(u, v):
row = int(v / course_h)
shift = 0.5 * (row % 2) / per_course # stagger alternate courses
col = int(((u + shift) % 1.0) * per_course)
return row, col, (v % course_h) / course_h, ((u + shift) % 1.0) * per_course - col
def base(u, v, px, py):
row, col, fj, fi = block_uv(u, v)
tone = 0.62 + 0.10 * _hash2(row, col, 11) # each block a little different
grain = 0.06 * (fractal(px * 0.15, py * 0.15, 5, 3) - 0.5) * 2.0
# joint: a dark band along the bed and the head of each block
joint = 0.0
if fj < 0.055 or fi < 0.055:
joint = -0.22
value = tone + grain + joint
return (value * 1.02, value, value * 0.94) # faintly warm stone
def rough(u, v, px, py):
row, col, fj, fi = block_uv(u, v)
base_r = 0.66 + 0.10 * _hash2(row, col, 29)
if fj < 0.055 or fi < 0.055:
base_r = 0.86 # joints are matte
return base_r + 0.06 * fractal(px * 0.2, py * 0.2, 3, 7)
return base, rough
def stucco():
def base(u, v, px, py):
blot = fractal(px * 0.03, py * 0.03, 4, 21)
tooth = fractal(px * 0.9, py * 0.9, 3, 22)
value = 0.58 + 0.12 * (blot - 0.5) + 0.05 * (tooth - 0.5)
return (value * 1.03, value, value * 0.96)
def rough(u, v, px, py):
return 0.74 + 0.18 * fractal(px * 0.06, py * 0.06, 3, 31)
return base, rough
def asphalt():
def base(u, v, px, py):
agg = fractal(px * 1.4, py * 1.4, 3, 41) # fine aggregate
patch = fractal(px * 0.02, py * 0.02, 3, 42) # repair patches
value = 0.055 + 0.05 * (patch - 0.5) + 0.06 * (agg - 0.5)
return (value, value, value * 1.05) # cool bitumen
def rough(u, v, px, py):
return 0.90 + 0.08 * fractal(px * 0.5, py * 0.5, 2, 43)
return base, rough
def concrete():
def base(u, v, px, py):
pores = fractal(px * 0.8, py * 0.8, 3, 51)
tone = fractal(px * 0.04, py * 0.04, 4, 52)
value = 0.50 + 0.10 * (tone - 0.5) + 0.05 * (pores - 0.5)
return (value * 1.01, value, value * 0.97)
def rough(u, v, px, py):
return 0.84 + 0.10 * fractal(px * 0.15, py * 0.15, 3, 53)
return base, rough
TEXTURES = {
"T_StoneAshlar": stone_ashlar(),
"T_Stucco": stucco(),
"T_Asphalt": asphalt(),
"T_Concrete": concrete(),
}
def render(name, pattern, size):
"""Render one texture; returns {suffix: path} for _BC and _R."""
base_fn, rough_fn = pattern
paths = {}
for suffix, fn in (("BC", base_fn), ("R", rough_fn)):
# Wrap in a function so the inner loop stays tight.
rows = []
for y in range(size):
v = y / size
row = bytearray()
for x in range(size):
u = x / size
value = fn(u, v, x / size * size / 64.0, y / size * size / 64.0)
if isinstance(value, tuple):
row += bytes((clamp8(value[0]), clamp8(value[1]), clamp8(value[2])))
else:
grey = clamp8(value)
row += bytes((grey, grey, grey))
rows.append(bytes(row))
path = os.path.join(OUT_DIR, f"{name}_{suffix}.png")
write_png(path, size, size, rows)
paths[suffix] = path
return paths
def main(argv=None) -> int:
global OUT_DIR
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/textures",
help="output directory (default /tmp/gigline/textures)")
ap.add_argument("--size", type=int, default=512, help="edge length in pixels")
args = ap.parse_args(argv)
OUT_DIR = args.out
print(f"writing {len(TEXTURES) * 2} maps at {args.size}² to {OUT_DIR}")
for name, pattern in TEXTURES.items():
paths = render(name, pattern, args.size)
for suffix, path in paths.items():
print(f" {os.path.basename(path):28} {os.path.getsize(path):>8} bytes")
return 0
OUT_DIR = "/tmp/gigline/textures"
if __name__ == "__main__":
raise SystemExit(main())
+32 -34
View File
@@ -1,56 +1,54 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Render in-game screenshots for visual review into <repo>/Screenshots/ (gitignored). # Render the demo-map screenshots used for visual review into <repo>/Screenshots/demo/.
# #
# Usage: Tools/render_screenshots.sh # Usage: Tools/render_screenshots.sh [--verify]
# Rebuilds /Game/Test/ReviewMap and /Game/Hollywood/DemoMap (shot lists), runs #
# the game on each map (the GigLineShotActor walks the cameras, captures, quits), # Only the **demo map** is captured: the review staging map exists to hold the
# and copies the PNGs into Screenshots/. PNGs are never committed. # hero/camera list, not to produce review screenshots. Everything here is what a
# player actually sees when the demo runs.
#
# --verify run Tools/visual_loop.py --verify afterwards and fail if a shot
# does not show the content its expectation describes
#
# The PNGs are committed (see Screenshots/README.md): a reviewer must see exactly
# what the build rendered.
set -euo pipefail set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}" ENGINE="${UE_ENGINE_DIR:-/run/media/system/DATA/Engines/UE_5.8.4}"
PROJ="$REPO/Game/GigLine/GigLine.uproject" PROJ="$REPO/Game/GigLine/GigLine.uproject"
VERIFY="no"
[ "${1:-}" = "--verify" ] && VERIFY="yes"
echo "== 1/4 build review + demo shot maps ==" echo "== 1/3 stage the demo shot cameras =="
cd "$ENGINE" cd "$ENGINE"
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \ ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" \
-run=pythonscript -script="$REPO/Tools/ue_build_review_map.py" \ -run=pythonscript -script="$REPO/Tools/ue_build_review_map.py" \
-nullrhi -unattended -nopause -stdout -nullrhi -unattended -nopause -stdout
cat /tmp/ue-review-map-result.txt | tail -3 tail -3 /tmp/ue-review-map-result.txt 2>/dev/null || true
run_capture() { echo "== 2/3 capture the demo map =="
local map="$1" label="$2" ./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" /Game/Hollywood/DemoMap \
echo "== capture: $label ==" -game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
./Engine/Binaries/Linux/UnrealEditor-Cmd "$PROJ" "$map" \
-game -windowed -ResX=1920 -ResY=1080 -nopause -nosplash -stdout
}
echo "== 2/4 run game on review map (hero/scene shots) ==" echo "== 3/3 collect the demo screenshots =="
run_capture "/Game/Test/ReviewMap" "review"
echo "== 3/4 run game on demo map (walkable-demo shots) =="
run_capture "/Game/Hollywood/DemoMap" "demo"
echo "== 4/4 collect screenshots =="
mkdir -p "$REPO/Screenshots/demo" mkdir -p "$REPO/Screenshots/demo"
rm -f "$REPO"/Screenshots/review-*.png "$REPO"/Screenshots/demo/review-*.png rm -f "$REPO"/Screenshots/demo/review-*.png
found=0 found=0
for src in \ for src in \
"$REPO/Game/GigLine/Saved/Screenshots"/*/review-*.png \ "$REPO/Game/GigLine/Saved/Screenshots"/*/review-*.png \
"$REPO/Game/GigLine/Saved/Screenshots"/*/*/review-*.png \ "$REPO/Game/GigLine/Saved/Screenshots"/*/*/review-*.png \
"$HOME/.config/Epic/GigLine/Saved/Screenshots"/*/review-*.png; do "$HOME/.config/Epic/GigLine/Saved/Screenshots"/*/review-*.png; do
[ -e "$src" ] || continue [ -e "$src" ] || continue
name=$(basename "$src") case "$(basename "$src")" in
if [[ "$name" == review-?("")-demo-* || "$name" == review-*-demo-* || "$name" == review-0?-demo-* ]] \ *demo-*) cp -f "$src" "$REPO/Screenshots/demo/"; found=1 ;;
|| [[ "$name" == *demo-* ]]; then esac
cp -f "$src" "$REPO/Screenshots/demo/$name"
else
cp -f "$src" "$REPO/Screenshots/$name"
fi
found=1
done done
[ "$found" = "1" ] || { echo "ERROR: no review-*.png found"; exit 1; } [ "$found" = "1" ] || { echo "ERROR: no demo screenshots found"; exit 1; }
echo "--- Screenshots/ ---" ls -1 "$REPO/Screenshots/demo"
ls -1 "$REPO/Screenshots"
echo "--- Screenshots/demo/ ---" if [ "$VERIFY" = "yes" ]; then
ls -1 "$REPO/Screenshots/demo" echo "== verify: does each shot show what it should? =="
cd "$REPO"
python3 Tools/visual_loop.py --verify
fi
+139
View File
@@ -0,0 +1,139 @@
"""Tests for Tools/visual_loop.py — metrics, comparison and feedback rules.
Synthetic 8-bit images are written to a temp directory and measured, so the
numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge
density and contrast; a checkerboard has a lot of both).
"""
import os
import sys
import tempfile
import unittest
from PIL import Image
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "Tools"))
import visual_loop as vl # noqa: E402
def write_image(path, kind, size=(64, 64)):
img = Image.new("RGB", size)
if kind == "flat":
img.paste((128, 128, 128), (0, 0, size[0], size[1]))
elif kind == "dark":
img.paste((10, 10, 10), (0, 0, size[0], size[1]))
elif kind == "checker":
px = img.load()
for y in range(size[1]):
for x in range(size[0]):
px[x, y] = (240, 240, 240) if (x // 4 + y // 4) % 2 else (20, 20, 20)
elif kind == "sky":
# bright top third, mid stone bottom: composition like an elevation
px = img.load()
for y in range(size[1]):
for x in range(size[0]):
px[x, y] = (150, 190, 230) if y < size[1] // 3 else (170, 165, 155)
img.save(path)
return path
class MetricsTest(unittest.TestCase):
def test_flat_frame_has_no_edges_and_no_contrast(self):
with tempfile.TemporaryDirectory() as tmp:
m = vl.metrics(write_image(os.path.join(tmp, "a.png"), "flat"))
self.assertEqual(m["size"], [64, 64])
self.assertAlmostEqual(m["mean_luma"], 128.0, delta=1.0)
self.assertLess(m["rms_contrast"], 1.0)
self.assertLess(m["edge_density"], vl.MIN_EDGE_DENSITY)
def test_checker_frame_has_edges_and_contrast(self):
with tempfile.TemporaryDirectory() as tmp:
m = vl.metrics(write_image(os.path.join(tmp, "b.png"), "checker"))
self.assertGreater(m["edge_density"], 0.01)
self.assertGreater(m["rms_contrast"], 50.0)
def test_composition_grid_is_nine_cells(self):
with tempfile.TemporaryDirectory() as tmp:
m = vl.metrics(write_image(os.path.join(tmp, "c.png"), "sky"))
self.assertEqual(len(m["grid_luma"]), 9)
# sky row should be brighter than the stone rows
self.assertGreater(m["grid_luma"][0], m["grid_luma"][8])
def test_dominant_palette_is_reported(self):
with tempfile.TemporaryDirectory() as tmp:
m = vl.metrics(write_image(os.path.join(tmp, "d.png"), "flat"))
self.assertTrue(m["dominant"])
self.assertAlmostEqual(m["dominant"][0]["share"], 1.0, delta=0.05)
class CompareTest(unittest.TestCase):
def _metrics(self, kind):
with tempfile.TemporaryDirectory() as tmp:
return vl.metrics(write_image(os.path.join(tmp, "x.png"), kind))
def test_identical_images_score_high(self):
m = self._metrics("checker")
delta = vl.compare(m, m)
self.assertGreater(delta["score"], 0.9)
self.assertEqual(delta["delta_luma"], 0.0)
def test_flat_against_checker_scores_low_on_edges(self):
flat, checker = self._metrics("flat"), self._metrics("checker")
delta = vl.compare(flat, checker)
self.assertLess(delta["edge_ratio"], 0.5)
self.assertLess(delta["score"], 0.9)
def test_dark_against_bright_reports_a_luma_delta(self):
dark, bright = self._metrics("dark"), self._metrics("checker")
delta = vl.compare(dark, bright)
self.assertLess(delta["delta_luma"], -50)
class FeedbackTest(unittest.TestCase):
def test_low_edge_ratio_names_the_modelling_skills(self):
notes = vl.feedback("x", {}, {}, {
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 0.3,
"palette_distance": 10, "composition_correlation": 0.8, "score": 0.5})
joined = " ".join(notes)
self.assertIn("Relief", joined)
self.assertIn("blender-modeler", joined)
def test_bright_and_compressed_flags_lighting_and_lookdev(self):
notes = vl.feedback("x", {}, {}, {
"delta_luma": 40, "contrast_ratio": 0.5, "edge_ratio": 1.0,
"palette_distance": 10, "composition_correlation": 0.8, "score": 0.5})
joined = " ".join(notes)
self.assertIn("Too bright", joined)
self.assertIn("lookdev", joined)
def test_framing_mismatch_names_the_camera_skill(self):
notes = vl.feedback("x", {}, {}, {
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0,
"palette_distance": 10, "composition_correlation": 0.1, "score": 0.5})
self.assertIn("camera-cinematography", " ".join(notes))
def test_no_delta_reports_no_action(self):
notes = vl.feedback("x", {}, {}, {
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0,
"palette_distance": 5, "composition_correlation": 0.9, "score": 0.95})
self.assertIn("No metric is outside its tolerance", notes[0])
class TargetSpecTest(unittest.TestCase):
def test_every_target_has_expectation_and_reference(self):
for target in vl.load_targets():
self.assertTrue(target["expect"].strip(), target["name"])
self.assertTrue(target["references"], target["name"])
self.assertTrue(target["shot"].startswith("Screenshots/demo/"),
f"{target['name']} must be a demo-map shot")
def test_iteration_output_is_gitignored(self):
# Iterations are deliberately uncommitted until the owner confirms a shot.
ignore = open(os.path.join(ROOT, ".gitignore"), encoding="utf-8").read()
self.assertIn("/Art/reference/iterations/", ignore)
if __name__ == "__main__":
unittest.main()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""El Capitan Theatre (6838) trim: marquee canopy and blade sign.
The theatre blockout is a plain volume; what makes it read as *the* El Capitan
Theatre is the projecting marquee over the pavement and the vertical blade sign on
the corner. Both are built **in world coordinates** here, so the export carries its
position and the Unreal staging step is a single spawn at the origin — the same
convention as the block frontage and the north buildings.
The building's verified span (from `Geo/verification/2026-11-06-el-capitan-block.md`
and the staged mesh) is X −12082..−8210, street face at Y ≈ +1001, height 20.55 m.
blender -b --factory-startup --python Tools/theatre_trim_build.py -- \
--out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json
Everything the photographs do not resolve at this scale (exact marquee depth,
soffit height, sign lettering) is an assumption recorded in the part report.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east,
# Y north, Z up, metres).
THEATRE_X0 = -120.82
THEATRE_X1 = -82.10
# Street face of the blockout volume, in BLENDER metres. Blender's +Y is north and
# Unreal's is -Y, so the theatre's Unreal face at Y +1001 cm is Blender Y -10.01 m.
# Getting this sign wrong put the whole marquee on the far side of the boulevard.
FACE_Y = -10.01
HEIGHT = 20.55
# Ground level at the theatre frontage, in the shared frame (Z up, metres). The
# pavement here is 4.56 m above the project datum, so every trim height below is
# measured from the pavement and then offset: the first build used absolute Z and
# put the marquee canopy 5 cm above the pavement, where the wall probe found it at
# knee height.
GROUND_Z = 4.56
# Marquee: centred on the frontage, projecting over the pavement.
MARQUEE_W = 12.0
MARQUEE_DEPTH = 2.60
MARQUEE_Z = GROUND_Z + 4.60 # underside of the canopy above the pavement
CANOPY_T = 0.30
FASCIA_H = 1.10
BLADE_W = 1.30
BLADE_H = 5.20
BLADE_Z0 = GROUND_Z + 5.20
BLADE_PROJECT = 2.00
PARTS: list[dict] = []
def make_material(name, value, rough=0.6):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (value, value * 0.97, value * 0.92, 1.0)
bsdf.inputs["Roughness"].default_value = rough
return mat
def validate(obj, tri_budget=6000):
import bmesh
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.edges.ensure_lookup_table()
bad = [e for e in bm.edges if len(e.link_faces) != 2]
loose = [v for v in bm.verts if not v.link_edges]
tris = sum(len(f.verts) - 2 for f in bm.faces)
bm.free()
if bad or loose:
print(f"[trim] WARNING {obj.name}: {len(bad)} non-manifold, {len(loose)} loose")
if tris > tri_budget:
print(f"[trim] WARNING {obj.name}: {tris} tris over budget")
return {"manifold": not bad, "loose_verts": len(loose), "tris": tris}
def note(obj, text):
PARTS.append({
"object": obj.name,
"asset": "SM_" + obj.name,
"dims_m": [round(d, 3) for d in obj.dimensions],
"location_m": [round(c, 3) for c in obj.location],
"topology": validate(obj),
"evidence": text,
})
print(f"[trim] {obj.name:22} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} "
f"x {obj.dimensions.z:5.2f} m ({text})")
def cube(name, x, y, z, dx, dy, dz, material):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for mesh in list(bpy.data.meshes):
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
def build():
"""Marquee canopy, fascia, soffit and the blade sign, in world metres."""
clear_scene()
stone = make_material("M_Trim_Stone", 0.62)
dark = make_material("M_Trim_Shadow", 0.30, rough=0.75)
lightbox = make_material("M_Trim_Lightbox", 0.86, rough=0.35)
cx = (THEATRE_X0 + THEATRE_X1) / 2.0
# The street is at smaller Unreal Y than the face, which is *larger* Blender Y,
# so the marquee projects in +Y here.
y_outer = FACE_Y + MARQUEE_DEPTH
parts = []
canopy = cube("Trim_MarqueeCanopy", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
MARQUEE_Z + CANOPY_T / 2.0, MARQUEE_W, MARQUEE_DEPTH, CANOPY_T, stone)
parts.append((canopy, "photographs: projecting marquee over the pavement; "
"12.0 x 2.60 m at 4.60 m soffit — depth and height assumed"))
fascia = cube("Trim_MarqueeFascia", cx, y_outer - 0.12,
MARQUEE_Z + CANOPY_T + FASCIA_H / 2.0,
MARQUEE_W, 0.24, FASCIA_H, lightbox)
parts.append((fascia, "photographs: illuminated fascia band around the marquee "
"edge; 1.10 m depth assumed"))
soffit = cube("Trim_MarqueeSoffit", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
MARQUEE_Z - 0.05, MARQUEE_W - 0.4, MARQUEE_DEPTH - 0.4, 0.10, dark)
parts.append((soffit, "assumption: recessed soffit panel under the canopy; "
"reads as shadow from the pavement"))
# Blade sign on the west corner, projecting perpendicular to the facade.
bx = THEATRE_X0 + 3.6
blade = cube("Trim_BladeSign", bx, FACE_Y + BLADE_PROJECT / 2.0,
BLADE_Z0 + BLADE_H / 2.0, BLADE_W, 0.42, BLADE_H, lightbox)
parts.append((blade, "photographs: vertical blade sign on the corner; "
"1.30 x 5.20 m, 2.00 m projection assumed"))
frame = cube("Trim_BladeSignFrame", bx, FACE_Y + BLADE_PROJECT / 2.0,
BLADE_Z0 + BLADE_H / 2.0, BLADE_W + 0.28, 0.60, 0.35, stone)
parts.append((frame, "assumption: stone frame band at the base of the blade"))
# Bracket back to the wall so the sign does not float.
bracket = cube("Trim_BladeSignBracket", bx, FACE_Y + BLADE_PROJECT - 0.6,
BLADE_Z0 + BLADE_H - 0.5, 0.60, 1.40, 0.30, dark)
parts.append((bracket, "assumption: bracket tying the blade back to the facade"))
return parts
def export_objects(parts, out_dir):
count = 0
for obj, _text in parts:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
fbx_preset.export_static(path, selection=True)
count += 1
return count
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/theatre")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
parts = build()
for obj, text in parts:
note(obj, text)
exported = export_objects(parts, args.out)
print(f"[trim] exported {exported} object(s) to {args.out}")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"building": "El Capitan Theatre (6838)",
"span_m": [THEATRE_X0, THEATRE_X1, FACE_Y, HEIGHT],
"parts": PARTS,
}, handle, indent=2)
print(f"[trim] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Assemble the façade kit onto the block buildings.
Takes the imported kit from `/Game/Hollywood/Kit` and lays it out over a frontage
building's street face: shopfront bays on the ground floor, window bays above,
pilasters on the bay boundaries, a cornice under the parapet and a coping over it.
Layout is driven by the building's own bounding box, so it follows whatever the
blockout gives: the bay count is `round(width / BAY)` and the storey count comes
from the height above the ground floor. Nothing is hand-placed per building.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout
# or, as a step inside the demo builder:
from ue_apply_facade_kit import apply_to_actor
Result file: /tmp/gigline/kit-apply.txt
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ue_common # noqa: E402
KIT = "/Game/Hollywood/Kit"
RESULT = "/tmp/gigline/kit-apply.txt"
# Material per kit piece. Without this the pieces keep the grey material the FBX
# import creates (M_Kit_*), so the glazing is not glass and the shopfronts are not
# stone - the kit reads as a plastic scaffold over the buildings.
KIT_MATERIALS = {
"Kit_WindowGlass": "M_FacadeGlass",
"Kit_ShopGlass": "M_FacadeGlass",
"Kit_Door": "M_FacadeStoneShadow",
"Kit_WindowBay": "M_FacadeStone",
"Kit_ShopFascia": "M_FacadeBanner",
}
KIT_MATERIAL_FALLBACK = "M_FacadeStone"
BAY_CM = 496.0 # the kit module
STOREY_CM = 310.0
SHOP_FLOOR_CM = 310.0 # ground floor is one storey tall
KIT_PREFIX = "Kit_"
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def load_kit() -> dict:
"""Load every kit mesh once, keyed by its short name."""
kit = {}
registry = unreal.AssetRegistryHelpers.get_asset_registry()
filt = unreal.ARFilter(package_paths=[KIT], recursive_paths=False,
class_paths=[unreal.TopLevelAssetPath("/Script/Engine", "StaticMesh")])
for data in registry.get_assets(filt):
mesh = unreal.EditorAssetLibrary.load_asset(str(data.package_name))
if mesh:
# Assets are SM_-prefixed (Unreal convention); the layout code refers to
# pieces by their short name, so key on the name without the prefix.
name = mesh.get_name()
kit[name[3:] if name.startswith("SM_") else name] = mesh
return kit
def _bounds(mesh):
box = mesh.get_bounding_box()
return box.min, box.max
def _spawn(mesh, location, label, material=None, parent_collection=None):
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, location)
if actor is None:
log(f"could not spawn {label}")
return None
actor.set_actor_label(label)
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
comp.set_collision_profile_name("BlockAll")
if material:
comp.set_material(0, material)
return actor
def apply_to_actor(actor, kit, materials=None, prefix=None):
"""Lay the kit out over one building actor's street face.
Returns the number of pieces placed. The building's own mesh stays in place
as the wall the pieces sit on.
"""
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
return 0
# Labels must be unique per building, otherwise two buildings' pieces share a
# label and the outliner (and any label-based lookup) becomes ambiguous.
slug = actor.get_actor_label().replace("Frontage_SM_", "").replace("North_", "").lower()
prefix = prefix or f"Kit_{slug}_"
# WORLD-space bounds. The imported meshes carry a building-line-local offset in
# their own vertices and the demo builder then positions the actor, so the
# asset's bounding box is NOT world space: using it put every kit piece about
# 12 km east of its building, floating in empty ground where nothing could see
# it. get_actor_bounds() includes the actor transform.
origin_w, extent_w = actor.get_actor_bounds(False)
mn = unreal.Vector(origin_w.x - extent_w.x, origin_w.y - extent_w.y, origin_w.z - extent_w.z)
mx = unreal.Vector(origin_w.x + extent_w.x, origin_w.y + extent_w.y, origin_w.z + extent_w.z)
origin = unreal.Vector(0.0, 0.0, 0.0)
width = mx.x - mn.x
height = mx.z - mn.z
if width < BAY_CM * 0.75 or height < STOREY_CM:
log(f"{actor.get_actor_label()}: too small for the kit "
f"({width / 100:.1f} x {height / 100:.1f} m)")
return 0
bays = max(1, int(round(width / BAY_CM)))
bay_w = width / bays # actual bay width for this building
# Street face: the frontage runs along X, and the street is at smaller Y.
face_y = mn.y
base_z = mn.z
storeys = max(1, int((height - SHOP_FLOOR_CM) // STOREY_CM))
def kit_material(name):
if materials is None:
return None
want = KIT_MATERIALS.get(name, KIT_MATERIAL_FALLBACK)
return materials.get(want)
placed = 0
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
# ground floor: shopfront
for piece, dx in (("Kit_ShopPier_L", -bay_w / 2 + 30.0),
("Kit_ShopPier_R", bay_w / 2 - 30.0)):
if piece in kit:
if _spawn(kit[piece], unreal.Vector(cx + dx, face_y, base_z),
f"{prefix}shop_pier_{bay}", kit_material(piece)):
placed += 1
if "Kit_ShopGlass" in kit:
if _spawn(kit["Kit_ShopGlass"], unreal.Vector(cx, face_y, base_z),
f"{prefix}shop_glass_{bay}", kit_material("Kit_ShopGlass")):
placed += 1
# upper storeys: window bays
for storey in range(storeys):
if "Kit_WindowBay" in kit:
z = base_z + SHOP_FLOOR_CM + STOREY_CM * storey + 65.0
if _spawn(kit["Kit_WindowBay"], unreal.Vector(cx, face_y, z),
f"{prefix}window_{bay}_{storey}", kit_material("Kit_WindowBay")):
placed += 1
# pilaster on the left boundary of every bay
if "Kit_Pilaster" in kit:
if _spawn(kit["Kit_Pilaster"],
unreal.Vector(mn.x + bay_w * bay, face_y, base_z),
f"{prefix}pilaster_{bay}", kit_material("Kit_Pilaster")):
placed += 1
# cornice under the parapet, coping on top
if "Kit_Cornice" in kit:
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
if _spawn(kit["Kit_Cornice"],
unreal.Vector(cx, face_y, base_z + height - 145.0),
f"{prefix}cornice_{bay}", kit_material("Kit_Cornice")):
placed += 1
if "Kit_Parapet" in kit:
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
if _spawn(kit["Kit_Parapet"],
unreal.Vector(cx, face_y, base_z + height - 90.0),
f"{prefix}parapet_{bay}", kit_material("Kit_Parapet")):
placed += 1
log(f"{actor.get_actor_label()}: {bays} bays x {storeys} storeys, "
f"{width / 100:.1f} x {height / 100:.1f} m -> {placed} pieces")
return placed
def main() -> None:
kit = load_kit()
if not kit:
log(f"no kit meshes under {KIT} — run Tools/ue_import_facade_kit.py first")
return
log(f"kit meshes: {len(kit)}")
# Site materials built by Tools/ue_build_demo_map.py.
materials = {}
for name in ("M_FacadeStone", "M_FacadeStoneShadow", "M_FacadeGlass",
"M_FacadeBanner", "M_Stucco", "M_Concrete"):
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
materials[name] = asset
log(f"kit materials resolved: {len(materials)}/6")
level = "/Game/Hollywood/DemoMap"
if not unreal.EditorLevelLibrary.load_level(level):
log(f"could not load {level}")
return
# The map is LFS-tracked and checks out read-only.
content = unreal.Paths.project_content_dir()
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
# Remove a previous application so re-running does not stack duplicates.
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith("Kit_"):
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
log(f"cleared {removed} previous kit actors")
targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors()
if a.get_actor_label().startswith(("Frontage_", "North_"))]
log(f"frontage actors: {len(targets)}")
total = 0
for actor in targets:
total += apply_to_actor(actor, kit, materials)
log(f"placed {total} kit pieces")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), level)
log(f"save_map {level}: {saved}")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+223 -16
View File
@@ -16,10 +16,13 @@
# deterministically instead of stacking duplicates. # deterministically instead of stacking duplicates.
import os import os
import sys
import unreal import unreal
MAP = "/Game/Hollywood/DemoMap" MAP = "/Game/Hollywood/DemoMap"
# Repository root, so the texture generator can be invoked by path.
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
RESULT = "/tmp/gigline/demo-map.txt" RESULT = "/tmp/gigline/demo-map.txt"
lines = [] lines = []
@@ -50,7 +53,46 @@ def spawn(cls, loc, label, rot=None):
return actor return actor
def ensure_material(mat_name, spec): TEXTURE_DIR = "/Game/Hollywood/Textures"
def import_texture(png_path, asset_name):
"""Import a generated PNG as a Texture2D (idempotent: replaces the asset)."""
if not os.path.exists(png_path):
log(f"texture missing: {png_path}")
return None
task = unreal.AssetImportTask()
task.filename = png_path
task.destination_path = TEXTURE_DIR
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.TextureImportUI() if hasattr(unreal, "TextureImportUI") else None
if ui:
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
return unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}")
def ensure_textures():
"""Generate/point at the facade maps and import them. Returns {name: asset}."""
out_dir = "/tmp/gigline/textures"
gen = os.path.join(ROOT, "Tools", "generate_facade_textures.py")
if os.path.exists(gen) and not os.path.exists(os.path.join(out_dir, "T_StoneAshlar_BC.png")):
import subprocess
subprocess.run([sys.executable, gen, "--out", out_dir, "--size", "512"], check=False)
imported = {}
for name in ("T_StoneAshlar_BC", "T_StoneAshlar_R", "T_Stucco_BC", "T_Stucco_R",
"T_Asphalt_BC", "T_Asphalt_R", "T_Concrete_BC", "T_Concrete_R"):
asset = import_texture(os.path.join(out_dir, name + ".png"), name)
if asset:
imported[name] = asset
log(f"textures imported: {len(imported)}/8")
return imported
def ensure_material(mat_name, spec, textures=None):
"""Create/reuse a look-developed material under /Game/Hollywood/Site. """Create/reuse a look-developed material under /Game/Hollywood/Site.
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
@@ -65,6 +107,13 @@ def ensure_material(mat_name, spec):
spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale
(world units per noise cycle; smaller = larger features). (world units per noise cycle; smaller = larger features).
Optional `courses_m` adds **masonry coursing**: horizontal joint lines at a
given course height in world metres, plus a per-course horizontal offset so
the vertical joints stagger like real ashlar. Optional `joint_tint` is the
colour the joints darken toward, and `roughness_variation` lerps roughness
between `roughness` and a rougher value using the same noise, so the surface
does not read as one flat specular value.
""" """
path = f"/Game/Hollywood/Site/{mat_name}" path = f"/Game/Hollywood/Site/{mat_name}"
if unreal.EditorAssetLibrary.does_asset_exist(path): if unreal.EditorAssetLibrary.does_asset_exist(path):
@@ -88,6 +137,20 @@ def ensure_material(mat_name, spec):
n.set_editor_property("r", float(value)) n.set_editor_property("r", float(value))
return n return n
def link(src, dst, pin=""):
"""Connect, and say so when it fails.
`connect_material_expressions` returns False rather than raising, so an
unknown pin name silently leaves an input unconnected and the material
compiles with a default value — a coursing mask of 0 is invisible, which
is exactly how the first attempt failed.
"""
ok = lib.connect_material_expressions(src, "", dst, pin)
if not ok:
log(f"material {mat_name}: FAILED to connect {src.__class__.__name__} "
f"-> {dst.__class__.__name__}.{pin or '(default)'}")
return ok
# world position -> noise -> lerp(dark, bright) -> base colour # world position -> noise -> lerp(dark, bright) -> base colour
wp = node(unreal.MaterialExpressionWorldPosition) wp = node(unreal.MaterialExpressionWorldPosition)
scale = scalar(spec.get("noise_scale", 0.004)) scale = scalar(spec.get("noise_scale", 0.004))
@@ -108,10 +171,95 @@ def ensure_material(mat_name, spec):
lib.connect_material_expressions(c3(spec["dark"]), "", lerp, "A") lib.connect_material_expressions(c3(spec["dark"]), "", lerp, "A")
lib.connect_material_expressions(c3(spec["bright"]), "", lerp, "B") lib.connect_material_expressions(c3(spec["bright"]), "", lerp, "B")
lib.connect_material_expressions(noise, "", lerp, "Alpha") lib.connect_material_expressions(noise, "", lerp, "Alpha")
lib.connect_material_property(lerp, "", unreal.MaterialProperty.MP_BASE_COLOR)
lib.connect_material_property( # --- optional masonry coursing -----------------------------------------
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS) # A world-space course mask: the fractional part of Z / course height is a
# sawtooth per course; zeroing it gives a joint line every course. The row
# index shifts each course sideways by half a brick, so vertical joints
# stagger. All of it is world-space, so every building shares the coursing
# without needing its own UVs.
base_color_expr = lerp
if spec.get("courses_m"):
# WorldPosition is in CENTIMETRES, so a course height given in metres must
# be scaled by 100 here; getting this wrong makes the coursing 100x too
# fine to see at all (the first attempt did exactly that).
course_cm = float(spec["courses_m"]) * 100.0
row = node(unreal.MaterialExpressionDivide)
link(wp, row, "A")
link(c3((course_cm, course_cm, course_cm)), row, "B")
saw = node(unreal.MaterialExpressionFrac)
link(row, saw)
# Joint mask without a comparison node (UE has no Less): 1 - saw/joint is
# 1 at the course boundary and 0 once past the joint width; clamped to 0..1
# so it is a linear band rather than a hard step.
div = node(unreal.MaterialExpressionDivide)
link(saw, div, "A")
# joint_width is a fraction of one course (0.11 ~= a 6 cm joint on a
# 55 cm course), which is the space `saw` lives in after the divide.
link(scalar(spec.get("joint_width", 0.11)), div, "B")
inv = node(unreal.MaterialExpressionOneMinus)
link(div, inv)
joint = node(unreal.MaterialExpressionClamp)
link(inv, joint)
tinted = node(unreal.MaterialExpressionLinearInterpolate)
link(lerp, tinted, "A")
link(c3(spec.get("joint_tint", (0.34, 0.31, 0.27))), tinted, "B")
link(joint, tinted, "Alpha")
base_color_expr = tinted
# If a texture set is supplied for this material, sample it world-aligned and
# use it instead of the graph-only coursing: the map carries the coursing and
# the grain, so overlaying both would double the pattern.
tex = (textures or {}).get(spec.get("texture", ""))
if tex:
bc = (textures or {}).get(spec.get("texture", "") + "_BC")
# world position (cm) -> UV: one texture tile every tile_cm centimetres
tile_cm = float(spec.get("tile_cm", 300.0))
uv_scale = node(unreal.MaterialExpressionMultiply)
link(wp, uv_scale, "A")
link(scalar(1.0 / tile_cm), uv_scale, "B")
mask_xy = node(unreal.MaterialExpressionComponentMask)
for pin in ("R", "G"):
try:
mask_xy.set_editor_property(pin, True)
except Exception: # noqa: BLE001
pass
try:
mask_xy.set_editor_property("B", False)
mask_xy.set_editor_property("A", False)
except Exception: # noqa: BLE001
pass
link(uv_scale, mask_xy)
if bc:
sample = node(unreal.MaterialExpressionTextureSample)
sample.set_editor_property("texture", bc)
link(mask_xy, sample, "UVs")
lib.connect_material_property(sample, "RGB",
unreal.MaterialProperty.MP_BASE_COLOR)
base_color_expr = sample
r_map = (textures or {}).get(spec.get("texture", "") + "_R")
if r_map:
r_sample = node(unreal.MaterialExpressionTextureSample)
r_sample.set_editor_property("texture", r_map)
link(mask_xy, r_sample, "UVs")
lib.connect_material_property(r_sample, "R",
unreal.MaterialProperty.MP_ROUGHNESS)
lib.connect_material_property(base_color_expr, "", unreal.MaterialProperty.MP_BASE_COLOR)
# Roughness: lerp(base, base + variation, noise) so the highlight breaks up.
variation = float(spec.get("roughness_variation", 0.0))
if variation > 0.0:
rough_lerp = node(unreal.MaterialExpressionLinearInterpolate)
lib.connect_material_expressions(
scalar(spec.get("roughness", 0.85)), "", rough_lerp, "A")
lib.connect_material_expressions(
scalar(min(1.0, spec.get("roughness", 0.85) + variation)), "", rough_lerp, "B")
lib.connect_material_expressions(noise, "", rough_lerp, "Alpha")
lib.connect_material_property(rough_lerp, "", unreal.MaterialProperty.MP_ROUGHNESS)
else:
lib.connect_material_property(
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
try: try:
lib.connect_material_property( lib.connect_material_property(
scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR) scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR)
@@ -269,19 +417,47 @@ def build():
# Look-developed surface materials (see ensure_material for the graph). # Look-developed surface materials (see ensure_material for the graph).
MATERIAL_SPECS = { MATERIAL_SPECS = {
"M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078), "M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078),
roughness=0.92, specular=0.22, noise_scale=0.006), roughness=0.92, specular=0.22, noise_scale=0.006,
texture="T_Asphalt", tile_cm=900.0),
"M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50), "M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50),
roughness=0.88, specular=0.28, noise_scale=0.004), roughness=0.88, specular=0.28, noise_scale=0.004,
texture="T_Concrete", tile_cm=250.0),
"M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54), "M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54),
roughness=0.85, specular=0.30, noise_scale=0.008), roughness=0.85, specular=0.30, noise_scale=0.008),
"M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26), "M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26),
roughness=0.98, specular=0.15, noise_scale=0.003), roughness=0.98, specular=0.15, noise_scale=0.003),
"M_RoadPaint": dict(dark=(0.74, 0.74, 0.72), bright=(0.86, 0.86, 0.84), "M_RoadPaint": dict(dark=(0.74, 0.74, 0.72), bright=(0.86, 0.86, 0.84),
roughness=0.62, specular=0.35, noise_scale=0.02), roughness=0.62, specular=0.35, noise_scale=0.02),
"M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.74, 0.70, 0.62), # Ashlar coursing at 0.55 m (a 0.10 m band joint), the reading the 2009
roughness=0.72, specular=0.32, noise_scale=0.005), # facade photograph gives for the hero's stone; roughness breaks up so the
"M_Stucco": dict(dark=(0.52, 0.48, 0.42), bright=(0.66, 0.62, 0.55), # wall is not one specular plane.
roughness=0.86, specular=0.25, noise_scale=0.004), "M_FacadeStone": dict(dark=(0.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64),
roughness=0.70, specular=0.32, noise_scale=0.005,
courses_m=0.55, joint_width=0.11,
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12,
texture="T_StoneAshlar", tile_cm=440.0),
# Rendered/stucco frontage: no coursing, but a fine tooth and a wider
# roughness range than before, because flat stucco is what read as plastic.
"M_Stucco": dict(dark=(0.50, 0.46, 0.40), bright=(0.68, 0.64, 0.57),
roughness=0.76, specular=0.25, noise_scale=0.0025,
roughness_variation=0.20, texture="T_Stucco", tile_cm=260.0),
# Stone in shadow: the hero's recessed wall behind the colonnade. Same
# coursing as the face, a stop darker, so depth reads without a light.
"M_FacadeStoneShadow": dict(dark=(0.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43),
roughness=0.78, specular=0.26, noise_scale=0.005,
courses_m=0.55, joint_width=0.11,
joint_tint=(0.28, 0.26, 0.23),
roughness_variation=0.10),
# Glazing: low roughness, strong specular. A plausible IOR for glass sits
# near 1.5, which UE expresses through specular ~0.5-0.6 on an opaque
# surface (there is no refraction pass at blockout fidelity).
"M_FacadeGlass": dict(dark=(0.030, 0.045, 0.055), bright=(0.075, 0.095, 0.110),
roughness=0.14, specular=0.62, noise_scale=0.01,
roughness_variation=0.06),
# The JIMMY KIMMEL banners: the one saturated surface on the elevation.
"M_FacadeBanner": dict(dark=(0.32, 0.05, 0.07), bright=(0.55, 0.10, 0.12),
roughness=0.72, specular=0.22, noise_scale=0.02,
roughness_variation=0.12),
} }
SURFACE_MATERIALS = { SURFACE_MATERIALS = {
"Terrain_DEM": "M_Ground", "Terrain_DEM": "M_Ground",
@@ -292,9 +468,10 @@ def build():
"Kerb_North": "M_KerbConcrete", "Kerb_North": "M_KerbConcrete",
"Crossing_Markings": "M_RoadPaint", "Crossing_Markings": "M_RoadPaint",
} }
textures = ensure_textures()
built = {} built = {}
for mat_name, spec in MATERIAL_SPECS.items(): for mat_name, spec in MATERIAL_SPECS.items():
built[mat_name] = ensure_material(mat_name, spec) built[mat_name] = ensure_material(mat_name, spec, textures)
imported = 0 imported = 0
for name, mat_name in SURFACE_MATERIALS.items(): for name, mat_name in SURFACE_MATERIALS.items():
@@ -337,15 +514,45 @@ def build():
LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone", LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone",
"North_TCL_Chinese": "M_Stucco", "North_TCL_Chinese": "M_Stucco",
"North_Madame_Tussauds": "M_Stucco"} "North_Madame_Tussauds": "M_Stucco"}
# Assign per SLOT, by what the slot's imported material is called, not just
# slot 0: the hero mesh's slot 0 is the banner, so the stone, the shadowed
# stone and the glazing were all still wearing their flat Blender materials.
SLOT_RULES = (
("glass", "M_FacadeGlass"),
("banner", "M_FacadeBanner"),
("stoneshadow", "M_FacadeStoneShadow"),
("stone", "M_FacadeStone"),
("roof", "M_Concrete"),
("wall", "M_Stucco"),
)
def slot_material_name(slot_mesh_material):
if not slot_mesh_material:
return None
key = slot_mesh_material.get_name().lower()
for needle, target in SLOT_RULES:
if needle in key:
return target
return None
assigned = 0 assigned = 0
for actor in unreal.EditorLevelLibrary.get_all_level_actors(): for actor in unreal.EditorLevelLibrary.get_all_level_actors():
mat_name = LABEL_MATERIALS.get(actor.get_actor_label()) fallback = LABEL_MATERIALS.get(actor.get_actor_label())
if not mat_name or mat_name not in built:
continue
comp = actor.get_component_by_class(unreal.StaticMeshComponent) comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if comp and comp.static_mesh: if not comp or not comp.static_mesh:
comp.set_material(0, built[mat_name]) continue
slots = comp.static_mesh.get_editor_property("static_materials")
touched = 0
for index, slot in enumerate(slots):
mesh_mat = slot.get_editor_property("material_interface")
target = slot_material_name(mesh_mat) or fallback
if target and target in built:
comp.set_material(index, built[target])
touched += 1
if touched:
assigned += 1 assigned += 1
log(f"materials {actor.get_actor_label()}: {touched}/{len(slots)} slots")
log(f"assigned look materials to {assigned} facade/background actors") log(f"assigned look materials to {assigned} facade/background actors")
# --- surrounding block frontage --------------------------------------- # --- surrounding block frontage ---------------------------------------
+12 -3
View File
@@ -240,9 +240,9 @@ try:
# verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a # 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 # 7.6 m target, which converged the columns — the one thing an
# architectural elevation must not do (archviz: "straight verticals"). # 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)), ("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760), 42.0),
# oblique from the north-west, reading the colonnade in depth # 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)), ("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700), 55.0),
] ]
# --- review shot cameras + save ------------------------------------------- # --- review shot cameras + save -------------------------------------------
# Order matters: the review scene is staged in the default (unsaved) world. # Order matters: the review scene is staged in the default (unsaved) world.
@@ -256,6 +256,12 @@ try:
"shot_rotations", "shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams]) [unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
shots.set_editor_property("shot_names", [c[0] for c in cams]) shots.set_editor_property("shot_names", [c[0] for c in cams])
# Optional 4th element: field of view in degrees. 0 keeps the default 90.
# Elevations shot from across the street use a long lens so the building
# fills the frame without pitching the camera (archviz: straight
# verticals, telephoto for elevations).
shots.set_editor_property(
"shot_field_of_view", [float(c[3]) if len(c) > 3 else 0.0 for c in cams])
shots.set_editor_property("quit_when_done", True) shots.set_editor_property("quit_when_done", True)
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}") log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
@@ -282,7 +288,7 @@ try:
# the colonnade's underside. # the colonnade's underside.
# Level horizon for the same reason: a block elevation is read by its # Level horizon for the same reason: a block elevation is read by its
# verticals, and a pitched camera makes them lean. # verticals, and a pitched camera makes them lean.
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620)), ("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
# Street level, from the north sidewalk: the old camera sat at Y -400, # 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 # 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 # the unlit back face. Y -2600 is on the sidewalk in front of the lit
@@ -309,6 +315,9 @@ try:
seq.set_editor_property("shot_rotations", seq.set_editor_property("shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots]) [unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots])
seq.set_editor_property("shot_names", [c[0] for c in demo_shots]) seq.set_editor_property("shot_names", [c[0] for c in demo_shots])
seq.set_editor_property(
"shot_field_of_view",
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
# quit after the demo shots so a dedicated DemoMap capture run # quit after the demo shots so a dedicated DemoMap capture run
# terminates cleanly (render_screenshots.sh runs both maps). # terminates cleanly (render_screenshots.sh runs both maps).
seq.set_editor_property("quit_when_done", True) seq.set_editor_property("quit_when_done", True)
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Import the façade module kit into Unreal: /Game/Hollywood/Kit/*.
Pairs with `Tools/facade_kit_build.py`. Each piece is imported as its own static
mesh at the module's origin (bottom-front centre), so a building can place a
piece by bay index and storey index with a single offset.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout
Result file: /tmp/gigline/kit-import.txt (the commandlet swallows print()).
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ue_common # noqa: E402
SRC = "/tmp/gigline/kit"
DEST = "/Game/Hollywood/Kit"
RESULT = "/tmp/gigline/kit-import.txt"
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def main() -> None:
if not os.path.isdir(SRC):
log(f"MISSING source directory {SRC} — run Tools/facade_kit_build.py first")
return
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
log(f"no FBX files in {SRC}")
return
# Drop kit assets that the current build no longer produces (the pieces were
# renamed to follow the SM_ convention), so the folder cannot accumulate two
# generations of the same piece.
expected = {os.path.splitext(f)[0] for f in files}
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
name = existing.split("/")[-1].split(".")[0]
if name not in expected:
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
log(f"removed stale kit asset {name}")
for filename in files:
asset_name = os.path.splitext(filename)[0]
task = unreal.AssetImportTask()
task.filename = os.path.join(SRC, filename)
task.destination_path = DEST
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
# Kit pieces are thin boxes and panes: complex-as-simple keeps the
# collision on the real faces instead of a fat convex hull that would
# fence off the shopfront glazing.
smd.auto_generate_collision = False
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
if mesh is None:
log(f"{asset_name}: IMPORT FAILED")
continue
# The FBX import also creates an M_Kit_* material per piece. The demo
# assigns real site materials at assembly time, so those are clutter.
for slot in mesh.get_editor_property("static_materials"):
mat = slot.get_editor_property("material_interface")
if mat and mat.get_name().startswith("M_Kit_"):
unreal.EditorAssetLibrary.delete_asset(mat.get_path_name().split(".")[0])
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{asset_name:20} size=({mx.x - mn.x:6.1f}, {mx.y - mn.y:6.1f}, "
f"{mx.z - mn.z:6.1f}) cm min=({mn.x:6.1f}, {mn.y:6.1f}, {mn.z:6.1f}) "
f"collision={unreal.GigLineAssetTools.get_collision_complexity(mesh)}")
log(f"imported {len(files)} kit piece(s) into {DEST}")
try:
main()
except Exception as exc: # noqa: BLE001 - surface the failure in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+1
View File
@@ -12,6 +12,7 @@
# -script=<repo>/Tools/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout # -script=<repo>/Tools/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout
import os import os
import sys
import unreal import unreal
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Import and stage the El Capitan Theatre trim (marquee + blade sign).
Two steps, both idempotent:
1. Import `/tmp/gigline/theatre/*.fbx` into `/Game/Hollywood/Theatre`.
2. Stage one static-mesh actor per piece at the **origin**, because the meshes are
built in world coordinates (same convention as the block frontage and the north
buildings). Anything that has to be positioned by arithmetic is a bug waiting to
happen — the kit was once placed 12 km away that way.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout
Result file: `/tmp/gigline/theatre-import.txt`.
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ue_common # noqa: E402
SRC = "/tmp/gigline/theatre"
DEST = "/Game/Hollywood/Theatre"
MAP = "/Game/Hollywood/DemoMap"
RESULT = "/tmp/gigline/theatre-import.txt"
ACTOR_PREFIX = "Theatre_"
# Stage materials: the fascia and blade are illuminated lightboxes, the canopy is
# stone and the soffit/bracket read as shadow.
MATERIALS = {
"SM_Trim_MarqueeCanopy": "M_FacadeStone",
"SM_Trim_MarqueeFascia": "M_FacadeBanner",
"SM_Trim_MarqueeSoffit": "M_FacadeStoneShadow",
"SM_Trim_BladeSign": "M_FacadeBanner",
"SM_Trim_BladeSignFrame": "M_FacadeStone",
"SM_Trim_BladeSignBracket": "M_FacadeStoneShadow",
}
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def import_pieces() -> list[str]:
if not os.path.isdir(SRC):
log(f"MISSING {SRC} — run Tools/theatre_trim_build.py first")
return []
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
log(f"no FBX in {SRC}")
return []
expected = {os.path.splitext(f)[0] for f in files}
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
name = existing.split("/")[-1].split(".")[0]
if name not in expected:
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
log(f"removed stale asset {name}")
for filename in files:
asset_name = os.path.splitext(filename)[0]
task = unreal.AssetImportTask()
task.filename = os.path.join(SRC, filename)
task.destination_path = DEST
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
# A projecting canopy over the pavement must block: the player should not be
# able to walk through the fascia or clip up into the soffit.
smd.auto_generate_collision = False
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
if mesh is None:
log(f"{asset_name}: IMPORT FAILED")
continue
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
box = mesh.get_bounding_box()
log(f"{asset_name:26} X {box.min.x:8.0f}..{box.max.x:7.0f} "
f"Y {box.min.y:7.0f}..{box.max.y:7.0f} Z {box.min.z:6.0f}..{box.max.z:6.0f}")
return [os.path.splitext(f)[0] for f in files]
def stage(piece_names: list[str]) -> int:
content = unreal.Paths.project_content_dir()
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
if not unreal.EditorLevelLibrary.load_level(MAP):
log(f"could not load {MAP}")
return 0
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith(ACTOR_PREFIX):
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
log(f"cleared {removed} previous theatre trim actor(s)")
materials = {}
for name in set(MATERIALS.values()):
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
materials[name] = asset
placed = 0
for piece in piece_names:
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{piece}")
if mesh is None:
continue
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0))
if actor is None:
log(f"could not spawn {piece}")
continue
actor.set_actor_label(ACTOR_PREFIX + piece.replace("SM_Trim_", ""))
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
comp.set_collision_profile_name("BlockAll")
want = MATERIALS.get(piece)
if want and want in materials:
comp.set_material(0, materials[want])
placed += 1
log(f"staged {placed} theatre trim actor(s) in {MAP}")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), MAP)
log(f"save_map {MAP}: {saved}")
return placed
def main() -> None:
names = import_pieces()
if names:
stage(names)
try:
main()
except Exception as exc: # noqa: BLE001 - surface in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Iterate a building against the reference photography and the engine render.
The loop this tool implements:
```
reference photograph ─┐
├─ compare ─► metrics + feedback ─► blender-skill actions
engine screenshot ─┘ │
└─ next iteration (uncommitted)
```
It answers three questions with numbers instead of opinions:
1. **Does the shot show what it was supposed to?** (`--verify`) — every capture
has an expectation in `Tools/visual_targets.json`; a shot that is blank, the
wrong size, or wildly unlike its expectation is reported as a failure.
2. **How far is the render from the reference?** (`--compare`) — luminance,
contrast, edge density, palette and 3×3 composition are measured on both and
reported as deltas with a 0–1 similarity score.
3. **What should be modelled next?** (`--feedback`) — the deltas are translated
into concrete actions phrased for the Blender skill packs (silhouette, relief,
value range, hue, framing).
Iterations are **not committed**: every artefact goes under
`Art/reference/iterations/<iteration>/`, which is gitignored. Promote a shot into
`Screenshots/` only after the owner confirms it.
python3 Tools/visual_loop.py --verify # gate the current set
python3 Tools/visual_loop.py --compare --iteration 3 # measure vs references
python3 Tools/visual_loop.py --compare --iteration 3 --judge # + vision advisory
Metrics are computed with Pillow (already in requirements-tools.txt); no numpy.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from PIL import Image, ImageFilter, ImageStat
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
TARGETS = os.path.join(ROOT, "Tools", "visual_targets.json")
ITER_ROOT = os.path.join(ROOT, "Art", "reference", "iterations")
# A shot must clear these floors or it is treated as broken, not merely ugly.
MIN_EDGE_DENSITY = 0.010
MIN_CONTRAST = 12.0
MIN_MEAN_LUMA = 8.0
# --- metrics ----------------------------------------------------------------
def _luma_gray(image):
return image.convert("L")
def metrics(image_path):
"""Measure one image. Everything is on the 8-bit luma/RGB 0..255 scale."""
with Image.open(image_path) as handle:
image = handle.convert("RGB")
width, height = image.size
gray = _luma_gray(image)
stat = ImageStat.Stat(gray)
# Edge density: fraction of pixels above a low threshold after FIND_EDGES.
# Stands in for "how much relief and silhouette detail is in the frame".
# Filter, then crop: FIND_EDGES samples outside the image as zero, so it leaves
# its own border ring at a high value (236 of 3600 px on a 60x60 frame, 6.5%),
# which would make a perfectly flat picture look detailed. Cropping *before*
# filtering does not help — the new border gets the same treatment.
filtered = gray.filter(ImageFilter.FIND_EDGES)
inner = filtered.crop((2, 2, max(3, width - 2), max(3, height - 2)))
hist = inner.histogram()
area = float(inner.width * inner.height)
edge_pixels = sum(hist[24:])
edge_density = edge_pixels / area if area else 0.0
# Palette: quantise and keep the dominant colours with their share.
total = float(width * height)
quantised = image.quantize(colors=12, method=Image.Quantize.MEDIANCUT)
palette = quantised.getpalette() or []
counts = sorted(quantised.getcolors() or [], reverse=True)
dominant = []
for count, index in counts[:5]:
base = index * 3
if base + 2 < len(palette):
dominant.append({
"rgb": palette[base:base + 3],
"share": round(count / total, 3),
})
# 3x3 composition grid of mean luma, so "too much sky" is measurable.
grid = []
for row in range(3):
for col in range(3):
box = (int(col * width / 3), int(row * height / 3),
int((col + 1) * width / 3), int((row + 1) * height / 3))
grid.append(round(ImageStat.Stat(gray.crop(box)).mean[0], 1))
return {
"path": os.path.relpath(image_path, ROOT),
"size": [width, height],
"mean_luma": round(stat.mean[0], 1),
"rms_contrast": round(stat.stddev[0], 1),
"edge_density": round(edge_density, 4),
"dominant": dominant,
"grid_luma": grid,
}
def _palette_distance(a, b):
"""Mean nearest-colour distance between two dominant palettes, 0..441."""
if not a or not b:
return None
total = 0.0
for entry in a:
pa = entry["rgb"]
best = min(
sum((pa[i] - pb["rgb"][i]) ** 2 for i in range(3)) ** 0.5 for pb in b)
total += best
return round(total / len(a), 1)
def _correlation(values_a, values_b):
n = len(values_a)
mean_a = sum(values_a) / n
mean_b = sum(values_b) / n
var_a = sum((v - mean_a) ** 2 for v in values_a)
var_b = sum((v - mean_b) ** 2 for v in values_b)
if var_a <= 0.0 or var_b <= 0.0:
return 0.0
cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(values_a, values_b))
return round(cov / (var_a * var_b) ** 0.5, 3)
def compare(shot_metrics, reference_metrics):
"""Delta between a render and a reference, plus a 0..1 similarity score."""
luma = shot_metrics["mean_luma"] - reference_metrics["mean_luma"]
contrast_ratio = (shot_metrics["rms_contrast"] /
reference_metrics["rms_contrast"]) if reference_metrics["rms_contrast"] else 1.0
edge_ratio = (shot_metrics["edge_density"] /
reference_metrics["edge_density"]) if reference_metrics["edge_density"] else 1.0
palette = _palette_distance(shot_metrics["dominant"], reference_metrics["dominant"])
composition = _correlation(shot_metrics["grid_luma"], reference_metrics["grid_luma"])
# Score: luminance within 25, contrast and edge ratios near 1, palette near 0,
# composition correlated. Each term is clamped to 0..1 and equally weighted.
def near(value, target, span):
return max(0.0, 1.0 - abs(value - target) / span)
score = (
near(luma, 0.0, 25.0)
+ near(contrast_ratio, 1.0, 1.0)
+ near(edge_ratio, 1.0, 1.5)
+ (1.0 if palette is None else near(palette, 0.0, 120.0))
+ max(0.0, composition)
) / 5.0
return {
"delta_luma": round(luma, 1),
"contrast_ratio": round(contrast_ratio, 2),
"edge_ratio": round(edge_ratio, 2),
"palette_distance": palette,
"composition_correlation": composition,
"score": round(score, 3),
}
# --- feedback ---------------------------------------------------------------
# Each rule maps a measured delta to an action phrased for the Blender skills.
def feedback(name, shot, reference, delta):
notes = []
if delta["edge_ratio"] < 0.6:
notes.append(
f"**Relief and silhouette detail** are {1 - delta['edge_ratio']:.0%} below "
f"the reference (`blender-modeler` / `archviz`): add window rhythm, "
f"pilasters, cornices or quoins that cast a shadow line.")
elif delta["edge_ratio"] > 1.8:
notes.append(
"**Too busy** against the reference: the render has far more edge detail. "
"Check for stray kit pieces, z-fighting or duplicated geometry.")
if delta["delta_luma"] <= -15:
notes.append(
f"**Too dark** by {abs(delta['delta_luma']):.0f} luma (`materials` / "
f"`lighting`): raise the material albedo or the exposure.")
elif delta["delta_luma"] >= 15:
notes.append(
f"**Too bright** by {delta['delta_luma']:.0f} luma (`materials` / "
f"`lighting`): lower the albedo or the exposure.")
if delta["contrast_ratio"] < 0.7:
notes.append(
"**Value range compressed** (`lookdev`): the reference has much stronger "
"light/shadow separation. Deepen recesses or widen the material range.")
elif delta["contrast_ratio"] > 1.5:
notes.append(
"**Value range too harsh** (`lookdev`): soften the key or the shadow "
"contrast so the surface does not clip.")
if delta["palette_distance"] is not None and delta["palette_distance"] > 60:
notes.append(
f"**Hue drift** ({delta['palette_distance']:.0f} palette distance, "
f"`materials`): compare the dominant colours in the iteration report "
f"against the reference and correct the base colours.")
if delta["composition_correlation"] < 0.3:
notes.append(
"**Framing differs** (`camera-cinematography`): the 3x3 luma grid does "
"not match the reference. Check the camera height, lens and how much "
"sky/ground is in frame before changing the model.")
if not notes:
notes.append(
"No metric is outside its tolerance. Differences left are stylistic; "
"confirm visually rather than by number.")
return notes
# --- verification -----------------------------------------------------------
def verify(targets, judge=False):
"""Check that each shot exists, is not broken, and looks like its expectation."""
results = []
for target in targets:
shot = os.path.join(ROOT, target["shot"])
entry = {"name": target["name"], "shot": target["shot"], "expect": target["expect"]}
if not os.path.exists(shot):
entry["status"] = "MISSING"
results.append(entry)
continue
m = metrics(shot)
problems = []
if m["edge_density"] < MIN_EDGE_DENSITY:
problems.append(f"almost flat (edge density {m['edge_density']})")
if m["rms_contrast"] < MIN_CONTRAST:
problems.append(f"no tonal range (contrast {m['rms_contrast']})")
if m["mean_luma"] < MIN_MEAN_LUMA:
problems.append(f"black frame (luma {m['mean_luma']})")
expected_size = target.get("size")
if expected_size and m["size"] != expected_size:
problems.append(f"size {m['size']} != {expected_size}")
entry["metrics"] = {k: m[k] for k in
("size", "mean_luma", "rms_contrast", "edge_density")}
entry["status"] = "FAIL" if problems else "OK"
entry["problems"] = problems
if judge and os.environ.get("OPENROUTER_API_KEY") or (
judge and os.path.exists(os.path.join(ROOT, ".env"))):
entry["judge"] = run_judge(shot, target["expect"])
results.append(entry)
return results
def run_judge(shot, expect):
"""Advisory vision check. Never a gate: the model is unreliable on this content."""
import subprocess
try:
proc = subprocess.run(
[sys.executable, os.path.join(ROOT, "Tools", "judge_screenshot.py"),
"--image", shot, "--expect", expect],
capture_output=True, text=True, timeout=180, cwd=ROOT)
out = (proc.stdout or "") + (proc.stderr or "")
for line in out.splitlines():
if line.startswith("verdict="):
return line.strip()
return "unavailable"
except Exception as exc: # noqa: BLE001 - advisory only
return f"error: {exc}"
# --- reporting --------------------------------------------------------------
def write_iteration(iteration, rows, args):
"""Write the iteration artefacts (gitignored) and return the directory."""
out_dir = os.path.join(ITER_ROOT, str(iteration))
os.makedirs(out_dir, exist_ok=True)
for row in rows:
shot = os.path.join(ROOT, row["shot"])
if os.path.exists(shot):
shutil.copy2(shot, os.path.join(out_dir, os.path.basename(shot)))
with open(os.path.join(out_dir, "metrics.json"), "w", encoding="utf-8") as handle:
json.dump({"iteration": iteration, "targets": rows}, handle, indent=2)
lines = [f"# Iteration {iteration} — reference vs render", ""]
for row in rows:
lines.append(f"## {row['name']}")
lines.append("")
lines.append(f"- shot: `{row['shot']}`")
if row.get("reference"):
lines.append(f"- reference: `{row['reference']}`")
delta = row.get("delta")
if delta:
lines.append(
f"- **score {delta['score']:.2f}** · luma {delta['delta_luma']:+.0f} · "
f"contrast ×{delta['contrast_ratio']} · edges ×{delta['edge_ratio']} · "
f"palette {delta['palette_distance']} · composition r={delta['composition_correlation']}")
lines.append("")
for note in row.get("feedback", []):
lines.append(f"- {note}")
lines.append("")
path = os.path.join(out_dir, "feedback.md")
with open(path, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
return out_dir, path
def load_targets():
with open(TARGETS, encoding="utf-8") as handle:
return json.load(handle)["targets"]
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--verify", action="store_true",
help="gate the current shots against their expectations")
ap.add_argument("--compare", action="store_true",
help="measure each shot against its reference(s)")
ap.add_argument("--feedback", action="store_true", help="include action notes (default with --compare)")
ap.add_argument("--judge", action="store_true", help="add the advisory vision verdict")
ap.add_argument("--iteration", default=None, help="iteration label for the output directory")
args = ap.parse_args(argv)
if not (args.verify or args.compare):
args.verify = args.compare = True
targets = load_targets()
if args.verify:
print("== verification ==")
results = verify(targets, judge=args.judge)
failed = [r for r in results if r["status"] != "OK"]
for r in results:
extra = f" ({'; '.join(r['problems'])})" if r.get("problems") else ""
judge = f" [judge: {r['judge']}]" if r.get("judge") else ""
print(f" {r['status']:7} {r['name']:32}{extra}{judge}")
print(f" -> {len(results) - len(failed)}/{len(results)} shots OK")
if args.compare:
print("\n== comparison ==")
rows = []
for target in targets:
shot_path = os.path.join(ROOT, target["shot"])
if not os.path.exists(shot_path):
continue
shot_m = metrics(shot_path)
best = None
for ref_rel in target.get("references", []):
ref_path = os.path.join(ROOT, ref_rel)
if not os.path.exists(ref_path):
continue
ref_m = metrics(ref_path)
delta = compare(shot_m, ref_m)
if best is None or delta["score"] > best[1]["score"]:
best = (ref_rel, delta, ref_m)
if best is None:
continue
ref_rel, delta, ref_m = best
rows.append({
"name": target["name"],
"shot": target["shot"],
"reference": ref_rel,
"delta": delta,
"feedback": feedback(target["name"], shot_m, ref_m, delta),
"expect": target["expect"],
})
print(f" {target['name']:32} score {delta['score']:.2f} "
f"luma {delta['delta_luma']:+.0f} edges ×{delta['edge_ratio']}")
if args.iteration is not None:
out_dir, feedback_path = write_iteration(args.iteration, rows, args)
print(f"\n iteration written to {os.path.relpath(out_dir, ROOT)}")
print(f" feedback: {os.path.relpath(feedback_path, ROOT)}")
print(" (gitignored: promote a shot into Screenshots/ only after owner confirmation)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
{
"comment": "What each demo-map capture must show, and which reference photograph it is measured against. Used by Tools/visual_loop.py --verify (does the shot show the expected content?) and --compare (how far is the render from the reference?).",
"targets": [
{
"name": "demo-spawn-view",
"shot": "Screenshots/demo/review-demo-spawn-view.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
],
"expect": "The whole south block from the north sidewalk: the neoclassical hero facade between its neighbouring buildings, both sidewalks and the road in the foreground, daylight"
},
{
"name": "demo-street-level",
"shot": "Screenshots/demo/review-demo-street-level.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg"
],
"expect": "Street level from the north side: the hero colonnade with the adjacent frontage buildings either side, shopfronts at pavement level, daylight"
},
{
"name": "demo-crossing",
"shot": "Screenshots/demo/review-demo-crossing.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg"
],
"expect": "The designated mid-block crossing: continental zebra bars on the asphalt, the south frontage and hero colonnade behind, daylight"
},
{
"name": "demo-north-buildings",
"shot": "Screenshots/demo/review-demo-north-buildings.png",
"size": [
1920,
1080
],
"references": [
"Art/reference/north-chinese/01-grauman-s-chinese-theatre-by-carol-highsmith.jpg"
],
"expect": "The two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) seen from across Hollywood Boulevard, full height in frame, daylight"
}
]
}
+1 -1
View File
@@ -1 +1 @@
requests>=2.28 Pillow>=9.2