#!/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())