Files
gig-line/Tools/ue_apply_facade_kit.py
T
zwitschi 1b069188a2 refactor(kit): SM_ asset naming and topology validation per the modeling checklist
- Kit pieces export as SM_Kit_* (Unreal static-mesh convention) while the Blender
  objects keep their readable names; the kit loader strips the prefix so layout
  still addresses pieces by short name.
- facade_kit_build.py validates each piece before handoff (non-manifold edges,
  loose vertices, triangle count) and records the result in the part report:
  16 pieces, all manifold, no loose geometry, 12 tris each.
- ue_import_facade_kit.py removes kit assets the current build no longer produces,
  so a rename cannot leave two generations of the same piece behind.
- Kit re-imported and re-applied (625 pieces); packaged QA still PASS.
2026-11-08 19:38:29 +01:00

219 lines
8.4 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"
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 = max(1, int(round(width / BAY_CM)))
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 = max(1, int((height - SHOP_FLOOR_CM) // STOREY_CM))
def kit_material(name):
return (materials or {}).get(name)
placed = 0
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
# ground floor: shopfront
for piece, dx in (("Kit_ShopPier_L", -bay_w / 2 + 30.0),
("Kit_ShopPier_R", bay_w / 2 - 30.0)):
if piece in kit:
if _spawn(kit[piece], unreal.Vector(cx + dx, face_y, base_z),
f"{prefix}shop_pier_{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_CM * 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)}")
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)
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")