"""Exports the Spearfish 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; Spearfish_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-spearfish" FBX = os.path.join(FOLDER, "fbx") O = bpy.data.objects PARTS = [ # name, pivot rule, parent part, role ("SM_Spearfish_Hull", "zero", None, "static: skin, fins, canards, fairings, ribbed actuators, rails, scoop, stern, sensors, " "vents, seams, window and hatch frames, docking clips"), ("SM_Spearfish_Cabin", "zero", None, "static: cabin lining, deck, two seats, console, racks, lights (seen through the canopy, " "the side windows and the open hatch)"), ("SM_Spearfish_Glass", "zero", None, "static, translucent: the canopy and the two side windows"), ("SM_Spearfish_Hatch", "origin", None, "top hatch lid: swings up about its aft edge"), ("SM_Spearfish_Mast_L", "origin", None, "port whip mast: folds aft about its foot"), ("SM_Spearfish_Mast_R", "origin", None, "starboard whip mast: folds aft about its foot"), ] 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["Spearfish_Root"] saved = {k: root[k] for k in ("masts_folded", "hatch_open_deg")} 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("_spearfish_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() 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(bpy.data.collections["SPEARFISH | Collision (UCX)"].objects, key=lambda o: o.name) if name == "SM_Spearfish_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, "Spearfish_Assembled.fbx"), objs) written.append("Spearfish_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["SPEARFISH | 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") masts = {} for side in ("L", "R"): o = O["SM_Spearfish_Mast_" + side] a = o.rotation_axis_angle ang = float(o.animation_data.drivers[0].driver.expression.split("*")[1]) ax = Vector((a[1], a[2], a[3])) masts["SM_Spearfish_Mast_" + side] = { "pivot_ue_cm": ue(o.matrix_world.translation), "fold_deg": round(math.degrees(ang), 2), "blender_axis": [round(c, 5) for c in ax], "unreal_axis": [round(-ax.y, 5), round(-ax.x, 5), round(ax.z, 5)], "note": "folded = turned fold_deg about blender_axis (right-handed, Blender); the axis change to Unreal is a mirror, so " "in Unreal turn by -fold_deg about unreal_axis", "blender_property": "Spearfish_Root['masts_folded'] 0 raised (as drawn) .. 1 folded"} hatch = O["SM_Spearfish_Hatch"] d = { "asset": "Spearfish: a Tender (sub tub) after the user's orthographic blueprint 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": 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": dict(masts, **{ "SM_Spearfish_Hatch": {"pivot_ue_cm": ue(hatch.matrix_world.translation), "range_deg": [0, 100], "axis": "Unreal Y through its pivot (Blender X); Blender rotation_euler.x = -deg lifts the fore edge", "blender_property": "Spearfish_Root['hatch_open_deg']"}}), "stowing": "fold the masts (masts_folded 1) before the carousel turns: raised they reach 0.52 m into the bay's curved wall; " "folded the least side clearance is %s m (Kazah %s m)" % (sw.get("spearfish", [{}])[0].get("side"), sw.get("kazah", [{}])[0].get("side")), "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), on a column between the dorsal rails"}, "checks": {"motion": mot, "moonpool_board": sw.get("moonpool_board_spearfish"), "carousel_sweep_masts_folded": sw.get("spearfish", [None])[0], "carousel_sweep_masts_raised": sw.get("spearfish_masts_raised"), "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_Spearfish_Exterior_Masks": "UV0, non-colour: R occlusion, G edges, B crevices (hull, glass, hatch, masts)", "T_Spearfish_Exterior_BevelNormal": "UV0, normal (OpenGL, as Blender bakes it; flip green in Unreal)", "T_Spearfish_Cabin_Masks": "UV0 of the cabin", "T_Spearfish_Cabin_BevelNormal": "UV0 of the cabin", "T_Spearfish_Screens": "UV0 of the cabin's three screens, emissive", "tiling": "pack ModSci_Engineer Base/ScratchedMetal, Base/StainedSteel, FloorPanel_A, Base/Glass on UV1 (metres); " "seats: CC0 leather_white (models/vessel-interior/source_textures)"}, } p = os.path.join(FOLDER, "Spearfish_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))