Files
gig-line/Tools/ue_hero_facade_import.py
T
zwitschi 73aca9db80 feat(facades): 0.1.5 — hero detail, scene audit, material orphaning fixed (#32)
Merged PR #32: 0.1.5 — hero detail, scene audit, material orphaning fixed

Co-authored-by: Zwitschi <zwitschi82@gmail.com>
2026-11-08 22:56:08 +01:00

155 lines
6.2 KiB
Python

# 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/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.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/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))
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:
import_fbx()
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")