"""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 (107 deform + 6 controls + 27 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_Boom (the boom joint, 2026-10-16) > ArmN_Upper (shoulder) > ArmN_Lower (elbow) > ArmN_Head (wrist) > ArmN_Claw1..4 hoist to BOARD: Upper +phi, Lower +2*phi, Head +phi, phi = asin(lowered / 6.0) on down to POOL: the boom pitches and the links follow (kinematics.py) Controls: CTRL_ArmN_Hoist slides 0..9.3 m down its local Y (5.3 = BOARD, 9.3 = POOL); its custom property "reach_in" is how far the head moves in toward the hub by BOARD, "auto" 1..0 hands the four 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 mechanism bones per arm carry the staged values and the three angles from drivers (kinematics.py), 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) R_TIP, ZS, STOWED_Z, BOARD_Z = math.hypot(6.0, 0.09), 0.94, -1.0, -6.3 # as in build_dockarm.py exec(open(os.path.join(HERE, "kinematics.py")).read()) # XJ, ZJ, Y_MAX, arm_pose(), the driver chain 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, reach=0.0): a = src["Arm%d" % (k + 1)] a["lower_m"] = float(lower); a["claw_open"] = float(claw); a["reach_in"] = float(reach) 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) bo, up, lo, hd = src[n + "_Boom"], src[n + "_UpperLink"], src[n + "_LowerLink"], src[n + "_Head"] J, S, E, W = bo.matrix_world.translation.copy(), 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 + "_Boom", J, S, DOWN, "Turret", size=0.05) # the boom joint (2026-10-16): pitches the boom down into the pool expect_x[n + "_Boom"] = YB bone(n + "_Upper", S, E, DOWN, n + "_Boom") 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["R"]): bone("%s_HoseR%d" % (n, i), J, J + (S - J).normalized() * 0.25, DOWN, "Turret", size=0.012) expect_x["%s_HoseR%d" % (n, i)] = YB for i in range(1, FAN_STEPS["S"]): bone("%s_HoseS%d" % (n, i), S, S + (E - S).normalized() * 0.25, DOWN, n + "_Boom", 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", "Theta", "M1", "M2", "M3", "M4", "M5", "M6")): # carriers of the angles and the staged values (kinematics.py) Q = J + 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 POOL" c.owner_space = 'LOCAL' c.use_min_y = c.use_max_y = True c.min_y, c.max_y = 0.0, Y_MAX # 5.3 = BOARD; on down the boom joint takes over (kinematics.py) 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") hp = 'pose.bones["%s"]' % hoist.name bpb = P[n + "_Boom"] for tag in ("Phi", "2Phi", "Theta", "M1", "M2", "M3", "M4", "M5", "M6"): P["MCH_%s_%s" % (n, tag)].lock_rotation = (True, True, True) inputs = {"y": (rig, hp + ".location[1]"), "r": (rig, hp + '["reach_in"]')} for stage_, _ch in CHAIN: # the staged values live as custom properties on the helper bones for ch_ in "xyz": P["MCH_%s_%s" % (n, stage_)][ch_] = 0.0 channel_of = lambda stage, n=n: {"id": rig, "prop_path": 'pose.bones["MCH_%s_%s"]' % (n, stage)} build_chain(lambda stage, i, n=n: rig.driver_add('pose.bones["MCH_%s_%s"]["%s"]' % (n, stage, "xyz"[i])), inputs, channel_of) for tag, key in (("Theta", "boom"), ("Phi", "shoulder"), ("2Phi", "elbow")): drive_output(rig.driver_add('pose.bones["MCH_%s_%s"].rotation_euler' % (n, tag), 0), key, 1.0, inputs, channel_of) follow(bpb, "MCH_%s_Theta" % n, hoist.name) 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"]') c = hpb.constraints.new('COPY_ROTATION') # the wrist: elbow - shoulder + boom, so the head stays level however the boom pitches c.name = "Hoist, plus the boom" c.target = rig; c.subtarget = "MCH_%s_Theta" % 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 drive('pose.bones["%s"].constraints["Hoist, plus the boom"].influence' % hpb.name, None, "auto", "auto", hp + '["auto"]') limit_x(bpb, -2.0, BOOM_PITCH_LIMIT_DEG) # 51 on the ship's launch arm at POOL, 59 at r 0 limit_x(upb, -2.0, SHOULDER_LIMIT_DEG) # 119 on the ship's launch arm at POOL, 155 at r 0 (the knee opens to a near-straight arm) limit_x(lpb, -2.0, 180.0) limit_x(hpb, -WRIST_LIMIT_DEG, WRIST_LIMIT_DEG) # 2.34 m in at BOARD needs 98.8 (the zigzag never passes 62); 102.9 at r 2.5 for key, joint in (("R", bpb), ("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 = {"Hub": "Turret", "Boom": an + "_Boom", "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 + "_Boom"].rotation_euler.x = 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() # 3b. the pool stage (the boom joint, 2026-10-16): deep poses, the rig against the unrigged model (same formulas) and the Python reference for lower, reach in ((7.0, 2.34), (9.3, 2.34), (9.3, 0.0), (8.0, 1.2), (6.0, 2.5)): rig_reset() for k in range(3): static_pose(k, 0.0, 0.0, 0.0, 0.0) P["CTRL_Arm%d_Hoist" % (k + 1)]["auto"] = 1.0 static_pose(0, lower, 0.0, 0.0, reach) h1["reach_in"] = reach h1.location.y = lower rig.update_tag(); vl.update() d, who = deviation() ref = arm_pose(lower, reach) EV = rig.evaluated_get(bpy.context.evaluated_depsgraph_get()).pose.bones ang = {} for part in ("Boom", "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) want = S1 - out1 * (R_TIP - ref["W"][0]) + Vector((0, 0, -lower)) level = EV["Arm1_Head"].matrix.to_3x3() - ad.bones["Arm1_Head"].matrix_local.to_3x3() row = dict(mode="pool stage", lowered_m=lower, reach_in=reach, max_deviation_mm=round(d * 1000, 4), at=who, joints_deg=ang, reference_deg={k_: round(math.degrees(ref[k_]), 2) for k_ in ("boom", "shoulder", "elbow", "wrist")}, wrist_error_mm=round((EV["Arm1_Head"].head - want).length * 1000, 4), head_tilt=round(max(abs(v) for r_ in level for v in r_), 6)) checks.append(row) if d >= 5e-4: # say which parts are off, and by how much, before failing for name, sk in skinned.items(): a_, b_ = world_verts(src[name]), world_verts(sk) dd = max((p - q).length for p, q in zip(a_, b_)) if dd > 5e-4: i_ = max(range(len(a_)), key=lambda i: (a_[i] - b_[i]).length) print("RIGDEV", name, round(dd, 4), "unrigged", [round(v, 3) for v in a_[i_]], "rigged", [round(v, 3) for v in b_[i_]], "vertex", i_) if d >= 5e-4: for an_ in ("Arm1", "Arm3"): hb_ = EV["CTRL_%s_Hoist" % an_] print("RIGDBG", an_, "handle y", round(hb_.location.y, 4), "reach", rig.pose.bones["CTRL_%s_Hoist" % an_].get("reach_in"), {t_: ([round(rig.pose.bones["MCH_%s_%s" % (an_, t_)].get(c_, 0.0), 4) for c_ in "xyz"], round(EV["MCH_%s_%s" % (an_, t_)].rotation_euler.x, 4)) for t_ in ("M1", "M2", "M3", "M4", "M5", "M6", "Theta", "Phi", "2Phi")}) for fc_ in rig.animation_data.drivers: if fc_.data_path in ('pose.bones["MCH_%s_M3"]["x"]' % an_, 'pose.bones["MCH_%s_Phi"].rotation_euler' % an_) and fc_.array_index in (0, -1): print("RIGDBG driver", fc_.data_path, fc_.driver.expression, [(v.name, v.type, v.targets[0].data_path, v.targets[0].bone_target, v.targets[0].transform_type) for v in fc_.driver.variables], "valid", fc_.driver.is_valid) assert row["wrist_error_mm"] < 0.5 and row["head_tilt"] < 1e-4 and d < 5e-4, row for a_, b_ in zip(("Boom", "Upper", "Lower", "Head"), ("boom", "shoulder", "elbow", "wrist")): assert abs(ang[a_] - row["reference_deg"][b_]) < 0.05, row rig_reset() for k in range(3): static_pose(k, 0.0, 0.0, 0.0, 0.0) P["CTRL_Arm%d_Hoist" % (k + 1)]["auto"] = 1.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 + {"R": "_Boom", "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_Boom": "pitches the boom down on its joint", "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", "boom_joint": "ArmN_Boom, head at the pin %s cm in the boom frame (Unreal, rest pose), added 2026-10-16: pitches the boom down 0 .. %.0f deg so the arm can put the Tender into the moon pool; ArmN_Upper hangs on it" % (ue(Vector((XJ, 0, ZJ))), BOOM_PITCH_LIMIT_DEG), "pool": "handle y above 5.3 (to %.1f = POOL): the head keeps its reach_in and goes straight on down, the knee keeps its BOARD bend and the boom pitches as far as the links need. " "dockarm_layout.json 'hoist' has the formulas; the drivers are their staged form on MCH_ArmN_M1..M6 (location channels), MCH_ArmN_Theta / _Phi / _2Phi (local X). " "At POOL on the ship's launch arm (r 2.34): boom %.1f, shoulder %.1f, elbow %.1f, wrist %.1f deg. Do not run carousel_rigged.add_reach() on this rig: it rewrites the Phi / 2Phi drivers without the pool stage" % ((Y_MAX,) + tuple(math.degrees(arm_pose(Y_MAX, 2.34)[k_]) for k_ in ("boom", "shoulder", "elbow", "wrist"))), "joint_ranges_deg": {"ArmN_Boom": [0, BOOM_PITCH_LIMIT_DEG], "ArmN_Upper": [0, SHOULDER_LIMIT_DEG], "ArmN_Lower": [0, 180], "ArmN_Head": [-WRIST_LIMIT_DEG, WRIST_LIMIT_DEG]}, "ore_hose": "SK_DockArm_ArmN_OreHose: every ring is weighted 1 to one bone. The run from the port to the boom joint rides on Turret, the boom runs on ArmN_Boom, link runs on ArmN_Upper / _Lower, the end on ArmN_Head, " "and the bends on the fan bones ArmN_HoseR1..6 (boom joint), _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..9.3 m. To 5.3 (BOARD) the arm unfolds with the head level;\n" " on down to 9.3 (POOL) the boom joint pitches the arm down and puts the Tender into the moon pool. Its custom property\n" " 'reach_in' (the ship: 2.34 on the launch arm) is how far the head moves in toward the hub by BOARD.\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 boom, 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 26 joint bones, 'Controls' the 6 handles,\n" "'Mechanism' (hidden) the 27 helper bones that carry the staged values and angles (kinematics.py),\n" "'Hose (fan bones)' (hidden) the 81 bones that bend the ore hoses round the boom joint, shoulder, elbow and wrist.\n") vl.objects.active = rig BLEND = os.path.join(OUT, "DockArm_Carousel_Rigged.blend") bpy.ops.wm.save_as_mainfile(filepath=BLEND) bpy.ops.file.make_paths_relative() # textures are referenced beside the file, not packed bpy.ops.wm.save_mainfile() # ------------------------------------------------------------------ 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))