Files
gig-line/Tools/blender/hero_facade_build.py
T
zwitschi 33f8139cb2 feat(hero): stepped medallion relief, seated on the parapet face
The medallions were not just flat - they were buried. seat_on_front put
Hero_Medallion at offset 0.08 (front Y -0.42) while the 1.2 m-deep parapet stands
at Y -0.15, so both discs sat inside the parapet and never rendered. The motto
band and surround have the same burial (offset 0.06) and are a separate follow-up.

- Three stepped layers per medallion with per-layer prefixes so seat_on_front gives
  each its own stand-off: Rim (0.55 m, dark, offset 0.36 -> Y -0.14), Field
  (0.38 m, light, offset 0.43 -> Y -0.07), Boss (0.15 m, dark, offset 0.50 -> Y 0.00).
  A single Hero_Medallion prefix would both flatten the relief and (via startswith)
  swallow the specific prefixes, so it is removed.
- Moved from +/-6.2 (inside the inscription text, which spans +/-6.87) to +/-9.8.
- Fixed Tools/review/hero_iterate.sh, which still had the pre-tools-move
  'cd dirname/..' and landed in Tools/ instead of the repo root.

Rendered and judged; the advisory two-model judge (no, 0.93) no longer flags the
medallions. Owner review pending.
2026-11-10 15:04:48 +01:00

578 lines
29 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/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
_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
# --- 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)
# Dark marks, not stone-on-stone: at 45 m a same-colour relief disappears, and the reference letters read
# as dark incisions against the pale wall.
inscription(motto_w - 0.6, motto_z, 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: stepped round reliefs (dark rim, raised
# light field, dark central boss) so they read as round medallions at 45 m instead of
# flat, empty discs. Each layer has its own prefix so `seat_on_front` gives it its own
# stand-off; one shared prefix would seat every disc at the same offset and flatten the
# relief. They sit OUTSIDE the inscription text (which spans ~±6.9 m) on the parapet
# face, which is 0.35 m proud of FRONT_Y - at the old 0.08 offset they were buried
# inside the 1.2 m-deep parapet and never rendered.
for i, x in enumerate((-9.8, 9.8), start=1):
disc_on_wall(f"Hero_MedallionRim_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
0.55, 0.10, stone_dark, vertices=28)
disc_on_wall(f"Hero_MedallionField_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
0.38, 0.16, stone, vertices=28)
disc_on_wall(f"Hero_MedallionBoss_{i}", x, -1.30, HEIGHT - PARAPET_H / 2.0,
0.15, 0.20, stone_dark, vertices=20)
note("Hero_Medallion_x2", (1.1, 0.20, 1.1),
"photographs: two round reliefs beside the motto - dark rim, raised light field, dark boss")
# --- 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_MedallionRim", 0.36), ("Hero_MedallionField", 0.43), ("Hero_MedallionBoss", 0.50),
("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),
# The parapet is 1.2 m deep and stands in front of the wall plane, so the inscription has to sit on
# ITS face, not on FRONT_Y - otherwise the parapet hides the letters entirely.
("Hero_Inscription", 0.40), ("Hero_MottoBand", 0.40),
))
return parts, lines
def inscription(width_m, z, material, lines=3):
"""Three lines of carved lettering across the motto band, as marks rather than glyphs.
The reference carries "FREEMASONRY BUILDS ITS TEMPLES / AMONG THE NATIONS / AND IN THE HEARTS OF MEN"
in incised capitals. At the 45 m review distance the words read as three bands of even letter marks, so
the geometry is a deterministic run of small raised blocks of varied width - it holds the silhouette and
the texture of carved text without pretending to spell the words. Legible lettering would need a baked
texture, which is the next step if the owner wants to read it.
"""
import random as _random
rng = _random.Random(7)
rows = ((0.28, 0.62), (0.06, 0.40), (-0.16, 0.50))
placed = 0
for row_index in range(lines):
z_off, coverage = rows[row_index % len(rows)]
x = -width_m * coverage / 2.0
limit = width_m * coverage / 2.0
while x < limit:
w = rng.uniform(0.16, 0.30)
gap = rng.uniform(0.05, 0.10)
if x + w > limit:
break
cube(f"Hero_Inscription_{row_index}_{placed:03d}", x + w / 2.0, FRONT_Y - 0.03,
z + z_off, w, 0.05, 0.22, material)
placed += 1
x += w + gap
print(f"[hero] inscription: {placed} letter marks in {lines} 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()