"""Rigs the docking carousel: one armature with a bone on every joint, the parts skinned rigidly to their bones. Reads DockArm_Carousel.blend (never saves it), writes DockArm_Carousel_Rigged.blend, dockarm_rig.json and fbx/SK_DockArm_Carousel.fbx. blender.exe -b --python-exit-code 1 --python rig_dockarm.py Bones (23 deform + 6 controls + 6 mechanism). Every joint bone turns about its own local X only, and positive X is always "lower the hoist" / "open the claw": Root > Turret (yaw = its local Y) > ArmN_Upper (shoulder) > ArmN_Lower (elbow) > ArmN_Head (wrist) > ArmN_Claw1..4 (hinges) hoist: Upper +phi, Lower +2*phi, Head +phi, phi = asin(lowered / 6.0) Controls: CTRL_ArmN_Hoist slides 0..5.3 m down its local Y; its custom property "auto" 1..0 hands the three joints back for posing by hand. CTRL_ArmN_Claws turns 0..25 deg. The hoist is NOT an IK chain: stowed, the wrist sits exactly on the shoulder axis, which is the singular case of a two-bone IK (tried first: 55 mm off at rest and metres off once the turret was turned). The two mechanism bones per arm carry phi and 2*phi from a driver, and the joints copy them, so the motion is exact. """ import bpy, math, os, json from mathutils import Vector, Matrix HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.dirname(HERE) LINK, BOARD, CLAW_OPEN = 3.0, 5.3, math.radians(25.0) bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT, "DockArm_Carousel.blend")) sc = bpy.context.scene vl = bpy.context.view_layer C_ASM = bpy.data.collections["DockArm_Assembly"] src = {o.name: o for o in C_ASM.objects} exec(open(os.path.join(HERE, "hose_rig_setup.py")).read()) # FAN_STEPS, HOSE_GROUPS (the unrigged model's hose rig) def static_pose(k, lower, claw, yaw=None): a = src["Arm%d" % (k + 1)] a["lower_m"] = float(lower); a["claw_open"] = float(claw) for o in src.values(): # the arm, its parts, its hose rig and hose if o.name.startswith(a.name): o.update_tag() if yaw is not None: src["SM_DockArm_Turret"].rotation_euler.z = yaw for k in range(3): static_pose(k, 0.0, 0.0) vl.update() # ------------------------------------------------------------------ armature: bones on the real pivots of the model C_RIG = bpy.data.collections.new("DockArm_Rigged") sc.collection.children.link(C_RIG) ad = bpy.data.armatures.new("DockArm_Rig") rig = bpy.data.objects.new("DockArm_Rig", ad) C_RIG.objects.link(rig) ad.display_type = 'BBONE' rig.show_in_front = True for o in vl.objects: o.select_set(False) rig.select_set(True) vl.objects.active = rig bpy.ops.object.mode_set(mode='EDIT') eb = ad.edit_bones DOWN = Vector((0, 0, -1)) def bone(name, head, tail, zdir, parent=None, connect=False, deform=True, size=0.045): b = eb.new(name) b.head = head; b.tail = tail b.align_roll(Vector(zdir)) if parent: b.parent = eb[parent] b.use_connect = connect b.use_deform = deform b.bbone_x = b.bbone_z = size return b bone("Root", (0, 0, 0), (0, 0, 0.30), (1, 0, 0)) bone("Turret", (0, 0, 0.70), (0, 0, 1.02), (1, 0, 0), "Root", size=0.08) expect_x = {} # bone -> the hinge direction its local X must have (armature space) for k in range(3): n = "Arm%d" % (k + 1) up, lo, hd = src[n + "_UpperLink"], src[n + "_LowerLink"], src[n + "_Head"] S, E, W = up.matrix_world.translation.copy(), lo.matrix_world.translation.copy(), hd.matrix_world.translation.copy() XB = up.matrix_world.to_3x3().col[0].normalized() YB = up.matrix_world.to_3x3().col[1].normalized() bone(n + "_Upper", S, E, DOWN, "Turret") bone(n + "_Lower", E, W, DOWN, n + "_Upper", connect=True) bone(n + "_Head", W, W + DOWN * 0.36, XB, n + "_Lower", connect=True, size=0.06) expect_x[n + "_Upper"] = -YB; expect_x[n + "_Lower"] = YB; expect_x[n + "_Head"] = -YB for j in range(1, 5): cl = src["%s_Claw%d" % (n, j)] H = cl.matrix_world.translation.copy() rj = cl.matrix_world.to_3x3().col[0].normalized() tj = cl.matrix_world.to_3x3().col[1].normalized() bone("%s_Claw%d" % (n, j), H, H + DOWN * 0.295, rj, n + "_Head", size=0.02) expect_x["%s_Claw%d" % (n, j)] = -tj # ore hose: fan bones on the three joints. Each has the rest orientation and parent of the joint bone it shadows and # takes i/n of that bone's rotation, so the hose bends round the joint in even steps. for i in range(1, FAN_STEPS["S"]): bone("%s_HoseS%d" % (n, i), S, S + (E - S).normalized() * 0.25, DOWN, "Turret", size=0.012) for i in range(1, FAN_STEPS["E"]): bone("%s_HoseE%d" % (n, i), E, E + (W - E).normalized() * 0.25, DOWN, n + "_Upper", size=0.012) for i in range(1, FAN_STEPS["W"]): bone("%s_HoseW%d" % (n, i), W, W + DOWN * 0.25, XB, n + "_Lower", size=0.012) bone("CTRL_%s_Hoist" % n, W, W + DOWN * 0.5, XB, "Turret", deform=False, size=0.02) P = W + XB * 0.95 + DOWN * 0.30 bone("CTRL_%s_Claws" % n, P, P + DOWN * 0.25, XB, n + "_Head", deform=False, size=0.02) for i, tag in enumerate(("Phi", "2Phi")): # carriers of the hoist angles (only their local X rotation is read) Q = S + YB * (0.9 + 0.15 * i) + Vector((0, 0, 0.05)) bone("MCH_%s_%s" % (n, tag), Q, Q + Vector((0, 0, 0.12)), XB, "Turret", deform=False, size=0.01) bpy.ops.object.mode_set(mode='OBJECT') for name, x in expect_x.items(): got = ad.bones[name].matrix_local.to_3x3().col[0] assert (got - x).length < 1e-5, ("hinge axis", name, tuple(got), tuple(x)) # bone collections and colours bc_def = ad.collections.new("Deform (joints)") bc_ctl = ad.collections.new("Controls") bc_mch = ad.collections.new("Mechanism") bc_hose = ad.collections.new("Hose (fan bones)") for b in ad.bones: ctl, mch, hos = b.name.startswith("CTRL_"), b.name.startswith("MCH_"), "_Hose" in b.name (bc_ctl if ctl else bc_mch if mch else bc_hose if hos else bc_def).assign(b) b.color.palette = 'THEME01' if ctl else 'THEME10' if mch else 'THEME07' if hos else 'THEME04' bc_mch.is_visible = False bc_hose.is_visible = False # control widgets (wire shapes in the bones' horizontal X-Z plane) C_WGT = bpy.data.collections.new("DockArm_Widgets") sc.collection.children.link(C_WGT) def widget(name, pts): me = bpy.data.meshes.new(name) me.from_pydata(pts, [(i, (i + 1) % len(pts)) for i in range(len(pts))], []) o = bpy.data.objects.new(name, me) C_WGT.objects.link(o) o.hide_render = True return o W_SQUARE = widget("WGT_DockArm_Hoist", [(-0.5, 0, -0.5), (0.5, 0, -0.5), (0.5, 0, 0.5), (-0.5, 0, 0.5)]) W_RING = widget("WGT_DockArm_Claws", [(0.5 * math.cos(2 * math.pi * i / 20), 0, 0.5 * math.sin(2 * math.pi * i / 20)) for i in range(20)]) # ------------------------------------------------------------------ pose: locks, limits, IK, controls def limit_x(pb, lo, hi, name="Joint range"): c = pb.constraints.new('LIMIT_ROTATION') c.name = name c.owner_space = 'LOCAL' c.use_limit_x = True c.min_x, c.max_x = math.radians(lo), math.radians(hi) c.use_transform_limit = True def drive(path, index, expr, var, src_path, more=()): fc = rig.driver_add(path, index) if index is not None else rig.driver_add(path) d = fc.driver d.type = 'SCRIPTED' for name, dp in ((var, src_path),) + tuple(more): v = d.variables.new() v.name = name; v.type = 'SINGLE_PROP' v.targets[0].id = rig v.targets[0].data_path = dp d.expression = expr def follow(pb, mch, ctrl): """the joint copies the mechanism bone's local rotation while the handle's 'auto' is 1""" c = pb.constraints.new('COPY_ROTATION') c.name = "Hoist" c.target = rig; c.subtarget = mch c.target_space = 'LOCAL'; c.owner_space = 'LOCAL' c.mix_mode = 'REPLACE' drive('pose.bones["%s"].constraints["Hoist"].influence' % pb.name, None, "auto", "auto", 'pose.bones["%s"]["auto"]' % ctrl) for pb in rig.pose.bones: pb.rotation_mode = 'XYZ' pb.lock_scale = (True, True, True) if pb.name == "Root": continue pb.lock_location = (True, True, True) pb.lock_rotation = (False, True, True) # hinge = local X rig.pose.bones["Turret"].lock_rotation = (True, False, True) # yaw = local Y (the bone points up) for k in range(3): n = "Arm%d" % (k + 1) P = rig.pose.bones hoist, claws = P["CTRL_%s_Hoist" % n], P["CTRL_%s_Claws" % n] hoist.lock_location = (True, False, True) # slides down its own Y only hoist.lock_rotation = (True, True, True) c = hoist.constraints.new('LIMIT_LOCATION') c.name = "Stowed to BOARD" c.owner_space = 'LOCAL' c.use_min_y = c.use_max_y = True c.min_y, c.max_y = 0.0, BOARD c.use_transform_limit = True hoist["auto"] = 1.0 hoist.id_properties_ui("auto").update(min=0.0, max=1.0, soft_min=0.0, soft_max=1.0, description="1 = this handle drives shoulder, elbow and wrist. 0 = pose the three joints by hand") hoist.custom_shape = W_SQUARE hoist.use_custom_shape_bone_size = False hoist.custom_shape_scale_xyz = (0.45, 0.45, 0.45) hoist.custom_shape_translation = (0.0, 0.0, 1.30) # shown outboard of the head rim limit_x(claws, 0.0, 25.0, "Closed to open") claws.custom_shape = W_RING claws.use_custom_shape_bone_size = False claws.custom_shape_scale_xyz = (0.30, 0.30, 0.30) upb, lpb, hpb = P[n + "_Upper"], P[n + "_Lower"], P[n + "_Head"] # The mechanism, as the vessel interior session widened it on 2026-10-13 (models/vessel-interior/build/carousel_rigged.py, # add_reach; same names and expressions, so that script can still be run on this rig): let down by y, the head also # moves in toward the hub by x = reach_in * (y / 5.3)^2 and stays level. With D = sqrt(x^2 + y^2): # shoulder = atan2(y, x) - acos(D / 6) elbow = pi - 2 acos(D / 6) wrist = elbow - shoulder # reach_in 0 is the plain zigzag (phi, 2 phi, phi; the head stays under the boom tip). hoist["reach_in"] = 0.0 hoist.id_properties_ui("reach_in").update(min=0.0, max=2.5, soft_min=0.0, soft_max=2.0, description="How far the head has moved in toward the hub when the hoist is at BOARD (m). 0 = straight down") x_in = "r*y*y/%.2f" % (BOARD * BOARD) dist = "clamp(sqrt(pow(%s, 2) + y*y)/%.1f, 0.0, 0.99)" % (x_in, 2 * LINK) hp = 'pose.bones["%s"]' % hoist.name for tag, expr in (("Phi", "atan2(y + 0.000000001, %s) - acos(%s)" % (x_in, dist)), ("2Phi", "pi - 2.0*acos(%s)" % dist)): m = P["MCH_%s_%s" % (n, tag)] m.lock_rotation = (True, True, True) drive('pose.bones["%s"].rotation_euler' % m.name, 0, expr, "y", hp + ".location[1]", more=(("r", hp + '["reach_in"]'),)) follow(upb, "MCH_%s_Phi" % n, hoist.name) follow(lpb, "MCH_%s_2Phi" % n, hoist.name) follow(hpb, "MCH_%s_2Phi" % n, hoist.name) # the wrist: elbow minus shoulder, so the head stays level c = hpb.constraints.new('COPY_ROTATION') c.name = "Hoist, less the shoulder" c.target = rig; c.subtarget = "MCH_%s_Phi" % n c.target_space = 'LOCAL'; c.owner_space = 'LOCAL' c.mix_mode = 'ADD' c.use_x, c.use_y, c.use_z = True, False, False c.invert_x = True drive('pose.bones["%s"].constraints["Hoist, less the shoulder"].influence' % hpb.name, None, "auto", "auto", hp + '["auto"]') limit_x(upb, -2.0, 92.0) limit_x(lpb, -2.0, 180.0) limit_x(hpb, -100.0, 100.0) # 2.34 m in at BOARD needs 98.8 (the zigzag never passes 62) for key, joint in (("S", upb), ("E", lpb), ("W", hpb)): # hose fans: i/n of the joint, by hand or by the handle steps = FAN_STEPS[key] for i in range(1, steps): f = P["%s_Hose%s%d" % (n, key, i)] f.lock_rotation = (True, True, True) c = f.constraints.new('COPY_ROTATION') c.name = "%d/%d of %s" % (i, steps, joint.name) c.target = rig; c.subtarget = joint.name c.target_space = 'LOCAL'; c.owner_space = 'LOCAL' c.mix_mode = 'REPLACE' c.influence = i / steps for j in range(1, 5): cpb = P["%s_Claw%d" % (n, j)] c = cpb.constraints.new('COPY_ROTATION') c.name = "Claws control" c.target = rig; c.subtarget = claws.name c.use_x, c.use_y, c.use_z = True, False, False c.target_space = 'LOCAL'; c.owner_space = 'LOCAL' c.mix_mode = 'ADD' limit_x(cpb, -2.0, 30.0) # ------------------------------------------------------------------ skin: a copy of every part in rest position, one bone each def bone_of(name): if name == "SM_DockArm_Mount": return "Root" if name == "SM_DockArm_Turret": return "Turret" return name.replace("_UpperLink", "_Upper").replace("_LowerLink", "_Lower") skinned = {} for o in [o for o in C_ASM.objects if o.type == 'MESH']: me = o.data.copy() me.transform(o.matrix_world) me.update() nm = "SK_DockArm_" + (o.name[len("SM_DockArm_"):] if o.name.startswith("SM_DockArm_") else o.name) me.name = nm n = bpy.data.objects.new(nm, me) C_RIG.objects.link(n) n.parent = rig if o.name.endswith("_OreHose"): # the hose is already weighted ring by ring; its generic groups get this arm's bone names an = o.name.split("_")[0] for vg in n.vertex_groups: vg.name = {"Boom": "Turret", "Upper": an + "_Upper", "Lower": an + "_Lower", "Head": an + "_Head"}.get(vg.name, an + "_Hose" + vg.name) assert vg.name in ad.bones, vg.name else: b = bone_of(o.name) assert b in ad.bones, b n.vertex_groups.new(name=b).add(range(len(me.vertices)), 1.0, 'REPLACE') n.modifiers.new("Armature", 'ARMATURE').object = rig skinned[o.name] = n # ------------------------------------------------------------------ check the rig against the static model's own kinematics def world_verts(o): e = o.evaluated_get(bpy.context.evaluated_depsgraph_get()) M = e.matrix_world.copy() return [M @ v.co for v in e.data.vertices] def deviation(): vl.update() worst = (0.0, "") for name, sk in skinned.items(): a, b = world_verts(src[name]), world_verts(sk) d = max((p - q).length for p, q in zip(a, b)) if d > worst[0]: worst = (d, name) return worst def rig_reset(): for pb in rig.pose.bones: pb.location = (0, 0, 0); pb.rotation_euler = (0, 0, 0) P = rig.pose.bones checks = [] # 1. by hand (ik 0): the documented angles on the joint bones for lower, claw, yaw in ((0.0, 0.0, 0.0), (0.25, 1.0, 0.0), (2.5, 0.5, 0.7), (5.3, 1.0, -2.1)): rig_reset() phi = math.asin(lower / (2 * LINK)) for k in range(3): n = "Arm%d" % (k + 1) lw = lower if k != 1 else 0.0 ph = math.asin(lw / (2 * LINK)) static_pose(k, lw, claw, yaw) P["CTRL_%s_Hoist" % n]["auto"] = 0.0 P[n + "_Upper"].rotation_euler.x = ph P[n + "_Lower"].rotation_euler.x = 2 * ph P[n + "_Head"].rotation_euler.x = ph for j in range(1, 5): P["%s_Claw%d" % (n, j)].rotation_euler.x = claw * CLAW_OPEN P["Turret"].rotation_euler.y = yaw rig.update_tag() d, who = deviation() checks.append(dict(mode="joints by hand", lowered_m=lower, claw=claw, turret_rad=yaw, max_deviation_mm=round(d * 1000, 4), at=who)) # 2. by the controls (auto 1) for lower, claw, yaw in ((0.0, 0.0, 0.0), (0.01, 0.0, 0.0), (0.25, 1.0, 0.0), (1.0, 0.0, 0.4), (2.5, 0.5, 0.7), (4.0, 1.0, 3.0), (5.3, 1.0, -2.1)): rig_reset() for k in range(3): n = "Arm%d" % (k + 1) lw = lower if k != 1 else 0.0 static_pose(k, lw, claw, yaw) P["CTRL_%s_Hoist" % n]["auto"] = 1.0 P["CTRL_%s_Hoist" % n].location.y = lw P["CTRL_%s_Claws" % n].rotation_euler.x = claw * CLAW_OPEN P["Turret"].rotation_euler.y = yaw rig.update_tag() d, who = deviation() checks.append(dict(mode="controls", lowered_m=lower, claw=claw, turret_rad=yaw, max_deviation_mm=round(d * 1000, 4), at=who)) rig_reset() for k in range(3): static_pose(k, 0.0, 0.0, 0.0) P["CTRL_Arm%d_Hoist" % (k + 1)]["auto"] = 1.0 rig.update_tag() vl.update() # 3. reach_in (pulled from the vessel interior's rig): at BOARD with 2.34 m the head is 2.34 m nearer the hub, 5.3 m down, level h1 = P["CTRL_Arm1_Hoist"] h1.location.y = BOARD h1["reach_in"] = 2.34 rig.update_tag(); vl.update() EV = rig.evaluated_get(bpy.context.evaluated_depsgraph_get()).pose.bones S1 = ad.bones["Arm1_Upper"].head_local.copy() out1 = (S1 - ad.bones["Arm1_Upper"].tail_local).normalized() want = S1 - out1 * 2.34 + Vector((0, 0, -BOARD)) ang = {} for part in ("Upper", "Lower", "Head"): pb = EV["Arm1_" + part] rest = pb.parent.bone.matrix_local.inverted() @ pb.bone.matrix_local pose = pb.parent.matrix.inverted() @ pb.matrix ang[part] = round(math.degrees((rest.inverted() @ pose).to_euler('XYZ').x), 2) level = EV["Arm1_Head"].matrix.to_3x3() - ad.bones["Arm1_Head"].matrix_local.to_3x3() reach = dict(mode="reach_in 2.34 at BOARD", wrist_error_mm=round((EV["Arm1_Head"].head - want).length * 1000, 4), joints_deg=ang, head_tilt=round(max(abs(v) for r in level for v in r), 6)) checks.append(reach) assert reach["wrist_error_mm"] < 0.1 and reach["head_tilt"] < 1e-4, reach assert abs(ang["Upper"] - 51.1) < 0.1 and abs(ang["Lower"] - 149.9) < 0.1 and abs(ang["Head"] - 98.8) < 0.1, ang h1.location.y = 0.0 h1["reach_in"] = 0.0 rig.update_tag(); vl.update() for c in checks: print("RIGCHECK", c) worst_hand = max(c["max_deviation_mm"] for c in checks if c["mode"] == "joints by hand") worst_ctrl = max(c["max_deviation_mm"] for c in checks if c["mode"] == "controls") print("RIGCHECK reach:", reach) print("RIGCHECK worst: by hand %.4f mm, by controls %.4f mm" % (worst_hand, worst_ctrl)) json.dump(checks, open(os.path.join(HERE, "rig_report.json"), "w"), indent=1) assert worst_hand < 0.01, "the joint bones do not reproduce the model's kinematics" assert worst_ctrl < 0.5, "the controls do not reproduce the model's kinematics" # ------------------------------------------------------------------ rig description for a coder def ue(v, s=100.0): return [round(v[0] * s, 3), round(-v[1] * s, 3), round(v[2] * s, 3)] bones = [] for b in ad.bones: if not b.use_deform: continue n = b.matrix_local.to_3x3().col[1 if b.name == "Turret" else 0] ent = dict(name=b.name, parent=b.parent.name if b.parent else None, head_cm=ue(b.head_local)) if b.name != "Root": # Unreal: FQuat(axis, angle) with this axis turns the bone the same way as +angle on the Blender axis ent["turn_axis_unreal"] = ue(-n, 1.0) ent["blender_axis"] = "local Y" if b.name == "Turret" else "local X" if "_Hose" in b.name: # ore hose fan bone: a fixed share of its joint's angle an, tag = b.name.split("_Hose") key, i = tag[0], int(tag[1:]) ent["angle"] = "%d/%d of %s" % (i, FAN_STEPS[key], an + {"S": "_Upper", "E": "_Lower", "W": "_Head"}[key]) bones.append(ent) rigdoc = { "what": "Skeleton of the docking carousel. Rest pose = stowed. One rigid part per bone (weight 1).", "axes": "Unreal = (bx, -by, bz) * 100 cm, component space, rest pose. turn_axis_unreal is given so that a positive angle means the same as in Blender.", "positive_angle": {"Turret": "counter-clockwise seen from above (Unreal yaw = -angle)", "ArmN_Upper / _Lower / _Head": "lowers the hoist", "ArmN_ClawJ": "opens the claw, 0 .. 25 deg"}, "hoist": "phi = asin(lowered_m / 6.0): Upper +phi, Lower +2*phi, Head +phi. lowered_m = StowedZ - TenderOriginZ, 0 .. 5.3; phi = 62.047 deg at BOARD", "hoist_reaching_in": "with reach_in r (m, 0 .. 2.5; the vessel interior uses 2.34 on the launch arm): y = lowered_m, x = r * (y / 5.3)^2, D = sqrt(x^2 + y^2); " "Upper = atan2(y, x) - acos(D / 6), Lower = pi - 2 * acos(D / 6), Head = Lower - Upper. The head moves x toward the hub and stays level. " "r = 0 gives the plain hoist above. At BOARD with 2.34: 51.1, 149.9, 98.8 deg", "ore_hose": "SK_DockArm_ArmN_OreHose: every ring is weighted 1 to one bone. Boom runs ride on Turret, link runs on ArmN_Upper / _Lower, the end on ArmN_Head, " "and the bends on the fan bones ArmN_HoseS1..8 (shoulder), _HoseE1..9 (elbow), _HoseW1..4 (wrist), each turned by the share of its joint's angle given in 'angle'", "bones": bones, } json.dump(rigdoc, open(os.path.join(OUT, "dockarm_rig.json"), "w"), indent=1) # ------------------------------------------------------------------ the rigged file holds only the rig for coll in (C_ASM, bpy.data.collections["DockArm_Parts"], bpy.data.collections["DockArm_Collision"]): for o in list(coll.objects): bpy.data.objects.remove(o) bpy.data.collections.remove(coll) for me in list(bpy.data.meshes): if me.users == 0: bpy.data.meshes.remove(me) for a in list(bpy.data.armatures): # the unrigged model's hose rig if a.users == 0: bpy.data.armatures.remove(a) for t in list(bpy.data.texts): bpy.data.texts.remove(t) def exclude(coll, flag): def walk(lc): if lc.collection == coll: lc.exclude = flag for ch in lc.children: walk(ch) walk(vl.layer_collection) exclude(C_WGT, True) txt = bpy.data.texts.new("DOCKARM RIG README") txt.write("Docking carousel, rigged. See README.md next to this file.\n" "Select DockArm_Rig > Pose Mode.\n" " Square handle outboard of each head (CTRL_ArmN_Hoist): G moves it down 0..5.3 m, the arm unfolds and the head stays level.\n" " Ring next to each head (CTRL_ArmN_Claws): R turns it 0..25 deg, the four claws open.\n" " Turret: R turns the carousel.\n" " To pose shoulder, elbow and wrist by hand set the handle's custom property 'auto' to 0;\n" " every joint bone then turns about its local X only (R).\n" "Bone collections: 'Deform (joints)' are the 23 joint bones, 'Controls' the 6 handles,\n" "'Mechanism' (hidden) the 6 helper bones that carry the hoist angles,\n" "'Hose (fan bones)' (hidden) the 63 bones that bend the ore hoses round shoulder, elbow and wrist.\n") vl.objects.active = rig bpy.ops.file.pack_all() BLEND = os.path.join(OUT, "DockArm_Carousel_Rigged.blend") bpy.ops.wm.save_as_mainfile(filepath=BLEND) # ------------------------------------------------------------------ skeletal FBX (nothing is saved after this point) # The data itself is put into centimetres and the exporter's own x100 is cancelled (global_scale 0.01 with "All Local": # its matrix becomes Scale(100 * 0.01) = identity and the file's unit stays 1 cm). So no node carries a scale and nothing is # left for the importer to convert: the root bone comes out with scale 1. Turned -90 degrees about Z like the static parts, # so that Unreal's "convert scene" + "force front X axis" gives (bx, -by, bz). PRE = Matrix.Scale(100.0, 4) @ Matrix.Rotation(-math.pi / 2, 4, 'Z') ad.transform(PRE) for sk in skinned.values(): sk.data.transform(PRE) rig.name = "Armature" # Unreal drops a root node with exactly this name instead of making it a bone vl.update() for o in vl.objects: o.select_set(False) rig.select_set(True) for sk in skinned.values(): sk.select_set(True) vl.objects.active = rig bpy.ops.export_scene.fbx(filepath=os.path.join(OUT, "fbx", "SK_DockArm_Carousel.fbx"), use_selection=True, object_types={"ARMATURE", "MESH"}, use_mesh_modifiers=False, mesh_smooth_type="EDGE", add_leaf_bones=False, use_armature_deform_only=True, bake_anim=False, axis_forward="-Y", axis_up="Z", global_scale=0.01, apply_unit_scale=True, apply_scale_options="FBX_SCALE_NONE", use_space_transform=True, bake_space_transform=False, path_mode="STRIP", use_metadata=False) print("RIG OK", BLEND, "bones", len(ad.bones), "deform", sum(1 for b in ad.bones if b.use_deform), "skinned objects", len(skinned))