#!/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_; 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())