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.
271 lines
11 KiB
Python
271 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/unreal/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
|
|
|
|
_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 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/unreal/ue_import_facade_kit.py first")
|
|
return
|
|
log(f"kit meshes: {len(kit)}")
|
|
|
|
# Site materials built by Tools/unreal/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")
|
|
|
|
# The kit is the fallback for buildings that have nothing but a blockout. These
|
|
# two are modelled from their own photograph in 0.1.6
|
|
# (Tools/blender/retail_frontage_build.py); pasting the generic kit over them put a
|
|
# second, contradicting facade language on the same wall.
|
|
MODELLED = ("Frontage_SM_LeeDrug_6800", "Frontage_SM_Hollywood_6806")
|
|
targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors()
|
|
if a.get_actor_label().startswith(("Frontage_", "North_"))
|
|
and a.get_actor_label() not in MODELLED]
|
|
skipped = [a.get_actor_label() for a in unreal.EditorLevelLibrary.get_all_level_actors()
|
|
if a.get_actor_label() in MODELLED]
|
|
log(f"frontage actors: {len(targets)} (kit skipped on modelled facades: {skipped})")
|
|
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")
|