Files
gig-line/Tools/unreal/ue_hero_facade_import.py
T
zwitschi 2dac1d0921 fix(hero): pale marble-limestone stone at the reference's value, per the owner's note
The owner rejected the previous pass on three counts - too saturated, too dark, and the albedo was wrong for the
building. All three were true, and the reason the colour work kept missing is that the albedo never reached the
material: import_albedo() resolved the repository root one level short after the tools move, so every 'stone'
render was the flat tint alone. That is fixed first.

Then measured instead of guessed: the reference's sunlit stone samples sRGB ~0.49 (linear ~0.19), attic
highlight ~0.75, shaded recess ~0.43, and it is nearly neutral, not warm. New albedo generated for a pale
low-contrast marble-limestone ashlar (two checkpoints compared, AlbedoBase won) and the tint moved to
(0.47, 0.455, 0.43) with the shadow at (0.30, 0.29, 0.27), which lands lit stone around 0.62 sRGB - brighter
than the last pass and close to the photographs.

The elevation now reads as pale, low-saturation concrete/marble with thin-jointed ashlar. Row 4-6 re-rendered
and pending. The architectural pass the owner also asked for - carved inscription, medallions, banner lettering,
capital and grille depth - is untouched and recorded as the next step.

THIRD_PARTY_NOTICES carries the new seed and prompt.
2026-11-10 14:17:27 +01:00

280 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.
# 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_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.
# 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, 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 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_albedo()
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
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
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: {'albedo x tint' if source is not colour else 'tint'} {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")