Tools/sibling_asset_audit.py reads the sibling security-game project's prop library straight from the GLB headers: 20 street props, 11.8k triangles, every one with a Blender source. The 50 .blend sources are copied to Art/props/blender_src/ with a manifest; nothing is imported into Unreal yet. Its building meshes are deliberately not taken - they are Godot GLBs built around a 29.6 m facade that this project measured at 25.75 m. The facade proportions were wrong against the photograph: 5.7 m of blank wall above the capitals. Entablature is now 3.4 m, parapet 2.2 m, with architrave and cornice bands and square pedestals, so the elevation reads as layered stone.
288 lines
13 KiB
Python
288 lines
13 KiB
Python
"""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 -------------------------------------------------------------
|
|
# Calibrated against the 2009 façade photograph: the entablature starts a little
|
|
# over half the total height, and the parapet carries the motto at about three
|
|
# quarters. The first pass had the enframed wall carrying 5.7 m of blank stone
|
|
# above the capitals, which read as a warehouse rather than a temple.
|
|
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 = 3.40 # band between the capitals and the motto panel
|
|
PARAPET_H = 2.20 # motto panel and cornice above the entablature
|
|
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)
|
|
cube(f"Hero_Pedestal_{i + 1}", x, -0.62, PEDESTAL_H + 0.27,
|
|
1.24, 1.24, 0.54, stone)
|
|
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
|
|
# Lower entablature band (architrave) standing proud of the wall, so the
|
|
# elevation reads as layered stone rather than one flat plane.
|
|
cube("Hero_Architrave", 0.0, -1.55, ent_z0 + 0.42, FRONTAGE_W, 1.65, 0.84, stone)
|
|
note("Hero_Architrave", (FRONTAGE_W, 1.65, 0.84), "assumption: stacked entablature bands")
|
|
# Cornice under the parapet.
|
|
cube("Hero_Cornice", 0.0, -1.6, HEIGHT - PARAPET_H - 0.18, FRONTAGE_W, 1.75, 0.36, stone)
|
|
note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line")
|
|
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()
|