Files
gig-line/Tools/facade_kit_build.py
T
zwitschi 5e2c5e29c0 feat(facades): 0.1.4 — module kit, textured materials, theatre trim (#30)
Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
2026-11-08 20:29:11 +01:00

316 lines
12 KiB
Python

#!/usr/bin/env python3
"""Build the reusable façade module kit (0.1.4).
One module, measured: the **4.96 m bay** (the hero's column rhythm, which the
El Capitan Theatre next door shares per the reference notes). Every block building
should assemble from the same pieces instead of being a bare extrusion, so the
street wall gets a consistent window rhythm, shopfront line, cornice and parapet.
Pieces (`ue_metric_v1`, metres, origin at the module's bottom-front centre so a
building can place them by bay index and storey index):
Kit_Pilaster vertical pier on a bay boundary
Kit_Sill window sill band
Kit_Lintel window head / lintel band
Kit_WindowBay recessed window opening in one bay (glass + reveal)
Kit_ShopfrontBay ground-floor shopfront: piers, glass, fascia
Kit_DoorBay entrance bay: recessed door, jambs, threshold
Kit_Cornice projecting cornice band, one module long
Kit_Parapet parapet wall + coping, one module long
Dimensions come from the measured storey height and the reference photography
where it resolves them (window proportions, shopfront line); everything the
photographs do not resolve is an assumption and is recorded as one in the part
report, so model and evidence stay separable.
python3 Tools/facade_kit_build.py --out /tmp/gigline/kit \
--report /tmp/gigline/kit-report.json
blender -b --factory-startup --python Tools/facade_kit_build.py -- --out /tmp/gigline/kit
"""
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.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
# --- module ------------------------------------------------------------------
BAY = 4.96 # m, measured: hero column rhythm, shared by the theatre
STOREY = 3.10 # m, the calibrated level height (see block_height_report)
PARAPET_H = 0.90 # m, coping above the top storey
CORNICE_H = 0.55 # m, projecting band
CORNICE_OUT = 0.35 # m, how far the cornice projects
PILASTER_W = 0.60
PILASTER_D = 0.28
WINDOW_W = 1.60
WINDOW_H = 1.85
WINDOW_REVEAL = 0.18 # how far the glass sits behind the wall face
SILL_H = 0.14
LINTEL_H = 0.34
SHOP_GLASS_H = 2.30 # shopfront glazing height from the pavement
SHOP_FASCIA_H = 0.55 # sign band above the glazing
DOOR_W = 1.90
DOOR_H = 2.45
STONE = None # materials are assigned in Unreal; Blender carries one grey
PARTS: list[dict] = []
def make_material(name, value):
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.98, value * 0.94, 1.0)
bsdf.inputs["Roughness"].default_value = 0.75
return mat
def validate(obj, tri_budget=4000):
"""Topology check before handoff (blender-modeler cleanup checklist).
Reports non-manifold edges, loose geometry and the triangle count. Kit pieces
are primitive solids, so any non-manifold edge means a modelling slip rather
than an intentional hole.
"""
import bmesh
me = obj.data
bm = bmesh.new()
bm.from_mesh(me)
bm.edges.ensure_lookup_table()
non_manifold = [e for e in bm.edges if len(e.link_faces) != 2]
loose_verts = [v for v in bm.verts if not v.link_edges]
tri_count = sum(len(f.verts) - 2 for f in bm.faces)
bm.free()
if non_manifold or loose_verts:
print(f"[kit] WARNING {obj.name}: {len(non_manifold)} non-manifold edge(s), "
f"{len(loose_verts)} loose vertex/vertices")
if tri_count > tri_budget:
print(f"[kit] WARNING {obj.name}: {tri_count} tris exceeds the {tri_budget} budget")
return {"manifold": not non_manifold, "loose_verts": len(loose_verts),
"tris": tri_count}
def note(obj, note_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": note_text,
})
print(f"[kit] {obj.name:22} {obj.dimensions.x:.2f} x {obj.dimensions.y:.2f} "
f"x {obj.dimensions.z:.2f} m ({note_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 cylinder(name, x, y, z0, z1, radius, material, vertices=16):
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
obj.data.materials.append(material)
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(35.0))
return obj
def clear_scene():
"""Remove objects and orphan meshes. Materials are left alone: deleting them
invalidates handles the previous piece still holds ("StructRNA of type
Material has been removed")."""
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)
# --- pieces ------------------------------------------------------------------
# Each builder returns [(object, note)] for one module. Origin: bottom-front
# centre of the module, +Y into the wall depth, +Z up, X across the bay.
def build_pilaster(mat):
o = cube("Kit_Pilaster", 0.0, PILASTER_D / 2.0, STOREY / 2.0,
PILASTER_W, PILASTER_D, STOREY, mat)
return [(o, "assumption: 0.60 x 0.28 m pier on the bay boundary")]
def build_sill(mat):
o = cube("Kit_Sill", 0.0, 0.10, SILL_H / 2.0, WINDOW_W + 0.30, 0.24, SILL_H, mat)
return [(o, "assumption: projecting stone sill under each opening")]
def build_lintel(mat):
o = cube("Kit_Lintel", 0.0, 0.10, LINTEL_H / 2.0, WINDOW_W + 0.30, 0.24, LINTEL_H, mat)
return [(o, "assumption: lintel band over each opening")]
def build_window_bay(mat):
"""One storey of window: a surround standing proud of the wall, glazing inset.
The blockout wall is a solid box, so a real recess cannot be cut. The piece is
therefore a shallow surround that projects 3 cm and a glazing plane set 6 cm
behind its front, which reads as a window from any street angle. The first
version modelled everything at +Y (into the wall), so the whole bay sat inside
the solid wall and was invisible in every render.
"""
parts = []
surround_d = 0.22
surround = cube("Kit_WindowBay", 0.0, -0.03 + surround_d / 2.0, WINDOW_H / 2.0,
WINDOW_W, surround_d, WINDOW_H, mat)
parts.append((surround, "photographs: window proportions 1.60 x 1.85 m; surround "
"projects 3 cm so it reads against a solid blockout wall"))
glass = cube("Kit_WindowGlass", 0.0, 0.09, WINDOW_H / 2.0,
WINDOW_W - 0.16, 0.04, WINDOW_H - 0.16, mat)
parts.append((glass, "glazing plane set 6 cm behind the surround face; "
"material assigned in Unreal"))
return parts
def build_shopfront_bay(mat):
"""Ground floor: two piers, glazing between, fascia above."""
parts = []
for side in (-1, 1):
x = side * (BAY / 2.0 - PILASTER_W / 2.0)
o = cube(f"Kit_ShopPier_{'L' if side < 0 else 'R'}", x, PILASTER_D / 2.0,
SHOP_GLASS_H / 2.0, PILASTER_W, PILASTER_D, SHOP_GLASS_H, mat)
parts.append((o, "shopfront pier, same 0.60 m module as the pilaster"))
glass = cube("Kit_ShopGlass", 0.0, 0.06, SHOP_GLASS_H / 2.0,
BAY - 2 * PILASTER_W, 0.05, SHOP_GLASS_H - 0.25, mat)
parts.append((glass, "photographs: continuous shopfront glazing to a "
"2.30 m head"))
fascia = cube("Kit_ShopFascia", 0.0, 0.14, SHOP_GLASS_H + SHOP_FASCIA_H / 2.0,
BAY, 0.26, SHOP_FASCIA_H, mat)
parts.append((fascia, "sign band over the shopfront; 0.55 m assumption"))
return parts
def build_door_bay(mat):
parts = []
for side in (-1, 1):
x = side * (DOOR_W / 2.0 + 0.35)
o = cube(f"Kit_DoorJamb_{'L' if side < 0 else 'R'}", x, 0.14, STOREY / 2.0,
0.35, 0.30, STOREY, mat)
parts.append((o, "entrance jamb standing 0.30 m proud of the wall"))
door = cube("Kit_Door", 0.0, 0.06, DOOR_H / 2.0, DOOR_W, 0.09, DOOR_H, mat)
parts.append((door, "assumption: 1.90 x 2.45 m recessed door"))
threshold = cube("Kit_Threshold", 0.0, 0.22, 0.05, DOOR_W + 0.70, 0.44, 0.10, mat)
parts.append((threshold, "threshold slab, flush with the pavement"))
return parts
def build_cornice(mat):
o = cube("Kit_Cornice", 0.0, -CORNICE_OUT / 2.0, CORNICE_H / 2.0,
BAY, CORNICE_OUT * 2.0, CORNICE_H, mat)
return [(o, "projecting cornice band, one bay long; 0.35 m projection assumption")]
def build_parapet(mat):
parts = []
wall = cube("Kit_Parapet", 0.0, -0.15, PARAPET_H / 2.0, BAY, 0.55, PARAPET_H, mat)
parts.append((wall, "assumption: 0.90 m parapet above the top storey"))
coping = cube("Kit_Coping", 0.0, -0.15, PARAPET_H - 0.06, BAY, 0.65, 0.12, mat)
parts.append((coping, "coping slab over the parapet"))
return parts
BUILDERS = {
"pilaster": build_pilaster,
"sill": build_sill,
"lintel": build_lintel,
"window": build_window_bay,
"shopfront": build_shopfront_bay,
"door": build_door_bay,
"cornice": build_cornice,
"parapet": build_parapet,
}
def build_piece(name):
"""Build one piece group in a clean scene. The material is created *after*
the clear so its handle stays valid."""
clear_scene()
mat = make_material(f"M_Kit_{name}", 0.6)
return BUILDERS[name](mat)
def export_group(objects, out_dir):
"""Export each object to its own FBX with the shared preset."""
exported = []
for obj in objects:
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
# Unreal naming convention for static meshes is SM_<Name>; the Blender
# object keeps its readable name, the asset the importable one.
path = os.path.join(out_dir, "SM_" + obj.name + ".fbx")
fbx_preset.export_static(path, selection=True)
exported.append(path)
return exported
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/kit")
ap.add_argument("--report", default=None)
ap.add_argument("--only", default=None, help="comma-separated piece names")
args = ap.parse_args(argv)
os.makedirs(args.out, exist_ok=True)
names = [n.strip() for n in args.only.split(",")] if args.only else list(BUILDERS)
unknown = [n for n in names if n not in BUILDERS]
if unknown:
raise SystemExit(f"unknown piece(s): {unknown}; known: {sorted(BUILDERS)}")
total = 0
for name in names:
made = build_piece(name)
if name == "window":
made = made # window bay exports two objects (reveal + glass)
objects = [o for o, _ in made]
for obj, note_text in made:
note(obj, note_text)
exported = export_group(objects, args.out)
total += len(exported)
print(f"[kit] {name}: {len(exported)} object(s) exported")
if args.report:
with open(args.report, "w", encoding="utf-8") as handle:
json.dump({
"module_m": BAY,
"storey_m": STOREY,
"pieces": sorted(BUILDERS),
"parts": PARTS,
}, handle, indent=2)
print(f"[kit] report -> {args.report}")
print(f"[kit] exported {total} object(s) to {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())