Files
gig-line/Tools/blender/theatre_trim_build.py
T
zwitschi 4d27109668 refactor(tools): group the sixty scripts by what they talk to
One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
2026-11-10 12:40:30 +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/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
sys.path.insert(0, os.path.dirname(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())