Files
gig-line/Tools/ue_build_review_map.py
T
zwitschi dbb177da6f feat(walk): the stride is captured — walking shot 8, traversal check closed
Shot 8 shows the body mid-stride in profile: legs apart, arms counter-swinging. The
traversal release check for 0.2.0 is closed with that evidence.

The reason earlier attempts froze on one pose was not the clip. A probe of the
single-node instance showed the clip advancing normally (assetTime 0.19 -> 1.03,
rate 1.0, playing) while the pawn's speed stayed 0 and the clip stayed Idle: the
call that enables walking sat in the shot sequencer's BeginPlay, which runs before
the player pawn exists, so the cast found nothing and silently did nothing. Moved
into the per-frame follow update, the same run logs walk=1 speed=28 clip=GigLine_Walk
and the stride appears.

Also: the walk expectation was missing from visual_targets.json after the earlier
retirement, so --verify was checking 7 shots while 8 were captured. Restored; 8/8
verify.
2026-11-09 12:18:22 +01:00

356 lines
20 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), 42.0),
# 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), 55.0),
]
# --- 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])
# Optional 4th element: field of view in degrees. 0 keeps the default 90.
# Elevations shot from across the street use a long lens so the building
# fills the frame without pitching the camera (archviz: straight
# verticals, telephoto for elevations).
shots.set_editor_property(
"shot_field_of_view", [float(c[3]) if len(c) > 3 else 0.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-third-person-walk", unreal.Vector(-12940, -980, 560), unreal.Vector(-13336, -980, 500), 55.0),
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
# 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)),
# 0.1.6 retail frontage: 6800 Lee Drug (X -3340..-1417, face Y +1467)
# and 6806-6820 (X -6618..-3297, face Y +1252). Stand on the north
# sidewalk at 2 m, look south with a level horizon so the parapets and
# the cornice line read as horizontals. 68 deg covers both frontages at
# this distance; the ends crop rather than the elevation leaning.
("demo-retail-frontage", unreal.Vector(-4700, -2600, 210), unreal.Vector(-4700, 1400, 620), 66.0),
# 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)),
# 0.2.0 third-person body. Stands where the pawn's own boom camera sits —
# 3.4 m behind the spawn point, 1 m above it — and looks at the pawn, so
# the visible body and its idle clip are what the shot is for.
# Demo_PlayerStart is at (-13336, -1150, 600), yaw 90 (facing +Y).
("demo-third-person", unreal.Vector(-13336, -1750, 780), unreal.Vector(-13336, -1150, 560), 58.0),
# Front view of the same pawn: the third-person shot only ever shows its
# back, so a pose defect there is invisible. The pawn spawns facing +Y
# (yaw 90), so the camera stands on the road side and looks back north.
("demo-third-person-front", unreal.Vector(-13336, -520, 700), unreal.Vector(-13336, -1150, 540), 55.0),
]
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])
# No follow shots in the current list: the stride capture was retired
# because the walk clip does not visibly advance in game (see TODO.md).
# The follow flag stays wired for when it does.
seq.set_editor_property("shot_follow", [True] + [False] * (len(demo_shots) - 1))
seq.set_editor_property(
"shot_field_of_view",
[float(c[3]) if len(c) > 3 else 0.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")