feat: hero facade blockout for 6840 Hollywood Blvd

Tools/hero_facade_build.py builds the north elevation parametrically and writes
a JSON report naming the evidence behind every part: the 25.75 m frontage and
14.5 m height are measured, the 4.96 m column bay comes from the sibling notes
and matches that frontage, the three portal flights follow the photographs (five
risers, overturning the inherited eight-step, 12 m wide claim), and the rest is
labelled assumption.

Tools/ue_hero_facade_import.py imports it with the ue_metric_v1 preset
(2604 x 1257 x 1450 cm, 1892 tris, 4 materials) and places it on the measured
building line of /Game/Hollywood/DemoMap.

Two new review shots at review-04 and review-05, both pending approval. Camera
placement cost four renders: the first two sat inside the facade body, and the
frontage faces Unreal -Y, so a camera has to stand at Y < -1450 cm, not north of
the building line in project terms.
This commit is contained in:
zwitschi committed 2026-11-07 11:42:29 +01:00
1 parent 41fd249b8a
commit c5e30d1c36
14 files changed
+600 -6

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
"""Parametric hero façade: 6840 Hollywood Blvd / El Capitan Entertainment Centre.
Builds the north elevation in the project frame (X east, Y north, Z up, metres,
origin Hollywood x Highland) and exports it with the named FBX preset
`ue_metric_v1`, so it imports into Unreal at 1 m = 100 uu.
blender -b --factory-startup --python Tools/hero_facade_build.py -- \
--out /tmp/gigline/hero-facade.fbx \
--save-blend Art/blender/hero/hero-facade-2026-11-07.blend \
--report Art/blender/hero/hero-facade-2026-11-07.json
Every dimension traces to a recorded measurement or a named assumption; the
report writes both, so a later pass can see what was evidence and what was
invented:
width 25.75 m measured, OSM way 424024147 north edge
height 14.50 m tag `height` on the same way
6 columns, 4.96 m sibling venue notes, cross-checked against the 25.75 m
frontage (5 bays + one column = 25.70 m)
three portals photographs: three separate openings, each with its own
short flight, not one 12 m grand stair
everything else assumption, listed in the report
Quieter choices are deliberate: a blockout for the demo, not a scanned facade.
Detail that the photographs do not resolve (moulding profiles, window rhythm)
is left abstract rather than invented in fine detail.
"""
import argparse
import json
import math
import os
import sys
import bpy
# --- measured / derived ------------------------------------------------------
FRONTAGE_W = 25.75
BODY_D = 12.0 # street-side mass only; the building runs 54.9 m deep
HEIGHT = 14.5
COLUMN_COUNT = 6
COLUMN_BAY = 4.96
FRONTAGE_Y = 0.0 # front plane sits at the building line of the block frame
# --- assumptions -------------------------------------------------------------
PEDESTAL_H = 1.45 # column pedestal top; entrance platform sits on it
COLUMN_D = 0.95 # Ionic shaft diameter at the base
CAPITAL_H = 0.95
ENTABLATURE_H = 2.20
PARAPET_H = 1.60
PORTAL_W = 3.10 # each of the three door openings
PORTAL_H = 3.40
STEP_TREAD = 0.34
STEP_RISE = 0.15
STEP_WIDTH = 3.60
def cube(name, x, y, z, dx, dy, dz, material=None):
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)
if material:
obj.data.materials.append(material)
return obj
def cylinder(name, x, y, z0, z1, radius, material, vertices=24):
bpy.ops.mesh.primitive_cylinder_add(
vertices=vertices,
radius=radius,
depth=(z1 - z0),
location=(x, y, (z0 + z1) / 2.0),
)
obj = bpy.context.active_object
obj.name = name
if material:
obj.data.materials.append(material)
return obj
def make_material(name, rgba, roughness=0.75):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = rgba
bsdf.inputs["Roughness"].default_value = roughness
return mat
def build():
scene = bpy.context.scene
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
stone = make_material("M_Facade_Stone", (0.78, 0.74, 0.66, 1.0), 0.85)
stone_dark = make_material("M_Facade_StoneShadow", (0.62, 0.59, 0.53, 1.0), 0.85)
glass = make_material("M_Facade_GlassDark", (0.05, 0.06, 0.07, 1.0), 0.25)
banner = make_material("M_Facade_Banner", (0.66, 0.06, 0.05, 1.0), 0.9)
half_w = FRONTAGE_W / 2.0
lines = []
parts = []
def note(name, dims, evidence):
parts.append({"object": name, "dims_m": [round(d, 3) for d in dims], "evidence": evidence})
lines.append(f"[hero] {name:22} {dims[0]:6.2f} x {dims[1]:6.2f} x {dims[2]:6.2f} m ({evidence})")
# --- main mass ---------------------------------------------------------
# The mass sits behind the colonnade; its front face is the colonnade line.
# Inset so the entablature (1.5 m deep) and parapet (1.2 m deep) sit inside the
# measured 25.75 m frontage instead of overhanging it.
inset = 0.75
cube("Hero_Mass", 0.0, -BODY_D / 2.0 - 0.5, HEIGHT / 2.0, FRONTAGE_W - 2 * inset, BODY_D, HEIGHT, stone_dark)
note("Hero_Mass", (FRONTAGE_W - 2 * inset, BODY_D, HEIGHT),
"measured frontage x assumption depth x measured height, inset behind the cornice")
# --- stylobate and steps ----------------------------------------------
# A plinth from the pavement to the pedestal shelf, with three door flights.
cube("Hero_Stylobate", 0.0, -1.2, PEDESTAL_H / 2.0, FRONTAGE_W, 2.4, PEDESTAL_H, stone)
note("Hero_Stylobate", (FRONTAGE_W, 2.4, PEDESTAL_H), "assumption: pedestal height")
# --- Ionic colonnade ---------------------------------------------------
col_xs = []
span = (COLUMN_COUNT - 1) * COLUMN_BAY
for i in range(COLUMN_COUNT):
x = -span / 2.0 + i * COLUMN_BAY
col_xs.append(x)
cylinder(f"Hero_Pedestal_{i + 1}", x, -0.55, PEDESTAL_H, PEDESTAL_H + 0.55,
0.62, stone, vertices=16)
cylinder(f"Hero_Column_{i + 1}", x, -0.55, PEDESTAL_H + 0.55,
PEDESTAL_H + 0.55 + (HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H - 0.55 - CAPITAL_H),
COLUMN_D / 2.0, stone)
cylinder(f"Hero_Capital_{i + 1}", x, -0.55,
HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H,
HEIGHT - ENTABLATURE_H - PARAPET_H, 0.62, stone, vertices=16)
note("Hero_Column_x6", (COLUMN_D, COLUMN_D, HEIGHT - ENTABLATURE_H - PARAPET_H - PEDESTAL_H),
f"6 columns at {COLUMN_BAY} m centres (sibling notes x measured frontage)")
# --- entablature and parapet ------------------------------------------
ent_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H
cube("Hero_Entablature", 0.0, -0.75, ent_z0 + ENTABLATURE_H / 2.0,
FRONTAGE_W, 1.5, ENTABLATURE_H, stone)
note("Hero_Entablature", (FRONTAGE_W, 1.5, ENTABLATURE_H), "assumption: entablature depth")
cube("Hero_Parapet", 0.0, -0.75, HEIGHT - PARAPET_H / 2.0,
FRONTAGE_W, 1.2, PARAPET_H, stone)
note("Hero_Parapet", (FRONTAGE_W, 1.2, PARAPET_H), "assumption: parapet carries the motto band")
# Motto band: a recessed panel across the parapet, as the photographs show.
cube("Hero_MottoBand", 0.0, -1.32, HEIGHT - PARAPET_H / 2.0,
FRONTAGE_W - 3.0, 0.08, 0.85, stone_dark)
note("Hero_MottoBand", (FRONTAGE_W - 3.0, 0.08, 0.85), "photographs: motto panel above the colonnade")
# Medallions either side of the inscription.
for i, x in enumerate((-6.2, 6.2), start=1):
cylinder(f"Hero_Medallion_{i}", x, -1.36, HEIGHT - PARAPET_H / 2.0 + 0.42,
HEIGHT - PARAPET_H / 2.0 + 0.44, 0.55, stone_dark, vertices=20)
note("Hero_Medallion_x2", (1.1, 1.1, 0.02), "photographs: two stone discs beside the motto")
# --- entrances ---------------------------------------------------------
# Three portals between column pairs, each with its own short flight.
portal_xs = (-COLUMN_BAY, 0.0, COLUMN_BAY)
for i, x in enumerate(portal_xs, start=1):
# Recessed doorway: a dark panel set into the stylobate face.
cube(f"Hero_Portal_{i}", x, -2.35, PORTAL_H / 2.0,
PORTAL_W, 0.10, PORTAL_H, glass)
# Door grille above the steps, using the banner red for the sign panel.
cube(f"Hero_PortalSign_{i}", x, -2.42, PORTAL_H + 0.55,
0.7, 0.06, 0.9, banner)
# Photographs show four to five risers per portal; the count follows the
# pedestal height rather than inheriting the old "eight steps" claim.
steps = max(3, min(5, int(round((PEDESTAL_H - 0.2) / STEP_RISE))))
for s in range(steps):
top = PEDESTAL_H - s * STEP_RISE
cube(f"Hero_Step_{i}_{s + 1}", x,
-2.4 - 0.14 - s * STEP_TREAD - STEP_TREAD / 2.0,
top - STEP_RISE / 2.0,
STEP_WIDTH, STEP_TREAD, STEP_RISE, stone)
note(f"Hero_PortalFlight_{i}", (PORTAL_W, STEP_TREAD * steps, PEDESTAL_H - 0.2),
f"{steps} steps at {STEP_RISE:.2f} m rise / {STEP_TREAD:.2f} m tread "
f"(assumption; photos show 4-5, the old notes said 8)")
# --- banners -----------------------------------------------------------
# Three vertical event banners hung from the column line, as the recordings show.
for i, x in enumerate(portal_xs, start=1):
cube(f"Hero_Banner_{i}", x, -1.05, 7.9, 1.55, 0.06, 5.4, banner)
note("Hero_Banner_x3", (1.55, 0.06, 5.4), "photographs: JIMMY KIMMEL LIVE banners on the column line")
# --- windows -----------------------------------------------------------
# Two ranks of windows between the columns and behind the colonnade.
for rank, z in enumerate((4.4, 8.2), start=1):
for i, x in enumerate(col_xs[:-1], start=1):
wx = x + COLUMN_BAY / 2.0
cube(f"Hero_Window_{rank}_{i}", wx, -1.98, z, 2.0, 0.08, 1.9, glass)
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
return parts, lines
def export_fbx(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
# Named export preset "ue_metric_v1" - same settings as Tools/scale_test_export.py.
bpy.ops.export_scene.fbx(
filepath=path,
use_selection=False,
object_types={"MESH"},
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_NONE",
axis_forward="-Y",
axis_up="Z",
use_space_transform=True,
use_mesh_modifiers=True,
mesh_smooth_type="OFF",
path_mode="AUTO",
bake_space_transform=False,
)
print(f"[hero] exported FBX preset ue_metric_v1 -> {path}")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
parser = argparse.ArgumentParser()
parser.add_argument("--out", default="/tmp/gigline/hero-facade.fbx")
parser.add_argument("--save-blend", default=None)
parser.add_argument("--report", default=None)
args = parser.parse_args(argv)
parts, lines = build()
for line in lines:
print(line)
# Bounds check: the facade must be exactly the measured frontage and height.
import mathutils
mn = mathutils.Vector((1e9, 1e9, 1e9))
mx = mathutils.Vector((-1e9, -1e9, -1e9))
for obj in bpy.context.scene.objects:
if obj.type != "MESH":
continue
for corner in obj.bound_box:
world = obj.matrix_world @ mathutils.Vector(corner)
mn = mathutils.Vector((min(mn[i], world[i]) for i in range(3)))
mx = mathutils.Vector((max(mx[i], world[i]) for i in range(3)))
size = mx - mn
print(f"[hero] bounds: {size.x:.2f} x {size.y:.2f} x {size.z:.2f} m "
f"(x {mn.x:.2f}..{mx.x:.2f}, y {mn.y:.2f}..{mx.y:.2f}, z {mn.z:.2f}..{mx.z:.2f})")
if abs(size.z - HEIGHT) > 0.05:
print(f"[hero] WARNING: height {size.z:.2f} m does not match the {HEIGHT} m tag")
if args.save_blend:
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
print(f"[hero] saved {args.save_blend}")
if args.report:
os.makedirs(os.path.dirname(args.report), exist_ok=True)
with open(args.report, "w") as handle:
json.dump(
{
"asset": "hero-facade",
"frame": "project frame, metres, X east Y north Z up",
"bounds_m": {"min": [round(v, 3) for v in mn], "max": [round(v, 3) for v in mx],
"size": [round(v, 3) for v in size]},
"parts": parts,
},
handle,
indent=2,
)
print(f"[hero] wrote {args.report}")
export_fbx(args.out)
main()
+30 -2
View File
@@ -94,6 +94,23 @@ try:
c.static_mesh_component.set_static_mesh(m)
log(f"staged: {name} at ({x}, 600, 50)")
# Hero facade, if the import has run: two views a reviewer needs — straight on
# from the north sidewalk, and the oblique street view from further west.
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
# The imported mesh spans Y -7..+1250 cm in local space (front plane at -7, the
# body behind it) and is centred on X. To put the front plane on the block's
# building line the actor goes at Y = building line + 7 cm.
hero_x = -12024.5 + 1302.0
hero_y = -1450.0 + 7.0
if hero:
actor = spawn(unreal.StaticMeshActor, unreal.Vector(hero_x, hero_y, 0.0), "Hero_Facade")
if actor:
actor.static_mesh_component.set_static_mesh(hero)
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
log("staged: hero facade at the measured building line")
else:
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/ue_hero_facade_import.py)")
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if shots:
shots.set_editor_property("shot_locations", [
@@ -103,6 +120,12 @@ try:
# first-person eye height, just south-east of the character: this is the
# view a player has at spawn, capsule and camera stack in frame
unreal.Vector(120, -260, 165),
# straight on from the north sidewalk at eye height. No camera may sit
# inside the facade body (Y -1443..+250 cm), so both stand north of the
# front plane and look south into the colonnade.
unreal.Vector(hero_x + 200, -3200, 420),
# oblique from the north-west, reading the colonnade in depth
unreal.Vector(hero_x - 2200, -3400, 620),
])
shots.set_editor_property("shot_rotations", [
unreal.MathLibrary.find_look_at_rotation(
@@ -113,11 +136,16 @@ try:
unreal.Vector(200, 100, 200), unreal.Vector(0, 600, 50)),
unreal.MathLibrary.find_look_at_rotation(
unreal.Vector(120, -260, 165), unreal.Vector(-200, 600, 90)),
unreal.MathLibrary.find_look_at_rotation(
unreal.Vector(hero_x + 200, -3200, 420), unreal.Vector(hero_x, hero_y + 400, 760)),
unreal.MathLibrary.find_look_at_rotation(
unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
])
shots.set_editor_property("shot_names",
["overview", "character", "scale-cubes", "first-person"])
["overview", "character", "scale-cubes", "first-person",
"hero-facade-front", "hero-facade-oblique"])
shots.set_editor_property("quit_when_done", True)
log("staged: ShotSequencer with 4 cameras")
log("staged: ShotSequencer with 6 cameras")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
+106
View File
@@ -0,0 +1,106 @@
# Imports the hero façade FBX into Unreal with the named preset `ue_metric_v1`
# and places it at the measured site position.
#
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_hero_facade_import.py -nullrhi -unattended -nopause -stdout
#
# Placement follows Geo/manifests/local-origin.md and the blockout frame:
# project X = UTM easting - 376515.389 (east +)
# project Y = -(UTM northing - 3774224.083) in Unreal, because the FBX preset
# maps Blender +Y (north) to Unreal -Y and Blender +X to Unreal +X. The building
# line of the hero block sits at Unreal Y = -1450 cm (14.5 m south of the road
# centreline, from the street profile), and the hero's east edge at
# X = -12024.5 cm (projected control point in local-origin.md).
import unreal
FBX = "/tmp/gigline/hero-facade.fbx"
DEST = "/Game/Hollywood/ElCapitan"
ACTOR_LABEL = "Hero_Facade"
LEVEL = "/Game/Hollywood/DemoMap"
# Unreal centimetres: south-east corner of the façade, on the building line.
PLACEMENT_CM = (-12024.5, -1450.0, 0.0)
RESULT = "/tmp/gigline/hero-facade-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
def import_fbx():
task = unreal.AssetImportTask()
task.filename = FBX
task.destination_path = DEST
task.destination_name = "SM_HeroFacade"
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
def measure_and_place():
asset = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade")
if asset is None:
log(f"MISSING {DEST}/SM_HeroFacade")
return
box = asset.get_bounding_box()
mn, mx = box.min, box.max
log(f"SM_HeroFacade bounds cm: size=({mx.x - mn.x:.1f}, {mx.y - mn.y:.1f}, {mx.z - mn.z:.1f}) "
f"min=({mn.x:.1f}, {mn.y:.1f}, {mn.z:.1f})")
# StaticMaterial is a struct wrapper: the material is its material_interface.
names = []
for sm in asset.get_editor_property("static_materials"):
mat = sm.get_editor_property("material_interface")
names.append(mat.get_name() if mat else "None")
log(f"materials: {names}")
# Place on the demo map if it exists; otherwise create it from the empty level.
if unreal.EditorAssetLibrary.does_asset_exist(LEVEL):
unreal.EditorLevelLibrary.load_level(LEVEL)
else:
if not unreal.EditorLevelLibrary.new_level(LEVEL):
log(f"could not create {LEVEL}")
return
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if actor.get_actor_label() == ACTOR_LABEL:
unreal.EditorLevelLibrary.destroy_actor(actor)
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(*PLACEMENT_CM))
actor.set_actor_label(ACTOR_LABEL)
comp = actor.static_mesh_component
comp.set_static_mesh(asset)
comp.set_mobility(unreal.ComponentMobility.STATIC)
log(f"placed {ACTOR_LABEL} at {PLACEMENT_CM} (cm) on {LEVEL}")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), LEVEL)
log(f"save_map {LEVEL}: {saved}")
try:
import_fbx()
measure_and_place()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
with open(RESULT, "w") as handle:
handle.write("\n".join(lines) + "\n")