Files
gig-line/Tools/ue_apply_facade_kit.py
T
zwitschi 600633cd5b fix(materials): stop orphaning references; add a scene audit gate
The blockout cleaning pass found the map was rendering with 604 of 620 mesh actors
unmaterialed - every kit piece and all theatre trim.

Cause: ensure_material() deleted and recreated each /Game/Hollywood/Site/M_* asset on
every run, so actors staged by other tools kept references to deleted assets and fell
back to the default material. Nothing errored; the map looked merely slightly flat.

- Materials are now updated IN PLACE (load, delete_all_material_expressions, rebuild
  the graph, recompile, save). Material.expressions is protected from Python, so the
  editing library's deleter is used.
- 53 duplicated kit actor labels fixed (both shop piers of a bay shared one name).
- New Tools/ue_audit_scene.py gates the scene: every mesh actor has a material, every
  verified frontage span is within +/-1 m (via ue_placement), no duplicate labels.
- render_screenshots.sh now runs the chain in the required order (demo map -> kit ->
  theatre -> audit -> cameras -> capture) and exits non-zero on an audit failure,
  because the demo builder rebuilds materials and later staging must follow it.

Scene audit: 0 failures. Packaged QA PASS. Spawn-view reference score 0.72.
2026-11-08 22:48:00 +01:00

261 lines
11 KiB
Python

#!/usr/bin/env python3
"""Assemble the façade kit onto the block buildings.
Takes the imported kit from `/Game/Hollywood/Kit` and lays it out over a frontage
building's street face: shopfront bays on the ground floor, window bays above,
pilasters on the bay boundaries, a cornice under the parapet and a coping over it.
Layout is driven by the building's own bounding box, so it follows whatever the
blockout gives: the bay count is `round(width / BAY)` and the storey count comes
from the height above the ground floor. Nothing is hand-placed per building.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout
# or, as a step inside the demo builder:
from ue_apply_facade_kit import apply_to_actor
Result file: /tmp/gigline/kit-apply.txt
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ue_common # noqa: E402
KIT = "/Game/Hollywood/Kit"
RESULT = "/tmp/gigline/kit-apply.txt"
# Material per kit piece. Without this the pieces keep the grey material the FBX
# import creates (M_Kit_*), so the glazing is not glass and the shopfronts are not
# stone - the kit reads as a plastic scaffold over the buildings.
KIT_MATERIALS = {
"Kit_WindowGlass": "M_FacadeGlass",
"Kit_ShopGlass": "M_FacadeGlass",
"Kit_Door": "M_FacadeStoneShadow",
"Kit_WindowBay": "M_FacadeStone",
"Kit_ShopFascia": "M_FacadeBanner",
}
KIT_MATERIAL_FALLBACK = "M_FacadeStone"
BAY_CM = 496.0 # the kit module
STOREY_CM = 310.0
SHOP_FLOOR_CM = 310.0 # ground floor is one storey tall
KIT_PREFIX = "Kit_"
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def load_kit() -> dict:
"""Load every kit mesh once, keyed by its short name."""
kit = {}
registry = unreal.AssetRegistryHelpers.get_asset_registry()
filt = unreal.ARFilter(package_paths=[KIT], recursive_paths=False,
class_paths=[unreal.TopLevelAssetPath("/Script/Engine", "StaticMesh")])
for data in registry.get_assets(filt):
mesh = unreal.EditorAssetLibrary.load_asset(str(data.package_name))
if mesh:
# Assets are SM_-prefixed (Unreal convention); the layout code refers to
# pieces by their short name, so key on the name without the prefix.
name = mesh.get_name()
kit[name[3:] if name.startswith("SM_") else name] = mesh
return kit
def _bounds(mesh):
box = mesh.get_bounding_box()
return box.min, box.max
def _spawn(mesh, location, label, material=None, parent_collection=None):
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, location)
if actor is None:
log(f"could not spawn {label}")
return None
actor.set_actor_label(label)
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
comp.set_collision_profile_name("BlockAll")
if material:
comp.set_material(0, material)
return actor
def apply_to_actor(actor, kit, materials=None, prefix=None):
"""Lay the kit out over one building actor's street face.
Returns the number of pieces placed. The building's own mesh stays in place
as the wall the pieces sit on.
"""
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
return 0
# Labels must be unique per building, otherwise two buildings' pieces share a
# label and the outliner (and any label-based lookup) becomes ambiguous.
slug = actor.get_actor_label().replace("Frontage_SM_", "").replace("North_", "").lower()
prefix = prefix or f"Kit_{slug}_"
# WORLD-space bounds. The imported meshes carry a building-line-local offset in
# their own vertices and the demo builder then positions the actor, so the
# asset's bounding box is NOT world space: using it put every kit piece about
# 12 km east of its building, floating in empty ground where nothing could see
# it. get_actor_bounds() includes the actor transform.
origin_w, extent_w = actor.get_actor_bounds(False)
mn = unreal.Vector(origin_w.x - extent_w.x, origin_w.y - extent_w.y, origin_w.z - extent_w.z)
mx = unreal.Vector(origin_w.x + extent_w.x, origin_w.y + extent_w.y, origin_w.z + extent_w.z)
origin = unreal.Vector(0.0, 0.0, 0.0)
width = mx.x - mn.x
height = mx.z - mn.z
if width < BAY_CM * 0.75 or height < STOREY_CM:
log(f"{actor.get_actor_label()}: too small for the kit "
f"({width / 100:.1f} x {height / 100:.1f} m)")
return 0
# Bays: the kit module is one window bay (4.96 m). A very wide building gets a
# bay count that keeps the rhythm readable rather than one window per 4.96 m of
# frontage — 6922 is 65.8 m wide, which is 13 bays, giving a window every 4.96 m
# across a 37 m tall block: the rhythm reads as a wall of glass, not a building.
# Above MAX_BAYS the module is widened instead, so the bay count stays legible.
MAX_BAYS = 9
bays = max(1, int(round(width / BAY_CM)))
if bays > MAX_BAYS:
bays = MAX_BAYS
bay_w = width / bays # actual bay width for this building
# Street face: the frontage runs along X, and the street is at smaller Y.
face_y = mn.y
base_z = mn.z
# Storeys never overrun the volume: cap the count so the top band (cornice +
# parapet, 145 cm) always lands inside the building.
storeys = max(1, int((height - SHOP_FLOOR_CM - 145.0) // STOREY_CM) + 1)
top_z = base_z + height
# Windows above the shopfront, spread over the available height rather than
# assumed to be exactly STOREY_CM apart, so a 37 m block does not leave a gap
# of bare wall under the cornice.
usable = max(0.0, (top_z - 145.0) - (base_z + SHOP_FLOOR_CM))
storey_step = (usable / storeys) if storeys else STOREY_CM
def kit_material(name):
if materials is None:
return None
want = KIT_MATERIALS.get(name, KIT_MATERIAL_FALLBACK)
return materials.get(want)
placed = 0
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
# ground floor: shopfront
for piece, dx, side in (("Kit_ShopPier_L", -bay_w / 2 + 30.0, "L"),
("Kit_ShopPier_R", bay_w / 2 - 30.0, "R")):
if piece in kit:
# The side belongs in the label: both piers of a bay shared one
# label, which left 53 ambiguous pairs in the outliner.
if _spawn(kit[piece], unreal.Vector(cx + dx, face_y, base_z),
f"{prefix}shop_pier_{side}_{bay}", kit_material(piece)):
placed += 1
if "Kit_ShopGlass" in kit:
if _spawn(kit["Kit_ShopGlass"], unreal.Vector(cx, face_y, base_z),
f"{prefix}shop_glass_{bay}", kit_material("Kit_ShopGlass")):
placed += 1
# upper storeys: window bays
for storey in range(storeys):
if "Kit_WindowBay" in kit:
z = base_z + SHOP_FLOOR_CM + storey_step * storey + 65.0
if _spawn(kit["Kit_WindowBay"], unreal.Vector(cx, face_y, z),
f"{prefix}window_{bay}_{storey}", kit_material("Kit_WindowBay")):
placed += 1
# pilaster on the left boundary of every bay
if "Kit_Pilaster" in kit:
if _spawn(kit["Kit_Pilaster"],
unreal.Vector(mn.x + bay_w * bay, face_y, base_z),
f"{prefix}pilaster_{bay}", kit_material("Kit_Pilaster")):
placed += 1
# cornice under the parapet, coping on top
if "Kit_Cornice" in kit:
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
if _spawn(kit["Kit_Cornice"],
unreal.Vector(cx, face_y, base_z + height - 145.0),
f"{prefix}cornice_{bay}", kit_material("Kit_Cornice")):
placed += 1
if "Kit_Parapet" in kit:
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
if _spawn(kit["Kit_Parapet"],
unreal.Vector(cx, face_y, base_z + height - 90.0),
f"{prefix}parapet_{bay}", kit_material("Kit_Parapet")):
placed += 1
log(f"{actor.get_actor_label()}: {bays} bays x {storeys} storeys, "
f"{width / 100:.1f} x {height / 100:.1f} m -> {placed} pieces")
return placed
def main() -> None:
kit = load_kit()
if not kit:
log(f"no kit meshes under {KIT} — run Tools/ue_import_facade_kit.py first")
return
log(f"kit meshes: {len(kit)}")
# Site materials built by Tools/ue_build_demo_map.py.
materials = {}
for name in ("M_FacadeStone", "M_FacadeStoneShadow", "M_FacadeGlass",
"M_FacadeBanner", "M_Stucco", "M_Concrete"):
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
materials[name] = asset
log(f"kit materials resolved: {len(materials)}/6")
level = "/Game/Hollywood/DemoMap"
if not unreal.EditorLevelLibrary.load_level(level):
log(f"could not load {level}")
return
# The map is LFS-tracked and checks out read-only.
content = unreal.Paths.project_content_dir()
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
# Remove a previous application so re-running does not stack duplicates.
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith("Kit_"):
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
log(f"cleared {removed} previous kit actors")
targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors()
if a.get_actor_label().startswith(("Frontage_", "North_"))]
log(f"frontage actors: {len(targets)}")
total = 0
for actor in targets:
total += apply_to_actor(actor, kit, materials)
log(f"placed {total} kit pieces")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), level)
log(f"save_map {level}: {saved}")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")