"""Exports the pressure hatch for Unreal: one FBX per mesh at its own pivot with its UCX hulls, an assembled FBX for a quick look, and PressureHatch_layout.json (pivots, motion, timing, materials). Same recipe as the pressure door (models/pressure-door/build/pd_export.py). 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 and the pressure door, 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 "PH | 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-hatch" FBX = os.path.join(FOLDER, "fbx") PRE = Matrix.Rotation(-math.pi / 2, 4, 'Z') R_OPEN, R_RING, R_LEAF = 0.50, 0.83, 0.56 PIN = Vector((0.72, -0.160, 0.0)) CAM_O = Vector((0.0, 0.004, 0.0)) PARTS = ["SM_PressureHatch_Frame", "SM_PressureHatch_Bulkhead", "SM_PressureHatch_Leaf", "SM_PressureHatch_Cam", "SM_PressureHatch_Dog"] def coll(): top = bpy.data.collections["PressureHatch"] c = bpy.data.collections.get("PH | collision") if c is None: c = bpy.data.collections.new("PH | 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 prism(xz, y0, y1): return [Vector((x, y, z)) for (x, z) in xz for y in (y0, y1)] def make_hulls(set_open): set_open(0.0) C = coll() for o in list(C.objects): bpy.data.objects.remove(o) fr, bk, lf = (bpy.data.objects[n] for n in ("SM_PressureHatch_Frame", "SM_PressureHatch_Bulkhead", "SM_PressureHatch_Leaf")) k = 0 # frame: 16 ring segments between the opening and the seal ring, through the wall to the back flange for i in range(16): a0, a1 = math.radians(i * 22.5), math.radians((i + 1) * 22.5) am = (a0 + a1) / 2 pts = [] for a, rs in ((a0, (R_OPEN, R_RING)), (am, (R_RING / math.cos((a1 - a0) / 2),)), (a1, (R_OPEN, R_RING))): for r in rs: pts += [Vector((r * math.cos(a), y, r * math.sin(a))) for y in (-0.118, 0.23)] hull("UCX_SM_PressureHatch_Frame_%02d" % k, pts, fr, C); k += 1 # the wall plate round the ring: four pieces clear of the opening (the bottom left one follows the cut corner) for xz in ([(-0.55, -0.95), (1.05, -0.95), (1.05, -0.50), (-0.9745, -0.50)], [(-1.05, -0.42), (-0.9745, -0.50), (-0.50, -0.50), (-0.50, 0.50), (-1.05, 0.50)], [(0.50, -0.50), (1.05, -0.50), (1.05, 0.50), (0.50, 0.50)], [(-1.05, 0.50), (1.05, 0.50), (1.05, 0.95), (-1.05, 0.95)]): hull("UCX_SM_PressureHatch_Frame_%02d" % k, prism(xz, -0.075, 0.0), fr, C); k += 1 # hinge block hull("UCX_SM_PressureHatch_Frame_%02d" % k, box((0.65, -0.225, -0.27), (0.89, -0.06, 0.27)), fr, C); k += 1 # leaf (shut; stored in the leaf's frame): disc with its front parts and the hub, then the arm with knuckle and handle pts = [] for i in range(24): a = 2 * math.pi * i / 24 pts += [Vector((R_LEAF * math.cos(a), y, R_LEAF * math.sin(a))) for y in (-0.106, 0.07)] hull("UCX_SM_PressureHatch_Leaf_00", pts, lf, C) hull("UCX_SM_PressureHatch_Leaf_01", box((0.42, -0.215, -0.09), (0.765, -0.079, 0.09)), lf, C) # wall round its hole (R 0.64; the frame's ring covers the corners) for i, (lo, hi) in enumerate((((-1.5, 0.0, -1.4), (-0.64, 0.20, 1.4)), ((0.64, 0.0, -1.4), (1.5, 0.20, 1.4)), ((-0.64, 0.0, 0.64), (0.64, 0.20, 1.4)), ((-0.64, 0.0, -1.4), (0.64, 0.20, -0.64)))): hull("UCX_SM_PressureHatch_Bulkhead_%02d" % i, box(lo, hi), bk, 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("_ph_export") bpy.context.scene.collection.children.link(tmpc) lc = bpy.context.view_layer.layer_collection.children["PressureHatch"].children.get("PH | collision") was_excluded = None 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("_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: give the temps 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["PressureHatch_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_")] # the FBX exporter drops the materials of objects that share a mesh: give the dogs single copies meanwhile shared = {} for o in objs: if o.type == 'MESH' and o.data.users > 1: shared[o] = o.data o.data = o.data.copy() try: _fbx(os.path.join(FBX, "PressureHatch_Assembled.fbx"), objs, {"MESH", "EMPTY"}) finally: for o, me in shared.items(): tmp = o.data o.data = me bpy.data.meshes.remove(tmp) written.append("PressureHatch_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, clearances): dogs = sorted((o for o in bpy.data.objects if o.name.startswith("SM_PressureHatch_Dog_")), key=lambda o: o.name) def mats(o): return [m.name for m in o.data.materials] d = { "asset": "Pressure hatch: a round hinged hatch in a wall plate, after the user's reference sheet 'PRESSURE HATCH' (2026-10-14)", "units": "Unreal cm; ue = (x, -y, z) * 100 from Blender. Origin: on the hatch axis, in the wall's front face (not at the floor).", "front": "the side the leaf opens into is Unreal +Y (Blender -Y); the hinge is on the right seen from the front", "opening": {"clear_diameter_cm": 100, "leaf_diameter_cm": 112, "seal_ring_diameter_cm": 166, "plate_cm": [210, 190, 6], "wall_thickness_cm": 20, "note": "mount the axis about 85 cm above the floor for a crouch-through hatch"}, "static_meshes": {n: {"mesh": "/Game/Vessel/PressureHatch/" + n, "location": [0, 0, 0], "materials": mats(bpy.data.objects[n])} for n in PARTS[:2]}, "moving": { "SM_PressureHatch_Leaf": {"parent": None, "pivot": ue(PIN), "motion": "yaw about the vertical hinge pin", "shut_deg": 0.0, "open_deg": -rig["OPEN_DEG"], "note": "Blender +%g deg about Z = Unreal yaw -%g deg (the y flip)" % (rig["OPEN_DEG"], rig["OPEN_DEG"]), "materials": mats(bpy.data.objects["SM_PressureHatch_Leaf"])}, "SM_PressureHatch_Cam": {"parent": "SM_PressureHatch_Leaf", "pivot": ue(CAM_O), "motion": "roll about the hatch axis (Unreal Y) by %g deg, counter-clockwise seen from the front" % abs(rig["CAM_DEG"]), "materials": mats(bpy.data.objects["SM_PressureHatch_Cam"])}, "SM_PressureHatch_Dog": {"parent": "SM_PressureHatch_Leaf", "mesh_local_axis": "local +X points out from the hatch axis; slides along local -X by %g cm (unlocking)" % (rig["DOG_TRAVEL"] * 100), "instances": [{"name": o.name, "shut_location": ue(PIN + Vector(o["closed_location"])), "angle_deg": o["phi_deg"], "rotation_note": "local X points out from the axis at this angle, counter-clockwise from the hinge side seen from the front"} for o in dogs], "materials": mats(bpy.data.objects["SM_PressureHatch_Dog_01"])}, }, "sequence_open_0_to_1": {"cam_and_dogs": [rig["D0"], rig["D1"]], "swing": [rig["S0"], rig["S1"]], "easing": "smoothstep inside each span", "blender_timeline": "shut 1, open 90, held to 150, shut 240 at 30 fps (3 s to open)"}, "clearances_m": clearances, "textures": ["T_PH_Masks (UV0, non-colour: R occlusion, G edges, B crevices)", "T_PH_Hazard_BaseColor (UV1, 0.12 m a repeat)", "pack: ModSci_Engineer Base/ScratchedMetal, Base/StainedSteel, Base/Glass (UV1, metres)"], } p = os.path.join(FOLDER, "PressureHatch_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p