""" Unreal editor script: turns models/anchorage/anchorage_layout.json into a level, using the ORIGINAL kit assets under /Game/StarterBundle (no geometry is imported; every piece is a StaticMeshActor of the pack's own mesh). Dry run (the default): builds the station in an unsaved temporary level, reads every actor back, compares its transform with the layout file and quits. Nothing is written to the project. UnrealEditor-Cmd.exe TheDescent.uproject -run=pythonscript -script="" -unattended -nullrhi For real (only when asked for): set ANCHORAGE_SAVE_MAP to the level to create, for example /Game/Maps/Anchorage/Anchorage_Station. An existing level of that name is NOT overwritten unless ANCHORAGE_OVERWRITE=1. The pack's own folders are never written to. Outliner folders: Anchorage//Structure | Lettering | Lights, Anchorage/Markers Door leaves are Movable (they are separate meshes with their own pivot); everything else is Static. Lights keep the pack's own values times `intensity_scale` (see build_anchorage.py LIGHT_SCALE). The station's own meshes (layout "custom_meshes": outer shell, decks, the ice) are the one thing that IS imported: their FBX files (build/export_custom.py) become static meshes, get the pack's materials on their slots and the collision the layout names, and are placed like everything else. In the dry run they are imported into /Temp, which exists in memory only, and each import is checked against the layout: size and position of its box, material slots, triangle count. For real they go to ANCHORAGE_MESH_DIR (default /Game/Anchorage/Meshes) and are saved. Environment: ANCHORAGE_LAYOUT (path of the json), ANCHORAGE_SAVE_MAP, ANCHORAGE_OVERWRITE, ANCHORAGE_REPORT, ANCHORAGE_MESH_DIR. """ import json import math import os import sys import time import traceback import unreal sys.dont_write_bytecode = True HERE = os.path.dirname(os.path.abspath(__file__)) LAYOUT = os.environ.get("ANCHORAGE_LAYOUT", os.path.join(os.path.dirname(HERE), "anchorage_layout.json")) SAVE_MAP = os.environ.get("ANCHORAGE_SAVE_MAP", "").strip() OVERWRITE = os.environ.get("ANCHORAGE_OVERWRITE", "") == "1" REPORT = os.environ.get("ANCHORAGE_REPORT", os.path.join(os.path.dirname(HERE), "kit_export", "ue_spawn_report.json")) MESH_DIR = (os.environ.get("ANCHORAGE_MESH_DIR", "").strip() or "/Game/Anchorage/Meshes").rstrip("/") T0 = time.time() def log(msg): unreal.log("[anchorage] +%6.1fs %s" % (time.time() - T0, msg)) def to_transform(rows): X, Y, Z, W = rows m = unreal.Matrix(unreal.Plane(X[0], X[1], X[2], 0.0), unreal.Plane(Y[0], Y[1], Y[2], 0.0), unreal.Plane(Z[0], Z[1], Z[2], 0.0), unreal.Plane(W[0], W[1], W[2], 1.0)) return m.transform() def rows_of(t): m = t.to_matrix() return [[p.x, p.y, p.z] for p in (m.x_plane, m.y_plane, m.z_plane, m.w_plane)] def max_dev(a, b): return max(abs(a[i][j] - b[i][j]) for i in range(4) for j in range(3)) def set_prop(obj, name, value): try: obj.set_editor_property(name, value) return True except Exception: # noqa: BLE001 return False def import_fbx(fbx, dest, name): """One FBX as one static mesh, in place (no pivot baking), without materials or textures.""" ui = unreal.FbxImportUI() ui.set_editor_property("import_mesh", True) ui.set_editor_property("import_as_skeletal", False) ui.set_editor_property("import_animations", False) ui.set_editor_property("import_materials", False) ui.set_editor_property("import_textures", False) ui.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_STATIC_MESH) d = ui.get_editor_property("static_mesh_import_data") d.set_editor_property("combine_meshes", True) d.set_editor_property("convert_scene", True) d.set_editor_property("force_front_x_axis", False) d.set_editor_property("convert_scene_unit", True) d.set_editor_property("import_uniform_scale", 1.0) d.set_editor_property("transform_vertex_to_absolute", True) d.set_editor_property("bake_pivot_in_vertex", False) d.set_editor_property("normal_import_method", unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) d.set_editor_property("remove_degenerates", True) d.set_editor_property("generate_lightmap_u_vs", False) d.set_editor_property("auto_generate_collision", False) d.set_editor_property("build_nanite", False) task = unreal.AssetImportTask() task.set_editor_property("filename", fbx) task.set_editor_property("destination_path", dest) task.set_editor_property("destination_name", name) task.set_editor_property("replace_existing", True) task.set_editor_property("replace_existing_settings", True) task.set_editor_property("automated", True) task.set_editor_property("save", False) task.set_editor_property("factory", unreal.FbxFactory()) task.set_editor_property("options", ui) unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) objs = [] try: objs = list(task.get_objects()) except Exception: # noqa: BLE001 objs = [] if not objs: for p in task.get_editor_property("imported_object_paths") or []: o = unreal.load_object(None, p) if o is not None: objs.append(o) for o in objs: if isinstance(o, unreal.StaticMesh): return o return None def main(): with open(LAYOUT, "r", encoding="utf-8") as f: layout = json.load(f) log("layout %s: %s" % (LAYOUT, json.dumps(layout.get("summary", {})))) les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) eal = unreal.EditorAssetLibrary problems = [] if SAVE_MAP: if eal.does_asset_exist(SAVE_MAP) and not OVERWRITE: raise RuntimeError("%s exists; set ANCHORAGE_OVERWRITE=1 to rebuild it" % SAVE_MAP) if SAVE_MAP.startswith("/Game/StarterBundle"): raise RuntimeError("the pack's own folder is not written to") if eal.does_asset_exist(SAVE_MAP): les.load_level(SAVE_MAP) for a in list(eas.get_all_level_actors()): if unreal.SystemLibrary.is_valid(a) and str(a.get_folder_path()).startswith("Anchorage"): eas.destroy_actor(a) elif not les.new_level(SAVE_MAP): raise RuntimeError("could not create " + SAVE_MAP) log("building INTO %s" % SAVE_MAP) else: # a blank map that exists only in memory: it has no file and is never saved if unreal.EditorLoadingAndSavingUtils.new_blank_map(False) is None: raise RuntimeError("could not create a temporary level for the dry run") log("DRY RUN in an unsaved temporary level: nothing will be saved") # ---- assets ------------------------------------------------------------------------------------------------ cache = {} own = {m["path"]: m for m in layout.get("materials", [])} own_report = [] mel = unreal.MaterialEditingLibrary tools = unreal.AssetToolsHelpers.get_asset_tools() def import_texture(path, dest, name, srgb, normal): task = unreal.AssetImportTask() task.set_editor_property("filename", path) task.set_editor_property("destination_path", dest) task.set_editor_property("destination_name", name) task.set_editor_property("replace_existing", True) task.set_editor_property("automated", True) task.set_editor_property("save", False) tools.import_asset_tasks([task]) tex = None for p in task.get_editor_property("imported_object_paths") or []: o = unreal.load_object(None, p) if isinstance(o, unreal.Texture2D): tex = o if tex is None: return None set_prop(tex, "srgb", srgb) if normal: set_prop(tex, "compression_settings", unreal.TextureCompressionSettings.TC_NORMALMAP) set_prop(tex, "flip_green_channel", True) # the copies are OpenGL style return tex def make_material(d): """One of the station's own materials: an instance of a pack material with values changed, or a material from a scanned texture set laid on by the mesh's UVs.""" name = d["path"].rsplit("/", 1)[-1] dest = d["path"].rsplit("/", 1)[0] if SAVE_MAP else "/Temp/Anchorage_DryRun" if SAVE_MAP and not dest.startswith("/Game/Anchorage"): raise RuntimeError("the station's own materials go under /Game/Anchorage, not " + dest) rec = {"path": d["path"], "kind": d["kind"], "ok": False} own_report.append(rec) def create(cls, factory): # for real: an asset under /Game/Anchorage. In the dry run: an object in memory only (AssetTools does not # make assets under /Temp) return tools.create_asset(name, dest, cls, factory) if SAVE_MAP else unreal.new_object(cls) if d["kind"] == "instance": parent = asset(d["parent"]) if parent is None: return None mi = create(unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew()) if mi is None: return None mel.set_material_instance_parent(mi, parent) for k, v in (d.get("vectors") or {}).items(): mel.set_material_instance_vector_parameter_value(mi, k, unreal.LinearColor(v[0], v[1], v[2], v[3] if len(v) > 3 else 1.0)) for k, v in (d.get("scalars") or {}).items(): mel.set_material_instance_scalar_parameter_value(mi, k, float(v)) mel.update_material_instance(mi) made = mi else: mat = create(unreal.Material, unreal.MaterialFactoryNew()) if mat is None: return None uv = mel.create_material_expression(mat, unreal.MaterialExpressionTextureCoordinate, -900, 0) k = float(d.get("uv_unit_m", 4.4)) / float(d.get("size_m", 2.0)) uv.set_editor_property("u_tiling", k) uv.set_editor_property("v_tiling", k) root = os.path.dirname(LAYOUT) y = -300 rec["textures"] = 0 for key, prop, srgb, normal, sampler, pin in ( ("BaseColor", unreal.MaterialProperty.MP_BASE_COLOR, True, False, unreal.MaterialSamplerType.SAMPLERTYPE_COLOR, "RGB"), ("Roughness", unreal.MaterialProperty.MP_ROUGHNESS, False, False, unreal.MaterialSamplerType.SAMPLERTYPE_LINEAR_COLOR, "R"), ("Normal", unreal.MaterialProperty.MP_NORMAL, False, True, unreal.MaterialSamplerType.SAMPLERTYPE_NORMAL, "RGB")): rel = (d.get("files") or {}).get(key) if not rel: continue src = os.path.join(root, rel.replace("/", os.sep)) tex = import_texture(src, dest, "T_%s_%s" % (name, key), srgb, normal) if os.path.isfile(src) else None if tex is None: problems.append("%s: texture %s did not come in (%s)" % (name, key, src)) continue node = mel.create_material_expression(mat, unreal.MaterialExpressionTextureSample, -500, y) y += 300 node.set_editor_property("texture", tex) node.set_editor_property("sampler_type", sampler) mel.connect_material_expressions(uv, "", node, "UVs") mel.connect_material_property(node, pin, prop) rec["textures"] += 1 if SAVE_MAP: eal.save_loaded_asset(tex) mel.recompile_material(mat) made = mat if SAVE_MAP: eal.save_loaded_asset(made) rec["ok"] = True rec["asset"] = made.get_path_name() return made def asset(path): if path not in cache: cache[path] = make_material(own[path]) if path in own else unreal.load_asset(path) if cache[path] is None: problems.append("asset not found: " + path) return cache[path] checks = [] counts = {"instances": 0, "decals": 0, "lights": 0, "markers": 0} # ---- meshes ------------------------------------------------------------------------------------------------ for it in layout["instances"]: sm = asset(it["mesh"]) if sm is None: continue t = to_transform(it["matrix"]) # by class, not spawn_actor_from_object: the asset factories do not spawn in the commandlet a = eas.spawn_actor_from_class(unreal.StaticMeshActor, t.translation, t.rotation.rotator()) if a is None: problems.append("could not spawn " + it["name"]) continue comp = a.static_mesh_component comp.set_mobility(unreal.ComponentMobility.MOVABLE) # so the mesh and transform can be set comp.set_static_mesh(sm) a.set_actor_transform(t, False, True) a.set_actor_label(it["name"]) a.set_folder_path("Anchorage/%s/Structure" % it.get("zone", "Unsorted")) comp.set_mobility(unreal.ComponentMobility.MOVABLE if it.get("role") == "door_leaf" else unreal.ComponentMobility.STATIC) for idx, mpath in (it.get("materials") or {}).items(): mat = asset(mpath) if mat is not None: comp.set_material(int(idx), mat) a.tags = [unreal.Name("Anchorage"), unreal.Name(it.get("module", ""))] + ([unreal.Name("DoorLeaf")] if it.get("role") == "door_leaf" else []) checks.append((a, it["matrix"], it["name"])) counts["instances"] += 1 # ---- the station's own meshes: outer shell, decks, the ice ------------------------------------------------- custom_report = [] counts["custom_meshes"] = 0 dest = MESH_DIR if SAVE_MAP else "/Temp/Anchorage_DryRun" if SAVE_MAP and dest.startswith("/Game/StarterBundle"): raise RuntimeError("the pack's own folder is not written to") for it in layout.get("custom_meshes", []): fbx = os.path.join(os.path.dirname(LAYOUT), it["fbx"].replace("/", os.sep)) if not os.path.isfile(fbx): problems.append("FBX missing for %s: %s (run build/export_custom.py)" % (it["name"], fbx)) continue sm = import_fbx(fbx, dest, it["name"]) if sm is None: problems.append("import of %s gave no static mesh" % it["name"]) continue rec = {"name": it["name"], "asset": sm.get_path_name()} # the pack's materials on the slots, matched by the slot's name (the Blender material's name) wanted = dict(zip(it.get("material_names", []), it.get("materials", []))) slots = list(sm.get_editor_property("static_materials")) hit = 0 for i, s in enumerate(slots): sname = str(s.get_editor_property("material_slot_name")) iname = str(s.get_editor_property("imported_material_slot_name")) path = wanted.get(sname) or wanted.get(iname) if path is None and len(slots) == len(it.get("materials", [])): path = it["materials"][i] mat = asset(path) if path else None if mat is not None: sm.set_material(i, mat) hit += 1 else: problems.append("%s: no material for slot %d (%s)" % (it["name"], i, sname)) rec["slots"] = len(slots) rec["slots_with_material"] = hit # collision bs = sm.get_editor_property("body_setup") if it.get("collision", "complex") == "complex" and bs is not None: set_prop(bs, "collision_trace_flag", unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE) rec["simple_hulls"] = -1 for count in (lambda: len(bs.get_editor_property("agg_geom").get_editor_property("convex_elems")), lambda: unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem).get_simple_collision_count(sm), lambda: unreal.EditorStaticMeshLibrary.get_simple_collision_count(sm)): try: rec["simple_hulls"] = int(count()) break except Exception: # noqa: BLE001 continue if it.get("collision") == "ucx" and rec["simple_hulls"] == 0: problems.append("%s: the FBX's collision hulls did not arrive" % it["name"]) # is it the size, and at the place, the layout says? This is what proves the FBX's units and axes. box = sm.get_bounding_box() got = [[box.min.x, box.min.y, box.min.z], [box.max.x, box.max.y, box.max.z]] dev = max(abs(got[0][k] - it["bounds_min"][k]) for k in range(3)) dev = max(dev, max(abs(got[1][k] - it["bounds_max"][k]) for k in range(3))) rec["bounds"] = [[round(v, 1) for v in got[0]], [round(v, 1) for v in got[1]]] rec["bounds_expected"] = [it["bounds_min"], it["bounds_max"]] rec["bounds_deviation_cm"] = round(dev, 2) if dev > 2.0: problems.append("%s: imported box is off by %.1f cm: %s, expected %s" % (it["name"], dev, rec["bounds"], rec["bounds_expected"])) try: rec["triangles"] = int(sm.get_num_triangles(0)) except Exception: # noqa: BLE001 rec["triangles"] = -1 rec["triangles_in_blend"] = it.get("triangles") if SAVE_MAP: eal.save_loaded_asset(sm) t = to_transform(it["matrix"]) a = eas.spawn_actor_from_class(unreal.StaticMeshActor, t.translation, t.rotation.rotator()) if a is None: problems.append("could not spawn " + it["name"]) continue comp = a.static_mesh_component comp.set_mobility(unreal.ComponentMobility.MOVABLE) comp.set_static_mesh(sm) a.set_actor_transform(t, False, True) a.set_actor_label(it["name"]) a.set_folder_path("Anchorage/%s/Structure" % it.get("zone", "Unsorted")) comp.set_mobility(unreal.ComponentMobility.STATIC) a.tags = [unreal.Name("Anchorage"), unreal.Name(it.get("module", ""))] checks.append((a, it["matrix"], it["name"])) counts["custom_meshes"] += 1 custom_report.append(rec) log("custom mesh %-22s box off by %.2f cm, %d slots (%d with material), %s triangles, %s hulls" % ( it["name"], dev, rec["slots"], hit, rec["triangles"], rec["simple_hulls"])) # ---- decals ------------------------------------------------------------------------------------------------ for it in layout["decals"]: mat = asset(it["material"]) t = to_transform(it["matrix"]) a = eas.spawn_actor_from_class(unreal.DecalActor, t.translation, t.rotation.rotator()) if a is None: problems.append("could not spawn decal " + it["name"]) continue a.set_actor_transform(t, False, True) a.set_actor_label(it["name"]) a.set_folder_path("Anchorage/%s/Lettering" % it.get("zone", "Unsorted")) dc = a.decal if mat is not None: dc.set_decal_material(mat) set_prop(dc, "decal_size", unreal.Vector(*it.get("size", [1.0, 1.0, 1.0]))) a.tags = [unreal.Name("Anchorage")] checks.append((a, it["matrix"], it["name"])) counts["decals"] += 1 # ---- lights ------------------------------------------------------------------------------------------------ for it in layout["lights"]: cls = it.get("class", "") actor_class = unreal.SpotLight if "Spot" in cls else unreal.PointLight if "Point" in cls else None if actor_class is None: continue t = to_transform(it["matrix"]) a = eas.spawn_actor_from_class(actor_class, t.translation, t.rotation.rotator()) if a is None: problems.append("could not spawn light " + it["name"]) continue a.set_actor_label(it["name"]) a.set_folder_path("Anchorage/%s/Lights" % it.get("zone", "Unsorted")) lc = a.light_component ue = it.get("ue", {}) set_prop(lc, "intensity_units", unreal.LightUnits.UNITLESS) lc.set_intensity(float(ue.get("intensity") or 0.0) * float(it.get("intensity_scale", 1.0))) col = ue.get("light_color") or [255, 255, 255, 255] lc.set_light_color(unreal.LinearColor(col[0] / 255.0, col[1] / 255.0, col[2] / 255.0, 1.0), True) for prop in ("attenuation_radius", "source_radius", "soft_source_radius", "source_length", "inner_cone_angle", "outer_cone_angle", "cast_shadows", "use_inverse_squared_falloff", "light_falloff_exponent", "indirect_lighting_intensity", "volumetric_scattering_intensity"): if ue.get(prop) is not None: set_prop(lc, prop, ue[prop]) if ue.get("use_temperature"): set_prop(lc, "use_temperature", True) set_prop(lc, "temperature", ue.get("temperature", 6500.0)) lc.set_mobility(unreal.ComponentMobility.STATIONARY) a.tags = [unreal.Name("Anchorage")] counts["lights"] += 1 # ---- markers ----------------------------------------------------------------------------------------------- for it in layout["markers"]: cls = unreal.PlayerStart if it["name"].lower().startswith("playerstart") else unreal.TargetPoint loc = unreal.Vector(*it["location"]) if cls is unreal.PlayerStart: loc.z += 100.0 # a PlayerStart sits at capsule centre, not on the floor a = eas.spawn_actor_from_class(cls, loc, unreal.Rotator(roll=0.0, pitch=0.0, yaw=float(it.get("yaw", 0.0)))) if a is None: problems.append("could not spawn marker " + it["name"]) continue a.set_actor_label("Anchorage " + it["name"]) a.set_folder_path("Anchorage/Markers") a.tags = [unreal.Name("Anchorage"), unreal.Name("AnchorageMarker")] counts["markers"] += 1 # ---- robots (build/furnish.py): the pack's skeletal mesh, its paint scheme, its animation held at the pose ---- counts["robots"] = 0 for it in layout.get("robots", []): sk = asset(it["skeletal_mesh"]) if sk is None: continue t = to_transform(it["matrix"]) a = eas.spawn_actor_from_class(unreal.SkeletalMeshActor, t.translation, t.rotation.rotator()) if a is None: problems.append("could not spawn robot " + it["name"]) continue comp = a.skeletal_mesh_component try: comp.set_skeletal_mesh_asset(sk) except AttributeError: comp.set_skeletal_mesh(sk) for i, mpath in enumerate(it.get("materials", [])): mat = asset(mpath) if mat is not None: comp.set_material(i, mat) anim = asset(it["anim"]) if it.get("anim") else None if anim is not None: comp.set_animation_mode(unreal.AnimationMode.ANIMATION_SINGLE_NODE) data = comp.get_editor_property("animation_data") data.set_editor_property("anim_to_play", anim) data.set_editor_property("saved_position", float(it.get("anim_time_s", 0.0))) data.set_editor_property("saved_play_rate", 0.0) # held at the pose; the game drives them later comp.set_editor_property("animation_data", data) a.set_actor_transform(t, False, True) a.set_actor_label(it["name"]) a.set_folder_path("Anchorage/%s/Robots" % it.get("zone", "Unsorted")) a.tags = [unreal.Name("Anchorage"), unreal.Name("AnchorageRobot")] checks.append((a, it["matrix"], it["name"])) counts["robots"] += 1 # ---- read back: does every actor stand where the layout says? --------------------------------------------- worst = 0.0 worst_name = "" bad = 0 for a, rows, name in checks: dev = max_dev(rows_of(a.get_actor_transform()), rows) if dev > worst: worst, worst_name = dev, name if dev > 0.05: bad += 1 if bad <= 10: problems.append("transform of %s is off by %.3f" % (name, dev)) log("spawned %s; checked %d transforms: worst deviation %.4f (%s), %d over 0.05" % ( json.dumps(counts), len(checks), worst, worst_name, bad)) log("the station's own materials: %d made of %d listed (%s)" % ( len([m for m in own_report if m["ok"]]), len(own), ", ".join("%s %s" % (m["path"].rsplit("/", 1)[-1], "ok" if m["ok"] else "FAILED") for m in own_report))) props = layout.get("props", []) if props: fab = [p for p in props if p["asset"].startswith("fab:")] fbx = [p for p in props if p["asset"].startswith("fbx:")] ph = [p for p in props if p not in fab and p not in fbx] log("props listed with their places, not imported by this script (their glTF, .blend and FBX files lie beside " "the layout): %d of %d kinds from polyhaven.com, %d of %d kinds from the user's Fab library and Assets " "folder (the ice coolers, the furnishing), %d of %d kinds of the project's own models (the nutrition " "feeder's FBX)" % (len(ph), len(set(p["asset"] for p in ph)), len(fab), len(set(p["asset"] for p in fab)), len(fbx), len(set(p["asset"] for p in fbx)))) saved = False if SAVE_MAP and not problems: saved = bool(les.save_current_level()) log("saved %s: %s" % (SAVE_MAP, saved)) report = {"layout": LAYOUT, "dry_run": not bool(SAVE_MAP), "saved_map": SAVE_MAP if saved else "", "spawned": counts, "expected": layout.get("summary", {}), "transforms_checked": len(checks), "worst_deviation": worst, "worst_name": worst_name, "over_tolerance": bad, "problems": problems[:60], "assets_loaded": len([v for v in cache.values() if v is not None]), "custom_meshes": custom_report, "own_materials": own_report, "props_listed_not_imported": len(layout.get("props", []))} os.makedirs(os.path.dirname(REPORT), exist_ok=True) with open(REPORT, "w", encoding="utf-8") as f: json.dump(report, f, indent=1) log("report: %s" % REPORT) if problems: raise RuntimeError("%d problem(s), first: %s" % (len(problems), problems[0])) try: main() log("DONE") except Exception as exc: # noqa: BLE001 unreal.log_error("[anchorage] FAIL: %s" % (exc,)) unreal.log_error(traceback.format_exc()) sys.exit(1)