3 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
zwitschi d6d3d54810 fix(tools): importers clear the read-only LFS bit before saving; 0.1.3 docs refresh (#29)
Merged PR #29: v0.1.3 packaging and setup fixes

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
2026-11-08 18:53:48 +01:00
zwitschi ba68dcfdbc feat(hero): v0.1.2 — scale calibration, façade detail, export preset refactor (#28)
Merged PR #28: v0.1.2 — scale calibration, façade detail, export preset

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
2026-11-08 18:48:33 +01:00
119 changed files with 3820 additions and 703 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.
Binary file not shown.
+35 -8
View File
@@ -3,18 +3,18 @@
"frame": "project frame, metres, X east Y north Z up", "frame": "project frame, metres, X east Y north Z up",
"bounds_m": { "bounds_m": {
"min": [ "min": [
-13.02, -13.08,
-12.5, -12.5,
0.0 0.0
], ],
"max": [ "max": [
13.02, 13.08,
0.07, 0.13,
14.5 14.5
], ],
"size": [ "size": [
26.04, 26.16,
12.57, 12.63,
14.5 14.5
] ]
}, },
@@ -37,6 +37,24 @@
], ],
"evidence": "6 columns at 4.96 m centres (sibling notes x measured frontage)" "evidence": "6 columns at 4.96 m centres (sibling notes x measured frontage)"
}, },
{
"object": "Hero_ColumnBase_x6",
"dims_m": [
1.36,
1.36,
0.22
],
"evidence": "assumption: Ionic base moulding; photographs show a wider base at the shaft foot"
},
{
"object": "Hero_Abacus_x6",
"dims_m": [
1.3,
1.3,
0.14
],
"evidence": "assumption: square abacus under the architrave, as Ionic capitals carry"
},
{ {
"object": "Hero_Architrave", "object": "Hero_Architrave",
"dims_m": [ "dims_m": [
@@ -55,6 +73,15 @@
], ],
"evidence": "assumption: projecting cornice line" "evidence": "assumption: projecting cornice line"
}, },
{
"object": "Hero_Dentil_xN",
"dims_m": [
0.18,
0.3,
0.22
],
"evidence": "52 dentils at 0.45 m centres under the corona; assumption: photographed cornice reads as a dentilled course"
},
{ {
"object": "Hero_Entablature", "object": "Hero_Entablature",
"dims_m": [ "dims_m": [
@@ -86,10 +113,10 @@
"object": "Hero_Medallion_x2", "object": "Hero_Medallion_x2",
"dims_m": [ "dims_m": [
1.1, 1.1,
1.1, 0.04,
0.02 1.1
], ],
"evidence": "photographs: two stone discs beside the motto" "evidence": "photographs: two stone discs beside the motto, mounted on the parapet face"
}, },
{ {
"object": "Hero_Stylobate", "object": "Hero_Stylobate",
+4 -14
View File
@@ -15,6 +15,9 @@ import os
import sys import sys
import bpy import bpy
sys.path.insert(0, os.path.join(os.path.join(os.path.dirname(__file__), "..", "..", ".."), "Tools"))
import fbx_export_preset as fbx_preset # noqa: E402
MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North", MESHES = ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
"Kerb_South", "Kerb_North", "Crossing_Markings") "Kerb_South", "Kerb_North", "Crossing_Markings")
@@ -60,20 +63,7 @@ def main():
_ = bpy.context.view_layer _ = bpy.context.view_layer
bpy.context.view_layer.objects.active = obj bpy.context.view_layer.objects.active = obj
path = os.path.join(args.out, name + ".fbx") path = os.path.join(args.out, name + ".fbx")
bpy.ops.export_scene.fbx( fbx_preset.export_static(path, selection=True)
filepath=path,
use_selection=True,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
if mirrored: if mirrored:
# The flip is an export correction, not a change to the source: undo it. # The flip is an export correction, not a change to the source: undo it.
obj.scale.y *= -1.0 obj.scale.y *= -1.0
+136 -90
View File
@@ -8,127 +8,173 @@ Format: [Keep a Changelog](https://keepachangelog.com/) style, versions follow [
Nothing yet. Nothing yet.
## [0.1.1] — 2026-11-08 ## [0.1.4] — 2026-11-09
Collision-fidelity patch. No new scope: street geometry corrections and the QA 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.
tooling that measures them.
### Fixed
- **Sidewalk outer edge ledge.** The sidewalk stood the full 0.15 m kerb height
above the DEM at both edges, leaving a 15.8 cm lip where the walk route crossed
from terrain onto the south sidewalk. The upstand now tapers from full at the
road-side edge to zero at the building-side edge; the north route's worst step
fell from 15.1 cm to 6.9 cm.
- **Crossing ramps too steep.** The kerb-flush run was a fixed 1.5 m, giving
~8.7 % (measured 10–13 cm over 1.5 m). The run is now derived from a target
grade (`--max-ramp-grade`, default 8 % ⇒ 1.63 m); the crossing steps 3.4 cm at
0.25 m sampling.
### Added ### Added
- **Walk pre-check improvements** (`Art/blender/site/site_assembly.py`): sampling - **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.
coverage is independent of `--sample` (it previously shrank the route), and the - **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.
report now prints the grade and the arc-length/offset **location** of the worst - **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.
step, so a step can be located rather than guessed. - **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 ### Changed
- **Kerb height recorded as final** at 0.15 m modelled / 0.13 m as built — a - **Elevation cameras** are levelled (pitch 0, so verticals stay parallel) and use a 42–55° lens to fill the frame from across the street.
standard 6 in LA kerb; neither the 3DEP DEM nor the reference photography can - **`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.
resolve it further (±2 cm), which is far below the character's 45 cm step
height. ### 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 ### Verified
- Site meshes rebuilt and re-imported (7/7, collision flags intact), DemoMap - 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.
rebuilt, packaged Development build `EXIT=0`. - All eight buildings probed against the verified world spans.
- `-qaselftest` on the packaged build: walk 761 cm in 2 s at steady Z 456, reset - 67 tool tests pass.
0 cm, pause/unpause, transect 1456 samples across 16 rows / **0 holes**, wall
probe **0/35** blocking hits.
### Known limitations ### Known limitations
- One residual **13.4 cm surface step at s≈198 m** on the south sidewalk (8.0 cm - Kit pieces are boxes; cornice dentil spacing, abacus and plinth dimensions are recorded assumptions.
at s≈251 m north). It is not a gap and is below the 45 cm `MaxStepHeight`, so it - Textures are generated, not authored: no decals, signage text or weathering maps.
does not block traversal; the cause is a sidewalk-strip discontinuity near a - The vision pre-check remains advisory — it is unreliable on this content.
centreline vertex, recorded in `Art/blender/README.md` as a smoothness - Retail neighbours, hero fluting and the blockout cleaning pass move to `0.1.5`.
refinement. - Performance has not been re-measured since `0.1.0`; 625 kit actors are unmeasured.
- Kerb radii at junctions, driveway aprons, drainage inlets and pavement joints
remain unmodelled (unchanged from `0.1.0`). ## [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.
### Fixed
- **Map imports could report success while saving nothing.** `.umap` files are LFS-tracked and check out read-only, so `save_map()` returned `False` with no error and the staged actor vanished on the next load. New `Tools/ue_common.py` (`make_writable`, `require_saved`) clears the read-only bit and makes the failure loud; `ue_hero_facade_import.py` and `ue_import_north_buildings.py` now use it. Verified from a forced `0o444` map: chmod logged, `save_map: True`, exit 0.
### Changed
- `Docs/build.md`: content-pipeline table now lists every step as it exists (seven site FBXs, frontage, north buildings, hero, map) and the shared FBX preset; packaging archive corrected to `Builds/GigLineQA`; the packaged log location and `-qaselftest` usage are documented; new **traps** table covers the read-only save, the hero `--out`-is-the-FBX mistake, the MPFB add-on path, the shot actor quitting under QA, and the null-RHI trace trap.
- `Docs/setup.md`: the fresh-checkout build is marked verified (2026-11-08) and its blocker closed; a new section documents the two optional keys (`OPENROUTER_API_KEY`, `MAPBOX_ACCESS_TOKEN`) and that neither is needed by the build, the tests or a release.
- `Docs/Architecture/07_deployment_view.md` and the combined `architecture.md`: build-output path corrected and marked as living outside the repository.
### Verified
- 54 tool tests pass (5 new for `ue_common`).
- Packaged build still launches into `DemoMap` and `-qaselftest` passes every phase.
## [0.1.2] — 2026-11-09
Scale and façade correction patch. Buildings and the hero elevation now match the reference photography and the cross-checked height data; no new gameplay scope.
### Added
- **`Tools/facade_detail_concepts.py`** — generates frontal building plates (whole elevation, entrance in its façade, building with neighbours) from a local reference photograph through OpenRouter image models. Spec-driven, never overwrites, writes a provenance sidecar per image, output stays in the gitignored `Art/reference/` tree. Local working material only.
- **`Tools/fbx_export_preset.py`** — the single FBX export preset (`ue_metric_v1` / `ue_character_v1`). Replaces the argument block that had been copied into six exporters.
- **Façade occlusion probe** in `-qaselftest`: 40 m traces sideways from the road centre at walking height, required wherever a building is staged.
- **Ionic detail on the hero**: base moulding at every shaft foot, a square abacus over every capital, and a 52-dentil cornice course.
- **Tool tests**: `Tools/tests/` now covers the input driver, height classification, the Mapbox cross-check, the concept generator and the FBX preset (49 tests).
### Changed
- **Storey height 3.2 → 3.1 m**, calibrated against Mapbox building data, with explicit overrides for three rear outbuildings; the block cross-check now reports 66 matches and no disagreements.
- **Hero colonnade 47.6 % → 53.1 %** of the 14.50 m elevation, inside the photographed 50–60 % band (thinner entablature and parapet).
- Blockout regenerated at the new storey height; frontage re-exported and re-imported (6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m).
- FBX exports now carry smoothing groups (`FACE` instead of `OFF`), so round geometry is no longer faceted and imports stop warning.
### Fixed
- **Windows floated 1.44 m in front of the wall**, ahead of the colonnade; they now sit on the wall plane behind the columns.
- **Parapet medallions were flat discs lying on the coping** instead of discs on the parapet face.
- **The hero façade had no usable collision** — the player could walk through it. It is now complex-as-simple; auto-generated convex hulls were tried and rejected because they bulged into the street.
- **The three "guessed" block heights were never buildings**: two `building=roof` canopies and a transit shelter. They are classified as non-building volumes and the block now reports zero guessed heights.
### Verified
- Packaged build `-qaselftest`: walk, reset and pause pass; seam transect 1456 samples / 0 holes; wall probe 0/35; façade probe 0/9.
- Screenshots re-rendered for every change and approved by the owner.
### Known limitations
- Cornice dentil spacing, the base and abacus dimensions are recorded assumptions: the reference photography does not resolve them at our scale.
- Façade surfaces remain flat blockout materials; the textured pass is `0.1.4`.
## [0.1.1] — 2026-11-08
Collision-fidelity patch. No new scope: street geometry corrections and the QA tooling that measures them.
### Fixed
- **Sidewalk outer edge ledge.** The sidewalk stood the full 0.15 m kerb height above the DEM at both edges, leaving a 15.8 cm lip where the walk route crossed from terrain onto the south sidewalk. The upstand now tapers from full at the road-side edge to zero at the building-side edge; the north route's worst step fell from 15.1 cm to 6.9 cm.
- **Crossing ramps too steep.** The kerb-flush run was a fixed 1.5 m, giving ~8.7 % (measured 10–13 cm over 1.5 m). The run is now derived from a target grade (`--max-ramp-grade`, default 8 % ⇒ 1.63 m); the crossing steps 3.4 cm at 0.25 m sampling.
### Added
- **Walk pre-check improvements** (`Art/blender/site/site_assembly.py`): sampling coverage is independent of `--sample` (it previously shrank the route), and the report now prints the grade and the arc-length/offset **location** of the worst step, so a step can be located rather than guessed.
### Changed
- **Kerb height recorded as final** at 0.15 m modelled / 0.13 m as built — a standard 6 in LA kerb; neither the 3DEP DEM nor the reference photography can resolve it further (±2 cm), which is far below the character's 45 cm step height.
### Verified
- Site meshes rebuilt and re-imported (7/7, collision flags intact), DemoMap rebuilt, packaged Development build `EXIT=0`.
- `-qaselftest` on the packaged build: walk 761 cm in 2 s at steady Z 456, reset 0 cm, pause/unpause, transect 1456 samples across 16 rows / **0 holes**, wall probe **0/35** blocking hits.
### Known limitations
- One residual **13.4 cm surface step at s≈198 m** on the south sidewalk (8.0 cm at s≈251 m north). It is not a gap and is below the 45 cm `MaxStepHeight`, so it does not block traversal; the cause is a sidewalk-strip discontinuity near a centreline vertex, recorded in `Art/blender/README.md` as a smoothness refinement.
- Kerb radii at junctions, driveway aprons, drainage inlets and pavement joints remain unmodelled (unchanged from `0.1.0`).
Nothing yet. Nothing yet.
## [0.1.0] — 2026-11-08 ## [0.1.0] — 2026-11-08
The first playable release: a walkable exterior demo of the **6840 Hollywood Blvd** The first playable release: a walkable exterior demo of the **6840 Hollywood Blvd** (El Capitan Entertainment Centre) block in Unreal Engine, built on OpenStreetMap layout data and USGS elevation, with the hero façade and the verified block frontage staged on a walkable street.
(El Capitan Entertainment Centre) block in Unreal Engine, built on OpenStreetMap
layout data and USGS elevation, with the hero façade and the verified block
frontage staged on a walkable street.
### Added ### Added
- **Unreal project** (`Game/GigLine`) — C++ game module, Enhanced Input, packaged - **Unreal project** (`Game/GigLine`) — C++ game module, Enhanced Input, packaged Linux build, and a `DemoMap` that a plain launch opens directly.
Linux build, and a `DemoMap` that a plain launch opens directly. - **Walkable demo level** — terrain from the 3DEP DEM, Hollywood Blvd carriageway, both sidewalks and kerbs with collision, the designated mid-block crossing, and fixed daytime lighting (sun, sky light, sky atmosphere, height fog, pinned exposure).
- **Walkable demo level** — terrain from the 3DEP DEM, Hollywood Blvd carriageway, - **Hero façade** for 6840 Hollywood Blvd — six-column neoclassical elevation with three genuine portal recesses, entablature, parapet and motto band (`Tools/hero_facade_build.py`, `Tools/ue_hero_facade_import.py`).
both sidewalks and kerbs with collision, the designated mid-block crossing, and - **Block frontage** — the five verified south-side neighbours (6922, 6902, 6838 El Capitan Theatre, 6822 Budget, 6806–6822, 6800 Lee Drug) and the two north-side buildings (TCL Chinese Theatre, Madame Tussaud's) placed at their OSM positions.
fixed daytime lighting (sun, sky light, sky atmosphere, height fog, pinned - **First-person player** — `AGigLineCharacter`: 1.75 m capsule, 1.60 m eye camera, 4 m/s walk, mouse look, jump, reset (`R`) and pause (`Escape`); controls documented in `Docs/controls.md` and shown in-game.
exposure). - **In-game ODbL attribution** — `AGigLineHUD` draws the OpenStreetMap credit and licence line every frame.
- **Hero façade** for 6840 Hollywood Blvd — six-column neoclassical elevation with - **QA harness** — `-qaselftest` (walk, reset, pause, seam transect, invisible-wall probe, frame-time capture) and `Tools/input_test.py` / `Tools/input_emulation.py` for OS-level virtual keyboard/mouse input.
three genuine portal recesses, entablature, parapet and motto band - **Documentation** — setup, build, toolchain, licensing, ODbL review, release checks, and arc42 architecture docs.
(`Tools/hero_facade_build.py`, `Tools/ue_hero_facade_import.py`).
- **Block frontage** — the five verified south-side neighbours (6922, 6902, 6838
El Capitan Theatre, 6822 Budget, 6806–6822, 6800 Lee Drug) and the two north-side
buildings (TCL Chinese Theatre, Madame Tussaud's) placed at their OSM positions.
- **First-person player** — `AGigLineCharacter`: 1.75 m capsule, 1.60 m eye camera,
4 m/s walk, mouse look, jump, reset (`R`) and pause (`Escape`); controls
documented in `Docs/controls.md` and shown in-game.
- **In-game ODbL attribution** — `AGigLineHUD` draws the OpenStreetMap credit and
licence line every frame.
- **QA harness** — `-qaselftest` (walk, reset, pause, seam transect, invisible-wall
probe, frame-time capture) and `Tools/input_test.py` / `Tools/input_emulation.py`
for OS-level virtual keyboard/mouse input.
- **Documentation** — setup, build, toolchain, licensing, ODbL review, release
checks, and arc42 architecture docs.
### Fixed ### Fixed
- **All controls were dead in packaged and PIE builds.** The mapping-context - **All controls were dead in packaged and PIE builds.** The mapping-context generator wrote key bindings to the deprecated `mappings` array; UE 5.8 reads `DefaultKeyMappings.Mappings`, so the cooked game had zero runtime mappings and every key resolved to nothing. `Tools/ue_create_input_assets.py` now populates the current field.
generator wrote key bindings to the deprecated `mappings` array; UE 5.8 reads - **"Fall-through" was an 8 m invisible box.** `Terrain_DEM` shipped only a bounding-box collision proxy; forcing complex-as-simple collision (`UGigLineAssetTools`) put the player on the actual street.
`DefaultKeyMappings.Mappings`, so the cooked game had zero runtime mappings and - **Reset returned the player to mid-air** — the start position is now captured on the first grounded frame.
every key resolved to nothing. `Tools/ue_create_input_assets.py` now populates - **Plain launch opened the engine template** — the default map now lives in `DefaultEngine.ini` and points at `DemoMap`.
the current field. - **Game target would not compile** — removed editor-only API use (`UStaticMesh::PostEditChange`, `AActor::GetActorLabel`).
- **"Fall-through" was an 8 m invisible box.** `Terrain_DEM` shipped only a - **`demo-north-buildings` screenshot** framed 85 % sky (camera 9.5 km west of the buildings); re-pointed to the building mass.
bounding-box collision proxy; forcing complex-as-simple collision
(`UGigLineAssetTools`) put the player on the actual street.
- **Reset returned the player to mid-air** — the start position is now captured on
the first grounded frame.
- **Plain launch opened the engine template** — the default map now lives in
`DefaultEngine.ini` and points at `DemoMap`.
- **Game target would not compile** — removed editor-only API use
(`UStaticMesh::PostEditChange`, `AActor::GetActorLabel`).
- **`demo-north-buildings` screenshot** framed 85 % sky (camera 9.5 km west of the
buildings); re-pointed to the building mass.
### Verified ### Verified
- Both sides of the demo are traversable: 1456-sample seam transect, 0 holes, - Both sides of the demo are traversable: 1456-sample seam transect, 0 holes, no blocking collision or invisible walls (`-qaselftest` on the packaged build).
no blocking collision or invisible walls (`-qaselftest` on the packaged build). - Placement and street scale match the reference photography and Geo measurements (road 24.3 m, sidewalk offsets 12.5/13.2 m, hero frontage 26.0 m).
- Placement and street scale match the reference photography and Geo - **414 fps average / p99 3.01 ms at 2560×1440** (Epic preset, TSR, VSync off, no frame generation) — far above the 60 fps target.
measurements (road 24.3 m, sidewalk offsets 12.5/13.2 m, hero frontage 26.0 m). - Fresh checkout builds and packages with the documented external engine (UE 5.8.4).
- **414 fps average / p99 3.01 ms at 2560×1440** (Epic preset, TSR, VSync off, no
frame generation) — far above the 60 fps target.
- Fresh checkout builds and packages with the documented external engine
(UE 5.8.4).
### Known limitations ### Known limitations
- Buildings other than the hero are flat blockout volumes; façade detail, - Buildings other than the hero are flat blockout volumes; façade detail, materials and Blosm style scripts are planned for `0.1.4`.
materials and Blosm style scripts are planned for `0.1.4`. - Kerb height (0.13 m as built vs 0.15 m assumed), kerb radii, driveway aprons and graded crossing ramps are deferred to `0.1.1`; crossing ramps are kerb flushes.
- 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.1...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.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
[0.1.0]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.0 [0.1.0]: https://git.allucanget.biz/zwitschi/gig-line/releases/tag/v0.1.0
+1 -1
View File
@@ -7,7 +7,7 @@
| Dev workstation | Bazzite Linux 45 (Fedora 45 base, immutable ostree), Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB, Vulkan RHI | | Dev workstation | Bazzite Linux 45 (Fedora 45 base, immutable ostree), Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB, Vulkan RHI |
| Engine install | `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, external to repo) | | Engine install | `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, external to repo) |
| Project repo | `/run/media/system/DATA/Documents/06-games/gig-line` (Git + LFS, remote at `git.allucanget.biz`) | | Project repo | `/run/media/system/DATA/Documents/06-games/gig-line` (Git + LFS, remote at `git.allucanget.biz`) |
| Build output | `/run/media/system/DATA/Builds/GigLine` (packaged Linux build, ~1.1 GiB) | | Build output | `/run/media/system/DATA/Builds/GigLineQA/Linux` (packaged Development build, ~1.1 GiB; outside the repository) |
Motivation Motivation
Single-machine development. No build farm, no CI pipeline yet. The engine, toolchain, and GPU are all on the same host. Single-machine development. No build farm, no CI pipeline yet. The engine, toolchain, and GPU are all on the same host.
+1 -1
View File
@@ -300,7 +300,7 @@ Source data: `Art/blender/` (`.blend` files), `Geo/` (OSM extracts, DEM, verific
| Dev workstation | Bazzite Linux 45 (Fedora 45 base, immutable ostree), Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB, Vulkan RHI | | Dev workstation | Bazzite Linux 45 (Fedora 45 base, immutable ostree), Ryzen 7 7700X, 64 GB RAM, RTX 5070 Ti 16 GB, Vulkan RHI |
| Engine install | `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, external to repo) | | Engine install | `/run/media/system/DATA/Engines/UE_5.8.4` (73 GiB, external to repo) |
| Project repo | `/run/media/system/DATA/Documents/06-games/gig-line` (Git + LFS, remote at `git.allucanget.biz`) | | Project repo | `/run/media/system/DATA/Documents/06-games/gig-line` (Git + LFS, remote at `git.allucanget.biz`) |
| Build output | `/run/media/system/DATA/Builds/GigLine` (packaged Linux build, ~1.1 GiB) | | Build output | `/run/media/system/DATA/Builds/GigLineQA/Linux` (packaged Development build, ~1.1 GiB; outside the repository) |
Motivation Motivation
Single-machine development. No build farm, no CI pipeline yet. The engine, toolchain, and GPU are all on the same host. Single-machine development. No build farm, no CI pipeline yet. The engine, toolchain, and GPU are all on the same host.
+45 -37
View File
@@ -1,6 +1,6 @@
# Build # Build
Fresh-checkout build procedure. Completed 2026-11-07 after the pinned engine was installed and a blank `Game/GigLine` build was packaged and launch-tested. Fresh-checkout build, content-pipeline and packaging procedure for the walkable demo. Begun 2026-11-07 from a blank build and extended through the `0.1.x` patches; the release checks that exercise it are in [release-checks.md](release-checks.md).
## Constraints ## Constraints
@@ -10,8 +10,7 @@ Fresh-checkout build procedure. Completed 2026-11-07 after the pinned engine was
## Engine ## Engine
Pinned install: **Unreal Engine 5.8.4** (CL 58902052, promoted build) at Pinned install: **Unreal Engine 5.8.4** (CL 58902052, promoted build) at `/run/media/system/DATA/Engines/UE_5.8.4`. The engine bundles **no** Linux clang; the system clang (23.1.4, Fedora 45) is the game-code toolchain. Game targets must use `BuildSettingsVersion.V7` to share the installed editor's build environment.
`/run/media/system/DATA/Engines/UE_5.8.4`. The engine bundles **no** Linux clang; the system clang (23.1.4, Fedora 45) is the game-code toolchain. Game targets must use `BuildSettingsVersion.V7` to share the installed editor's build environment.
## Fresh-checkout build ## Fresh-checkout build
@@ -42,20 +41,14 @@ cd /run/media/system/DATA/Engines/UE_5.8.4
Engine/Build/BatchFiles/RunUAT.sh BuildCookRun \ Engine/Build/BatchFiles/RunUAT.sh BuildCookRun \
-project="/run/media/system/DATA/Documents/06-games/gig-line/Game/GigLine/GigLine.uproject" \ -project="/run/media/system/DATA/Documents/06-games/gig-line/Game/GigLine/GigLine.uproject" \
-noP4 -platform=Linux -clientconfig=Development \ -noP4 -platform=Linux -clientconfig=Development \
-cook -allmaps -build -stage -pak \ -cook -build -stage -pak \
-archive -archivedirectory="/run/media/system/DATA/Builds/GigLine" \ -archive -archivedirectory="/run/media/system/DATA/Builds/GigLineQA" \
-unattended -unattended
# Expected: "BUILD SUCCESSFUL" (~1 min for the blank project; archive ~1.1 GiB) # Expected: "AutomationTool exiting with ExitCode=0 (Success)" and "EXIT=0", ~90 s
# Launch test (plain, no -map): /run/media/system/DATA/Builds/GigLine/Linux/GigLine.sh # Launch test (plain, no -map): /run/media/system/DATA/Builds/GigLineQA/Linux/GigLine.sh
``` ```
**Launch test must open the DemoMap.** A plain `./GigLine.sh` should load **Launch test must open the DemoMap.** A plain `./GigLine.sh` should load `/Game/Hollywood/DemoMap` (set via `DefaultEngine.ini` `GameMapsSettings`). If it instead opens `/Engine/Maps/Templates/OpenWorld`, the game shows an empty non-interactive "stuck" scene — check that `GameDefaultMap` is in `DefaultEngine.ini`, not `DefaultGame.ini`. Confirm the loaded map in the engine log: `.../Linux/GigLine/Saved/Logs/GigLine.log` (`LoadMap: /Game/Hollywood/DemoMap`). The archive directory is deliberately **outside** the repository; never archive into the checkout.
`/Game/Hollywood/DemoMap` (set via `DefaultEngine.ini` `GameMapsSettings`). If it
instead opens `/Engine/Maps/Templates/OpenWorld`, the game shows an empty
non-interactive "stuck" scene — check that `GameDefaultMap` is in
`DefaultEngine.ini`, not `DefaultGame.ini`. Confirm the loaded map in the engine
log: `.../Linux/GigLine/Saved/Logs/GigLine.log` (`LoadMap: /Game/Hollywood/DemoMap`).
The archive directory is deliberately **outside** the repository; never archive into the checkout.
## Headless content checks ## Headless content checks
@@ -76,9 +69,7 @@ Both content-acceptance tests run headless and are the template for all asset im
## Content pipeline (current) ## Content pipeline (current)
The hero block and the demo level are built by a fixed sequence. Each step writes The hero block and the demo level are built by a fixed sequence. Each step writes a result file under `/tmp/gigline/`; read it, because the commandlet's own stdout can be swallowed.
a result file under `/tmp/gigline/`; read it, because the commandlet's own stdout
can be swallowed.
| Step | Tool | Output | | Step | Tool | Output |
| --- | --- | --- | | --- | --- | --- |
@@ -86,21 +77,23 @@ can be swallowed.
| Terrain | `Art/blender/terrain/terrain_from_dem.py` | `Art/blender/terrain/terrain-*.blend` | | Terrain | `Art/blender/terrain/terrain_from_dem.py` | `Art/blender/terrain/terrain-*.blend` |
| Street | `Art/blender/street/street_mesh.py` | `Art/blender/street/street-*.blend` | | Street | `Art/blender/street/street_mesh.py` | `Art/blender/street/street-*.blend` |
| Site assembly | `Art/blender/site/site_assembly.py` | `Art/blender/site/site-*.blend` | | Site assembly | `Art/blender/site/site_assembly.py` | `Art/blender/site/site-*.blend` |
| Site export | `Art/blender/site/export_site_fbx.py` | six FBXs, one per mesh | | Site export | `Art/blender/site/export_site_fbx.py` | seven FBXs, one per mesh (terrain, road, both sidewalks, both kerbs, crossing) |
| Hero façade | `Tools/hero_facade_build.py` | `Art/blender/hero/hero-facade-*.blend` + JSON report | | Hero façade | `Tools/hero_facade_build.py` | `Art/blender/hero/hero-facade-*.blend` + JSON part report |
| Block frontage | `Tools/export_block_frontage.py` | six frontage FBXs from the blockout |
| North buildings | `Tools/export_north_buildings.py` | TCL and Tussaud's FBXs from the blockout |
| Site import | `Tools/ue_import_site.py` | `/Game/Hollywood/Site/*` | | Site import | `Tools/ue_import_site.py` | `/Game/Hollywood/Site/*` |
| Frontage import | `Tools/ue_import_block_frontage.py` | `/Game/Hollywood/Frontage/*` |
| North import | `Tools/ue_import_north_buildings.py` | `/Game/Hollywood/North/*` |
| Hero import | `Tools/ue_hero_facade_import.py` | `/Game/Hollywood/ElCapitan/SM_HeroFacade` | | Hero import | `Tools/ue_hero_facade_import.py` | `/Game/Hollywood/ElCapitan/SM_HeroFacade` |
| Demo map | `Tools/ue_build_demo_map.py` | `/Game/Hollywood/DemoMap` | | Demo map | `Tools/ue_build_demo_map.py` | `/Game/Hollywood/DemoMap` |
| Review shots | `Tools/ue_build_review_map.py` + `Tools/render_screenshots.sh` | `Screenshots/` | | Review shots | `Tools/ue_build_review_map.py` + `Tools/render_screenshots.sh` | `Screenshots/` |
Every Blender exporter calls [`Tools/fbx_export_preset.py`](../Tools/fbx_export_preset.py) (`ue_metric_v1` / `ue_character_v1`) instead of repeating the FBX argument block — one place for the scale conversion, the axis mapping and `mesh_smooth_type="FACE"` (without which every Unreal import warns about missing smoothing groups and round geometry arrives faceted).
Two rules that the pipeline enforces rather than documents: Two rules that the pipeline enforces rather than documents:
- **One conversion.** Metres to Unreal centimetres happens only in - **One conversion.** Metres to Unreal centimetres happens only in `Geo/tools/osm_common.py::to_unreal_local`. Sidecars (`<asset>.geo.json`) record the result and `Geo/tools/asset_metadata.py audit` re-derives it.
`Geo/tools/osm_common.py::to_unreal_local`. Sidecars (`<asset>.geo.json`) record - **Collision is decided at import,** not later: terrain, road and sidewalks are complex-as-simple; kerbs and props stay simple. A simple hull over a kerbed street ramps straight across the kerb.
the result and `Geo/tools/asset_metadata.py audit` re-derives it.
- **Collision is decided at import,** not later: terrain, road and sidewalks are
complex-as-simple; kerbs and props stay simple. A simple hull over a kerbed
street ramps straight across the kerb.
## Known engine quirks (2026-11-07) ## Known engine quirks (2026-11-07)
@@ -110,20 +103,35 @@ Two rules that the pipeline enforces rather than documents:
## Packaged Linux build ## Packaged Linux build
- 2026-11-07: blank `Game/GigLine` Development build packaged via the command above; 1.1 GiB archive at `/run/media/system/DATA/Builds/GigLine/Linux`; `GigLine.sh` launched on the desktop session, Vulkan swapchain created, 759 frames rendered across a 60 s smoke test. Controls document to be written with the first playable (`0.1.0` Gameplay section). Current QA archive: `/run/media/system/DATA/Builds/GigLineQA`. A plain `./GigLine.sh` opens `/Game/Hollywood/DemoMap`; controls are in [controls.md](controls.md) and shown in-game bottom-right. The packaged log is written **relative to the wrapper's working directory**, i.e. `<archive>/Linux/GigLine/Saved/Logs/GigLine.log` — not into the repository.
Behaviour check without a human at the keyboard:
```bash
cd /run/media/system/DATA/Builds/GigLineQA/Linux
./GigLine.sh -windowed -ResX=1280 -ResY=720 -nosplash -qaselftest
# then read <archive>/Linux/GigLine/Saved/Logs/GigLine.log for "GigLine QA:" lines
```
`-qaselftest` walks for 2 s, resets, pauses/unpauses, sweeps a 16-row seam transect, fires a ground-referenced 21 m wall probe along the street, fires 40 m façade-occlusion traces sideways from the road centre wherever a building is staged, and writes a frame-time series to `Saved/Profiling/`. Every phase logs `... PASS`/`FAIL` and the run ends with `RESULT`.
## Traps that cost real time in `0.1.x`
| Symptom | Cause | Fix |
| --- | --- | --- |
| A builder reports success but the asset is unchanged | `.blend`/`.umap` are LFS-tracked and check out **read-only**; Blender and Unreal fail the save quietly | `chmod u+w <file>` first — `ue_build_*`/`ue_hero_facade_import` and every Blender builder need it |
| `File format is not supported` when reopening a `.blend` | `hero_facade_build.py --out` writes the **FBX**; putting the `.blend` path there overwrites it with FBX bytes | `--out /tmp/…fbx --save-blend Art/…blend --report Art/…json` |
| `Add-on not loaded: "bl_ext.blender_org.mpfb"` | `mpfb_character_export.py` needs the MPFB add-on visible to Blender | export `BLENDER_USER_SCRIPTS` (see the invocation in [../Art/blender/README.md](../Art/blender/README.md)) |
| The packaged run exits after a couple of seconds during QA | `GigLineShotActor` runs its screenshot list and quits (`bQuitWhenDone`) | it skips its rounds when `-qaselftest` is on the command line — keep that guard |
| Headless `-nullrhi` traces report no hits for everything | the null RHI has no physics scene | verify traces in the **packaged** run (`-qaselftest`), never in a commandlet |
| Import logs "No smoothing group information" for every mesh | `mesh_smooth_type="OFF"` in the FBX export | use `Tools/fbx_export_preset.py` (`FACE`) |
## PIE input test (compare editor vs packaged input intake) ## PIE input test (compare editor vs packaged input intake)
Use to isolate whether the "no controls respond" bug is the packaged standalone's Use to isolate whether the "no controls respond" bug is the packaged standalone's input intake or the Enhanced Input wiring generally.
input intake or the Enhanced Input wiring generally.
- `Tools/run_pie.sh` opens the editor on `/Game/Hollywood/DemoMap`. Press the - `Tools/run_pie.sh` opens the editor on `/Game/Hollywood/DemoMap`. Press the green **Play** (PIE) button and walk / mouse-look.
green **Play** (PIE) button and walk / mouse-look.
- Interpretation: - Interpretation:
- PIE **works** -> the Enhanced Input wiring is fine and the standalone - PIE **works** -> the Enhanced Input wiring is fine and the standalone packaged build's SDL input intake is the environment-dependent bug.
packaged build's SDL input intake is the environment-dependent bug. - PIE **fails** too -> the Enhanced Input setup itself is broken; rebuild the input config / character from scratch.
- PIE **fails** too -> the Enhanced Input setup itself is broken; rebuild - The DemoMap is PIE-ready as committed: `GlobalDefaultGameMode` + `GameDefaultMap=/Game/Hollywood/DemoMap`, and the map stages a PlayerStart at `(-13336,-1150,600)` on the cut sidewalk with the walkable `AGigLineCharacter`.
the input config / character from scratch.
- The DemoMap is PIE-ready as committed: `GlobalDefaultGameMode` +
`GameDefaultMap=/Game/Hollywood/DemoMap`, and the map stages a PlayerStart at
`(-13336,-1150,600)` on the cut sidewalk with the walkable `AGigLineCharacter`.
+6 -12
View File
@@ -1,7 +1,6 @@
# Controls (`0.1.0` walkable demo) # Controls (`0.1.0` walkable demo)
Keyboard and mouse. The same list is shown in-game, bottom-right of the frame Keyboard and mouse. The same list is shown in-game, bottom-right of the frame (`AGigLineHUD`), and the packaged Linux build ships with these bindings.
(`AGigLineHUD`), and the packaged Linux build ships with these bindings.
| Action | Key | Notes | | Action | Key | Notes |
| --- | --- | --- | | --- | --- | --- |
@@ -16,21 +15,16 @@ Keyboard and mouse. The same list is shown in-game, bottom-right of the frame
## Notes ## Notes
- Movement is relative to where you are looking (controller yaw), so the capsule - Movement is relative to where you are looking (controller yaw), so the capsule stays upright on the sloped street.
stays upright on the sloped street. - The mouse is captured on launch, so looking works without clicking first. `Escape` toggles the capture as part of pause.
- The mouse is captured on launch, so looking works without clicking first. - Bindings are Enhanced Input actions in `/Game/Input` built by `Tools/ue_create_input_assets.py`; the mapping context is `IMC_Default`.
`Escape` toggles the capture as part of pause.
- Bindings are Enhanced Input actions in `/Game/Input` built by
`Tools/ue_create_input_assets.py`; the mapping context is `IMC_Default`.
## Testing input without a physical keyboard ## Testing input without a physical keyboard
`Tools/input_test.py` injects OS-level keyboard/mouse events through a virtual `Tools/input_test.py` injects OS-level keyboard/mouse events through a virtual `/dev/uinput` device (see `Tools/input_emulation.py`), e.g.:
`/dev/uinput` device (see `Tools/input_emulation.py`), e.g.:
```bash ```bash
python3 Tools/input_test.py --sequence move:2,look:3 python3 Tools/input_test.py --sequence move:2,look:3
``` ```
It drives the focused game window exactly like real hardware, which is how the It drives the focused game window exactly like real hardware, which is how the controls were regression-tested on the packaged build.
controls were regression-tested on the packaged build.
+9 -26
View File
@@ -37,22 +37,12 @@ Important nuance: ODbL governs the **database** (the OSM data as a collection an
## 4. Decision: posture for the derived geometry ## 4. Decision: posture for the derived geometry
The OSM-derived block geometry (streets, footprints, sidewalks) is treated as a The OSM-derived block geometry (streets, footprints, sidewalks) is treated as a **Produced Work / derivative database** of the OpenStreetMap database.
**Produced Work / derivative database** of the OpenStreetMap database.
- The **share-alike obligation is accepted for the data layer only**. The - The **share-alike obligation is accepted for the data layer only**. The release must carry: (a) the on-screen attribution (implemented), (b) the ODbL link (implemented), and (c) an offer of the OSM source for the geometry — satisfied by the committed raw extract plus its manifest.
release must carry: (a) the on-screen attribution (implemented), (b) the ODbL - **Project creative work (code, shaders, artist-authored façades, textures) is NOT under ODbL.** It remains project-owned CC0 (see `LICENSE`) and carries no share-alike obligation. Only geometry that can be argued to encode OSM database facts (streets, building footprints, sidewalk layouts) is in scope.
link (implemented), and (c) an offer of the OSM source for the geometry — - **No new obligation is created by the DEM**: 3DEP is US Government public domain and is independent of ODbL.
satisfied by the committed raw extract plus its manifest. - **Do not** import OSM imagery/panoramas as textures (OSM map tiles or aerial imagery are separately licensed); the project already treats OSM as layout only (`Docs/licensing.md` rule 3).
- **Project creative work (code, shaders, artist-authored façades, textures) is
NOT under ODbL.** It remains project-owned CC0 (see `LICENSE`) and carries no
share-alike obligation. Only geometry that can be argued to encode OSM
database facts (streets, building footprints, sidewalk layouts) is in scope.
- **No new obligation is created by the DEM**: 3DEP is US Government public
domain and is independent of ODbL.
- **Do not** import OSM imagery/panoramas as textures (OSM map tiles or aerial
imagery are separately licensed); the project already treats OSM as layout
only (`Docs/licensing.md` rule 3).
Release checklist items this lands: Release checklist items this lands:
@@ -63,20 +53,13 @@ Release checklist items this lands:
## 5. Remaining (tracked separately) ## 5. Remaining (tracked separately)
- **Blosm add-on licence** is still `pending` in THIRD_PARTY_NOTICES (tool-only, - **Blosm add-on licence** is still `pending` in THIRD_PARTY_NOTICES (tool-only, not distributed — must be checked once, not a release blocker for ODbL).
not distributed — must be checked once, not a release blocker for ODbL). - Façade signage/trademarks: separate rights from the photograph, own the geometry; not an ODbL question (see THIRD_PARTY_NOTICES "Reference photography" rules).
- Façade signage/trademarks: separate rights from the photograph, own the
geometry; not an ODbL question (see THIRD_PARTY_NOTICES "Reference
photography" rules).
## 6. Sources checked ## 6. Sources checked
- ODbL text: <https://opendatacommons.org/licenses/odbl/1-0/> - ODbL text: <https://opendatacommons.org/licenses/odbl/1-0/>
- OSM licence summary: <https://www.openstreetmap.org/copyright> - OSM licence summary: <https://www.openstreetmap.org/copyright>
- ISPRS/ODbL practice notes on derived game geometry from OSM footprints - ISPRS/ODbL practice notes on derived game geometry from OSM footprints (objective standard: attribution + produced-work offer, share-alike limited to the data layer).
(objective standard: attribution + produced-work offer, share-alike limited
to the data layer).
Not reviewed here (out of scope for this item): engine redistribution terms Not reviewed here (out of scope for this item): engine redistribution terms (Epic), MPFB asset terms, street-prop reuse terms — all tracked in `THIRD_PARTY_NOTICES.md`.
(Epic), MPFB asset terms, street-prop reuse terms — all tracked in
`THIRD_PARTY_NOTICES.md`.
+26 -68
View File
@@ -35,7 +35,7 @@ Measured at 1440p on the development workstation: Bazzite, Ryzen 7 7700X, 64 GB
## Check-record template ## Check-record template
| Version | Check | Result | Evidence | Date | | Version | Check | Result | Evidence | Date |
| ------- | ----------------------- | ------ | -------- | ---- | | --- | --- | --- | --- | --- |
| `0.1.0` | Traversal, both sides | PASS — Blender pre-check + in-engine swept transect, 0 holes, ground Z 317..395 | `-qaselftest`, TODO 0.1.0 notes | 2026-11-08 | | `0.1.0` | Traversal, both sides | PASS — Blender pre-check + in-engine swept transect, 0 holes, ground Z 317..395 | `-qaselftest`, TODO 0.1.0 notes | 2026-11-08 |
| `0.1.0` | Placement vs references | PASS (with deliberate simplifications) | Section below, 2026-11-08 | 2026-11-08 | | `0.1.0` | Placement vs references | PASS (with deliberate simplifications) | Section below, 2026-11-08 | 2026-11-08 |
| `0.1.0` | Collision and packaging | PASS — no blocking collision, no invisible walls, no seam gaps (see audit note) | `-qaselftest` on the packaged build, 2026-11-08 | 2026-11-08 | | `0.1.0` | Collision and packaging | PASS — no blocking collision, no invisible walls, no seam gaps (see audit note) | `-qaselftest` on the packaged build, 2026-11-08 | 2026-11-08 |
@@ -45,10 +45,7 @@ Measured at 1440p on the development workstation: Bazzite, Ryzen 7 7700X, 64 GB
## `0.1.0` — Placement vs references — record (2026-11-08) ## `0.1.0` — Placement vs references — record (2026-11-08)
Checked against the reference photography (`Art/reference/`) and the Geo measurements Checked against the reference photography (`Art/reference/`) and the Geo measurements (`Geo/verification/*`). Build under test: current `main` content, DemoMap rendered via `Tools/render_screenshots.sh` (2026-11-08, Vulkan SM5, 1920×1080); engine positions probed from the DemoMap (commandlet, 2026-11-08).
(`Geo/verification/*`). Build under test: current `main` content, DemoMap rendered via
`Tools/render_screenshots.sh` (2026-11-08, Vulkan SM5, 1920×1080); engine positions
probed from the DemoMap (commandlet, 2026-11-08).
### Placement and scale (in-engine ↔ reference) ### Placement and scale (in-engine ↔ reference)
@@ -80,55 +77,33 @@ probed from the DemoMap (commandlet, 2026-11-08).
- **Crossing ramps are kerb flushes, not graded ramps** — the `0.1.1` Crossing ramp gradient item. - **Crossing ramps are kerb flushes, not graded ramps** — the `0.1.1` Crossing ramp gradient item.
- Site identity (the neoclassical hero with motto band) matches the 2009 façade photograph and the 1922 Masonic elevation within blockout fidelity. - Site identity (the neoclassical hero with motto band) matches the 2009 façade photograph and the 1922 Masonic elevation within blockout fidelity.
Pre-check method: `Tools/judge_screenshot.py` (vision model) flagged the simplified Pre-check method: `Tools/judge_screenshot.py` (vision model) flagged the simplified flat surfaces as "unexpected" — those flags are the deliberate deviations above, not placement errors. Final verdict is by human review of the shots in `Screenshots/demo/`.
flat surfaces as "unexpected" — those flags are the deliberate deviations above, not
placement errors. Final verdict is by human review of the shots in `Screenshots/demo/`.
## `0.1.0` — Collision and packaging — audit note (2026-11-08) ## `0.1.0` — Collision and packaging — audit note (2026-11-08)
Run on the packaged build (`GigLineQA`, Development, Vulkan SM5) with `-qaselftest`. Run on the packaged build (`GigLineQA`, Development, Vulkan SM5) with `-qaselftest`.
- **Transect (holes/gaps):** 1456 samples over 16 rows — lane centres **plus the - **Transect (holes/gaps):** 1456 samples over 16 rows — lane centres **plus the kerb/road/sidewalk seam Ys** (−1382, −1053, +718, +1047 and neighbours) and X −20000..−2000. Result **0 holes**. (The old 84-sample check covered only 3 lane centres; the seam rows are what this check adds.)
kerb/road/sidewalk seam Ys** (−1382, −1053, +718, +1047 and neighbours) and - **Invisible-wall probe:** horizontal traces 30 m long, grounded at 160 cm above the local ground, along the road and both seam rows. Result **0/35 blocking hits inside 21 m** — no invisible walls or misplaced blockers in the street envelope.
X −20000..−2000. Result **0 holes**. (The old 84-sample check covered only 3 - **Facade occlusion probe:** from the road centre, 40 m traces sideways at walking height toward each frontage, required only where the map stages a building (the spans are listed in `AGigLineCharacter` and mirror `Tools/ue_build_demo_map.py`). Result **0/9 directions without a blocking wall** — the player cannot walk through the hero or the frontage. This probe found the hero façade had **no simple collision** at all (complex-only, so traces and capsule sweeps passed through it); it is now `UseComplexAsSimple`. Auto-generated convex hulls were tried first and rejected: on a 95-part, 26 m façade they bulged into the street (the wall probe went 0/35 → 2/35). (First pass used a fixed Z 160 start; the street is at Z ~366, so the probe started inside the earth and reported every row as a dist-0 blocker. Grounding the probe fixed that measurement bug.)
lane centres; the seam rows are what this check adds.) - **Walk / reset / pause:** 761 cm in 2 s (Z steady 456), reset 0 cm, pause `IsGamePaused()=1`, unpause 0 — all PASS.
- **Invisible-wall probe:** horizontal traces 30 m long, grounded at 160 cm above - Packaging: the packaged build launches, reaches gameplay, and the QA run quits cleanly. The `GigLineShotActor` screenshot rounds are suppressed under `-qaselftest` so they cannot quit the run before the audit phases.
the local ground, along the road and both seam rows. Result **0/35 blocking hits - Caveat recorded: ground Z extremes (−12 m / 3000 cm) are trace-start/terrain-edge artifacts of the widened extent, not walkable holes — the walkable corridor is continuous (walk + transect both PASS).
inside 21 m** — no invisible walls or misplaced blockers in the street envelope.
(First pass used a fixed Z 160 start; the street is at Z ~366, so the probe
started inside the earth and reported every row as a dist-0 blocker. Grounding
the probe fixed that measurement bug.)
- **Walk / reset / pause:** 761 cm in 2 s (Z steady 456), reset 0 cm, pause
`IsGamePaused()=1`, unpause 0 — all PASS.
- Packaging: the packaged build launches, reaches gameplay, and the QA run quits
cleanly. The `GigLineShotActor` screenshot rounds are suppressed under
`-qaselftest` so they cannot quit the run before the audit phases.
- Caveat recorded: ground Z extremes (−12 m / 3000 cm) are trace-start/terrain-edge
artifacts of the widened extent, not walkable holes — the walkable corridor is
continuous (walk + transect both PASS).
## `0.1.0` — 60 FPS at 1440p — record (2026-11-08) ## `0.1.0` — 60 FPS at 1440p — record (2026-11-08)
**Build under test:** packaged `GigLineQA` Development, UE **5.8.4**, Vulkan SM5, **Build under test:** packaged `GigLineQA` Development, UE **5.8.4**, Vulkan SM5, NVIDIA 615.78.08 (RTX 5070 Ti), Bazzite, Ryzen 7 7700X, 64 GB RAM.
NVIDIA 615.78.08 (RTX 5070 Ti), Bazzite, Ryzen 7 7700X, 64 GB RAM.
**Documented quality preset** (applied via `-ExecCmds`): all scalability groups at **Documented quality preset** (applied via `-ExecCmds`): all scalability groups at 3 = Epic — `sg.ViewDistanceQuality 3, sg.ShadowQuality 3, sg.GlobalIlluminationQuality 3, sg.ReflectionQuality 3, sg.PostProcessQuality 3, sg.TextureQuality 3, sg.EffectsQuality 3, sg.FoliageQuality 3, sg.ShadingQuality 3`.
3 = Epic — `sg.ViewDistanceQuality 3, sg.ShadowQuality 3,
sg.GlobalIlluminationQuality 3, sg.ReflectionQuality 3, sg.PostProcessQuality 3,
sg.TextureQuality 3, sg.EffectsQuality 3, sg.FoliageQuality 3, sg.ShadingQuality 3`.
**Upscaling state (recorded from the run):** `r.AntiAliasingMethod=4` → **TSR on**, **Upscaling state (recorded from the run):** `r.AntiAliasingMethod=4` → **TSR on**, default screen percentage, `r.VSync=0` (**off**), frame generation **off/unavailable** (`r.Streamline.FrameGeneration.Mode` absent → -999).
default screen percentage, `r.VSync=0` (**off**), frame generation **off/unavailable**
(`r.Streamline.FrameGeneration.Mode` absent → -999).
**Route:** the deterministic `-qaselftest` traversal — spawn **Route:** the deterministic `-qaselftest` traversal — spawn `(-13336,-1150,456)`, walk forward (+Y) into the block for 2 s. Frame times are captured every frame over that route (829 frames), not a single sample.
`(-13336,-1150,456)`, walk forward (+Y) into the block for 2 s. Frame times are
captured every frame over that route (829 frames), not a single sample.
**Result at 2560×1440:** **Result at 2560×1440:**
| Metric | Value | | Metric | Value |
| --- | --- | | --------------- | --------------------- |
| Frames captured | 829 | | Frames captured | 829 |
| Average | **2.42 ms → 414 fps** | | Average | **2.42 ms → 414 fps** |
| p50 | 2.38 ms | | p50 | 2.38 ms |
@@ -136,13 +111,9 @@ captured every frame over that route (829 frames), not a single sample.
| p99 | 3.01 ms (≈332 fps) | | p99 | 3.01 ms (≈332 fps) |
| Max | 4.00 ms | | Max | 4.00 ms |
**Verdict: PASS** — worst-case p99 is ~3 ms (≈330 fps), far above the 60 FPS **Verdict: PASS** — worst-case p99 is ~3 ms (≈330 fps), far above the 60 FPS target without frame generation.
target without frame generation.
**Variance note:** two back-to-back runs measured ~9.2 ms (≈108 fps) when another **Variance note:** two back-to-back runs measured ~9.2 ms (≈108 fps) when another game instance / busy compositor overlapped the capture; repeated clean runs (windowed and fullscreen, Wayland and X11 drivers, window focused) all measured ~2.4–2.5 ms. The high outlier is recorded here rather than hidden.
game instance / busy compositor overlapped the capture; repeated clean runs
(windowed and fullscreen, Wayland and X11 drivers, window focused) all measured
~2.4–2.5 ms. The high outlier is recorded here rather than hidden.
**Reproduce:** **Reproduce:**
@@ -156,8 +127,7 @@ Committed frame-time series: `Docs/perf/0.1.0-walk-frametimes.csv`.
## `0.1.0` — Fresh checkout build — record (2026-11-08) ## `0.1.0` — Fresh checkout build — record (2026-11-08)
Followed the documented steps in [`Docs/build.md`](build.md) exactly, into a clean Followed the documented steps in [`Docs/build.md`](build.md) exactly, into a clean directory with no prior state:
directory with no prior state:
```bash ```bash
git clone https://git.allucanget.biz/zwitschi/gig-line.git /tmp/fresh-check git clone https://git.allucanget.biz/zwitschi/gig-line.git /tmp/fresh-check
@@ -170,35 +140,23 @@ Engine/Build/BatchFiles/Linux/Build.sh GigLine Linux Development \
-project=/tmp/fresh-check/Game/GigLine/GigLine.uproject -WaitMutex -project=/tmp/fresh-check/Game/GigLine/GigLine.uproject -WaitMutex
``` ```
- Clone of `main` at `4bc41e9`; LFS objects fetched (`DemoMap.umap` is real UE - Clone of `main` at `4bc41e9`; LFS objects fetched (`DemoMap.umap` is real UE package content, not a pointer).
package content, not a pointer). - `GigLineEditor` → **Result: Succeeded** (28.2 s); produced `/tmp/fresh-check/Game/GigLine/Binaries/Linux/libUnrealEditor-GigLine.so`.
- `GigLineEditor` → **Result: Succeeded** (28.2 s); produced
`/tmp/fresh-check/Game/GigLine/Binaries/Linux/libUnrealEditor-GigLine.so`.
- `GigLine` (game) → **Result: Succeeded** (26.9 s). - `GigLine` (game) → **Result: Succeeded** (26.9 s).
- Engine: documented external install at `/run/media/system/DATA/Engines/UE_5.8.4` - Engine: documented external install at `/run/media/system/DATA/Engines/UE_5.8.4` (UE 5.8.4), system clang; no machine-specific paths beyond the documented `-project=` argument.
(UE 5.8.4), system clang; no machine-specific paths beyond the documented
`-project=` argument.
**Verdict: PASS.** **Verdict: PASS.**
## `0.1.0` — Packaged Linux build + documented controls — record (2026-11-08) ## `0.1.0` — Packaged Linux build + documented controls — record (2026-11-08)
**Package:** `RunUAT BuildCookRun … -platform=Linux -clientconfig=Development **Package:** `RunUAT BuildCookRun … -platform=Linux -clientconfig=Development -cook -build -stage -pak -archive -archivedirectory=/run/media/system/DATA/Builds/GigLineQA` → `EXIT=0` (Success).
-cook -build -stage -pak -archive -archivedirectory=/run/media/system/DATA/Builds/GigLineQA`
→ `EXIT=0` (Success).
**Launch:** `./GigLine.sh -windowed` (plain, no map argument) → **Launch:** `./GigLine.sh -windowed` (plain, no map argument) → `LogLoad: LoadMap: /Game/Hollywood/DemoMap?Name=Player` — the packaged game opens the walkable demo directly (the default-map fix holds in a package, not just the editor). No `LogLinker`/missing-asset/failed-to-find errors in the run.
`LogLoad: LoadMap: /Game/Hollywood/DemoMap?Name=Player` — the packaged game opens
the walkable demo directly (the default-map fix holds in a package, not just the
editor). No `LogLinker`/missing-asset/failed-to-find errors in the run.
**Controls documented and shown in-game:** **Controls documented and shown in-game:**
- `Docs/controls.md` — full binding table (WASD, mouse look, Space, R, Esc) plus
how to test input headlessly. - `Docs/controls.md` — full binding table (WASD, mouse look, Space, R, Esc) plus how to test input headlessly.
- `AGigLineHUD` now draws a control legend bottom-right every frame: - `AGigLineHUD` now draws a control legend bottom-right every frame: "WASD move · Mouse look · Space jump · R reset · Esc pause".
"WASD move · Mouse look · Space jump · R reset · Esc pause". - Verified in the packaged capture: the bottom-right text bbox is 426×12 px (a single right-aligned line), attributed text bottom-left. Screenshot: `Docs/perf/0.1.0-packaged-hud-controls.png`.
- Verified in the packaged capture: the bottom-right text bbox is 426×12 px
(a single right-aligned line), attributed text bottom-left. Screenshot:
`Docs/perf/0.1.0-packaged-hud-controls.png`.
**Verdict: PASS.** **Verdict: PASS.**
+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`).
+13 -2
View File
@@ -83,13 +83,24 @@ Without `git lfs install`, adding an LFS-matched file does not error — it comm
Sizes: the engine lives outside the repository (pinned install path recorded in `toolchain.md`). The repository holds the Unreal project, Blender sources, geo sources, and tooling. Sizes: the engine lives outside the repository (pinned install path recorded in `toolchain.md`). The repository holds the Unreal project, Blender sources, geo sources, and tooling.
**Fresh checkout not yet tested end to end.** The build procedure in [build.md](build.md) was produced from a working tree, not from a clean clone. Exercise it before `v0.1.0` is tagged — it is a release check on the board. **Fresh checkout verified 2026-11-08.** A clean clone of `main` with `git lfs pull` built both `GigLineEditor` and `GigLine` against the documented external engine (recorded in [release-checks.md](release-checks.md)). Re-run it before a release tag if the engine pin or the module's dependencies change.
### Optional keys (local only)
Two tools talk to paid services and read their keys from the gitignored `.env`:
| Variable | Used by | Needed for |
| --- | --- | --- |
| `OPENROUTER_API_KEY` | `Tools/judge_screenshot.py`, `Tools/facade_detail_concepts.py` | vision pre-check and AI concept plates |
| `MAPBOX_ACCESS_TOKEN` | `Geo/tools/mapbox_buildings.py` | the second building-height source |
Nothing in the build, the tests or the release path needs either key. Never commit `.env`; `git check-ignore .env` should succeed.
## Known blockers ## Known blockers
| Blocker | Owner | Needed for | | Blocker | Owner | Needed for |
| --- | --- | --- | | --- | --- | --- |
| Delete the fallback 5.2.0 engine archive (`/run/media/system/DATA/Downloads/Linux_Unreal_Engine_5.2.0.zip`) once the 5.8.4 pin is trusted through asset production | agent | Disk hygiene (19.6 GiB), pin safety | | Delete the fallback 5.2.0 engine archive (`/run/media/system/DATA/Downloads/Linux_Unreal_Engine_5.2.0.zip`) once the 5.8.4 pin is trusted through asset production | agent | Disk hygiene (19.6 GiB), pin safety |
| Fresh-checkout build exercised against the documented procedure | agent | `v0.1.0` release check | | ~~Fresh-checkout build exercised against the documented procedure~~ | — | Done 2026-11-08 |
Closed 2026-11-07: Epic account and engine download, QGIS `lts` removal, Blosm and MPFB on Blender 5.2 (headless invocation fix in `Art/blender/README.md`), engine validation on Bazzite, full project bootstrap. Earlier closed: Bazzite update and reboot, driver and Vulkan verification, `git-lfs`/`clang`/`cmake`/`ninja` layering. Closed 2026-11-07: Epic account and engine download, QGIS `lts` removal, Blosm and MPFB on Blender 5.2 (headless invocation fix in `Art/blender/README.md`), engine validation on Bazzite, full project bootstrap. Earlier closed: Bazzite update and reboot, driver and Vulkan verification, `git-lfs`/`clang`/`cmake`/`ninja` layering.
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.
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)
@@ -416,6 +420,81 @@ void AGigLineCharacter::StartQASelfTest()
TEXT("GigLine QA: wall probe %d/%d traces with blocking hit inside 21 m -> %s"), TEXT("GigLine QA: wall probe %d/%d traces with blocking hit inside 21 m -> %s"),
Walls, WallSamples, Walls, WallSamples,
Walls == 0 ? TEXT("PASS") : TEXT("FAIL")); Walls == 0 ? TEXT("PASS") : TEXT("FAIL"));
// Facade occlusion probe: walkable street is only half the story —
// the player must also NOT be able to walk through the buildings.
// From the road centre, fire 40 m sideways at walking height toward
// each frontage and require a blocking hit. A miss means the
// building has no collision (or only non-blocking collision) and the
// player would pass through the wall.
// Expect a wall only where the map actually stages a building.
// Spans below mirror the actors placed by Tools/ue_build_demo_map.py
// (frontage + hero) and the two north buildings; update them if
// that list changes. Gaps between buildings are legitimate misses.
struct FSpan { float Min, Max; };
static const FSpan SouthSpans[] = {
{-24276.f, -17700.f}, // 6922
{-17749.f, -14591.f}, // 6902
{-14638.f, -12034.f}, // 6840 hero
{-12082.f, -8210.f}, // 6838 El Capitan Theatre
{-7854.f, -7252.f}, // 6822 Budget
{-6608.f, -3307.f}, // 6806-6820
{-3320.f, -1437.f}, // 6800 Lee Drug
};
static const FSpan NorthSpans[] = {
{-27071.f, -22799.f}, // Madame Tussaud's
{-22889.f, -18442.f}, // TCL Chinese Theatre
};
auto InSpans = [](const FSpan* Spans, int32 Count, float X)
{
for (int32 i = 0; i < Count; ++i)
{
if (X >= Spans[i].Min && X <= Spans[i].Max) { return true; }
}
return false;
};
int32 Facades = 0, FacadeSamples = 0;
for (float X0 = -19000.0f; X0 <= -3000.0f; X0 += 2000.0f)
{
for (float Dir : {1.0f, -1.0f}) // +Y south frontage, -Y north side
{
const bool bExpectWall = (Dir > 0.0f)
? InSpans(SouthSpans, UE_ARRAY_COUNT(SouthSpans), X0)
: InSpans(NorthSpans, UE_ARRAY_COUNT(NorthSpans), X0);
if (!bExpectWall)
{
continue;
}
FHitResult GroundHit2;
FCollisionQueryParams GQ2(TEXT("GigLineFacadeGround"), false, this);
if (!W->LineTraceSingleByChannel(GroundHit2,
FVector(X0, 0.0f, 3000.f), FVector(X0, 0.0f, -1000.f),
ECC_Visibility, GQ2))
{
continue;
}
const float ProbeZ2 = GroundHit2.ImpactPoint.Z + 160.f;
FHitResult Hit2;
FCollisionQueryParams Q2(TEXT("GigLineFacadeProbe"), false, this);
const bool bHit2 = W->LineTraceSingleByChannel(Hit2,
FVector(X0, 0.0f, ProbeZ2),
FVector(X0, Dir * 4000.f, ProbeZ2),
ECC_Visibility, Q2);
++FacadeSamples;
const bool bBlocking = bHit2 && Hit2.GetActor() != nullptr;
if (!bBlocking)
{
++Facades;
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: facade probe miss at X %.0f dir %.0f (no blocking hit in 40 m)"),
X0, Dir);
}
}
}
UE_LOG(LogTemp, Log,
TEXT("GigLine QA: facade probe %d/%d directions without a blocking wall -> %s"),
Facades, FacadeSamples,
Facades == 0 ? TEXT("PASS") : TEXT("FAIL"));
} }
// 7.0: verdict, then stop ticking. // 7.0: verdict, then stop ticking.
@@ -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;
+145 -143
View File
@@ -239,182 +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 ## 2026-11-09 — AI detail plates from reference photos; floating windows fixed
relative to those two constants).
- **Gotcha (cost a bad build):** `hero_facade_build.py` takes `--out` for the - `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/`.
**FBX** and `--save-blend` for the `.blend`. Passing `--out <file>.blend` writes - **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.
FBX bytes over the blend and Blender then reports "File format is not supported". - **Output is real:** 1184×864 to 1344×768 PNGs, ~2 MB each.
Correct invocation: `--out /tmp/gigline/hero-facade.fbx --save-blend Art/...blend - **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`.
--report Art/...json`. - 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).
- Rebuilt hero → re-imported (`SM_HeroFacade`, bounds 2604 × 1257 × 1450 cm) →
DemoMap rebuilt → screenshots re-rendered. Note `ue_hero_facade_import.py` saves ## 2026-11-09 — UE export hygiene: smoothing groups + facade collision
the map, so `chmod u+w` the `.umap` first or the import silently does not persist.
- **All our FBX exporters used `mesh_smooth_type="OFF"`**, which is why every Unreal import logged "No smoothing group information was found for this mesh". The hero now exports `"FACE"` and its round parts are shaded with `shade_smooth_by_angle(35°)` before export: cylinders read as round instead of faceted, and the import warning is gone (6 → 0).
- **The hero façade had no simple collision** (complex-only), so line traces and capsule sweeps passed straight through it — the player could walk through the wall of the demo. Caught by a new `-qaselftest` **facade occlusion probe** (40 m sideways traces from the road centre at walking height).
- **Auto-generated convex collision on a 95-part 26 m façade is unusable**: it produced coarse hulls that bulged into the street and the wall probe went 0/35 → 2/35. Rejected in favour of `UseComplexAsSimple` via `UGigLineAssetTools.set_complex_collision_as_simple` (same reason the terrain uses it), saved on the asset.
- Probe expectations must mirror the staged buildings: the facade probe now carries explicit X spans per frontage (and the TCL/Tussaud's spans on the north side) so a gap between buildings is not reported as a defect.
## 2026-11-09 — one FBX export preset instead of six copies
- The `bpy.ops.export_scene.fbx(...)` argument block was duplicated in six scripts (hero, block frontage, north buildings, site, scale test, MPFB character), and all six carried `mesh_smooth_type="OFF"` — hence the "No smoothing group information was found" warning on every import.
- Now `Tools/fbx_export_preset.py` holds `ue_metric_v1` and `ue_character_v1`: `preset_kwargs()` (bpy-free, unit-tested) plus `export_static()` / `export_character()` wrappers that import `bpy` lazily. Every caller imports it and passes `selection=True/False`; the seven duplicated blocks are gone.
- Callers in `Tools/` resolve the module with `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))`; the Blender-side `Art/blender/site/export_site_fbx.py` computes the repo root (`..`, `..`, `..`) and joins `Tools`. Getting that wrong silently inserts `Tools/Tools` — check the import path when adding a caller.
- Verified end to end: scale test, hero, frontage (6/6), site (7/7) and north buildings all re-exported through the preset, re-imported with **0 smoothing warnings**, DemoMap rebuilt and `-qaselftest` all PASS (walk, transect 0 holes, wall probe 0/35, facade probe 0/9).
- `mpfb_character_export.py` was not re-run: it needs the MPFB add-on on `BLENDER_USER_SCRIPTS` ("Add-on not loaded: bl_ext.blender_org.mpfb"), which is pre-existing and unrelated; the character is not in `0.1.x` gameplay.
## 2026-11-09 — v0.1.2 released
- Cornice finished: projecting corona over a **52-dentil course** (0.18 × 0.30 × 0.22 m at 0.45 m centres, z 12.12–12.34, 0.25 m clear of the corona). Hero is now 3 044 tris across 148 parts, still 14.50 m. Verified: packaged `-qaselftest` all PASS (transect 0 holes, wall probe 0/35, facade probe 0/9).
- `CHANGELOG.md` gets the `0.1.2` entry (added/changed/fixed/verified/limitations), the `0.1.2` section is removed from `TODO.md`, `ROADMAP.md` status updated, tag `v0.1.2` cut after the merge.
- Assumptions recorded rather than hidden: dentil spacing, base and abacus dimensions are not resolvable in our reference photography.
## 2026-11-09 — elevation cameras levelled (straight verticals)
- archviz MUST: an elevation must not lean. `hero-facade-front` looked up ~4° from 4.2 m at a 7.6 m target; `demo-spawn-view` and `demo-street-level` were pitched too. All three now put the camera at the target's height, so `FindLookAtRotation` yields pitch 0 and roll 0 and verticals are parallel by construction.
- Do not try to _measure_ verticality from the rendered PNG with a naive edge scan: banners, windows and the parapet produce different edge sets at different heights and the pairwise match is meaningless (it reported a 418 px "drift"). Verify it geometrically in the camera list instead.
## 2026-11-09 — 0.1.3 packaging/setup fixes
- **`Tools/ue_common.py`**: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False with no error and the staged actors vanish), and `require_saved()` makes that failure visible. `ue_hero_facade_import.py` and `ue_import_north_buildings.py` never did this — which is why importing the hero appeared to succeed while saving nothing. Verified by forcing `chmod u-w` on DemoMap first: the run logged `map writable: chmod u+w … (was 0o444)`, then `save_map: True`, exit 0.
- Gotcha while adding it: `ue_hero_facade_import.py` had **no** `import os`/`sys` (only `unreal`), so the sys.path line raised `NameError: name 'sys' is not defined` inside the commandlet — an exit code of 255 with no obvious cause.
- Docs: `build.md` gained the current pipeline table (7 site FBXs, frontage, north, hero, map), the `Builds/GigLineQA` archive, the packaged-log path, `-qaselftest` usage and a **traps** table; `setup.md` marks the fresh-checkout build verified and documents the two optional API keys; architecture 07 + combined doc corrected the build-output path.
## 2026-11-09 — 0.1.4 materials + architecture-camera pass
- **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.
- **`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.
- **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.
- 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).
- **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 %.
## 2026-11-09 — facade texture maps (0.1.4)
- `Tools/generate_facade_textures.py` writes deterministic 512² base-colour and roughness maps (ashlar stone, stucco, asphalt, concrete) with a pure-zlib PNG encoder and hash-based value noise — no numpy/Pillow, ~14 s for all eight, and byte-identical on a re-run (checked by md5sum).
- `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 -44
View File
@@ -1,62 +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 and window panels float detached from the colonnade wall (next modelling pass). - Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement.
- Between kerb and building line, the walkable surface is the terrain mesh, not a modelled pavement. - The El Capitan Theatre has no marquee or blade sign yet — the feature that makes it read as itself.
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.
+8 -31
View File
@@ -9,42 +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.2 — Scale and façade corrections (patch) ## 0.1.5 — Façade finishing (patch)
Suggested blender skills: **references** (reference-image-match and visual-match-checklist templates — measure the guessed heights and colonnade proportions against the photographs), **archviz** (real-world scale and façade composition discipline), **camera-cinematography** (reproduce the reference camera angles when comparing renders to photos). 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.
### Patch tasks - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564 (branch `scale-facade-0.1.2`) 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.
- [x] Correct building scale and street proportions flagged against references. **Mapbox adopted 2026-11-09.** `Geo/tools/mapbox_buildings.py` merges our OSM heights with Mapbox's building data for the same footprints; after adopting its two findings the cross-check reports **66 match, 6 non-building, 0 disagreements**. Adopted: **`LEVEL_HEIGHT` 3.2 → 3.1 m** (`block_height_report.py`, and the Blosm `--level-height` default), plus explicit `HEIGHT_OVERRIDES` (3.0 m) for the three rear `building=yes`/`apartments` structures Mapbox gives as single-storey. Blockout regenerated, frontage re-exported/re-imported, DemoMap rebuilt; packaged `-qaselftest` all PASS. Measured effect: 6922 42.1 → 39.1 m, El Capitan Theatre 20.0 → 20.5 m (both now ~1.9 m above their table height = roof mass).
- [ ] Refine hero façade details where reference photography shows discrepancies.
- [x] **Replace the placeholder heights.** **Resolved 2026-11-08 — they were never unknown buildings.** The three "guessed" ways are not building volumes at all: `1360755125` and `1360755126` are `building=roof` at `layer=1` (elevated canopies) and `1417995292` is `amenity=shelter` / `shelter_type=public_transport` (a transit shelter). The report defaulted them to "2 levels × 3.2 m" and produced the bogus 6.4 m. `Geo/tools/block_height_report.py` now classifies roof/shelter explicitly and reports **0 guessed heights**: all 15 block volumes have a real `height` tag, a `building:levels` derivation, or are correctly excluded as non-building volumes. No photograph measurement was needed, and no frontage building was affected.
- [x] Resolve the remaining unknown on the hero: the colonnade currently occupies about 45 % of the height against roughly 50–60 % in the photographs. Fix the vertical proportions. **Fixed 2026-11-09.** Measured baseline: colonnade (columns + capitals) 6.90 m of the 14.50 m envelope = **47.6 %**, because the entablature (3.40 m) and parapet (2.20 m) left only 7.90 m for the order. Thinned both to **3.00 m** and **1.80 m** (`Tools/hero_facade_build.py`), which lifts the colonnade to **7.70 m = 53.1 %** — inside the photographed 50–60 % band, with the 14.50 m envelope, the motto band and the medallions unchanged in position relative to the parapet. Hero rebuilt, re-imported (bounds 2604 × 1257 × 1450 cm) and re-rendered.
- [ ] Tag `v0.1.2` and write `CHANGELOG.md` entry; then remove this section.
## 0.1.3 — Packaging and setup fixes (patch)
Suggested blender skills: **export-pipeline** and **unreal-export** (scale/pivot/normals/materials/collision verification) if any packaging defect traces back to the Blender export presets.
### Patch tasks ### Patch tasks
- [ ] Fix any packaging or build issues encountered in `0.1.x`. - [ ] **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.
- [ ] Update `Docs/` setup instructions for anything that changed. - [ ] **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.
- [ ] Tag `v0.1.3` and write `CHANGELOG.md` entry; then remove this section. - [ ] **Blosm blockout cleaning pass.** Roofs and facades remain untextured volumes and the `setupScript` styles stay unused; with the kit carrying the elevations, this is now cleanup rather than a second façade system.
- [ ] **Re-measure 1440p performance** with 625 kit actors and 8 generated textures in the map. `0.1.0` measured 414 fps average, so there is headroom, but it has not been re-run since.
## 0.1.4 — Proper building façades (patch) - [ ] Tag `v0.1.5` and write `CHANGELOG.md` entry; then remove this section.
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).
### 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.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **Hero façade detail pass.** Ionic capitals and fluting, the parapet motto band and medallions as geometry or decal, window rhythm behind the colonnade, and the three portal flights at their photographed proportions.
- [ ] **El Capitan Theatre (6838) and the retail neighbours.** Model from the 18-frame 5472×3648 reference set; the theatre shares the measured 4.96 m bay rhythm with the temple.
- [ ] 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)
+19 -2
View File
@@ -16,6 +16,14 @@ 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. |
| `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 |
| `scale_test_export.py` / `ue_scale_test_import.py` | The 1 m = 100 uu scale test; the named presets `ue_metric_v1` live in these two files | | `scale_test_export.py` / `ue_scale_test_import.py` | The 1 m = 100 uu scale test; the named presets `ue_metric_v1` live in these two files |
@@ -50,16 +58,25 @@ 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
| Command | Purpose | | Command | Purpose |
| --- | --- | | --- | --- |
| `UnrealEditor <uproject> /Game/Hollywood/DemoMap -game -qaselftest` | Headless behaviour check: drives forward input for 2 s, then resets and pauses. Logs `GigLine QA: ... PASS/FAIL` lines and a final `RESULT`. Runs only with `-qaselftest`, so normal play is unaffected. | | `UnrealEditor <uproject> /Game/Hollywood/DemoMap -game -qaselftest` | Headless behaviour check: drives forward input for 2 s, then resets and pauses. Logs `GigLine QA: ... PASS/FAIL` lines and a final `RESULT`. Runs only with `-qaselftest`, so normal play is unaffected. |
| `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes) and a ground-referenced wall probe (0/35), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. | | `-qaselftest` phases | Walk, reset, pause/unpause, a 16-row seam transect (0 holes), a ground-referenced wall probe (0/35) and a **facade occlusion probe** that fires sideways from the road centre and requires a blocking wall wherever a building is staged (0/9), plus a frame-time capture writing `Saved/Profiling/perf-walk-frametimes.csv` and logging `GigLine PERF:`. |
| `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. | | `gigline.SetupLogs` cvar | Spawn/input setup logging, **off by default** (`DefaultEngine.ini [ConsoleVariables]`); enable at runtime with `gigline.SetupLogs 1`. |
## Reference and concept generation
| Tool | Purpose |
| --- | --- |
| `facade_detail_concepts.py` | Generates **frontal building plates** (whole elevation, entrance in its facade, or building with neighbours) from a local reference photograph with an image model (OpenRouter); close-up detail crops are deliberately not requested because they cannot be measured for proportion. `--list` is free and shows which studies have their reference present; `--all`/`--only <name>` generates. Never overwrites (writes `-v2`, `-v3`), writes a `.refgen.json` sidecar per image (model, prompt, source photo, SHA-256), and puts everything under `Art/reference/facade-details/` — gitignored, local-only, never shipped. Costs money per image, so it is never automatic. |
| `judge_screenshot.py` | (see Visual review) |
## Input injection ## Input injection
| Tool | Purpose | | Tool | Purpose |
+4 -14
View File
@@ -19,6 +19,9 @@ import os
import sys import sys
import bpy import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# blockout object name -> (asset name, note). Addresses from the verification doc. # blockout object name -> (asset name, note). Addresses from the verification doc.
BUILDINGS = { BUILDINGS = {
@@ -69,20 +72,7 @@ def main():
bpy.context.view_layer.objects.active = new_obj bpy.context.view_layer.objects.active = new_obj
path = os.path.join(args.out, asset_name + ".fbx") path = os.path.join(args.out, asset_name + ".fbx")
bpy.ops.export_scene.fbx( fbx_preset.export_static(path, selection=True)
filepath=path,
use_selection=True,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
world = new_obj.matrix_world world = new_obj.matrix_world
zs = [(world @ v.co).z for v in new_mesh.vertices] zs = [(world @ v.co).z for v in new_mesh.vertices]
ys = [(world @ v.co).y for v in new_mesh.vertices] ys = [(world @ v.co).y for v in new_mesh.vertices]
+4 -14
View File
@@ -13,6 +13,9 @@ import os
import sys import sys
import bpy import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
BUILDINGS = ("TCL Chinese Theatre", "Madame Tussaud's") BUILDINGS = ("TCL Chinese Theatre", "Madame Tussaud's")
@@ -50,20 +53,7 @@ def main():
bpy.context.view_layer.objects.active = new_obj bpy.context.view_layer.objects.active = new_obj
path = os.path.join(args.out, name.replace(" ", "_").replace("'", "") + ".fbx") path = os.path.join(args.out, name.replace(" ", "_").replace("'", "") + ".fbx")
bpy.ops.export_scene.fbx( fbx_preset.export_static(path, selection=True)
filepath=path,
use_selection=True,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
world = new_obj.matrix_world world = new_obj.matrix_world
zs = [(world @ v.co).z for v in new_mesh.vertices] zs = [(world @ v.co).z for v in new_mesh.vertices]
print(f"[north] {name:22} verts {len(new_mesh.vertices):5} z {min(zs):+.2f}..{max(zs):+.2f} m " print(f"[north] {name:22} verts {len(new_mesh.vertices):5} z {min(zs):+.2f}..{max(zs):+.2f} m "
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""Generate frontal building plates from the local reference photography.
Each study takes one photograph we already hold (`Art/reference/`, gitignored) as
the geometric anchor and asks an image model to re-stage it as a clean **frontal
view of the building** — the whole elevation, the entrance in its facade, or the
building with its neighbours — evenly lit, no crowd, no staging, so the modelling
pass has something measurable instead of a tourist photo.
Deliberately **not** close-up detail crops: a doorway or cornice crop cannot be
measured for proportion, which is what the modelling pass actually needs.
python3 Tools/facade_detail_concepts.py --list
python3 Tools/facade_detail_concepts.py --only door-recess,parapet-motto
python3 Tools/facade_detail_concepts.py --all
python3 Tools/facade_detail_concepts.py --all --model google/gemini-2.5-flash-image
Output: `Art/reference/facade-details/<study>.png` plus a `<study>.refgen.json`
sidecar recording the model, the source photograph, the prompt and the SHA-256 of
the result. `Art/reference/` is gitignored, so **neither the images nor the
sidecars are committed** — they are local working material. The study list and
the tool are the committed part.
Design notes
------------
* Standard library only: the OpenRouter chat-completions call is `urllib`, and
the returned `data:` URL is decoded with `base64` and written straight to disk.
* An existing output is never overwritten; `-v2`, `-v3` … are used instead, so
re-running after a prompt edit keeps both studies comparable.
* Generation costs money, so it is never automatic: `--list` is free, and every
generating run prints the model and the study count first.
* The token is read from `OPENROUTER_API_KEY` or the ignored `.env` and is never
written to a sidecar or a log line.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
REFERENCE_DIR = os.path.join(ROOT, "Art", "reference")
OUT_DIR = os.path.join(REFERENCE_DIR, "facade-details")
API_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "google/gemini-2.5-flash-image"
# The instruction appended to every study. Kept identical to the sibling
# project's convention so the two sets stay comparable: the photograph fixes the
# geometry, the prompt only restages light, weather, people and surface.
ANCHOR_SUFFIX = (
"Use the attached reference photograph as the source of the architecture: keep "
"the building's proportions, storey count, column rhythm, band heights and "
"signage placement exactly as photographed. You may REFRAME — step back across "
"the street, zoom out or widen the field of view — so that the whole building "
"fits in the frame from the pavement to the parapet; the reference is often "
"cropped and the plate must not be. Keep the view exterior and frontal (or "
"three-quarter where asked). Restage only lighting, weather, people and surface "
"condition. Do not invent extra storeys, wings or ornaments, and do not change "
"the architecture to fit the frame."
)
# name -> (reference photo relative to Art/reference/, what the plate must show)
#
# Verified framing (vision pre-check, 2026-11-09): the model follows the reference
# crop closely, so a plate is only as wide as its source photo. `building-entrance`
# and `building-street-front` come back as full-elevation frontal views; the studies
# anchored on the tightly cropped 2009 facade photo stay cropped, because the model
# will not invent the parapet and sky that the photograph does not contain. A full
# building plate for 6840 needs a full-elevation photograph, which the local set
# does not have (see Docs/reference-notes-hollywood.md).
#
# Every study asks for a *frontal view of a building*, not a crop of one detail:
# the whole elevation, the entrance in its facade, or the building together with
# its neighbours. Close-up framing is deliberately absent — a detail crop cannot
# be measured for proportion, which is what the modelling pass needs.
STUDIES: dict[str, tuple[str, str]] = {
"building-front": (
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"A frontal view of the complete building shown in the reference "
"photograph: the whole elevation from the pavement to the parapet fits in "
"the frame with clear sky above the roofline and pavement below the ground "
"floor, shot straight on and centred. Nothing may be cut off at the top or "
"bottom. Even daylight, no crowd, no vehicles.",
),
"building-entrance": (
"el-capitan-entrance/01-hollywood-el-capitan-entrance.jpg",
"A frontal view of the building in the reference photograph, framed so its "
"street entrance is clearly visible as part of the facade — the doors and "
"the wall around them in proportion, not a close-up of the doorway. "
"Include the pavement in front. Even daylight, no crowd.",
),
"building-with-neighbours": (
"hollywood/el-capitan-theatre-blade-sign-day-wikimedia-2008-10.jpg",
"A straight-on view of the street wall in the reference photograph: the "
"building and its neighbouring buildings either side, each at full height "
"from pavement to parapet with sky above, the joins between them visible, "
"nothing cut off. Shot from across the road, parallel to the frontage. "
"Even daylight, no crowd.",
),
"building-street-front": (
"hollywood/walk-of-fame-terrazzo-ovation-day-cc0-2019-06-20.jpg",
"A frontal view along the pavement of the building in the reference "
"photograph: the building's frontage seen from street level with its "
"neighbours continuing either side and the pavement in the foreground. "
"Straight on, even daylight, no crowd.",
),
"building-front-oblique": (
"hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg",
"A three-quarter view of the complete building in the reference "
"photograph: the frontal elevation and one side visible together so the "
"building's depth and mass read, with its neighbouring building at one "
"edge. The whole building stays in frame. Even daylight, no crowd.",
),
"theatre-front-with-neighbours": (
"el-capitan-theater-detail/01-el-capitan-marquee.jpg",
"A frontal view of the theatre in the reference photograph together with "
"the buildings either side of it, the whole frontage and its full height "
"in frame, shot from across the road. Even daylight, no crowd, marquee "
"lettering blocked out as unreadable texture.",
),
}
def load_token() -> str:
token = os.environ.get("OPENROUTER_API_KEY")
if token:
return token.strip()
env_path = os.path.join(ROOT, ".env")
if os.path.exists(env_path):
with open(env_path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if line.startswith("OPENROUTER_API_KEY="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit("no OpenRouter key: set OPENROUTER_API_KEY or add it to .env")
def build_prompt(study: str) -> str:
"""The full prompt for a study: its request plus the fixed anchor suffix."""
if study not in STUDIES:
raise KeyError(f"unknown study {study!r}; known: {sorted(STUDIES)}")
return f"{STUDIES[study][1]}\n\n{ANCHOR_SUFFIX}"
def reference_path(study: str) -> str:
return os.path.join(REFERENCE_DIR, STUDIES[study][0])
def missing_references() -> list[str]:
"""Studies whose source photograph is not present locally."""
return [name for name in STUDIES if not os.path.isfile(reference_path(name))]
def next_free_path(study: str, directory: str = OUT_DIR) -> str:
"""`<study>.png`, or `<study>-v2.png`, `-v3` … if the name is taken."""
base = os.path.join(directory, f"{study}.png")
if not os.path.exists(base):
return base
index = 2
while True:
candidate = os.path.join(directory, f"{study}-v{index}.png")
if not os.path.exists(candidate):
return candidate
index += 1
def data_url(path: str) -> str:
suffix = os.path.splitext(path)[1].lower()
mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"}.get(suffix)
if not mime:
raise SystemExit(f"unsupported reference type: {path}")
with open(path, "rb") as handle:
return f"data:{mime};base64,{base64.b64encode(handle.read()).decode('ascii')}"
def decode_image(payload: dict) -> bytes:
"""Extract the returned image bytes from a chat-completions payload."""
message = (payload.get("choices") or [{}])[0].get("message", {})
urls = [item.get("image_url", {}).get("url", "")
for item in (message.get("images") or []) if isinstance(item, dict)]
if not urls:
match = re.search(r"data:image/[a-z]+;base64,[A-Za-z0-9+/=]+",
str(message.get("content") or ""))
if match:
urls.append(match.group(0))
if not urls or not urls[0]:
raise SystemExit(f"model returned no image data: {json.dumps(payload)[:400]}")
url = urls[0]
if url.startswith("data:"):
return base64.b64decode(url.split(",", 1)[1])
with urllib.request.urlopen(urllib.request.Request(url), timeout=120) as response:
return response.read()
def generate(study: str, token: str, model: str, force: bool = False) -> str:
"""Generate one study; returns the written path. Never overwrites by default."""
path = next_free_path(study)
if force and os.path.exists(os.path.join(OUT_DIR, f"{study}.png")):
path = os.path.join(OUT_DIR, f"{study}.png")
# Write to a temp name then replace, so a failed call cannot destroy the
# previous result.
target = path + ".new"
else:
target = path
body = {
"model": model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": build_prompt(study)},
{"type": "image_url", "image_url": {"url": data_url(reference_path(study))}},
],
}],
}
request = urllib.request.Request(
API_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/gig-line",
"X-Title": "gig-line facade details",
},
method="POST",
)
payload = None
for attempt in range(3):
try:
with urllib.request.urlopen(request, timeout=180) as response:
payload = json.load(response)
break
except urllib.error.HTTPError as exc:
if exc.code == 429 or exc.code >= 500:
wait = 5 * (attempt + 1)
sys.stderr.write(f" HTTP {exc.code}; retrying in {wait}s\n")
time.sleep(wait)
continue
detail = exc.read().decode("utf-8", "replace")[:300]
raise SystemExit(f"{study}: HTTP {exc.code} — {detail}")
except urllib.error.URLError as exc:
sys.stderr.write(f" network error ({exc.reason}); retrying\n")
time.sleep(5 * (attempt + 1))
if payload is None:
raise SystemExit(f"{study}: no response after retries")
image = decode_image(payload)
os.makedirs(OUT_DIR, exist_ok=True)
with open(target, "wb") as handle:
handle.write(image)
if target != path:
os.replace(target, path)
import hashlib
sidecar = {
"study": study,
"generated": os.path.basename(path),
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"model": model,
"prompt": build_prompt(study),
"reference": {
"file": STUDIES[study][0],
"note": "local reference photography; Art/reference is gitignored and never shipped",
},
"sha256": hashlib.sha256(image).hexdigest(),
"bytes": len(image),
"redistribution": "local development reference only; not committed, not shipped",
}
sidecar_path = os.path.splitext(path)[0] + ".refgen.json"
with open(sidecar_path, "w", encoding="utf-8") as handle:
json.dump(sidecar, handle, indent=2)
if token in json.dumps(sidecar):
raise SystemExit("refusing to write a sidecar containing the token")
return path
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--list", action="store_true", help="list studies and exit (free)")
ap.add_argument("--all", action="store_true", help="generate every study")
ap.add_argument("--only", help="comma-separated study names")
ap.add_argument("--model", default=DEFAULT_MODEL)
ap.add_argument("--force", action="store_true",
help="replace the existing <study>.png instead of writing a -vN")
args = ap.parse_args(argv)
if args.list or not (args.all or args.only):
missing = set(missing_references())
print(f"{len(STUDIES)} studies; model {args.model}")
for name, (ref, _prompt) in STUDIES.items():
state = "MISSING reference" if name in missing else "ready"
print(f" {name:22} {state:18} <- {ref}")
if missing:
print(f"\n{len(missing)} study(ies) cannot run without their reference photograph.")
return 0
names = list(STUDIES) if args.all else [s.strip() for s in args.only.split(",") if s.strip()]
unknown = [n for n in names if n not in STUDIES]
if unknown:
raise SystemExit(f"unknown study(ies): {unknown}; known: {sorted(STUDIES)}")
blocked = [n for n in names if n in set(missing_references())]
if blocked:
raise SystemExit(f"missing reference photograph for: {blocked}")
token = load_token()
print(f"generating {len(names)} study(ies) with {args.model} -> {OUT_DIR}")
for index, name in enumerate(names, 1):
print(f"[{index}/{len(names)}] {name}")
try:
path = generate(name, token, args.model, force=args.force)
except SystemExit as exc:
sys.stderr.write(f" FAILED: {exc}\n")
continue
print(f" wrote {os.path.relpath(path, ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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())
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""The one FBX export preset for this project ("ue_metric_v1"/"ue_character_v1").
Before this module the same `bpy.ops.export_scene.fbx(...)` argument block was
copied into six scripts (hero, frontage, north buildings, site, scale test, MPFB
character). They had already drifted only by accident, and every one of them
carried `mesh_smooth_type="OFF"` — which is why Unreal logged "No smoothing group
information was found for this mesh" on every import and round parts rendered
faceted. One preset, one place to fix that.
Use from inside Blender:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "Tools"))
import fbx_export_preset as preset
preset.export_static(path) # whole scene, MESH only
preset.export_static(path, selection=True) # selected objects only
preset.export_character(path) # armature + mesh, character flags
`preset_kwargs()` is pure (no `bpy`), so the argument set can be unit-tested
without Blender — see Tools/tests/test_fbx_export_preset.py.
Why these values:
* `FBX_SCALE_NONE` + `apply_unit_scale` keeps Blender metres → Unreal centimetres
at exactly ×100, verified by the three-cube scale test (1 m → 100.00 uu).
* `axis_forward="-Y"`, `axis_up="Z"` is the project's recorded mapping: east → +X,
north → −Y, up → +Z.
* `mesh_smooth_type="FACE"` exports smoothing groups. `"OFF"` (the old value)
drops them, so Unreal rebuilds flat normals and every cylinder is faceted.
* `use_mesh_modifiers=True` bakes the bevel/modifier stack at export; the source
`.blend` stays non-destructive.
"""
from __future__ import annotations
# Shared by every mesh export. Values are the recorded ue_metric_v1 preset.
_METRIC = {
"object_types": {"MESH"},
"apply_unit_scale": True,
"apply_scale_options": "FBX_SCALE_NONE",
"axis_forward": "-Y",
"axis_up": "Z",
"use_space_transform": True,
"use_mesh_modifiers": True,
"mesh_smooth_type": "FACE",
"path_mode": "AUTO",
"bake_space_transform": False,
}
# Character exports differ only in the object set and the armature/path flags.
_CHARACTER = dict(
_METRIC,
object_types={"ARMATURE", "MESH"},
add_leaf_bones=False,
path_mode="COPY",
embed_textures=False,
)
PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER}
def preset_kwargs(preset: str = "ue_metric_v1", selection: bool = False, **overrides) -> dict:
"""Return the `bpy.ops.export_scene.fbx` argument dict (no `bpy` needed).
`preset` selects the base set, `selection` decides whether only selected
objects are exported, and `overrides` wins last for the rare special case.
"""
if preset not in PRESETS:
raise KeyError(f"unknown preset {preset!r}; known: {sorted(PRESETS)}")
kwargs = dict(PRESETS[preset])
kwargs["use_selection"] = bool(selection)
kwargs.update(overrides)
if "filepath" in kwargs:
raise ValueError("pass the path to export_static/export_character, not as an override")
return kwargs
def _export(path: str, preset: str, selection: bool, overrides: dict) -> str:
import bpy # imported here so `preset_kwargs` stays usable without Blender
kwargs = preset_kwargs(preset, selection=selection, **overrides)
bpy.ops.export_scene.fbx(filepath=path, **kwargs)
return path
def export_static(path: str, selection: bool = False, **overrides) -> str:
"""Export meshes with the ue_metric_v1 preset. Returns the path."""
return _export(path, "ue_metric_v1", selection, overrides)
def export_character(path: str, selection: bool = False, **overrides) -> str:
"""Export armature + mesh with the ue_character_v1 preset. Returns the path."""
return _export(path, "ue_character_v1", selection, overrides)
+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())
+85 -27
View File
@@ -33,6 +33,9 @@ import os
import sys import sys
import bpy import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# --- measured / derived ------------------------------------------------------ # --- measured / derived ------------------------------------------------------
FRONTAGE_W = 25.75 FRONTAGE_W = 25.75
@@ -74,7 +77,7 @@ def cube(name, x, y, z, dx, dy, dz, material=None):
return obj return obj
def cylinder(name, x, y, z0, z1, radius, material, vertices=24): def cylinder(name, x, y, z0, z1, radius, material, vertices=24, smooth=True):
bpy.ops.mesh.primitive_cylinder_add( bpy.ops.mesh.primitive_cylinder_add(
vertices=vertices, vertices=vertices,
radius=radius, radius=radius,
@@ -85,6 +88,30 @@ def cylinder(name, x, y, z0, z1, radius, material, vertices=24):
obj.name = name obj.name = name
if material: if material:
obj.data.materials.append(material) obj.data.materials.append(material)
if smooth:
# Round parts must shade smoothly *around* the axis while keeping the
# caps creased. Without this the columns read as faceted prisms, and the
# FBX carries no smoothing groups at all (Unreal warns about it).
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
return obj
def disc_on_wall(name, x, y, z, radius, thickness, material=None, vertices=24):
"""A round disc mounted vertically on a wall face.
`cylinder()` builds along Z, so a "medallion" made with it is a pancake lying
flat on top of the parapet rather than a disc on its face — which is what the
first build did (1.1 m across in Y, 0.02 m thick in Z, sitting at the coping).
This rotates the cylinder 90 degrees about X so the axis points along Y and
the disc faces the street.
"""
obj = cylinder(name, x, y, z - thickness / 2.0, z + thickness / 2.0,
radius, material, vertices=vertices)
obj.rotation_euler = (math.radians(90.0), 0.0, 0.0)
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
return obj return obj
@@ -136,14 +163,32 @@ def build():
col_xs.append(x) col_xs.append(x)
cube(f"Hero_Pedestal_{i + 1}", x, -0.62, PEDESTAL_H + 0.27, cube(f"Hero_Pedestal_{i + 1}", x, -0.62, PEDESTAL_H + 0.27,
1.24, 1.24, 0.54, stone) 1.24, 1.24, 0.54, stone)
cylinder(f"Hero_Column_{i + 1}", x, -0.55, PEDESTAL_H + 0.55, # Base moulding: a taller, wider cylinder where the shaft meets the
PEDESTAL_H + 0.55 + (HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H - 0.55 - CAPITAL_H), # pedestal. Without it the shaft starts straight off a square plinth,
# which is not how an Ionic base reads in silhouette.
base_z0 = PEDESTAL_H + 0.55
base_h = 0.22
cylinder(f"Hero_ColumnBase_{i + 1}", x, -0.55, base_z0, base_z0 + base_h,
0.68, stone, vertices=20)
shaft_z0 = base_z0 + base_h
cap_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H
cylinder(f"Hero_Column_{i + 1}", x, -0.55, shaft_z0, cap_z0,
COLUMN_D / 2.0, stone) COLUMN_D / 2.0, stone)
cylinder(f"Hero_Capital_{i + 1}", x, -0.55, # Capital block, then the square abacus that carries the architrave.
HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H, # The abacus takes the top 0.14 m of the capital zone so the colonnade
HEIGHT - ENTABLATURE_H - PARAPET_H, 0.62, stone, vertices=16) # still ends at HEIGHT - ENTABLATURE_H - PARAPET_H (proportion unchanged).
abacus_h = 0.14
cylinder(f"Hero_Capital_{i + 1}", x, -0.55, cap_z0,
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h, 0.62, stone, vertices=16)
cube(f"Hero_Abacus_{i + 1}", x, -0.55,
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h / 2.0,
1.30, 1.30, abacus_h, stone)
note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H), note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H),
f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)") f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)")
note("Hero_ColumnBase_x6", (1.36, 1.36, 0.22),
"assumption: Ionic base moulding; photographs show a wider base at the shaft foot")
note("Hero_Abacus_x6", (1.30, 1.30, 0.14),
"assumption: square abacus under the architrave, as Ionic capitals carry")
# --- entablature and parapet ------------------------------------------ # --- entablature and parapet ------------------------------------------
ent_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H ent_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H
@@ -151,9 +196,25 @@ def build():
# elevation reads as layered stone rather than one flat plane. # elevation reads as layered stone rather than one flat plane.
cube("Hero_Architrave", 0.0, -1.55, ent_z0 + 0.42, FRONTAGE_W, 1.65, 0.84, stone) cube("Hero_Architrave", 0.0, -1.55, ent_z0 + 0.42, FRONTAGE_W, 1.65, 0.84, stone)
note("Hero_Architrave", (FRONTAGE_W, 1.65, 0.84), "assumption: stacked entablature bands") note("Hero_Architrave", (FRONTAGE_W, 1.65, 0.84), "assumption: stacked entablature bands")
# Cornice under the parapet. # Cornice under the parapet: a projecting corona carried on a dentil course,
cube("Hero_Cornice", 0.0, -1.6, HEIGHT - PARAPET_H - 0.18, FRONTAGE_W, 1.75, 0.36, stone) # so the top of the order reads as layered stone with a shadow line rather
# than one flat box. Dentils hang under the corona and stand 0.25 m clear of
# it, which is what gives the elevation its cornice shadow at street level.
cornice_z = HEIGHT - PARAPET_H - 0.18
cube("Hero_Cornice", 0.0, -1.6, cornice_z, FRONTAGE_W, 1.75, 0.36, stone)
note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line") note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line")
DENTIL_W, DENTIL_D, DENTIL_H, DENTIL_GAP = 0.18, 0.30, 0.22, 0.27
dentil_y = -2.30
dentil_z = cornice_z - 0.18 - DENTIL_H / 2.0
dentil_span = FRONTAGE_W - 2.0
dentil_count = int(dentil_span / (DENTIL_W + DENTIL_GAP))
for i in range(dentil_count):
dx = -dentil_span / 2.0 + (DENTIL_W + DENTIL_GAP) * i + DENTIL_W / 2.0
cube(f"Hero_Dentil_{i + 1}", dx, dentil_y, dentil_z,
DENTIL_W, DENTIL_D, DENTIL_H, stone)
note("Hero_Dentil_xN", (DENTIL_W, DENTIL_D, DENTIL_H),
f"{dentil_count} dentils at {DENTIL_W + DENTIL_GAP:.2f} m centres under the corona; "
"assumption: photographed cornice reads as a dentilled course")
cube("Hero_Entablature", 0.0, -0.75, ent_z0 + ENTABLATURE_H / 2.0, cube("Hero_Entablature", 0.0, -0.75, ent_z0 + ENTABLATURE_H / 2.0,
FRONTAGE_W, 1.5, ENTABLATURE_H, stone) FRONTAGE_W, 1.5, ENTABLATURE_H, stone)
note("Hero_Entablature", (FRONTAGE_W, 1.5, ENTABLATURE_H), "assumption: entablature depth") note("Hero_Entablature", (FRONTAGE_W, 1.5, ENTABLATURE_H), "assumption: entablature depth")
@@ -167,11 +228,13 @@ def build():
FRONTAGE_W - 3.0, 0.08, 0.85, stone_dark) FRONTAGE_W - 3.0, 0.08, 0.85, stone_dark)
note("Hero_MottoBand", (FRONTAGE_W - 3.0, 0.08, 0.85), "photographs: motto panel above the colonnade") note("Hero_MottoBand", (FRONTAGE_W - 3.0, 0.08, 0.85), "photographs: motto panel above the colonnade")
# Medallions either side of the inscription. # Medallions either side of the inscription: discs on the parapet face, not
# pancakes on the coping (see disc_on_wall).
for i, x in enumerate((-6.2, 6.2), start=1): for i, x in enumerate((-6.2, 6.2), start=1):
cylinder(f"Hero_Medallion_{i}", x, -1.36, HEIGHT - PARAPET_H / 2.0 + 0.42, disc_on_wall(f"Hero_Medallion_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
HEIGHT - PARAPET_H / 2.0 + 0.44, 0.55, stone_dark, vertices=20) 0.55, 0.04, stone_dark, vertices=20)
note("Hero_Medallion_x2", (1.1, 1.1, 0.02), "photographs: two stone discs beside the motto") note("Hero_Medallion_x2", (1.1, 0.04, 1.1),
"photographs: two stone discs beside the motto, mounted on the parapet face")
# --- entrances --------------------------------------------------------- # --- entrances ---------------------------------------------------------
# Three portals between column pairs, each with its own short flight. The # Three portals between column pairs, each with its own short flight. The
@@ -245,32 +308,27 @@ def build():
# --- windows ----------------------------------------------------------- # --- windows -----------------------------------------------------------
# Two ranks of windows between the columns and behind the colonnade. # Two ranks of windows between the columns and behind the colonnade.
# Y must sit on the wall plane: the mass front face is at Y -0.50 and the
# columns stand proud to -1.02. These were at -1.98, i.e. 1.44 m in FRONT of
# the wall and ahead of the column line, so the glazing floated in the
# colonnade instead of reading as openings behind it (visible in the
# review-hero-facade-* shots). WINDOW_Y puts the pane 6 cm proud of the wall,
# safely behind the columns.
WINDOW_Y = -0.56
for rank, z in enumerate((4.4, 8.2), start=1): for rank, z in enumerate((4.4, 8.2), start=1):
for i, x in enumerate(col_xs[:-1], start=1): for i, x in enumerate(col_xs[:-1], start=1):
wx = x + COLUMN_BAY / 2.0 wx = x + COLUMN_BAY / 2.0
cube(f"Hero_Window_{rank}_{i}", wx, -1.98, z, 2.0, 0.08, 1.9, glass) cube(f"Hero_Window_{rank}_{i}", wx, WINDOW_Y, z, 2.0, 0.08, 1.9, glass)
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade") note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
return parts, lines return parts, lines
def export_fbx(path): def export_fbx(path):
# Preset lives in Tools/fbx_export_preset.py (ue_metric_v1).
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_metric_v1" - same settings as Tools/scale_test_export.py. # Named export preset "ue_metric_v1" - same settings as Tools/scale_test_export.py.
bpy.ops.export_scene.fbx( fbx_preset.export_static(path)
filepath=path,
use_selection=False,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
print(f"[hero] exported FBX preset ue_metric_v1 -> {path}") print(f"[hero] exported FBX preset ue_metric_v1 -> {path}")
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more