Merged PR #30: 0.1.4 — facade module kit, textured materials, theatre trim Co-authored-by: Zwitschi <zwitschi82@gmail.com>
316 lines
12 KiB
Python
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())
|