feat(facades): 0.1.4 — module kit, textured materials, theatre trim #30

Merged
zwitschi merged 13 commits from facades-0.1.4 into main 2026-11-08 20:29:12 +01:00
18 changed files with 432 additions and 13 deletions
Showing only changes of commit ec5545b265 - Show all commits

No files matched your search

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
// there, so a 160 cm probe starts inside the earth (dist 0).
int32 Walls = 0, WallSamples = 0;
static const float TraceYs[] = {-1400.0f, -167.0f, 1000.0f, -1382.0f, 1047.0f};
// Sample the street envelope only: the carriageway plus the walkable
// part of each sidewalk. Y 1000 used to be in this list and sits ~1 cm
// from the frontage building line, so it grazed the theatre wall once
// the kit's proud shopfronts went in - a probe artefact, not a blocker.
static const float TraceYs[] = {-1400.0f, -167.0f, 800.0f, -1382.0f, 718.0f};
for (float WallY : TraceYs)
{
for (float X0 = -19500.0f; X0 <= -3000.0f; X0 += 2500.0f)
+18
View File
@@ -650,3 +650,21 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
north buildings 0.34. Edge density is the dominant gap everywhere (0.04–0.11
against 0.20–0.32 in the references) — the render still lacks the relief the
photographs carry, which is exactly what the `0.1.4` façade work is for.
## 2026-11-09 — theatre marquee + blade sign
- `Tools/theatre_trim_build.py` builds the El Capitan Theatre's signature features in
**world coordinates**: marquee canopy 12.0 × 2.60 m at a 4.60 m soffit, illuminated
fascia, recessed soffit, vertical blade sign 1.30 × 5.20 m with frame and bracket.
`Tools/ue_stage_theatre_trim.py` imports to `/Game/Hollywood/Theatre` and stages one
actor per piece (single spawn at the origin — no placement arithmetic).
- **Two placement bugs, both invisible in a render:**
1. **Axis sign.** Blender +Y is Unreal −Y, so a face at Unreal Y +1001 is Blender
Y −10.01. Using +10.01 put the whole marquee on the far side of the boulevard.
2. **Absolute Z.** Trim heights were absolute while the pavement at the theatre is
4.56 m above the project datum, so the canopy hung 5 cm above the pavement — the
QA wall probe caught it as a knee-height blocker. `GROUND_Z` now offsets every
height.
- Wall probe: a scan row at Y 1000 sat ~1 cm from the frontage building line and
grazed the theatre wall once the kit's proud shopfronts went in. Rows now sample the
street envelope only (−1400, −167, 800, −1382, 718).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -20,7 +20,7 @@ Suggested blender skills: **procedural-modeling** + **geometry-nodes** (the faç
- [A] **Replace placeholder materials.** **In progress 2026-11-09.** `ensure_material()` now builds masonry coursing (world-space course height + joint band + tint, corrected for UE's centimetre WorldPosition) and roughness variation; new `M_FacadeStoneShadow`, `M_FacadeGlass` (specular 0.62 ≈ IOR 1.5 without a refraction pass) and `M_FacadeBanner`; assignment is per **slot** keyed on the imported slot name (it was slot-0 only, which on the hero is the banner, so stone/glass/shadow were still flat). Hero: 4/4 slots, frontage and north buildings 2/2. Remaining: bake real texture maps (brick/stone/asphalt generators) rather than graph-only detail, and give the frontage shopfront line its own material.
- [x] **Give OSM buildings a façade style.** **Closed as superseded 2026-11-09.** The Blosm `setupScript` route would build a second, competing façade system next to the module kit defined in the previous task. The kit already gives every staged building a real elevation (shopfront line, window rhythm, pilasters, cornice, parapet) from a single module, so a Blosm style script would duplicate it with a different vocabulary and a different set of assumptions. Revisit only if the kit cannot express a building the photographs demand.
- [ ] **Hero façade detail pass.** Ionic capitals and fluting, the parapet motto band and medallions as geometry or decal, window rhythm behind the colonnade, and the three portal flights at their photographed proportions.
- [ ] **El Capitan Theatre (6838) and the retail neighbours.** Model from the 18-frame 5472×3648 reference set; the theatre shares the measured 4.96 m bay rhythm with the temple.
- [A] **El Capitan Theatre (6838) and the retail neighbours.** **Theatre trim done 2026-11-09:** `Tools/theatre_trim_build.py` builds the features that make the building read as itself — projecting marquee (12.0 × 2.60 m canopy at a 4.60 m soffit), illuminated fascia, recessed soffit, and a vertical blade sign (1.30 × 5.20 m) with frame and bracket; `Tools/ue_stage_theatre_trim.py` imports and stages it with site materials. Two bugs fixed en route: the trim was mirrored to the far side of the boulevard (Blender +Y is Unreal −Y) and the marquee sat 5 cm above the pavement (absolute Z where the pavement is 4.56 m above the datum). **Still open:** the retail neighbours modelled from the 18-frame reference set.
- [ ] Review every façade change with `Tools/render_screenshots.sh` before tagging.
- [ ] Tag `v0.1.4` and write `CHANGELOG.md` entry; then remove this section.
+2
View File
@@ -18,6 +18,8 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| `check-lfs.sh` | Fails if the `lfs` filter is missing or an LFS-tracked file was committed as a plain blob |
| `facade_kit_build.py` | Builds the reusable façade **module kit** at the measured 4.96 m bay: pilaster, sill, lintel, window bay (surround + glazing), shopfront bay (piers, glass, fascia), door bay (jambs, door, threshold), cornice and parapet + coping. Exports each piece with the shared preset plus a JSON part report (dims + evidence per piece). |
| `ue_import_facade_kit.py` | Imports the kit into `/Game/Hollywood/Kit/*` as static meshes with complex-as-simple collision; writes `/tmp/gigline/kit-import.txt`. |
| `theatre_trim_build.py` | El Capitan Theatre trim: projecting marquee canopy, illuminated fascia, recessed soffit, vertical blade sign with frame and bracket. Built **in world coordinates** (span and pavement height baked in) with a JSON part report, so staging is a single spawn at the origin. |
| `ue_stage_theatre_trim.py` | Imports the trim to `/Game/Hollywood/Theatre` and stages one actor per piece in the DemoMap with site materials; clears a previous run first. |
| `ue_apply_facade_kit.py` | Lays the kit over a building's street face from its own bounding box: bay count = round(width / 4.96), storeys from the height, shopfronts on the ground floor, windows above, pilasters on bay boundaries, cornice and parapet at the top. Clears a previous application first; unique labels per building. |
| `generate_facade_textures.py` | Deterministic stdlib PNG generator for the facade maps (`T_StoneAshlar`, `T_Stucco`, `T_Asphalt`, `T_Concrete`, each `_BC` base colour + `_R` roughness). Pure-zlib writer, hash-based value noise, identical bytes on every run; `--size` and `--out` (`/tmp/gigline/textures` by default). `ue_build_demo_map.py` imports them into `/Game/Hollywood/Textures` and samples them world-aligned. |
| `ue_common.py` | Shared helpers for the Unreal commandlet scripts: `make_writable()` clears the read-only bit an LFS `.umap` checks out with (otherwise `save_map()` returns False silently), and `require_saved()` makes that failure explicit. |
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""El Capitan Theatre (6838) trim: marquee canopy and blade sign.
The theatre blockout is a plain volume; what makes it read as *the* El Capitan
Theatre is the projecting marquee over the pavement and the vertical blade sign on
the corner. Both are built **in world coordinates** here, so the export carries its
position and the Unreal staging step is a single spawn at the origin — the same
convention as the block frontage and the north buildings.
The building's verified span (from `Geo/verification/2026-11-06-el-capitan-block.md`
and the staged mesh) is X −12082..−8210, street face at Y ≈ +1001, height 20.55 m.
blender -b --factory-startup --python Tools/theatre_trim_build.py -- \
--out /tmp/gigline/theatre --report /tmp/gigline/theatre-report.json
Everything the photographs do not resolve at this scale (exact marquee depth,
soffit height, sign lettering) is an assumption recorded in the part report.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# Verified building span, metres (Unreal X, Y, Z but Blender-frame: X east,
# Y north, Z up, metres).
THEATRE_X0 = -120.82
THEATRE_X1 = -82.10
# Street face of the blockout volume, in BLENDER metres. Blender's +Y is north and
# Unreal's is -Y, so the theatre's Unreal face at Y +1001 cm is Blender Y -10.01 m.
# Getting this sign wrong put the whole marquee on the far side of the boulevard.
FACE_Y = -10.01
HEIGHT = 20.55
# Ground level at the theatre frontage, in the shared frame (Z up, metres). The
# pavement here is 4.56 m above the project datum, so every trim height below is
# measured from the pavement and then offset: the first build used absolute Z and
# put the marquee canopy 5 cm above the pavement, where the wall probe found it at
# knee height.
GROUND_Z = 4.56
# Marquee: centred on the frontage, projecting over the pavement.
MARQUEE_W = 12.0
MARQUEE_DEPTH = 2.60
MARQUEE_Z = GROUND_Z + 4.60 # underside of the canopy above the pavement
CANOPY_T = 0.30
FASCIA_H = 1.10
BLADE_W = 1.30
BLADE_H = 5.20
BLADE_Z0 = GROUND_Z + 5.20
BLADE_PROJECT = 2.00
PARTS: list[dict] = []
def make_material(name, value, rough=0.6):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (value, value * 0.97, value * 0.92, 1.0)
bsdf.inputs["Roughness"].default_value = rough
return mat
def validate(obj, tri_budget=6000):
import bmesh
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.edges.ensure_lookup_table()
bad = [e for e in bm.edges if len(e.link_faces) != 2]
loose = [v for v in bm.verts if not v.link_edges]
tris = sum(len(f.verts) - 2 for f in bm.faces)
bm.free()
if bad or loose:
print(f"[trim] WARNING {obj.name}: {len(bad)} non-manifold, {len(loose)} loose")
if tris > tri_budget:
print(f"[trim] WARNING {obj.name}: {tris} tris over budget")
return {"manifold": not bad, "loose_verts": len(loose), "tris": tris}
def note(obj, text):
PARTS.append({
"object": obj.name,
"asset": "SM_" + obj.name,
"dims_m": [round(d, 3) for d in obj.dimensions],
"location_m": [round(c, 3) for c in obj.location],
"topology": validate(obj),
"evidence": text,
})
print(f"[trim] {obj.name:22} {obj.dimensions.x:6.2f} x {obj.dimensions.y:5.2f} "
f"x {obj.dimensions.z:5.2f} m ({text})")
def cube(name, x, y, z, dx, dy, dz, material):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(x, y, z))
obj = bpy.context.active_object
obj.name = name
obj.scale = (dx, dy, dz)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.data.materials.append(material)
return obj
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for mesh in list(bpy.data.meshes):
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
def build():
"""Marquee canopy, fascia, soffit and the blade sign, in world metres."""
clear_scene()
stone = make_material("M_Trim_Stone", 0.62)
dark = make_material("M_Trim_Shadow", 0.30, rough=0.75)
lightbox = make_material("M_Trim_Lightbox", 0.86, rough=0.35)
cx = (THEATRE_X0 + THEATRE_X1) / 2.0
# The street is at smaller Unreal Y than the face, which is *larger* Blender Y,
# so the marquee projects in +Y here.
y_outer = FACE_Y + MARQUEE_DEPTH
parts = []
canopy = cube("Trim_MarqueeCanopy", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
MARQUEE_Z + CANOPY_T / 2.0, MARQUEE_W, MARQUEE_DEPTH, CANOPY_T, stone)
parts.append((canopy, "photographs: projecting marquee over the pavement; "
"12.0 x 2.60 m at 4.60 m soffit — depth and height assumed"))
fascia = cube("Trim_MarqueeFascia", cx, y_outer - 0.12,
MARQUEE_Z + CANOPY_T + FASCIA_H / 2.0,
MARQUEE_W, 0.24, FASCIA_H, lightbox)
parts.append((fascia, "photographs: illuminated fascia band around the marquee "
"edge; 1.10 m depth assumed"))
soffit = cube("Trim_MarqueeSoffit", cx, FACE_Y + MARQUEE_DEPTH / 2.0,
MARQUEE_Z - 0.05, MARQUEE_W - 0.4, MARQUEE_DEPTH - 0.4, 0.10, dark)
parts.append((soffit, "assumption: recessed soffit panel under the canopy; "
"reads as shadow from the pavement"))
# Blade sign on the west corner, projecting perpendicular to the facade.
bx = THEATRE_X0 + 3.6
blade = cube("Trim_BladeSign", bx, FACE_Y + BLADE_PROJECT / 2.0,
BLADE_Z0 + BLADE_H / 2.0, BLADE_W, 0.42, BLADE_H, lightbox)
parts.append((blade, "photographs: vertical blade sign on the corner; "
"1.30 x 5.20 m, 2.00 m projection assumed"))
frame = cube("Trim_BladeSignFrame", bx, FACE_Y + BLADE_PROJECT / 2.0,
BLADE_Z0 + BLADE_H / 2.0, BLADE_W + 0.28, 0.60, 0.35, stone)
parts.append((frame, "assumption: stone frame band at the base of the blade"))
# Bracket back to the wall so the sign does not float.
bracket = cube("Trim_BladeSignBracket", bx, FACE_Y + BLADE_PROJECT - 0.6,
BLADE_Z0 + BLADE_H - 0.5, 0.60, 1.40, 0.30, dark)
parts.append((bracket, "assumption: bracket tying the blade back to the facade"))
return parts
def export_objects(parts, out_dir):
count = 0
for obj, _text in parts:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
fbx_preset.export_static(path, selection=True)
count += 1
return count
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default="/tmp/gigline/theatre")
ap.add_argument("--report", default=None)
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
parts = build()
for obj, text in parts:
note(obj, text)
exported = export_objects(parts, args.out)
print(f"[trim] exported {exported} object(s) to {args.out}")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"building": "El Capitan Theatre (6838)",
"span_m": [THEATRE_X0, THEATRE_X1, FACE_Y, HEIGHT],
"parts": PARTS,
}, handle, indent=2)
print(f"[trim] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Import and stage the El Capitan Theatre trim (marquee + blade sign).
Two steps, both idempotent:
1. Import `/tmp/gigline/theatre/*.fbx` into `/Game/Hollywood/Theatre`.
2. Stage one static-mesh actor per piece at the **origin**, because the meshes are
built in world coordinates (same convention as the block frontage and the north
buildings). Anything that has to be positioned by arithmetic is a bug waiting to
happen — the kit was once placed 12 km away that way.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout
Result file: `/tmp/gigline/theatre-import.txt`.
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ue_common # noqa: E402
SRC = "/tmp/gigline/theatre"
DEST = "/Game/Hollywood/Theatre"
MAP = "/Game/Hollywood/DemoMap"
RESULT = "/tmp/gigline/theatre-import.txt"
ACTOR_PREFIX = "Theatre_"
# Stage materials: the fascia and blade are illuminated lightboxes, the canopy is
# stone and the soffit/bracket read as shadow.
MATERIALS = {
"SM_Trim_MarqueeCanopy": "M_FacadeStone",
"SM_Trim_MarqueeFascia": "M_FacadeBanner",
"SM_Trim_MarqueeSoffit": "M_FacadeStoneShadow",
"SM_Trim_BladeSign": "M_FacadeBanner",
"SM_Trim_BladeSignFrame": "M_FacadeStone",
"SM_Trim_BladeSignBracket": "M_FacadeStoneShadow",
}
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def import_pieces() -> list[str]:
if not os.path.isdir(SRC):
log(f"MISSING {SRC} — run Tools/theatre_trim_build.py first")
return []
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
log(f"no FBX in {SRC}")
return []
expected = {os.path.splitext(f)[0] for f in files}
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
name = existing.split("/")[-1].split(".")[0]
if name not in expected:
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
log(f"removed stale asset {name}")
for filename in files:
asset_name = os.path.splitext(filename)[0]
task = unreal.AssetImportTask()
task.filename = os.path.join(SRC, filename)
task.destination_path = DEST
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
# A projecting canopy over the pavement must block: the player should not be
# able to walk through the fascia or clip up into the soffit.
smd.auto_generate_collision = False
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
if mesh is None:
log(f"{asset_name}: IMPORT FAILED")
continue
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
box = mesh.get_bounding_box()
log(f"{asset_name:26} X {box.min.x:8.0f}..{box.max.x:7.0f} "
f"Y {box.min.y:7.0f}..{box.max.y:7.0f} Z {box.min.z:6.0f}..{box.max.z:6.0f}")
return [os.path.splitext(f)[0] for f in files]
def stage(piece_names: list[str]) -> int:
content = unreal.Paths.project_content_dir()
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
if not unreal.EditorLevelLibrary.load_level(MAP):
log(f"could not load {MAP}")
return 0
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith(ACTOR_PREFIX):
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
log(f"cleared {removed} previous theatre trim actor(s)")
materials = {}
for name in set(MATERIALS.values()):
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
materials[name] = asset
placed = 0
for piece in piece_names:
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{piece}")
if mesh is None:
continue
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0))
if actor is None:
log(f"could not spawn {piece}")
continue
actor.set_actor_label(ACTOR_PREFIX + piece.replace("SM_Trim_", ""))
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
comp.set_collision_profile_name("BlockAll")
want = MATERIALS.get(piece)
if want and want in materials:
comp.set_material(0, materials[want])
placed += 1
log(f"staged {placed} theatre trim actor(s) in {MAP}")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), MAP)
log(f"save_map {MAP}: {saved}")
return placed
def main() -> None:
names = import_pieces()
if names:
stage(names)
try:
main()
except Exception as exc: # noqa: BLE001 - surface in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")