"""Exports the pressure door for Unreal: one FBX per mesh at its own pivot with its UCX hulls, an assembled FBX for a quick look, and PressureDoor_layout.json (pivots, motion, timing, materials). The project's import pair (FBX forward -Y, up Z; Unreal "Convert Scene" + "Force Front X Axis") gives ue = (-by, -bx, bz); the data is turned -90 deg about Z first, as for the docking arm, so in Unreal ue_cm = (x, -y, z) * 100 (Blender x right, y into the wall, z up) Everything is exported from temporary copies: the scene is left as it was. UCX hulls live in the collection "PD | collision" (kept, excluded from the view layer) so they can be edited. """ import bpy, bmesh, math, os, json from mathutils import Vector, Matrix FOLDER = r"D:\HADAL - The Descent\models\pressure-door" FBX = os.path.join(FOLDER, "fbx") PRE = Matrix.Rotation(-math.pi / 2, 4, 'Z') ZC, R_OPEN, R_COL, R_LEAF = 1.28, 1.08, 1.53, 1.24 HX, HY = 1.62, -0.62 P = Vector((HX, HY, ZC)) PARTS = ["SM_PressureDoor_Frame", "SM_PressureDoor_Housing", "SM_PressureDoor_Bulkhead", "SM_PressureDoor_FloorPlate", "SM_PressureDoor_Leaf", "SM_PressureDoor_Wheel", "SM_PressureDoor_Bolt"] def coll(): top = bpy.data.collections["PressureDoor"] c = bpy.data.collections.get("PD | collision") if c is None: c = bpy.data.collections.new("PD | collision") top.children.link(c) return c def hull(name, pts, owner, C): """convex hull of world points, stored in the owner's local frame""" bm = bmesh.new() vs = [bm.verts.new(p) for p in pts] bmesh.ops.convex_hull(bm, input=vs) loose = [v for v in bm.verts if not v.link_faces] bmesh.ops.delete(bm, geom=loose, context='VERTS') me = bpy.data.meshes.get(name) or bpy.data.meshes.new(name) me.clear_geometry() bm.to_mesh(me) bm.free() me.transform(owner.matrix_world.inverted()) ob = bpy.data.objects.get(name) or bpy.data.objects.new(name, me) ob.data = me if ob.name not in C.objects: C.objects.link(ob) ob.parent = owner ob.matrix_parent_inverse = Matrix.Identity(4) ob.matrix_basis = Matrix.Identity(4) ob.display_type = 'WIRE' ob.hide_render = True return ob def box(lo, hi): return [Vector((x, y, z)) for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])] def make_hulls(set_open): set_open(0.0) C = coll() for o in list(C.objects): bpy.data.objects.remove(o) ob = {n: bpy.data.objects.get(n) for n in PARTS} fr, hs, bk, fl, lf = (ob["SM_PressureDoor_Frame"], ob["SM_PressureDoor_Housing"], ob["SM_PressureDoor_Bulkhead"], ob["SM_PressureDoor_FloorPlate"], ob["SM_PressureDoor_Leaf"]) k = 0 # frame: 16 ring segments between the opening and the collar (wider where the bumper tube runs), clipped at the floor for i in range(16): a0, a1 = math.radians(i * 22.5), math.radians((i + 1) * 22.5) mid = math.degrees((a0 + a1) / 2) ro = 1.70 if 92 <= mid <= 218 else 1.56 pts = [] for a in (a0, (a0 + a1) / 2, a1): for r in ((R_OPEN, ro) if a != (a0 + a1) / 2 else (ro,)): x, z = r * math.cos(a), ZC + r * math.sin(a) for y in (-0.40, 0.42): pts.append(Vector((x, y, max(z, 0.0)))) hull("UCX_SM_PressureDoor_Frame_%02d" % k, pts, fr, C); k += 1 hull("UCX_SM_PressureDoor_Frame_%02d" % k, box((-0.5, 0.42, 0.0), (0.5, 0.78, 0.13)), fr, C) # leaf (in its shut place; stored in the leaf's frame): disc, wheel, arm, handle pts = [] for i in range(24): a = 2 * math.pi * i / 24 for y in (-0.19, -0.64): pts.append(Vector((R_LEAF * math.cos(a), y, ZC + R_LEAF * math.sin(a)))) hull("UCX_SM_PressureDoor_Leaf_00", pts, lf, C) pts = [Vector((0.29 * math.cos(2 * math.pi * i / 16), y, ZC + 0.29 * math.sin(2 * math.pi * i / 16))) for i in range(16) for y in (-0.62, -0.87)] hull("UCX_SM_PressureDoor_Leaf_01", pts, lf, C) hull("UCX_SM_PressureDoor_Leaf_02", box((0.10, -0.74, ZC - 0.18), (HX + 0.12, -0.50, ZC + 0.18)), lf, C) hull("UCX_SM_PressureDoor_Leaf_03", box((0.98, -0.85, ZC - 0.13), (1.10, -0.70, ZC + 0.13)), lf, C) # housing for i, (lo, hi) in enumerate((((1.56, -0.48, 0.0), (2.72, 0.0, 2.28)), ((2.23, -0.74, 0.36), (2.61, -0.44, 1.06)), ((1.48, -0.76, ZC - 0.36), (1.76, -0.40, ZC + 0.36)), ((1.78, -0.55, 2.30), (2.88, 0.0, 2.88)), ((2.90, -0.42, 0.0), (3.22, 0.0, 1.62)), ((2.98, -0.36, 1.60), (3.12, -0.24, 2.70)), ((1.67, -0.60, 0.17), (2.69, -0.44, 0.31)))): hull("UCX_SM_PressureDoor_Housing_%02d" % i, box(lo, hi), hs, C) # wall: round the opening (the frame's ring covers the corners) for i, (lo, hi) in enumerate((((-2.30, 0.0, 0.0), (-R_OPEN, 0.30, 3.40)), ((R_OPEN, 0.0, 0.0), (3.40, 0.30, 3.40)), ((-R_OPEN, 0.0, ZC + R_OPEN), (R_OPEN, 0.30, 3.40)))): hull("UCX_SM_PressureDoor_Bulkhead_%02d" % i, box(lo, hi), bk, C) hull("UCX_SM_PressureDoor_FloorPlate_00", box((-2.30, -3.80, -0.03), (3.40, 0.0, 0.0)), fl, C) return len(C.objects) def _fbx(path, objs, types): for o in bpy.context.view_layer.objects: o.select_set(False) 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=types, use_mesh_modifiers=False, mesh_smooth_type="OFF", add_leaf_bones=False, bake_anim=False, axis_forward="-Y", axis_up="Z", global_scale=1.0, apply_unit_scale=True, apply_scale_options="FBX_SCALE_NONE", use_space_transform=True, bake_space_transform=False, path_mode="STRIP", use_metadata=False, use_tspace=True) def export(set_open): os.makedirs(FBX, exist_ok=True) set_open(0.0) C = coll() tmpc = bpy.data.collections.new("_pd_export") bpy.context.scene.collection.children.link(tmpc) was_excluded = None lc = bpy.context.view_layer.layer_collection.children["PressureDoor"].children.get("PD | collision") if lc: was_excluded = lc.exclude lc.exclude = False written = [] temps = [] try: for name in PARTS: src = bpy.data.objects.get(name) or bpy.data.objects.get(name + "_01") me = src.data.copy() me.transform(PRE) t = bpy.data.objects.new(name, me) if name not in bpy.data.objects else None if t is None: # the name is taken by the scene object: export under it anyway t = bpy.data.objects.new("_tmp_" + name, me) tmpc.objects.link(t) temps.append(t) objs = [t] for h in [o for o in C.objects if o.name.startswith("UCX_" + name + "_")]: hm = h.data.copy() hm.transform(PRE) ht = bpy.data.objects.new("_tmp_" + h.name, hm) tmpc.objects.link(ht) temps.append(ht) objs.append(ht) # FBX node names are the object names: rename the temps to the real names while exporting real = {} for o in objs: want = name if o is t else o.name.replace("_tmp_", "", 1) holder = bpy.data.objects.get(want) if holder is not None and holder is not o: real[holder] = want holder.name = want + "__hold" o.name = want bpy.context.view_layer.update() path = os.path.join(FBX, name + ".fbx") _fbx(path, objs, {"MESH"}) written.append(os.path.basename(path)) for o in objs: o.name = "_tmp_" + o.name for holder, want in real.items(): holder.name = want # assembled, shut, for a quick look root = bpy.data.objects["PressureDoor_Root"] rz = root.rotation_euler.z root.rotation_euler.z = -math.pi / 2 bpy.context.view_layer.update() objs = [root] + [o for o in root.children_recursive if o.type == 'MESH' and not o.name.startswith("UCX_")] _fbx(os.path.join(FBX, "PressureDoor_Assembled.fbx"), objs, {"MESH", "EMPTY"}) written.append("PressureDoor_Assembled.fbx") root.rotation_euler.z = rz bpy.context.view_layer.update() finally: for o in temps: me = o.data bpy.data.objects.remove(o) if me.users == 0: bpy.data.meshes.remove(me) bpy.data.collections.remove(tmpc) if lc is not None and was_excluded is not None: lc.exclude = True return written def ue(v): return [round(v[0] * 100, 2), round(-v[1] * 100, 2), round(v[2] * 100, 2)] def layout(rig): bolts = sorted((o for o in bpy.data.objects if o.name.startswith("SM_PressureDoor_Bolt_")), key=lambda o: o.name) def mats(o): return [m.name for m in o.data.materials] d = { "asset": "Pressure door for the vessel's docking gate (boarding gate, works hall port wall)", "units": "Unreal cm; ue = (x, -y, z) * 100 from Blender. Origin: floor, on the door's axis, in the wall's front face.", "front": "the side the door opens into is Unreal +Y (Blender -Y)", "opening": {"clear_diameter_cm": 216, "axis_height_cm": 128, "sill_cm": 20, "tread_cm": 25}, "static_meshes": {n: {"mesh": "/Game/Vessel/PressureDoor/" + n, "location": [0, 0, 0], "materials": mats(bpy.data.objects[n])} for n in PARTS[:4]}, "moving": { "SM_PressureDoor_Leaf": {"parent": None, "pivot": ue(P), "motion": "yaw about the vertical hinge pin", "shut_deg": 0.0, "open_deg": -100.0, "note": "Blender +100 deg about Z = Unreal yaw -100 deg (the y flip)", "materials": mats(bpy.data.objects["SM_PressureDoor_Leaf"])}, "SM_PressureDoor_Wheel": {"parent": "SM_PressureDoor_Leaf", "pivot": ue(Vector((0, -0.765, ZC))), "motion": "roll about the door axis (Unreal Y)", "open_deg": 90.0, "note": "anticlockwise seen from the front", "materials": mats(bpy.data.objects["SM_PressureDoor_Wheel"])}, "SM_PressureDoor_Bolt": {"parent": "SM_PressureDoor_Leaf", "mesh_local_axis": "slides along its local -X by 28 cm (into the leaf)", "instances": [{"name": b.name, "shut_location": ue(P + Vector(b["closed_location"])), "angle_deg": b["phi_deg"], "rotation_note": "local X points out from the door axis at this angle, seen from the front, counter-clockwise from the hinge side"} for b in bolts]}, }, "sequence_open_0_to_1": {"wheel": [rig["W0"], rig["W1"]], "bolts": [rig["B0"], rig["B1"]], "swing": [rig["S0"], rig["S1"]], "easing": "smoothstep inside each span", "blender_timeline": "shut 1, open 100, shut 260 at 30 fps (3.3 s to open)"}, "clearances_m": {"leaf_to_floor": 0.04, "leaf_to_gasket_shut": 0.002, "pin_in_bore": 0.005, "bolt_to_keeper": 0.005}, "textures": ["T_PD_Masks (UV0, non-colour: R occlusion, G edges, B crevices)", "T_PD_Hazard_BaseColor (UV1, 0.40 m a repeat)", "T_PD_Screen_Emissive (UV0)", "pack: ModSci_Engineer Base/ScratchedMetal, Base/StainedSteel, Base/Glass (UV1, metres)"], } p = os.path.join(FOLDER, "PressureDoor_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p