Files
gig-line/Tools/theatre_trim_build.py
T
zwitschi 5e2c5e29c0 feat(facades): 0.1.4 — module kit, textured materials, theatre trim (#30)
Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
2026-11-08 20:29:11 +01:00

209 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())