refactor(tools): group the sixty scripts by what they talk to

One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:

  Tools/unreal/    commandlets and the two map builders, with ue_common.py and ue_placement.py as the
                   shared imports (they need the engine)
  Tools/blender/   builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
                   (they need the Blender Flatpak)
  Tools/review/    the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
                   build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
                   visual_targets.json
  Tools/gen/       image and texture generation: the ComfyUI driver, deterministic texture writers,
                   reference fetching
  Tools/input/     OS-level input injection
  root             _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/

Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.

51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.

Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
This commit is contained in:
zwitschi committed 2026-11-10 12:40:30 +01:00
1 parent f681d83736
commit 4d27109668
168 files changed
+799 -407

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Assemble the façade kit onto the block buildings.
Takes the imported kit from `/Game/Hollywood/Kit` and lays it out over a frontage
building's street face: shopfront bays on the ground floor, window bays above,
pilasters on the bay boundaries, a cornice under the parapet and a coping over it.
Layout is driven by the building's own bounding box, so it follows whatever the
blockout gives: the bay count is `round(width / BAY)` and the storey count comes
from the height above the ground floor. Nothing is hand-placed per building.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/unreal/ue_apply_facade_kit.py -nullrhi -unattended -nopause -stdout
# or, as a step inside the demo builder:
from ue_apply_facade_kit import apply_to_actor
Result file: /tmp/gigline/kit-apply.txt
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
KIT = "/Game/Hollywood/Kit"
RESULT = "/tmp/gigline/kit-apply.txt"
# Material per kit piece. Without this the pieces keep the grey material the FBX
# import creates (M_Kit_*), so the glazing is not glass and the shopfronts are not
# stone - the kit reads as a plastic scaffold over the buildings.
KIT_MATERIALS = {
"Kit_WindowGlass": "M_FacadeGlass",
"Kit_ShopGlass": "M_FacadeGlass",
"Kit_Door": "M_FacadeStoneShadow",
"Kit_WindowBay": "M_FacadeStone",
"Kit_ShopFascia": "M_FacadeBanner",
}
KIT_MATERIAL_FALLBACK = "M_FacadeStone"
BAY_CM = 496.0 # the kit module
STOREY_CM = 310.0
SHOP_FLOOR_CM = 310.0 # ground floor is one storey tall
KIT_PREFIX = "Kit_"
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def load_kit() -> dict:
"""Load every kit mesh once, keyed by its short name."""
kit = {}
registry = unreal.AssetRegistryHelpers.get_asset_registry()
filt = unreal.ARFilter(package_paths=[KIT], recursive_paths=False,
class_paths=[unreal.TopLevelAssetPath("/Script/Engine", "StaticMesh")])
for data in registry.get_assets(filt):
mesh = unreal.EditorAssetLibrary.load_asset(str(data.package_name))
if mesh:
# Assets are SM_-prefixed (Unreal convention); the layout code refers to
# pieces by their short name, so key on the name without the prefix.
name = mesh.get_name()
kit[name[3:] if name.startswith("SM_") else name] = mesh
return kit
def _bounds(mesh):
box = mesh.get_bounding_box()
return box.min, box.max
def _spawn(mesh, location, label, material=None, parent_collection=None):
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, location)
if actor is None:
log(f"could not spawn {label}")
return None
actor.set_actor_label(label)
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
comp.set_collision_profile_name("BlockAll")
if material:
comp.set_material(0, material)
return actor
def apply_to_actor(actor, kit, materials=None, prefix=None):
"""Lay the kit out over one building actor's street face.
Returns the number of pieces placed. The building's own mesh stays in place
as the wall the pieces sit on.
"""
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
return 0
# Labels must be unique per building, otherwise two buildings' pieces share a
# label and the outliner (and any label-based lookup) becomes ambiguous.
slug = actor.get_actor_label().replace("Frontage_SM_", "").replace("North_", "").lower()
prefix = prefix or f"Kit_{slug}_"
# WORLD-space bounds. The imported meshes carry a building-line-local offset in
# their own vertices and the demo builder then positions the actor, so the
# asset's bounding box is NOT world space: using it put every kit piece about
# 12 km east of its building, floating in empty ground where nothing could see
# it. get_actor_bounds() includes the actor transform.
origin_w, extent_w = actor.get_actor_bounds(False)
mn = unreal.Vector(origin_w.x - extent_w.x, origin_w.y - extent_w.y, origin_w.z - extent_w.z)
mx = unreal.Vector(origin_w.x + extent_w.x, origin_w.y + extent_w.y, origin_w.z + extent_w.z)
origin = unreal.Vector(0.0, 0.0, 0.0)
width = mx.x - mn.x
height = mx.z - mn.z
if width < BAY_CM * 0.75 or height < STOREY_CM:
log(f"{actor.get_actor_label()}: too small for the kit "
f"({width / 100:.1f} x {height / 100:.1f} m)")
return 0
# Bays: the kit module is one window bay (4.96 m). A very wide building gets a
# bay count that keeps the rhythm readable rather than one window per 4.96 m of
# frontage — 6922 is 65.8 m wide, which is 13 bays, giving a window every 4.96 m
# across a 37 m tall block: the rhythm reads as a wall of glass, not a building.
# Above MAX_BAYS the module is widened instead, so the bay count stays legible.
MAX_BAYS = 9
bays = max(1, int(round(width / BAY_CM)))
if bays > MAX_BAYS:
bays = MAX_BAYS
bay_w = width / bays # actual bay width for this building
# Street face: the frontage runs along X, and the street is at smaller Y.
face_y = mn.y
base_z = mn.z
# Storeys never overrun the volume: cap the count so the top band (cornice +
# parapet, 145 cm) always lands inside the building.
storeys = max(1, int((height - SHOP_FLOOR_CM - 145.0) // STOREY_CM) + 1)
top_z = base_z + height
# Windows above the shopfront, spread over the available height rather than
# assumed to be exactly STOREY_CM apart, so a 37 m block does not leave a gap
# of bare wall under the cornice.
usable = max(0.0, (top_z - 145.0) - (base_z + SHOP_FLOOR_CM))
storey_step = (usable / storeys) if storeys else STOREY_CM
def kit_material(name):
if materials is None:
return None
want = KIT_MATERIALS.get(name, KIT_MATERIAL_FALLBACK)
return materials.get(want)
placed = 0
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
# ground floor: shopfront
for piece, dx, side in (("Kit_ShopPier_L", -bay_w / 2 + 30.0, "L"),
("Kit_ShopPier_R", bay_w / 2 - 30.0, "R")):
if piece in kit:
# The side belongs in the label: both piers of a bay shared one
# label, which left 53 ambiguous pairs in the outliner.
if _spawn(kit[piece], unreal.Vector(cx + dx, face_y, base_z),
f"{prefix}shop_pier_{side}_{bay}", kit_material(piece)):
placed += 1
if "Kit_ShopGlass" in kit:
if _spawn(kit["Kit_ShopGlass"], unreal.Vector(cx, face_y, base_z),
f"{prefix}shop_glass_{bay}", kit_material("Kit_ShopGlass")):
placed += 1
# upper storeys: window bays
for storey in range(storeys):
if "Kit_WindowBay" in kit:
z = base_z + SHOP_FLOOR_CM + storey_step * storey + 65.0
if _spawn(kit["Kit_WindowBay"], unreal.Vector(cx, face_y, z),
f"{prefix}window_{bay}_{storey}", kit_material("Kit_WindowBay")):
placed += 1
# pilaster on the left boundary of every bay
if "Kit_Pilaster" in kit:
if _spawn(kit["Kit_Pilaster"],
unreal.Vector(mn.x + bay_w * bay, face_y, base_z),
f"{prefix}pilaster_{bay}", kit_material("Kit_Pilaster")):
placed += 1
# cornice under the parapet, coping on top
if "Kit_Cornice" in kit:
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
if _spawn(kit["Kit_Cornice"],
unreal.Vector(cx, face_y, base_z + height - 145.0),
f"{prefix}cornice_{bay}", kit_material("Kit_Cornice")):
placed += 1
if "Kit_Parapet" in kit:
for bay in range(bays):
cx = mn.x + bay_w * (bay + 0.5)
if _spawn(kit["Kit_Parapet"],
unreal.Vector(cx, face_y, base_z + height - 90.0),
f"{prefix}parapet_{bay}", kit_material("Kit_Parapet")):
placed += 1
log(f"{actor.get_actor_label()}: {bays} bays x {storeys} storeys, "
f"{width / 100:.1f} x {height / 100:.1f} m -> {placed} pieces")
return placed
def main() -> None:
kit = load_kit()
if not kit:
log(f"no kit meshes under {KIT} — run Tools/unreal/ue_import_facade_kit.py first")
return
log(f"kit meshes: {len(kit)}")
# Site materials built by Tools/unreal/ue_build_demo_map.py.
materials = {}
for name in ("M_FacadeStone", "M_FacadeStoneShadow", "M_FacadeGlass",
"M_FacadeBanner", "M_Stucco", "M_Concrete"):
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
materials[name] = asset
log(f"kit materials resolved: {len(materials)}/6")
level = "/Game/Hollywood/DemoMap"
if not unreal.EditorLevelLibrary.load_level(level):
log(f"could not load {level}")
return
# The map is LFS-tracked and checks out read-only.
content = unreal.Paths.project_content_dir()
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
# Remove a previous application so re-running does not stack duplicates.
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith("Kit_"):
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
log(f"cleared {removed} previous kit actors")
# The kit is the fallback for buildings that have nothing but a blockout. These
# two are modelled from their own photograph in 0.1.6
# (Tools/blender/retail_frontage_build.py); pasting the generic kit over them put a
# second, contradicting facade language on the same wall.
MODELLED = ("Frontage_SM_LeeDrug_6800", "Frontage_SM_Hollywood_6806")
targets = [a for a in unreal.EditorLevelLibrary.get_all_level_actors()
if a.get_actor_label().startswith(("Frontage_", "North_"))
and a.get_actor_label() not in MODELLED]
skipped = [a.get_actor_label() for a in unreal.EditorLevelLibrary.get_all_level_actors()
if a.get_actor_label() in MODELLED]
log(f"frontage actors: {len(targets)} (kit skipped on modelled facades: {skipped})")
total = 0
for actor in targets:
total += apply_to_actor(actor, kit, materials)
log(f"placed {total} kit pieces")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), level)
log(f"save_map {level}: {saved}")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Audit the DemoMap for staged actors that would render wrong.
Checks that are cheap and catch the failures this project actually had:
1. **No unmaterialed actors.** 604 actors once rendered with the default material
because the demo builder deleted and recreated the site materials, orphaning
every reference; the map looked merely "a bit flat".
2. **Placement spans.** Each verified frontage building is probed against
`Tools/unreal/ue_placement.py`; a building 13 m off its footprint still renders fine,
which is exactly why it needs a number, not an eye.
3. **No duplicate actor labels.** Two buildings' kit pieces sharing a label made
the outliner ambiguous and hid one of them.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/unreal/ue_audit_scene.py -nullrhi -unattended -nopause -stdout
Result file: `/tmp/gigline/scene-audit.txt`. Exit code from the commandlet is
always 0, so **read the file** — `visual_loop.py --verify` does not depend on it.
"""
from __future__ import annotations
import collections
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_placement # noqa: E402
MAP = "/Game/Hollywood/DemoMap"
RESULT = "/tmp/gigline/scene-audit.txt"
lines: list[str] = []
failures = 0
def log(message: str) -> None:
lines.append(message)
print(message)
def fail(message: str) -> None:
global failures
failures += 1
log("FAIL " + message)
def main() -> None:
if not unreal.EditorLevelLibrary.load_level(MAP):
log(f"could not load {MAP}")
return
actors = unreal.EditorLevelLibrary.get_all_level_actors()
# 1. materials
unmaterialed = []
for actor in actors:
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if not comp or not comp.static_mesh:
continue
if comp.get_material(0) is None:
unmaterialed.append(actor.get_actor_label())
log(f"mesh actors: {sum(1 for a in actors if a.get_component_by_class(unreal.StaticMeshComponent))}")
if unmaterialed:
fail(f"{len(unmaterialed)} actor(s) with no material, e.g. {unmaterialed[:6]}")
else:
log("OK every staged mesh has a material")
# 2. placement spans
for actor in actors:
label = actor.get_actor_label()
key = None
if label.startswith("Frontage_SM_"):
name = label[len("Frontage_SM_"):]
key = {"Hollywood_6922": "6922", "Hollywood_6902": "6902",
"ElCapitanTheatre": "6838", "Budget_6822": "6822",
"Hollywood_6806": "6806", "LeeDrug_6800": "6800"}.get(name)
elif label == ue_placement.HERO_ACTOR:
key = "hero"
if not key:
continue
origin, extent = actor.get_actor_bounds(False)
lo, hi = origin.x - extent.x, origin.x + extent.x
problem = ue_placement.describe_span_failure(key, lo, hi)
if problem:
fail(problem)
else:
log(f"OK {key}: X {lo:.0f}..{hi:.0f}")
# 3. duplicate labels
counts = collections.Counter(a.get_actor_label() for a in actors)
duplicates = {k: v for k, v in counts.items() if v > 1}
if duplicates:
sample = list(duplicates.items())[:6]
fail(f"{len(duplicates)} duplicated actor label(s), e.g. {sample}")
else:
log("OK no duplicate actor labels")
log(f"== {failures} failure(s) ==")
try:
main()
except Exception as exc: # noqa: BLE001 - surface in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+808
View File
@@ -0,0 +1,808 @@
# 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__), "..", ".."))
sys.path.insert(0, os.path.dirname(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_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")
+446
View File
@@ -0,0 +1,446 @@
# Builds /Game/Test/ReviewMap — the visual-review scene — and saves it as a
# map asset. Run before the screenshot pass (Tools/review/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/unreal/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 clear_level():
"""Destroy every actor before staging.
Without this the review map accumulated whatever previous runs and experiments left behind, and the
captures showed a scene nobody staged. The map is rebuilt from empty on every run.
"""
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
try:
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
except Exception: # noqa: BLE001
pass
log(f"cleared {removed} actors before staging")
return removed
def sit_on_floor(actor, floor_z=2.0):
"""Shift an actor so the mesh's lowest point lands on `floor_z`.
Staging heights were hand-typed and every one of them was a little wrong - the mannequin's root
is not at the soles, the scale cube's origin is not at its base - which is what the review keeps
catching. Measuring the bounds and correcting is the only version that stays right when an
asset changes. The default 2 cm keeps soles and tyres on TOP of the floor plane rather than
exactly level with it, where they read as sunk.
"""
try:
origin, box = actor.get_actor_bounds(False)
except Exception: # noqa: BLE001
return None
lowest = origin.z - box.z
loc = actor.get_actor_location()
actor.set_actor_location(unreal.Vector(loc.x, loc.y, loc.z + (floor_z - lowest)), False, False)
return floor_z - lowest
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:
# Start from an empty level: the review map used to keep whatever previous runs left behind, and
# captures showed a scene nobody staged.
clear_level()
# 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.
# Actor bounds on a freshly assigned skeletal mesh read as zero, so the generic
# correction came out at +2 cm while the mannequin sat visibly low. 8 cm was then
# 2 cm too high on review, so it sits at 6.
shift = sit_on_floor(ch, floor_z=6.0)
if shift is not None:
log(f"staged: MPFB character sat on the floor (shift {shift:+.1f} cm)")
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)
if name == "ScaleTest_A":
# The resting cube is the ground-contact reference, so its base must be
# exactly on the floor rather than wherever its origin happens to be.
shift = sit_on_floor(c)
log(f"staged: {name} sat on the floor (shift {shift:+.1f} cm)")
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/unreal/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")
# --- vehicle, from the latest Blender import ------------------------------
# Staged from the same assets the demo map uses, so the review always shows what
# Tools/blender/vehicle_from_template.py most recently produced rather than a copy that can
# drift. The panels are plain static meshes because the vehicle pawn keeps its
# physics only.
vehicle_origin = unreal.Vector(-1600.0, 600.0, 0.0)
vehicle_systems = ("SM_Vehicle_Body", "SM_Vehicle_Trim", "SM_Vehicle_Glass",
"SM_Vehicle_Lights", "SM_Vehicle_Wheels")
staged_vehicle = 0
for index, mesh_name in enumerate(vehicle_systems):
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}")
if not mesh:
log(f"MISSING vehicle system: {mesh_name}")
continue
piece = spawn(unreal.StaticMeshActor, vehicle_origin, f"Review_Vehicle_{index:02d}")
if piece:
piece.static_mesh_component.set_static_mesh(mesh)
piece.static_mesh_component.set_mobility(unreal.ComponentMobility.STATIC)
if mesh_name == "SM_Vehicle_Wheels":
shift = sit_on_floor(piece)
log(f"staged: vehicle wheels sat on the floor (shift {shift:+.1f} cm)")
if mesh_name == "SM_Vehicle_Body":
paint = unreal.EditorAssetLibrary.load_asset(
"/Game/Hollywood/Vehicle/M_Vehicle_HatchPaint")
if paint:
piece.static_mesh_component.set_material(0, paint)
staged_vehicle += 1
log(f"staged: {staged_vehicle} vehicle systems at the staging area")
# --- 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 vehicle staging, three-quarter front, same framing idea as the demo shot
("vehicle", unreal.Vector(-900, -200, 260), unreal.Vector(-1600, 600, 90)),
# ground-level contact check: feet on the floor, shadow at the contact point
("character-feet", unreal.Vector(220, -420, 45), unreal.Vector(0, 0, 25)),
# 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, -1280, 500), unreal.Vector(-13336, -1450, 220), 55.0),
# The sedan, staged on the carriageway at (-12950, -900). Three-quarter front
# view from the road so both the body and the wheels are visible: a side-on
# or rear view hides the wheel stances that the build is judged on.
("demo-vehicle", unreal.Vector(-12560, -2150, 420), unreal.Vector(-12950, -1450, 220), 45.0),
# Ground-level angles on the contact points. The wide shots cannot settle whether
# the feet and wheels touch the surface - that question needs the camera near the
# ground, looking along it, where a gap is a visible sliver of surface.
("demo-feet", unreal.Vector(-13140, -1280, 200), unreal.Vector(-13336, -1450, 170), 40.0),
("demo-wheel", unreal.Vector(-12380, -1050, 300), unreal.Vector(-12950, -1450, 175), 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, -2050, 620), unreal.Vector(-13336, -1450, 250), 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, -820, 620), unreal.Vector(-13336, -1450, 240), 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/unreal/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")
+96
View File
@@ -0,0 +1,96 @@
# Import the character animation clips into Unreal (0.2.0).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/blender/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout
#
# Imports the animated FBX next to the existing character assets and verifies the
# result rather than assuming it: an FBX whose export omitted `bake_anim` imports
# without error and produces a skeletal mesh but no AnimSequence, which is exactly
# the failure this tool must catch.
#
# Destination: /Game/Test/Character. Result file: /tmp/gigline/character-anim-import.txt
import glob
import os
import unreal
SRC = "/tmp/gigline/character-anim.fbx"
DEST = "/Game/Test/Character"
PREFIX = "CharacterV6"
RESULT = "/tmp/gigline/character-anim-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
if not os.path.exists(SRC):
log(f"missing {SRC}; run Tools/blender/character_anim_build.py first")
return
task = unreal.AssetImportTask()
task.filename = SRC
task.destination_path = DEST
task.destination_name = PREFIX
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = True
ui.import_materials = True
ui.import_textures = False
# UE 5.8 has no SKELETON_ONLY content type (FBXICT_ALL/GEOMETRY/SKINNING_WEIGHTS
# only), so the clips come in with the mesh and the verification below decides
# whether that worked. The mesh is replaced at the same path by design.
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
# Verify: the clips are the deliverable, not the mesh.
registry = unreal.AssetRegistryHelpers.get_asset_registry()
found = []
for asset in registry.get_assets_by_path(DEST, recursive=False):
name = str(asset.asset_name)
if not name.startswith(PREFIX):
continue
obj = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if isinstance(obj, unreal.AnimSequence):
length = obj.get_play_length()
# UE 5.8 exposes only play length and sequence length here; the sampled
# frame count is derived so the report still states the clip duration.
frames = round(length * 30.0)
found.append(name)
log(f"AnimSequence {name:34} {length:.3f} s (~{frames} frames at 30 fps)")
elif isinstance(obj, unreal.SkeletalMesh):
skeleton = obj.get_editor_property("skeleton")
log(f"SkeletalMesh {name:34} skeleton={'ok' if skeleton else 'MISSING'}")
if not found:
log("FAILED: no AnimSequence was created — the FBX carried no baked animation")
lines.append("VERDICT: FAIL")
return
log(f"{len(found)} AnimSequence(s) imported: {', '.join(sorted(found))}")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
@@ -0,0 +1,71 @@
# Import the garment meshes as separate skeletal meshes (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/blender/character_clothing_build.py -- --out /tmp/gigline/clothing
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout
#
# Each garment is its own asset, per the character-artist skill's rule that clothing
# stays separate so it can be swapped or re-materialled. The pawn attaches each one as
# a component using the body as the leader pose.
#
# Result file: /tmp/gigline/character-clothing-import.txt
import unreal
DEST = "/Game/Test/Character"
SOURCE = "/tmp/gigline/clothing"
GARMENTS = (("tee", "CharacterTee"), ("jeans", "CharacterJeans"), ("shoes", "CharacterShoes"))
RESULT = "/tmp/gigline/character-clothing-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
for source, name in GARMENTS:
task = unreal.AssetImportTask()
task.filename = f"{SOURCE}/{source}.fbx"
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"{name}: MISSING after import")
lines.append("VERDICT: FAIL")
return
mats = mesh.get_editor_property("materials")
log(f"{name}: imported, {len(mats)} slot(s): "
+ ", ".join((m.get_editor_property("material_interface").get_name()
if m.get_editor_property("material_interface") else "None")
for m in mats))
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+70
View File
@@ -0,0 +1,70 @@
# Import the hair cards as their own skeletal mesh (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/blender/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout
#
# The hair is a separate asset on purpose: Unreal collapses the second material slot
# when hair is joined into the body mesh, so a card system has to be its own object
# (which is what the character-artist skill asks for anyway).
#
# Result file: /tmp/gigline/character-hair-import.txt
import unreal
SRC = "/tmp/gigline/character-hair.fbx"
DEST = "/Game/Test/Character"
NAME = "CharacterHair"
RESULT = "/tmp/gigline/character-hair-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
task = unreal.AssetImportTask()
task.filename = SRC
task.destination_path = DEST
task.destination_name = NAME
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{NAME}")
if mesh is None:
log(f"MISSING {DEST}/{NAME}")
lines.append("VERDICT: FAIL")
return
mats = mesh.get_editor_property("materials")
log(f"{NAME}: {len(mats)} material slot(s)")
for i, m in enumerate(mats):
face = m.get_editor_property("material_interface")
log(f" slot {i}: {face.get_name() if face else 'None'}")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+117
View File
@@ -0,0 +1,117 @@
# Unreal-side half of the MPFB character acceptance: imports the skeletal FBX
# with the named import preset "ue_character_v1" and verifies the result.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/unreal/ue_character_import.py \
# -nullrhi -unattended -nopause -stdout
import unreal
FBX = "/tmp/gigline/character.fbx"
DEST = "/Game/Test/Character"
REPORT = "/tmp/ue-character-result.txt"
lines = []
def log(msg):
lines.append(str(msg))
def import_character():
if unreal.EditorAssetLibrary.does_directory_exist(DEST):
unreal.EditorAssetLibrary.delete_directory(DEST)
task = unreal.AssetImportTask()
task.filename = FBX
task.destination_path = DEST
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = True
ui.import_as_skeletal = True
ui.import_animations = False
smd = ui.skeletal_mesh_import_data
smd.import_uniform_scale = 1.0
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
def import_texture_and_material():
# Interchange 5.8 does not create material/texture assets for skeletal
# FBX imports; finish the baked-material chain explicitly.
task = unreal.AssetImportTask()
task.filename = "/tmp/gigline/T_Character_Diffuse.png"
task.destination_path = DEST
task.automated = True
task.save = True
task.replace_existing = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
tex = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
log(f"texture: {tex.get_class().get_name() if tex else 'MISSING'}")
mat_path = f"{DEST}/M_Character_Export"
if unreal.EditorAssetLibrary.does_asset_exist(mat_path):
unreal.EditorAssetLibrary.delete_asset(mat_path)
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"M_Character_Export", DEST, unreal.Material, unreal.MaterialFactoryNew())
tex_expr = unreal.MaterialEditingLibrary.create_material_expression(
mat, unreal.MaterialExpressionTextureSample)
tex_expr.set_editor_property("texture", tex)
unreal.MaterialEditingLibrary.connect_material_property(
tex_expr, "RGB", unreal.MaterialProperty.MP_BASE_COLOR)
unreal.MaterialEditingLibrary.recompile_material(mat)
sm = unreal.EditorAssetLibrary.load_asset(f"{DEST}/character")
entry = unreal.SkeletalMaterial()
entry.set_editor_property("material_interface", mat)
entry.set_editor_property("material_slot_name", "M_Character_Export")
sm.set_editor_property("materials", [entry])
unreal.EditorAssetLibrary.save_loaded_asset(sm)
log("material assigned to slot 0 (via SkeletalMaterial.material_interface)")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
def verify():
sm = unreal.EditorAssetLibrary.load_asset(f"{DEST}/character")
if sm is None:
log(f"MISSING skeletal asset {DEST}/character; assets: {unreal.EditorAssetLibrary.list_assets(DEST)}")
return
log(f"class: {sm.get_class().get_name()}")
try:
skel = sm.get_editor_property("skeleton")
tree = skel.get_editor_property("bone_tree")
log(f"bones: {len(tree)}")
except Exception as exc: # noqa
log(f"bone count unavailable: {exc!r}")
try:
mats = sm.get_editor_property("materials")
log(f"material slots: {len(mats)} -> {[m.get_editor_property('material_slot_name') for m in mats]}")
except Exception as exc: # noqa
log(f"material slots unavailable: {exc!r}")
try:
bounds = sm.get_editor_property("bounds")
o, e = bounds.origin, bounds.box_extent
log(f"bounds cm: size=({e.x * 2:.1f}, {e.y * 2:.1f}, {e.z * 2:.1f}) origin=({o.x:.1f}, {o.y:.1f}, {o.z:.1f})")
except Exception as exc: # noqa
log(f"bounds unavailable: {exc!r}")
log(f"assets: {unreal.EditorAssetLibrary.list_assets(DEST)}")
# materials actually imported as assets?
for a in unreal.EditorAssetLibrary.list_assets(DEST):
obj = unreal.EditorAssetLibrary.load_asset(a)
log(f" {a} : {obj.get_class().get_name()}")
import_character()
import_texture_and_material()
verify()
with open(REPORT, "w") as f:
f.write("\n".join(lines) + "\n")
+164
View File
@@ -0,0 +1,164 @@
# Wire the generated character textures into Unreal materials (0.2.2).
#
# python3 Tools/gen/generate_character_textures.py --out Art/textures/generated
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_character_materials.py -nullrhi -unattended -nopause -stdout
#
# Why new materials instead of editing the imported ones:
#
# * `unreal.MaterialEditingLibrary` SEGFAULTS in `-run=pythonscript` when it clears
# and rebuilds an existing material graph. The first version of this tool did that
# and crashed the commandlet with no traceback.
# * `unreal.SkeletalMesh` has no `set_material`, so a mesh slot cannot be repointed
# from Python. The pawn therefore assigns the skin material at runtime (see
# AGigLineCharacter) and the hair's existing MaterialInstanceConstant gets a new
# parent, which is a property write rather than a graph edit.
#
# Assets created:
#
# M_Character_Skin diffuse x generated pore detail, roughness 0.55
# M_Character_Hair_Mat strand sheet colour + alpha, masked and two-sided
#
# Result file: /tmp/gigline/character-materials.txt
import os
import unreal
DEST = "/Game/Test/Character"
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
TEXTURE_DIR = os.path.join(_REPO, "Art", "textures", "generated")
RESULT = "/tmp/gigline/character-materials.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_texture(filename):
path = os.path.join(TEXTURE_DIR, filename)
name = os.path.splitext(filename)[0]
existing = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if not os.path.exists(path):
log(f"missing {path}")
return None
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
texture = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
log(f"texture {name}: {'ok' if texture else 'MISSING'}"
+ ("" if existing is None else " (replaced)"))
return texture
def new_material(name):
"""Create an empty material, replacing one from a previous run."""
full = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(full):
unreal.EditorAssetLibrary.delete_asset(full)
tools = unreal.AssetToolsHelpers.get_asset_tools()
material = tools.create_asset(name, DEST, unreal.Material, unreal.MaterialFactoryNew())
# Without this flag Unreal refuses to use the material on a skeletal mesh and
# silently falls back to the default material, which renders near-black here.
material.set_editor_property("used_with_skeletal_mesh", True)
return material
def skin_material(diffuse, detail, unlit_test=False):
material = new_material("M_Character_Skin")
lib = unreal.MaterialEditingLibrary
# Base colour is a constant, not the baked diffuse. Sampling T_Character_Diffuse
# rendered the body black: the MPFB mesh's UVs do not map onto that bake, so every
# lookup fell outside the texture. A constant tone is correct-looking and honest;
# the pore sheet waits until the UVs are verified.
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -420, 0)
# Measured, not guessed: the red test colour reached the render, so the assignment
# was always working - the skin simply reads dark under the demo's key light. This
# tone is lifted to sit in shadow the way the blueprint's skin does.
base.set_editor_property("constant", unreal.LinearColor(0.78, 0.55, 0.44, 1.0))
if unlit_test:
# Diagnostic: unlit renders the constant exactly, so the render tells us whether
# a wrong skin tone comes from lighting/shading or from the material itself.
material.set_editor_property("shading_model", unreal.MaterialShadingModel.MSM_UNLIT)
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_EMISSIVE_COLOR)
else:
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -200, -200)
rough.set_editor_property("r", 0.58)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
log(("M_Character_Skin: UNLIT TEST, emissive " if unlit_test else
"M_Character_Skin: constant skin tone, roughness 0.58 ")
"(baked diffuse not used - it samples black on this mesh)")
return material
def hair_material(strands):
material = new_material("M_Character_Hair_Mat")
lib = unreal.MaterialEditingLibrary
material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_MASKED)
material.set_editor_property("two_sided", True)
sample = lib.create_material_expression(material, unreal.MaterialExpressionTextureSample, -520, 0)
sample.texture = strands
# Alpha only: the strand sheet's RGB is a pale highlight map and using it as the
# base colour made the hair read blond and thin.
lib.connect_material_property(sample, "A", unreal.MaterialProperty.MP_OPACITY_MASK)
colour = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -520, 220)
colour.set_editor_property("constant", unreal.LinearColor(0.030, 0.020, 0.014, 1.0))
lib.connect_material_property(colour, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -220, -180)
rough.set_editor_property("r", 0.40)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
lib.recompile_material(material)
log("M_Character_Hair_Mat: dark constant colour, strand sheet as opacity mask, "
"masked two-sided, roughness 0.40")
return material
def main(unlit_test=False):
diffuse = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
log(f"diffuse: {'ok' if diffuse else 'MISSING'}")
detail = import_texture("T_Character_SkinDetail.png")
strands = import_texture("T_Character_Hair.png")
if diffuse and detail:
skin_material(diffuse, detail, unlit_test=unlit_test)
if strands:
hair = hair_material(strands)
# Repoint the imported instance rather than editing its graph.
instance = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Hair")
if isinstance(instance, unreal.MaterialInstanceConstant):
instance.set_editor_property("parent", hair)
unreal.MaterialEditingLibrary.update_material_instance(instance)
log("M_Character_Hair instance: parent -> M_Character_Hair_Mat")
else:
log(f"M_Character_Hair is {instance.get_class().get_name() if instance else 'missing'}, "
"not an instance - skipped")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
lines.append("VERDICT: PASS")
try:
# Environment, not a command-line flag: the commandlet drops unknown switches, so
# the flag never reached Python. An env var is inherited.
main(unlit_test=os.environ.get("GIGLINE_UNLIT_TEST") == "1")
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+66
View File
@@ -0,0 +1,66 @@
"""Remove the auto-generated simple collision from the site meshes, keeping their triangles.
<engine>/UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=<repo>/Tools/unreal/ue_clear_simple_collision.py -nullrhi -unattended -nopause -stdout
Why: the FBX import runs with `auto_generate_collision`, which adds ONE convex hull per mesh. For a
279 m terrain or road that hull is a giant slab, and any *physics body* inside it - the Chaos vehicle -
is pushed out of the hull every frame. Walked characters never notice, because character movement
sweeps instead of resolving penetration, which is why the player was fine while the car was thrown
into the air.
Clearing the hulls leaves `CTF_USE_COMPLEX_AS_SIMPLE`, so traces and physics use the real triangles.
Result: /tmp/gigline/clear-simple-collision.txt
"""
import unreal
FOLDERS = ("/Game/Hollywood/Site", "/Game/Hollywood/Streets")
RESULT = "/tmp/gigline/clear-simple-collision.txt"
lines = []
def log(message):
lines.append(str(message))
unreal.log(str(message))
def main():
cleared = 0
for folder in FOLDERS:
for path in unreal.EditorAssetLibrary.list_assets(folder, recursive=False):
mesh = unreal.EditorAssetLibrary.load_asset(path)
if not isinstance(mesh, unreal.StaticMesh):
continue
body = mesh.get_editor_property("body_setup")
if body is None:
continue
agg = body.get_editor_property("agg_geom")
convex = len(agg.get_editor_property("convex_elems"))
boxes = len(agg.get_editor_property("box_elems"))
if not convex and not boxes:
continue
agg.set_editor_property("convex_elems", [])
agg.set_editor_property("box_elems", [])
body.set_editor_property("agg_geom", agg)
body.set_editor_property("collision_trace_flag",
unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE)
mesh.set_editor_property("body_setup", body)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
log(f"{mesh.get_name()}: cleared {convex} convex, {boxes} box, complex-as-simple")
cleared += 1
lines.append(f"VERDICT: {'PASS' if cleared else 'NOTHING-TO-DO'}")
log(f"{cleared} meshes cleared")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Small shared helpers for the Unreal commandlet scripts.
These run inside `UnrealEditor-Cmd -run=pythonscript`, where a Python `print()`
is swallowed and a failed save reports `False` rather than raising. Both are traps
that cost time in `0.1.x`, so they live here once instead of in each script.
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common
ue_common.make_map_writable("/run/media/.../Content/Hollywood/DemoMap.umap")
"""
from __future__ import annotations
import os
import stat
def is_writable(path: str) -> bool:
"""True when the current user may write `path` (existing file)."""
return os.access(path, os.W_OK)
def make_writable(path: str) -> str | None:
"""Clear read-only bits on `path` so a save cannot fail silently.
Content is LFS-tracked and `.umap`/`.blend` check out read-only, which makes
`EditorLoadingAndSavingUtils.save_map()` return False with no error — the
staged actors then vanish on the next load. Returns a note when the mode was
changed, `None` when it was already writable, and raises when the file is
missing so a typo cannot masquerade as a permissions problem.
"""
if not os.path.exists(path):
raise FileNotFoundError(path)
mode = os.stat(path).st_mode
if mode & stat.S_IWUSR:
return None
os.chmod(path, mode | stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH |
stat.S_IWGRP)
return f"chmod u+w {path} (was {oct(mode & 0o777)})"
def require_saved(saved: bool, what: str) -> bool:
"""Turn a silent `save_map(False)` into a loud failure.
Use right after a save call: `ue_common.require_saved(saved, LEVEL)`. A
commandlet cannot be relied on to exit non-zero, so the caller should log this
and make the failure obvious in the result file.
"""
return bool(saved)
+188
View File
@@ -0,0 +1,188 @@
# Creates the Enhanced Input assets the first-person character loads:
# /Game/Input/IA_Move Axis2D, WASD + left stick
# /Game/Input/IA_Look Axis2D, mouse XY + right stick
# /Game/Input/IA_Jump axis1d, Space / gamepad face button
# /Game/Input/IMC_Default InputMappingContext holding the three mappings
#
# Run headless (imports/dirty packages are saved before exit):
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_create_input_assets.py -nullrhi -unattended -nopause \
# -stdout -FullStdOutLogOutput
#
# The result goes to /tmp/gigline/input-assets.txt because 5.8's pythonscript
# commandlet swallows print() output with -stdout.
#
# Mapping choices:
# - Look stays unit-free (no scalar modifier): the character applies its own
# degrees-per-second, and a modifier here would double-scale.
# - Move is a 2D axis: W = +Y, S = -Y, D = +X, A = -X via Negate on the Y axis.
# For keyboard keys the Negate modifier's Y axis maps to the axis's Y.
import unreal
ASSET_DIR = "/Game/Input"
_KEEP_ALIVE = []
def negate_y_modifier():
"""A negate-by-Y modifier, built with its Y flag set and X/Z cleared.
Returns the object plus its falsy reference; constructing the default
modifier gives all axes true, so each flag is set explicitly. The caller owns
keep-alive.
"""
neg = unreal.InputModifierNegate()
neg.set_editor_property("x", False)
neg.set_editor_property("y", True)
neg.set_editor_property("z", False)
return neg
RESULT = "/tmp/gigline/input-assets.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def fresh(name, cls, factory):
"""Load the asset if it exists, otherwise create it.
Deleting then recreating in the same commandlet run fails: create_asset
returns None for a path deleted earlier in the process. Overwriting in place
is also what keeps the assets' identities stable across reruns.
"""
path = f"{ASSET_DIR}/{name}"
existing = unreal.EditorAssetLibrary.load_asset(path)
if existing is not None:
return existing
return unreal.AssetToolsHelpers.get_asset_tools().create_asset(
name, ASSET_DIR, cls, factory())
def save(asset):
unreal.EditorAssetLibrary.save_loaded_asset(asset, only_if_is_dirty=False)
def make_key(name):
"""Build an FKey by name.
`unreal.InputCoreTypes` is not exposed to Python in 5.8 and `unreal.Key` does
not accept a constructor argument, but the struct's `import_text` parses the
key's export text, which is just its name.
"""
key = unreal.Key()
key.import_text(name)
return key
def build():
if not unreal.EditorAssetLibrary.does_directory_exist(ASSET_DIR):
unreal.EditorAssetLibrary.make_directory(ASSET_DIR)
# --- actions ---------------------------------------------------------
# Move keys are one-dimensional: W/S carry forward/back on the value's single
# component and D/A carry strafe, combined by the character. A 2D value would
# need per-key axis modifiers, and modifiers do not survive the save path here.
ia_move = fresh("IA_Move", unreal.InputAction, unreal.InputAction_Factory)
# Boolean (pressed = 1), not AXIS1D: the C++ handler reads Get<bool>() and
# moves at full walk speed. An axis action from a keyboard key can deliver 0
# depending on the trigger, which showed up as "character stands, W does not
# move" in QA. WASD are four separate per-direction actions below.
ia_move.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_move)
ia_look = fresh("IA_Look", unreal.InputAction, unreal.InputAction_Factory)
ia_look.set_editor_property("value_type", unreal.InputActionValueType.AXIS2D)
save(ia_look)
ia_jump = fresh("IA_Jump", unreal.InputAction, unreal.InputAction_Factory)
ia_jump.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_jump)
# --- mapping context -------------------------------------------------
imc = fresh("IMC_Default", unreal.InputMappingContext, unreal.InputMappingContext_Factory)
# Nine entries, one per key. No modifiers: the character negates S and A in
# C++ instead, because a modifier object attached to a mapping struct
# serialises as None on save in this build (the struct's object array does not
# keep the modifier alive across the save path). One less moving part, and the
# negation is visible where it is used.
# Two actions feed the one-dimensional move: IA_Move for forward/back, and a
# second action for strafe, both reporting on the value's single component.
ia_strafe = fresh("IA_Strafe", unreal.InputAction, unreal.InputAction_Factory)
ia_strafe.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_strafe)
ia_back = fresh("IA_MoveBack", unreal.InputAction, unreal.InputAction_Factory)
ia_back.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_back)
ia_left = fresh("IA_StrafeLeft", unreal.InputAction, unreal.InputAction_Factory)
ia_left.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_left)
# Reset and pause are boolean: one press, no accumulator.
ia_reset = fresh("IA_Reset", unreal.InputAction, unreal.InputAction_Factory)
ia_reset.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_reset)
ia_debug = fresh("IA_ToggleDebug", unreal.InputAction, unreal.InputAction_Factory)
ia_debug.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_debug)
ia_cam = fresh("IA_ToggleCamera", unreal.InputAction, unreal.InputAction_Factory)
ia_cam.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_cam)
ia_pause = fresh("IA_Pause", unreal.InputAction, unreal.InputAction_Factory)
ia_pause.set_editor_property("value_type", unreal.InputActionValueType.BOOLEAN)
save(ia_pause)
mapping_table = [
(ia_move, "W"), (ia_back, "S"), (ia_strafe, "D"), (ia_left, "A"),
(ia_move, "Gamepad_LeftY"), (ia_strafe, "Gamepad_LeftX"),
(ia_look, "MouseX"), (ia_look, "MouseY"), (ia_look, "Gamepad_Right2D"),
(ia_jump, "SpaceBar"), (ia_jump, "Gamepad_FaceButton_Bottom"),
(ia_reset, "R"), (ia_pause, "Escape"), (ia_cam, "V"), (ia_debug, "F3"),
]
mappings = []
for action, key_name in mapping_table:
try:
entry = unreal.EnhancedActionKeyMapping()
entry.set_editor_property("action", action)
entry.set_editor_property("key", make_key(key_name))
mappings.append(entry)
except Exception as exc: # noqa: BLE001
log(f"skip {action.get_name()}/{key_name}: {exc}")
# UE 5.8: the runtime reads DefaultKeyMappings.Mappings (FInputMappingContextMappingData),
# NOT the deprecated 'mappings' array on the context. The deprecated array is
# what editor UI shows and what the old script wrote, so the cooked game got 0
# runtime mappings (key->action lookup failed -> no input). Populate the new
# struct field that the runtime actually consumes.
default_mapping_data = imc.get_editor_property("default_key_mappings")
if default_mapping_data is None:
default_mapping_data = unreal.InputMappingContextMappingData()
default_mapping_data.set_editor_property("mappings", mappings)
# Mirror into the deprecated array so the editor UI and any legacy reader agree.
imc.set_editor_property("mappings", mappings)
imc.set_editor_property("default_key_mappings", default_mapping_data)
save(imc)
log(f"IA_Move={ia_move.get_path_name()} IA_Look={ia_look.get_path_name()} "
f"IA_Jump={ia_jump.get_path_name()}")
log(f"IMC_Default={imc.get_path_name()} mappings={len(mappings)}")
log("assets=" + ",".join(unreal.EditorAssetLibrary.list_assets(ASSET_DIR)))
try:
build()
except Exception as exc: # noqa: BLE001 - surface the failure in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
with open(RESULT, "w") as handle:
handle.write("\n".join(lines))
+292
View File
@@ -0,0 +1,292 @@
# Imports the hero façade FBX into Unreal with the named preset `ue_metric_v1`
# and places it at the measured site position.
#
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_hero_facade_import.py -nullrhi -unattended -nopause -stdout
#
# Placement follows Geo/manifests/local-origin.md and the blockout frame:
# project X = UTM easting - 376515.389 (east +)
# project Y = -(UTM northing - 3774224.083) in Unreal, because the FBX preset
# maps Blender +Y (north) to Unreal -Y and Blender +X to Unreal +X. The building
# line of the hero block sits at Unreal Y = -1450 cm (14.5 m south of the road
# centreline, from the street profile), and the hero's east edge at
# X = -12024.5 cm (projected control point in local-origin.md).
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
import ue_placement # noqa: E402
FBX = "/tmp/gigline/hero-facade.fbx"
DEST = "/Game/Hollywood/ElCapitan"
ACTOR_LABEL = ue_placement.HERO_ACTOR
LEVEL = "/Game/Hollywood/DemoMap"
# Unreal centimetres: south-east corner of the façade, on the building line.
# Placement lives in Tools/unreal/ue_placement.py. It used to be a literal here, and it
# drifted from ue_build_demo_map.py: this file kept the pre-0.1.0 position
# (-12024.5, -1450) while the builder staged (-13336, 1307), so running the importer
# on its own moved the hero 13 m east and onto the north side of the boulevard.
PLACEMENT_CM = ue_placement.HERO_LOCATION_CM
RESULT = "/tmp/gigline/hero-facade-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
# Generated with Tools/gen/comfyui_generate.py texture (AlbedoBase XL, see the sidecar beside the PNG). The
# reference facade is coursed rubble-ashlar, not a flat tint, and a texture is what carries that at 45 m.
FACADE_ALBEDO = "Art/textures/generated/T_FacadeAshlar_AI.png"
FACADE_ALBEDO_ASSET = "T_FacadeAshlar_AI"
TEXTURE_DIR = "/Game/Hollywood/Textures"
FACADE_TINTS = {
# Linear values, sampled from the reference photograph: the blockout's near-white stone was the one
# thing the judge kept calling out. The FBX import keeps an existing material asset, so the colour has
# to be written into the material after the import - dropping the asset instead broke the mesh's slots.
"M_Facade_Stone": (0.34, 0.29, 0.21),
"M_Facade_StoneShadow": (0.21, 0.175, 0.125),
"M_Facade_GlassDark": (0.02, 0.025, 0.03),
"M_Facade_Banner": (0.42, 0.035, 0.03),
"M_Facade_Grille": (0.06, 0.06, 0.065),
}
def import_albedo():
"""Import the generated ashlar albedo, if it is on disk. Returns the asset or None."""
# unreal.Paths.project_dir() is Game/GigLine, not the repository root: the textures live beside Art/,
# two levels above this file.
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(repo_root, FACADE_ALBEDO)
if not os.path.exists(path):
log(f"albedo missing: {path}")
return None
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = TEXTURE_DIR
task.destination_name = FACADE_ALBEDO_ASSET
task.automated = True
task.save = True
task.replace_existing = True
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
asset = unreal.EditorAssetLibrary.load_asset(f"{TEXTURE_DIR}/{FACADE_ALBEDO_ASSET}")
log(f"albedo imported: {asset.get_path_name() if asset else 'FAILED'}")
return asset
def tint_imported_materials():
"""Author plain facade materials for the imported slots.
The FBX import produces material *instances*, which cannot carry their own base colour, and the
instance's parent keeps the blockout's near-white constant. Instead of fighting that, this creates one
simple material per facade surface with the colour sampled from the reference and assigns it to the
matching slot. That is the step that finally moves the stone off white; a tint attempt that silently
did nothing is what let it survive three rebuilds.
"""
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade")
if mesh is None:
log("no SM_HeroFacade to tint")
return
library = unreal.MaterialEditingLibrary
albedo = import_albedo()
slots = mesh.get_editor_property("static_materials")
assigned = 0
for index, slot in enumerate(slots):
current = slot.get_editor_property("material_interface")
name = current.get_name() if current else ""
base = name.split(".")[0].replace("_T", "")
rgb = FACADE_TINTS.get(base)
if rgb is None:
continue
path = f"{DEST}/{base}_Ref"
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
material = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
base + "_Ref", DEST, unreal.Material, unreal.MaterialFactoryNew())
colour = library.create_material_expression(
material, unreal.MaterialExpressionConstant3Vector, -400, 0)
colour.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
source = colour
if albedo is not None and base in ("M_Facade_Stone", "M_Facade_StoneShadow"):
# Texture sample multiplied by the tint: the map carries the coursing and grain, the tint keeps
# the value in the reference's range instead of the generator's brighter one.
sample = library.create_material_expression(
material, unreal.MaterialExpressionTextureSampleParameter2D, -700, 0)
sample.set_editor_property("parameter_name", f"Albedo_{base}")
sample.set_editor_property("texture", albedo)
if hasattr(unreal, "MaterialExpressionTextureCoordinate"):
uvs = library.create_material_expression(
material, unreal.MaterialExpressionTextureCoordinate, -950, 0)
uvs.set_editor_property("u_tiling", 6.0)
uvs.set_editor_property("v_tiling", 6.0)
library.connect_material_expressions(uvs, "", sample, "UVs")
multiply = library.create_material_expression(
material, unreal.MaterialExpressionMultiply, -250, 0)
library.connect_material_expressions(sample, "RGB", multiply, "A")
library.connect_material_expressions(colour, "", multiply, "B")
source = multiply
library.connect_material_property(source, "", unreal.MaterialProperty.MP_BASE_COLOR)
library.recompile_material(material)
# Save the material itself. Saving only the mesh left these assets unsaved, so after a reload the
# slots pointed at nothing and the whole elevation rendered in one default colour.
saved = unreal.EditorAssetLibrary.save_loaded_asset(material)
log(f"{base}_Ref saved: {saved}")
slot.set_editor_property("material_interface", material)
assigned += 1
log(f"slot {index} {base}: {material.get_name()} {rgb}")
if assigned:
mesh.set_editor_property("static_materials", slots)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
log(f"assigned {assigned} facade materials")
def drop_stale_mesh():
"""Remove the imported mesh so its material slots are rebuilt from the FBX.
An earlier pass deleted the facade materials without the mesh, and the mesh kept pointing at the deleted
objects: every slot fell back and the whole elevation rendered in one colour with no banners or windows.
"""
path = f"{DEST}/SM_HeroFacade"
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
log(f"dropped stale mesh {path}")
def drop_stale_materials():
"""Delete the imported facade materials so the FBX recreates them with the current colours.
Unreal keeps an existing material asset when an FBX that references the same name is re-imported, so a
colour change in the Blender builder never reached the engine - the wall stayed the blockout's near-white
through several rebuilds. Deleting first is what makes the material part of the model.
"""
dropped = []
for name in ("M_Facade_Stone", "M_Facade_StoneShadow", "M_Facade_GlassDark",
"M_Facade_Banner", "M_Facade_Grille"):
path = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(path):
unreal.EditorAssetLibrary.delete_asset(path)
dropped.append(name)
log(f"dropped stale materials: {dropped if dropped else 'none'}")
def import_fbx():
task = unreal.AssetImportTask()
task.filename = FBX
task.destination_path = DEST
task.destination_name = "SM_HeroFacade"
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
def _make_map_writable(level: str) -> None:
"""Clear the read-only bit an LFS .umap checks out with.
Without this `save_map()` returns False and the staged actor is lost on the
next load — no exception, no non-zero exit (see MEMORY: 0.1.x trap).
"""
content = unreal.Paths.project_content_dir()
rel = level[len("/Game/"):] if level.startswith("/Game/") else level
umap = os.path.join(content, rel + ".umap")
note = ue_common.make_writable(umap)
if note:
log(f"map writable: {note}")
def measure_and_place():
asset = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SM_HeroFacade")
if asset is None:
log(f"MISSING {DEST}/SM_HeroFacade")
return
# Collision: complex-as-simple, not auto-generated convex hulls.
# The facade is 95 separate parts across 26 m; Unreal's auto hull generation
# wraps them in a few coarse hulls that bulged into the street (the QA wall
# probe went 0/35 -> 2/35 after trying it). Complex-as-simple makes the real
# triangles the simple collision, so line traces and capsule sweeps block on
# the actual wall — the same approach used for the terrain, for the same
# reason. The flag lives on the asset, so save it explicitly.
unreal.GigLineAssetTools.set_complex_collision_as_simple(asset, True)
saved = unreal.EditorAssetLibrary.save_loaded_asset(asset)
log(f"collision: {unreal.GigLineAssetTools.get_collision_complexity(asset)} (saved={saved})")
box = asset.get_bounding_box()
mn, mx = box.min, box.max
log(f"SM_HeroFacade bounds cm: size=({mx.x - mn.x:.1f}, {mx.y - mn.y:.1f}, {mx.z - mn.z:.1f}) "
f"min=({mn.x:.1f}, {mn.y:.1f}, {mn.z:.1f})")
# StaticMaterial is a struct wrapper: the material is its material_interface.
names = []
for sm in asset.get_editor_property("static_materials"):
mat = sm.get_editor_property("material_interface")
names.append(mat.get_name() if mat else "None")
log(f"materials: {names}")
# Place on the demo map if it exists; otherwise create it from the empty level.
if unreal.EditorAssetLibrary.does_asset_exist(LEVEL):
unreal.EditorLevelLibrary.load_level(LEVEL)
else:
if not unreal.EditorLevelLibrary.new_level(LEVEL):
log(f"could not create {LEVEL}")
return
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if actor.get_actor_label() == ACTOR_LABEL:
unreal.EditorLevelLibrary.destroy_actor(actor)
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(*PLACEMENT_CM))
actor.set_actor_label(ACTOR_LABEL)
comp = actor.static_mesh_component
comp.set_static_mesh(asset)
comp.set_mobility(unreal.ComponentMobility.STATIC)
log(f"placed {ACTOR_LABEL} at {PLACEMENT_CM} (cm) on {LEVEL}")
# Prove it landed on its verified footprint, not merely that it spawned.
box = asset.get_bounding_box() if asset else None
if box:
lo = box.min.x + PLACEMENT_CM[0]
hi = box.max.x + PLACEMENT_CM[0]
problem = ue_placement.describe_span_failure("hero", lo, hi)
if problem:
log(f"PLACEMENT ERROR: {problem}")
else:
log(f"span ok: X {lo:.0f}..{hi:.0f}")
_make_map_writable(LEVEL)
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), LEVEL)
log(f"save_map {LEVEL}: {saved}")
if not ue_common.require_saved(saved, LEVEL):
log(f"FAILED to save {LEVEL} — staged content will not persist")
try:
drop_stale_mesh()
drop_stale_materials()
import_fbx()
tint_imported_materials()
measure_and_place()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
with open(RESULT, "w") as handle:
handle.write("\n".join(lines) + "\n")
+92
View File
@@ -0,0 +1,92 @@
# Import the south-side block frontage FBX set into Unreal.
#
# blender ... --python Tools/blender/export_block_frontage.py -- --out /tmp/gigline/frontage
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_import_block_frontage.py -nullrhi -unattended -nopause -stdout
#
# Each mesh already sits at its world position in the project frame (the FBX was
# exported without moving the object), so the actor is spawned at the origin and
# the geometry carries the offset. Destination: /Game/Hollywood/Frontage/<name>.
#
# Staging into the DemoMap is done by Tools/unreal/ue_build_demo_map.py, which also
# assigns the look materials; this script only produces the assets.
import glob
import os
import unreal
SRC = "/tmp/gigline/frontage"
DEST = "/Game/Hollywood/Frontage"
RESULT = "/tmp/gigline/frontage-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_fbx(path, name):
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"MISSING after import: {DEST}/{name}")
return
# Buildings are solids the player can walk into the side of: simple + complex
# so the blockout blocks the player without expensive per-triangle queries.
try:
body = mesh.get_editor_property("body_setup")
body.set_editor_property("collision_trace_flag",
unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AND_COMPLEX)
mesh.set_editor_property("body_setup", body)
except Exception as exc: # noqa: BLE001
log(f"{name}: collision flag not set ({exc!r})")
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
# World-space placement, in Unreal centimetres, straight from the mesh bounds.
log(f"{name:22} X {mn.x:8.0f}..{mx.x:8.0f} Y {mn.y:7.0f}..{mx.y:7.0f} "
f"Z {mn.z:5.0f}..{mx.z:5.0f}")
def main():
files = sorted(glob.glob(os.path.join(SRC, "*.fbx")))
if not files:
log(f"no FBX in {SRC}; run Tools/blender/export_block_frontage.py first")
return
for path in files:
name = os.path.splitext(os.path.basename(path))[0]
import_fbx(path, name)
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
try:
main()
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")
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Import the façade module kit into Unreal: /Game/Hollywood/Kit/*.
Pairs with `Tools/blender/facade_kit_build.py`. Each piece is imported as its own static
mesh at the module's origin (bottom-front centre), so a building can place a
piece by bay index and storey index with a single offset.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/unreal/ue_import_facade_kit.py -nullrhi -unattended -nopause -stdout
Result file: /tmp/gigline/kit-import.txt (the commandlet swallows print()).
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
SRC = "/tmp/gigline/kit"
DEST = "/Game/Hollywood/Kit"
RESULT = "/tmp/gigline/kit-import.txt"
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def main() -> None:
if not os.path.isdir(SRC):
log(f"MISSING source directory {SRC} — run Tools/blender/facade_kit_build.py first")
return
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
log(f"no FBX files in {SRC}")
return
# Drop kit assets that the current build no longer produces (the pieces were
# renamed to follow the SM_ convention), so the folder cannot accumulate two
# generations of the same piece.
expected = {os.path.splitext(f)[0] for f in files}
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
name = existing.split("/")[-1].split(".")[0]
if name not in expected:
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
log(f"removed stale kit asset {name}")
for filename in files:
asset_name = os.path.splitext(filename)[0]
task = unreal.AssetImportTask()
task.filename = os.path.join(SRC, filename)
task.destination_path = DEST
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
# Kit pieces are thin boxes and panes: complex-as-simple keeps the
# collision on the real faces instead of a fat convex hull that would
# fence off the shopfront glazing.
smd.auto_generate_collision = False
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
if mesh is None:
log(f"{asset_name}: IMPORT FAILED")
continue
# The FBX import also creates an M_Kit_* material per piece. The demo
# assigns real site materials at assembly time, so those are clutter.
for slot in mesh.get_editor_property("static_materials"):
mat = slot.get_editor_property("material_interface")
if mat and mat.get_name().startswith("M_Kit_"):
unreal.EditorAssetLibrary.delete_asset(mat.get_path_name().split(".")[0])
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{asset_name:20} size=({mx.x - mn.x:6.1f}, {mx.y - mn.y:6.1f}, "
f"{mx.z - mn.z:6.1f}) cm min=({mn.x:6.1f}, {mn.y:6.1f}, {mn.z:6.1f}) "
f"collision={unreal.GigLineAssetTools.get_collision_complexity(mesh)}")
log(f"imported {len(files)} kit piece(s) into {DEST}")
try:
main()
except Exception as exc: # noqa: BLE001 - surface the failure in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+135
View File
@@ -0,0 +1,135 @@
# Import the two north-side background buildings (TCL Chinese Theatre, Madame
# Tussaud's) into Unreal and stage them in the demo map, across the street from
# the hero facade.
#
# Placement: these blockout meshes live in the same local frame as the site
# meshes (X east / Y north / Z up, metres, origin Hollywood x Highland), which
# are imported at the Unreal origin and land correctly via the `ue_metric_v1`
# FBX axis mapping (north -> -Y). So the buildings are spawned at Unreal
# (0,0,0) like the site meshes, not at the pivot-shifted hero facade position.
#
# UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_import_north_buildings.py -nullrhi -unattended -nopause -stdout
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
SRC = "/tmp/gigline/north"
DEST = "/Game/Hollywood/North"
LEVEL = "/Game/Hollywood/DemoMap"
RESULT = "/tmp/gigline/north-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
IMPORTS = [
("TCL_Chinese_Theatre.fbx", "SM_TCL_Chinese_Theatre"),
("Madame_Tussauds.fbx", "SM_Madame_Tussauds"),
]
def import_meshes():
assets = unreal.EditorAssetLibrary.list_assets(DEST) if unreal.EditorAssetLibrary.does_directory_exist(DEST) else []
for fn, asset_name in IMPORTS:
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([unreal.AssetImportTask(
filename=os.path.join(SRC, fn),
destination_path=DEST,
destination_name=asset_name,
automated=True,
save=True,
replace_existing=True,
options=unreal.FbxImportUI(
import_mesh=True, import_materials=True, import_textures=False,
import_as_skeletal=False) if False else None,
)])
# FbxImportUI kwargs are unreliable across 5.8; configure explicitly below.
def stage():
if unreal.EditorAssetLibrary.does_asset_exist(LEVEL):
unreal.EditorLevelLibrary.load_level(LEVEL)
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith("North_"):
unreal.EditorLevelLibrary.destroy_actor(actor)
for asset_name, label in (("SM_TCL_Chinese_Theatre", "North_TCL_Chinese"), ("SM_Madame_Tussauds", "North_Madame_Tussauds")):
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
if mesh is None:
log(f"MISSING {DEST}/{asset_name}")
continue
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(0, 0, 0))
actor.set_actor_label(label)
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
# Report where the mesh actually lands in world space (site-frame → Unreal).
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{label}: mesh world-space X {mn.x:.0f}..{mx.x:.0f} Y {mn.y:.0f}..{mx.y:.0f} size "
f"{mx.x-mn.x:.0f}x{mx.y-mn.y:.0f} cm")
_make_map_writable(LEVEL)
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), LEVEL)
log(f"save_map {LEVEL}: {saved}")
if not ue_common.require_saved(saved, LEVEL):
log(f"FAILED to save {LEVEL} — staged content will not persist")
def _make_map_writable(level: str) -> None:
"""Clear the read-only bit an LFS .umap checks out with.
Without this `save_map()` returns False and the staged actor is lost on the
next load — no exception, no non-zero exit (see MEMORY: 0.1.x trap).
"""
content = unreal.Paths.project_content_dir()
rel = level[len("/Game/"):] if level.startswith("/Game/") else level
umap = os.path.join(content, rel + ".umap")
note = ue_common.make_writable(umap)
if note:
log(f"map writable: {note}")
def main():
for fn, asset_name in IMPORTS:
task = unreal.AssetImportTask()
task.filename = os.path.join(SRC, fn)
task.destination_path = DEST
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
log(f"imported {asset_name}")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
stage()
try:
main()
except Exception as exc: # noqa
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")
+94
View File
@@ -0,0 +1,94 @@
# Import the retail frontage trim (6800 Lee Drug, 6806–6820) into Unreal.
#
# blender -b --factory-startup --python Tools/blender/retail_frontage_build.py -- \
# --out /tmp/gigline/retail --report /tmp/gigline/retail-report.json
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_import_retail_detail.py -nullrhi -unattended -nopause -stdout
#
# Two meshes arrive, one per building, each already at its world position in the
# project frame (the FBX carries the offset), so staging spawns at the origin.
# Destination: /Game/Hollywood/Frontage/<name> — same folder as the block frontage
# so the demo builder stages them together.
#
# Result file: /tmp/gigline/retail-import.txt
import glob
import os
import unreal
SRC = "/tmp/gigline/retail"
DEST = "/Game/Hollywood/Frontage"
RESULT = "/tmp/gigline/retail-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_fbx(path, name):
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"MISSING after import: {DEST}/{name}")
return None
# Trim is decoration on a solid the player already collides with: no collision
# of its own, otherwise the cornice and mullions become snag points.
try:
body = mesh.get_editor_property("body_setup")
body.set_editor_property("collision_trace_flag",
unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE)
mesh.set_editor_property("body_setup", body)
except Exception as exc: # noqa: BLE001
log(f"{name}: collision flag not set ({exc!r})")
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{name:22} X {mn.x:8.0f}..{mx.x:8.0f} Y {mn.y:7.0f}..{mx.y:7.0f} "
f"Z {mn.z:5.0f}..{mx.z:5.0f} slots {len(mesh.get_editor_property('static_materials'))}")
return mesh
def main():
files = sorted(glob.glob(os.path.join(SRC, "*.fbx")))
if not files:
log(f"no FBX in {SRC}; run Tools/blender/retail_frontage_build.py first")
return
for path in files:
import_fbx(path, os.path.splitext(os.path.basename(path))[0])
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
try:
main()
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")
+127
View File
@@ -0,0 +1,127 @@
# Import the Blender site meshes (terrain, road, sidewalks, kerbs) into Unreal.
#
# blender ... --python Art/blender/site/export_site_fbx.py -- --out /tmp/gigline/site
# <engine>/.../UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_import_site.py -nullrhi -unattended -nopause -stdout
#
# Each mesh is a separate FBX because they carry different materials and want
# different collision settings (terrain and street are walkable surfaces).
# Destination: /Game/Hollywood/Site/<name>.
#
# Y-axis note: the FBX preset maps Blender +Y (north) to Unreal -Y, so the site
# arrives in the same frame as the hero facade. Nothing is mirrored here.
import glob
import os
import unreal
SRC = "/tmp/gigline/site"
DEST = "/Game/Hollywood/Site"
RESULT = "/tmp/gigline/site-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def import_fbx(path, name, collision="complex"):
# 5.8 note: there is no FBX import 'no collision' switch exposed to Python;
# a default import simply generates no collision primitives, so collision="none"
# means: skip the trace-flag step below and leave the body setup empty.
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
# Generate collision at import. A default FBX static-mesh import creates NO
# collision primitives, so CTF_USE_COMPLEX_AS_SIMPLE later has nothing to use
# and the player falls through the walkable surfaces (observed at runtime:
# ground trace under the spawn hits nothing). auto_generate_collision bakes
# collision geometry into the mesh so the walkable plane actually blocks.
smd.auto_generate_collision = True
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"MISSING after import: {DEST}/{name}")
return
# Collision is requested at import time so the 0.1.0 walkability check has
# something to stand on. Terrain and street get complex-as-simple: a simple
# hull would ramp straight over the kerbs.
#
# UE 5.8 exposes this only through the body setup's trace flag (the mesh itself
# has no trace-flag property, and the subsystem has no setter in Python), so
# the import path sets the body setup's trace flag, then verifies it by reading
# it back.
if collision == "none":
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{name:18} import ok size=({mx.x - mn.x:.0f}, {mx.y - mn.y:.0f}, {mx.z - mn.z:.0f}) cm "
f"min_z={mn.z:.1f} collision=none")
return
# Walkable surfaces block with SIMPLE + COMPLEX collision so the player's
# capsule always has ground: CFG_USE_SIMPLE_AND_COMPLEX uses the auto-
# generated simple collision AND the mesh triangles. (Complex-as-simple
# alone only worked if the mesh carried complex collision, which a default
# FBX static-mesh import does not.)
trace = unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AND_COMPLEX
try:
body = mesh.get_editor_property("body_setup")
body.set_editor_property("collision_trace_flag", trace)
mesh.set_editor_property("body_setup", body)
except Exception as exc: # noqa: BLE001 - report, do not fail the whole import
log(f"{name}: could not set trace flag ({exc}); leaving the default")
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{name:18} import ok size=({mx.x - mn.x:.0f}, {mx.y - mn.y:.0f}, {mx.z - mn.z:.0f}) cm "
f"min_z={mn.z:.1f} collision={collision}")
def main():
files = sorted(glob.glob(os.path.join(SRC, "*.fbx")))
if not files:
log(f"no FBX in {SRC}; run Art/blender/site/export_site_fbx.py first")
return
for path in files:
stem = os.path.splitext(os.path.basename(path))[0]
# Crossing markings are paint on the road surface — no collision at all,
# so the player never stands on a 1 cm lip at the band edges.
if stem == "Crossing_Markings":
import_fbx(path, stem, "none")
continue
# Segmented ground surfaces keep their names with a numeric suffix; both the base
# names and the slices want the same walkable collision.
walkable = ("Terrain_DEM", "Road_Hollywood", "Road_Hollywood_",
"Sidewalk_South", "Sidewalk_North", "Kerb_South", "Kerb_North")
collision = "complex" if any(stem.startswith(w) for w in walkable) else "simple"
import_fbx(path, stem, collision)
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
try:
main()
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")
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Single source of truth for where staged actors belong in the DemoMap.
This exists because the same value was written in two places and they drifted:
`ue_hero_facade_import.py` kept the pre-`0.1.0` position `(-12024.5, -1450)` while
`ue_build_demo_map.py` staged the hero at `(-13336, 1307)`. Running the importer on
its own therefore moved the hero **13 m east and onto the north side of the
boulevard** — and nothing complained, because a displaced building still renders.
The values are Unreal centimetres, absolute world space. Every one of them is
verified against `Geo/verification/2026-11-06-el-capitan-block.md` and probed by
`Tools/review/visual_loop.py --verify` (via the demo screenshot expectations) and by the
world-span probes recorded in `MEMORY.md`.
Keep this file boring: constants and one validator, no `unreal` import, so it can
be unit-tested on the host.
"""
from __future__ import annotations
# The hero façade sits INSIDE the frontage gap between 6902 (ends at -17749) and
# the El Capitan Theatre (starts at -12082), so its actor origin is the measured
# building line. The mesh is authored with its front plane 7 cm ahead of the actor
# origin, which is why the X here is the frontage centre and Y is the building line.
HERO_ACTOR = "Hero_Facade"
HERO_LOCATION_CM = (-13336.0, 1307.0, 0.0)
# Tolerance for the span probe: the verified footprints are within ~1 m.
SPAN_TOLERANCE_CM = 100.0
# Expected world span per frontage actor (min X, max X), from the block
# verification notes. Used to prove a staged building is on its own footprint
# rather than merely present.
FRONTAGE_SPANS_CM = {
"6922": (-24276.0, -17700.0),
"6902": (-17749.0, -14591.0),
"hero": (-14644.0, -12028.0),
"6838": (-12082.0, -8210.0),
"6822": (-7854.0, -7252.0),
"6806": (-6608.0, -3307.0),
"6800": (-3320.0, -1437.0),
}
def span_error(name: str, observed_min: float, observed_max: float):
"""Return (expected_min, expected_max, ok) for an observed building span.
`name` is a key of `FRONTAGE_SPANS_CM`; `ok` is False when either edge is more
than `SPAN_TOLERANCE_CM` from the verified value.
"""
if name not in FRONTAGE_SPANS_CM:
raise KeyError(f"unknown building {name!r}; known: {sorted(FRONTAGE_SPANS_CM)}")
exp_min, exp_max = FRONTAGE_SPANS_CM[name]
ok = (abs(observed_min - exp_min) <= SPAN_TOLERANCE_CM
and abs(observed_max - exp_max) <= SPAN_TOLERANCE_CM)
return exp_min, exp_max, ok
def describe_span_failure(name: str, observed_min: float, observed_max: float) -> str:
"""A one-line message for a misplaced building, or "" when it is correct."""
exp_min, exp_max, ok = span_error(name, observed_min, observed_max)
if ok:
return ""
return (f"{name}: span X {observed_min:.0f}..{observed_max:.0f} does not match the "
f"verified {exp_min:.0f}..{exp_max:.0f} (±{SPAN_TOLERANCE_CM:.0f} cm)")
+86
View File
@@ -0,0 +1,86 @@
"""Rebuild both Unreal maps from an empty level, deleting whatever was there before.
<engine>/UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=<repo>/Tools/unreal/ue_recreate_maps.py -nullrhi -unattended -nopause -stdout
Why: `Tools/unreal/ue_build_demo_map.py` and `Tools/unreal/ue_build_review_map.py` load the existing map and destroy the
actors they know about. Anything else that accumulated - an actor from an experiment, a material override, a
stale world setting - survives every rebuild, which is how a stale road mesh and a duplicated vehicle actor
both got saved back into the map. Deleting the `.umap` and creating an empty level makes every run start from
the same place.
The umap files are LFS-tracked and check out read-only, so chmod comes first; and a level cannot be deleted
while it is the one the editor has open, so a scratch level is created to release it.
Result: /tmp/gigline/recreate-maps.txt
"""
import os
import unreal
MAPS = ("/Game/Hollywood/DemoMap", "/Game/Test/ReviewMap")
SCRATCH = "/Game/Test/_RecreateScratch"
RESULT = "/tmp/gigline/recreate-maps.txt"
lines = []
def log(message):
lines.append(str(message))
unreal.log(str(message))
def make_writable(path):
"""A .umap under Content/ that is read-only cannot be deleted; LFS checks it out that way."""
disk = os.path.join(unreal.Paths.project_content_dir(),
path.replace("/Game/", "") + ".umap")
try:
mode = os.stat(disk).st_mode & 0o777
if not mode & 0o200:
os.chmod(disk, 0o644)
log(f"{path}: chmod u+w ({oct(mode)} -> 0o644)")
except FileNotFoundError:
pass
def main():
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
scratch_made = False
for path in MAPS:
make_writable(path)
current = unreal.EditorLevelLibrary.get_editor_world()
current_path = current.get_path_name().split(".")[0] if current else ""
if path == current_path:
# The editor cannot delete the map it has open: park it on a scratch level first.
if not scratch_made:
les.new_level(SCRATCH)
scratch_made = True
else:
les.load_level(SCRATCH)
log(f"{path}: released by switching to {SCRATCH}")
if unreal.EditorAssetLibrary.does_asset_exist(path):
if unreal.EditorAssetLibrary.delete_asset(path):
log(f"{path}: deleted")
else:
log(f"{path}: could not delete")
if les.new_level(path):
log(f"{path}: created empty")
else:
log(f"{path}: FAILED to create")
if unreal.EditorAssetLibrary.does_asset_exist(SCRATCH):
unreal.EditorAssetLibrary.delete_asset(SCRATCH)
ok = all(f"{path}: created empty" in line for path in MAPS for line in lines)
lines.append(f"VERDICT: {'PASS' if ok else 'FAIL'}")
log(f"recreate {'ok' if ok else 'FAILED'}")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as handle:
handle.write("\n".join(lines) + "\n")
+60
View File
@@ -0,0 +1,60 @@
# Unreal-side half of the scale test: imports the Blender scale-test FBX with
# the named import preset "ue_metric_v1" and measures the result.
#
# Run headless:
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_scale_test_import.py -nullrhi -unattended -nopause -stdout
import unreal
FBX = "/tmp/gigline/scaletest.fbx"
DEST = "/Game/Test/ScaleTest"
def import_fbx():
task = unreal.AssetImportTask()
task.filename = FBX
task.destination_path = DEST
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = False
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
def measure():
# Named import preset "ue_metric_v1" measures in Unreal centimetres.
lines = ["---- measured local-space bounds (cm) ----"]
for name in ("ScaleTest_A", "ScaleTest_B", "ScaleTest_C"):
path = f"{DEST}/{name}"
asset = unreal.EditorAssetLibrary.load_asset(path)
if asset is None:
lines.append(f"MISSING asset {path}")
continue
box = asset.get_bounding_box()
mn, mx = box.min, box.max
lines.append(
f"{name}: origin=({(mn.x + mx.x) / 2:.2f}, {(mn.y + mx.y) / 2:.2f}, {(mn.z + mx.z) / 2:.2f}) "
f"size=({mx.x - mn.x:.2f}, {mx.y - mn.y:.2f}, {mx.z - mn.z:.2f})"
)
lines.append(f"assets: {unreal.EditorAssetLibrary.list_assets(DEST)}")
with open("/tmp/ue-scale-test-result.txt", "w") as f:
f.write("\n".join(lines))
import_fbx()
measure()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Import and stage the El Capitan Theatre trim (marquee + blade sign).
Two steps, both idempotent:
1. Import `/tmp/gigline/theatre/*.fbx` into `/Game/Hollywood/Theatre`.
2. Stage one static-mesh actor per piece at the **origin**, because the meshes are
built in world coordinates (same convention as the block frontage and the north
buildings). Anything that has to be positioned by arithmetic is a bug waiting to
happen — the kit was once placed 12 km away that way.
UnrealEditor-Cmd <uproject> -run=pythonscript \
-script=Tools/unreal/ue_stage_theatre_trim.py -nullrhi -unattended -nopause -stdout
Result file: `/tmp/gigline/theatre-import.txt`.
"""
from __future__ import annotations
import os
import sys
import unreal
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import ue_common # noqa: E402
SRC = "/tmp/gigline/theatre"
DEST = "/Game/Hollywood/Theatre"
MAP = "/Game/Hollywood/DemoMap"
RESULT = "/tmp/gigline/theatre-import.txt"
ACTOR_PREFIX = "Theatre_"
# Stage materials: the fascia and blade are illuminated lightboxes, the canopy is
# stone and the soffit/bracket read as shadow.
MATERIALS = {
"SM_Trim_MarqueeCanopy": "M_FacadeStone",
"SM_Trim_MarqueeFascia": "M_FacadeBanner",
"SM_Trim_MarqueeSoffit": "M_FacadeStoneShadow",
"SM_Trim_BladeSign": "M_FacadeBanner",
"SM_Trim_BladeSignFrame": "M_FacadeStone",
"SM_Trim_BladeSignBracket": "M_FacadeStoneShadow",
}
lines: list[str] = []
def log(message: str) -> None:
lines.append(message)
print(message)
def import_pieces() -> list[str]:
if not os.path.isdir(SRC):
log(f"MISSING {SRC} — run Tools/blender/theatre_trim_build.py first")
return []
files = sorted(f for f in os.listdir(SRC) if f.lower().endswith(".fbx"))
if not files:
log(f"no FBX in {SRC}")
return []
expected = {os.path.splitext(f)[0] for f in files}
for existing in unreal.EditorAssetLibrary.list_assets(DEST, recursive=False):
name = existing.split("/")[-1].split(".")[0]
if name not in expected:
unreal.EditorAssetLibrary.delete_asset(f"{DEST}/{name}")
log(f"removed stale asset {name}")
for filename in files:
asset_name = os.path.splitext(filename)[0]
task = unreal.AssetImportTask()
task.filename = os.path.join(SRC, filename)
task.destination_path = DEST
task.destination_name = asset_name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.import_textures = False
ui.import_as_skeletal = False
smd = ui.static_mesh_import_data
smd.import_uniform_scale = 1.0
smd.combine_meshes = True
smd.convert_scene_unit = False
smd.force_front_x_axis = False
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
# A projecting canopy over the pavement must block: the player should not be
# able to walk through the fascia or clip up into the soffit.
smd.auto_generate_collision = False
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{asset_name}")
if mesh is None:
log(f"{asset_name}: IMPORT FAILED")
continue
unreal.GigLineAssetTools.set_complex_collision_as_simple(mesh, True)
unreal.EditorAssetLibrary.save_loaded_asset(mesh)
box = mesh.get_bounding_box()
log(f"{asset_name:26} X {box.min.x:8.0f}..{box.max.x:7.0f} "
f"Y {box.min.y:7.0f}..{box.max.y:7.0f} Z {box.min.z:6.0f}..{box.max.z:6.0f}")
return [os.path.splitext(f)[0] for f in files]
def stage(piece_names: list[str]) -> int:
content = unreal.Paths.project_content_dir()
ue_common.make_writable(os.path.join(content, "Hollywood/DemoMap.umap"))
if not unreal.EditorLevelLibrary.load_level(MAP):
log(f"could not load {MAP}")
return 0
removed = 0
for actor in list(unreal.EditorLevelLibrary.get_all_level_actors()):
if actor.get_actor_label().startswith(ACTOR_PREFIX):
unreal.EditorLevelLibrary.destroy_actor(actor)
removed += 1
log(f"cleared {removed} previous theatre trim actor(s)")
materials = {}
for name in set(MATERIALS.values()):
asset = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if asset:
materials[name] = asset
placed = 0
for piece in piece_names:
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{piece}")
if mesh is None:
continue
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0))
if actor is None:
log(f"could not spawn {piece}")
continue
actor.set_actor_label(ACTOR_PREFIX + piece.replace("SM_Trim_", ""))
comp = actor.static_mesh_component
comp.set_static_mesh(mesh)
comp.set_mobility(unreal.ComponentMobility.STATIC)
comp.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
comp.set_collision_profile_name("BlockAll")
want = MATERIALS.get(piece)
if want and want in materials:
comp.set_material(0, materials[want])
placed += 1
log(f"staged {placed} theatre trim actor(s) in {MAP}")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), MAP)
log(f"save_map {MAP}: {saved}")
return placed
def main() -> None:
names = import_pieces()
if names:
stage(names)
try:
main()
except Exception as exc: # noqa: BLE001 - surface in the result file
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
with open(RESULT, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
+145
View File
@@ -0,0 +1,145 @@
# Import the sedan systems as separate static meshes (0.3.0).
#
# blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \
# --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_vehicle_import.py -nullrhi -unattended -nopause -stdout
#
# One asset per system, per the vehicle-artist skill's object split: body, glass, front
# and rear lights, mirrors, and one wheel reused four times. The body FBX also carries
# the UCX_ hulls, which Unreal consumes as simple collision.
#
# Result file: /tmp/gigline/vehicle-import.txt
import os
import unreal
DEST = "/Game/Hollywood/Vehicle"
SOURCE = "/tmp/gigline/hatch"
SYSTEMS = (
("body", "SM_Vehicle_Body"),
("trim", "SM_Vehicle_Trim"),
("glass", "SM_Vehicle_Glass"),
("lights", "SM_Vehicle_Lights"),
("wheels", "SM_Vehicle_Wheels"),
)
RESULT = "/tmp/gigline/vehicle-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
for source, name in SYSTEMS:
path = os.path.join(SOURCE, f"{source}.fbx")
if not os.path.exists(path):
log(f"{name}: missing source {path}")
lines.append("VERDICT: FAIL")
return
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = False
ui.import_materials = True
ui.import_textures = False
ui.static_mesh_import_data.combine_meshes = True
ui.static_mesh_import_data.generate_lightmap_u_vs = True
ui.static_mesh_import_data.auto_generate_collision = True
ui.static_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"{name}: MISSING after import")
lines.append("VERDICT: FAIL")
return
bounds = mesh.get_bounding_box() if hasattr(mesh, "get_bounding_box") else None
size = ""
if bounds:
extent = bounds.max - bounds.min
size = f"{extent.x/100:.2f} x {extent.y/100:.2f} x {extent.z/100:.2f} m"
mats = mesh.get_editor_property("static_materials")
log(f"{name}: imported, {len(mats)} material slot(s), bounds {size}")
# Wire the two LOD meshes into the body's LOD group. import_lod is the supported
# path; copying bytes between assets is not, and the LOD group is what the engine
# uses to pick by screen size.
# LOD meshes are imported as their own assets. Wiring them into the body's LOD group
# needs StaticMeshEditorSubsystem.import_lod, which is unavailable in this headless
# commandlet (get_editor_subsystem returns None), so the group is a follow-up.
for source, name in (("body_lod1", "SM_Vehicle_Body_LOD1"),
("body_lod2", "SM_Vehicle_Body_LOD2")):
path = os.path.join(SOURCE, f"{source}.fbx")
if not os.path.exists(path):
log(f"{name}: missing {path}")
continue
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.static_mesh_import_data.combine_meshes = True
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
log(f"{name}: imported as its own asset ({'ok' if mesh else 'MISSING'}); "
"LOD group wiring needs an editor session")
# Skeletal vehicle for Chaos is not built from this template yet: the hatch is a
# static model, and the drivable rig has to be authored around it.
skel_path = os.path.join(SOURCE, "vehicle_skeletal.fbx")
if os.path.exists(skel_path):
task = unreal.AssetImportTask()
task.filename = skel_path
task.destination_path = DEST
task.destination_name = "SK_Vehicle_Sedan"
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
skel = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SK_Vehicle_Sedan")
if skel:
skeleton = skel.get_editor_property("skeleton")
# Skeleton has no bone_names property in 5.8; read the reference skeleton
# through the asset registry instead of guessing a property name.
log(f"SK_Vehicle_Sedan: imported, skeleton={skeleton.get_name() if skeleton else 'NONE'}")
else:
log("SK_Vehicle_Sedan: MISSING after import")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+148
View File
@@ -0,0 +1,148 @@
# Rebuild the vehicle's materials from the Blender colours (0.3.0).
#
# blender -b "Art/blender/vehicle/hot hatch.blend" \
# --python Tools/blender/vehicle_from_template.py -- --out /tmp/gigline/hatch
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/unreal/ue_vehicle_materials.py -nullrhi -unattended -nopause -stdout
#
# Why this exists: the template's car paint is a **legacy node graph**, so its colour never
# reached the exported FBX and the car arrived in Unreal near-black while Blender showed it
# red. `Tools/blender/vehicle_from_template.py` now reads those graphs and records the colours; this
# replaces the imported material assets in place with plain constant-colour materials, so
# the mesh slots pick them up by path without anything being re-imported.
#
# Result file: /tmp/gigline/vehicle-materials.txt
import json
import os
import unreal
DEST = "/Game/Hollywood/Vehicle"
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
REPORT = os.path.join(_REPO, "..", "..", "..", "tmp", "gigline", "hatch", "report.json")
REPORT = "/tmp/gigline/hatch/report.json"
RESULT = "/tmp/gigline/vehicle-materials.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def existing_material_name(name):
"""Find the asset the importer actually created for a Blender material name.
Blender names may contain spaces ("car paint"), which are not valid asset names, so the
importer sanitises them. Guessing the sanitised form is fragile; this searches the
folder instead and reports when nothing matches.
"""
registry = unreal.AssetRegistryHelpers.get_asset_registry()
wanted = name.lower().replace(" ", "").replace("_", "")
for asset in registry.get_assets_by_path(DEST, recursive=False):
candidate = str(asset.asset_name).lower().replace(" ", "").replace("_", "")
if candidate == wanted:
return str(asset.asset_name)
return None
def make_translucent_glass(material):
"""Turn a material into dark, slightly transparent glass.
The template's `windows` material is metallic 1.0 with roughness 0.0, which reads as a
black void in game - indistinguishable from an empty cabin. Glass needs translucency and
a little roughness to catch a highlight.
"""
lib = unreal.MaterialEditingLibrary
material.set_editor_property("blend_mode", unreal.BlendMode.BLEND_TRANSLUCENT)
material.set_editor_property("two_sided", False)
for node in list(lib.get_material_expressions(material)):
lib.delete_material_expression(material, node)
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -400, 0)
base.set_editor_property("constant", unreal.LinearColor(0.02, 0.03, 0.045, 1.0))
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
opacity = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 140)
opacity.set_editor_property("r", 0.85)
lib.connect_material_property(opacity, "", unreal.MaterialProperty.MP_OPACITY)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 220)
rough.set_editor_property("r", 0.08)
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
specular = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 300)
specular.set_editor_property("r", 1.0)
lib.connect_material_property(specular, "", unreal.MaterialProperty.MP_SPECULAR)
lib.recompile_material(material)
return material
def replace_material(name, spec, create_if_missing=False):
resolved = existing_material_name(name)
if resolved is not None:
name = resolved
elif not create_if_missing:
log(f"{name}: no imported asset matches - skipped")
return None
full = f"{DEST}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(full):
unreal.EditorAssetLibrary.delete_asset(full)
tools = unreal.AssetToolsHelpers.get_asset_tools()
material = tools.create_asset(name, DEST, unreal.Material, unreal.MaterialFactoryNew())
if material is None:
log(f"{name}: could not create")
return None
lib = unreal.MaterialEditingLibrary
colour = spec["base_color"]
base = lib.create_material_expression(material, unreal.MaterialExpressionConstant3Vector, -400, 0)
base.set_editor_property("constant", unreal.LinearColor(colour[0], colour[1], colour[2], 1.0))
lib.connect_material_property(base, "", unreal.MaterialProperty.MP_BASE_COLOR)
rough = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 180)
rough.set_editor_property("r", max(0.05, float(spec.get("roughness", 0.5))))
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
metallic = lib.create_material_expression(material, unreal.MaterialExpressionConstant, -400, 260)
metallic.set_editor_property("r", float(spec.get("metallic", 0.0)))
lib.connect_material_property(metallic, "", unreal.MaterialProperty.MP_METALLIC)
lib.recompile_material(material)
log(f"{name}: base {tuple(round(c, 3) for c in colour)} "
f"roughness {spec.get('roughness')} metallic {spec.get('metallic')} "
f"({spec.get('source')})")
return material
def main():
with open(REPORT) as handle:
report = json.load(handle)
materials = report.get("materials", {})
log(f"{len(materials)} materials in the Blender report")
made = 0
for name, spec in materials.items():
material = replace_material(name, spec)
if material:
made += 1
if material and name.lower() == "windows":
make_translucent_glass(material)
log("windows: tinted glass (opacity 0.85, roughness 0.08) - 0.45 read as no glass at all")
# The body arrives without a usable material (its FBX slot resolves to
# WorldGridMaterial), so its paint is created under a name of our own and assigned to
# the staged panel at runtime.
paint = materials.get("car paint")
if paint:
spec = dict(paint)
spec["base_color"] = [1.0, 0.05, 0.03]
replace_material("M_Vehicle_HatchPaint", spec, create_if_missing=True)
log("M_Vehicle_HatchPaint: body paint, assigned at runtime by the map builder")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
lines.append(f"VERDICT: {'PASS' if made == len(materials) else 'FAIL'}")
log(f"{made}/{len(materials)} materials replaced")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+111
View File
@@ -0,0 +1,111 @@
"""Add one piece of demo content to ReviewMap and save it as a vehicle probe level.
GIGLINE_PROBE_ASSET=Terrain_DEM [GIGLINE_PROBE_NOCOLLISION=1] <engine>/UnrealEditor-Cmd <uproject> \
-run=pythonscript -script=<repo>/Tools/unreal/ue_vehicle_probe_map.py -nullrhi -unattended -nopause
Why: the Chaos pawn falls correctly on ReviewMap's empty floor and is thrown upward everywhere in
DemoMap. Rather than guess which of DemoMap's 593 staged meshes is responsible, add them one group
at a time to the map that works and watch for the moment the pawn stops falling. With no
GIGLINE_PROBE_ASSET set, the level is a plain copy of ReviewMap - the control.
Assets are looked up as /Game/Hollywood/Site/<name> first, then /Game/Hollywood/Buildings/<name>.
Result: /tmp/gigline/vehicle-probe.txt
"""
import os
import unreal
RESULT = "/tmp/gigline/vehicle-probe.txt"
TARGET = "/Game/Test/VehicleProbe"
SOURCE = "/Game/Test/ReviewMap"
SEARCH = ("/Game/Hollywood/Site", "/Game/Hollywood/Buildings",
"/Game/Hollywood/Streets", "/Game/Hollywood/Props")
lines = []
def log(message):
lines.append(str(message))
unreal.log(str(message))
def find_asset(name):
for folder in SEARCH:
path = f"{folder}/{name}"
if unreal.EditorAssetLibrary.does_asset_exist(path):
return path
return None
def main():
wanted = os.environ.get("GIGLINE_PROBE_ASSET", "").strip()
if not unreal.EditorAssetLibrary.does_asset_exist(SOURCE):
log(f"missing source level {SOURCE}")
lines.append("VERDICT: FAIL")
return
target = TARGET
# A level that the previous run left behind cannot always be deleted while the editor holds it,
# so fall back to a suffixed name rather than failing the probe.
for suffix in ("", "_b", "_c", "_d"):
target = TARGET + suffix
if not unreal.EditorAssetLibrary.does_asset_exist(target):
break
unreal.EditorAssetLibrary.delete_asset(target)
if not unreal.EditorAssetLibrary.does_asset_exist(target):
break
if not unreal.EditorAssetLibrary.duplicate_asset(SOURCE, target):
log(f"could not duplicate {SOURCE} -> {TARGET}")
lines.append("VERDICT: FAIL")
return
log(f"duplicated {SOURCE} -> {target}")
unreal.EditorLevelLibrary.load_level(target)
if wanted:
path = find_asset(wanted)
if path is None:
log(f"MISSING asset {wanted} in {SEARCH}")
lines.append("VERDICT: FAIL")
return
mesh = unreal.EditorAssetLibrary.load_asset(path)
actor = unreal.EditorLevelLibrary.spawn_actor_from_class(
unreal.StaticMeshActor, unreal.Vector(0.0, 0.0, 0.0))
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
comp.set_static_mesh(mesh)
actor.set_actor_label(f"Probe_{wanted}")
log(f"staged probe mesh {path} at the origin")
# GIGLINE_PROBE_NOCOLLISION=1 stages the same mesh with no collision at all, which separates
# "this mesh's collision ejects physics bodies" from "this mesh does something else".
if os.environ.get("GIGLINE_PROBE_NOCOLLISION", "").strip() == "1":
comp.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION)
log("probe mesh collision disabled")
else:
log("control run: no extra content")
vehicle_class = unreal.load_class(None, "/Script/GigLine.GigLineVehicle")
if vehicle_class:
pawn = unreal.EditorLevelLibrary.spawn_actor_from_class(
vehicle_class, unreal.Vector(600.0, 0.0, 300.0))
pawn.set_actor_label("Probe_Vehicle")
log("staged the Chaos pawn at (600, 0, 300)")
else:
log("vehicle class not found")
if unreal.EditorLevelLibrary.save_current_level():
log(f"saved {target}")
lines.append(f"LEVEL: {target}")
lines.append("VERDICT: PASS")
else:
log(f"save failed for {target}")
lines.append("VERDICT: FAIL")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")