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:
1 parent
f681d83736
commit
4d27109668
168 files changed
+799
-407
No files matched your search
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user