The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first: - ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now. - render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/... and the capture step failed in 0 s. - the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build' passed without building. Result files are unlinked before each step and child output streams to a per-step log file rather than a pipe. - two editors on one project hang instead of failing, so the driver refuses to start while one is running. Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the builders actually need. Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before, and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
318 lines
12 KiB
Python
318 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/blender/facade_kit_build.py --out /tmp/gigline/kit \
|
|
--report /tmp/gigline/kit-report.json
|
|
blender -b --factory-startup --python Tools/blender/facade_kit_build.py -- --out /tmp/gigline/kit
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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
|
|
|
|
# --- 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())
|