archviz rule: an elevation must not lean. hero-facade-front looked up ~4 deg from 4.2 m at a 7.6 m target (columns converged); demo-spawn-view and demo-street-level were pitched too. All three now sit at the target's height, so pitch and roll are zero by construction and the verticals stay parallel. Screenshots re-rendered. A pixel-based verticality check is useless here (banners and windows break naive edge matching), so the guarantee is geometric and is recorded that way in the README and MEMORY.
326 lines
17 KiB
Python
326 lines
17 KiB
Python
# Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a
|
|
# map asset. Run before the screenshot pass (Tools/render_screenshots.sh does
|
|
# this automatically).
|
|
#
|
|
# Scene: metric floor, key light, fill sky, sky atmosphere, MPFB character,
|
|
# the three 1 m scale cubes, and an AGigLineShotActor with the camera list.
|
|
#
|
|
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/ue_build_review_map.py \
|
|
# -nullrhi -unattended -nopause -stdout
|
|
#
|
|
# Do NOT load_map here: after a map load the commandlet's spawn context dies
|
|
# (every spawn_actor call returns None). Everything is spawned into the
|
|
# default world and saved as a new map asset.
|
|
|
|
import os
|
|
|
|
import unreal
|
|
|
|
RESULT = "/tmp/ue-review-map-result.txt"
|
|
MAP_PATH = "/Game/Test/ReviewMap"
|
|
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))
|
|
|
|
|
|
def ensure_writable(path):
|
|
# A packed .umap left read-only (git sets 644->444 on checkout, or the
|
|
# editor re-flags it) makes save_map silently return False with no error.
|
|
# chmod u+w it so the staged camera list and actors actually persist.
|
|
p = os.path.join(unreal.Paths.project_dir(), path.replace("/Game/", "Content/") + ".umap")
|
|
try:
|
|
mode = os.stat(p).st_mode & 0o777
|
|
if mode & 0o200 == 0:
|
|
os.chmod(p, 0o644)
|
|
log(f"map writable: chmod u+w {p} (was {oct(mode)})")
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def spawn(cls, loc, label=None, scale=None, rot=None):
|
|
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
|
|
if a is None:
|
|
log(f"spawn {cls.__name__} returned None")
|
|
return None
|
|
if rot is not None:
|
|
a.set_actor_rotation(rot, False)
|
|
if scale is not None:
|
|
a.set_actor_scale3d(scale)
|
|
if label:
|
|
a.set_actor_label(label)
|
|
return a
|
|
|
|
|
|
# --- scene -------------------------------------------------------------------
|
|
|
|
try:
|
|
# Neutral gray floor material (the scale-test mesh ships red)
|
|
floor_mat = None
|
|
if not unreal.EditorAssetLibrary.does_asset_exist("/Game/Test/M_ReviewFloor"):
|
|
floor_mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
|
"M_ReviewFloor", "/Game/Test", unreal.Material, unreal.MaterialFactoryNew())
|
|
n = unreal.MaterialEditingLibrary.create_material_expression(
|
|
floor_mat, unreal.MaterialExpressionConstant3Vector)
|
|
n.set_editor_property("constant", unreal.LinearColor(0.35, 0.35, 0.35, 1.0))
|
|
unreal.MaterialEditingLibrary.connect_material_property(
|
|
n, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
|
unreal.MaterialEditingLibrary.recompile_material(floor_mat)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(floor_mat)
|
|
else:
|
|
floor_mat = unreal.EditorAssetLibrary.load_asset("/Game/Test/M_ReviewFloor")
|
|
|
|
floor_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/ScaleTest/ScaleTest_A")
|
|
if floor_mesh and floor_mat:
|
|
# 1 m reference cube scaled to 90 m x 90 m x 0.1 m, top face at Z 0. The
|
|
# scale is applied on the actor, so Z is -5 cm (+/- 5 cm around centre).
|
|
# Covers the review scene AND the hero blockout 100 m away, so facade shots
|
|
# no longer end at the black horizon band (160 m square, top face at Z 0).
|
|
floor = spawn(unreal.StaticMeshActor, unreal.Vector(-5000, 450, -5.0), "ReviewFloor",
|
|
scale=(160.0, 160.0, 0.1))
|
|
if floor:
|
|
floor.static_mesh_component.set_static_mesh(floor_mesh)
|
|
floor.static_mesh_component.set_material(0, floor_mat)
|
|
log("staged: floor (160 m square, gray, top face Z 0)")
|
|
else:
|
|
log("floor spawn failed")
|
|
|
|
# Lights are Movable and the project disables static lighting
|
|
# (r.AllowStaticLighting=False), so no baked lighting build is needed and
|
|
# the editor stops asking to rebuild lighting.
|
|
sun = spawn(unreal.DirectionalLight, unreal.Vector(0, 0, 900), "RenderKeyLight",
|
|
rot=unreal.Rotator(pitch=-38.0, yaw=35.0, roll=0.0))
|
|
if sun:
|
|
comp = sun.get_component_by_class(unreal.DirectionalLightComponent)
|
|
if comp:
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"key light mobility not set: {exc!r}")
|
|
log("staged: DirectionalLight (movable)")
|
|
sky = spawn(unreal.SkyLight, unreal.Vector(0, 0, 1200), "RenderFillSky")
|
|
if sky:
|
|
comp = sky.get_component_by_class(unreal.SkyLightComponent)
|
|
if comp:
|
|
try:
|
|
comp.set_mobility(unreal.ComponentMobility.MOVABLE)
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"fill sky mobility not set: {exc!r}")
|
|
log("staged: SkyLight (movable)")
|
|
if spawn(unreal.SkyAtmosphere, unreal.Vector(0, 0, 0), "RenderSky"):
|
|
log("staged: SkyAtmosphere")
|
|
|
|
char_mesh = unreal.EditorAssetLibrary.load_asset("/Game/Test/Character/character")
|
|
if char_mesh:
|
|
ch = spawn(unreal.SkeletalMeshActor, unreal.Vector(0, 0, 0), "ReviewCharacter",
|
|
rot=unreal.Rotator(pitch=0.0, yaw=180.0, roll=0.0))
|
|
if ch:
|
|
comp = ch.skeletal_mesh_component
|
|
comp.set_skinned_asset_and_update(char_mesh)
|
|
# Root at the soles, not the world origin: the composes put feet on the
|
|
# floor's top face (Z = 0). If the skeletal root is not at the soles the
|
|
# character would float or sink, so report the bounds for the review.
|
|
if hasattr(ch, "set_actor_location"):
|
|
ch.set_actor_location(unreal.Vector(0, 0, 0), False, False)
|
|
mn, mx = (None, None)
|
|
try:
|
|
_origin, _box = ch.get_actor_bounds(False)
|
|
log(f"staged: MPFB character at origin, bounds z {_box.z:.1f} cm")
|
|
except Exception as exc: # noqa: BLE001
|
|
log(f"character bounds unavailable: {exc!r}")
|
|
log("staged: MPFB character at origin, facing -Y")
|
|
else:
|
|
log("MISSING: /Game/Test/Character/character")
|
|
|
|
# Two cubes only, on purpose: one resting on the floor gives the ground-contact
|
|
# and scale reference, one held at 2 m gives a clean cast shadow to compare
|
|
# against. Their positions are chosen so both are in the overview frame and
|
|
# neither overlaps the character.
|
|
cube_specs = (
|
|
("ScaleTest_A", -250.0, 600.0, 50.0, "on the ground: base at Z 0"),
|
|
("ScaleTest_B", 250.0, 600.0, 250.0, "floating at 2 m: casts a separation shadow"),
|
|
)
|
|
for name, x, y, z, why in cube_specs:
|
|
m = unreal.EditorAssetLibrary.load_asset(f"/Game/Test/ScaleTest/{name}")
|
|
if m:
|
|
c = spawn(unreal.StaticMeshActor, unreal.Vector(x, y, z), f"Cube_{name}")
|
|
if c:
|
|
c.static_mesh_component.set_static_mesh(m)
|
|
log(f"staged: {name} at ({x}, {y}, {z}) - {why}")
|
|
|
|
# Hero facade, if the import has run: two views a reviewer needs — straight on
|
|
# from the north sidewalk, and the oblique street view from further west.
|
|
# Hero placement in Unreal centimetres (see Geo/manifests/local-origin.md):
|
|
# front plane on the building line, body behind it.
|
|
# Corrected 2026-11-07 with the block frontage: UE south is +Y (south
|
|
# sidewalk Y +718..+1447) and 6840 occupies the frontage gap X -14591..-12082.
|
|
hero_x = -13336.0
|
|
hero_y = 1307.0
|
|
hero = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/ElCapitan/SM_HeroFacade")
|
|
# The imported mesh spans Y -7..+1250 cm in local space (front plane at -7, the
|
|
# body behind it) and is centred on X, so the actor sits at building line + 7 cm.
|
|
if hero:
|
|
actor = spawn(unreal.StaticMeshActor, unreal.Vector(hero_x, hero_y, 0.0), "Hero_Facade")
|
|
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 measured building line")
|
|
else:
|
|
log("MISSING: /Game/Hollywood/ElCapitan/SM_HeroFacade (run Tools/ue_hero_facade_import.py)")
|
|
|
|
# --- player pawn ---------------------------------------------------------
|
|
# The review rounds must show the pawn the game actually spawns, not only the
|
|
# mannequin. Spawn the real character class so its capsule, camera height and
|
|
# ground contact are what gets rendered.
|
|
pawn = None
|
|
char_class = unreal.load_class(None, "/Script/GigLine.GigLineCharacter")
|
|
if char_class:
|
|
pawn = spawn(char_class, unreal.Vector(0, 0, 90), "PlayerPawn")
|
|
if pawn:
|
|
log("staged: PlayerPawn (AGigLineCharacter) at the spawn point")
|
|
else:
|
|
log("MISSING: /Script/GigLine.GigLineCharacter")
|
|
|
|
# --- NPC prefab -----------------------------------------------------------
|
|
# The reusable NPC person: a collision capsule + visible MPFB body, standing
|
|
# feet on Z 0, placed clear of the pawn so the prefab reads on its own.
|
|
npc = None
|
|
npc_class = unreal.load_class(None, "/Script/GigLine.GigLineNPCPerson")
|
|
if npc_class:
|
|
npc = spawn(npc_class, unreal.Vector(-4500, 300, 0), "NPC_Person")
|
|
if npc:
|
|
log("staged: NPC_Person (AGigLineNPCPerson) at Z 0")
|
|
else:
|
|
log("MISSING: /Script/GigLine.GigLineNPCPerson")
|
|
|
|
# --- camera list ---------------------------------------------------------
|
|
# Every camera is a (name, location, target) triple; the look-at rotation is
|
|
# computed from the pair so a camera cannot drift out of aim when one number
|
|
# changes. Keep retired shots out of this list entirely.
|
|
cams = [
|
|
# wide scene: character, both cubes, floor
|
|
("overview", unreal.Vector(700, -900, 620), unreal.Vector(0, 300, 80)),
|
|
# the MPFB mannequin, mid shot
|
|
("character", unreal.Vector(200, -300, 160), unreal.Vector(0, 0, 85)),
|
|
# both cubes from the south: contact shadow vs cast shadow
|
|
("scale-cubes", unreal.Vector(0, -900, 200), unreal.Vector(0, 600, 80)),
|
|
# the pawn the game spawns, with the grounded cube beside it
|
|
("player-placement", unreal.Vector(320, -520, 140), unreal.Vector(0, 0, 80)),
|
|
# the NPC prefab: capsule + visible body, feet on Z 0, at double scale
|
|
# distance because it stands off to the west of the main staging
|
|
("npc-prefab", unreal.Vector(-4200, 600, 160), unreal.Vector(-4500, 300, 90)),
|
|
# low, close on feet and cube base: ground contact must be obvious
|
|
("ground-contact", unreal.Vector(180, -260, 35), unreal.Vector(0, 40, 20)),
|
|
# shadow comparison, low angle so both shadows read
|
|
("shadow-compare", unreal.Vector(-650, -700, 120), unreal.Vector(0, 600, 60)),
|
|
# the three entry portals at street level
|
|
# street level, aimed at the base of the centre portal: the portals sit at
|
|
# Z 0..340 cm behind a 145 cm plinth, so the camera looks slightly down and
|
|
# targets the portal sill rather than the centre of the elevation.
|
|
# Standing 25 m out at eye height frames the whole elevation, which is what
|
|
# the portal detail needs: the portals are small against a 14.5 m façade.
|
|
# On the pavement in front of the flight: a pedestrian's own eye line, the
|
|
# angle the photographs that established the portals were taken from.
|
|
# eye height at 8 m: fills the frame with the three doorways, the flights and
|
|
# the plinth, which is what "portal detail" means here.
|
|
("hero-portals", unreal.Vector(hero_x + 100, -2250, 170), unreal.Vector(hero_x + 100, hero_y, 230)),
|
|
# straight on from the north sidewalk; the building body spans Y -1443..+250
|
|
# Level horizon: camera height == target height, so the elevation's
|
|
# verticals stay parallel. The old shot looked up ~4 deg from 4.2 m at a
|
|
# 7.6 m target, which converged the columns — the one thing an
|
|
# architectural elevation must not do (archviz: "straight verticals").
|
|
("hero-facade-front", unreal.Vector(hero_x + 200, -3200, 760), unreal.Vector(hero_x, hero_y + 400, 760)),
|
|
# oblique from the north-west, reading the colonnade in depth
|
|
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
|
|
]
|
|
# --- review shot cameras + save -------------------------------------------
|
|
# Order matters: the review scene is staged in the default (unsaved) world.
|
|
# Save it as ReviewMap BEFORE touching DemoMap — switching back to a map
|
|
# that only exists in memory fails silently, and the final save would then
|
|
# write the demo world over ReviewMap (this exact corruption happened once).
|
|
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
|
|
if shots:
|
|
shots.set_editor_property("shot_locations", [c[1] for c in cams])
|
|
shots.set_editor_property(
|
|
"shot_rotations",
|
|
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
|
|
shots.set_editor_property("shot_names", [c[0] for c in cams])
|
|
shots.set_editor_property("quit_when_done", True)
|
|
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
|
|
|
|
ensure_writable(MAP_PATH)
|
|
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
|
unreal.EditorLevelLibrary.get_editor_world(), MAP_PATH)
|
|
log(f"save_map {MAP_PATH}: {saved}")
|
|
|
|
# --- demo map views: what the player actually sees ----------------------
|
|
demo = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/DemoMap")
|
|
if demo and unreal.EditorAssetLibrary.does_asset_exist("/Game/Hollywood/DemoMap"):
|
|
# The demo map has terrain, street, sidewalks and the facade; the review
|
|
# floor does not. Load it, stage the shot cameras, save. No switch back:
|
|
# the review map is already saved, and the run ends here.
|
|
switch_level("/Game/Hollywood/DemoMap")
|
|
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
|
if actor.get_actor_label() == "ShotSequencer":
|
|
unreal.EditorLevelLibrary.destroy_actor(actor)
|
|
# Unreal signs, measured from the imported site meshes: north sidewalk at
|
|
# Y -1912..-1053, road -1382..+1047, south sidewalk +718..+1447, facade at
|
|
# Y -1450. So "opposite the facade" is Y -1650 and the view faces -Y.
|
|
demo_shots = [
|
|
# 14 m back so the 14.5 m elevation fits: at 2 m the camera sees only
|
|
# the colonnade's underside.
|
|
# Level horizon for the same reason: a block elevation is read by its
|
|
# verticals, and a pitched camera makes them lean.
|
|
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620)),
|
|
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
|
# inside the facade body (which spans Y -1457..-200), so it looked at
|
|
# the unlit back face. Y -2600 is on the sidewalk in front of the lit
|
|
# elevation.
|
|
("demo-street-level", unreal.Vector(-13420, -1650, 170), unreal.Vector(-13336, 1300, 170)),
|
|
# The designated mid-block crossing. Street arc length runs from the
|
|
# west end (s=0 at X -27966 cm), so s=130.1 m is UE X -14956, not the
|
|
# -24291 the old camera used (that is the s=36.6 crossing). Camera sits
|
|
# on the north sidewalk looking along the crosswalk itself, which is
|
|
# the pedestrian's view and shows the zebra bars as bars.
|
|
("demo-crossing", unreal.Vector(-15250, -2500, 400), unreal.Vector(-14956, 400, 150)),
|
|
# the two north-side background buildings (TCL, Tussaud's) seen from
|
|
# the south across Hollywood Blvd. Probed world-space (2026-11-08):
|
|
# TCL bbox X -22889..-18442, Y -9431..-1802; Tussaud's X -27071..
|
|
# -22799, Y -7586..-1888. +Y = south, -Y = north (axis mapping). So
|
|
# stand SOUTH of the road (Y +1600) at the combined mass centre
|
|
# (X -22750) and look NORTH toward the mass. The old camera was at
|
|
# X -22800 Y +2600 aiming at its own X, giving an 85%-sky frame.
|
|
("demo-north-buildings", unreal.Vector(-22750, 1600, 500), unreal.Vector(-22750, -5200, 350)),
|
|
]
|
|
seq = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
|
|
if seq:
|
|
seq.set_editor_property("shot_locations", [c[1] for c in demo_shots])
|
|
seq.set_editor_property("shot_rotations",
|
|
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in demo_shots])
|
|
seq.set_editor_property("shot_names", [c[0] for c in demo_shots])
|
|
# quit after the demo shots so a dedicated DemoMap capture run
|
|
# terminates cleanly (render_screenshots.sh runs both maps).
|
|
seq.set_editor_property("quit_when_done", True)
|
|
ensure_writable("/Game/Hollywood/DemoMap")
|
|
saved = unreal.EditorLoadingAndSavingUtils.save_map(
|
|
unreal.EditorLevelLibrary.get_editor_world(), "/Game/Hollywood/DemoMap")
|
|
log(f"demo map staged: {len(demo_shots)} cameras, save {saved}")
|
|
else:
|
|
log("MISSING: /Game/Hollywood/DemoMap (run Tools/ue_build_demo_map.py)")
|
|
except Exception as exc: # noqa
|
|
log(f"staging failed: {exc!r}")
|
|
|
|
with open(RESULT, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|