The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first: - ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now. - render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/... and the capture step failed in 0 s. - the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build' passed without building. Result files are unlinked before each step and child output streams to a per-step log file rather than a pipe. - two editors on one project hang instead of failing, so the driver refuses to start while one is running. Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the builders actually need. Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before, and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
295 lines
13 KiB
Python
295 lines
13 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/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
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
|
|
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
|
|
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")
|