""" Exports the player's cybernetic arm from the user's .blend into FBX files + a manifest for Unreal 5.8, and writes the C++ layout table. Run headless (Tools/export_arm.ps1 does this): blender.exe -b D:\\HADAL - The Descent\\models\\robotic-arm\\robotic-arm.blend --python-exit-code 1 ^ --python Tools\\blender\\export_arm.py -- [--out DIR] THE SOURCE .BLEND IS NEVER SAVED. Everything works on copies made in memory; the script compares the file's size and mtime before and after and aborts if they differ. What it produces: SourceArt/Arm/Arm_.fbx one file per mesh part (20), vertices in the PART'S OWN LOCAL FRAME: origin = the joint, axes = the object's axes at rest (so the joint axis is exactly local Y or X, and the rest rotation is applied by the Unreal component, not baked into the mesh) SourceArt/Arm/arm_manifest.json the chain (parent, local offset, rest rotation, joint axis), bounds, materials, and the geometric analysis of handedness / palm direction Source/TheDescent/Player/Arm/ArmLayout.inl the C++ table of the same chain (cm, Unreal axes, GENERATED) The user's standing rule: anything that moves is its own object. The arm keeps every segment separate (RA_Root is an empty; the other 20 are meshes), each with its origin on its joint. Leftover Cube / Camera / Light / RA_Cam / RA_Sun are not part of the chain and are skipped. Coordinate systems Blender: right-handed, +Z up. The arm lies along +X, the palm faces -Z, the thumb is on +Y (checked below from the geometry, not taken from the file's comments). Unreal: left-handed, +X forward, +Y right, +Z up. The arm must still be the SAME physical right arm, so the mapping is ue = (bx, -by, bz) * 100 cm. Its determinant is -1 (a reflection, as any right->left-handed change must be). FBX axes: forward '-Y', up 'Z' (the Kazah pair) after turning each copy -90 degrees about Z; Unreal "convert scene" + "force front X". The Unreal import script compares the imported bounds with the prediction written here (0.05 cm). Rotations: a rotation R_b in Blender becomes M R_b M (M = diag(1,-1,1)) in Unreal axes. The generated joint axis is chosen so that FQuat(axis_ue, +angle) is the SAME physical rotation as Blender's +angle about its axis. """ import bpy import json import math import os import re import sys import time from mathutils import Matrix, Vector, Quaternion # --------------------------------------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------------------------------------- THIS = os.path.abspath(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(THIS), "..", "..")) DEFAULT_OUT = os.path.join(PROJECT_ROOT, "SourceArt", "Arm") LAYOUT_INL = os.path.join(PROJECT_ROOT, "Source", "TheDescent", "Player", "Arm", "ArmLayout.inl") ROOT_NAME = "RA_Root" # The chain the brief names. The export aborts if the file does not contain exactly these (nothing missing, nothing # extra under RA_Root). The order is the order of the generated table (parents always before children). EXPECTED_CHAIN = ( ["RA_Root", "RA_UpperArm", "RA_Forearm", "RA_PistonRod", "RA_Wrist", "RA_Hand"] + ["RA_%s_%d" % (f, i) for f in ("Thumb", "Index", "Middle", "Ring", "Pinky") for i in (1, 2, 3)] ) FINGERS = ("Thumb", "Index", "Middle", "Ring", "Pinky") # kind of joint -> (axis in the part's local frame in BLENDER axes). 'ball' has no single axis (yaw/pitch/roll). JOINT_KINDS = { "RA_Root": ("fixed", None), "RA_UpperArm": ("ball", None), # the shoulder "RA_Forearm": ("hinge", (0, 1, 0)), # the elbow "RA_PistonRod": ("slide", (1, 0, 0)), # slides along its own X inside the forearm "RA_Wrist": ("roll", (1, 0, 0)), # forearm roll "RA_Hand": ("hinge", (0, 1, 0)), # wrist flex } for _f in FINGERS: for _i in (1, 2, 3): JOINT_KINDS["RA_%s_%d" % (_f, _i)] = ("finger", (0, 1, 0)) FBX_FORWARD = "-Y" # same pair as the Kazah export, whose import mapping is proven: ue = (-by, -bx, bz) FBX_UP = "Z" # Blender -> Unreal axis map for positions (before the x100 to centimetres). M_REFLECT = Matrix(((1, 0, 0), (0, -1, 0), (0, 0, 1))) def log(*a): print("[export_arm]", *a, flush=True) def die(msg): print("[export_arm] ERROR:", msg, file=sys.stderr, flush=True) sys.exit(1) def parse_args(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] out = DEFAULT_OUT i = 0 while i < len(argv): if argv[i] == "--out": out = os.path.abspath(argv[i + 1]); i += 2 else: die("unknown argument " + argv[i]) return out # --------------------------------------------------------------------------------------------------------- # Materials (same approach as the Kazah export: plain PBR numbers, duplicates merged by name) # --------------------------------------------------------------------------------------------------------- def canonical_material_name(name): """'RA_ArmorWhite.001' -> 'RA_ArmorWhite' (duplicates made by copy/paste in Blender merge into one).""" n = re.sub(r"\.\d{3}$", "", name) n = re.sub(r"[^A-Za-z0-9]+", "_", n).strip("_") return n if n.startswith("RA_") else "RA_" + n def read_principled(mat): out = {"base_color": [0.5, 0.5, 0.5], "metallic": 0.0, "roughness": 0.5, "alpha": 1.0, "emissive_color": [0.0, 0.0, 0.0], "emissive_strength": 0.0} if not mat or not mat.use_nodes: return out bsdf = next((n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED"), None) if not bsdf: return out def val(name): s = bsdf.inputs.get(name) return s.default_value if s is not None else None bc = val("Base Color") out["base_color"] = [round(bc[0], 4), round(bc[1], 4), round(bc[2], 4)] out["metallic"] = round(float(val("Metallic")), 4) out["roughness"] = round(float(val("Roughness")), 4) out["alpha"] = round(float(val("Alpha")), 4) ec = val("Emission Color") out["emissive_color"] = [round(ec[0], 4), round(ec[1], 4), round(ec[2], 4)] out["emissive_strength"] = round(float(val("Emission Strength")), 4) return out # --------------------------------------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------------------------------------- def to_ue_cm(v): """Blender metres -> Unreal centimetres. '+ 0.0' turns -0.0 into 0.0 so the generated table has no '-0.000'.""" return [round(v.x * 100.0, 4) + 0.0, round(-v.y * 100.0, 4) + 0.0, round(v.z * 100.0, 4) + 0.0] def mesh_local_bounds_blender(me): xs = [v.co.x for v in me.vertices]; ys = [v.co.y for v in me.vertices]; zs = [v.co.z for v in me.vertices] return Vector((min(xs), min(ys), min(zs))), Vector((max(xs), max(ys), max(zs))) def bounds_ue_cm(mn, mx): a, b = to_ue_cm(mn), to_ue_cm(mx) return [min(a[i], b[i]) for i in range(3)], [max(a[i], b[i]) for i in range(3)] def world_bbox_m(obj): pts = [obj.matrix_world @ Vector(c) for c in obj.bound_box] return (Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))), Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))) def tri_count(me): me.calc_loop_triangles() return len(me.loop_triangles) def op_override(active, selected): return bpy.context.temp_override( active_object=active, object=active, selected_objects=selected, selected_editable_objects=selected) def drop_unused_slots(obj): """Removes material slots no face uses and renumbers the faces (Unreal drops unused slots on import anyway).""" me = obj.data idx = [0] * len(me.polygons) me.polygons.foreach_get("material_index", idx) used = sorted(set(idx)) if len(used) == len(me.materials): return keep = [me.materials[i] for i in used] remap = {old: new for new, old in enumerate(used)} me.materials.clear() for m in keep: me.materials.append(m) me.polygons.foreach_set("material_index", [remap[i] for i in idx]) me.update() def remap_materials(obj, src_obj, mat_cache, blender_mats): """Canonical (de-duplicated) material per slot; fixes the face indices.""" me = obj.data old = [s.material for s in src_obj.material_slots] new_names, old_to_new = [], {} for i, m in enumerate(old): cn = canonical_material_name(m.name) if m else "RA_Default" if cn not in new_names: new_names.append(cn) old_to_new[i] = new_names.index(cn) if cn not in mat_cache: mat_cache[cn] = {"source": m, "pbr": read_principled(m)} idx = [0] * len(me.polygons) me.polygons.foreach_get("material_index", idx) idx = [old_to_new.get(i, 0) for i in idx] me.materials.clear() for cn in new_names: bm = blender_mats.get(cn) if bm is None: # The canonical name is usually the file's own material name: reuse it (a new datablock with that name # would be renamed 'RA_X.001' by Blender, and Unreal would then see a slot called 'RA_X_001'). bm = bpy.data.materials.get(cn) or bpy.data.materials.new(cn) blender_mats[cn] = bm me.materials.append(bm) me.polygons.foreach_set("material_index", idx) me.update() def export_fbx(obj, path): for o in bpy.context.scene.objects: o.select_set(False) obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.export_scene.fbx( filepath=path, use_selection=True, object_types={"MESH"}, use_mesh_modifiers=False, mesh_smooth_type="OFF", # write the normals as they are add_leaf_bones=False, bake_anim=False, axis_forward=FBX_FORWARD, axis_up=FBX_UP, 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, ) # --------------------------------------------------------------------------------------------------------- # Rotation conversion # --------------------------------------------------------------------------------------------------------- def rot_to_ue(R): """3x3 rotation in Blender axes -> the same physical rotation in Unreal axes (numeric matrix on UE coordinates).""" return M_REFLECT @ R @ M_REFLECT def quat_xyzw(R_ue): q = R_ue.to_quaternion() if q.w < 0: q = Quaternion((-q.w, -q.x, -q.y, -q.z)) # canonical hemisphere: w >= 0 return [round(q.x, 7) + 0.0, round(q.y, 7) + 0.0, round(q.z, 7) + 0.0, round(q.w, 7) + 0.0] def joint_axis_ue(axis_b, kind="hinge"): """Axis A (Unreal) such that a rotation of +theta about A equals Blender's +theta about axis_b, in Unreal axes. Under the reflection M, M R(a, t) M = R(M a, -t) = R(-M a, t), hence A = -M a. A SLIDE is a translation, a true vector: it maps as M a (no sign flip), so +distance is the same physical direction.""" a = M_REFLECT @ Vector(axis_b) if kind != "slide": a = -a return [round(a.x, 6) + 0.0, round(a.y, 6) + 0.0, round(a.z, 6) + 0.0] def rot_axis_angle_matrix(axis, angle): return Matrix.Rotation(angle, 3, Vector(axis)) # --------------------------------------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------------------------------------- def descendants(obj): out = [] for c in obj.children: out.append(c) out += descendants(c) return out def analyse_hand(objs, mats_by_part): """Works out from the geometry which hand this is and which way the palm faces. Returns a dict for the manifest. Evidence: (1) the direction the fingers point (pivot of each finger root relative to the hand pivot); (2) the palm direction from where the RA_Rubber grip pads sit on each phalanx (they are on the palm side of a real gripper), and from which way a positive rotation about a finger's joint axis moves its tip; (3) the side of the thumb. For a right-handed Blender frame: right hand <=> (fingers x palm) . thumb > 0.""" hand = objs["RA_Hand"] hand_p = hand.matrix_world.translation finger_dirs, palm_dirs, curl_dirs = [], [], [] for f in ("Index", "Middle", "Ring", "Pinky"): p1 = objs["RA_%s_1" % f] finger_dirs.append((p1.matrix_world.translation - hand_p)) # palm side from the grip pads: centroid of the RA_Rubber faces of phalanx 1..2, offset from the bone axis for i in (1, 2): o = objs["RA_%s_%d" % (f, i)] me = o.data rub_idx = [k for k, s in enumerate(o.material_slots) if s.material and canonical_material_name(s.material.name) == "RA_Rubber"] cs = [p.center for p in me.polygons if p.material_index in rub_idx] if not cs: continue c = sum(cs, Vector()) / len(cs) off_local = Vector((0.0, c.y, c.z)) # offset from the bone axis (local X) palm_dirs.append((o.matrix_world.to_3x3() @ off_local).normalized()) # a positive rotation about local Y moves the local +X tip toward local -Z curl_dirs.append((o.matrix_world.to_3x3() @ Vector((0, 0, -1))).normalized()) fdir = sum(finger_dirs, Vector()).normalized() palm = sum(palm_dirs, Vector()).normalized() curl = sum(curl_dirs, Vector()).normalized() # remove any component along the fingers palm = (palm - fdir * palm.dot(fdir)).normalized() thumb_vec = objs["RA_Thumb_1"].matrix_world.translation - objs["RA_Pinky_1"].matrix_world.translation thumb_vec = (thumb_vec - fdir * thumb_vec.dot(fdir)).normalized() triple = fdir.cross(palm).dot(thumb_vec) handed = "right" if triple > 0 else "left" axes = {"+X": Vector((1, 0, 0)), "-X": Vector((-1, 0, 0)), "+Y": Vector((0, 1, 0)), "-Y": Vector((0, -1, 0)), "+Z": Vector((0, 0, 1)), "-Z": Vector((0, 0, -1))} def nearest(v): return max(axes.items(), key=lambda kv: kv[1].dot(v))[0] return { "hand": handed, "fingers_point_blender": nearest(fdir), "palm_faces_blender": nearest(palm), "thumb_side_blender": nearest(thumb_vec), "positive_finger_rotation_moves_tip_toward_blender": nearest(curl), "palm_vs_curl_direction_dot": round(palm.dot(curl), 3), "triple_product_fingers_x_palm_dot_thumb": round(triple, 3), "rule": "right-handed frame: right hand iff (fingers x palm) . thumb > 0", "unreal_note": "ue = (bx, -by, bz): fingers +X, palm -Z (down), thumb -Y (left of the arm) = a RIGHT arm; the left arm is this one mirrored in Y", } def main(): out_dir = parse_args() src = bpy.data.filepath if not src: die("run this with the .blend opened: blender -b --python export_arm.py") st0 = os.stat(src) t_start = time.time() os.makedirs(out_dir, exist_ok=True) scene = bpy.context.scene log("source:", src) if scene.unit_settings.system != "METRIC" or abs(scene.unit_settings.scale_length - 1.0) > 1e-9: die("expected metric units with scale 1.0 (1 Blender unit = 1 m)") root = bpy.data.objects.get(ROOT_NAME) if root is None: die("no object named %s in the file" % ROOT_NAME) chain_objs = [root] + descendants(root) found = sorted(o.name for o in chain_objs) expected = sorted(EXPECTED_CHAIN) if found != expected: die("the chain under %s is not the expected one. Missing: %s. Extra: %s" % ( ROOT_NAME, sorted(set(expected) - set(found)), sorted(set(found) - set(expected)))) objs = {o.name: o for o in chain_objs} skipped = sorted(o.name for o in bpy.data.objects if o.name not in objs) log("chain: %d objects (RA_Root + %d meshes). Skipped (not part of the arm): %s" % (len(objs), len(objs) - 1, skipped)) for n, o in objs.items(): if n != ROOT_NAME and o.type != "MESH": die("%s is not a mesh" % n) s = o.matrix_world.to_scale() if any(abs(abs(c) - 1.0) > 1e-6 for c in s) or any(c < 0 for c in s): die("%s has a scale other than 1 (%s); the export assumes unit scale and would mis-place the joint" % (n, tuple(s))) if o.animation_data is not None: die("%s has animation data; the arm is supposed to be animated in code only" % n) if bpy.data.armatures: die("the file has an armature; this export assumes none") bpy.context.view_layer.update() # ---- handedness / palm direction, from the geometry ---- hand_info = analyse_hand(objs, None) log("hand analysis:", json.dumps(hand_info)) if hand_info["hand"] != "right": log("WARNING: the geometry says LEFT hand. The C++ default assumes a right arm; check DescentArmLayout.") if hand_info["palm_vs_curl_direction_dot"] < 0.7: die("the grip pads and the finger curl direction disagree about which side the palm is on") # ---- the chain: local transforms in Unreal axes, with a numeric self-check ---- depsgraph = bpy.context.evaluated_depsgraph_get() scratch = bpy.data.collections.new("_export_arm_scratch") scene.collection.children.link(scratch) mat_cache, blender_mats = {}, {} parts_out = [] world_ue = {} # name -> 4x4 world matrix in Unreal axes (metres), built from the exported local data total_tris = 0 overall_min, overall_max = Vector((1e9,) * 3), Vector((-1e9,) * 3) exported = [] # (obj_copy, entry) for name in EXPECTED_CHAIN: o = objs[name] parent = o.parent pname = parent.name if parent else None Wp = parent.matrix_world if parent else Matrix.Identity(4) W = o.matrix_world L = Wp.inverted() @ W # local matrix in Blender axes (parent frame) t_b = L.to_translation() R_b = L.to_3x3() R_ue = rot_to_ue(R_b) t_ue_cm = to_ue_cm(t_b) q = quat_xyzw(R_ue) kind, axis_b = JOINT_KINDS[name] axis_ue = joint_axis_ue(axis_b, kind) if axis_b is not None else [0.0, 0.0, 0.0] # numeric proof that the generated axis reproduces the Blender rotation (slides are checked on a vector instead) if axis_b is not None and kind == "slide": if (Vector(axis_ue) - M_REFLECT @ Vector(axis_b)).length > 1e-6: die("slide axis conversion for %s is inconsistent" % name) elif axis_b is not None: for ang in (0.3, -0.7): lhs = rot_to_ue(rot_axis_angle_matrix(axis_b, ang)) rhs = rot_axis_angle_matrix(axis_ue, ang) if max(abs(lhs[i][j] - rhs[i][j]) for i in range(3) for j in range(3)) > 1e-5: die("joint axis conversion for %s is inconsistent" % name) # rest curl about the joint axis (fingers): the Euler Y of the object, which for a phalanx is a pure rotation about # local Y (plus a splay about Z outside it). The thumb root has a composite rest orientation: its reference is 0. rest_curl = 0.0 if kind == "finger" and name != "RA_Thumb_1": rest_curl = round(math.degrees(o.rotation_euler.y), 4) entry = { "name": name, "parent": pname, "parent_index": (EXPECTED_CHAIN.index(pname) if pname else -1), "joint": kind, "joint_axis_blender": list(axis_b) if axis_b is not None else None, "joint_axis_ue": axis_ue, "local_offset_ue_cm": t_ue_cm, "rest_rotation_ue_xyzw": q, "rest_curl_deg": rest_curl, "pivot_world_blender_m": [round(c, 5) for c in W.translation], "pivot_world_ue_cm": to_ue_cm(W.translation), } # the chain rebuilt from the exported numbers must land on the Blender world pivot T_ue = Matrix.Translation(Vector(t_ue_cm) / 100.0) @ R_ue.to_4x4() parent_world = world_ue[pname] if pname else Matrix.Identity(4) world_ue[name] = parent_world @ T_ue expect = Vector(to_ue_cm(W.translation)) / 100.0 err = (world_ue[name].translation - expect).length entry["chain_check_error_m"] = round(err, 9) if err > 1e-5: die("the exported local transforms of %s do not reproduce its world pivot (error %.6f m)" % (name, err)) if o.type == "MESH": ev = o.evaluated_get(depsgraph) me = bpy.data.meshes.new_from_object(ev, preserve_all_data_layers=True, depsgraph=depsgraph) me.name = "Arm_" + name cp = bpy.data.objects.new("Arm_" + name, me) # identity transform: vertices stay in the part's local frame scratch.objects.link(cp) remap_materials(cp, o, mat_cache, blender_mats) drop_unused_slots(cp) bpy.context.view_layer.update() mn, mx = mesh_local_bounds_blender(cp.data) lo, hi = bounds_ue_cm(mn, mx) # The FBX pair below (forward -Y, up Z, Unreal "force front X") maps Blender to Unreal as (-by, -bx, bz), which # puts a model lying along +X on the wrong axis (found by the import's bounds check). Turn the copy by -90 degrees # about Z before it is written: (bx, by) -> (by, -bx), and the importer then yields (bx, -by, bz) = the mapping # this file predicts above. A rotation, so handedness is untouched. cp.data.transform(Matrix.Rotation(-math.pi / 2.0, 4, "Z")) cp.data.update() tris = tri_count(cp.data) total_tris += tris wmn, wmx = world_bbox_m(o) overall_min = Vector((min(overall_min.x, wmn.x), min(overall_min.y, wmn.y), min(overall_min.z, wmn.z))) overall_max = Vector((max(overall_max.x, wmx.x), max(overall_max.y, wmx.y), max(overall_max.z, wmx.z))) entry.update({ "file": "Arm_%s.fbx" % name[3:], "asset": "SM_Arm_%s" % name[3:], "local_bounds_ue_cm": {"min": lo, "max": hi}, "world_bounds_blender_m": {"min": [round(c, 5) for c in wmn], "max": [round(c, 5) for c in wmx]}, "triangles": tris, "materials": [m.name for m in cp.data.materials], }) exported.append((cp, entry)) else: entry.update({"file": None, "asset": None, "triangles": 0, "materials": [], "local_bounds_ue_cm": {"min": [0, 0, 0], "max": [0, 0, 0]}}) parts_out.append(entry) # ---- 'every child's pivot lies on its parent' : the child's pivot is inside (or within 1 cm of) the parent's mesh bounds ---- by_name = {p["name"]: p for p in parts_out} for p in parts_out: if p["parent"] is None or by_name[p["parent"]]["asset"] is None: p["pivot_on_parent_margin_cm"] = 0.0 continue pb = by_name[p["parent"]]["world_bounds_blender_m"] piv = Vector(p["pivot_world_blender_m"]) # distance outside the parent's world AABB (0 when inside) d = Vector((max(pb["min"][i] - piv[i], 0.0, piv[i] - pb["max"][i]) for i in range(3))).length p["pivot_on_parent_margin_cm"] = round(d * 100.0, 3) if d * 100.0 > 1.0: die("the pivot of %s lies %.2f cm outside its parent %s" % (p["name"], d * 100.0, p["parent"])) # ---- FBX per mesh ---- for cp, entry in exported: path = os.path.join(out_dir, entry["file"]) export_fbx(cp, path) entry["fbx_bytes"] = os.path.getsize(path) log("exported %d FBX files, %d triangles in total" % (len(exported), total_tris)) # ---- arm dimensions ---- tip = max(p["world_bounds_blender_m"]["max"][0] for p in parts_out if p.get("asset")) up = by_name["RA_Forearm"]["pivot_world_blender_m"][0] wrist = by_name["RA_Wrist"]["pivot_world_blender_m"][0] hand = by_name["RA_Hand"]["pivot_world_blender_m"][0] dims = { "shoulder_to_elbow_m": round(up, 4), "elbow_to_hand_pivot_m": round(hand - up, 4), "hand_pivot_to_fingertip_m": round(tip - hand, 4), "shoulder_to_fingertip_m": round(tip, 4), "bounds_blender_m": {"min": [round(c, 4) for c in overall_min], "max": [round(c, 4) for c in overall_max]}, } log("dimensions (m):", json.dumps(dims)) mats_out = {} for cn, info in sorted(mat_cache.items()): mats_out[cn] = dict(info["pbr"], source_name=info["source"].name if info["source"] else None) manifest = { "source_blend": src.replace("\\", "/"), "exported_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "axis_note": "ue = (bx, -by, bz) * 100 cm; chain origin = RA_Root = the shoulder; meshes are in each part's own local frame", "fbx_export": {"forward": FBX_FORWARD, "up": FBX_UP}, "handedness": hand_info, "dimensions": dims, "skipped_objects": skipped, "total_triangles": total_tris, "parts": parts_out, "materials": mats_out, } with open(os.path.join(out_dir, "arm_manifest.json"), "w", encoding="utf-8") as f: json.dump(manifest, f, indent=2) log("wrote manifest;", len(parts_out), "parts,", len(mats_out), "materials") write_layout_inl(manifest) st1 = os.stat(src) if (st0.st_size, st0.st_mtime_ns) != (st1.st_size, st1.st_mtime_ns): die("the source .blend changed on disk during export. This script must never do that.") log("source .blend unchanged (size %d, mtime preserved)" % st1.st_size) log("done in %.1f s" % (time.time() - t_start)) def f3(v): return "%.4f, %.4f, %.4f" % (v[0], v[1], v[2]) def write_layout_inl(m): kind_enum = {"fixed": "Fixed", "ball": "Ball", "hinge": "Hinge", "roll": "Roll", "slide": "Slide", "finger": "Finger"} L = [] L.append("// GENERATED by Tools/blender/export_arm.py from %s" % os.path.basename(m["source_blend"])) L.append("// Do not edit by hand: re-run Tools/export_arm.ps1. Included by Player/Arm/DescentArmLayout.cpp.") L.append("//") L.append("// Centimetres, Unreal axes (+X forward = along the arm, +Y right, +Z up), arm at rest. RA_Root is the shoulder.") L.append("// Each part's mesh has its own joint as the asset origin and the part's own axes (see export_arm.py), so the part's") L.append("// component sits at LocalOffsetCm in its parent's frame, turned by RestRotation, and then moves by rotating about") L.append("// JointAxis (FQuat(JointAxis, +angle) = the model's positive rotation: finger/hand/wrist-flex curl toward the palm).") L.append("// Hand analysis from the geometry: %s hand, fingers %s, palm %s, thumb %s (Blender axes)." % ( m["handedness"]["hand"], m["handedness"]["fingers_point_blender"], m["handedness"]["palm_faces_blender"], m["handedness"]["thumb_side_blender"])) L.append("") d = m["dimensions"] L.append("static constexpr double GArmShoulderToElbowCm = %.4f;" % (d["shoulder_to_elbow_m"] * 100)) L.append("static constexpr double GArmElbowToHandPivotCm = %.4f;" % (d["elbow_to_hand_pivot_m"] * 100)) L.append("static constexpr double GArmHandPivotToFingertipCm = %.4f;" % (d["hand_pivot_to_fingertip_m"] * 100)) L.append("static constexpr double GArmShoulderToFingertipCm = %.4f;" % (d["shoulder_to_fingertip_m"] * 100)) L.append("static constexpr bool GArmModelIsRightArm = %s;" % ("true" if m["handedness"]["hand"] == "right" else "false")) L.append("") L.append("static const FDescentArmPartDef GArmPartDefs[] =") L.append("{") for p in m["parts"]: b = p["local_bounds_ue_cm"] q = p["rest_rotation_ue_xyzw"] L.append("\t{ TEXT(\"%s\"), %d, TEXT(\"%s\"), EDescentArmJoint::%s, FVector(%s), FQuat(%.7f, %.7f, %.7f, %.7f), FVector(%s), %.4f," % ( p["name"], p["parent_index"], p["asset"] or "", kind_enum[p["joint"]], f3(p["local_offset_ue_cm"]), q[0], q[1], q[2], q[3], f3(p["joint_axis_ue"]), p["rest_curl_deg"])) L.append("\t FVector(%s), FVector(%s), FVector(%s), %d }," % (f3(p["pivot_world_ue_cm"]), f3(b["min"]), f3(b["max"]), p["triangles"])) L.append("};") L.append("") os.makedirs(os.path.dirname(LAYOUT_INL), exist_ok=True) with open(LAYOUT_INL, "w", encoding="utf-8", newline="\n") as f: f.write("\n".join(L)) log("wrote", LAYOUT_INL) try: main() except SystemExit: raise except Exception as e: import traceback traceback.print_exc() die("unhandled exception: %r" % (e,))