"""Exports the Manta for Unreal, the Kazah's convention (TheDescentUE/Tools/blender/export_kazah.py): FBX forward -Y, up Z; Unreal import with "Force Front X Axis". Then ue_cm = (-blender_y, -blender_x, blender_z) * 100 (nose to Unreal +X, port to Unreal -Y, up +Z) One FBX per part, its vertices relative to ITS PIVOT (the asset origin in Unreal is the axis it moves about), the hull with its UCX hulls; an assembled FBX for a quick look; Manta_layout.json with pivots, motion, ranges, sockets, materials and textures. Everything is exported from temporary copies: the scene is left as it was. Run in the open Blender or headless.""" import bpy, os, json, math from mathutils import Vector, Matrix FOLDER = r"D:\HADAL - The Descent\models\tender-manta" FBX = os.path.join(FOLDER, "fbx") O = bpy.data.objects PARTS = [ # name, pivot rule, parent part, role ("SM_Manta_Hull", "zero", None, "static: skin, fins, strakes, keel, duct, mast, window frames, holder clips"), ("SM_Manta_Cabin", "zero", None, "static: cabin lining, deck, seat, console, lights (seen through the windows)"), ("SM_Manta_Glass", "zero", None, "static, translucent: the two cabin windows"), ("SM_Manta_Rotor", "origin", None, "rotor: spins about Unreal X (the Tender's long axis)"), ("SM_Manta_ToolBoom", "origin", None, "tool boom: swings about Unreal Z on its hinge"), ("SM_Manta_ToolBit", "origin", "SM_Manta_ToolBoom", "drill and suction head: spins about its own long axis (Unreal X at rest)"), ] def ue(v): return [round(-v[1] * 100, 2), round(-v[0] * 100, 2), round(v[2] * 100, 2)] def _fbx(path, objs): 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={"MESH", "EMPTY"}, 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(): os.makedirs(FBX, exist_ok=True) root = O["Manta_Root"] saved = {k: root[k] for k in ("rotor_turns", "boom_swing_deg", "bit_turns")} for k in saved: root[k] = 0.0 for o in O: o.update_tag() bpy.context.view_layer.update() tmpc = bpy.data.collections.new("_manta_export") bpy.context.scene.collection.children.link(tmpc) temps, written, held = [], [], {} info = {} try: for name, rule, parent, role in PARTS: src = O[name] piv = Vector((0, 0, 0)) if rule == "zero" else src.matrix_world.translation.copy() me = src.data.copy() me.transform(Matrix.Translation(-piv) @ src.matrix_world) objs = [] for nm, mesh in [(name, me)] + [(h.name, h.data) for h in sorted(bpy.data.collections["MANTA | Collision (UCX)"].objects, key=lambda o: o.name) if name == "SM_Manta_Hull"]: if mesh is not me: mesh = mesh.copy() mesh.transform(Matrix.Translation(-piv)) holder = O.get(nm) if holder is not None: # FBX node names are object names: free the name while exporting held[holder] = nm holder.name = nm + "__hold" t = O.new(nm, mesh) tmpc.objects.link(t) temps.append(t) objs.append(t) bpy.context.view_layer.update() path = os.path.join(FBX, name + ".fbx") _fbx(path, objs) written.append(os.path.basename(path)) for t in objs: t.name = "_tmp_" + t.name for h, nm in list(held.items()): h.name = nm held.clear() mn = Vector([min(v.co[i] for v in me.vertices) for i in range(3)]) mx = Vector([max(v.co[i] for v in me.vertices) for i in range(3)]) lo, hi = ue(mn), ue(mx) info[name] = {"file": os.path.basename(path), "role": role, "pivot_ue_cm": ue(piv), "parent": parent, "local_bounds_ue_cm": {"min": [min(a, b) for a, b in zip(lo, hi)], "max": [max(a, b) for a, b in zip(lo, hi)]}, "triangles": sum(len(p.vertices) - 2 for p in me.polygons), "materials": [m.name for m in src.data.materials], "ucx": sum(1 for o in objs if o.name.startswith("_tmp_UCX_"))} # assembled, for a quick look objs = [root] + [o for o in root.children_recursive if o.type == 'MESH'] _fbx(os.path.join(FBX, "Manta_Assembled.fbx"), objs) written.append("Manta_Assembled.fbx") finally: for h, nm in held.items(): h.name = nm for o in temps: me = o.data O.remove(o) if me.users == 0: bpy.data.meshes.remove(me) bpy.data.collections.remove(tmpc) for k, v in saved.items(): root[k] = v bpy.context.view_layer.update() return written, info def layout(info): rep = json.load(open(os.path.join(FOLDER, "build", "build_report.json"))) socks = {o.name: ue(o.matrix_world.translation) for o in bpy.data.collections["MANTA | Sockets"].objects} mot = {} mp = os.path.join(FOLDER, "build", "checks", "motion.json") if os.path.exists(mp): mot = json.load(open(mp)) d = { "asset": "Manta: a third Tender (sub tub) after the user's reference picture of 2026-10-14; working name, not the user's", "units": "Unreal cm; ue = (-blender_y, -blender_x, blender_z) * 100. Origin: as the Kazah's, 9 cm behind the clamp axis " "on the Tender's centre line; nose +X, port -Y, up +Z", "fbx": {"forward": "-Y", "up": "Z", "unreal_import": "Force Front X Axis on, Convert Scene on, scale 1"}, "size_m": {"length_incl_boom": rep["dimensions"][1], "span": rep["dimensions"][0], "height": rep["dimensions"][2]}, "bounds_ue_cm": {"min": [min(a, b) for a, b in zip(ue(rep["bounds_min"]), ue(rep["bounds_max"]))], "max": [max(a, b) for a, b in zip(ue(rep["bounds_min"]), ue(rep["bounds_max"]))]}, "parts": info, "motion": { "SM_Manta_Rotor": {"axis": "Unreal X", "blender_property": "Manta_Root['rotor_turns'] (turns)"}, "SM_Manta_ToolBoom": {"axis": "Unreal Z through its pivot", "range_deg_blender": mot.get("boom_clear_range_deg"), "note": "Blender +deg about Z swings the boom outboard (to starboard) = Unreal yaw -deg (the axis flip); " "the negative end is toward the duct"}, "SM_Manta_ToolBit": {"axis": "its local Unreal X (along the boom)", "blender_property": "Manta_Root['bit_turns'] (turns)"}, }, "clamp_interface": {"axis_ue_cm": ue(Vector((0.0, -0.09, 0.0))), "note": "the docking arm's head hangs the Tender here, wrist 194 cm " "over the origin; four holder clips at 45/135/225/315 deg, wall r 53.5 cm z 118-133.5, hook lip r 55.2 cm, top z 137.5 " "(the Kazah's collar profile, no hatch)", "clips": rep.get("clips")}, "sockets_ue_cm": socks, "textures": {"T_Manta_Exterior_Masks": "UV0, non-colour: R occlusion, G edges, B crevices (hull, glass, rotor, boom, bit)", "T_Manta_Exterior_BevelNormal": "UV0, normal (OpenGL, as Blender bakes it; flip green in Unreal)", "T_Manta_Cabin_Masks": "UV0 of the cabin", "T_Manta_Cabin_BevelNormal": "UV0 of the cabin", "T_Manta_Screens": "UV0 of the cabin's three screens, emissive", "tiling": "pack ModSci_Engineer Base/ScratchedMetal, Base/StainedSteel, FloorPanel_A, Base/Glass on UV1 (metres); " "seat: CC0 leather_white (models/vessel-interior/source_textures)"}, } p = os.path.join(FOLDER, "Manta_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p written, info = export() print("EXPORT OK", written, layout(info))