Files
gig-line/Tools/unreal/ue_hero_facade_import.py
T
zwitschi 8c34eaece0 feat(hero): banner lettering, re-render the three staging shots; defer the rest to 0.9.0
Banner lettering (JIMMY KIMMEL LIVE): new Tools/gen/generate_banner_texture.py
draws the exact title with Pillow + Liberation Sans Bold - an image model would
mangle a trademark string, and Qwen-Image 2.1 is research-only. Gold JIMMY /
KIMMEL / LIVE stacked on a crimson ground. ue_hero_facade_import.py generalises
import_albedo to import_texture and wires the banner into M_Facade_Banner_Ref as
Base Color (tiling 1.0), replacing the flat dark tint with a brighter red per the
owner's 'less dark, more bright'.

Re-rendered all three ReviewMap staging shots (front, oblique, portals) and
updated their Screenshots/README.md status rows.

The hero facade rework is now closed at 'surface detail that reads at 45 m'. The
remaining elevation work - volute capital depth, lattice grille depth, and the
full realism pass - is deferred to 0.9.0 per the owner, and the 0.9.0 hero item
records the hand-off.
2026-11-10 15:27:08 +01:00

298 lines
14 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"
BANNER_ALBEDO = "Art/textures/generated/T_Banner_KIMMEL.png"
BANNER_ALBEDO_ASSET = "T_Banner_KIMMEL"
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.
# Owner review: the elevation read as near-white grey. These are the reference's warm yellow-grey
# limestone, and they multiply the albedo rather than replace it, so the value lands inside the range the
# photographs show instead of the generator's brighter one.
# Sampled from the reference (02-masonictemple01.jpg): sunlit stone sRGB ~0.49, attic highlight ~0.75,
# shaded recess ~0.43. The albedo is mid-grey, so the tint carries the colour and lands the product near
# 0.25 linear for lit stone - not the near-white the owner rejected.
"M_Facade_Stone": (0.47, 0.455, 0.43),
"M_Facade_StoneShadow": (0.30, 0.29, 0.27),
"M_Facade_GlassDark": (0.02, 0.025, 0.03),
"M_Facade_Banner": (0.26, 0.012, 0.012),
"M_Facade_Grille": (0.06, 0.06, 0.065),
}
def import_texture(rel_path, asset_name):
"""Import a generated texture, 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.
# Tools/unreal/<this file> -> repository root is three levels up.
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
path = os.path.join(repo_root, rel_path)
if not os.path.exists(path):
log(f"texture missing: {path}")
return None
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = TEXTURE_DIR
task.destination_name = asset_name
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}/{asset_name}")
log(f"texture imported: {asset.get_path_name() if asset else 'FAILED'}")
return asset
def tint_imported_materials():
"""Author one plain material per facade surface, with the reference colour and the stone albedo.
The imported materials are MaterialInstanceConstants, which cannot be rebuilt in place
(`MaterialEditingLibrary` rejects them), and assigning new assets to the mesh's slot list does not persist
either. So this creates real Material assets named `<imported>_Ref` and the map builders point the hero's
component slots at them (`apply_hero_materials()`), which does persist in the saved map.
Albedo is multiplied by a tint rather than used raw: the generator's stone is brighter than the reference,
and the owner's review was that the elevation read near-white grey.
"""
library = unreal.MaterialEditingLibrary
albedo = import_texture(FACADE_ALBEDO, FACADE_ALBEDO_ASSET)
banner_tex = import_texture(BANNER_ALBEDO, BANNER_ALBEDO_ASSET)
built = 0
for base, rgb in FACADE_TINTS.items():
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(
f"{base}_Ref", DEST, unreal.Material, unreal.MaterialFactoryNew())
colour = library.create_material_expression(
material, unreal.MaterialExpressionConstant3Vector, -600, 200)
colour.set_editor_property("constant", unreal.LinearColor(rgb[0], rgb[1], rgb[2], 1.0))
source = colour
kind = "tint"
if albedo is not None and base in ("M_Facade_Stone", "M_Facade_StoneShadow"):
sample = library.create_material_expression(
material, unreal.MaterialExpressionTextureSampleParameter2D, -900, -100)
sample.set_editor_property("parameter_name", f"Albedo_{base}")
sample.set_editor_property("texture", albedo)
sample.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_COLOR)
uvs = library.create_material_expression(
material, unreal.MaterialExpressionTextureCoordinate, -1150, -100)
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, -300, 0)
library.connect_material_expressions(sample, "RGB", multiply, "A")
library.connect_material_expressions(colour, "", multiply, "B")
source = multiply
kind = "albedo x tint"
elif banner_tex is not None and base == "M_Facade_Banner":
# The banner texture carries both the red ground and the gold lettering, so it
# replaces the tint outright (tiling 1.0, not the 6x stone coursing).
sample = library.create_material_expression(
material, unreal.MaterialExpressionTextureSampleParameter2D, -900, -100)
sample.set_editor_property("parameter_name", f"Banner_{base}")
sample.set_editor_property("texture", banner_tex)
sample.set_editor_property("sampler_type", unreal.MaterialSamplerType.SAMPLERTYPE_COLOR)
uvs = library.create_material_expression(
material, unreal.MaterialExpressionTextureCoordinate, -1150, -100)
library.connect_material_expressions(uvs, "", sample, "UVs")
source = sample
kind = "banner"
library.connect_material_property(source, "", unreal.MaterialProperty.MP_BASE_COLOR)
library.recompile_material(material)
saved = unreal.EditorAssetLibrary.save_loaded_asset(material)
built += 1
log(f"{base}_Ref: {kind} {rgb}, saved {saved}")
log(f"authored {built} facade materials")
def drop_stale_mesh():
"""Remove the imported mesh so its slots are rebuilt from the FBX."""
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")