feat(theatre): marquee canopy and blade sign; fix two placement bugs
Tools/theatre_trim_build.py builds the El Capitan Theatre's signature features in world coordinates - projecting marquee (12.0 x 2.60 m canopy, 4.60 m soffit), illuminated fascia, recessed soffit, vertical blade sign (1.30 x 5.20 m) with frame and bracket - with a JSON part report. Tools/ue_stage_theatre_trim.py imports and stages one actor per piece with site materials, clearing a previous run first. Two bugs, neither visible in a render: - Axis sign: Blender +Y is Unreal -Y, so the theatre face at Unreal Y +1001 is Blender Y -10.01; using +10.01 mirrored the marquee across the boulevard. - Absolute Z: trim heights ignored the 4.56 m pavement, leaving the canopy 5 cm above the ground. GROUND_Z now offsets every height. QA wall probe: the Y 1000 scan row sat ~1 cm from the building line and grazed the theatre wall after the kit's proud shopfronts; rows now sample the street envelope only. Packaged QA back to PASS (transect 0 holes, wall probe 0/35, facade probe 0/9). Screenshots re-rendered and verified 4/4.
This commit is contained in:
1 parent
bd1f8a9c2e
commit
ec5545b265
18 files changed
+432
-13
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user