Models the two retail buildings beside the hero from their own photograph rather than the generic kit: bronze cornice, mural row, lettering band, stepped Deco parapet and rounded corner tower on 6800 Lee Drug; burgundy fascia, pilasters, white-framed windows and banded parapet on 6806-6820. Reference analysis written before any geometry (Art/blender/retail/ref-analysis.md), every unmeasured proportion carried into the part report as an assumption. 6822 Budget is deferred, not guessed: no photograph of it exists and the 6.0 x 5.5 m sliver cannot be read out of the neighbour shot. Pipeline fixes found on the way: - render_screenshots.sh called ./Engine/... for the capture step while every commandlet used the absolute path; set -e killed the script at 127 *after* the maps were rebuilt, so screenshots silently stayed a day old. - the facade kit applies to every Frontage_*/North_* actor, so it pasted its own language over the new modelled facades; it now skips them, and the trim stages as RetailTrim_*. - pure bay arithmetic moved to Tools/_common.py so it is unit-testable without Blender (4 new tests, 82 total pass). Known gaps recorded in Screenshots/README.md, not hidden: the parapet reads as a blank band, murals and lettering do not resolve at review distance, the tower is a plain cylinder, and the review camera crops the east end.
657 lines
32 KiB
Python
657 lines
32 KiB
Python
# Build /Game/Hollywood/DemoMap: the walkable demo level.
|
|
#
|
|
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
|
# -script=<repo>/Tools/ue_build_demo_map.py -nullrhi -unattended -nopause -stdout
|
|
#
|
|
# Content this map expects (built by earlier steps):
|
|
# /Game/Hollywood/ElCapitan/SM_HeroFacade Tools/ue_hero_facade_import.py
|
|
# /Game/Hollywood/Site/... Tools/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__), ".."))
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
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(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 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
|
|
|
|
removed = clear(("Demo_", "PlayerStart", "Hero_Facade", "Frontage_", "ShotSequencer"))
|
|
log(f"cleared {removed} previous demo actors")
|
|
|
|
# --- 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=-62.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 -62, yaw 120, intensity 4, warm; higher sun so both frontages read)")
|
|
|
|
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/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.62, 0.58, 0.50), bright=(0.76, 0.72, 0.64),
|
|
roughness=0.70, specular=0.32, noise_scale=0.005,
|
|
courses_m=0.55, joint_width=0.11,
|
|
joint_tint=(0.40, 0.37, 0.32), roughness_variation=0.12,
|
|
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.40, 0.37, 0.32), bright=(0.52, 0.49, 0.43),
|
|
roughness=0.78, specular=0.26, noise_scale=0.005,
|
|
courses_m=0.55, joint_width=0.11,
|
|
joint_tint=(0.28, 0.26, 0.23),
|
|
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),
|
|
}
|
|
SURFACE_MATERIALS = {
|
|
"Terrain_DEM": "M_Ground",
|
|
"Road_Hollywood": "M_Asphalt",
|
|
"Sidewalk_South": "M_Concrete",
|
|
"Sidewalk_North": "M_Concrete",
|
|
"Kerb_South": "M_KerbConcrete",
|
|
"Kerb_North": "M_KerbConcrete",
|
|
"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)
|
|
|
|
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.
|
|
if "Terrain" 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:
|
|
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/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/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)
|
|
log(f"staged: {label}")
|
|
|
|
# --- 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.
|
|
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1150.0, 600.0), "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)")
|
|
|
|
# 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")
|