Merged PR #32: 0.1.5 — hero detail, scene audit, material orphaning fixed Co-authored-by: Zwitschi <zwitschi82@gmail.com>
261 lines
11 KiB
Python
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")
|