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.
811 lines
41 KiB
Python
811 lines
41 KiB
Python
# Build /Game/Hollywood/DemoMap: the walkable demo level.
|
|
#
|
|
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
|
# -script=<repo>/Tools/unreal/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout
|
|
#
|
|
# Content this map expects (built by earlier steps):
|
|
# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/unreal/ue_hero_facade_import.py
|
|
# /Game/Hollywood/Site/... Tools/unreal/ue_import_site.py
|
|
#
|
|
# Map conventions, from Geo/manifests/local-origin.md:
|
|
# X east, Y south, Z up, centimetres, origin at Hollywood x Highland.
|
|
# The player starts on the north sidewalk opposite the hero frontage, looking
|
|
# south at it, which is the view the reference photographs were taken from.
|
|
#
|
|
# Every actor is (re)created by label, so rerunning the script rebuilds the map
|
|
# deterministically instead of stacking duplicates.
|
|
|
|
import os
|
|
import sys
|
|
|
|
import unreal
|
|
|
|
MAP = "/Game/Hollywood/DemoMap"
|
|
# Repository root, so the texture generator can be invoked by path.
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
_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_placement # noqa: E402
|
|
RESULT = "/tmp/gigline/demo-map.txt"
|
|
lines = []
|
|
|
|
|
|
# EditorLevelLibrary.load_level/new_level are deprecated no-ops in -nullrhi
|
|
# commandlets (they return without switching worlds, so staged actors land in
|
|
# whatever world is current and get saved over the wrong map). The
|
|
# LevelEditorSubsystem actually switches.
|
|
LES = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
|
|
|
|
|
def switch_level(path):
|
|
return LES.load_level(path)
|
|
|
|
def log(msg):
|
|
lines.append(str(msg))
|
|
unreal.log(str(msg))
|
|
|
|
|
|
def spawn(cls, loc, label, rot=None):
|
|
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
|
|
if actor is None:
|
|
log(f"spawn {cls} failed")
|
|
return None
|
|
if rot is not None:
|
|
actor.set_actor_rotation(rot, False)
|
|
actor.set_actor_label(label)
|
|
return actor
|
|
|
|
|
|
TEXTURE_DIR = "/Game/Hollywood/Textures"
|
|
|
|
|
|
def import_texture(png_path, asset_name):
|
|
"""Import a generated PNG as a Texture2D (idempotent: replaces the asset)."""
|
|
if not os.path.exists(png_path):
|
|
log(f"texture missing: {png_path}")
|
|
return None
|
|
task = unreal.AssetImportTask()
|
|
task.filename = png_path
|
|
task.destination_path = TEXTURE_DIR
|
|
task.destination_name = asset_name
|
|
task.automated = True
|
|
task.save = True
|
|
task.replace_existing = True
|
|
ui = unreal.TextureImportUI() if hasattr(unreal, "TextureImportUI") else None
|
|
if ui:
|
|
task.options = ui
|
|
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
|
return unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{asset_name}")
|
|
|
|
|
|
def ensure_textures():
|
|
"""Generate/point at the facade maps and import them. Returns {name: asset}."""
|
|
out_dir = "/tmp/gigline/textures"
|
|
gen = os.path.join(ROOT, "Tools", "generate_facade_textures.py")
|
|
if os.path.exists(gen) and not os.path.exists(os.path.join(out_dir, "T_StoneAshlar_BC.png")):
|
|
import subprocess
|
|
subprocess.run([sys.executable, gen, "--out", out_dir, "--size", "512"], check=False)
|
|
imported = {}
|
|
for name in ("T_StoneAshlar_BC", "T_StoneAshlar_R", "T_Stucco_BC", "T_Stucco_R",
|
|
"T_Asphalt_BC", "T_Asphalt_R", "T_Concrete_BC", "T_Concrete_R"):
|
|
asset = import_texture(os.path.join(out_dir, name + ".png"), name)
|
|
if asset:
|
|
imported[name] = asset
|
|
log(f"textures imported: {len(imported)}/8")
|
|
return imported
|
|
|
|
|
|
def ensure_material(mat_name, spec, textures=None):
|
|
"""Create/reuse a look-developed material under /Game/Hollywood/Site.
|
|
|
|
Blender's flat diffuse colours do not survive the FBX -> Unreal import (the
|
|
terrain even lands with WorldGridMaterial), so the demo has always assigned
|
|
its own material per surface. Those were flat colour only, which read as
|
|
plastic. Each material here gets:
|
|
|
|
* base colour with a subtle world-space noise blend between two shades,
|
|
so asphalt and concrete have large-scale variation instead of a fill,
|
|
* an explicit roughness (and specular) so the surface type is readable,
|
|
wet-looking stone vs matte concrete vs chalky road paint.
|
|
|
|
spec keys: dark, bright (RGB tuples), roughness, specular, noise_scale
|
|
(world units per noise cycle; smaller = larger features).
|
|
|
|
Optional `courses_m` adds **masonry coursing**: horizontal joint lines at a
|
|
given course height in world metres, plus a per-course horizontal offset so
|
|
the vertical joints stagger like real ashlar. Optional `joint_tint` is the
|
|
colour the joints darken toward, and `roughness_variation` lerps roughness
|
|
between `roughness` and a rougher value using the same noise, so the surface
|
|
does not read as one flat specular value.
|
|
"""
|
|
path = f"/Game/Hollywood/Site/{mat_name}"
|
|
lib = unreal.MaterialEditingLibrary
|
|
|
|
# Update the existing material IN PLACE rather than deleting and recreating it.
|
|
# Deleting the asset to force a look rebuild silently orphaned every reference:
|
|
# after one demo-builder run, all 598 kit actors and the theatre trim pointed at
|
|
# deleted materials and rendered with the default one (604 actors with no
|
|
# material), while the map itself looked merely "a bit flat".
|
|
mat = unreal.EditorAssetLibrary.load_asset(path)
|
|
if mat is None:
|
|
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
|
mat_name, "/Game/Hollywood/Site", unreal.Material, unreal.MaterialFactoryNew())
|
|
else:
|
|
# Clear the old graph so a removed node cannot survive a look change.
|
|
# `Material.expressions` is protected from Python, so use the editing
|
|
# library's own deleter rather than reading the array.
|
|
lib.delete_all_material_expressions(mat)
|
|
|
|
def node(cls):
|
|
return lib.create_material_expression(mat, cls)
|
|
|
|
def c3(rgb):
|
|
n = node(unreal.MaterialExpressionConstant3Vector)
|
|
n.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
|
|
return n
|
|
|
|
def scalar(value):
|
|
n = node(unreal.MaterialExpressionConstant)
|
|
n.set_editor_property("r", float(value))
|
|
return n
|
|
|
|
def link(src, dst, pin=""):
|
|
"""Connect, and say so when it fails.
|
|
|
|
`connect_material_expressions` returns False rather than raising, so an
|
|
unknown pin name silently leaves an input unconnected and the material
|
|
compiles with a default value — a coursing mask of 0 is invisible, which
|
|
is exactly how the first attempt failed.
|
|
"""
|
|
ok = lib.connect_material_expressions(src, "", dst, pin)
|
|
if not ok:
|
|
log(f"material {mat_name}: FAILED to connect {src.__class__.__name__} "
|
|
f"-> {dst.__class__.__name__}.{pin or '(default)'}")
|
|
return ok
|
|
|
|
# world position -> noise -> lerp(dark, bright) -> base colour
|
|
wp = node(unreal.MaterialExpressionWorldPosition)
|
|
scale = scalar(spec.get("noise_scale", 0.004))
|
|
mul = node(unreal.MaterialExpressionMultiply)
|
|
lib.connect_material_expressions(wp, "", mul, "A")
|
|
lib.connect_material_expressions(scale, "", mul, "B")
|
|
|
|
noise = node(unreal.MaterialExpressionNoise)
|
|
for prop, value in (("scale", 1.0), ("quality", 1), ("levels", 2),
|
|
("output_min", 0.0), ("output_max", 1.0)):
|
|
try:
|
|
noise.set_editor_property(prop, value)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
lib.connect_material_expressions(mul, "", noise, "Position")
|
|
|
|
lerp = node(unreal.MaterialExpressionLinearInterpolate)
|
|
lib.connect_material_expressions(c3(spec["dark"]), "", lerp, "A")
|
|
lib.connect_material_expressions(c3(spec["bright"]), "", lerp, "B")
|
|
lib.connect_material_expressions(noise, "", lerp, "Alpha")
|
|
|
|
# --- optional masonry coursing -----------------------------------------
|
|
# A world-space course mask: the fractional part of Z / course height is a
|
|
# sawtooth per course; zeroing it gives a joint line every course. The row
|
|
# index shifts each course sideways by half a brick, so vertical joints
|
|
# stagger. All of it is world-space, so every building shares the coursing
|
|
# without needing its own UVs.
|
|
base_color_expr = lerp
|
|
if spec.get("courses_m"):
|
|
# WorldPosition is in CENTIMETRES, so a course height given in metres must
|
|
# be scaled by 100 here; getting this wrong makes the coursing 100x too
|
|
# fine to see at all (the first attempt did exactly that).
|
|
course_cm = float(spec["courses_m"]) * 100.0
|
|
row = node(unreal.MaterialExpressionDivide)
|
|
link(wp, row, "A")
|
|
link(c3((course_cm, course_cm, course_cm)), row, "B")
|
|
saw = node(unreal.MaterialExpressionFrac)
|
|
link(row, saw)
|
|
# Joint mask without a comparison node (UE has no Less): 1 - saw/joint is
|
|
# 1 at the course boundary and 0 once past the joint width; clamped to 0..1
|
|
# so it is a linear band rather than a hard step.
|
|
div = node(unreal.MaterialExpressionDivide)
|
|
link(saw, div, "A")
|
|
# joint_width is a fraction of one course (0.11 ~= a 6 cm joint on a
|
|
# 55 cm course), which is the space `saw` lives in after the divide.
|
|
link(scalar(spec.get("joint_width", 0.11)), div, "B")
|
|
inv = node(unreal.MaterialExpressionOneMinus)
|
|
link(div, inv)
|
|
joint = node(unreal.MaterialExpressionClamp)
|
|
link(inv, joint)
|
|
tinted = node(unreal.MaterialExpressionLinearInterpolate)
|
|
link(lerp, tinted, "A")
|
|
link(c3(spec.get("joint_tint", (0.34, 0.31, 0.27))), tinted, "B")
|
|
link(joint, tinted, "Alpha")
|
|
base_color_expr = tinted
|
|
|
|
# If a texture set is supplied for this material, sample it world-aligned and
|
|
# use it instead of the graph-only coursing: the map carries the coursing and
|
|
# the grain, so overlaying both would double the pattern.
|
|
tex = (textures or {}).get(spec.get("texture", ""))
|
|
if tex:
|
|
bc = (textures or {}).get(spec.get("texture", "") + "_BC")
|
|
# world position (cm) -> UV: one texture tile every tile_cm centimetres
|
|
tile_cm = float(spec.get("tile_cm", 300.0))
|
|
uv_scale = node(unreal.MaterialExpressionMultiply)
|
|
link(wp, uv_scale, "A")
|
|
link(scalar(1.0 / tile_cm), uv_scale, "B")
|
|
mask_xy = node(unreal.MaterialExpressionComponentMask)
|
|
for pin in ("R", "G"):
|
|
try:
|
|
mask_xy.set_editor_property(pin, True)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
try:
|
|
mask_xy.set_editor_property("B", False)
|
|
mask_xy.set_editor_property("A", False)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
link(uv_scale, mask_xy)
|
|
if bc:
|
|
sample = node(unreal.MaterialExpressionTextureSample)
|
|
sample.set_editor_property("texture", bc)
|
|
link(mask_xy, sample, "UVs")
|
|
lib.connect_material_property(sample, "RGB",
|
|
unreal.MaterialProperty.MP_BASE_COLOR)
|
|
base_color_expr = sample
|
|
r_map = (textures or {}).get(spec.get("texture", "") + "_R")
|
|
if r_map:
|
|
r_sample = node(unreal.MaterialExpressionTextureSample)
|
|
r_sample.set_editor_property("texture", r_map)
|
|
link(mask_xy, r_sample, "UVs")
|
|
lib.connect_material_property(r_sample, "R",
|
|
unreal.MaterialProperty.MP_ROUGHNESS)
|
|
|
|
lib.connect_material_property(base_color_expr, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
|
|
|
# Roughness: lerp(base, base + variation, noise) so the highlight breaks up.
|
|
variation = float(spec.get("roughness_variation", 0.0))
|
|
if variation > 0.0:
|
|
rough_lerp = node(unreal.MaterialExpressionLinearInterpolate)
|
|
lib.connect_material_expressions(
|
|
scalar(spec.get("roughness", 0.85)), "", rough_lerp, "A")
|
|
lib.connect_material_expressions(
|
|
scalar(min(1.0, spec.get("roughness", 0.85) + variation)), "", rough_lerp, "B")
|
|
lib.connect_material_expressions(noise, "", rough_lerp, "Alpha")
|
|
lib.connect_material_property(rough_lerp, "", unreal.MaterialProperty.MP_ROUGHNESS)
|
|
else:
|
|
lib.connect_material_property(
|
|
scalar(spec.get("roughness", 0.85)), "", unreal.MaterialProperty.MP_ROUGHNESS)
|
|
try:
|
|
lib.connect_material_property(
|
|
scalar(spec.get("specular", 0.3)), "", unreal.MaterialProperty.MP_SPECULAR)
|
|
except Exception: # noqa: BLE001 - specular input is optional in UE5
|
|
pass
|
|
|
|
lib.recompile_material(mat)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(mat)
|
|
log(f"material {mat_name}: rough={spec.get('roughness')} "
|
|
f"dark={spec['dark']} bright={spec['bright']}")
|
|
return mat
|
|
|
|
|
|
def clear_all():
|
|
"""Destroy every actor in the level.
|
|
|
|
The prefix-based clear only removed what this builder knows about, so anything an experiment left behind
|
|
survived every rebuild - that is how a stale road mesh and a duplicate vehicle actor both ended up saved
|
|
back into the map. Every build now starts from an empty world.
|
|
"""
|
|
removed = 0
|
|
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
|
|
label = actor.get_actor_label()
|
|
try:
|
|
unreal.EditorLevelLibrary.destroy_actor(actor)
|
|
removed += 1
|
|
_ = label
|
|
except Exception: # noqa: BLE001 - a locked actor must not abort the clear
|
|
log(f"could not destroy {label}")
|
|
return removed
|
|
|
|
|
|
def clear(prefixes=()):
|
|
removed = 0
|
|
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
|
label = actor.get_actor_label()
|
|
if label.startswith(prefixes):
|
|
unreal.EditorLevelLibrary.destroy_actor(actor)
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
def ground_z(x, y, from_z=2000.0, fallback=None):
|
|
"""Z of the surface under (x, y), measured, not assumed.
|
|
|
|
Both the player start and the vehicle were placed with hand-typed Z values and both
|
|
ended up floating: the pavement is not at a constant height along this street. Every
|
|
placement now reads the surface it stands on.
|
|
"""
|
|
world = unreal.EditorLevelLibrary.get_editor_world()
|
|
start = unreal.Vector(x, y, from_z)
|
|
end = unreal.Vector(x, y, from_z - 6000.0)
|
|
hit = unreal.KismetSystemLibrary.line_trace_single_by_channel(
|
|
world, start, end, unreal.TraceTypeQuery.TRACE_TYPE_QUERY1,
|
|
False, [], unreal.DrawDebugTrace.NONE, True)
|
|
if hit and hit[0]:
|
|
z = hit[1].impact_point.z
|
|
log(f"ground at ({x:.0f}, {y:.0f}) = Z {z:.0f} cm")
|
|
return z
|
|
log(f"ground at ({x:.0f}, {y:.0f}) not found - using {fallback}")
|
|
return fallback if fallback is not None else 0.0
|
|
|
|
|
|
def build():
|
|
# --- level ------------------------------------------------------------
|
|
if unreal.EditorAssetLibrary.does_asset_exist(MAP):
|
|
if not switch_level(MAP):
|
|
log(f"could not load {MAP}")
|
|
return
|
|
else:
|
|
if not switch_level(MAP):
|
|
log(f"could not create {MAP}")
|
|
return
|
|
|
|
# Repeat until nothing is left: destroying actors while iterating a snapshot can miss
|
|
# some, which is how two Demo_Vehicle_Sedan actors survived into a saved map and the
|
|
# scene audit failed on a duplicate label.
|
|
removed = clear_all()
|
|
log(f"cleared {removed} previous actors (whole level, not just known prefixes)")
|
|
|
|
# --- lighting: fixed daytime ------------------------------------------
|
|
# The demo is a fixed daytime scene, not a time-of-day simulation. Three
|
|
# things matter and each was wrong before:
|
|
#
|
|
# 1. Sun angle. The hero facade front faces -Y and the road faces +Z. A sun
|
|
# at yaw 120 / pitch -50 shines toward (-X, +Y, down), so it lights both
|
|
# the facade front and the carriageway instead of raking across them.
|
|
# 2. Exposure. Auto-exposure made the same road washed-out in one shot and
|
|
# near-black in the next, because each camera framed a different amount
|
|
# of sky. A manual-exposure post-process volume pins it.
|
|
# 3. Ambience. A height fog gives the street depth and hides the terrain
|
|
# edge that otherwise reads as a black band at the horizon.
|
|
sun = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 2000), "Demo_Sun",
|
|
unreal.Rotator(pitch=-82.0, yaw=120.0, roll=0.0))
|
|
if sun:
|
|
comp = sun.get_component_by_class(unreal.DirectionalLightComponent)
|
|
if comp:
|
|
for prop, value in (
|
|
("intensity", 4.0),
|
|
("light_color", unreal.Color(r=255, g=245, b=230, a=255)),
|
|
("atmosphere_sun_light", True),
|
|
):
|
|
try:
|
|
comp.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sun.{prop} not set: {exc!r}")
|
|
# Movable: static lighting is disabled (r.AllowStaticLighting=False),
|
|
# so nothing needs a lighting build.
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sun mobility not set: {exc!r}")
|
|
log("staged: Demo_Sun (pitch -74, yaw 120, intensity 4, warm; higher sun lifts the north sidewalk out of building shadow)")
|
|
|
|
sky = spawn(unreal.SkyLight, unreal.Vector(0, 0, 2000), "Demo_SkyLight")
|
|
if sky:
|
|
comp = sky.get_component_by_class(unreal.SkyLightComponent)
|
|
if comp:
|
|
for prop, value in (("intensity", 2.6), ("real_time_capture", True)):
|
|
try:
|
|
comp.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sky.{prop} not set: {exc!r}")
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"sky mobility not set: {exc!r}")
|
|
log("staged: Demo_SkyLight (intensity 2.6, real-time capture)")
|
|
|
|
spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "Demo_Sky")
|
|
log("staged: Demo_Sky (sky atmosphere)")
|
|
|
|
fog = spawn(unreal.ExponentialHeightFog, unreal.Vector(0, 0, 0), "Demo_Fog")
|
|
if fog:
|
|
comp = fog.get_component_by_class(unreal.ExponentialHeightFogComponent)
|
|
if comp:
|
|
for prop, value in (
|
|
("fog_density", 0.008),
|
|
("fog_height_falloff", 0.15),
|
|
("start_distance", 3000.0),
|
|
):
|
|
try:
|
|
comp.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"fog.{prop} not set: {exc!r}")
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"fog mobility not set: {exc!r}")
|
|
log("staged: Demo_Fog (height fog, density 0.008)")
|
|
|
|
# Fixed exposure: without this the same road renders washed out or black
|
|
# depending on how much sky the camera frames. Pinning min == max brightness
|
|
# freezes the exposure at one value (the classic fixed-exposure trick);
|
|
# AEM_MANUAL with a bias was tried first and blew the frame out.
|
|
ppv = spawn(unreal.PostProcessVolume, unreal.Vector(0, 0, 0), "Demo_PostProcess")
|
|
if ppv:
|
|
ppv.set_editor_property("unbound", True)
|
|
settings = ppv.get_editor_property("settings")
|
|
for prop, value in (
|
|
("override_auto_exposure_method", True),
|
|
("auto_exposure_method", unreal.AutoExposureMethod.AEM_HISTOGRAM),
|
|
("override_auto_exposure_min_brightness", True),
|
|
("auto_exposure_min_brightness", 1.0),
|
|
("override_auto_exposure_max_brightness", True),
|
|
("auto_exposure_max_brightness", 1.0),
|
|
("override_auto_exposure_bias", True),
|
|
("auto_exposure_bias", 0.0),
|
|
):
|
|
try:
|
|
settings.set_editor_property(prop, value)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"pp.{prop} not set: {exc!r}")
|
|
ppv.set_editor_property("settings", settings)
|
|
log("staged: Demo_PostProcess (fixed exposure, min=max brightness 1.0)")
|
|
|
|
# --- hero facade on the measured building line ------------------------
|
|
# Hero placement, corrected 2026-11-07 when the neighbouring frontage was
|
|
# added: UE south is +Y (the south sidewalk is Y +718..+1447) and the block
|
|
# frontage sits at X -24276..-12082 with a gap at -14591..-12082 for 6840.
|
|
# The old (-10722.5, -1443) put the facade 26 m east of its footprint and on
|
|
# the NORTH side of the street, which only looked right while it stood alone.
|
|
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
|
|
if hero:
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(*ue_placement.HERO_LOCATION_CM), ue_placement.HERO_ACTOR)
|
|
if actor:
|
|
actor.static_mesh_component.set_static_mesh(hero)
|
|
actor.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
|
|
log("staged: Hero_Facade at the building line")
|
|
else:
|
|
log("MISSING: SM_HeroFacade — run Tools/unreal/ue_hero_facade_import.py")
|
|
|
|
# --- site meshes, if they have been imported --------------------------
|
|
# Blender's flat diffuse colours do not survive FBX -> Unreal: Interchange
|
|
# builds a material with a default white base colour, and the terrain even
|
|
# lands with WorldGridMaterial. Every site surface therefore rendered white,
|
|
# which is what washed out the demo. Assign explicit colours per mesh here.
|
|
# Look-developed surface materials (see ensure_material for the graph).
|
|
MATERIAL_SPECS = {
|
|
"M_Asphalt": dict(dark=(0.040, 0.040, 0.044), bright=(0.072, 0.072, 0.078),
|
|
roughness=0.92, specular=0.22, noise_scale=0.006,
|
|
texture="T_Asphalt", tile_cm=900.0),
|
|
"M_Concrete": dict(dark=(0.44, 0.42, 0.39), bright=(0.56, 0.54, 0.50),
|
|
roughness=0.88, specular=0.28, noise_scale=0.004,
|
|
texture="T_Concrete", tile_cm=250.0),
|
|
"M_KerbConcrete": dict(dark=(0.47, 0.45, 0.42), bright=(0.60, 0.58, 0.54),
|
|
roughness=0.85, specular=0.30, noise_scale=0.008),
|
|
"M_Ground": dict(dark=(0.22, 0.20, 0.17), bright=(0.33, 0.30, 0.26),
|
|
roughness=0.98, specular=0.15, noise_scale=0.003),
|
|
"M_RoadPaint": dict(dark=(0.74, 0.74, 0.72), bright=(0.86, 0.86, 0.84),
|
|
roughness=0.62, specular=0.35, noise_scale=0.02),
|
|
# Ashlar coursing at 0.55 m (a 0.10 m band joint), the reading the 2009
|
|
# facade photograph gives for the hero's stone; roughness breaks up so the
|
|
# wall is not one specular plane.
|
|
"M_FacadeStone": dict(dark=(0.30, 0.26, 0.20), bright=(0.47, 0.41, 0.31),
|
|
roughness=0.70, specular=0.32, noise_scale=0.005,
|
|
courses_m=0.55, joint_width=0.11,
|
|
joint_tint=(0.16, 0.14, 0.11), roughness_variation=0.16,
|
|
texture="T_StoneAshlar", tile_cm=440.0),
|
|
# Rendered/stucco frontage: no coursing, but a fine tooth and a wider
|
|
# roughness range than before, because flat stucco is what read as plastic.
|
|
"M_Stucco": dict(dark=(0.50, 0.46, 0.40), bright=(0.68, 0.64, 0.57),
|
|
roughness=0.76, specular=0.25, noise_scale=0.0025,
|
|
roughness_variation=0.20, texture="T_Stucco", tile_cm=260.0),
|
|
# Stone in shadow: the hero's recessed wall behind the colonnade. Same
|
|
# coursing as the face, a stop darker, so depth reads without a light.
|
|
"M_FacadeStoneShadow": dict(dark=(0.18, 0.155, 0.12), bright=(0.28, 0.24, 0.18),
|
|
roughness=0.78, specular=0.26, noise_scale=0.005,
|
|
courses_m=0.55, joint_width=0.11,
|
|
joint_tint=(0.10, 0.09, 0.07),
|
|
roughness_variation=0.10),
|
|
# Glazing: low roughness, strong specular. A plausible IOR for glass sits
|
|
# near 1.5, which UE expresses through specular ~0.5-0.6 on an opaque
|
|
# surface (there is no refraction pass at blockout fidelity).
|
|
"M_FacadeGlass": dict(dark=(0.030, 0.045, 0.055), bright=(0.075, 0.095, 0.110),
|
|
roughness=0.14, specular=0.62, noise_scale=0.01,
|
|
roughness_variation=0.06),
|
|
# The JIMMY KIMMEL banners: the one saturated surface on the elevation.
|
|
"M_FacadeBanner": dict(dark=(0.32, 0.05, 0.07), bright=(0.55, 0.10, 0.12),
|
|
roughness=0.72, specular=0.22, noise_scale=0.02,
|
|
roughness_variation=0.12),
|
|
}
|
|
# Segmented ground: the site export slices sidewalks and kerbs into 20 m pieces, so the
|
|
# map stages every slice. A single 279 m slab could not report an honest local height and
|
|
# its collision was degenerate; slices fix both.
|
|
segments = [f"Road_Hollywood_{i:02d}" for i in range(14)] + \
|
|
[f"Sidewalk_North_{i:02d}" for i in range(14)] + \
|
|
[f"Sidewalk_South_{i:02d}" for i in range(14)] + \
|
|
[f"Kerb_North_{i:02d}" for i in range(14)] + \
|
|
[f"Kerb_South_{i:02d}" for i in range(14)]
|
|
SURFACE_MATERIALS = {
|
|
"Terrain_DEM": "M_Ground",
|
|
"Crossing_Markings": "M_RoadPaint",
|
|
}
|
|
textures = ensure_textures()
|
|
built = {}
|
|
for mat_name, spec in MATERIAL_SPECS.items():
|
|
built[mat_name] = ensure_material(mat_name, spec, textures)
|
|
|
|
for segment in segments:
|
|
if unreal.EditorAssetLibrary.does_asset_exist(f"/Game/Hollywood/Site/{segment}"):
|
|
if "Road" in segment:
|
|
SURFACE_MATERIALS[segment] = "M_Asphalt"
|
|
elif "Sidewalk" in segment:
|
|
SURFACE_MATERIALS[segment] = "M_Concrete"
|
|
else:
|
|
SURFACE_MATERIALS[segment] = "M_KerbConcrete"
|
|
|
|
imported = 0
|
|
for name, mat_name in SURFACE_MATERIALS.items():
|
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
|
|
if mesh is None:
|
|
continue
|
|
# The imported terrain carries a bounding-box simple collision proxy,
|
|
# which puts an invisible lid ~8 m over the whole block: the player
|
|
# walks on that box instead of the street. Force trace against the real
|
|
# triangles so the carved street corridor is actually open.
|
|
# Walkable surfaces need the same treatment as the terrain: with only the
|
|
# auto-generated simple collision, a sidewalk does not block a downward trace while
|
|
# the road does - so every ground mesh that the player can stand on is switched to
|
|
# complex-as-simple, where the real triangles do the work.
|
|
if "Terrain" in name or "Sidewalk" in name or "Kerb" in name:
|
|
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
|
|
if asset:
|
|
unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True)
|
|
# The flag lives on the asset, not the map: persist it or the
|
|
# next session reloads the bbox proxy and the street is buried.
|
|
saved_asset = unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
log(f"terrain collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} "
|
|
f"(saved asset: {saved_asset})")
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Demo_{name}")
|
|
if actor and "Terrain" in name:
|
|
# Terrain answers traces (so the player walks on it) but is invisible to the physics
|
|
# solver: a body inside its triangle soup is pushed out every frame, which is what threw
|
|
# the Chaos vehicle into the air. Vehicles drive on the street slices, which carry their
|
|
# own UCX boxes.
|
|
comp_t = actor.get_component_by_class(unreal.StaticMeshComponent)
|
|
if comp_t:
|
|
comp_t.set_collision_enabled(unreal.CollisionEnabled.QUERY_ONLY)
|
|
if actor:
|
|
comp = actor.static_mesh_component
|
|
comp.set_static_mesh(mesh)
|
|
# Immutable level geometry must be STATIC to reliably expose its
|
|
# complex-as-simple collision body to CharacterMovement; staging
|
|
# these MOVABLE let the player fall through the world.
|
|
comp.set_mobility(unreal.ComponentMobility.STATIC)
|
|
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
|
|
comp.set_collision_object_type(unreal.CollisionChannel.ECC_WORLD_STATIC)
|
|
comp.set_collision_profile_name("BlockAll")
|
|
mat = built.get(mat_name)
|
|
if mat:
|
|
comp.set_material(0, mat)
|
|
imported += 1
|
|
log(f"staged {imported} site meshes with explicit surface materials")
|
|
|
|
# The hero facade and the two north-side buildings are staged by other
|
|
# scripts (or reused between rebuilds), so assign their look materials by
|
|
# actor label after everything is in the level.
|
|
LABEL_MATERIALS = {"Hero_Facade": "M_FacadeStone",
|
|
"North_TCL_Chinese": "M_Stucco",
|
|
"North_Madame_Tussauds": "M_Stucco"}
|
|
|
|
# Assign per SLOT, by what the slot's imported material is called, not just
|
|
# slot 0: the hero mesh's slot 0 is the banner, so the stone, the shadowed
|
|
# stone and the glazing were all still wearing their flat Blender materials.
|
|
SLOT_RULES = (
|
|
("glass", "M_FacadeGlass"),
|
|
("banner", "M_FacadeBanner"),
|
|
("stoneshadow", "M_FacadeStoneShadow"),
|
|
("stone", "M_FacadeStone"),
|
|
("roof", "M_Concrete"),
|
|
("wall", "M_Stucco"),
|
|
)
|
|
|
|
def slot_material_name(slot_mesh_material):
|
|
if not slot_mesh_material:
|
|
return None
|
|
key = slot_mesh_material.get_name().lower()
|
|
for needle, target in SLOT_RULES:
|
|
if needle in key:
|
|
return target
|
|
return None
|
|
|
|
assigned = 0
|
|
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
|
fallback = LABEL_MATERIALS.get(actor.get_actor_label())
|
|
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
|
if not comp or not comp.static_mesh:
|
|
continue
|
|
slots = comp.static_mesh.get_editor_property("static_materials")
|
|
touched = 0
|
|
for index, slot in enumerate(slots):
|
|
mesh_mat = slot.get_editor_property("material_interface")
|
|
target = slot_material_name(mesh_mat) or fallback
|
|
if target and target in built:
|
|
comp.set_material(index, built[target])
|
|
touched += 1
|
|
if touched:
|
|
assigned += 1
|
|
log(f"materials {actor.get_actor_label()}: {touched}/{len(slots)} slots")
|
|
log(f"assigned look materials to {assigned} facade/background actors")
|
|
|
|
# --- surrounding block frontage ---------------------------------------
|
|
# The other five south-side buildings verified for the block
|
|
# (Geo/verification/2026-11-06-el-capitan-block.md), exported in place from
|
|
# the OSM blockout with a Y mirror so they sit on the south side with the
|
|
# hero. The 6840 site itself is the hand-modelled SM_HeroFacade above.
|
|
FRONTAGE = (
|
|
("SM_Hollywood_6922", "6922"),
|
|
("SM_Hollywood_6902", "6902"),
|
|
("SM_ElCapitanTheatre", "6838 El Capitan Theatre"),
|
|
("SM_Budget_6822", "6822 Budget"),
|
|
("SM_Hollywood_6806", "6806-6820"),
|
|
("SM_LeeDrug_6800", "6800 Lee Drug"),
|
|
)
|
|
staged_frontage = 0
|
|
for asset_name, label in FRONTAGE:
|
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}")
|
|
if mesh is None:
|
|
log(f"MISSING frontage {asset_name} — run Tools/unreal/ue_import_block_frontage.py")
|
|
continue
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"Frontage_{asset_name}")
|
|
if actor:
|
|
comp = actor.static_mesh_component
|
|
comp.set_static_mesh(mesh)
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
# The two theatre buildings read as stone; the retail run as stucco.
|
|
mat_name = "M_FacadeStone" if "Theatre" in asset_name else "M_Stucco"
|
|
if mat_name in built:
|
|
comp.set_material(0, built[mat_name])
|
|
staged_frontage += 1
|
|
log(f"staged {staged_frontage} frontage buildings")
|
|
|
|
# --- retail frontage trim (0.1.6) -------------------------------------
|
|
# The two retail buildings beside the hero are modelled from their own
|
|
# photograph (Art/reference/retail-neighbours/); the trim is laid over the
|
|
# blockout volume, so it keeps the trim's own sampled materials rather than a
|
|
# look-dev override. 6822 Budget has no photograph and stays a bare volume.
|
|
for asset_name, label in (("SM_Retail_LeeDrug", "6800 Lee Drug trim"),
|
|
("SM_Retail_6806", "6806-6820 trim")):
|
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Frontage/{asset_name}")
|
|
if mesh is None:
|
|
log(f"MISSING trim {asset_name} — run Tools/unreal/ue_import_retail_detail.py")
|
|
continue
|
|
# RetailTrim_ rather than Frontage_ so the kit assembler leaves it alone.
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(0, 0, 0), f"RetailTrim_{asset_name}")
|
|
if actor:
|
|
comp = actor.static_mesh_component
|
|
comp.set_static_mesh(mesh)
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
# Map the trim's own slots onto the look-dev materials where the surface
|
|
# type matches, so the walls sit in the same noise/texture family as the
|
|
# rest of the block. The trim's colour slots (bronze, burgundy, mural,
|
|
# lettering) stay as authored: those tones came from the photograph.
|
|
TRIM_LOOK = {"M_Retail_Stucco": "M_Stucco", "M_Retail_White": "M_Stucco",
|
|
"M_Retail_Glass": "M_FacadeGlass"}
|
|
mapped = 0
|
|
for index, slot in enumerate(mesh.get_editor_property("static_materials")):
|
|
target = TRIM_LOOK.get(str(slot.get_editor_property("material_slot_name")))
|
|
if target and target in built:
|
|
comp.set_material(index, built[target])
|
|
mapped += 1
|
|
log(f"staged: {label} ({mapped} slot(s) onto look materials)")
|
|
|
|
# --- player start: north sidewalk, facing the hero frontage -------------
|
|
# Signs were measured from the imported meshes rather than assumed: the FBX
|
|
# preset flips north, so in Unreal the road spans Y -1382..+1047, the NORTH
|
|
# sidewalk is at Y -1912..-1053 and the SOUTH sidewalk at Y +718..+1447.
|
|
# The hero facade is on the SOUTH side at Y +1307. Stand on the north
|
|
# sidewalk and face south (+Y, yaw 90) across the road at it.
|
|
# CRITICAL: the terrain is only carved inside the street corridor, and
|
|
# terrain_from_dem.py --corridor 10.7 is a HALF-width around the road
|
|
# centreline (Y ~= -167), so the cut band is Y -1237..+902. A PlayerStart at
|
|
# Y -1700 sits OUTSIDE it, under ~8 m of uncut terrain: the capsule spawns
|
|
# embedded, depenetration pops it to Z ~909 (terrain top 819 + half-height
|
|
# 87.5) and it never touches the sidewalk. Y -1150 is on the north sidewalk
|
|
# (Y -1912..-1053) AND inside the cut, so the pavement is the ground.
|
|
# Z 600 is clear air; the capsule drops onto the pavement and settles.
|
|
# Measured, not guessed: the QA self-test reports the capsule settling at Z 561 on this
|
|
# pavement (capsule half height 88), so 561 puts the soles exactly on the surface. The
|
|
# previous Z 600 started 39 cm in the air and read as floating.
|
|
# Measured from the staged geometry: Demo_Sidewalk_North spans Y -1912..-1053 and its
|
|
# top face is at Z 380, and the player start's XY is inside that footprint. The capsule
|
|
# centre belongs at 380 + half height 88 + 1 = 469. Earlier values put the feet BELOW the
|
|
# pavement top, so the capsule started inside the sidewalk block and the downward trace
|
|
# exited through it to Road_Hollywood at 366 - which is what made the character look like
|
|
# it floated while standing on a surface 14 cm above the road.
|
|
player_z = 400.0
|
|
# The invisible sidewalk collision slab is retired: the site export now slices the
|
|
# sidewalks into 20 m pieces, so the walkable surface is real geometry with an honest
|
|
# local height instead of a 279 m slab whose bounding box was the highest point anywhere.
|
|
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1450.0, player_z), "Demo_PlayerStart",
|
|
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
|
|
log("staged: Demo_PlayerStart on the cut north sidewalk, facing south (+Y)")
|
|
|
|
# --- vehicle ---------------------------------------------------------
|
|
# The physics pawn and the visible panels are staged as two things on purpose: the
|
|
# probe showed the meshes render as plain StaticMeshActors while the same meshes
|
|
# attached to the vehicle pawn stay invisible, so the visuals do not depend on the
|
|
# pawn until that is solved. A visible car that does not drive yet beats an invisible
|
|
# one that does.
|
|
# The hot-hatch template, normalised to 4.05 m and split per the vehicle-artist
|
|
# object split. Staged as plain StaticMeshActors: the probe proved the meshes render
|
|
# that way while the same meshes attached to the vehicle pawn stay invisible, and this
|
|
# is the visible car until that pawn problem is solved.
|
|
# Same source: the vehicle's own downward trace in game reports the road under it at
|
|
# Z 366, and the model is grounded in Blender (wheel bottoms at Z 0), so the panels sit
|
|
# at 367 - one centimetre of clearance rather than a visible gap.
|
|
# Parked on the same sidewalk surface the player stands on (158 cm at this X). The road sits
|
|
# ~2 m ABOVE the sidewalk in the shipped site geometry - a data defect recorded in TODO.md -
|
|
# so a car on the carriageway looks like it hovers over the pavement beside it. Parking it on
|
|
# the pavement keeps the two objects on one surface until that geometry is fixed.
|
|
vehicle_origin = unreal.Vector(-12950.0, -1450.0, 158.0)
|
|
vehicle_rotation = unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0)
|
|
|
|
panel_systems = (
|
|
"SM_Vehicle_Body",
|
|
"SM_Vehicle_Trim",
|
|
"SM_Vehicle_Glass",
|
|
"SM_Vehicle_Lights",
|
|
"SM_Vehicle_Wheels",
|
|
)
|
|
staged_panels = 0
|
|
for index, mesh_name in enumerate(panel_systems):
|
|
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}")
|
|
if not mesh:
|
|
log(f"vehicle panel {mesh_name}: MISSING")
|
|
continue
|
|
actor = spawn(unreal.StaticMeshActor, vehicle_origin,
|
|
f"Demo_VehiclePanel_{index:02d}", vehicle_rotation)
|
|
if actor:
|
|
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
|
if comp:
|
|
comp.set_static_mesh(mesh)
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
# The body imports with no usable material (its slot resolves to
|
|
# WorldGridMaterial), so the paint is assigned on the staged panel.
|
|
if mesh_name == "SM_Vehicle_Body":
|
|
paint = unreal.EditorAssetLibrary.load_asset(
|
|
"/Game/Hollywood/Vehicle/M_Vehicle_HatchPaint")
|
|
if paint:
|
|
comp.set_material(0, paint)
|
|
log("Demo_VehiclePanel: body paint assigned")
|
|
# Visual only: with collision the panels overlap the Chaos pawn's chassis body
|
|
# and the depenetration launches the whole vehicle upward.
|
|
if comp:
|
|
comp.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION)
|
|
staged_panels += 1
|
|
log(f"staged: {staged_panels} Demo_VehiclePanel actors (plain static meshes)")
|
|
|
|
# The Chaos pawn stays parked. Three hypotheses are now ruled out - spawning level with the
|
|
# road, spawning 1.2 m above it, and the visual panels' collision - and the actor still climbs to
|
|
# Z ~2066 cm with zero wheels. See the 'Vehicle physics - Chaos rig' task in TODO.md.
|
|
log("staged: Demo_Vehicle_Sedan skipped - Chaos pawn parked (see TODO.md)")
|
|
|
|
# A lockable LFS .umap checks out read-only, which makes save_map silently
|
|
# return False — chmod u+w first or the staged actors never persist.
|
|
umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap")
|
|
if os.path.exists(umap) and not os.access(umap, os.W_OK):
|
|
os.chmod(umap, 0o644)
|
|
log(f"map writable: chmod u+w {umap}")
|
|
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
|
unreal.EditorLevelLibrary.get_editor_world(), MAP)
|
|
log(f"save_map {MAP}: {saved}")
|
|
|
|
|
|
try:
|
|
build()
|
|
except Exception as exc: # noqa: BLE001
|
|
import traceback
|
|
log(f"FAILED: {type(exc).__name__}: {exc}")
|
|
log(traceback.format_exc())
|
|
|
|
with open(RESULT, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|