1343 lines
58 KiB
JavaScript
1343 lines
58 KiB
JavaScript
// Walking-in-place animation for the juno humanoid STEP assembly.
|
|
//
|
|
// The STEP geometry is baked in the athletic ready stance (see juno.py /
|
|
// juno_parts/chain.py). This module recomputes full-chain FK each frame at
|
|
// (athletic pose + gait deltas) and applies, per link subtree, the rigid
|
|
// delta matrix T_target * inverse(T_athletic) in the fixed model frame
|
|
// (mm, +X forward, +Y robot-left, +Z up, pelvis waist-yaw center at the
|
|
// origin).
|
|
//
|
|
// Gait design: an in-place march. Each leg swings inside its own window of
|
|
// the cycle with a raised-cosine envelope that starts and ends at zero, and
|
|
// is EXACTLY at the athletic pose for the rest of the cycle — so the stance
|
|
// foot stays planted on the ground with no IK. The pelvis root is
|
|
// intentionally static for the same reason. Arms swing continuously,
|
|
// antiphase to their own-side leg; the torso counter-rotates about the
|
|
// waist with a hint of roll, and the neck compensates so the head keeps
|
|
// facing forward.
|
|
|
|
const X = [1, 0, 0];
|
|
const Y = [0, 1, 0];
|
|
const Z = [0, 0, 1];
|
|
|
|
const HIP_Y = 90;
|
|
const SHOULDER_Y = 148;
|
|
|
|
// Athletic ready stance angles (deg), mirrored from juno_parts/chain.py.
|
|
const ATHLETIC = {
|
|
HIP_PITCH: -16,
|
|
KNEE: 32,
|
|
ANKLE_PITCH: -16,
|
|
HIP_ROLL_ABDUCT: 3,
|
|
HIP_YAW: 0,
|
|
WAIST_YAW: 0,
|
|
SHOULDER_PITCH: -8,
|
|
SHOULDER_ROLL_ABDUCT: 8,
|
|
SHOULDER_YAW_INTERNAL: 8,
|
|
ELBOW: -20,
|
|
WRIST_ROLL: 0,
|
|
WRIST_PITCH: -2,
|
|
NECK_YAW: 0,
|
|
NECK_PITCH: -2
|
|
};
|
|
|
|
// Gait amplitudes (deg) at parameter scale 1. Sign conventions follow the
|
|
// chain ledger: pitch about +Y (negative = forward), roll about +X
|
|
// (positive = toward robot-left), yaw about +Z (positive = turn left).
|
|
const HIP_LIFT_DEG = 24; // swing-leg thigh raise (forward = negative pitch)
|
|
const KNEE_FOLD_DEG = 44; // extra swing-leg knee flexion
|
|
const TOE_POINT_DEG = 8; // slight toes-down of the airborne foot
|
|
const ARM_SWING_DEG = 12; // shoulder pitch swing, antiphase to own-side leg
|
|
const ELBOW_EXTRA_DEG = 10; // extra elbow flexion on the forward arm swing
|
|
const WAIST_SWAY_DEG = 4; // torso counter-rotation about the waist yaw axis
|
|
const TORSO_ROLL_DEG = 1.2; // weight-shift hint, roll about +X at the waist
|
|
const NECK_COMPENSATION = 0.8; // head stabilization fraction against waist sway
|
|
|
|
// Swing windows of the normalized cycle (left lifts first), with
|
|
// double-support dwell between them.
|
|
const SWING = { left: [0.05, 0.45], right: [0.55, 0.95] };
|
|
|
|
// Stride gait (treadmill-style walk in place): the stance foot slides
|
|
// backward flat on the ground while the swing leg passes through the air,
|
|
// both solved with planar two-link leg IK in each leg's (slightly rolled)
|
|
// sagittal plane. The athletic stance is exactly the IK solution at stride
|
|
// center, so the gait converges to plain standing as strideLength -> 0.
|
|
const THIGH_LEN = 290; // hip-pitch to knee (mm)
|
|
const SHIN_LEN = 290; // knee to ankle-pitch (mm)
|
|
const STANCE_FRACTION = 0.58; // ground-contact share of each leg cycle
|
|
const STRIDE_LIFT_MM = 60; // swing-foot apex clearance at legLift 1
|
|
const STRIDE_TOE_OFF_DEG = 14; // toes-down just after toe-off
|
|
const STRIDE_HEEL_DEG = 10; // toes-up heel-first approach to contact
|
|
const STRIDE_ARM_FACTOR = 1.5; // arms swing wider with long strides
|
|
const STRIDE_SWAY_FACTOR = 1.25;
|
|
const MAX_STRIDE_MM = 300; // keeps two-link IK inside reach at the extremes
|
|
const DEFAULT_STRIDE_MM = 230; // lift/pitch accents are normalized to this
|
|
const STRIDE_PHASE = { left: 0, right: 0.5 };
|
|
// Mid-swing cycle positions (offset + stance + half the swing window).
|
|
const STRIDE_MID_SWING = {
|
|
left: (STRIDE_PHASE.left + STANCE_FRACTION + ((1 - STANCE_FRACTION) / 2)) % 1,
|
|
right: (STRIDE_PHASE.right + STANCE_FRACTION + ((1 - STANCE_FRACTION) / 2)) % 1
|
|
};
|
|
|
|
// Run gait: short ground contacts with a FLIGHT phase between them (both
|
|
// feet airborne), a vertical body bounce absorbed by the stance-leg IK
|
|
// (lowest at mid-stance, highest at mid-flight), toe-pivot heel-off into
|
|
// push-off, high heel-recovery swing, forward torso lean, and pumping bent
|
|
// arms. Foot contact lands slightly ahead of the hip and pushes off well
|
|
// behind it (asymmetric split of the stride travel).
|
|
const RUN_STANCE_FRACTION = 0.35;
|
|
const RUN_PHASE = { left: 0, right: 0.5 };
|
|
const RUN_MID_STANCE = {
|
|
left: RUN_STANCE_FRACTION / 2,
|
|
right: (RUN_PHASE.right + (RUN_STANCE_FRACTION / 2)) % 1
|
|
};
|
|
const RUN_MID_SWING = {
|
|
left: (RUN_STANCE_FRACTION + ((1 - RUN_STANCE_FRACTION) / 2)) % 1,
|
|
right: (RUN_PHASE.right + RUN_STANCE_FRACTION + ((1 - RUN_STANCE_FRACTION) / 2)) % 1
|
|
};
|
|
const RUN_CONTACT_SHARE = 0.3; // stride share landing ahead of the hip
|
|
const RUN_BOUNCE_MM = 22; // body bounce amplitude at legLift 1
|
|
const RUN_APEX_MM = 140; // swing-ankle apex over flat stance (heel tuck)
|
|
const RUN_HEEL_OFF_DEG = 26; // toe-pivot heel rise into push-off
|
|
const RUN_HEEL_OFF_START = 0.62; // stance progress where the heel starts rising
|
|
const RUN_HEEL_IN_DEG = 8; // toes-up into the next contact
|
|
const RUN_TOE_PIVOT_X_MM = 112; // sole front edge (foot bbox +x) — pivoting
|
|
const RUN_ANKLE_HEIGHT_MM = 56; // there keeps the toe from digging in
|
|
const RUN_LEAN_DEG = 7; // forward torso lean (waist pitch extra)
|
|
const RUN_ARM_SWING_DEG = 26; // shoulder pump amplitude
|
|
const RUN_ELBOW_BASE_DEG = -60; // extra constant elbow flexion (runner's arms)
|
|
const RUN_ELBOW_PUMP_DEG = 15; // extra flexion on the forward pump
|
|
const RUN_SWAY_DEG = 5; // waist yaw counter-rotation
|
|
const RUN_ROLL_DEG = 1.6; // lean toward the stance side
|
|
|
|
// Jump gait: countermovement crouch -> push -> ballistic flight with toe
|
|
// point and leg tuck -> springy landing (underdamped body-height response
|
|
// absorbed by the leg IK) -> recover. Arms swing back in the crouch, sweep
|
|
// overhead for the flight ("hands in the air"), dip with the landing
|
|
// spring, and settle back. Both legs work symmetrically; takeoff/landing
|
|
// fall out of the IK reach clamp (targets go out of reach -> legs straight
|
|
// -> feet leave/meet the ground smoothly).
|
|
const JUMP_TIMING = { crouch: 0.18, push: 0.3, land: 0.62, absorb: 0.74 };
|
|
const JUMP_CROUCH_MM = 120; // countermovement depth at legLift 1
|
|
const JUMP_TAKEOFF_MM = 20; // body rise at full leg extension (reach limit)
|
|
const JUMP_APEX_MM = 160; // ballistic apex above standing at legLift 1
|
|
const JUMP_TUCK_MM = 250; // mid-flight ankle tuck toward the body
|
|
const JUMP_TOE_POINT_DEG = 25; // toes point down in the air
|
|
const JUMP_LAND_DIP_MM = -80; // first landing compression
|
|
const JUMP_SPRING_DECAY = 3.5; // landing spring: exp decay rate ...
|
|
const JUMP_SPRING_CYCLES = 1.25; // ... and bounce count (ends at zero crossing)
|
|
const JUMP_ARM_BACK_DEG = 30; // crouch arm backswing (shoulder pitch)
|
|
const JUMP_ARM_UP_DEG = -155; // overhead shoulder pitch in flight
|
|
const JUMP_ARM_V_DEG = 20; // shoulder roll abduction for an overhead V
|
|
const JUMP_ARM_SPRING_DEG_PER_MM = 0.3; // landing arm dip coupled to body spring
|
|
const JUMP_LEAN_DEG = 8; // crouch/landing forward lean
|
|
const JUMP_HEAD_UP_DEG = -8; // look up slightly while airborne
|
|
|
|
// Showpiece animations: karate kick, handstand, and the Elvis dance each
|
|
// run as their own loop; every one starts and ends in the athletic stance.
|
|
// Lateral pendulum for weight shifts: the hip-roll joint sits at z=-184 in
|
|
// the pelvis frame; rolling there (with the ankle roll compensating) moves
|
|
// the planted sole sideways by ~(soleZ - HIP_ROLL_Z) * sin(roll).
|
|
const HIP_ROLL_Z_MM = -184;
|
|
// Kick (segment-local): weight shifts over the left leg, the right leg
|
|
// chambers and snaps a front kick at hip height with a guard up.
|
|
const KICK_WEIGHT_SHIFT_MM = 42;
|
|
const KICK_DIP_MM = 18;
|
|
const KICK_CHAMBER_HIP_DEG = -70;
|
|
const KICK_CHAMBER_KNEE_DEG = 115;
|
|
const KICK_CHAMBER_ANKLE_DEG = 10;
|
|
const KICK_EXTEND_HIP_DEG = -20; // added on top of the chamber
|
|
const KICK_EXTEND_KNEE_DEG = -105; // snaps the shin out nearly straight
|
|
const KICK_EXTEND_ANKLE_DEG = -25; // ball-of-foot strike (toes pulled back)
|
|
const KICK_GUARD_SHOULDER_DEG = -45;
|
|
const KICK_GUARD_ELBOW_DEG = -105;
|
|
const KICK_GUARD_ROLL_OUT_DEG = 18;
|
|
const KICK_HIP_TURN_DEG = -14; // hips rotate into the kick
|
|
const KICK_LEAN_BACK_DEG = -7; // counterbalance during the snap
|
|
// Handstand: keyframed root rotation about +Y with contact anchoring —
|
|
// toe-pinned while folding, palm-pinned once the hands plant.
|
|
const HS_PLANT_X_MM = 430; // hand plant line in front of the feet
|
|
// Palm contact point in the hand frame: the hands plant palm-down with the
|
|
// fingers pointing forward (total hand pitch -90 at the hold), so contact
|
|
// is on the local -x palm face — the one extent that is symmetric between
|
|
// the two hands (the posed finger curl differs in local z).
|
|
const HS_PALM_LOCAL_MM = [-35, 0, -70];
|
|
const HS_TOE_LOCAL_MM = [112, 0, -26]; // sole FRONT EDGE (pivoting there keeps the whole sole above ground)
|
|
const HS_WOBBLE_DEG = 1.6; // leg wobble while holding the stand
|
|
// Elvis dance (4 beats): weight over the right leg, left leg kicked out
|
|
// to the left planted on a pointed toe (closed-form lateral+sagittal leg
|
|
// IK), rubber-leg shake, right arm pointing up to the right with a beat
|
|
// pulse, hip swivel, head turned toward the pointing hand.
|
|
const ELVIS_BEATS = 4;
|
|
const ELVIS_WEIGHT_SHIFT_MM = -55; // pelvis shifts over the right leg
|
|
const ELVIS_DIP_MM = 20; // settled crouch on the stance leg
|
|
const ELVIS_BOUNCE_MM = 8; // beat bounce on top of the dip
|
|
const ELVIS_TOE_X_MM = 80; // kicked-out toe plant, forward of the hips
|
|
const ELVIS_TOE_Y_MM = 330; // ... and out to the robot-left
|
|
const ELVIS_TOE_SHAKE_MM = 28; // rubber-leg lateral toe shake (2x beat)
|
|
const ELVIS_FOOT_POINT_DEG = 55; // pointed toe of the kicked-out foot
|
|
const ELVIS_FOOT_POINT_SHAKE_DEG = 7;
|
|
const ELVIS_TOE_TIP_LOCAL = { x: 112, drop: 56 }; // toe tip rel ankle-pitch (fwd, down)
|
|
const ELVIS_POINT_ROLL_DEG = -115; // right arm raised out-and-up to the right
|
|
const ELVIS_POINT_PITCH_DEG = -25;
|
|
const ELVIS_POINT_ELBOW_DEG = -12; // nearly straight: the finger points
|
|
const ELVIS_POINT_PULSE_DEG = 8; // beat pulse of the pointing arm
|
|
const ELVIS_OFF_ARM_PITCH_DEG = -30; // left arm low and bent across
|
|
const ELVIS_OFF_ARM_ELBOW_DEG = -70;
|
|
const ELVIS_TWIST_DEG = 10; // hip swivel via the waist
|
|
const ELVIS_LEAN_RIGHT_DEG = -3; // lean into the point
|
|
const ELVIS_HEAD_TURN_DEG = -20; // look toward the pointing hand
|
|
const ELVIS_CHIN_UP_DEG = -5;
|
|
|
|
const SIDES = ["left", "right"];
|
|
|
|
function sideSign(side) {
|
|
return side === "left" ? 1 : -1;
|
|
}
|
|
|
|
// Kinematic chain in root-to-leaf order, mirrored from juno_parts/chain.py.
|
|
// origin is the joint center in the parent link frame (mm); each child link
|
|
// frame sits at its joint center.
|
|
function buildJoints() {
|
|
const joints = [
|
|
{ name: "waist_yaw", parent: "pelvis", child: "torso", origin: [0, 0, 0], axis: Z },
|
|
{ name: "neck_yaw", parent: "torso", child: "neck_collar", origin: [0, 0, 324], axis: Z },
|
|
{ name: "neck_pitch", parent: "neck_collar", child: "head", origin: [0, 0, 46], axis: Y }
|
|
];
|
|
for (const side of SIDES) {
|
|
const s = sideSign(side);
|
|
joints.push(
|
|
{ name: `hip_yaw_${side}`, parent: "pelvis", child: `hip_bracket_${side}`, origin: [0, s * HIP_Y, -120], axis: Z },
|
|
{ name: `hip_roll_${side}`, parent: `hip_bracket_${side}`, child: `hip_carrier_${side}`, origin: [0, 0, -64], axis: X },
|
|
{ name: `hip_pitch_${side}`, parent: `hip_carrier_${side}`, child: `thigh_${side}`, origin: [0, 0, -78], axis: Y },
|
|
{ name: `knee_${side}`, parent: `thigh_${side}`, child: `shin_${side}`, origin: [0, 0, -290], axis: Y },
|
|
{ name: `ankle_pitch_${side}`, parent: `shin_${side}`, child: `ankle_link_${side}`, origin: [0, 0, -290], axis: Y },
|
|
{ name: `ankle_roll_${side}`, parent: `ankle_link_${side}`, child: `foot_${side}`, origin: [0, 0, -30], axis: X }
|
|
);
|
|
}
|
|
for (const side of SIDES) {
|
|
const s = sideSign(side);
|
|
joints.push(
|
|
{ name: `shoulder_pitch_${side}`, parent: "torso", child: `shoulder_pod_${side}`, origin: [0, s * SHOULDER_Y, 290], axis: Y },
|
|
{ name: `shoulder_roll_${side}`, parent: `shoulder_pod_${side}`, child: `yaw_housing_${side}`, origin: [0, s * 34, -72], axis: X },
|
|
{ name: `shoulder_yaw_${side}`, parent: `yaw_housing_${side}`, child: `bicep_${side}`, origin: [0, 0, -24], axis: Z },
|
|
{ name: `elbow_${side}`, parent: `bicep_${side}`, child: `forearm_${side}`, origin: [0, 0, -156], axis: Y },
|
|
{ name: `wrist_roll_${side}`, parent: `forearm_${side}`, child: `wrist_carrier_${side}`, origin: [0, 0, -150], axis: Z },
|
|
{ name: `wrist_pitch_${side}`, parent: `wrist_carrier_${side}`, child: `hand_${side}`, origin: [0, 0, -28], axis: Y }
|
|
);
|
|
}
|
|
return joints;
|
|
}
|
|
|
|
const JOINTS = buildJoints();
|
|
|
|
// Optional artistic extras applied on top of joint FK, keyed by joint name:
|
|
// { rollDeg } adds a small rotation about +X at the same joint center.
|
|
function athleticAnglesDeg() {
|
|
const angles = {
|
|
waist_yaw: ATHLETIC.WAIST_YAW,
|
|
neck_yaw: ATHLETIC.NECK_YAW,
|
|
neck_pitch: ATHLETIC.NECK_PITCH
|
|
};
|
|
for (const side of SIDES) {
|
|
const s = sideSign(side);
|
|
angles[`hip_yaw_${side}`] = ATHLETIC.HIP_YAW;
|
|
angles[`hip_roll_${side}`] = s * ATHLETIC.HIP_ROLL_ABDUCT;
|
|
angles[`hip_pitch_${side}`] = ATHLETIC.HIP_PITCH;
|
|
angles[`knee_${side}`] = ATHLETIC.KNEE;
|
|
angles[`ankle_pitch_${side}`] = ATHLETIC.ANKLE_PITCH;
|
|
angles[`ankle_roll_${side}`] = -s * ATHLETIC.HIP_ROLL_ABDUCT;
|
|
angles[`shoulder_pitch_${side}`] = ATHLETIC.SHOULDER_PITCH;
|
|
angles[`shoulder_roll_${side}`] = s * ATHLETIC.SHOULDER_ROLL_ABDUCT;
|
|
angles[`shoulder_yaw_${side}`] = -s * ATHLETIC.SHOULDER_YAW_INTERNAL;
|
|
angles[`elbow_${side}`] = ATHLETIC.ELBOW;
|
|
angles[`wrist_roll_${side}`] = ATHLETIC.WRIST_ROLL;
|
|
angles[`wrist_pitch_${side}`] = ATHLETIC.WRIST_PITCH;
|
|
}
|
|
return angles;
|
|
}
|
|
|
|
const ATHLETIC_ANGLES = athleticAnglesDeg();
|
|
|
|
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);
|
|
}
|
|
|
|
const IDENTITY3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
|
|
function rotAxisDeg(axis, deg) {
|
|
const rad = (deg * Math.PI) / 180;
|
|
const c = Math.cos(rad);
|
|
const s = Math.sin(rad);
|
|
if (axis === X) {
|
|
return [[1, 0, 0], [0, c, -s], [0, s, c]];
|
|
}
|
|
if (axis === Y) {
|
|
return [[c, 0, s], [0, 1, 0], [-s, 0, c]];
|
|
}
|
|
return [[c, -s, 0], [s, c, 0], [0, 0, 1]];
|
|
}
|
|
|
|
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]]
|
|
];
|
|
}
|
|
|
|
// FK over the whole chain. extras[jointName] = { pitchDeg, rollDeg } folds
|
|
// small artistic +Y/+X rotations in at that joint center (torso lean and
|
|
// weight shift); rootOffset translates the pelvis (body bounce in the air
|
|
// gaits — the stance-leg IK absorbs it on the ground side); rootPitchDeg
|
|
// rotates the whole body about +Y at the pelvis (handstand inversion —
|
|
// callers solve the matching translation so contacts stay put).
|
|
function fkFrames(anglesDeg, extras = {}, rootOffset = [0, 0, 0], rootPitchDeg = 0) {
|
|
const rootR = rootPitchDeg ? rotAxisDeg(Y, rootPitchDeg) : IDENTITY3;
|
|
const frames = { pelvis: { R: rootR, p: [...rootOffset] } };
|
|
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]];
|
|
let R = matMul3(parent.R, rotAxisDeg(joint.axis, finite(anglesDeg[joint.name], 0)));
|
|
const extra = extras[joint.name];
|
|
if (extra && finite(extra.pitchDeg, 0) !== 0) {
|
|
R = matMul3(R, rotAxisDeg(Y, extra.pitchDeg));
|
|
}
|
|
if (extra && finite(extra.rollDeg, 0) !== 0) {
|
|
R = matMul3(R, rotAxisDeg(X, extra.rollDeg));
|
|
}
|
|
frames[joint.child] = { R, p };
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
const ATHLETIC_FRAMES = fkFrames(ATHLETIC_ANGLES);
|
|
|
|
// Per-side stride-IK constants derived from the athletic FK: the hip-pitch
|
|
// joint center stays fixed in the world (static pelvis, athletic hip
|
|
// yaw/roll), and the compensated ankle roll keeps the foot flat, so the
|
|
// ankle-pitch target maps exactly onto the rolled sagittal plane.
|
|
const STRIDE_RIG = (() => {
|
|
const rig = {};
|
|
for (const side of SIDES) {
|
|
const hip = ATHLETIC_FRAMES[`thigh_${side}`].p; // hip-pitch joint center
|
|
const foot = ATHLETIC_FRAMES[`foot_${side}`];
|
|
const rollRad = (sideSign(side) * ATHLETIC.HIP_ROLL_ABDUCT * Math.PI) / 180;
|
|
// Sole plane and the flat-foot vertical offset from the ankle-pitch
|
|
// joint: the 30 mm ankle-roll drop is still rolled by the hip roll, the
|
|
// 26 mm sole sits below the roll-compensated (world-aligned) foot frame.
|
|
const soleZ = foot.p[2] + matVec3(foot.R, [0, 0, -26])[2];
|
|
const ankleToSole = (30 * Math.cos(rollRad)) + 26;
|
|
rig[side] = { hipZ: hip[2], cosRoll: Math.cos(rollRad), soleZ, ankleToSole };
|
|
}
|
|
return rig;
|
|
})();
|
|
|
|
// Athletic left toe-tip world position (x, y): the Elvis kick-out leg IK
|
|
// starts and ends its toe-target path here so the dance loop rests
|
|
// exactly on the athletic stance.
|
|
const ELVIS_ATHLETIC_TOE = (() => {
|
|
const foot = ATHLETIC_FRAMES.foot_left;
|
|
const toe = matVec3(foot.R, [112, 0, -26]).map((v, k) => v + foot.p[k]);
|
|
return [toe[0], toe[1]];
|
|
})();
|
|
|
|
// Row-major 4x4 mapping the baked athletic 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
|
|
];
|
|
}
|
|
|
|
// Raised-cosine swing envelope: 0 at lift-off and touch-down, 1 mid-swing,
|
|
// identically 0 outside the window so the stance foot never moves.
|
|
function swingEnvelope(phase, [start, end]) {
|
|
const p = ((finite(phase, 0) % 1) + 1) % 1;
|
|
if (p <= start || p >= end) {
|
|
return 0;
|
|
}
|
|
const u = (p - start) / (end - start);
|
|
const lobe = Math.sin(Math.PI * u);
|
|
return lobe * lobe;
|
|
}
|
|
|
|
// Cosine forwardness peaking at the given cycle position.
|
|
function forwardness(phase, peakAt) {
|
|
return Math.cos(2 * Math.PI * (finite(phase, 0) - peakAt));
|
|
}
|
|
|
|
function gaitAnglesDeg(phase, { legLift = 1, armSwing = 1, torsoSway = 1 } = {}) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
|
|
for (const side of SIDES) {
|
|
const env = swingEnvelope(phase, SWING[side]) * legLift;
|
|
const hipDelta = -HIP_LIFT_DEG * env;
|
|
const kneeDelta = KNEE_FOLD_DEG * env;
|
|
// Cancel hip+knee so the airborne foot stays near level, then point
|
|
// the toes down a touch mid-swing.
|
|
const ankleDelta = -(hipDelta + kneeDelta) + (TOE_POINT_DEG * env);
|
|
angles[`hip_pitch_${side}`] += hipDelta;
|
|
angles[`knee_${side}`] += kneeDelta;
|
|
angles[`ankle_pitch_${side}`] += ankleDelta;
|
|
|
|
// Arms swing antiphase to the own-side leg: the left arm leads when the
|
|
// right leg lifts (left swing centered at 0.25, right at 0.75).
|
|
const ownLegPeak = side === "left" ? 0.25 : 0.75;
|
|
const armForward = forwardness(phase, ownLegPeak + 0.5) * armSwing;
|
|
angles[`shoulder_pitch_${side}`] += -ARM_SWING_DEG * armForward;
|
|
angles[`elbow_${side}`] += -ELBOW_EXTRA_DEG * Math.max(0, armForward);
|
|
}
|
|
|
|
// Torso counter-rotation: turning left brings the right shoulder forward
|
|
// exactly while the left leg swings; the neck compensates most of it so
|
|
// the head keeps facing forward.
|
|
const sway = WAIST_SWAY_DEG * torsoSway * Math.sin(2 * Math.PI * finite(phase, 0));
|
|
angles.waist_yaw += sway;
|
|
angles.neck_yaw += -sway * NECK_COMPENSATION;
|
|
|
|
return angles;
|
|
}
|
|
|
|
function torsoRollExtras(phase, torsoSway) {
|
|
// Lean toward the stance side: negative roll (lean robot-right) while the
|
|
// left leg is lifted at phase 0.25.
|
|
const rollDeg = -TORSO_ROLL_DEG * torsoSway * Math.sin(2 * Math.PI * finite(phase, 0));
|
|
return { waist_yaw: { rollDeg } };
|
|
}
|
|
|
|
function smooth01(value) {
|
|
const t = clamp(value, 0, 1);
|
|
return t * t * (3 - (2 * t));
|
|
}
|
|
|
|
// Two-link planar leg IK in the rolled sagittal plane. Targets the
|
|
// ankle-pitch joint at forward offset dx (mm, from the hip-pitch joint) and
|
|
// plane depth d (mm); footPitchDeg is the desired world pitch of the foot.
|
|
// Returns hip-pitch / knee / ankle-pitch angles in the chain's conventions
|
|
// (hip negative = forward, knee positive = flexion).
|
|
function legPitchIk(dx, d, footPitchDeg) {
|
|
const dist = clamp(Math.hypot(dx, d), Math.abs(THIGH_LEN - SHIN_LEN) + 1, THIGH_LEN + SHIN_LEN - 1);
|
|
const phi = Math.atan2(dx, d); // forward angle of the hip->ankle line from straight down
|
|
const cosInterior = clamp(
|
|
((THIGH_LEN * THIGH_LEN) + (SHIN_LEN * SHIN_LEN) - (dist * dist)) / (2 * THIGH_LEN * SHIN_LEN),
|
|
-1,
|
|
1
|
|
);
|
|
const kneeRad = Math.PI - Math.acos(cosInterior); // knee flexion, >= 0 (knee bends forward)
|
|
const cosBeta = clamp(
|
|
((THIGH_LEN * THIGH_LEN) + (dist * dist) - (SHIN_LEN * SHIN_LEN)) / (2 * THIGH_LEN * dist),
|
|
-1,
|
|
1
|
|
);
|
|
const thighRad = phi + Math.acos(cosBeta); // thigh angle from straight down, + forward
|
|
const hipDeg = (-thighRad * 180) / Math.PI;
|
|
const kneeDeg = (kneeRad * 180) / Math.PI;
|
|
const ankleDeg = footPitchDeg - (hipDeg + kneeDeg);
|
|
return { hipDeg, kneeDeg, ankleDeg };
|
|
}
|
|
|
|
// Treadmill stride: stance foot slides backward flat on the ground from
|
|
// +stride/2 to -stride/2; the swing leg returns through the air with toe-off
|
|
// and heel-strike accents. Both halves meet at zero velocity mismatch in
|
|
// position, so the loop is seamless.
|
|
function strideAnglesDeg(phase, { strideLength = 230, legLift = 1, armSwing = 1, torsoSway = 1 } = {}) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
const stride = clamp(strideLength, 0, MAX_STRIDE_MM);
|
|
|
|
for (const side of SIDES) {
|
|
const rig = STRIDE_RIG[side];
|
|
const q = ((finite(phase, 0) + STRIDE_PHASE[side]) % 1 + 1) % 1;
|
|
let ankleX;
|
|
let soleTargetZ;
|
|
let footPitchDeg;
|
|
if (q < STANCE_FRACTION) {
|
|
const s = q / STANCE_FRACTION;
|
|
ankleX = (stride / 2) - (stride * s); // front contact -> push-off behind
|
|
soleTargetZ = rig.soleZ;
|
|
footPitchDeg = 0;
|
|
} else {
|
|
const u = (q - STANCE_FRACTION) / (1 - STANCE_FRACTION);
|
|
ankleX = (-stride / 2) + (stride * smooth01(u));
|
|
const lobe = Math.sin(Math.PI * u);
|
|
// Clearance and toe-off/heel accents scale with the stride so a zero
|
|
// stride degenerates to plain athletic standing.
|
|
const reach = stride / DEFAULT_STRIDE_MM;
|
|
soleTargetZ = rig.soleZ + (STRIDE_LIFT_MM * clamp(legLift, 0, 1.4) * lobe * reach);
|
|
// Toes down right after toe-off, toes up (heel first) into contact.
|
|
footPitchDeg = ((STRIDE_TOE_OFF_DEG * lobe * (1 - u)) - (STRIDE_HEEL_DEG * lobe * u)) * reach;
|
|
}
|
|
const ankleTargetZ = soleTargetZ + rig.ankleToSole;
|
|
const depth = (rig.hipZ - ankleTargetZ) / rig.cosRoll;
|
|
// ankleX is authored relative to the hip-pitch joint; the athletic ankle
|
|
// sits exactly under the hip, so stride 0 reproduces the athletic legs.
|
|
const ik = legPitchIk(ankleX, depth, footPitchDeg);
|
|
angles[`hip_pitch_${side}`] = ik.hipDeg;
|
|
angles[`knee_${side}`] = ik.kneeDeg;
|
|
angles[`ankle_pitch_${side}`] = ik.ankleDeg;
|
|
|
|
// Arms counter the opposite leg: forward peak at the other side's
|
|
// mid-swing, wider than the march to match the longer stride.
|
|
const otherSide = side === "left" ? "right" : "left";
|
|
const armForward = forwardness(phase, STRIDE_MID_SWING[otherSide]) * armSwing * STRIDE_ARM_FACTOR;
|
|
angles[`shoulder_pitch_${side}`] += -ARM_SWING_DEG * armForward;
|
|
angles[`elbow_${side}`] += -ELBOW_EXTRA_DEG * Math.max(0, armForward);
|
|
}
|
|
|
|
// Shoulder girdle counters the hips: the right shoulder leads while the
|
|
// right arm swings forward (right-arm peak == left leg mid-swing).
|
|
const sway = WAIST_SWAY_DEG * STRIDE_SWAY_FACTOR * torsoSway
|
|
* forwardness(phase, STRIDE_MID_SWING.left);
|
|
angles.waist_yaw += sway;
|
|
angles.neck_yaw += -sway * NECK_COMPENSATION;
|
|
|
|
return angles;
|
|
}
|
|
|
|
function strideTorsoRollExtras(phase, torsoSway) {
|
|
// Lean toward the stance side: robot-right lean while the left leg swings.
|
|
const rollDeg = -TORSO_ROLL_DEG * STRIDE_SWAY_FACTOR * torsoSway
|
|
* forwardness(phase, STRIDE_MID_SWING.left);
|
|
return { waist_yaw: { rollDeg } };
|
|
}
|
|
|
|
// ------------------------------------------------------------------- run
|
|
function runBounceMm(phase, legLift) {
|
|
// Twice per cycle: lowest at each mid-stance, highest at mid-flight.
|
|
return -RUN_BOUNCE_MM * clamp(legLift, 0, 1.4)
|
|
* Math.cos(4 * Math.PI * (finite(phase, 0) - RUN_MID_STANCE.left));
|
|
}
|
|
|
|
// Stance-foot targets with a toe-pivot heel-off: the foot pivots about the
|
|
// sole's front edge so the heel rises into push-off without the toe digging
|
|
// below the ground.
|
|
function runStanceTarget(s, stride, reach, rig) {
|
|
const flatAnkleX = (stride * RUN_CONTACT_SHARE) - (stride * s);
|
|
let pitchDeg = 0;
|
|
if (s > RUN_HEEL_OFF_START) {
|
|
pitchDeg = RUN_HEEL_OFF_DEG * reach * smooth01((s - RUN_HEEL_OFF_START) / (1 - RUN_HEEL_OFF_START));
|
|
}
|
|
const pitchRad = (pitchDeg * Math.PI) / 180;
|
|
const c = Math.cos(pitchRad);
|
|
const sn = Math.sin(pitchRad);
|
|
// Ankle relative to the toe pivot when flat: (-RUN_TOE_PIVOT_X_MM, +ankle height).
|
|
const ankleX = flatAnkleX + (RUN_TOE_PIVOT_X_MM * (1 - c)) + (RUN_ANKLE_HEIGHT_MM * sn);
|
|
const ankleZ = rig.soleZ + (RUN_TOE_PIVOT_X_MM * sn) + (rig.ankleToSole * c);
|
|
return { ankleX, ankleZ, pitchDeg };
|
|
}
|
|
|
|
function runAnglesDeg(phase, { strideLength = 230, legLift = 1, armSwing = 1, torsoSway = 1 } = {}) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
const stride = clamp(strideLength, 0, MAX_STRIDE_MM);
|
|
const reach = stride / DEFAULT_STRIDE_MM;
|
|
const lift = clamp(legLift, 0, 1.4);
|
|
const bounce = runBounceMm(phase, legLift);
|
|
|
|
for (const side of SIDES) {
|
|
const rig = STRIDE_RIG[side];
|
|
const q = ((finite(phase, 0) + RUN_PHASE[side]) % 1 + 1) % 1;
|
|
let target;
|
|
if (q < RUN_STANCE_FRACTION) {
|
|
target = runStanceTarget(q / RUN_STANCE_FRACTION, stride, reach, rig);
|
|
} else {
|
|
const u = (q - RUN_STANCE_FRACTION) / (1 - RUN_STANCE_FRACTION);
|
|
const t = smooth01(u);
|
|
const end = runStanceTarget(1, stride, reach, rig);
|
|
const contactX = stride * RUN_CONTACT_SHARE;
|
|
const contactZ = rig.soleZ + rig.ankleToSole;
|
|
target = {
|
|
ankleX: end.ankleX + ((contactX - end.ankleX) * t),
|
|
ankleZ: end.ankleZ + ((contactZ - end.ankleZ) * t)
|
|
+ (RUN_APEX_MM * lift * reach * Math.sin(Math.PI * u)),
|
|
pitchDeg: (end.pitchDeg * (1 - t) * (1 - t))
|
|
- (RUN_HEEL_IN_DEG * reach * Math.sin(Math.PI * u) * u)
|
|
};
|
|
}
|
|
const depth = ((rig.hipZ + bounce) - target.ankleZ) / rig.cosRoll;
|
|
const ik = legPitchIk(target.ankleX, depth, target.pitchDeg);
|
|
angles[`hip_pitch_${side}`] = ik.hipDeg;
|
|
angles[`knee_${side}`] = ik.kneeDeg;
|
|
angles[`ankle_pitch_${side}`] = ik.ankleDeg;
|
|
|
|
// Runner's arm pump: bent elbows, antiphase to the own-side leg.
|
|
const otherSide = side === "left" ? "right" : "left";
|
|
const armForward = forwardness(phase, RUN_MID_SWING[otherSide]) * armSwing;
|
|
angles[`shoulder_pitch_${side}`] += -RUN_ARM_SWING_DEG * armForward;
|
|
angles[`elbow_${side}`] += (RUN_ELBOW_BASE_DEG * clamp(armSwing, 0, 1.5))
|
|
- (RUN_ELBOW_PUMP_DEG * Math.max(0, armForward));
|
|
}
|
|
|
|
// Shoulder girdle counters the hips; the head keeps level against both
|
|
// the sway and the forward lean.
|
|
const sway = RUN_SWAY_DEG * torsoSway * forwardness(phase, RUN_MID_SWING.left);
|
|
angles.waist_yaw += sway;
|
|
angles.neck_yaw += -sway * NECK_COMPENSATION;
|
|
angles.neck_pitch += -RUN_LEAN_DEG * NECK_COMPENSATION;
|
|
|
|
return angles;
|
|
}
|
|
|
|
function runTorsoExtras(phase, torsoSway) {
|
|
// Constant forward lean plus a lean toward the stance side.
|
|
const rollDeg = RUN_ROLL_DEG * torsoSway * forwardness(phase, RUN_MID_STANCE.left);
|
|
return { waist_yaw: { pitchDeg: RUN_LEAN_DEG, rollDeg } };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ jump
|
|
function jumpBodyZMm(phase, lift) {
|
|
const p = ((finite(phase, 0) % 1) + 1) % 1;
|
|
const { crouch, push, land, absorb } = JUMP_TIMING;
|
|
if (p < crouch) {
|
|
return -JUMP_CROUCH_MM * lift * smooth01(p / crouch);
|
|
}
|
|
if (p < push) {
|
|
const t = smooth01((p - crouch) / (push - crouch));
|
|
return (-JUMP_CROUCH_MM * lift) + (((JUMP_TAKEOFF_MM) + (JUMP_CROUCH_MM * lift)) * t);
|
|
}
|
|
if (p < land) {
|
|
// Ballistic parabola between takeoff and landing at the same height.
|
|
const t = (p - push) / (land - push);
|
|
const apex = JUMP_APEX_MM * lift;
|
|
return JUMP_TAKEOFF_MM + ((apex - JUMP_TAKEOFF_MM) * (1 - ((2 * t) - 1) * ((2 * t) - 1)));
|
|
}
|
|
if (p < absorb) {
|
|
// Impact: ride down into the first compression.
|
|
const t = smooth01((p - land) / (absorb - land));
|
|
return JUMP_TAKEOFF_MM + (((JUMP_LAND_DIP_MM * lift) - JUMP_TAKEOFF_MM) * t);
|
|
}
|
|
// Springy recovery: underdamped wobble that ends exactly at zero.
|
|
const t = (p - absorb) / (1 - absorb);
|
|
return JUMP_LAND_DIP_MM * lift * Math.exp(-JUMP_SPRING_DECAY * t)
|
|
* Math.cos(2 * Math.PI * JUMP_SPRING_CYCLES * t);
|
|
}
|
|
|
|
function jumpFlightProgress(phase) {
|
|
const p = ((finite(phase, 0) % 1) + 1) % 1;
|
|
const { push, land } = JUMP_TIMING;
|
|
if (p <= push || p >= land) {
|
|
return 0;
|
|
}
|
|
return (p - push) / (land - push);
|
|
}
|
|
|
|
// Arms: backswing in the crouch, sweep overhead for the flight, dip with
|
|
// the landing spring, settle back to athletic by the end of the cycle.
|
|
function jumpShoulderPitchDeg(phase, bodyZ) {
|
|
const p = ((finite(phase, 0) % 1) + 1) % 1;
|
|
const { crouch, push, land, absorb } = JUMP_TIMING;
|
|
const athletic = ATHLETIC.SHOULDER_PITCH;
|
|
if (p < crouch) {
|
|
return athletic + ((JUMP_ARM_BACK_DEG - athletic) * smooth01(p / crouch));
|
|
}
|
|
if (p < push) {
|
|
return JUMP_ARM_BACK_DEG + ((JUMP_ARM_UP_DEG - JUMP_ARM_BACK_DEG) * smooth01((p - crouch) / (push - crouch)));
|
|
}
|
|
if (p < land) {
|
|
return JUMP_ARM_UP_DEG;
|
|
}
|
|
if (p < absorb) {
|
|
// Dip with the impact: arms lower in proportion to the body drop.
|
|
return JUMP_ARM_UP_DEG + (Math.abs(Math.min(0, bodyZ)) * JUMP_ARM_SPRING_DEG_PER_MM);
|
|
}
|
|
const t = smooth01((p - absorb) / (1 - absorb));
|
|
const dipped = JUMP_ARM_UP_DEG + (Math.abs(Math.min(0, bodyZ)) * JUMP_ARM_SPRING_DEG_PER_MM);
|
|
return dipped + ((athletic - dipped) * t);
|
|
}
|
|
|
|
function jumpAnglesDeg(phase, { legLift = 1, armSwing = 1 } = {}) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
const lift = clamp(legLift, 0, 1.4);
|
|
const bodyZ = jumpBodyZMm(phase, lift);
|
|
const flight = jumpFlightProgress(phase);
|
|
const tuckLobe = Math.sin(Math.PI * flight);
|
|
|
|
for (const side of SIDES) {
|
|
const rig = STRIDE_RIG[side];
|
|
// Feet stay planted under the hips; in flight the ankle target rises
|
|
// (tuck) and the IK reach clamp straightens the legs at takeoff/landing.
|
|
const ankleZ = rig.soleZ + rig.ankleToSole + (flight > 0 ? JUMP_TUCK_MM * lift * tuckLobe : 0);
|
|
const pitchDeg = flight > 0 ? JUMP_TOE_POINT_DEG * tuckLobe : 0;
|
|
const depth = ((rig.hipZ + bodyZ) - ankleZ) / rig.cosRoll;
|
|
const ik = legPitchIk(0, depth, pitchDeg);
|
|
angles[`hip_pitch_${side}`] = ik.hipDeg;
|
|
angles[`knee_${side}`] = ik.kneeDeg;
|
|
angles[`ankle_pitch_${side}`] = ik.ankleDeg;
|
|
|
|
// Overhead V: pitch sweeps the arms up; roll spreads them outward in
|
|
// proportion to how raised they are.
|
|
const s = sideSign(side);
|
|
const shoulderPitch = jumpShoulderPitchDeg(phase, bodyZ);
|
|
const raised = clamp(
|
|
(shoulderPitch - ATHLETIC.SHOULDER_PITCH) / (JUMP_ARM_UP_DEG - ATHLETIC.SHOULDER_PITCH),
|
|
0,
|
|
1
|
|
);
|
|
const armScale = clamp(armSwing, 0, 1.5);
|
|
angles[`shoulder_pitch_${side}`] = ATHLETIC.SHOULDER_PITCH
|
|
+ ((shoulderPitch - ATHLETIC.SHOULDER_PITCH) * armScale);
|
|
angles[`shoulder_roll_${side}`] += s * JUMP_ARM_V_DEG * raised * armScale;
|
|
angles[`elbow_${side}`] = ATHLETIC.ELBOW + (5 * raised * armScale);
|
|
}
|
|
|
|
// Look up a touch while airborne.
|
|
angles.neck_pitch += JUMP_HEAD_UP_DEG * tuckLobe;
|
|
return { angles, bodyZ, flight };
|
|
}
|
|
|
|
function jumpTorsoExtras(phase, bodyZ, flight, torsoSway) {
|
|
// Lean into the crouch and the landing compression, upright in the air.
|
|
const compression = clamp(-Math.min(0, bodyZ) / JUMP_CROUCH_MM, 0, 1.2);
|
|
const pitchDeg = JUMP_LEAN_DEG * compression * (1 - flight) * clamp(torsoSway, 0, 1.5);
|
|
return { waist_yaw: { pitchDeg } };
|
|
}
|
|
|
|
// --------------------------------------------------------------- routine
|
|
function wrap01(value) {
|
|
return ((finite(value, 0) % 1) + 1) % 1;
|
|
}
|
|
|
|
// Smooth on/off window: ramps up over [inStart, inEnd], back down over
|
|
// [outStart, outEnd], zero outside.
|
|
function pulse(t, inStart, inEnd, outStart, outEnd) {
|
|
return smooth01((t - inStart) / Math.max(1e-9, inEnd - inStart))
|
|
* (1 - smooth01((t - outStart) / Math.max(1e-9, outEnd - outStart)));
|
|
}
|
|
|
|
// Planted-leg solve shared by the kick support leg and the dance: the
|
|
// pitch IK keeps the sole at ground height under a bobbing pelvis, and a
|
|
// hip/ankle roll pair absorbs a lateral pelvis shift dy (the second-order
|
|
// foot lift from the roll arc is under 2 mm at these amplitudes).
|
|
function plantedLegAngles(side, bodyZMm, pelvisDyMm) {
|
|
const rig = STRIDE_RIG[side];
|
|
const s = sideSign(side);
|
|
const depth = ((rig.hipZ + bodyZMm) - (rig.soleZ + rig.ankleToSole)) / rig.cosRoll;
|
|
const ik = legPitchIk(0, depth, 0);
|
|
const lateralDrop = HIP_ROLL_Z_MM - rig.soleZ;
|
|
const rollDeltaDeg = (Math.asin(clamp(pelvisDyMm / lateralDrop, -0.5, 0.5)) * 180) / Math.PI;
|
|
return {
|
|
[`hip_pitch_${side}`]: ik.hipDeg,
|
|
[`knee_${side}`]: ik.kneeDeg,
|
|
[`ankle_pitch_${side}`]: ik.ankleDeg,
|
|
[`hip_roll_${side}`]: (s * ATHLETIC.HIP_ROLL_ABDUCT) - rollDeltaDeg,
|
|
[`ankle_roll_${side}`]: -((s * ATHLETIC.HIP_ROLL_ABDUCT) - rollDeltaDeg)
|
|
};
|
|
}
|
|
|
|
// ---- karate front kick (segment-local t): weight onto the left leg,
|
|
// chamber, snap, re-chamber, plant.
|
|
function kickPose(t) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
const weight = pulse(t, 0.0, 0.12, 0.86, 0.98);
|
|
const chamber = pulse(t, 0.14, 0.3, 0.72, 0.88);
|
|
const extend = pulse(t, 0.36, 0.48, 0.58, 0.7);
|
|
const guard = pulse(t, 0.04, 0.16, 0.8, 0.94);
|
|
|
|
const dy = KICK_WEIGHT_SHIFT_MM * weight;
|
|
const bodyZ = -KICK_DIP_MM * chamber;
|
|
Object.assign(angles, plantedLegAngles("left", bodyZ, dy));
|
|
|
|
// Kicking leg, authored in joint space (airborne).
|
|
angles.hip_pitch_right = ATHLETIC.HIP_PITCH
|
|
+ ((KICK_CHAMBER_HIP_DEG - ATHLETIC.HIP_PITCH) * chamber)
|
|
+ (KICK_EXTEND_HIP_DEG * extend);
|
|
angles.knee_right = ATHLETIC.KNEE
|
|
+ ((KICK_CHAMBER_KNEE_DEG - ATHLETIC.KNEE) * chamber)
|
|
+ (KICK_EXTEND_KNEE_DEG * extend);
|
|
angles.ankle_pitch_right = ATHLETIC.ANKLE_PITCH
|
|
+ ((KICK_CHAMBER_ANKLE_DEG - ATHLETIC.ANKLE_PITCH) * chamber)
|
|
+ (KICK_EXTEND_ANKLE_DEG * extend);
|
|
angles.ankle_roll_right = -sideSign("right") * ATHLETIC.HIP_ROLL_ABDUCT;
|
|
|
|
// Guard: both fists up, elbows tight.
|
|
for (const side of SIDES) {
|
|
const s = sideSign(side);
|
|
angles[`shoulder_pitch_${side}`] = ATHLETIC.SHOULDER_PITCH
|
|
+ ((KICK_GUARD_SHOULDER_DEG - ATHLETIC.SHOULDER_PITCH) * guard);
|
|
angles[`shoulder_roll_${side}`] = (s * ATHLETIC.SHOULDER_ROLL_ABDUCT)
|
|
+ (s * KICK_GUARD_ROLL_OUT_DEG * guard);
|
|
angles[`elbow_${side}`] = ATHLETIC.ELBOW
|
|
+ ((KICK_GUARD_ELBOW_DEG - ATHLETIC.ELBOW) * guard);
|
|
}
|
|
|
|
const turn = KICK_HIP_TURN_DEG * ((0.5 * chamber) + (0.5 * extend));
|
|
angles.waist_yaw += turn;
|
|
angles.neck_yaw += -turn * NECK_COMPENSATION;
|
|
|
|
return {
|
|
angles,
|
|
extras: { waist_yaw: { pitchDeg: KICK_LEAN_BACK_DEG * extend } },
|
|
rootOffset: [0, dy, bodyZ],
|
|
rootPitchDeg: 0
|
|
};
|
|
}
|
|
|
|
// ---- handstand: keyframed whole-body rotation about +Y. While folding,
|
|
// the root is anchored so the sole front edge stays at its athletic spot
|
|
// (heels rise, toe pivot); once the hands plant, the root is anchored so
|
|
// the palm contact point stays on the ground at the plant line. Anchors
|
|
// are solved once at init by evaluating the FK and shifting the root.
|
|
function anchoredRootOffset(angles, rootPitchDeg, link, localMm, targetMm) {
|
|
const frames = fkFrames(angles, {}, [0, 0, 0], rootPitchDeg);
|
|
const f = frames[link];
|
|
const world = matVec3(f.R, localMm).map((v, k) => v + f.p[k]);
|
|
// null target components leave that axis where the pose naturally put it.
|
|
return targetMm.map((t, k) => (t === null ? 0 : t - world[k]));
|
|
}
|
|
|
|
function handstandKeyAngles(overrides) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
for (const side of SIDES) {
|
|
// Square the limbs for the gymnastic line: no abduction or twist.
|
|
angles[`hip_roll_${side}`] = 0;
|
|
angles[`ankle_roll_${side}`] = 0;
|
|
angles[`shoulder_yaw_${side}`] = 0;
|
|
angles[`wrist_pitch_${side}`] = 0;
|
|
for (const [name, value] of Object.entries(overrides.legs || {})) {
|
|
angles[`${name}_${side}`] = value;
|
|
}
|
|
for (const [name, value] of Object.entries(overrides.arms || {})) {
|
|
angles[`${name}_${side}`] = value;
|
|
}
|
|
}
|
|
if (overrides.neckPitch !== undefined) {
|
|
angles.neck_pitch = overrides.neckPitch;
|
|
}
|
|
return angles;
|
|
}
|
|
|
|
const HANDSTAND_KEYS = (() => {
|
|
const toeFrame = ATHLETIC_FRAMES.foot_left;
|
|
const toeWorld = matVec3(toeFrame.R, HS_TOE_LOCAL_MM).map((v, k) => v + toeFrame.p[k]);
|
|
// Anchor x/z only; the lateral position stays where the pose puts it.
|
|
const toeTarget = [toeWorld[0], null, toeWorld[2]];
|
|
const ground = STRIDE_RIG.left.soleZ;
|
|
const palmTarget = [HS_PLANT_X_MM, null, ground];
|
|
|
|
// The arms stay roughly vertical under the rotating body — the world
|
|
// arm direction is rootPitch + shoulderPitch, so the shoulder tracks
|
|
// -rootPitch as the body goes over, reaching shoulder -180 / elbow -18 /
|
|
// wrist -70 at the hold (total hand pitch -90: palms flat, fingers
|
|
// forward), all inside the URDF joint limits.
|
|
const HOLD_ARMS = { shoulder_pitch: -180, shoulder_roll: 0, elbow: -18, wrist_roll: 0, wrist_pitch: -70 };
|
|
const spec = [
|
|
{ t: 0.0, pitch: 0, anchor: null, overrides: null },
|
|
{
|
|
t: 0.09, pitch: 12, anchor: "toe",
|
|
overrides: {
|
|
legs: { hip_pitch: -30, knee: 20, ankle_pitch: 3 }, // foot pitch +5: heel just off the ground
|
|
arms: { shoulder_pitch: -52, shoulder_roll: 0, elbow: -10, wrist_roll: 0, wrist_pitch: -20 }
|
|
}
|
|
},
|
|
{
|
|
t: 0.2, pitch: 55, anchor: "toe",
|
|
overrides: {
|
|
legs: { hip_pitch: -100, knee: 30, ankle_pitch: 27 }, // foot pitch +12: heels rise in the fold
|
|
arms: { shoulder_pitch: -70, shoulder_roll: 0, elbow: -8, wrist_roll: 0, wrist_pitch: -45 }
|
|
}
|
|
},
|
|
{
|
|
// Press position: palms planted, arms vertical, pike fold with the
|
|
// toes still resting on the ground (hip solved at init below).
|
|
t: 0.3, pitch: 95, anchor: "palm", solvePressHip: true,
|
|
overrides: {
|
|
legs: { hip_pitch: -80, knee: 8, ankle_pitch: 30 },
|
|
arms: { shoulder_pitch: -95, shoulder_roll: 0, elbow: -20, wrist_roll: 0, wrist_pitch: -70 }
|
|
}
|
|
},
|
|
{
|
|
t: 0.4, pitch: 140, anchor: "palm",
|
|
overrides: {
|
|
legs: { hip_pitch: -45, knee: 25, ankle_pitch: 10 },
|
|
arms: { shoulder_pitch: -145, shoulder_roll: 0, elbow: -18, wrist_roll: 0, wrist_pitch: -67 }
|
|
}
|
|
},
|
|
{
|
|
t: 0.48, pitch: 178, anchor: "palm",
|
|
overrides: {
|
|
legs: { hip_pitch: -6, knee: 4, ankle_pitch: 25 },
|
|
arms: HOLD_ARMS
|
|
}
|
|
},
|
|
{
|
|
t: 0.58, pitch: 178, anchor: "palm",
|
|
overrides: {
|
|
legs: { hip_pitch: -6, knee: 4, ankle_pitch: 25 },
|
|
arms: HOLD_ARMS
|
|
}
|
|
}
|
|
];
|
|
// Mirror the way back down through the same shapes.
|
|
const back = [
|
|
{ ...spec[4], t: 0.66 },
|
|
{ ...spec[3], t: 0.76 },
|
|
{ ...spec[2], t: 0.86 },
|
|
{ ...spec[1], t: 0.94 },
|
|
{ ...spec[0], t: 1.0 }
|
|
];
|
|
|
|
// Resolve every key with its anchor TYPE plus the toe-line x position
|
|
// it implies; the root translation itself is solved per frame at
|
|
// runtime, so contacts stay exact through every interpolated pose.
|
|
// Palm-typed keys still record their natural toe x so toe<->palm
|
|
// boundary intervals can slide the toe target continuously.
|
|
const resolved = [...spec, ...back].map((key) => {
|
|
const angles = key.overrides ? handstandKeyAngles(key.overrides) : { ...ATHLETIC_ANGLES };
|
|
const type = key.anchor === "palm" ? "palm" : "toe";
|
|
if (key.solvePressHip) {
|
|
// Bisect the pike fold so the toes rest exactly on the ground
|
|
// behind the planted hands (toe height grows monotonically with
|
|
// hip angle on the toes-behind branch). The palm anchor depends
|
|
// only on the arm chain, so the hip search does not disturb it.
|
|
const rootOffset = anchoredRootOffset(angles, key.pitch, "hand_left", HS_PALM_LOCAL_MM, palmTarget);
|
|
let lo = -95;
|
|
let hi = -40;
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const mid = (lo + hi) / 2;
|
|
for (const side of SIDES) {
|
|
angles[`hip_pitch_${side}`] = mid;
|
|
}
|
|
const f = fkFrames(angles, {}, rootOffset, key.pitch).foot_left;
|
|
const toeZ = f.p[2] + matVec3(f.R, HS_TOE_LOCAL_MM)[2];
|
|
if (toeZ < ground) {
|
|
lo = mid;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
}
|
|
const rootOffset = type === "palm"
|
|
? anchoredRootOffset(angles, key.pitch, "hand_left", HS_PALM_LOCAL_MM, palmTarget)
|
|
: anchoredRootOffset(angles, key.pitch, "foot_left", HS_TOE_LOCAL_MM, toeTarget);
|
|
const foot = fkFrames(angles, {}, rootOffset, key.pitch).foot_left;
|
|
const toeX = foot.p[0] + matVec3(foot.R, HS_TOE_LOCAL_MM)[0];
|
|
return { t: key.t, pitch: key.pitch, type, toeX, angles };
|
|
});
|
|
return { keys: resolved.sort((a, b) => a.t - b.t), toeZ: toeWorld[2], palmTarget };
|
|
})();
|
|
|
|
function handstandPose(t) {
|
|
const { keys, toeZ, palmTarget } = HANDSTAND_KEYS;
|
|
let a = keys[0];
|
|
let b = keys[keys.length - 1];
|
|
for (let i = 0; i < keys.length - 1; i += 1) {
|
|
if (t >= keys[i].t && t <= keys[i + 1].t) {
|
|
a = keys[i];
|
|
b = keys[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
const s = smooth01((t - a.t) / Math.max(1e-9, b.t - a.t));
|
|
const angles = {};
|
|
for (const name of Object.keys(ATHLETIC_ANGLES)) {
|
|
angles[name] = a.angles[name] + ((b.angles[name] - a.angles[name]) * s);
|
|
}
|
|
// A breath of leg wobble during the hold (hips only, so the planted
|
|
// hands stay put).
|
|
const hold = pulse(t, 0.46, 0.5, 0.56, 0.6);
|
|
const wobble = HS_WOBBLE_DEG * hold * Math.sin(2 * Math.PI * ((t - 0.46) / 0.14) * 2);
|
|
for (const side of SIDES) {
|
|
angles[`hip_pitch_${side}`] += wobble;
|
|
angles[`knee_${side}`] += -wobble * 0.6;
|
|
}
|
|
const rootPitchDeg = a.pitch + ((b.pitch - a.pitch) * s);
|
|
// Solve the contact anchor for THIS frame: palm-anchored only while both
|
|
// bracketing keys are palm keys (the press key satisfies both contacts,
|
|
// so the anchor handoff there is seamless); otherwise toe-anchored with
|
|
// the toe target sliding along the ground line between the keys.
|
|
let rootOffset;
|
|
if (a.type === "palm" && b.type === "palm") {
|
|
rootOffset = anchoredRootOffset(angles, rootPitchDeg, "hand_left", HS_PALM_LOCAL_MM, palmTarget);
|
|
} else {
|
|
const toeX = a.toeX + ((b.toeX - a.toeX) * s);
|
|
rootOffset = anchoredRootOffset(angles, rootPitchDeg, "foot_left", HS_TOE_LOCAL_MM, [toeX, null, toeZ]);
|
|
}
|
|
return { angles, extras: {}, rootOffset, rootPitchDeg };
|
|
}
|
|
|
|
// ---- Elvis dance. The left leg kicks out to the robot-left and plants
|
|
// on a pointed toe, solved with closed-form leg IK: the hip roll angle
|
|
// follows directly from requiring the toe target to lie in the leg's
|
|
// (rolled) pitch plane, then the in-plane two-link IK places the
|
|
// ankle-pitch joint so the toe tip lands exactly on the target. The
|
|
// ankle roll stays 0, so the pointed foot rolls onto its outer toe edge
|
|
// with the leg — the classic look.
|
|
function elvisKickOutLeg(bodyZMm, pelvisDyMm, toeXMm, toeYMm, footPointDeg, liftMm, edgeScale) {
|
|
const rig = STRIDE_RIG.left;
|
|
// Left hip-roll joint center in the world, with the shifted pelvis.
|
|
const hipRoll = [0, HIP_Y + pelvisDyMm, HIP_ROLL_Z_MM + bodyZMm];
|
|
// The foot rolls with the leg (ankle roll 0), so at full kick-out the
|
|
// contact is the OUTER toe corner: lift the centerline target by the
|
|
// rolled half foot width (scaled in with the pose; roll barely changes
|
|
// with the lift, so one extra pass converges).
|
|
let target = [toeXMm, toeYMm, rig.soleZ + liftMm];
|
|
let v = [target[0] - hipRoll[0], target[1] - hipRoll[1], target[2] - hipRoll[2]];
|
|
const edgeLift = ((35 * Math.abs(Math.sin(Math.atan2(v[1], -v[2])))) + 1) * edgeScale;
|
|
target = [toeXMm, toeYMm, rig.soleZ + liftMm + edgeLift];
|
|
v = [target[0] - hipRoll[0], target[1] - hipRoll[1], target[2] - hipRoll[2]];
|
|
// Roll that brings the target into the leg's pitch plane.
|
|
const rollRad = Math.atan2(v[1], -v[2]);
|
|
const planeDepth = Math.hypot(v[1], v[2]); // hip-roll -> target, in-plane
|
|
// Toe tip relative to the ankle-pitch joint at the pointed foot pitch.
|
|
const pointRad = (footPointDeg * Math.PI) / 180;
|
|
const toeForward = (ELVIS_TOE_TIP_LOCAL.x * Math.cos(pointRad))
|
|
- (ELVIS_TOE_TIP_LOCAL.drop * Math.sin(pointRad));
|
|
const toeDrop = (ELVIS_TOE_TIP_LOCAL.x * Math.sin(pointRad))
|
|
+ (ELVIS_TOE_TIP_LOCAL.drop * Math.cos(pointRad));
|
|
// Ankle-pitch target measured from the hip-PITCH joint (78 mm further
|
|
// down the plane from the hip-roll joint).
|
|
const ik = legPitchIk(v[0] - toeForward, planeDepth - 78 - toeDrop, footPointDeg);
|
|
const rollDeg = (rollRad * 180) / Math.PI;
|
|
return {
|
|
hip_pitch_left: ik.hipDeg,
|
|
knee_left: ik.kneeDeg,
|
|
ankle_pitch_left: ik.ankleDeg,
|
|
hip_roll_left: rollDeg,
|
|
// Flat (roll-compensated) foot at rest, rolling onto the outer toe
|
|
// edge with the leg as the kick-out engages.
|
|
ankle_roll_left: -rollDeg * (1 - edgeScale)
|
|
};
|
|
}
|
|
|
|
function dancePose(t) {
|
|
const angles = { ...ATHLETIC_ANGLES };
|
|
const env = pulse(t, 0.0, 0.12, 0.88, 1.0);
|
|
const beat = t * ELVIS_BEATS;
|
|
|
|
const dy = ELVIS_WEIGHT_SHIFT_MM * env;
|
|
const bodyZ = (-ELVIS_DIP_MM * env)
|
|
- (ELVIS_BOUNCE_MM * env * Math.abs(Math.sin(Math.PI * beat)));
|
|
// Stance leg: planted under the shifted, bobbing pelvis.
|
|
Object.assign(angles, plantedLegAngles("right", bodyZ, dy));
|
|
|
|
// Kicked-out leg: toe planted out to the left, shaking with the beat.
|
|
// The whole transition is IK-driven — the toe target slides from the
|
|
// athletic toe spot to the kick-out point with a lift bump mid-blend,
|
|
// so the foot never sweeps through the floor (at env=0 the IK lands
|
|
// exactly back on the athletic leg).
|
|
const shake = env * Math.sin(2 * Math.PI * 2 * beat);
|
|
const toeX = ELVIS_ATHLETIC_TOE[0] + ((ELVIS_TOE_X_MM - ELVIS_ATHLETIC_TOE[0]) * env);
|
|
const toeY = ELVIS_ATHLETIC_TOE[1]
|
|
+ (((ELVIS_TOE_Y_MM + (ELVIS_TOE_SHAKE_MM * shake)) - ELVIS_ATHLETIC_TOE[1]) * env);
|
|
const point = (ELVIS_FOOT_POINT_DEG * env) + (ELVIS_FOOT_POINT_SHAKE_DEG * shake);
|
|
const lift = 45 * Math.sin(Math.PI * env);
|
|
Object.assign(angles, elvisKickOutLeg(bodyZ, dy, toeX, toeY, point, lift, env));
|
|
|
|
// The point: right arm up and out to the right, pulsing on the beat.
|
|
const pulseDeg = ELVIS_POINT_PULSE_DEG * Math.sin(2 * Math.PI * beat);
|
|
angles.shoulder_roll_right = (sideSign("right") * ATHLETIC.SHOULDER_ROLL_ABDUCT)
|
|
+ (((ELVIS_POINT_ROLL_DEG + pulseDeg) - (sideSign("right") * ATHLETIC.SHOULDER_ROLL_ABDUCT)) * env);
|
|
angles.shoulder_pitch_right = ATHLETIC.SHOULDER_PITCH
|
|
+ ((ELVIS_POINT_PITCH_DEG - ATHLETIC.SHOULDER_PITCH) * env);
|
|
angles.elbow_right = ATHLETIC.ELBOW + ((ELVIS_POINT_ELBOW_DEG - ATHLETIC.ELBOW) * env);
|
|
// Off arm low and bent across the body.
|
|
angles.shoulder_pitch_left = ATHLETIC.SHOULDER_PITCH
|
|
+ ((ELVIS_OFF_ARM_PITCH_DEG - ATHLETIC.SHOULDER_PITCH) * env);
|
|
angles.elbow_left = ATHLETIC.ELBOW + ((ELVIS_OFF_ARM_ELBOW_DEG - ATHLETIC.ELBOW) * env);
|
|
|
|
// Hip swivel, lean into the point, head to the pointing hand, chin up.
|
|
const twist = ELVIS_TWIST_DEG * env * Math.sin(Math.PI * beat);
|
|
angles.waist_yaw += twist;
|
|
angles.neck_yaw += (ELVIS_HEAD_TURN_DEG * env) - (twist * 0.6);
|
|
angles.neck_pitch += ELVIS_CHIN_UP_DEG * env;
|
|
|
|
return {
|
|
angles,
|
|
extras: { waist_yaw: { rollDeg: ELVIS_LEAN_RIGHT_DEG * env } },
|
|
rootOffset: [0, dy, bodyZ],
|
|
rootPitchDeg: 0
|
|
};
|
|
}
|
|
|
|
const FEATURE_BY_LINK = {
|
|
pelvis: "pelvis",
|
|
torso: "torso",
|
|
neck_collar: "neckCollar",
|
|
head: "head",
|
|
hip_bracket_left: "hipBracketLeft",
|
|
hip_carrier_left: "hipCarrierLeft",
|
|
thigh_left: "thighLeft",
|
|
shin_left: "shinLeft",
|
|
ankle_link_left: "ankleLinkLeft",
|
|
foot_left: "footLeft",
|
|
shoulder_pod_left: "shoulderPodLeft",
|
|
yaw_housing_left: "yawHousingLeft",
|
|
bicep_left: "bicepLeft",
|
|
forearm_left: "forearmLeft",
|
|
wrist_carrier_left: "wristCarrierLeft",
|
|
hand_left: "handLeft",
|
|
hip_bracket_right: "hipBracketRight",
|
|
hip_carrier_right: "hipCarrierRight",
|
|
thigh_right: "thighRight",
|
|
shin_right: "shinRight",
|
|
ankle_link_right: "ankleLinkRight",
|
|
foot_right: "footRight",
|
|
shoulder_pod_right: "shoulderPodRight",
|
|
yaw_housing_right: "yawHousingRight",
|
|
bicep_right: "bicepRight",
|
|
forearm_right: "forearmRight",
|
|
wrist_carrier_right: "wristCarrierRight",
|
|
hand_right: "handRight"
|
|
};
|
|
|
|
export default {
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
step: {
|
|
path: "models/robots/juno/juno.step"
|
|
},
|
|
label: "juno humanoid",
|
|
description: "Walking-in-place march for the juno humanoid: alternating leg lift with planted stance feet, antiphase arm swing, torso counter-sway, and a head that keeps facing forward.",
|
|
units: {
|
|
length: "mm",
|
|
angle: "deg",
|
|
time: "s"
|
|
},
|
|
features: {
|
|
pelvis: { ref: "#o1.1", label: "Pelvis", description: "Root link; static during the in-place march." },
|
|
torso: { ref: "#o1.2", label: "Torso" },
|
|
neckCollar: { ref: "#o1.3", label: "Neck collar" },
|
|
head: { ref: "#o1.4", label: "Head" },
|
|
hipBracketLeft: { ref: "#o1.5", label: "Left hip bracket" },
|
|
hipCarrierLeft: { ref: "#o1.6", label: "Left hip carrier" },
|
|
thighLeft: { ref: "#o1.7", label: "Left thigh" },
|
|
shinLeft: { ref: "#o1.8", label: "Left shin" },
|
|
ankleLinkLeft: { ref: "#o1.9", label: "Left ankle link" },
|
|
footLeft: { ref: "#o1.10", label: "Left foot" },
|
|
shoulderPodLeft: { ref: "#o1.11", label: "Left shoulder pod" },
|
|
yawHousingLeft: { ref: "#o1.12", label: "Left yaw housing" },
|
|
bicepLeft: { ref: "#o1.13", label: "Left bicep" },
|
|
forearmLeft: { ref: "#o1.14", label: "Left forearm" },
|
|
wristCarrierLeft: { ref: "#o1.15", label: "Left wrist carrier" },
|
|
handLeft: { ref: "#o1.16", label: "Left hand" },
|
|
hipBracketRight: { ref: "#o1.17", label: "Right hip bracket" },
|
|
hipCarrierRight: { ref: "#o1.18", label: "Right hip carrier" },
|
|
thighRight: { ref: "#o1.19", label: "Right thigh" },
|
|
shinRight: { ref: "#o1.20", label: "Right shin" },
|
|
ankleLinkRight: { ref: "#o1.21", label: "Right ankle link" },
|
|
footRight: { ref: "#o1.22", label: "Right foot" },
|
|
shoulderPodRight: { ref: "#o1.23", label: "Right shoulder pod" },
|
|
yawHousingRight: { ref: "#o1.24", label: "Right yaw housing" },
|
|
bicepRight: { ref: "#o1.25", label: "Right bicep" },
|
|
forearmRight: { ref: "#o1.26", label: "Right forearm" },
|
|
wristCarrierRight: { ref: "#o1.27", label: "Right wrist carrier" },
|
|
handRight: { ref: "#o1.28", label: "Right hand" }
|
|
},
|
|
parameters: {
|
|
phase: {
|
|
type: "number",
|
|
label: "Gait phase",
|
|
description: "One full gait cycle; the walk animations drive this 0 -> 1.",
|
|
default: 0,
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.001
|
|
},
|
|
gait: {
|
|
type: "select",
|
|
label: "Gait",
|
|
description: "Showpieces (Elvis dance, handstand, karate kick) and in-place gaits (jump, run, stride, march). Each walk animation drives the matching mode.",
|
|
default: "march",
|
|
options: [
|
|
{ value: "dance", label: "Elvis dance" },
|
|
{ value: "handstand", label: "Handstand" },
|
|
{ value: "kick", label: "Karate kick" },
|
|
{ value: "jump", label: "Jump in place" },
|
|
{ value: "run", label: "Run in place" },
|
|
{ value: "stride", label: "Stride in place" },
|
|
{ value: "march", label: "March in place" }
|
|
]
|
|
},
|
|
strideLength: {
|
|
type: "number",
|
|
label: "Stride length",
|
|
description: "Front-to-back foot travel (mm) of the stride gait; capped so the leg IK stays inside reach. Ignored by the march.",
|
|
default: 230,
|
|
min: 0,
|
|
max: 300,
|
|
step: 5
|
|
},
|
|
legLift: {
|
|
type: "number",
|
|
label: "Leg lift",
|
|
description: "Scales hip raise, knee fold, and toe point of the swing leg. Stance feet stay planted at any value.",
|
|
default: 1,
|
|
min: 0,
|
|
max: 1.4,
|
|
step: 0.01
|
|
},
|
|
armSwing: {
|
|
type: "number",
|
|
label: "Arm swing",
|
|
description: "Scales the antiphase shoulder swing and forward-arm elbow flexion.",
|
|
default: 1,
|
|
min: 0,
|
|
max: 1.5,
|
|
step: 0.01
|
|
},
|
|
torsoSway: {
|
|
type: "number",
|
|
label: "Torso sway",
|
|
description: "Scales the waist counter-rotation and weight-shift roll; the head compensates to keep facing forward.",
|
|
default: 1,
|
|
min: 0,
|
|
max: 1.5,
|
|
step: 0.01
|
|
}
|
|
},
|
|
// Ordered most exciting first — this is the order the viewer lists them.
|
|
animations: {
|
|
danceLoop: {
|
|
label: "Elvis dance",
|
|
description: "The King: weight on the right leg, left leg kicked out to the left on a shaking pointed toe, right finger pointing up to the right with a beat pulse, hip swivel and chin up.",
|
|
duration: 3.2,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "dance");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
},
|
|
handstandLoop: {
|
|
label: "Handstand",
|
|
description: "Toe-pivot fold, press to a palms-flat inverted hold with a breathing wobble, and back up to the ready stance.",
|
|
duration: 4.6,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "handstand");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
},
|
|
kickLoop: {
|
|
label: "Karate kick",
|
|
description: "Weight shifts over the left leg, the right leg chambers and snaps a ball-of-foot front kick at hip height behind a fists-up guard.",
|
|
duration: 2.8,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "kick");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
},
|
|
jumpLoop: {
|
|
label: "Jump in place",
|
|
description: "Crouch, leap with hands overhead, and land with a springy knee-absorbed wobble before settling back to the ready stance.",
|
|
duration: 1.8,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "jump");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
},
|
|
runLoop: {
|
|
label: "Run in place",
|
|
description: "Running cadence with flight phases: body bounce, toe-pivot push-off, high heel recovery, forward lean, and pumping bent arms.",
|
|
duration: 0.8,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "run");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
},
|
|
strideLoop: {
|
|
label: "Stride in place",
|
|
description: "Bigger strides: legs sweep back and forward with the stance foot sliding flat on the ground, wider arm swing, stronger torso counter-sway.",
|
|
duration: 1.9,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "stride");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
},
|
|
walkLoop: {
|
|
label: "Walk in place",
|
|
description: "Continuous march loop; scrub the gait phase or scale lift/swing/sway live.",
|
|
duration: 1.5,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("gait", "march");
|
|
set("phase", ((finite(cycle, 0) % 1) + 1) % 1);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
update({ params, effects }) {
|
|
const phase = ((finite(params.phase, 0) % 1) + 1) % 1;
|
|
const scales = {
|
|
legLift: clamp(params.legLift, 0, 1.4),
|
|
armSwing: clamp(params.armSwing, 0, 1.5),
|
|
torsoSway: clamp(params.torsoSway, 0, 1.5),
|
|
strideLength: clamp(params.strideLength, 0, MAX_STRIDE_MM)
|
|
};
|
|
const gait = String(params.gait || "march");
|
|
let targetFrames;
|
|
if (gait === "stride") {
|
|
targetFrames = fkFrames(
|
|
strideAnglesDeg(phase, scales),
|
|
strideTorsoRollExtras(phase, scales.torsoSway)
|
|
);
|
|
} else if (gait === "run") {
|
|
targetFrames = fkFrames(
|
|
runAnglesDeg(phase, scales),
|
|
runTorsoExtras(phase, scales.torsoSway),
|
|
[0, 0, runBounceMm(phase, scales.legLift)]
|
|
);
|
|
} else if (gait === "jump") {
|
|
const jump = jumpAnglesDeg(phase, scales);
|
|
targetFrames = fkFrames(
|
|
jump.angles,
|
|
jumpTorsoExtras(phase, jump.bodyZ, jump.flight, scales.torsoSway),
|
|
[0, 0, jump.bodyZ]
|
|
);
|
|
} else if (gait === "kick" || gait === "handstand" || gait === "dance") {
|
|
const pose = gait === "kick"
|
|
? kickPose(phase)
|
|
: gait === "handstand"
|
|
? handstandPose(phase)
|
|
: dancePose(phase);
|
|
targetFrames = fkFrames(pose.angles, pose.extras, pose.rootOffset, pose.rootPitchDeg);
|
|
} else {
|
|
targetFrames = fkFrames(
|
|
gaitAnglesDeg(phase, scales),
|
|
torsoRollExtras(phase, scales.torsoSway)
|
|
);
|
|
}
|
|
|
|
// The pelvis root is included: it bobs in the run and jump gaits and
|
|
// resolves to an identity delta in the march and stride.
|
|
for (const [link, feature] of Object.entries(FEATURE_BY_LINK)) {
|
|
effects.transform(feature, {
|
|
matrix: deltaMatrixRowMajor(ATHLETIC_FRAMES[link], targetFrames[link])
|
|
});
|
|
}
|
|
}
|
|
};
|