"""Grouper: FBX export and the layout file (headless; the .blend is not changed), after the Tiger's tg_export.py. blender.exe -b Tender_Grouper.blend --python build/gr_export.py -> fbx/SM_Grouper_Hull.fbx (with its UCX_ hulls), SM_Grouper_Glass, SM_Grouper_Cabin, SM_Grouper_Decals (static, pivot = the Tender origin), SM_Grouper_Hatch, _Door_L, _Door_R, _Rotor, _Antenna (moving: each at the world origin, unturned, so its vertices are relative to its own pivot), fbx/Grouper_Assembled.fbx (every mesh in place, at rest), Grouper_layout.json FBX forward -Y, up Z, 1 unit = 1 m (the Kazah's axes); Unreal import with Force Front X Axis, Convert Scene, scale 1. Unreal cm: ue = (-y, -x, z) * 100 (nose +X). LODs (critique round 1, M2): fbx/_LOD1.fbx (about 50 % of the triangles) and _LOD2.fbx (about 20 %) for the hull, the glass, the doors, the hatch and the rotor: small loose parts dropped first (bolts, dots, latches: under 5 cm for LOD1, 12 cm for LOD2), then Blender's collapse decimation; the same pivots as LOD0 (Unreal: Static Mesh > LOD Settings > Import LOD).""" import bpy import json import math import os from mathutils import Matrix HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) FBX = os.path.join(ROOT, "fbx") os.makedirs(FBX, exist_ok=True) CHECKS = os.path.join(HERE, "checks") D = bpy.data root = D.objects["Grouper_Root"] STATIC = ["SM_Grouper_Hull", "SM_Grouper_Glass", "SM_Grouper_Cabin", "SM_Grouper_Decals"] MOVERS = { "SM_Grouper_Hatch": dict(handle="hatch_open_deg", range=[0, 100], axis_blender="X", axis_unreal="Y", what="the hatch lid, hinged at its forward edge (opens forward and up)"), "SM_Grouper_Door_L": dict(handle="door_l_deg", range=[0, 95], axis_blender="Z", axis_unreal="Z", what="port side door, hinge at its aft edge just outside the skin (opens outward, swinging aft)"), "SM_Grouper_Door_R": dict(handle="door_r_deg", range=[0, 95], axis_blender="Z", axis_unreal="Z", what="starboard side door, the mirror of the port one"), "SM_Grouper_Rotor": dict(handle="rotor_turns", range=None, axis_blender="Y", axis_unreal="X", what="stern rotor, 5 blades behind the six-spoke stator; turns = whole revolutions"), "SM_Grouper_Antenna": dict(handle="antenna_fold_deg", range=[0, 70], axis_blender="X", axis_unreal="Y", what="antenna mast, folds aft about its foot's clevis; 0 = as drawn, fold it (70) for docking"), } UCX = sorted([o for o in D.objects if o.name.startswith("UCX_SM_Grouper_Hull")], key=lambda o: o.name) LOD_RATIO = {1: 0.50, 2: 0.20} LOD_SMALL = {1: 0.05, 2: 0.12} LOD_PARTS = ["SM_Grouper_Hull", "SM_Grouper_Glass", "SM_Grouper_Door_L", "SM_Grouper_Door_R", "SM_Grouper_Hatch", "SM_Grouper_Rotor"] for _o in D.objects: # the build's skin chart (kept in the file for reruns of the surfaces pass) if _o.type == 'MESH' and "UV_Grid" in _o.data.uv_layers: _o.data.uv_layers.remove(_o.data.uv_layers["UV_Grid"]) _o.data.uv_layers.active = _o.data.uv_layers["UVMap"] _o.data.uv_layers["UVMap"].active_render = True def tris_of(me): return sum(len(p.vertices) - 2 for p in me.polygons) def lod_mesh(o, level): """a decimated copy of o's mesh: loose parts smaller than LOD_SMALL dropped, then collapsed toward LOD_RATIO of LOD0""" import bmesh me = o.data.copy() bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() seen, drop = set(), [] for f in bm.faces: if f.index in seen: continue comp, st = [], [f] seen.add(f.index) while st: g = st.pop() comp.append(g) for e in g.edges: for h in e.link_faces: if h.index not in seen: seen.add(h.index) st.append(h) vs = {v for g in comp for v in g.verts} xs = [v.co.x for v in vs]; ys = [v.co.y for v in vs]; zs = [v.co.z for v in vs] diag = ((max(xs) - min(xs)) ** 2 + (max(ys) - min(ys)) ** 2 + (max(zs) - min(zs)) ** 2) ** 0.5 if diag < LOD_SMALL[level]: drop += comp if drop and len(drop) < len(bm.faces): bmesh.ops.delete(bm, geom=drop, context='FACES') bm.to_mesh(me) bm.free() target = tris_of(o.data) * LOD_RATIO[level] have = tris_of(me) tmp = D.objects.new("_lod_tmp", me) bpy.context.scene.collection.objects.link(tmp) if have > target * 1.02: m = tmp.modifiers.new("lod", 'DECIMATE') m.decimate_type = 'COLLAPSE' m.ratio = target / have bpy.context.view_layer.update() ev = tmp.evaluated_get(bpy.context.evaluated_depsgraph_get()) red = bpy.data.meshes.new_from_object(ev) tmp.modifiers.remove(m) tmp.data = red D.meshes.remove(me) tmp.data.name = "%s_LOD%d" % (o.name, level) tmp.name = "%s_LOD%d" % (o.name, level) return tmp def fbx(path, objs): for o in bpy.context.view_layer.objects: o.select_set(False) for o in objs: o.hide_set(False) o.hide_viewport = False 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"}, 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, use_custom_props=False) def rearm(): for ob in D.objects: if ob.animation_data: for fc in ob.animation_data.drivers: fc.driver.expression = fc.driver.expression fc.driver.is_valid = True ob.update_tag() bpy.context.view_layer.update() for c in D.collections: c.hide_viewport = False for lc in bpy.context.view_layer.layer_collection.children: lc.hide_viewport = False lc.exclude = False keep = {k: root.get(k, 0.0) for k in ("hatch_open_deg", "door_l_deg", "door_r_deg", "rotor_turns", "antenna_fold_deg")} for k in keep: root[k] = 0.0 rearm() objs = {n: D.objects[n] for n in STATIC + list(MOVERS)} fbx(os.path.join(FBX, "SM_Grouper_Hull.fbx"), [objs["SM_Grouper_Hull"]] + UCX) for n in STATIC[1:]: fbx(os.path.join(FBX, n + ".fbx"), [objs[n]]) fbx(os.path.join(FBX, "Grouper_Assembled.fbx"), list(objs.values())) LODS = {} for n in LOD_PARTS: o = objs[n] for lv in (1, 2): t = lod_mesh(o, lv) mover = n in MOVERS t.matrix_world = Matrix.Identity(4) if mover else o.matrix_world.copy() if mover: # the mover's own frame: its vertices are already relative to its pivot t.matrix_world = Matrix.Identity(4) fbx(os.path.join(FBX, "%s_LOD%d.fbx" % (n, lv)), [t]) LODS["%s_LOD%d" % (n, lv)] = tris_of(t.data) me_ = t.data D.objects.remove(t) D.meshes.remove(me_) pivots, drivers = {}, {} for n in MOVERS: o = objs[n] pivots[n] = [round(c, 4) for c in o.matrix_world.translation] drivers[n] = [{"path": fc.data_path, "index": fc.array_index, "expression": fc.driver.expression, "simple": fc.driver.is_simple_expression} for fc in (o.animation_data.drivers if o.animation_data else [])] for fc in (o.animation_data.drivers if o.animation_data else []): fc.mute = True loc, rot, par = o.location.copy(), o.rotation_euler.copy(), o.parent mw = o.matrix_world.copy() o.location, o.rotation_euler = (0, 0, 0), (0, 0, 0) bpy.context.view_layer.update() assert all(abs(o.matrix_world[i][j] - (1.0 if i == j else 0.0)) < 1e-6 for i in range(4) for j in range(4)), n fbx(os.path.join(FBX, n + ".fbx"), [o]) o.location, o.rotation_euler = loc, rot for fc in (o.animation_data.drivers if o.animation_data else []): fc.mute = False for k, v in keep.items(): root[k] = v rearm() lo, hi = [1e9] * 3, [-1e9] * 3 info = {} for n, o in objs.items(): me = o.data for v in me.vertices: w = o.matrix_world @ v.co for k in range(3): lo[k] = min(lo[k], w[k]) hi[k] = max(hi[k], w[k]) sizes = [len(p.vertices) for p in me.polygons] info[n] = dict(triangles=sum(s - 2 for s in sizes), quads=sizes.count(4), tris=sizes.count(3), ngons=sum(1 for s in sizes if s > 4), uv_layers=[u.name for u in me.uv_layers], materials=[m.name for m in me.materials if m]) info["UCX_SM_Grouper_Hull_*"] = dict(hulls=len(UCX), triangles=sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in UCX)) def ue(v): return [round(-v[1] * 100, 1), round(-v[0] * 100, 1), round(v[2] * 100, 1)] def _j(name): f = os.path.join(CHECKS, name) return json.load(open(f)) if os.path.exists(f) else None _sw = _j("sweep_and_moonpool.json") or {} checks = { "moon_pool_limit_m": 1.0, "moon_pool_at_board": _sw.get("moonpool_board_grouper"), "moon_pool_at_board_kazah_for_comparison": _sw.get("moonpool_board_kazah"), "carousel_sweep_margins": (_sw.get("grouper") or [None])[0], "carousel_sweep_margins_antenna_raised": (_sw.get("grouper_antenna_raised") or [None])[0], "carousel_sweep_margins_kazah": (_sw.get("kazah") or [None])[0], "carousel_triangles": _j("carousel_bvh.json"), "carousel_triangles_antenna_raised": _j("carousel_bvh_raised.json"), "clamp_docking_arm_file": (_j("clamp_DockArm_Carousel.json") or {}).get("summary"), "clamp_docking_arm_file_antenna_raised": (_j("clamp_DockArm_Carousel_raised.json") or {}).get("summary"), "clamp_ship_file": (_j("clamp_M_Ship_v2_interior_season2.json") or {}).get("summary"), "clamp_ship_file_antenna_raised": (_j("clamp_M_Ship_v2_interior_season2_raised.json") or {}).get("summary"), "descent_board_to_pool": (_j("descent.json") or {}).get("summary"), "moving_parts": (_j("motion.json") or {}).get("summary"), "cabin_inside_the_skin": _j("cabin.json"), "seats_view": (_j("view.json") or {}).get("summary", _j("view.json")), } textures = sorted(f for f in os.listdir(os.path.join(ROOT, "textures")) if f.startswith("T_Grouper_")) sockets = {o.name: {"m": [round(c, 4) for c in o.matrix_world.translation], "ue_cm": ue(list(o.matrix_world.translation))} for o in sorted(D.objects, key=lambda o: o.name) if o.name.startswith("SOCKET_")} cam = _j("concept_cam.json") dec = json.load(open(os.path.join(HERE, "decals.json"))) surf = json.load(open(os.path.join(HERE, "surfaces_report.json"))) if os.path.exists(os.path.join(HERE, "surfaces_report.json")) else {} layout = { "name": "Grouper (working name)", "source": "reference/user_blueprint.jpg (DS-ST-001, 1408 x 768) rules the shape (SIDE first); reference/user_concept.jpg " "(1408 x 768) rules the look; the user's 2026-10-22", "frame": "1 unit = 1 m, nose to -Y, port +X, Z up (the Kazah's); origin on the centre line 9 cm behind the docking clamp's " "axis; the docking arm's wrist 1.94 m over the origin", "scale": "0.0171 m per sheet pixel (the sheet's top ring = the Kazah docking collar)", "fbx": {"forward": "-Y", "up": "Z", "unreal_import": "Force Front X Axis on, Convert Scene on, scale 1", "to_unreal_cm": "ue = (-y, -x, z) * 100 (nose +X)", "files": sorted(f for f in os.listdir(FBX) if f.endswith(".fbx"))}, "bounds_m": {"min": [round(x, 3) for x in lo], "max": [round(x, 3) for x in hi], "length": round(hi[1] - lo[1], 3), "span": round(hi[0] - lo[0], 3), "height": round(hi[2] - lo[2], 3)}, "bounds_ue_cm": {"min": ue([hi[0], hi[1], lo[2]]), "max": ue([lo[0], lo[1], hi[2]])}, "meshes": info, "collision": {"SM_Grouper_Hull": [o.name for o in UCX]}, "moving_parts": {n: dict(pivot_m=pivots[n], pivot_ue_cm=ue(pivots[n]), axis_blender=m["axis_blender"], axis_unreal=m["axis_unreal"], range_deg=m["range"], handle='Grouper_Root["%s"]' % m["handle"], blender_drivers=drivers[n], what=m["what"]) for n, m in MOVERS.items()}, "lights": {"handle": 'Grouper_Root["lights"] (0 off .. 1 on)', "rule": "emission = emissive map x strength x lights", "strengths": {"M_Grouper_Gunmetal / _Band / _FrameDark (circuit traces)": 2.5, "M_Grouper_Strip (a 28 mm line in each groove floor)": 3.0, "M_Grouper_DuctGlow": 1.5, "M_Grouper_LampWhite": 8.0, "M_Grouper_ScannerLens": 3.0, "M_Grouper_Screen": 5.0, "M_Grouper_CabinGlow": 3.0, "M_Grouper_Marking (decals)": 2.5, "M_Grouper_Part* (copies on the Parts atlas)": "as their originals"}, "maps": {"exterior": "T_Grouper_Exterior_Emissive.png (UV0)", "parts": "T_Grouper_Parts_Emissive.png (UV0)", "cabin screens": "T_Grouper_Screens.png (UV0)", "decals": "T_Grouper_Decals_Emissive.png (UV0)"}, "unreal_lights": {"lamps (spot, forward -Y)": ["SOCKET_Lamp_L", "SOCKET_Lamp_R", "SOCKET_PodLamp_L", "SOCKET_PodLamp_R", "SOCKET_DrumLamp_L", "SOCKET_DrumLamp_R"], "scanner": "SOCKET_Scanner", "thrust (jet effect)": "SOCKET_Thrust"}}, "sockets": sockets, "clamp": { "axis_m": [0.0, -0.09], "convention": "the Kazah's: the docking arm's wrist 1.94 m over the origin, the clamp axis 9 cm ahead", "collar": {"is": "the sheet's top ring = the Kazah docking collar (no separate clips)", "claw_azimuths_deg": [45, 135, 225, 315], "pocket_half_deg": 16.0, "kazah_profile_in_pockets": {"wall_r_m": [0.506, 0.535], "lip_r_m": 0.552, "wall_z_m": [1.19, 1.335], "lip_top_z_m": 1.375}}, "hold": "the collar's profile only, as every Tender: the shut claws stand 5 mm off the lip (no catch)", "docked_pose": "antenna folded 70 deg (antenna_fold_deg = 70); hatch and doors shut"}, "checks": checks, "materials": surf.get("materials"), "textures": textures, "decals": {"mesh": "SM_Grouper_Decals: the hull's outer skin faces under each marking lifted 1.5 mm; Masked material " "(opacity = T_Grouper_Decals_BaseColor alpha), cast no shadow, no collision", "placed": surf.get("decals"), "sheet": dec.get("tiles")}, "uv": {"UV0": "UVMap: Exterior atlas 4096 px = the hull alone (its skin one arc-length island); Parts atlas 2048 px = the " "doors, hatch, glass, rotor, antenna; Cabin atlas 2048 px; screens: T_Grouper_Screens; decals: the decal sheet", "UV1": "UV_Tile: metres, for the tiling sets", "density_px_per_m": surf.get("uv")}, "lods": {"files": "fbx/_LOD1.fbx, _LOD2.fbx", "ratio": LOD_RATIO, "small_parts_dropped_m": LOD_SMALL, "triangles": LODS}, "concept_camera": cam, } json.dump(layout, open(os.path.join(ROOT, "Grouper_layout.json"), "w"), indent=1) print("GROUPER EXPORT OK", json.dumps(layout["bounds_m"]), json.dumps({k: v.get("triangles") for k, v in info.items()}), json.dumps(LODS))