""" Writes the station as a placement list for Unreal: models/anchorage/anchorage_layout.json. blender -b --factory-startup Anchorage_Station.blend --python build/export_layout.py [-- out.json] It reads the .blend as it is, so pieces moved or added by hand in Blender are exported too, as long as they are instances of kit meshes (mesh datablock with ["ue_mesh"]) inside the "ANCHORAGE STATION" collection. What goes out instances one per kit mesh object: Unreal asset path, world matrix, per-instance material overrides decals the lettering and the demo rooms' own decals: Unreal decal material, world matrix (decal size 1,1,1) lights the demo rooms' lights with their original Unreal values and the factor that brings them to one exposure (see build_anchorage.py LIGHT_SCALE) markers named points for game use (player start, berths) custom_meshes the station's own meshes (outer shell, decks, the ice): the FBX that build/export_custom.py writes for each, the Unreal material of every slot, how it collides, where it stands, and its size as Unreal will see it (so the import can be checked against it) props models Unreal has to import once (polyhaven.com, the user's Fab library, the coolers, the furnishing of build/furnish.py), each with its places robots the furnishing's robots: the pack's skeletal mesh, the materials of its paint scheme, the animation and the time in it that the Blender pose was taken from (build/robot_prepare.py) Matrices are Unreal's: cm, left-handed, four rows (X axis, Y axis, Z axis, origin), i.e. what unreal.Matrix(x_plane, y_plane, z_plane, w_plane) takes. build/ue_spawn_layout.py turns the file into a level. """ import json import math import os import sys import bpy sys.dont_write_bytecode = True # leave no __pycache__ next to the build scripts HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import bl_demo_map as demo # noqa: E402 import exterior as ext # noqa: E402 import own_materials # noqa: E402 from bl_kit import ROOT, matrix_to_ue_rows # noqa: E402 ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] OUT = ARGS[0] if ARGS else os.path.join(ROOT, "anchorage_layout.json") STATION = "ANCHORAGE STATION" def r(rows): return [[round(v, 4) for v in row] for row in rows] def main(): root = bpy.data.collections.get(STATION) if root is None: raise RuntimeError("no collection %r in this file" % STATION) out = { "name": "Anchorage Station", "source": "Modular SciFi Season 1 Starter Bundle (/Game/StarterBundle)", "units": "Unreal: cm, left-handed. Matrices are 4 rows: X axis, Y axis, Z axis, origin.", "origin": "centre of the Central Crossing, floor level; Unreal +X = east, -Y = north (Blender +Y)", "instances": [], "decals": [], "lights": [], "markers": [], "custom_meshes": [], "props": [], "materials": [], "robots": [], } props_used = {} light_fix_inv = demo.LIGHT_FIX.inverted() meshes_used = {} zones = {} for ob in sorted(root.all_objects, key=lambda o: o.name): zone = ob.get("anchorage_zone", "") if ob.get("anchorage_marker") is not None: loc = ob.matrix_world.translation out["markers"].append({ "name": ob.name.replace("MARKER | ", ""), "location": [round(loc.x * 100.0, 2), round(-loc.y * 100.0, 2), round(loc.z * 100.0, 2)], "yaw": round(-math.degrees(ob.rotation_euler.z), 3), "note": ob["anchorage_marker"], }) continue if ob.type == "EMPTY" and ob.get("robot_skeletal_mesh"): # a robot of the furnishing (build/furnish.py): the pack's skeletal mesh, posed by its animation out["robots"].append({"name": ob.name, "skeletal_mesh": ob["robot_skeletal_mesh"], "materials": list(ob.get("robot_materials", [])), "anim": ob.get("robot_anim", ""), "anim_time_s": float(ob.get("robot_anim_time", 0.0)), "pose": ob.get("robot_pose", ""), "matrix": r(matrix_to_ue_rows(ob.matrix_world)), "zone": zone, "note": "the Blender preview bakes this pose; 'reach' adds an arm on a panel " "(source_fab/robot_scout/robot_scout.blend)"}) continue if ob.type == "EMPTY" and ob.get("fab_prop") and ob.instance_collection is not None: # a model from the user's Fab library or Assets folder, placed as an instance of its collection (the # coolers, build/coolers.py; the furnishing, build/furnish.py): Unreal has to import the model once; it is # listed with its places, like the props from polyhaven.com key = ob.get("prop_asset") or ("fab:" + ob["fab_prop"]) src = ob.get("prop_file") or "source_fab/scifiEngine_005/scifiEngine_005_ice_cooler.blend" out["props"].append({"name": ob.name, "asset": key, "source": "%s (%s)" % (src, ob.get("prop_source") or "Fab: %s, %s" % ( ob.get("fab_title", ""), ob.get("fab_seller", ""))), "matrix": r(matrix_to_ue_rows(ob.matrix_world)), "zone": zone, "module": ob.get("anchorage_module", "")}) props_used[key] = props_used.get(key, 0) + 1 continue if ob.type == "MESH" and ob.get("ue_decal_material"): out["decals"].append({ "name": ob.name, "material": ob["ue_decal_material"], "size": [1.0, 1.0, 1.0], "matrix": r(matrix_to_ue_rows(ob.matrix_world)), "zone": zone, "module": ob.get("anchorage_module", ""), }) zones.setdefault(zone, [0, 0, 0])[1] += 1 continue if ob.type == "MESH" and ob.get("anchorage_custom_mesh"): me = ob.data pts = [ob.matrix_world @ v.co for v in me.vertices] lo = [min(p[k] for p in pts) for k in range(3)] hi = [max(p[k] for p in pts) for k in range(3)] depth = float(ob.get("anchorage_underlay_depth", 0.0)) fbx = "exterior_export/fbx/%s.fbx" % ob["anchorage_custom_mesh"] rec = { "name": ob["anchorage_custom_mesh"], "fbx": fbx, "fbx_exists": os.path.isfile(os.path.join(ROOT, fbx.replace("/", os.sep))), "materials": [(m.get("ue_material", "") if m is not None else "") for m in me.materials], "material_names": [(m.name if m is not None else "") for m in me.materials], "collision": ob.get("anchorage_collision", "complex"), "matrix": r(matrix_to_ue_rows(ob.matrix_world)), "zone": zone, "module": ob.get("anchorage_module", ""), "note": ob.get("anchorage_note", ""), # Unreal's box of the mesh in the level, cm: x as here, y turned round, z as here "bounds_min": [round(lo[0] * 100.0, 1), round(-hi[1] * 100.0, 1), round(lo[2] * 100.0, 1)], "bounds_max": [round(hi[0] * 100.0, 1), round(-lo[1] * 100.0, 1), round(hi[2] * 100.0, 1)], "triangles": sum(len(p.vertices) - 2 for p in me.polygons), "surroundings": bool(ob.get("anchorage_surroundings")), } if ob.get("anchorage_underlay_material"): rec["underlay"] = {"material": ob["anchorage_underlay_material"], "under_slot": int(ob["anchorage_underlay_slot"]), "depth_cm": round(depth * 100.0, 1), "note": "the FBX has these faces once more, lower, in this material; not in the .blend"} out["custom_meshes"].append(rec) zones.setdefault(zone, [0, 0, 0]) continue if ob.type == "MESH" and ob.data.get("ue_mesh"): mw = ob.matrix_world if ob.get("ue_role") == "door_leaf": mw = ext.door_closed_matrix(ob) # the doors are rigged (build/doors.py): Unreal gets them shut rec = {"name": ob.name, "mesh": ob.data["ue_mesh"], "matrix": r(matrix_to_ue_rows(mw)), "zone": zone, "module": ob.get("anchorage_module", "")} if ob.get("anchorage_surroundings"): rec["surroundings"] = True over = {} for i, slot in enumerate(ob.material_slots): if slot.link == "OBJECT" and slot.material is not None and slot.material.get("ue_material"): over[str(i)] = slot.material["ue_material"] if over: rec["materials"] = over if ob.get("ue_role"): rec["role"] = ob["ue_role"] if ob.get("furnish_cut_by"): # a feeder's tunnel goes into this wall: in Blender a boolean cuts it (build/furnish.py cut_walls); in # Unreal the pack's mesh is whole, so the hole needs a cut copy of it there rec["cut_by_feeders"] = list(ob["furnish_cut_by"]) if ob.get("ue_door_travel_m") is not None: t = list(ob["ue_door_travel_m"]) # how the leaf opens, in Unreal's axes and centimetres; a housing above takes it (build/outpost_more.py) rec["door_travel_cm"] = [round(t[0] * 100.0, 1), round(-t[1] * 100.0, 1), round(t[2] * 100.0, 1)] if ob.get("ue_door_open_seconds") is not None: rec["door_open_seconds"] = round(float(ob["ue_door_open_seconds"]), 3) # as in the Blender rig out["instances"].append(rec) meshes_used[rec["mesh"]] = meshes_used.get(rec["mesh"], 0) + 1 zones.setdefault(zone, [0, 0, 0])[0] += 1 continue if ob.type == "MESH" and ob.data.get("ph_asset"): # a model from polyhaven.com: its glTF lies beside the station; Unreal has to import it once out["props"].append({"name": ob.name, "asset": ob.data["ph_asset"], "source": ob.data.get("ph_source", ""), "matrix": r(matrix_to_ue_rows(ob.matrix_world)), "zone": zone, "module": ob.get("anchorage_module", "")}) props_used[ob.data["ph_asset"]] = props_used.get(ob.data["ph_asset"], 0) + 1 continue if ob.type == "LIGHT" and ob.get("ue_light"): ue = json.loads(ob["ue_light"]) m = ob.matrix_world @ light_fix_inv out["lights"].append({ "name": ob.name, "class": ue.get("comp_class", ""), "matrix": r(matrix_to_ue_rows(m)), "ue": {k: v for k, v in ue.items() if k not in ("comp_class", "visible")}, "intensity_scale": round(float(ob.get("ue_intensity_scale", 1.0)), 4), "zone": zone, }) zones.setdefault(zone, [0, 0, 0])[2] += 1 # the station's own materials: instances of the packs' materials with a value changed, and materials made from # scanned texture sets (the user's Fab library, polyhaven.com). Unreal has to make them; the files are listed. wanted = set() for it in out["custom_meshes"]: wanted.update(p for p in it["materials"] if p) for it in out["instances"]: wanted.update((it.get("materials") or {}).values()) for path in sorted(wanted): d = own_materials.OWN_MATERIALS.get(path) if d is not None: out["materials"].append({"path": path, "kind": "instance", "parent": d["parent"], "vectors": d.get("vectors", {}), "scalars": d.get("scalars", {}), "note": d.get("note", "")}) continue for m in bpy.data.materials: if m.get("ue_material") == path and m.get("ue_scan_files"): out["materials"].append({"path": path, "kind": "scan", "files": json.loads(m["ue_scan_files"]), "size_m": float(m.get("ue_scan_size", 2.0)), "uv_unit_m": 4.4, "normal": "OpenGL (flip the green channel in Unreal)", "note": m.get("ue_note", "")}) break out["summary"] = { "props": len(out["props"]), "distinct_props": len(props_used), "own_materials": len(out["materials"]), "robots": len(out["robots"]), "instances": len(out["instances"]), "decals": len(out["decals"]), "lights": len(out["lights"]), "markers": len(out["markers"]), "distinct_meshes": len(meshes_used), "custom_meshes": len(out["custom_meshes"]), "zones": {z: {"meshes": v[0], "decals": v[1], "lights": v[2]} for z, v in sorted(zones.items())}, } out["meshes_used"] = dict(sorted(meshes_used.items())) with open(OUT, "w", encoding="utf-8") as f: json.dump(out, f, indent=1) print("[layout] wrote %s: %s" % (OUT, json.dumps(out["summary"]))) main()