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.
209 lines
7.8 KiB
Python
209 lines
7.8 KiB
Python
#!/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())
|