""" Writes the station's own meshes (the outer shell, the decks, the ice: everything that is not a kit piece) as FBX for Unreal: models/anchorage/exterior_export/fbx/.fbx blender -b --factory-startup Anchorage_Station_exterior.blend --python build/export_custom.py One file per object that carries ["anchorage_custom_mesh"]. The file in Blender is not changed. * Units and axes are Unreal's own FBX conventions (metres declared in the file; Blender +Y comes out as Unreal -Y, the same turn the kit's meshes made on their way here). The meshes are modelled in place, so each one is placed in the level at the station's origin without rotation. * Material slots keep their order; build/export_layout.py lists the Unreal material for every slot, and build/ue_spawn_layout.py puts those on the imported mesh. No textures or materials travel in the FBX. * Collision: ["anchorage_collision"] = "complex" (the default here) means Unreal uses the mesh itself, which is right for one-sided walls and for the ice one walks on. "ucx" gets convex hulls named UCX__NN in the same file, one per separate part of the mesh: the decks and ramps, which are walked on and are convex. * ["anchorage_underlay_*"] (the ice): a copy of the faces in one material slot is added a few cm lower in another material. The pack's ice is translucent in Unreal and wants a snow surface under it; see build/ice.py. """ import json import os import sys import bmesh import bpy sys.dont_write_bytecode = True HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) OUT = os.path.join(ROOT, "exterior_export", "fbx") os.makedirs(OUT, exist_ok=True) sc = bpy.context.scene report = {} def export(objs, path, smooth): bpy.ops.object.select_all(action="DESELECT") for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] bpy.ops.export_scene.fbx( filepath=path, use_selection=True, object_types={"MESH"}, apply_unit_scale=True, apply_scale_options="FBX_SCALE_UNITS", axis_forward="-Z", axis_up="Y", bake_space_transform=False, mesh_smooth_type=smooth, use_mesh_modifiers=False, use_triangles=False, use_custom_props=False, add_leaf_bones=False, bake_anim=False, path_mode="AUTO", embed_textures=False) def with_underlay(me, slot, mat_path, depth): """A copy of the mesh with the faces of `slot` once more, `depth` lower, in the material `mat_path`.""" me2 = me.copy() body = None for i, m in enumerate(me2.materials): if m is not None and m.get("ue_material") == mat_path: body = i if body is None: raise RuntimeError("underlay material %s is not on the mesh" % mat_path) bm = bmesh.new() bm.from_mesh(me2) faces = [f for f in bm.faces if f.material_index == slot] dup = bmesh.ops.duplicate(bm, geom=faces) new_faces = [g for g in dup["geom"] if isinstance(g, bmesh.types.BMFace)] new_verts = [g for g in dup["geom"] if isinstance(g, bmesh.types.BMVert)] for v in new_verts: v.co.z -= depth for f in new_faces: f.material_index = body bm.to_mesh(me2) bm.free() me2.update() return me2, len(new_faces) def hulls(ob, name): """Convex hull of every separate part of the mesh, as UCX__NN objects.""" out = [] bm = bmesh.new() bm.from_mesh(ob.data) bm.verts.ensure_lookup_table() seen = set() parts = [] for v in bm.verts: if v.index in seen: continue stack, part = [v], [] seen.add(v.index) while stack: a = stack.pop() part.append(a.co.copy()) for e in a.link_edges: o = e.other_vert(a) if o.index not in seen: seen.add(o.index) stack.append(o) parts.append(part) bm.free() for k, part in enumerate(parts): hb = bmesh.new() for co in part: hb.verts.new(co) res = bmesh.ops.convex_hull(hb, input=hb.verts) junk = list(set(g for g in res.get("geom_interior", []) + res.get("geom_unused", []) if isinstance(g, bmesh.types.BMVert))) if junk: bmesh.ops.delete(hb, geom=junk, context="VERTS") bmesh.ops.remove_doubles(hb, verts=hb.verts, dist=1e-4) bmesh.ops.dissolve_limit(hb, angle_limit=0.01, verts=hb.verts, edges=hb.edges) hm = bpy.data.meshes.new("UCX_%s_%02d" % (name, k + 1)) hb.to_mesh(hm) hb.free() ho = bpy.data.objects.new("UCX_%s_%02d" % (name, k + 1), hm) ho.matrix_world = ob.matrix_world sc.collection.objects.link(ho) out.append(ho) return out for ob in sorted(sc.objects, key=lambda o: o.name): if ob.type != "MESH" or not ob.get("anchorage_custom_mesh"): continue name = ob["anchorage_custom_mesh"] path = os.path.join(OUT, name + ".fbx") extra = [] temp_mesh = None src_mesh = ob.data note = "" if ob.get("anchorage_underlay_material"): temp_mesh, n_new = with_underlay(ob.data, int(ob["anchorage_underlay_slot"]), ob["anchorage_underlay_material"], float(ob["anchorage_underlay_depth"])) ob.data = temp_mesh note = ", %d underlay faces added" % n_new collision = ob.get("anchorage_collision", "complex") if collision == "ucx": extra = hulls(ob, name) note += ", %d collision hulls" % len(extra) smooth = "EDGE" if any(p.use_smooth for p in ob.data.polygons) else "FACE" export([ob] + extra, path, smooth) report[name] = {"fbx": os.path.relpath(path, ROOT).replace("\\", "/"), "bytes": os.path.getsize(path), "faces": len(ob.data.polygons), "hulls": len(extra), "collision": collision} print("[custom] %-22s %6d faces -> %s (%.0f kB)%s" % (name, len(ob.data.polygons), os.path.basename(path), os.path.getsize(path) / 1024.0, note)) ob.data = src_mesh for h in extra: hm = h.data bpy.data.objects.remove(h) bpy.data.meshes.remove(hm) if temp_mesh is not None: bpy.data.meshes.remove(temp_mesh) with open(os.path.join(ROOT, "exterior_export", "custom_meshes.json"), "w", encoding="utf-8") as f: json.dump(report, f, indent=1) print("[custom] %d meshes written to %s" % (len(report), OUT))