Spine kinematics
The travelling-wave body solver behind the fish and the snake: a serpenoid curve with taper, steady turn, and ground clearance.
view
variant
drive
The head follows your pointer. Click it and it strikes — the wave flattens for the lunge and comes back.
contacts
Lifted sections cast an offset shadow and drop out of the contact marks: that is sidewinding.
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/spine-kinematics.jsonNotes
- Integrating a tangent angle rather than displacing joints is what keeps every link exactly the same length at every phase — the invariant the tests assert.
- A crest travels head to tail as phase rises. Run phase backwards to reverse it. No thrust, drag or friction is modelled.
Usage
import { solveSpine, spineOutline } from "@/lib/robocn/spine"
const pose = solveSpine({ segments: 14, phase: 0.3, amplitude: 0.7, waves: 1.4, taper: 0.8 })
pose.joints // position, angle, s, clearance, contact
spineOutline(pose, (s) => 12 * (1 - s)) // the hull, as one pathAPI
| Prop | Type | Default | Description |
|---|---|---|---|
| solveSpine | (options?: SpineOptions) => SpinePose | — | Walks the joints off a travelling tangent angle from the nose backwards. The nose sits at the origin pointing along +x, with the body running back toward −x. |
| SpineOptions | { segments?, length?, phase?, amplitude?, waves?, taper?, turn?, lift? } | — | Segments 3–24, amplitude 0–1 (1 is 52° of swing), waves 0.25–3, taper −1..1, turn −1..1 (1 is a half circle), lift 0–1 (up to 7 units of clearance). |
| SpineJoint | { position: Vec2, angle, s, clearance, contact } | — | Angle is the body's heading at that joint in degrees; s runs 0 at the nose to 1 at the tail; contact means the joint is on the ground plane. |
| spineOutline | (pose, halfWidth: (s) => number, round?) => string | — | The joints offset either side by a width profile and closed into one SVG path — the hull, built from the solved spine rather than drawn beside it. |
| spineLimits | { swing: 52, turn: 180, clearance: 7 } | — | What amplitude, turn and lift of 1 mean in degrees and world units. |
Source
src/lib/robocn/spine.ts
/** A serpenoid travelling-wave body: the spine a fish swims with and a snake crawls on. */
import { clamp, toRadians, type Vec2 } from "@/lib/robocn/kinematics"
export interface SpineOptions {
/** Links in the body; the pose returns one more joint than this. Clamped 3–24. */
segments?: number
/** Nose-to-tail contour length in world units. */
length?: number
/** Cycle fraction; wraps in either direction. */
phase?: number
/** Peak body swing, 0–1, where 1 is 52° off the axis. */
amplitude?: number
/** Wave crests along the body, clamped 0.25–3. */
waves?: number
/** Amplitude envelope: 1 piles the swing at the tail, −1 at the head, 0 spreads it evenly. */
taper?: number
/** Steady turn, −1..1, as a constant curvature over the body. 1 is a half circle. */
turn?: number
/** Peak ground clearance, 0–1, on the half of the wave that is lifted. */
lift?: number
}
export interface SpineJoint {
position: Vec2
/**
* Heading of the body at this joint, in degrees, 0 pointing along the nose
* axis. Rotate artwork — a head, a fin, a scale plate — by this.
*/
angle: number
/** Distance along the body, 0 at the nose and 1 at the tail. */
s: number
/** Height above the ground plane in world units. */
clearance: number
contact: boolean
}
export interface SpinePose {
joints: SpineJoint[]
/** Length of one link; every link is the same. */
link: number
head: SpineJoint
tail: SpineJoint
}
/** Body swing at `amplitude` 1, and the clearance at `lift` 1, in degrees and world units. */
export const spineLimits = { swing: 52, turn: 180, clearance: 7 } as const
const unit = (value: number, fallback: number) =>
Number.isFinite(value) ? clamp(value, 0, 1) : fallback
const signed = (value: number, fallback: number) =>
Number.isFinite(value) ? clamp(value, -1, 1) : fallback
const wrap = (value: number) => (Number.isFinite(value) ? ((value % 1) + 1) % 1 : 0)
/**
* The body's tangent angle is a travelling sine along its own length —
*
* θ(s) = amplitude · env(s) · cos(2π(waves · s − phase)) + turn · s
*
* — and the joints are walked off that angle from the nose backwards, one fixed
* link at a time. Integrating the angle rather than displacing joints is what
* keeps every link exactly the same length at every phase, and a crest travels
* head to tail as `phase` rises, the way it does on a swimming fish.
*
* The nose sits at the origin pointing along +x, and the body runs back toward
* −x. These are illustrative trajectories: no thrust, no drag, no balance.
*/
export function solveSpine({
segments = 12,
length = 120,
phase = 0,
amplitude = 0.6,
waves = 1,
taper = 0,
turn = 0,
lift = 0,
}: SpineOptions = {}): SpinePose {
const count = Number.isFinite(segments) ? Math.round(clamp(segments, 3, 24)) : 12
const span = Number.isFinite(length) ? clamp(length, 10, 1000) : 120
const swing = spineLimits.swing * unit(amplitude, 0.6)
const crests = Number.isFinite(waves) ? clamp(waves, 0.25, 3) : 1
const bias = signed(taper, 0)
const arc = spineLimits.turn * signed(turn, 0)
const clearance = spineLimits.clearance * unit(lift, 0)
const cycle = wrap(phase)
const link = span / count
const joints: SpineJoint[] = []
let cursor: Vec2 = { x: 0, y: 0 }
for (let index = 0; index <= count; index += 1) {
const s = index / count
const wave = 2 * Math.PI * (crests * s - cycle)
const angle = swing * envelope(s, bias) * Math.cos(wave) + arc * s
// Lifted where the wave is swinging one way, planted where it swings the
// other: sidewinding, and flat on the floor when `lift` is zero.
const height = clearance * Math.max(0, Math.sin(wave))
joints.push({
position: cursor,
angle,
s,
clearance: height,
contact: height < 1e-7,
})
// Tailwards is the nose heading turned around, so the body trails behind.
const heading = toRadians(angle + 180)
cursor = {
x: cursor.x + Math.cos(heading) * link,
y: cursor.y + Math.sin(heading) * link,
}
}
return { joints, link, head: joints[0], tail: joints[joints.length - 1] }
}
/** Where the swing is spent: `bias` 1 all at the tail, −1 all at the head. */
function envelope(s: number, bias: number) {
return bias >= 0 ? 1 - bias + bias * s : 1 + bias - bias * (1 - s)
}
/**
* The outline of a body drawn on a spine: the joints offset either side by
* `halfWidth(s)`, closed into one path. The silhouette is then the solver's
* output rather than artwork that has to be kept in step with it.
*/
export function spineOutline(
pose: SpinePose,
halfWidth: (s: number) => number,
round = 2,
): string {
const left: Vec2[] = []
const right: Vec2[] = []
for (const joint of pose.joints) {
const normal = toRadians(joint.angle + 90)
const width = Math.max(0, halfWidth(joint.s))
const offset = { x: Math.cos(normal) * width, y: Math.sin(normal) * width }
left.push({ x: joint.position.x + offset.x, y: joint.position.y + offset.y })
right.push({ x: joint.position.x - offset.x, y: joint.position.y - offset.y })
}
const fix = (value: number) => Number(value.toFixed(round))
const forward = left.map((p, i) => `${i ? "L" : "M"} ${fix(p.x)} ${fix(p.y)}`)
const back = right.reverse().map((p) => `L ${fix(p.x)} ${fix(p.y)}`)
return [...forward, ...back, "Z"].join(" ")
}