Files
cadSet/text-to-cad/models/robots/lyra/.lyra.step.js
T
2026-07-22 19:38:36 +08:00

520 lines
18 KiB
JavaScript

// Pose articulation animations for the lyra dexterous-hand STEP assembly.
//
// The STEP geometry is baked in the "relaxed" pose (see lyra.py /
// lyra_parts/chain.py). This module recomputes full-chain FK each frame at
// the target pose and applies, per link, the rigid delta matrix
// T_target * inverse(T_relaxed) in the fixed model frame (mm, RIGHT hand,
// +Z distal, +Y palmar, +X radial, wrist-flange face center at the origin).
//
// All pose tables mirror lyra_parts/chain.py named_poses_deg(); the joint
// list mirrors chain.all_joints() (origins mm in the parent link frame,
// axes: finger flexion about -X, thumb cmc yaw about +Z, thumb flexion
// about -Y). Poses are blended in joint space with smoothstep easing —
// every intermediate state of a serial digit chain is itself a valid pose,
// so blends never break the mechanism. Every loop starts and ends on the
// exact pose it began with.
const FINGERS = ["index", "middle", "ring", "pinky"];
// Chain offsets (mm), mirrored from lyra_parts/chain.py.
const MCP = {
index: [28.5, 0, 99],
middle: [9.5, 0, 103],
ring: [-9.5, 0, 99],
pinky: [-28.5, 0, 90]
};
const SEG = { index: [44, 26], middle: [48, 29], ring: [44, 27], pinky: [35, 21] };
const THUMB_CMC = [31, 4, 44];
const THUMB_BASE_LEN = 13;
const THUMB_METACARPAL_LEN = 46;
const THUMB_PROXIMAL_LEN = 30;
const NEG_X = [-1, 0, 0];
const NEG_Y = [0, -1, 0];
const Z = [0, 0, 1];
function buildJoints() {
const joints = [];
for (const finger of FINGERS) {
joints.push(
{ name: `${finger}_mcp`, parent: "palm", child: `${finger}_proximal`, origin: MCP[finger], axis: NEG_X },
{ name: `${finger}_pip`, parent: `${finger}_proximal`, child: `${finger}_middle`, origin: [0, 0, SEG[finger][0]], axis: NEG_X },
{ name: `${finger}_dip`, parent: `${finger}_middle`, child: `${finger}_distal`, origin: [0, 0, SEG[finger][1]], axis: NEG_X }
);
}
joints.push(
{ name: "thumb_cmc_yaw", parent: "palm", child: "thumb_base", origin: THUMB_CMC, axis: Z },
{ name: "thumb_cmc_flex", parent: "thumb_base", child: "thumb_metacarpal", origin: [THUMB_BASE_LEN, 0, 0], axis: NEG_Y },
{ name: "thumb_mp", parent: "thumb_metacarpal", child: "thumb_proximal", origin: [THUMB_METACARPAL_LEN, 0, 0], axis: NEG_Y },
{ name: "thumb_ip", parent: "thumb_proximal", child: "thumb_distal", origin: [THUMB_PROXIMAL_LEN, 0, 0], axis: NEG_Y }
);
return joints;
}
const JOINTS = buildJoints();
// ---------------------------------------------------------------- poses
function fingerPose(curls, thumb) {
const pose = {};
for (const finger of FINGERS) {
const [mcp, pip, dip] = curls[finger];
pose[`${finger}_mcp`] = mcp;
pose[`${finger}_pip`] = pip;
pose[`${finger}_dip`] = dip;
}
const [yaw, flex, mp, ip] = thumb;
pose.thumb_cmc_yaw = yaw;
pose.thumb_cmc_flex = flex;
pose.thumb_mp = mp;
pose.thumb_ip = ip;
return pose;
}
// Mirrored from chain.named_poses_deg().
const POSES = {
zero: fingerPose(
{ index: [0, 0, 0], middle: [0, 0, 0], ring: [0, 0, 0], pinky: [0, 0, 0] },
[0, 0, 0, 0]
),
relaxed: fingerPose(
{ index: [10, 14, 8], middle: [12, 16, 9], ring: [14, 18, 10], pinky: [16, 20, 12] },
[34, 26, 14, 12]
),
fist: fingerPose(
{ index: [78, 100, 60], middle: [78, 100, 60], ring: [78, 100, 60], pinky: [78, 100, 60] },
[92, 13, 58, 74]
),
precision_pinch: fingerPose(
{ index: [40, 48, 30], middle: [66, 92, 55], ring: [66, 92, 55], pinky: [66, 92, 55] },
[91, 34, 29, 8]
),
tripod_pinch: fingerPose(
{ index: [44, 52, 32], middle: [42, 50, 30], ring: [66, 92, 55], pinky: [66, 92, 55] },
[100, 39, 0, 14]
),
point: fingerPose(
{ index: [-6, 0, 0], middle: [80, 102, 62], ring: [80, 102, 62], pinky: [80, 102, 62] },
[62, 30, 42, 45]
),
ok_sign: fingerPose(
{ index: [42, 52, 32], middle: [6, 8, 4], ring: [10, 12, 6], pinky: [14, 16, 8] },
[91, 26, 35, 20]
)
};
const BAKED_POSE = POSES.relaxed;
// ------------------------------------------------------------- math kit
function finite(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(finite(value, min), min), max);
}
function smoothstep(u) {
const t = clamp(u, 0, 1);
return t * t * (3 - 2 * t);
}
// Rodrigues rotation about a unit axis (covers -X, -Y, and Z here).
function rotAxisDeg(axis, deg) {
const rad = (deg * Math.PI) / 180;
const c = Math.cos(rad);
const s = Math.sin(rad);
const t = 1 - c;
const [ax, ay, az] = axis;
return [
[c + ax * ax * t, ax * ay * t - az * s, ax * az * t + ay * s],
[ay * ax * t + az * s, c + ay * ay * t, ay * az * t - ax * s],
[az * ax * t - ay * s, az * ay * t + ax * s, c + az * az * t]
];
}
function matMul3(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i += 1) {
for (let j = 0; j < 3; j += 1) {
out[i][j] = (a[i][0] * b[0][j]) + (a[i][1] * b[1][j]) + (a[i][2] * b[2][j]);
}
}
return out;
}
function matVec3(a, v) {
return [
(a[0][0] * v[0]) + (a[0][1] * v[1]) + (a[0][2] * v[2]),
(a[1][0] * v[0]) + (a[1][1] * v[1]) + (a[1][2] * v[2]),
(a[2][0] * v[0]) + (a[2][1] * v[1]) + (a[2][2] * v[2])
];
}
function matTranspose3(a) {
return [
[a[0][0], a[1][0], a[2][0]],
[a[0][1], a[1][1], a[2][1]],
[a[0][2], a[1][2], a[2][2]]
];
}
const IDENTITY3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
function fkFrames(anglesDeg) {
const frames = { palm: { R: IDENTITY3, p: [0, 0, 0] } };
for (const joint of JOINTS) {
const parent = frames[joint.parent];
const offset = matVec3(parent.R, joint.origin);
const p = [parent.p[0] + offset[0], parent.p[1] + offset[1], parent.p[2] + offset[2]];
const R = matMul3(parent.R, rotAxisDeg(joint.axis, finite(anglesDeg[joint.name], 0)));
frames[joint.child] = { R, p };
}
return frames;
}
const BAKED_FRAMES = fkFrames(BAKED_POSE);
// Row-major 4x4 mapping the baked placement of a link onto its target
// placement: M = [R_t R_o^T | p_t - R_t R_o^T p_o].
function deltaMatrixRowMajor(original, target) {
const Rd = matMul3(target.R, matTranspose3(original.R));
const moved = matVec3(Rd, original.p);
const t = [target.p[0] - moved[0], target.p[1] - moved[1], target.p[2] - moved[2]];
return [
Rd[0][0], Rd[0][1], Rd[0][2], t[0],
Rd[1][0], Rd[1][1], Rd[1][2], t[1],
Rd[2][0], Rd[2][1], Rd[2][2], t[2],
0, 0, 0, 1
];
}
function blendPoses(a, b, u) {
const e = smoothstep(u);
const out = {};
for (const joint of JOINTS) {
out[joint.name] = finite(a[joint.name], 0) + ((finite(b[joint.name], 0) - finite(a[joint.name], 0)) * e);
}
return out;
}
function addScaled(pose, delta, scale) {
const out = {};
for (const joint of JOINTS) {
out[joint.name] = finite(pose[joint.name], 0) + (finite(delta[joint.name], 0) * scale);
}
return out;
}
// ---------------------------------------------------------------- modes
// Keyframe cycle through the showpiece poses; each segment blends for 65%
// of its window and dwells for 35%, and the cycle wraps back to its first
// key so the loop is exact. Key order is chosen so every adjacent blend is
// capsule-verified collision-free (the fist only neighbors tripod/relaxed;
// blending it with pinch/point/ok would sweep the thumb through the index).
const TOUR_KEYS = ["relaxed", "precision_pinch", "ok_sign", "point", "tripod_pinch", "fist"];
// The tour opens with one finger-ripple wave (starts and ends exactly on
// the relaxed pose, so it splices seamlessly before the first keyframe
// blend) and spends the rest of the cycle on the pose keyframes.
const TOUR_RIPPLE_FRAC = 0.22;
function tourPose(phase) {
const p = ((finite(phase, 0) % 1) + 1) % 1;
if (p < TOUR_RIPPLE_FRAC) {
return ripplePose(p / TOUR_RIPPLE_FRAC, 1);
}
const q = (p - TOUR_RIPPLE_FRAC) / (1 - TOUR_RIPPLE_FRAC);
const segCount = TOUR_KEYS.length;
const seg = Math.min(Math.floor(q * segCount), segCount - 1);
const u = (q * segCount) - seg;
const from = POSES[TOUR_KEYS[seg]];
const to = POSES[TOUR_KEYS[(seg + 1) % segCount]];
return blendPoses(from, to, u / 0.65);
}
// Open-close power grasp: relaxed -> fist -> relaxed on a raised cosine.
function graspPose(phase, grip) {
const p = ((finite(phase, 0) % 1) + 1) % 1;
const wave = 0.5 * (1 - Math.cos(2 * Math.PI * p));
return blendPoses(POSES.relaxed, POSES.fist, wave * grip);
}
// Precision pinch with a double pad tap while closed.
const PINCH_TAP = fingerPose(
{ index: [-5, -7, -4], middle: [0, 0, 0], ring: [0, 0, 0], pinky: [0, 0, 0] },
[0, 0, -7, -5]
);
function pinchPose(phase) {
const p = ((finite(phase, 0) % 1) + 1) % 1;
if (p < 0.3) {
return blendPoses(POSES.relaxed, POSES.precision_pinch, p / 0.3);
}
if (p < 0.72) {
const u = (p - 0.3) / 0.42;
const tap = Math.sin(2 * Math.PI * 2 * u);
return addScaled(POSES.precision_pinch, PINCH_TAP, Math.max(0, tap));
}
return blendPoses(POSES.precision_pinch, POSES.relaxed, (p - 0.72) / 0.28);
}
// Traveling curl wave: each digit pulses inside its own window (raised
// cosine, zero at both ends), thumb last. The windows are wide relative to
// the digit spacing, so each digit starts curling while its neighbor is
// still mid-pulse and the wave reads as one continuous motion.
const RIPPLE_ORDER = ["index", "middle", "ring", "pinky", "thumb"];
const RIPPLE_CURL = { mcp: 30, pip: 40, dip: 22, thumbFlex: 12, thumbMp: 30, thumbIp: 30 };
const RIPPLE_WINDOW = 0.45;
function ripplePose(phase, grip) {
const p = ((finite(phase, 0) % 1) + 1) % 1;
const window = RIPPLE_WINDOW;
const step = (1 - window) / (RIPPLE_ORDER.length - 1);
const pose = {};
for (const joint of JOINTS) {
pose[joint.name] = BAKED_POSE[joint.name];
}
RIPPLE_ORDER.forEach((digit, i) => {
const start = i * step;
const u = (p - start) / window;
if (u <= 0 || u >= 1) {
return;
}
const lobe = Math.sin(Math.PI * u);
const amp = lobe * lobe * grip;
if (digit === "thumb") {
pose.thumb_cmc_flex += RIPPLE_CURL.thumbFlex * amp;
pose.thumb_mp += RIPPLE_CURL.thumbMp * amp;
pose.thumb_ip += RIPPLE_CURL.thumbIp * amp;
} else {
pose[`${digit}_mcp`] += RIPPLE_CURL.mcp * amp;
pose[`${digit}_pip`] += RIPPLE_CURL.pip * amp;
pose[`${digit}_dip`] += RIPPLE_CURL.dip * amp;
}
});
return pose;
}
// Count 1..5 from a fist (index first, thumb last), then close back. The
// thumb lifts off to a hover clear of the fingers before any finger
// extends, and only re-wraps once the fingers are curled again — every
// adjacent blend is capsule-verified collision-free.
const COUNT_EXTENDED = { index: [2, 2, 1], middle: [2, 2, 1], ring: [4, 4, 2], pinky: [6, 6, 3] };
const COUNT_THUMB_FIST = [92, 13, 58, 74];
const COUNT_THUMB_HOVER = [50, 30, 25, 20];
const COUNT_THUMB_OPEN = [20, 10, 4, 4];
function countKey(step, thumb) {
const curls = {};
FINGERS.forEach((finger, i) => {
curls[finger] = step >= i + 1 ? COUNT_EXTENDED[finger] : [78, 100, 60];
});
return fingerPose(curls, thumb);
}
function countPose(phase) {
const p = ((finite(phase, 0) % 1) + 1) % 1;
// 8 segments: fist -> thumb hover -> 1 -> 2 -> 3 -> 4 -> 5 (thumb opens)
// -> hover (fingers re-curl) -> wrap back to the fist.
const keys = [
countKey(0, COUNT_THUMB_FIST),
countKey(0, COUNT_THUMB_HOVER),
countKey(1, COUNT_THUMB_HOVER),
countKey(2, COUNT_THUMB_HOVER),
countKey(3, COUNT_THUMB_HOVER),
countKey(4, COUNT_THUMB_HOVER),
countKey(5, COUNT_THUMB_OPEN),
countKey(0, COUNT_THUMB_HOVER)
];
const segCount = keys.length;
const seg = Math.min(Math.floor(p * segCount), segCount - 1);
const u = (p * segCount) - seg;
const from = keys[seg];
const to = seg + 1 < segCount ? keys[seg + 1] : keys[0];
return blendPoses(from, to, u / 0.6);
}
const FEATURE_BY_LINK = {
palm: "palm",
index_proximal: "indexProximal",
index_middle: "indexMiddle",
index_distal: "indexDistal",
middle_proximal: "middleProximal",
middle_middle: "middleMiddle",
middle_distal: "middleDistal",
ring_proximal: "ringProximal",
ring_middle: "ringMiddle",
ring_distal: "ringDistal",
pinky_proximal: "pinkyProximal",
pinky_middle: "pinkyMiddle",
pinky_distal: "pinkyDistal",
thumb_base: "thumbBase",
thumb_metacarpal: "thumbMetacarpal",
thumb_proximal: "thumbProximal",
thumb_distal: "thumbDistal"
};
export default {
manifest: {
schemaVersion: 1,
step: {
path: "models/robots/lyra/lyra.step"
},
label: "lyra dexterous hand",
description: "16-DOF humanoid right hand: pose tour, power grasp, precision pinch with pad taps, finger ripple, and counting, all driven by per-frame chain FK against the baked relaxed pose.",
units: {
length: "mm",
angle: "deg",
time: "s"
},
features: {
palm: { ref: "#o1.1", label: "Palm", description: "Root link; static during all hand animations." },
indexProximal: { ref: "#o1.2", label: "Index proximal" },
indexMiddle: { ref: "#o1.3", label: "Index middle" },
indexDistal: { ref: "#o1.4", label: "Index distal" },
middleProximal: { ref: "#o1.5", label: "Middle proximal" },
middleMiddle: { ref: "#o1.6", label: "Middle middle" },
middleDistal: { ref: "#o1.7", label: "Middle distal" },
ringProximal: { ref: "#o1.8", label: "Ring proximal" },
ringMiddle: { ref: "#o1.9", label: "Ring middle" },
ringDistal: { ref: "#o1.10", label: "Ring distal" },
pinkyProximal: { ref: "#o1.11", label: "Pinky proximal" },
pinkyMiddle: { ref: "#o1.12", label: "Pinky middle" },
pinkyDistal: { ref: "#o1.13", label: "Pinky distal" },
thumbBase: { ref: "#o1.14", label: "Thumb CMC base" },
thumbMetacarpal: { ref: "#o1.15", label: "Thumb metacarpal" },
thumbProximal: { ref: "#o1.16", label: "Thumb proximal" },
thumbDistal: { ref: "#o1.17", label: "Thumb distal" }
},
parameters: {
phase: {
type: "number",
label: "Cycle phase",
description: "One full cycle of the active mode; the animations drive this 0 -> 1.",
default: 0,
min: 0,
max: 1,
step: 0.001
},
mode: {
type: "select",
label: "Mode",
description: "Which articulation cycle the phase scrubs. Each animation drives the matching mode; 'pose' holds the static pose picked below.",
default: "tour",
options: [
{ value: "tour", label: "Pose tour" },
{ value: "grasp", label: "Power grasp" },
{ value: "pinch", label: "Precision pinch" },
{ value: "ripple", label: "Finger ripple" },
{ value: "count", label: "Count to five" },
{ value: "pose", label: "Hold named pose" }
]
},
pose: {
type: "select",
label: "Named pose",
description: "SRDF group-state pose held when mode is 'pose' (blend with Grip).",
default: "relaxed",
options: [
{ value: "zero", label: "Zero (flat open)" },
{ value: "relaxed", label: "Relaxed (baked)" },
{ value: "fist", label: "Fist" },
{ value: "precision_pinch", label: "Precision pinch" },
{ value: "tripod_pinch", label: "Tripod pinch" },
{ value: "point", label: "Point" },
{ value: "ok_sign", label: "OK sign" }
]
},
grip: {
type: "number",
label: "Grip",
description: "Scales the grasp/ripple amplitude, and blends relaxed -> named pose in 'pose' mode.",
default: 1,
min: 0,
max: 1,
step: 0.01
}
},
animations: {
poseTour: {
label: "Pose tour",
description: "Opens with a finger ripple wave, then cycles relaxed -> precision pinch -> OK sign -> point -> tripod pinch -> fist and back, dwelling on each pose.",
duration: 11.5,
loop: true,
update({ cycle, set }) {
set("mode", "tour");
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
}
},
graspLoop: {
label: "Power grasp",
description: "Full-hand open-close: all four fingers wrap and the thumb locks over them, then releases back to the relaxed pose.",
duration: 2.6,
loop: true,
update({ cycle, set }) {
set("mode", "grasp");
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
}
},
pinchLoop: {
label: "Precision pinch",
description: "Thumb-index pad opposition with a double tap while closed; the other fingers curl clear.",
duration: 2.8,
loop: true,
update({ cycle, set }) {
set("mode", "pinch");
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
}
},
rippleLoop: {
label: "Finger ripple",
description: "A curl wave travels index -> pinky and hands off to the thumb, like drumming fingers in the air.",
duration: 2.6,
loop: true,
update({ cycle, set }) {
set("mode", "ripple");
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
}
},
countLoop: {
label: "Count to five",
description: "From a fist: the thumb lifts clear, then index, middle, ring, pinky extend in turn and the thumb opens to a flat five before the hand closes again.",
duration: 7.0,
loop: true,
update({ cycle, set }) {
set("mode", "count");
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
}
}
}
},
update({ params, effects }) {
const phase = ((finite(params.phase, 0) % 1) + 1) % 1;
const grip = clamp(params.grip, 0, 1);
const mode = String(params.mode || "tour");
let target;
if (mode === "grasp") {
target = graspPose(phase, grip);
} else if (mode === "pinch") {
target = pinchPose(phase);
} else if (mode === "ripple") {
target = ripplePose(phase, grip);
} else if (mode === "count") {
target = countPose(phase);
} else if (mode === "pose") {
const named = POSES[String(params.pose || "relaxed")] || POSES.relaxed;
target = blendPoses(POSES.relaxed, named, grip);
} else {
target = tourPose(phase);
}
const targetFrames = fkFrames(target);
for (const [link, feature] of Object.entries(FEATURE_BY_LINK)) {
effects.transform(feature, {
matrix: deltaMatrixRowMajor(BAKED_FRAMES[link], targetFrames[link])
});
}
}
};