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:
zwitschi committed 2026-11-08 20:03:34 +01:00
1 parent bd1f8a9c2e
commit ec5545b265
18 files changed
+432 -13

No files matched your search

+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())