Animatronic kinematics
The whole-humanoid layer over the biped: one routine intent for the entire body, an attention cascade through the eyes, the neck and the waist, and a centre of mass measured against the ground the feet actually hold.
variant
view
Drawn by the machine this file solves for, so you can see the maths move.
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/animatronic-kinematics.jsonNotes
- No React, no three.js, no dependencies, and nothing is mutated. It sits on skeleton, face and hand kinematics rather than duplicating any of them.
- The balance correction is a rigid rotation about a pivot on the floor, so every bone is the same length before and after and the planted foot stays where it was. It is also verified: the rolled pose is measured, and a roll that does not improve the margin is discarded rather than applied on faith.
- There is still no dynamics. Nothing integrates a mass or computes a ground reaction; the margin is a measurement and the roll is a posture, not a fall being averted.
Usage
import { routineIntent, solveAttention, solveAnimatronic, balanceOf } from "@/lib/robocn/animatronic"
const intent = routineIntent("converse", clock) // the whole body at one instant
const look = solveAttention({ x: 0.8, y: 0.1 }) // eyes, then neck, then waist
const body = solveAnimatronic({ ...intent, balance: true })
body.balance.margin // signed distance from the plumb line to the support polygon
body.roll // the rigid roll taken to get there, in degreesAPI
| Prop | Type | Default | Description |
|---|---|---|---|
| routineIntent | (routine: AnimatronicRoutine, clock: number) => AnimatronicIntent | — | The self-control loop: gait, stance, lean, twist, reach, gaze, expression, breath and grip together, as a pure function of the clock. |
| blendIntent | (a, b, t) => AnimatronicIntent | — | Cross-fade two intents. Discrete fields — the gait, the grasp — take whichever side the fade is past halfway to; there is no halfway between a walk and a stand. |
| solveAttention | (target: Vec2 | null, effort?: number) => Attention | — | Split a look across the eyes (±30°), the neck (±34° yaw) and the waist (±22° twist), each taking only what the one before it could not reach. |
| centreOfMass | (pose: SkeletonPose) => Vec3 | — | Winter’s segment fractions, each at its own segment’s centre rather than at a joint. |
| supportPolygon | (pose, halfWidth?) => Vec2[] | — | The convex hull of the footprints of the feet actually loaded, in the ground plane. A foot in swing contributes nothing. |
| balanceOf | (pose, halfWidth?) => Balance | — | The weight, its plumb line, the polygon, and the signed margin between them. Positive is inside. |
| balanceRoll | (pose, effort?, halfWidth?) => number | — | The roll about the support centroid that puts the plumb line back over it, clamped to what an animatronic’s waist has. |
| reachArm | (arm, target, proportions?) => SkeletonArm | — | Re-solve one arm to a target of its own, against the pose the skeleton returned — same shoulder, same bone lengths. The per-side override the shared reach target cannot express. |
| solveAnimatronic | (options?: AnimatronicOptions) => AnimatronicBody | — | The whole machine: the biped, the cage it breathes with, the face rig and neck, both hands, and where its weight is standing. |
| ribCage | (spine: Vec3[], options?) => Rib[] | — | Hoops hung off the thoracic vertebrae, drawn in the transverse plane. Breath opens them in depth about three times as much as in width. |
Source
src/lib/robocn/animatronic.ts
/**
* robocn — the whole animatronic: one intent, one body, one balance.
*
* World axes follow `skeleton.ts`: `x` the machine's right, `y` up, `z` behind
* it. The machine faces `-z` and the floor is `y = 0`.
*
* Three things live here that the part-solvers deliberately do not have.
*
* **An intent.** A humanoid's behaviour is not a scalar. Looking at something
* moves the eyes, the neck and the waist; talking moves the jaw, the head and
* the hands; reaching shifts the weight first. So a routine returns an
* `AnimatronicIntent` — the whole body's demand at an instant — and every field
* of it is also a prop, which is what makes the machine posable.
*
* **An attention cascade.** `solveAttention` splits a demand across the eyes,
* the neck and the waist, each taking only what the one before it could not
* reach. That is what reads as looking at someone rather than as three sliders.
*
* **Balance.** `centreOfMass` sums segment masses at their own centres;
* `supportPolygon` is the hull of the feet actually on the floor;
* `balanceOf` reports the signed margin between the two. `balanceRoll` is the
* roll about the support centroid that puts the plumb line back inside it, and
* `solveAnimatronic` applies it as a **rigid** rotation — every bone the same
* length after as before, the planted foot where it was.
*
* What this is not: dynamics. Nothing here integrates a mass, computes a ground
* reaction or decides whether the machine falls. It measures where the weight
* is and moves the posture toward the support; that is the whole claim.
*/
import {
blendFace,
faceShape,
type FaceChannels,
type FaceExpression,
type FaceSolution,
type HeadGeometry,
type HeadPose,
defaultHeadGeometry,
solveFace,
} from "@/lib/robocn/face"
import { solveHand, type HandGrasp, type HandPose } from "@/lib/robocn/hand"
import {
clamp,
convexHull2,
lerp,
solveChain3,
toDegrees,
toRadians,
type Vec2,
type Vec3,
} from "@/lib/robocn/kinematics"
import {
defaultProportions,
solveSkeleton,
type SkeletonArm,
type SkeletonGait,
type SkeletonLeg,
type SkeletonPose,
type SkeletonProportions,
} from "@/lib/robocn/skeleton"
const finite = (value: number | undefined | null, fallback = 0) =>
typeof value === "number" && Number.isFinite(value) ? value : fallback
const unit = (value: number | undefined, fallback = 0) => clamp(finite(value, fallback), 0, 1)
const signed = (value: number | undefined, fallback = 0) => clamp(finite(value, fallback), -1, 1)
const wrap = (value: number) => {
const t = finite(value)
return ((t % 1) + 1) % 1
}
/* ------------------------------------------------------------------ routines */
/** What the machine does with nobody driving it. */
export type AnimatronicRoutine =
| "idle"
| "greet"
| "present"
| "inspect"
| "converse"
| "walk"
| "static"
export const animatronicRoutines: readonly AnimatronicRoutine[] = [
"idle",
"greet",
"present",
"inspect",
"converse",
"walk",
"static",
]
/**
* The whole body's demand at one instant. Every field is also a prop on the
* component: supply one and it wins over the routine.
*/
export interface AnimatronicIntent {
gait: SkeletonGait
/** Gait cycle fraction, 0..1. */
cycle: number
/** Hip height, 0 crouched to 1 standing tall. */
stance: number
stride: number
lift: number
/** Whole-column pitch in degrees, positive leaning forward. */
lean: number
/** Shoulders against the pelvis in degrees. */
twist: number
/** Neck angles in degrees, on top of whatever the spine is doing. */
neckYaw: number
neckPitch: number
neckRoll: number
/** Pupil aim, −1..1 on both axes. */
look: Vec2
/** A point both hands reach for, in the body frame. Null leaves them swinging. */
reach: Vec3 | null
/**
* A point one hand reaches for, which wins over `reach` for that side. This
* is what a wave is: `solveSkeleton` only takes a target both arms share, so
* without a per-side target every reaching pose comes out with the hands
* clasped in front of the machine.
*/
reachLeft: Vec3 | null
reachRight: Vec3 | null
/** The face rig's channel vector — already blended, never an expression name. */
expression: FaceChannels
intensity: number
blink: number
speech: number
/** Chest expansion, 0 emptied to 1 filled. */
breath: number
grasp: HandGrasp
grip: number
/** How hard the machine works to keep its weight over its feet, 0..1. */
effort: number
}
/** A machine that is switched on but doing nothing. */
export const restIntent: AnimatronicIntent = {
gait: "stand",
cycle: 0,
stance: 1,
stride: 0.7,
lift: 0.6,
lean: 0,
twist: 0,
neckYaw: 0,
neckPitch: 0,
neckRoll: 0,
look: { x: 0, y: 0 },
reach: null,
reachLeft: null,
reachRight: null,
expression: faceShape("neutral"),
intensity: 1,
blink: 0,
speech: 0,
breath: 0.35,
grasp: "open",
grip: 0.22,
effort: 1,
}
/**
* A blink is a spike, not a wave: long stretches open, then a fast close and a
* slower open. Two coprime rates so a row of machines never blinks in unison.
*/
export function animatronicBlink(clock: number): number {
const t = finite(clock)
const beat = ((t * 0.43) % 1 + 1) % 1
const second = ((t * 0.31 + 0.37) % 1 + 1) % 1
const spike = (u: number) => {
if (u > 0.12) return 0
const k = u / 0.12
return k < 0.4 ? k / 0.4 : 1 - (k - 0.4) / 0.6
}
return clamp(Math.max(spike(beat), spike(second)), 0, 1)
}
/** Breathing: a quick fill and a longer empty, which is the shape of a breath. */
export function animatronicBreath(clock: number, rate = 0.23): number {
const u = ((finite(clock) * rate) % 1 + 1) % 1
return u < 0.38
? (1 - Math.cos((u / 0.38) * Math.PI)) / 2
: (1 + Math.cos(((u - 0.38) / 0.62) * Math.PI)) / 2
}
const parade: FaceExpression[] = ["joy", "surprise", "doubt", "neutral", "sorrow", "anger"]
/**
* The self-control loop: a routine, a clock, and the whole body's demand out
* the other side. Pure, so it is tested by sampling rather than by faking
* animation frames.
*/
export function routineIntent(
routine: AnimatronicRoutine,
clock: number,
): AnimatronicIntent {
const t = finite(clock)
if (routine === "static") return { ...restIntent, expression: faceShape("neutral") }
const turn = t * Math.PI * 2
const breath = animatronicBreath(t)
const blink = animatronicBlink(t)
// Breathing is a posture as well as a chest: the column rises a little on the
// fill and the shoulders come back.
const carried = (breath - 0.5) * 2
switch (routine) {
case "greet": {
// One arm up and across, held at the top, and the whole body turned a
// little toward whoever it is greeting.
const beat = wrap(t * 0.5)
const raise = beat < 0.25 ? beat / 0.25 : beat < 0.75 ? 1 : 1 - (beat - 0.75) / 0.25
const eased = (1 - Math.cos(clamp(raise, 0, 1) * Math.PI)) / 2
const wave = Math.sin(turn * 2.4) * eased
return {
...restIntent,
gait: "stand",
cycle: wrap(t * 0.5),
stance: 1,
lean: 2 + carried * 0.8,
twist: 8 * eased + wave * 4,
neckYaw: -6 * eased + wave * 2,
neckPitch: 3 * eased,
neckRoll: wave * 4,
look: { x: -0.25 * eased + wave * 0.12, y: 0.1 },
// One arm, not two: a wave is the left hand up beside the head while
// the right stays where it hangs.
reachLeft: { x: -30 - wave * 10, y: 150 + eased * 18, z: -18 - eased * 10 },
expression: blendFace(faceShape("neutral"), faceShape("joy"), 0.35 + eased * 0.45),
blink,
breath,
grasp: "open",
grip: 0.08,
effort: 1,
}
}
case "present": {
// Both hands out, palms up, offering the thing in front of it.
const swing = Math.sin(turn * 0.31)
const open = 0.55 + 0.45 * Math.sin(turn * 0.23)
return {
...restIntent,
gait: "stand",
stance: 0.96,
lean: 4 + carried,
twist: swing * 5,
neckPitch: -7 + carried * 2,
neckYaw: swing * 4,
look: { x: swing * 0.18, y: -0.35 },
// Apart, palms up: two hands offering something, not two hands clasped.
reachLeft: { x: -20 + swing * 5, y: 116 + open * 10, z: -40 - open * 14 },
reachRight: { x: 20 + swing * 5, y: 116 + open * 10, z: -40 - open * 14 },
expression: blendFace(faceShape("neutral"), faceShape("joy"), 0.22),
blink,
breath,
grasp: "open",
grip: 0.12,
effort: 1,
}
}
case "inspect": {
// Weight onto one foot, turned down and in toward whatever it is holding.
const scan = Math.sin(turn * 0.37)
const close = (1 + Math.sin(turn * 0.53)) / 2
return {
...restIntent,
gait: "stand",
stance: 0.82,
lean: 11 + carried,
twist: 12 + scan * 9,
neckPitch: -16 + scan * 4,
neckYaw: 8 + scan * 6,
neckRoll: scan * 5,
look: { x: 0.2 + scan * 0.2, y: -0.55 },
// Held in front of it and turned over, but still two hands: a shared
// target would put both arms on one point and through the chest.
reachLeft: { x: -9 + scan * 4, y: 110, z: -46 },
reachRight: { x: 11 + scan * 4, y: 108, z: -44 },
expression: blendFace(faceShape("neutral"), faceShape("doubt"), 0.45),
blink: blink * 0.7,
breath,
grasp: "tripod",
grip: 0.35 + close * 0.45,
effort: 1,
}
}
case "converse": {
// Syllables riding a phrase envelope, and the hands beating with them —
// a talking machine that keeps its arms still reads as dubbed.
const phrase = Math.max(0, Math.sin(turn * 0.37))
const syllable = Math.abs(Math.sin(turn * 3.1)) * 0.7 + Math.abs(Math.sin(turn * 4.7)) * 0.3
const speech = phrase * syllable
const beat = Math.sin(turn * 1.6) * phrase
return {
...restIntent,
gait: "stand",
stance: 0.99,
lean: 3 + carried * 0.6 + phrase * 1.5,
twist: Math.sin(turn * 0.29) * 7,
neckYaw: Math.sin(turn * 0.33) * 10,
neckPitch: Math.sin(turn * 0.74) * 5 - phrase * 3,
neckRoll: Math.sin(turn * 0.21) * 4,
look: { x: Math.sin(turn * 0.61) * 0.35, y: Math.sin(turn * 0.29) * 0.2 },
// The hands beat out of phase with each other, which is what a talking
// pair of hands does; together they would read as a conductor.
reachLeft: { x: -22 + beat * 10, y: 122 + beat * 12, z: -30 - phrase * 10 },
reachRight: { x: 22 + beat * 10, y: 122 - beat * 12, z: -30 - phrase * 8 },
expression: blendFace(
faceShape("neutral"),
faceShape("joy"),
0.25 + Math.sin(turn * 0.5) * 0.15,
),
blink,
speech,
breath: Math.max(breath, phrase * 0.7),
grasp: "open",
grip: 0.18,
effort: 1,
}
}
case "walk":
return {
...restIntent,
gait: "walk",
cycle: wrap(t),
stance: 1,
stride: 0.78,
lift: 0.62,
lean: 5,
twist: 0,
neckPitch: -2,
look: { x: 0, y: -0.12 },
reach: null,
expression: blendFace(faceShape("neutral"), faceShape("joy"), 0.12),
blink,
breath: animatronicBreath(t, 0.5),
grasp: "open",
grip: 0.28,
effort: 1,
}
default: {
// Idle is never quite still: a dead-still machine reads as switched off.
// Weight drifts from foot to foot, the eyes flick and hold.
const drift = Math.sin(turn * 0.19)
return {
...restIntent,
gait: "stand",
stance: 0.99 - Math.abs(drift) * 0.03,
lean: 2.5 + carried * 1.2,
twist: drift * 6,
neckYaw: Math.sin(turn * 0.23) * 7 + drift * 3,
neckPitch: Math.sin(turn * 0.41) * 3 + carried * 1.5,
neckRoll: Math.sin(turn * 0.17) * 3,
look: {
x: Math.sin(turn * 0.31) * 0.35 + Math.sin(turn * 1.7) * 0.08,
y: Math.sin(turn * 0.47) * 0.22,
},
reach: null,
expression: blendFace(
faceShape("neutral"),
faceShape("joy"),
0.16 + Math.sin(turn * 0.27) * 0.1,
),
blink,
breath,
grasp: "open",
grip: 0.22,
effort: 1,
}
}
}
}
const mixVec2 = (a: Vec2, b: Vec2, t: number): Vec2 => ({
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
})
const mixVec3 = (a: Vec3, b: Vec3, t: number): Vec3 => ({
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
})
/**
* Cross-fade two intents. A routine change eases through this rather than
* snapping, and so does a released drag returning to what the routine has
* moved on to. The discrete fields — the gait, the grasp — take whichever side
* the fade is past halfway to; there is no halfway between a walk and a stand.
*/
export function blendIntent(a: AnimatronicIntent, b: AnimatronicIntent, t: number): AnimatronicIntent {
const k = unit(t)
const past = k >= 0.5
const fadeReach = (from: Vec3 | null, to: Vec3 | null) =>
from && to ? mixVec3(from, to, k) : from ? (k < 1 ? from : null) : to ? (k > 0 ? to : null) : null
const reach = fadeReach(a.reach, b.reach)
return {
gait: past ? b.gait : a.gait,
// The cycle is a phase, so it crossfades only within one gait; across a
// change it belongs to whichever gait won.
cycle: a.gait === b.gait ? wrap(lerp(a.cycle, b.cycle, k)) : past ? b.cycle : a.cycle,
stance: lerp(a.stance, b.stance, k),
stride: lerp(a.stride, b.stride, k),
lift: lerp(a.lift, b.lift, k),
lean: lerp(a.lean, b.lean, k),
twist: lerp(a.twist, b.twist, k),
neckYaw: lerp(a.neckYaw, b.neckYaw, k),
neckPitch: lerp(a.neckPitch, b.neckPitch, k),
neckRoll: lerp(a.neckRoll, b.neckRoll, k),
look: mixVec2(a.look, b.look, k),
reach,
reachLeft: fadeReach(a.reachLeft, b.reachLeft),
reachRight: fadeReach(a.reachRight, b.reachRight),
expression: blendFace(a.expression, b.expression, k),
intensity: lerp(a.intensity, b.intensity, k),
blink: lerp(a.blink, b.blink, k),
speech: lerp(a.speech, b.speech, k),
breath: lerp(a.breath, b.breath, k),
grasp: past ? b.grasp : a.grasp,
grip: lerp(a.grip, b.grip, k),
effort: lerp(a.effort, b.effort, k),
}
}
/* ----------------------------------------------------------------- attention */
/**
* How much of a look each stage of the chain can take, before the next one has
* to help. Eyes are fastest and cheapest, the waist is slowest and dearest, so
* they are spent in that order.
*/
export const attentionShare = {
eyeYaw: 30,
eyePitch: 22,
neckYaw: 34,
neckPitch: 22,
neckRoll: 10,
waistTwist: 22,
waistLean: 10,
} as const
export interface Attention {
/** Pupil aim, −1..1, which is the eyes' own share of the demand. */
look: Vec2
neckYaw: number
neckPitch: number
neckRoll: number
twist: number
lean: number
/** Total demand actually met, in degrees, against what was asked. */
demand: Vec2
/** False when even the waist ran out and the machine cannot see the target. */
reached: boolean
}
/** Nothing to look at. */
export const restAttention: Attention = {
look: { x: 0, y: 0 },
neckYaw: 0,
neckPitch: 0,
neckRoll: 0,
twist: 0,
lean: 0,
demand: { x: 0, y: 0 },
reached: true,
}
/**
* Split a look across the eyes, the neck and the waist.
*
* `target` is −1..1 on both axes, where 1 is as far as the machine is ever
* asked to look: `x` to its left on screen, `y` up. Each stage takes what it
* can of the remaining angle and passes the rest on, so a small look is pure
* eyes, a larger one turns the head, and only a look over the shoulder costs a
* twist. `effort` scales how willingly the later stages join in — a lazy
* machine leaves it to the eyes.
*/
export function solveAttention(target: Vec2 | null, effort = 1): Attention {
if (!target) return restAttention
const gain = unit(effort, 1)
const ax = signed(target.x)
const ay = signed(target.y)
// The full-scale demand in degrees: everything the chain could give.
const spanYaw = attentionShare.eyeYaw + attentionShare.neckYaw + attentionShare.waistTwist
const spanPitch = attentionShare.eyePitch + attentionShare.neckPitch + attentionShare.waistLean
const demandYaw = ax * spanYaw
const demandPitch = ay * spanPitch
const take = (remaining: number, limit: number) => {
const taken = clamp(remaining, -limit, limit)
return [taken, remaining - taken] as const
}
// Eyes first, at full authority; the later stages are scaled by effort, so a
// half-hearted look stays in the eyes.
const [eyeYaw, afterEyeYaw] = take(demandYaw, attentionShare.eyeYaw)
const [eyePitch, afterEyePitch] = take(demandPitch, attentionShare.eyePitch)
const [neckYaw, afterNeckYaw] = take(afterEyeYaw * gain, attentionShare.neckYaw)
const [neckPitch, afterNeckPitch] = take(afterEyePitch * gain, attentionShare.neckPitch)
const [twist, leftYaw] = take(afterNeckYaw, attentionShare.waistTwist)
const [lean, leftPitch] = take(afterNeckPitch, attentionShare.waistLean)
return {
look: {
x: clamp(eyeYaw / attentionShare.eyeYaw, -1, 1),
y: clamp(eyePitch / attentionShare.eyePitch, -1, 1),
},
neckYaw,
neckPitch,
// A head turning far tips a little with the turn, the way a real neck does.
neckRoll: clamp((neckYaw / attentionShare.neckYaw) * attentionShare.neckRoll * 0.5, -attentionShare.neckRoll, attentionShare.neckRoll),
twist,
// Leaning forward to see something low is positive lean on the spine.
lean: -lean,
demand: { x: demandYaw, y: demandPitch },
reached: Math.abs(leftYaw) < 1e-6 && Math.abs(leftPitch) < 1e-6,
}
}
/* ------------------------------------------------------------------- balance */
/**
* Segment masses as fractions of body mass (Winter), and where along each
* segment its own centre of mass sits, measured from the proximal joint. These
* sum to 1, which the test holds them to.
*/
export const segmentMass = {
head: 0.081,
trunk: 0.497,
upperArm: 0.028,
forearm: 0.022,
thigh: 0.1,
shank: 0.0465,
foot: 0.0145,
} as const
export const segmentCentre = {
trunk: 0.5,
upperArm: 0.436,
forearm: 0.682,
thigh: 0.433,
shank: 0.433,
} as const
const along = (a: Vec3, b: Vec3, t: number): Vec3 => ({
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
z: a.z + (b.z - a.z) * t,
})
/**
* Where the machine's weight is. Each segment contributes its own mass at its
* own centre — not at a joint, which is the mistake that puts the COM of a
* raised arm in the wrong place entirely.
*/
export function centreOfMass(pose: SkeletonPose): Vec3 {
let mass = 0
let x = 0
let y = 0
let z = 0
const add = (point: Vec3, m: number) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || !Number.isFinite(point.z)) return
mass += m
x += point.x * m
y += point.y * m
z += point.z * m
}
add(pose.head, segmentMass.head)
add(along(pose.pelvis, pose.shoulders, segmentCentre.trunk), segmentMass.trunk)
for (const arm of pose.arms) {
add(along(arm.shoulder, arm.elbow, segmentCentre.upperArm), segmentMass.upperArm)
add(along(arm.elbow, arm.wrist, segmentCentre.forearm), segmentMass.forearm)
}
for (const leg of pose.legs) {
add(along(leg.hip, leg.knee, segmentCentre.thigh), segmentMass.thigh)
add(along(leg.knee, leg.ankle, segmentCentre.shank), segmentMass.shank)
add(leg.ball, segmentMass.foot)
}
if (mass <= 0) return { x: 0, y: 0, z: 0 }
return { x: x / mass, y: y / mass, z: z / mass }
}
/** Half the width of a foot, in world units, for the footprint it leaves. */
export const footHalfWidth = 5.5
/**
* The footprint one foot puts on the floor, in ground-plane coordinates
* (`{x, y}` is world `{x, z}`). Only the part of the sole that is down counts:
* at toe-off that is the toe alone, which is what narrows the polygon exactly
* when a walking machine is least stable.
*/
export function footPolygon(leg: SkeletonLeg, halfWidth = footHalfWidth): Vec2[] {
if (!(leg.contact > 0)) return []
const w = Math.abs(finite(halfWidth, footHalfWidth))
// The roll tells which of heel, ball and toe are actually loaded. A foot at
// toe-off supports on the toe alone, which is what narrows the polygon
// exactly when a walking machine is least stable.
const loaded: Vec3[] = []
if (leg.roll.heelLoad > 0.01) loaded.push(leg.heel)
if (leg.roll.ballLoad > 0.01) loaded.push(leg.ball)
if (leg.roll.toeLoad > 0.01) loaded.push(leg.toe)
if (loaded.length === 0) loaded.push(leg.ball)
return loaded.flatMap((point) => [
{ x: point.x - w, y: point.z },
{ x: point.x + w, y: point.z },
])
}
/**
* The polygon the machine is allowed to keep its weight inside: the convex hull
* of every loaded footprint. A foot in swing contributes nothing, which is why
* this shrinks to one foot in mid-stride and vanishes in the flight phase of a
* run.
*/
export function supportPolygon(pose: SkeletonPose, halfWidth = footHalfWidth): Vec2[] {
const points = pose.legs.flatMap((leg) => footPolygon(leg, halfWidth))
if (points.length < 3) return points
return convexHull2(points)
}
const centroid = (polygon: readonly Vec2[]): Vec2 => {
if (polygon.length === 0) return { x: 0, y: 0 }
let x = 0
let y = 0
for (const point of polygon) {
x += point.x
y += point.y
}
return { x: x / polygon.length, y: y / polygon.length }
}
const distanceToSegment = (p: Vec2, a: Vec2, b: Vec2) => {
const dx = b.x - a.x
const dy = b.y - a.y
const lengthSquared = dx * dx + dy * dy
const t = lengthSquared > 0 ? clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared, 0, 1) : 0
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t))
}
const insidePolygon = (p: Vec2, polygon: readonly Vec2[]) => {
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const a = polygon[i]
const b = polygon[j]
if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x) {
inside = !inside
}
}
return inside
}
/**
* Signed distance from a point to a polygon: positive inside, negative outside,
* in the polygon's own units. This is the margin the machine has left.
*/
export function polygonMargin(point: Vec2, polygon: readonly Vec2[]): number {
if (polygon.length === 0) return Number.NEGATIVE_INFINITY
if (polygon.length === 1) return -Math.hypot(point.x - polygon[0].x, point.y - polygon[0].y)
let nearest = Infinity
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
nearest = Math.min(nearest, distanceToSegment(point, polygon[j], polygon[i]))
}
return insidePolygon(point, polygon) ? nearest : -nearest
}
export interface Balance {
/** The whole machine's centre of mass, in world units. */
com: Vec3
/** Its plumb line's footprint on the floor, in ground coordinates. */
ground: Vec2
support: Vec2[]
/** Centre of the support polygon — where the weight would ideally sit. */
centre: Vec2
/** Signed distance from the plumb line to the polygon. Positive is inside. */
margin: number
stable: boolean
}
export function balanceOf(pose: SkeletonPose, halfWidth = footHalfWidth): Balance {
const com = centreOfMass(pose)
const support = supportPolygon(pose, halfWidth)
const ground: Vec2 = { x: com.x, y: com.z }
const margin = polygonMargin(ground, support)
return {
com,
ground,
support,
centre: centroid(support),
margin,
stable: margin > 0,
}
}
/** The most an animatronic waist will lean sideways to save itself. */
export const balanceRollLimit = 13
/**
* The roll about the support centroid that puts the plumb line over it.
*
* The pivot is the centroid on the floor; the machine is a rigid body above it,
* so the angle to swing through is the one the COM's plumb line already makes
* with the vertical over that pivot. Standing on two feet the COM is already
* between them and this is near zero, which is the property that makes it safe
* to apply unconditionally.
*/
export function balanceRoll(
pose: SkeletonPose,
effort = 1,
halfWidth = footHalfWidth,
): number {
const balance = balanceOf(pose, halfWidth)
if (balance.support.length === 0) return 0
const dx = balance.com.x - balance.centre.x
const dy = balance.com.y
if (!(dy > 1e-6)) return 0
const lean = toDegrees(Math.atan2(dx, dy))
return clamp(-lean * unit(effort, 1), -balanceRollLimit, balanceRollLimit)
}
/**
* Roll a point about the `z` axis through a pivot on the floor. Rigid: every
* distance between rolled points is unchanged, which is what lets the whole
* machine go through it without a bone stretching.
*/
export function rollAbout(point: Vec3, pivot: Vec2, degrees: number): Vec3 {
const angle = toRadians(finite(degrees))
if (angle === 0) return point
const c = Math.cos(angle)
const s = Math.sin(angle)
const x = finite(point.x) - pivot.x
const y = finite(point.y) - pivot.y
return {
x: pivot.x + x * c - y * s,
y: pivot.y + x * s + y * c,
z: finite(point.z),
}
}
/* ------------------------------------------------------------------- ribcage */
export interface RibOptions {
count?: number
/** Where the cage sits on the column, as spine fractions. */
from?: number
to?: number
/** Half-width and half-depth of the widest hoop, unbreathed. */
width?: number
depth?: number
/**
* How far forward of the vertebra the hoop's centre sits. A cage centred on
* the spine puts as much of itself behind the machine's back as in front of
* its chest; a real one hangs forward, with the column at its back.
*/
front?: number
/** Chest expansion, 0 emptied to 1 filled. */
breath?: number
/** Shoulder rotation against the pelvis, in degrees. */
twist?: number
}
export interface Rib {
index: number
left: Vec3[]
right: Vec3[]
/** The point on the sternum this pair meets at. */
front: Vec3
/** 1 for a floating rib at the bottom of the cage, 0 for an attached one. */
floating: number
}
/** A point along a spine given as a fraction of its length. */
export function spineAt(spine: readonly Vec3[], fraction: number): Vec3 {
if (spine.length === 0) return { x: 0, y: 0, z: 0 }
if (spine.length === 1) return spine[0]
const t = clamp(finite(fraction), 0, 1) * (spine.length - 1)
const i = Math.min(spine.length - 2, Math.floor(t))
const f = t - i
return along(spine[i], spine[i + 1], f)
}
/**
* Hoops hung off the thoracic vertebrae, drawn in the transverse plane so they
* foreshorten into the ellipses a cage actually makes from a raised camera.
* `breath` opens them along the machine's depth more than across its width,
* which is the direction a pump moves a cage.
*/
export function ribCage(spine: readonly Vec3[], options: RibOptions = {}): Rib[] {
const count = Math.max(3, Math.min(12, Math.round(finite(options.count, 7))))
const from = clamp(finite(options.from, 0.4), 0, 1)
const to = clamp(finite(options.to, 0.94), 0, 1)
const width = Math.abs(finite(options.width, 22))
const depth = Math.abs(finite(options.depth, 15))
const front = finite(options.front, 0)
const fill = unit(options.breath, 0)
const twistDegrees = finite(options.twist, 0)
return Array.from({ length: count }, (_, index) => {
const k = index / (count - 1 || 1)
const at = lerp(from, to, k)
const centre = spineAt(spine, at)
const profile = 0.42 + 0.58 * Math.sin(Math.PI * (0.16 + 0.78 * (1 - k)))
// A breath opens the cage front-to-back about three times as much as it
// opens it side to side.
const half = width * profile * (1 + fill * 0.05)
const deep = depth * profile * (1 + fill * 0.16)
const floating = clamp((0.24 - k) / 0.24, 0, 1)
const gap = lerp(0.2, 0.85, floating)
// The fill also lifts the front of each hoop: the cage rotates up as well
// as out, which is what makes breathing read from the side.
const drop = lerp(4.5, 8, floating) * (1 - fill * 0.4)
const yaw = toRadians(twistDegrees * at)
const place = (a: number): Vec3 => {
const x = Math.sin(a) * half
const z = -Math.cos(a) * deep - front * profile
return {
x: centre.x + x * Math.cos(yaw) - z * Math.sin(yaw),
y: centre.y - drop * (1 - Math.abs(a) / Math.PI),
z: centre.z + x * Math.sin(yaw) + z * Math.cos(yaw),
}
}
const arc = (sign: number) =>
Array.from({ length: 11 }, (_, step) => place(lerp(Math.PI * 0.93, gap, step / 10) * sign))
// The sternum point is the midline the two arcs stop short of — the gap
// they leave is the thing it bridges, so it is `place(0)` and not the end
// of either arc, which would sit a rib's width off centre.
return { index, left: arc(-1), right: arc(1), front: place(0), floating }
})
}
/* ------------------------------------------------------------------- framing */
/**
* An orthonormal frame on the end of a limb, plus the scale the part drawn in
* it is modelled at. `placeIn` takes a point in the part's own coordinates out
* into the world.
*/
export interface LimbFrame {
origin: Vec3
right: Vec3
up: Vec3
out: Vec3
scale: number
}
const norm3 = (v: Vec3, fallback: Vec3): Vec3 => {
const length = Math.hypot(v.x, v.y, v.z)
return length > 1e-6 ? { x: v.x / length, y: v.y / length, z: v.z / length } : fallback
}
const cross3 = (a: Vec3, b: Vec3): Vec3 => ({
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
})
/**
* Re-solve one arm to a target of its own. `solveSkeleton` takes a single
* target both arms share, which is right for carrying something and wrong for
* everything else; this is the per-side override, applied to the pose it
* returned so the lengths and the shoulder it starts from are unchanged.
*/
export function reachArm(
arm: SkeletonArm,
target: Vec3 | null | undefined,
proportions: SkeletonProportions = defaultProportions,
): SkeletonArm {
if (!target) return arm
if (![target.x, target.y, target.z].every(Number.isFinite)) return arm
const [, elbow, wrist] = solveChain3(
arm.shoulder,
target,
[proportions.humerus, proportions.forearm],
{ up: { x: 0, y: 0, z: 1 } },
)
// Forearm heading in the sagittal plane, measured the same way the skeleton
// solver measures it: degrees from hanging straight down.
const heading = toDegrees(Math.atan2(wrist.y - elbow.y, -(wrist.z - elbow.z))) + 90
return { ...arm, elbow, wrist, heading }
}
/**
* The frame a hand sits in on the end of a forearm: `up` runs out along the
* forearm, the palm faces the body, and the thumb falls where a hanging hand's
* does.
*/
export function handFrame(arm: SkeletonArm, scale = 0.26): LimbFrame {
const sign = arm.side === "right" ? 1 : -1
const up = norm3(
{ x: arm.wrist.x - arm.elbow.x, y: arm.wrist.y - arm.elbow.y, z: arm.wrist.z - arm.elbow.z },
{ x: 0, y: -1, z: 0 },
)
const out: Vec3 = { x: -sign, y: 0, z: 0 }
const right = norm3(cross3(up, out), { x: 0, y: 0, z: -1 })
return { origin: arm.wrist, right, up, out, scale: Math.abs(finite(scale, 0.26)) }
}
export function placeIn(frame: LimbFrame, point: Vec3): Vec3 {
const s = frame.scale
return {
x: frame.origin.x + (frame.right.x * point.x + frame.up.x * point.y + frame.out.x * point.z) * s,
y: frame.origin.y + (frame.right.y * point.x + frame.up.y * point.y + frame.out.y * point.z) * s,
z: frame.origin.z + (frame.right.z * point.x + frame.up.z * point.y + frame.out.z * point.z) * s,
}
}
/* --------------------------------------------------------------- the machine */
export interface AnimatronicOptions extends Partial<AnimatronicIntent> {
/** Correct the posture toward the support polygon. */
balance?: boolean
proportions?: SkeletonProportions
head?: HeadGeometry
/** Rib hoop count. */
ribs?: number
/** The cage's own proportions, if the default does not suit the machine. */
cage?: Omit<RibOptions, "count" | "breath" | "twist">
/** How large a hand is against the solver's own units. */
handScale?: number
}
export interface AnimatronicHand {
side: "left" | "right"
pose: HandPose
frame: LimbFrame
}
export interface AnimatronicBody {
intent: AnimatronicIntent
skeleton: SkeletonPose
ribs: Rib[]
/** Where the sternum runs, bottom to top. */
sternum: [Vec3, Vec3]
face: FaceSolution
/** Neck rotation for the head, in degrees. */
headPose: HeadPose
/** The centre of the skull, in world units. */
headCentre: Vec3
hands: AnimatronicHand[]
balance: Balance
/** The roll that was applied to save it, in degrees. */
roll: number
/**
* The point on the floor that roll turned about. Anything that needs the
* machine's own upright frame — a limb's sagittal plane, a foot's sole line —
* undoes the roll about this and puts it back afterwards.
*/
pivot: Vec2
}
const rollPose = (pose: SkeletonPose, pivot: Vec2, degrees: number): SkeletonPose => {
if (degrees === 0) return pose
const at = (point: Vec3) => rollAbout(point, pivot, degrees)
return {
...pose,
pelvis: at(pose.pelvis),
spine: pose.spine.map(at),
shoulders: at(pose.shoulders),
neck: at(pose.neck),
head: at(pose.head),
legs: pose.legs.map((leg) => ({
...leg,
hip: at(leg.hip),
knee: at(leg.knee),
ankle: at(leg.ankle),
heel: at(leg.heel),
ball: at(leg.ball),
toe: at(leg.toe),
})),
arms: pose.arms.map((arm) => ({
...arm,
shoulder: at(arm.shoulder),
elbow: at(arm.elbow),
wrist: at(arm.wrist),
})),
}
}
/**
* The whole animatronic from one intent: the biped, the cage it breathes with,
* the head's rig and neck, both hands, and where its weight is standing.
*
* With `balance`, the pose is solved, measured, and then rolled rigidly about
* its own support centroid so the plumb line comes back inside the polygon. The
* roll is reported, and the balance returned is the one after it.
*/
export function solveAnimatronic(options: AnimatronicOptions = {}): AnimatronicBody {
const intent: AnimatronicIntent = { ...restIntent, ...options }
const proportions = options.proportions ?? defaultProportions
const geometry = options.head ?? defaultHeadGeometry
const raw = solveSkeleton({
gait: intent.gait,
phase: intent.cycle,
stance: intent.stance,
stride: intent.stride,
lift: intent.lift,
lean: intent.lean,
twist: intent.twist,
reach: intent.reach,
proportions,
})
// Per-side reach lands before the balance, not after: a raised arm moves the
// centre of mass, and a machine that balanced without it would be balancing
// the wrong body.
const armed: SkeletonPose =
intent.reachLeft || intent.reachRight
? {
...raw,
arms: raw.arms.map((arm) =>
reachArm(arm, arm.side === "left" ? intent.reachLeft : intent.reachRight, proportions),
),
}
: raw
const before = balanceOf(armed)
// The pivot is the support centroid **on the floor**, not at the centroid's
// own depth: rolling about anything above the floor swings the feet as well
// as the body and undoes the correction.
const pivot: Vec2 = { x: before.centre.x, y: 0 }
const wanted = options.balance ? balanceRoll(armed, intent.effort) : 0
// The correction moves the polygon as well as the weight, so it is measured
// rather than assumed: a roll that does not help is not taken.
const corrected = wanted === 0 ? null : rollPose(armed, pivot, wanted)
const after = corrected ? balanceOf(corrected) : before
const helps = corrected !== null && after.margin >= before.margin - 1e-9
const roll = helps ? wanted : 0
const skeleton = helps && corrected ? corrected : armed
const balance = helps ? after : before
const ribs = ribCage(skeleton.spine, {
...options.cage,
count: options.ribs,
breath: intent.breath,
twist: skeleton.shoulderYaw,
})
const ribFrom = clamp(finite(options.cage?.from, 0.4), 0, 1)
const attached = ribs.filter((rib) => rib.floating < 0.5)
const sternum: [Vec3, Vec3] = [
attached.length ? attached[0].front : spineAt(skeleton.spine, ribFrom),
attached.length ? attached[attached.length - 1].front : skeleton.shoulders,
]
const face = solveFace(
{
expression: intent.expression,
intensity: intent.intensity,
gaze: intent.look,
blink: intent.blink,
speech: intent.speech,
},
geometry,
)
// The head's own axes, on top of what the body already carries it through:
// the column's lean at the top of the spine, the shoulders' twist, and the
// roll the balance correction put through the whole machine. Folding them in
// here is what keeps a leaning machine from wearing its head bolted on level.
const carriedPitch = toDegrees(
Math.atan2(-(skeleton.neck.z - skeleton.shoulders.z), skeleton.neck.y - skeleton.shoulders.y),
)
const headPose: HeadPose = {
yaw:
clamp(finite(intent.neckYaw), -attentionShare.neckYaw, attentionShare.neckYaw) +
finite(skeleton.shoulderYaw),
pitch:
clamp(finite(intent.neckPitch), -attentionShare.neckPitch, attentionShare.neckPitch) -
(Number.isFinite(carriedPitch) ? carriedPitch : 0),
roll: clamp(finite(intent.neckRoll), -attentionShare.neckRoll * 2, attentionShare.neckRoll * 2) + roll,
}
const hands = skeleton.arms.map((arm): AnimatronicHand => ({
side: arm.side,
pose: solveHand({ grasp: intent.grasp, curl: intent.grip, side: arm.side }),
frame: handFrame(arm, options.handScale ?? 0.26),
}))
return {
intent,
skeleton,
ribs,
sternum,
face,
headPose,
headCentre: skeleton.head,
hands,
balance,
roll,
pivot,
}
}