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.
This commit is contained in:
1 parent
f681d83736
commit
4d27109668
168 files changed
+799
-407
No files matched your search
@@ -0,0 +1,527 @@
|
||||
"""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/blender/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
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import fbx_export_preset as fbx_preset # noqa: E402
|
||||
|
||||
|
||||
# --- 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.00 # band between the capitals and the motto panel
|
||||
# 3.40 -> 3.00 on 2026-11-09: the photographs put the
|
||||
# colonnade at 50-60 % of the 14.5 m elevation, and our
|
||||
# 3.40/2.20 entablature+parapet left it at 47.6 %. Thinning
|
||||
# both lifts the colonnade to 7.70 m = 53.1 %, inside the band.
|
||||
PARAPET_H = 1.80 # motto panel and cornice above the entablature (see above)
|
||||
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, smooth=True):
|
||||
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)
|
||||
if smooth:
|
||||
# Round parts must shade smoothly *around* the axis while keeping the
|
||||
# caps creased. Without this the columns read as faceted prisms, and the
|
||||
# FBX carries no smoothing groups at all (Unreal warns about it).
|
||||
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
|
||||
return obj
|
||||
|
||||
|
||||
def disc_on_wall(name, x, y, z, radius, thickness, material=None, vertices=24):
|
||||
"""A round disc mounted vertically on a wall face.
|
||||
|
||||
`cylinder()` builds along Z, so a "medallion" made with it is a pancake lying
|
||||
flat on top of the parapet rather than a disc on its face — which is what the
|
||||
first build did (1.1 m across in Y, 0.02 m thick in Z, sitting at the coping).
|
||||
This rotates the cylinder 90 degrees about X so the axis points along Y and
|
||||
the disc faces the street.
|
||||
"""
|
||||
obj = cylinder(name, x, y, z - thickness / 2.0, z + thickness / 2.0,
|
||||
radius, material, vertices=vertices)
|
||||
obj.rotation_euler = (math.radians(90.0), 0.0, 0.0)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
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)
|
||||
|
||||
# Warm limestone, sampled from 02-masonictemple01.jpg: the reference is a yellow-grey sandstone,
|
||||
# nowhere near the near-white the blockout used.
|
||||
stone = make_material("M_Facade_Stone", (0.34, 0.29, 0.21, 1.0), 0.82)
|
||||
stone_dark = make_material("M_Facade_StoneShadow", (0.22, 0.185, 0.135, 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
|
||||
# The wall's front face, and the only Y that matters for what is visible.
|
||||
# +Y is the street side: anything with y < FRONT_Y is inside the solid mass and never renders.
|
||||
# Hand-typed negative Y values throughout this file put the banners 55 cm, the portals 2 m and
|
||||
# the windows 6 cm behind this plane, which is why the captures showed a plain block with six
|
||||
# columns. `seat_on_front()` below re-seats every applied detail relative to FRONT_Y, so the
|
||||
# depth is stated once instead of guessed at each call site.
|
||||
global FRONT_Y
|
||||
FRONT_Y = -BODY_D / 2.0 - 0.5 + BODY_D / 2.0 # = -0.5 with the current BODY_D
|
||||
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.
|
||||
# The stylobate is built later as piers around the three door openings.
|
||||
|
||||
# --- 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)
|
||||
# Base moulding: a taller, wider cylinder where the shaft meets the
|
||||
# pedestal. Without it the shaft starts straight off a square plinth,
|
||||
# which is not how an Ionic base reads in silhouette.
|
||||
base_z0 = PEDESTAL_H + 0.55
|
||||
base_h = 0.22
|
||||
cylinder(f"Hero_ColumnBase_{i + 1}", x, -0.55, base_z0, base_z0 + base_h,
|
||||
0.68, stone, vertices=20)
|
||||
shaft_z0 = base_z0 + base_h
|
||||
cap_z0 = HEIGHT - ENTABLATURE_H - PARAPET_H - CAPITAL_H
|
||||
cylinder(f"Hero_Column_{i + 1}", x, -0.55, shaft_z0, cap_z0,
|
||||
COLUMN_D / 2.0, stone)
|
||||
|
||||
# Fluting: 20 shallow grooves around the shaft, as the photographs show on
|
||||
# the colonnade. Each flute is a thin cylinder set into the shaft surface;
|
||||
# modelling them as grooves would need booleans on 6 columns for detail the
|
||||
# eye reads only in silhouette and grazing light, so they are added mass in
|
||||
# the *shadow* direction instead — cheap, and it breaks the flat highlight.
|
||||
flutes = 20
|
||||
flute_r = 0.032
|
||||
for f in range(flutes):
|
||||
ang = (2.0 * math.pi * f) / flutes
|
||||
fx = x + (COLUMN_D / 2.0 - flute_r * 0.4) * math.cos(ang)
|
||||
fy = -0.55 + (COLUMN_D / 2.0 - flute_r * 0.4) * math.sin(ang)
|
||||
cylinder(f"Hero_Flute_{i + 1}_{f + 1}", fx, fy, shaft_z0 + 0.05,
|
||||
cap_z0 - 0.05, flute_r, stone_dark, vertices=6)
|
||||
# Capital block, then the square abacus that carries the architrave.
|
||||
# The abacus takes the top 0.14 m of the capital zone so the colonnade
|
||||
# still ends at HEIGHT - ENTABLATURE_H - PARAPET_H (proportion unchanged).
|
||||
abacus_h = 0.14
|
||||
cylinder(f"Hero_Capital_{i + 1}", x, -0.55, cap_z0,
|
||||
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h, 0.62, stone, vertices=16)
|
||||
cube(f"Hero_Abacus_{i + 1}", x, -0.55,
|
||||
HEIGHT - ENTABLATURE_H - PARAPET_H - abacus_h / 2.0,
|
||||
1.30, 1.30, abacus_h, stone)
|
||||
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)")
|
||||
note("Hero_Flute_x120", (0.064, 0.064, cap_z0 - shaft_z0),
|
||||
"photographs: fluted shafts; 20 grooves per column modelled as shallow "
|
||||
"cylinders in the shadow tone rather than boolean cut grooves")
|
||||
note("Hero_ColumnBase_x6", (1.36, 1.36, 0.22),
|
||||
"assumption: Ionic base moulding; photographs show a wider base at the shaft foot")
|
||||
note("Hero_Abacus_x6", (1.30, 1.30, 0.14),
|
||||
"assumption: square abacus under the architrave, as Ionic capitals carry")
|
||||
|
||||
# --- 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: a projecting corona carried on a dentil course,
|
||||
# so the top of the order reads as layered stone with a shadow line rather
|
||||
# than one flat box. Dentils hang under the corona and stand 0.25 m clear of
|
||||
# it, which is what gives the elevation its cornice shadow at street level.
|
||||
cornice_z = HEIGHT - PARAPET_H - 0.18
|
||||
cube("Hero_Cornice", 0.0, -1.6, cornice_z, FRONTAGE_W, 1.75, 0.36, stone)
|
||||
note("Hero_Cornice", (FRONTAGE_W, 1.75, 0.36), "assumption: projecting cornice line")
|
||||
DENTIL_W, DENTIL_D, DENTIL_H, DENTIL_GAP = 0.18, 0.30, 0.22, 0.27
|
||||
dentil_y = -2.30
|
||||
dentil_z = cornice_z - 0.18 - DENTIL_H / 2.0
|
||||
dentil_span = FRONTAGE_W - 2.0
|
||||
dentil_count = int(dentil_span / (DENTIL_W + DENTIL_GAP))
|
||||
for i in range(dentil_count):
|
||||
dx = -dentil_span / 2.0 + (DENTIL_W + DENTIL_GAP) * i + DENTIL_W / 2.0
|
||||
cube(f"Hero_Dentil_{i + 1}", dx, dentil_y, dentil_z,
|
||||
DENTIL_W, DENTIL_D, DENTIL_H, stone)
|
||||
note("Hero_Dentil_xN", (DENTIL_W, DENTIL_D, DENTIL_H),
|
||||
f"{dentil_count} dentils at {DENTIL_W + DENTIL_GAP:.2f} m centres under the corona; "
|
||||
"assumption: photographed cornice reads as a dentilled course")
|
||||
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 with a moulded surround, as
|
||||
# the photographs show. The panel was a 8 cm stripe of dark stone; it is now a
|
||||
# real recess — surround frame standing 6 cm proud, panel set 12 cm behind it —
|
||||
# so it reads as an inscription panel from the street instead of a paint line.
|
||||
motto_z = HEIGHT - PARAPET_H / 2.0
|
||||
motto_w = FRONTAGE_W - 3.0
|
||||
for x_off in (-(motto_w / 2.0 + 0.16), (motto_w / 2.0 + 0.16)):
|
||||
cube(f"Hero_MottoSurround_v{int(x_off > 0)}", x_off, -1.13, motto_z,
|
||||
0.32, 0.26, 1.15, stone)
|
||||
for z_off in (-0.575, 0.575):
|
||||
cube(f"Hero_MottoSurround_h{int(z_off > 0)}", 0.0, -1.13, motto_z + z_off,
|
||||
motto_w + 0.64, 0.26, 0.32, stone)
|
||||
cube("Hero_MottoBand", 0.0, -1.06, motto_z, motto_w, 0.10, 0.85, stone_dark)
|
||||
note("Hero_MottoBand", (motto_w, 0.10, 0.85),
|
||||
"photographs: motto panel above the colonnade, now an inset panel with a "
|
||||
"moulded surround rather than a flat stripe")
|
||||
|
||||
# Medallions either side of the inscription: discs on the parapet face, not
|
||||
# pancakes on the coping (see disc_on_wall).
|
||||
for i, x in enumerate((-6.2, 6.2), start=1):
|
||||
disc_on_wall(f"Hero_Medallion_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
|
||||
0.55, 0.04, stone_dark, vertices=20)
|
||||
note("Hero_Medallion_x2", (1.1, 0.04, 1.1),
|
||||
"photographs: two stone discs beside the motto, mounted on the parapet face")
|
||||
|
||||
# --- entrances ---------------------------------------------------------
|
||||
# Three portals between column pairs, each with its own short flight. The
|
||||
# doorway is cut INTO the stylobate face rather than laid on it: the plinth is
|
||||
# modelled as three segments so the opening is a real recess, and the portals
|
||||
# stand proud of the wall behind the colonnade so they read from the street.
|
||||
portal_xs = (-COLUMN_BAY, 0.0, COLUMN_BAY)
|
||||
plinth_front = -2.4 # front face of the plinth, in the facade frame
|
||||
plinth_back = 0.0
|
||||
gap = PORTAL_W
|
||||
# The plinth is four piers between and beside the openings, plus a band over
|
||||
# them, so each door is a real recess instead of a panel on a solid face.
|
||||
piers = [
|
||||
(-(FRONTAGE_W / 2.0), portal_xs[0] - gap / 2.0),
|
||||
(portal_xs[0] + gap / 2.0, portal_xs[1] - gap / 2.0),
|
||||
(portal_xs[1] + gap / 2.0, portal_xs[2] - gap / 2.0),
|
||||
(portal_xs[2] + gap / 2.0, (FRONTAGE_W / 2.0)),
|
||||
]
|
||||
for pi, (x0, x1) in enumerate(piers, start=1):
|
||||
cube(f"Hero_Stylobate_{pi}", (x0 + x1) / 2.0, -1.2, PEDESTAL_H / 2.0,
|
||||
x1 - x0, 2.4, PEDESTAL_H, stone)
|
||||
# Band above the openings, closing the plinth to full height between the doors.
|
||||
# The reference has no blank band across the portals - the openings run up to the entablature
|
||||
# with the banners hanging in front of them - so this filler is gone. What stays is the thin
|
||||
# lintel over each doorway, modelled with the portals.
|
||||
note("Hero_Stylobate", (FRONTAGE_W, 2.4, PEDESTAL_H),
|
||||
"assumption: pedestal height; four piers and a lintel band leave three recesses")
|
||||
|
||||
for i, x in enumerate(portal_xs, start=1):
|
||||
# Recessed doorway: a dark reveal set back inside the plinth, 1.2 m deep so
|
||||
# it reads as an opening from any angle.
|
||||
depth = 1.2
|
||||
# Door leaf set back inside the opening. A solid stone reveal box at the same volume z-fought
|
||||
# with the leaf and read as stripes, so the recess is carried by the jambs, the lintel and the
|
||||
# shadow instead.
|
||||
cube(f"Hero_PortalLeaf_{i}", x, plinth_front + depth - 0.10, PORTAL_H / 2.0,
|
||||
PORTAL_W - 0.10, 0.08, PORTAL_H - 0.10, glass)
|
||||
# Stone surround, standing 0.1 m proud of the plinth face.
|
||||
for side, sx in (("L", -1.0), ("R", 1.0)):
|
||||
# Jambs stop at the pavement: a 0.15 m overhang below Z 0 would sink the
|
||||
# whole facade into the ground plane.
|
||||
cube(f"Hero_PortalJamb_{i}{side}", x + sx * (PORTAL_W / 2.0 + 0.15),
|
||||
plinth_front - 0.10, (PORTAL_H + 0.3) / 2.0, 0.3, 0.2, PORTAL_H + 0.3, stone)
|
||||
cube(f"Hero_PortalLintel_{i}", x, plinth_front - 0.10, PORTAL_H + 0.15,
|
||||
PORTAL_W + 0.6, 0.2, 0.3, stone)
|
||||
# Door grille: mullions across the opening, dark metal read at distance.
|
||||
for m in range(1, 4):
|
||||
cube(f"Hero_PortalMullion_{i}_{m}", x - PORTAL_W / 2.0 + m * PORTAL_W / 4.0,
|
||||
plinth_front + 0.12, PORTAL_H / 2.0, 0.09, 0.06, PORTAL_H, stone_dark)
|
||||
# Sign panel over the doorway, as the photographs show.
|
||||
cube(f"Hero_PortalSign_{i}", x, plinth_front - 0.16, PORTAL_H + 0.75,
|
||||
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))))
|
||||
# Steps descend from the plinth to the pavement; the lowest tread must land
|
||||
# exactly on Z 0, never below it, so the flight meets the ground plane.
|
||||
steps = max(3, min(steps, int(round(PEDESTAL_H / STEP_RISE))))
|
||||
for st in range(steps):
|
||||
top = PEDESTAL_H - st * STEP_RISE
|
||||
height = top if st == steps - 1 else STEP_RISE
|
||||
cube(f"Hero_Step_{i}_{st + 1}", x,
|
||||
plinth_front - 0.14 - st * STEP_TREAD - STEP_TREAD / 2.0 + 0.28,
|
||||
top - height / 2.0, STEP_WIDTH, STEP_TREAD, height, 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):
|
||||
# The reference banners hang from just below the capitals down to the portal heads:
|
||||
# 6.2 m tall and 1.35 m wide, not the 5.4 x 1.55 blockout that sat too low and too fat.
|
||||
cube(f"Hero_Banner_{i}", x, -1.05, 7.2, 1.35, 0.06, 6.2, banner)
|
||||
note("Hero_Banner_x3", (1.35, 0.06, 6.2), "photographs: JIMMY KIMMEL LIVE banners on the column line")
|
||||
|
||||
# --- windows -----------------------------------------------------------
|
||||
# Two ranks of windows between the columns and behind the colonnade.
|
||||
# Y must sit on the wall plane: the mass front face is at Y -0.50 and the
|
||||
# columns stand proud to -1.02. These were at -1.98, i.e. 1.44 m in FRONT of
|
||||
# the wall and ahead of the column line, so the glazing floated in the
|
||||
# colonnade instead of reading as openings behind it (visible in the
|
||||
# review-hero-facade-* shots). WINDOW_Y puts the pane 6 cm proud of the wall,
|
||||
# safely behind the columns.
|
||||
WINDOW_Y = -0.56
|
||||
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, WINDOW_Y, z, 2.0, 0.08, 1.9, glass)
|
||||
note("Hero_Window_x10", (2.0, 0.08, 1.9), "photographs: window bands behind the colonnade")
|
||||
|
||||
# --- window grilles ------------------------------------------------------
|
||||
# The reference bays are closed with a dark metal trellis, not plain glass. Seven vertical and five
|
||||
# horizontal bars per opening, 4 cm square, read as a grille at street distance and cost 60 cubes.
|
||||
grille = make_material("M_Facade_Grille", (0.10, 0.10, 0.11, 1.0), 0.5)
|
||||
barred = 0
|
||||
for obj in list(bpy.data.objects):
|
||||
if not obj.name.startswith("Hero_Window_"):
|
||||
continue
|
||||
wx, wy, wz, wdx, wdy, wdz = (obj.location.x, obj.location.y, obj.location.z,
|
||||
obj.dimensions.x, obj.dimensions.y, obj.dimensions.z)
|
||||
for v in range(7):
|
||||
x = wx - wdx / 2.0 + (v + 0.5) * wdx / 7.0
|
||||
cube(f"Hero_GrilleV_{barred:02d}", x, wy - 0.02, wz, 0.04, 0.03, wdz * 0.96, grille)
|
||||
barred += 1
|
||||
for hcount in range(5):
|
||||
z = wz - wdz / 2.0 + (hcount + 0.5) * wdz / 5.0
|
||||
cube(f"Hero_GrilleH_{barred:02d}", wx, wy - 0.02, z, wdx * 0.96, 0.03, 0.04, grille)
|
||||
barred += 1
|
||||
print(f"[hero] window grilles: {barred} bars")
|
||||
|
||||
# --- rustication and roof cresting --------------------------------------
|
||||
# The photographs show the ground storey in banded ashlar and a cresting with balusters along
|
||||
# the roof edge; without them the elevation reads as a flat card.
|
||||
# Courses run from the pavement to the portal heads, as the street-level photograph shows.
|
||||
rustication(FRONTAGE_W, PORTAL_H, stone_dark)
|
||||
balustrade(FRONTAGE_W, HEIGHT - PARAPET_H * 0.15, stone)
|
||||
|
||||
# --- seat the applied detail on the wall ---------------------------------
|
||||
# Depth is a property of the façade, not of each call site: state it once here. Parts whose
|
||||
# front face is behind the wall plane are moved until it sits at FRONT_Y + offset.
|
||||
seat_on_front((
|
||||
("Hero_Architrave", 0.05), ("Hero_Cornice", 0.05), ("Hero_Dentil", 0.05),
|
||||
("Hero_Entablature", 0.03), ("Hero_MottoBand", 0.06), ("Hero_MottoSurround", 0.06),
|
||||
("Hero_Medallion", 0.08), ("Hero_Banner", 0.08), ("Hero_Window", 0.03),
|
||||
("Hero_Portal", 0.05), ("Hero_PortalJamb", 0.08), ("Hero_PortalLintel", 0.08),
|
||||
("Hero_PortalMullion", 0.04), ("Hero_PortalSign", 0.10),
|
||||
))
|
||||
|
||||
return parts, lines
|
||||
|
||||
|
||||
def rustication(width_m, height_m, material, course_m=0.55, joints=0):
|
||||
"""Horizontal ashlar joints cut across the ground storey.
|
||||
|
||||
A real façade reads as stone because of the shadow in its joints, not because of its colour.
|
||||
Grooves are 3 cm deep and 4 cm tall, spaced `course_m`, and sit just proud of the wall so they
|
||||
catch the light the way the photographs show.
|
||||
"""
|
||||
z = course_m
|
||||
count = 0
|
||||
while z < height_m - 0.2:
|
||||
cube(f"Hero_Course_{count:02d}", 0.0, FRONT_Y + 0.015, z,
|
||||
width_m - 1.5, 0.04, 0.04, material)
|
||||
count += 1
|
||||
z += course_m
|
||||
print(f"[hero] rustication: {count} courses at {course_m:.2f} m")
|
||||
|
||||
|
||||
def balustrade(width_m, z, material, spacing_m=0.62):
|
||||
"""A row of balusters with a bottom and top rail along the roof edge."""
|
||||
half = width_m / 2.0 - 0.9
|
||||
count = int((2 * half) / spacing_m)
|
||||
cube("Hero_BalustradeRailBottom", 0.0, FRONT_Y - 0.05, z - 0.06, 2 * half, 0.14, 0.10, material)
|
||||
cube("Hero_BalustradeRailTop", 0.0, FRONT_Y - 0.05, z + 0.42, 2 * half, 0.16, 0.10, material)
|
||||
for i in range(count):
|
||||
x = -half + i * spacing_m + spacing_m / 2.0
|
||||
cube(f"Hero_Baluster_{i:02d}", x, FRONT_Y - 0.05, z + 0.18, 0.12, 0.12, 0.40, material)
|
||||
print(f"[hero] balustrade: {count} balusters")
|
||||
|
||||
|
||||
def seat_on_front(table):
|
||||
"""Move each named part so its street-facing face sits `offset` metres proud of FRONT_Y.
|
||||
|
||||
`table` is (name_prefix, offset_m) pairs. Parts that name no prefix - the mass, the colonnade,
|
||||
the steps and the stylobate - are left where the geometry put them, because they define the
|
||||
plane rather than hang on it.
|
||||
"""
|
||||
moved = 0
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
for prefix, offset in table:
|
||||
if not obj.name.startswith(prefix):
|
||||
continue
|
||||
world_ys = [(obj.matrix_world @ v.co).y for v in obj.data.vertices]
|
||||
front = max(world_ys)
|
||||
delta = (FRONT_Y + offset) - front
|
||||
if abs(delta) < 1e-4:
|
||||
moved += 1
|
||||
else:
|
||||
obj.location.y += delta
|
||||
print(f"[hero] seat {obj.name:22} y {front:+.2f} -> {front + delta:+.2f} (offset {offset:+.2f})")
|
||||
moved += 1
|
||||
break
|
||||
print(f"[hero] seated {moved} parts on the wall plane at Y {FRONT_Y:+.2f}")
|
||||
|
||||
|
||||
def export_fbx(path):
|
||||
# Preset lives in Tools/blender/fbx_export_preset.py (ue_metric_v1).
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Named export preset "ue_metric_v1" - same settings as Tools/blender/scale_test_export.py.
|
||||
fbx_preset.export_static(path)
|
||||
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()
|
||||
Reference in new issue
Block a user