The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first: - ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now. - render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/... and the capture step failed in 0 s. - the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build' passed without building. Result files are unlinked before each step and child output streams to a per-step log file rather than a pipe. - two editors on one project hang instead of failing, so the driver refuses to start while one is running. Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the builders actually need. Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before, and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
211 lines
8.0 KiB
Python
211 lines
8.0 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/blender/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
|
||
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
|
||
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
|
||
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())
|