"""Exports the Mako for Unreal, the Kazah's convention (TheDescentUE/Tools/blender/export_kazah.py), as the Spearfish's export: 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; Mako_layout.json with pivots, motion, ranges, sockets, checks and textures. Everything is exported from temporary copies: the scene is left as it was. Headless: blender -b Tender_Mako.blend --python build/export_mako.py""" import bpy, os, json, math from mathutils import Vector, Matrix FOLDER = r"D:\HADAL - The Descent\models\tender-mako" FBX = os.path.join(FOLDER, "fbx") O = bpy.data.objects PARTS = [ # name, pivot rule, role ("SM_Mako_Hull", "zero", "static: skin with the canopy, window and hatch frames and sleeves, the collar ring, jaw with its " "grooves and fangs, the side pods (intakes, slots, nozzles), fins, dorsal spine, belly pack, stern " "housing and duct, lamps, the canopy's frames and rails, the four holder clips and the hatch's coaming"), ("SM_Mako_Cabin", "zero", "static: lining, cockpit deck, pilot seat, console, side sticks, bulkhead with crawl door, hatch trunk trim, " "rear bay rack, lights (seen through the canopy, the side windows and the open hatch)"), ("SM_Mako_Glass", "zero", "static, translucent: the canopy (the whole nose; clear below, pale above the line from the nose tip to the collar)"), ("SM_Mako_Hatch", "origin", "flush hatch lid on the back behind the dorsal spine: swings up about the hinge along its starboard edge"), ("SM_Mako_Rotor", "origin", "stern thruster rotor: spins about the long axis"), ("SM_Mako_Arm_L", "origin", "port stern manipulator: swings out about a vertical axis at its shoulder"), ("SM_Mako_Arm_R", "origin", "starboard stern manipulator: swings out about a vertical axis at its shoulder"), ] PROPS = ("hatch_open_deg", "arms_out_deg", "rotor_turns") 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["Mako_Root"] saved = {k: root.get(k, 0.0) for k in PROPS} for k in PROPS: root[k] = 0.0 for o in O: o.update_tag() bpy.context.view_layer.update() ucx_c = bpy.data.collections["MAKO | Collision (UCX)"] lc = bpy.context.view_layer.layer_collection.children.get(ucx_c.name) tmpc = bpy.data.collections.new("_mako_export") bpy.context.scene.collection.children.link(tmpc) temps, written, held, info = [], [], {}, {} try: for name, rule, role in PARTS: src = O[name] piv = Vector((0, 0, 0)) if rule == "zero" else src.matrix_world.translation.copy() me = src.data.copy() mw = src.matrix_world.copy() if rule == "origin": # at rest the moving parts carry no rotation: export them unturned mw = Matrix.Translation(mw.translation) me.transform(Matrix.Translation(-piv) @ mw) objs = [] for nm, mesh in [(name, me)] + [(h.name, h.data) for h in sorted(ucx_c.objects, key=lambda o: o.name) if name == "SM_Mako_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), "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_"))} objs = [root] + [o for o in root.children_recursive if o.type == 'MESH'] _fbx(os.path.join(FBX, "Mako_Assembled.fbx"), objs) written.append("Mako_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["MAKO | Sockets"].objects} ck = os.path.join(FOLDER, "build", "checks") def _j(n): p = os.path.join(ck, n) return json.load(open(p)) if os.path.exists(p) else {} mot, sw = _j("motion.json").get("summary"), _j("sweep_and_moonpool.json") root = O["Mako_Root"] hmax = root.id_properties_ui("hatch_open_deg").as_dict()["max"] motion = { "SM_Mako_Hatch": {"pivot_ue_cm": ue(O["SM_Mako_Hatch"].matrix_world.translation), "range_deg": [0, hmax], "axis": "Unreal X through its pivot (Blender Y); Blender rotation_euler.y = -deg lifts the port edge", "note": "v04: a flush plate on the back behind the dorsal spine, hinged along its starboard edge", "blender_property": "Mako_Root['hatch_open_deg']"}, "SM_Mako_Rotor": {"pivot_ue_cm": ue(O["SM_Mako_Rotor"].matrix_world.translation), "axis": "Unreal X (Blender Y), either way", "blender_property": "Mako_Root['rotor_turns'] (turns)"}, } for tag, sgn in (("L", -1), ("R", 1)): o = O["SM_Mako_Arm_" + tag] motion["SM_Mako_Arm_" + tag] = {"pivot_ue_cm": ue(o.matrix_world.translation), "range_deg": [0, 90], "axis": "Unreal Z through its pivot; Blender rotation_euler.z = %s deg swings it out" % ("-" if sgn < 0 else "+"), "note": "0 is stowed straight aft beside the duct (the moon pool's 1 m holds only stowed)", "blender_property": "Mako_Root['arms_out_deg']"} d = { "asset": "Mako: a Tender (sub tub) after the user's reference sheet of 2026-10-20; 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": rep["dimensions"][1], "span": rep["dimensions"][0], "height_incl_clips_and_fins": 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": motion, "clamp_interface": {"axis_ue_cm": ue(Vector((0.0, -0.09, 0.0))), "note": "where the user's red line marks it, over the canopy's rear " "and the collar: the docking arm's head hangs the Tender here, wrist 194 cm over the origin; four thin holder " "clips at 45/135/225/315 deg (curved plates 3 cm thick, 22 deg long: wall r 53.5 cm z 118.8-133.5, hook lip " "r 55.2 cm, top z 137.5, the Kazah collar's profile where the claws close); the aft pair stands on the collar, " "the fore pair on short pylons on the canopy's two top rails"}, "checks": {"motion": mot, "moonpool_board": sw.get("moonpool_board_mako"), "carousel_sweep": sw.get("mako", [None])[0], "clamp_carousel_file": _j("clamp_DockArm_Carousel.json").get("summary"), "clamp_ship_file": _j("clamp_M_Ship_v2_interior_season2.json").get("summary")}, "sockets_ue_cm": socks, "textures": {"T_Mako_Exterior_Masks": "UV0, non-colour: R occlusion, G edges, B crevices and seams (hull, glass, hatch, rotor, arms)", "T_Mako_Exterior_BevelNormal": "UV0, normal (OpenGL, as Blender bakes it; flip green in Unreal)", "T_Mako_Cabin_Masks": "UV0 of the cabin", "T_Mako_Cabin_BevelNormal": "UV0 of the cabin", "T_Mako_Screens": "UV0 of the cabin's screen plates, 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, "Mako_layout.json") with open(p, "w", encoding="utf-8") as f: json.dump(d, f, indent=1) return p for ob in bpy.data.objects: # the rig's drivers can come back flagged invalid if ob.animation_data: for fc in ob.animation_data.drivers: fc.driver.expression = fc.driver.expression fc.driver.is_valid = True written, info = export() print("EXPORT OK", written, layout(info))