For the complete index, see /llms.txt. A Markdown version of any documentation page is available by appending .md to its URL or by sending an Accept: text/markdown header.

Browse documentation

Vehicle geometry

The constraints a vehicle works against: Ackermann steering, steady-state articulation, the coordinated bank, a rigid body on N axles, the rocket equation, and surface-piercing foil lift.

CAR / 01
view
variant
motion
steer
angle
26°
road
0.35

One steering number, two wheel angles. Switch to plan and watch the inner wheel crank harder than the outer one — or drag across the car to steer it yourself.

inner
29.8°
outer
23.0°
radius
258 u
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/vehicle-geometry.json

Notes

  • Everything here is exact geometry except `pitchProgram` and `roadProfile`, which are stated shapes and say so. Nothing integrates a path, a force or a mass.
  • Positive is to starboard everywhere — clockwise seen from above — the same sense as every heading in the set. `rollPoint` follows the aircraft convention instead: positive puts the starboard side down.

Usage

import { ackermann, hitchAngle, coordinatedBank, foilRise } from "@/lib/robocn/vehicle"

const rack = ackermann(25, { wheelbase: 120, track: 62 })
rack.inner > rack.outer   // the inner wheel runs on the smaller circle
hitchAngle(25, { wheelbase: 106, track: 60, hitch: 29 }, 57)
coordinatedBank(110, 2100) // degrees
foilRise(32, 18)           // 0 hullborne .. 1 foilborne

API

exports
PropTypeDefaultDescription
ackermann(steer: number, geometry: SteerGeometry) => AckermannPose—The angle the centreline would need, answered with the two the wheels actually take, plus the radius the rear axle runs on. Exact geometry; the inner wheel always turns harder.
hitchAngle(steer, tractor: TractorGeometry, trailerWheelbase) => number—The steady-state articulation angle of a towed section, signed with the steer. No history, so straightening the rack straightens the vehicle.
coordinatedBank(speed, radius, gravity?) => number—atan(v²/rg), in degrees. An infinite radius or no speed is wings level.
axleRide(surface: (x) => number, positions) => AxleRide—A rigid body on N axles over a surface: the least-squares heave and pitch it settles to, and each axle's own travel from it.
tsiolkovsky / stackDeltaV(massRatio, exhaustVelocity) => number / (stages) => number—vₑ ln(mr), and the sum over the stages still attached. A ratio at or below one is no Δv.
foilLift / foilRise(speed, area, coefficient?, density?) => number / (speed, takeoff) => number—The ideal lift equation, and the rise that follows from it: below the takeoff speed nothing, above it the wetted fraction is (takeoff/v)² and the rest is the climb.
pitchProgram(fraction: number, kick?: number) => number—Illustrative. Degrees from vertical over an ascent: vertical off the pad, kicked over early, most of the turn taken in the middle. Not a solved trajectory.
roadProfile(x, amplitude?, wavelength?) => number—Illustrative. Two sines that do not share a period, so a body running over it never repeats over a short run.
wheelSolid / rollPoint(centre, radius, halfWidth, steer?, steps?) => Vec3[] / (point, depth, roll, centre?) => Vec3—Drawing geometry: a steered wheel as the solid it is, and a profile-elevation point lifted into the world and rolled about the fore-aft axis. Both feed straight into slabPath.

Source

src/lib/robocn/vehicle.ts
/**
 * vehicle-geometry — the constraints a vehicle works against.
 *
 * A machine that goes somewhere is not just a body with a heading. It is a
 * body held by a medium, and the mechanism worth drawing is the one that turns
 * it: a steering rack whose two wheels run on different circles, a hitch whose
 * angle is an output rather than an input, a wing that has to bank to turn, a
 * foil that carries less of the hull the faster it goes, a stack that throws
 * half of itself away.
 *
 * Pure functions over plain numbers and vectors. No React, no dependencies.
 * Everything here is exact geometry except `pitchProgram` and `roadProfile`,
 * which are stated shapes and are labelled as such — nothing in this file
 * integrates a path, a force or a mass.
 *
 * Design note: docs/vehicle-robots.md.
 */

import {
  clamp,
  toDegrees,
  toRadians,
  type Vec2,
  type Vec3,
} from "@/lib/robocn/kinematics"

const finite = (value: number, fallback = 0) =>
  Number.isFinite(value) ? value : fallback

/** A length: finite, positive, and never zero — it usually ends up a divisor. */
const span = (value: number, fallback: number) => {
  const magnitude = Math.abs(finite(value, fallback))
  return magnitude > 1e-6 ? magnitude : Math.abs(fallback)
}

/** Past this the rack is at its stop; no road car steers further. */
export const MAX_STEER = 60

/* -------------------------------------------------------------------------- */
/* steering                                                                    */
/* -------------------------------------------------------------------------- */

export interface SteerGeometry {
  /** Front axle to rear axle. */
  wheelbase: number
  /** Wheel centre to wheel centre across one axle. */
  track: number
}

export interface AckermannPose {
  /** The wheel on the inside of the turn. Always the harder angle. */
  inner: number
  /** The wheel on the outside, running on the larger circle. */
  outer: number
  /** Signed angle for the near-side wheel: positive turns to starboard. */
  left: number
  /** Signed angle for the off-side wheel. */
  right: number
  /** Radius the centre of the rear axle runs on. `Infinity` going straight. */
  radius: number
  /** −1 to port, 0 straight, +1 to starboard. */
  sign: number
}

/**
 * True Ackermann: the two front wheels run on circles half a track apart, so
 * the same rack has to turn them through different angles or one of them
 * scrubs. `steer` is the angle the *centreline* would need — what a driver
 * asks for — and the two wheel angles are what the geometry answers.
 *
 * Positive is a turn to starboard, which is clockwise seen from above, the
 * same sense as every heading in the set.
 */
export function ackermann(
  steer: number,
  geometry: SteerGeometry,
): AckermannPose {
  const wheelbase = span(geometry?.wheelbase, 1)
  const track = Math.abs(finite(geometry?.track, 0))
  const angle = clamp(finite(steer, 0), -MAX_STEER, MAX_STEER)
  if (angle === 0) {
    return { inner: 0, outer: 0, left: 0, right: 0, radius: Infinity, sign: 0 }
  }
  const sign = angle > 0 ? 1 : -1
  const radius = wheelbase / Math.tan(toRadians(Math.abs(angle)))
  // The inner circle can never close inside the axle itself: hold it off the
  // wheel centre so a rack at full lock gives a hard angle, not a right one.
  const inside = Math.max(radius - track / 2, wheelbase / 20)
  const inner = toDegrees(Math.atan(wheelbase / inside))
  const outer = toDegrees(Math.atan(wheelbase / (radius + track / 2)))
  return {
    inner,
    outer,
    left: sign > 0 ? outer * sign : inner * sign,
    right: sign > 0 ? inner * sign : outer * sign,
    radius,
    sign,
  }
}

export interface TractorGeometry extends SteerGeometry {
  /** How far behind the rear axle the pivot sits. Negative is ahead of it. */
  hitch: number
}

/**
 * The steady-state articulation angle of a towed section, in degrees, signed
 * with the steer.
 *
 * The hitch rides a circle of its own — offset behind the rear axle, so a
 * larger one than the tractor's — and the towed axle cannot slide sideways, so
 * its velocity lies along its own body. Those two facts fix the angle between
 * the sections: no integration and no history, which is why a bus that has
 * been round a roundabout comes out of it straight.
 *
 * Past the jackknife, where the hitch circle is smaller than the towed
 * wheelbase, there is no steady state at all; the angle is held at the last
 * one there is rather than reported as a solution.
 */
export function hitchAngle(
  steer: number,
  tractor: TractorGeometry,
  trailerWheelbase: number,
): number {
  const angle = clamp(finite(steer, 0), -MAX_STEER, MAX_STEER)
  if (angle === 0) return 0
  const wheelbase = span(tractor?.wheelbase, 1)
  const hitch = finite(tractor?.hitch, 0)
  const towed = span(trailerWheelbase, 1)
  const sign = angle > 0 ? 1 : -1
  const radius = wheelbase / Math.tan(toRadians(Math.abs(angle)))
  const hitchRadius = Math.hypot(radius, hitch)
  const lead = Math.asin(clamp(towed / hitchRadius, -1, 1))
  const offset = Math.atan2(hitch, radius)
  return sign * toDegrees(lead - offset)
}

/* -------------------------------------------------------------------------- */
/* flight                                                                      */
/* -------------------------------------------------------------------------- */

const GRAVITY = 9.81

/**
 * The bank a level turn is coordinated at: `atan(v² / rg)`. Nothing is
 * balanced here beyond that identity — no lift, no load factor, no stall.
 * `radius` of `Infinity` (or zero speed) is wings level.
 */
export function coordinatedBank(
  speed: number,
  radius: number,
  gravity = GRAVITY,
): number {
  const v = Math.abs(finite(speed, 0))
  const r = Math.abs(finite(radius, Infinity))
  const g = span(gravity, GRAVITY)
  if (v === 0 || !Number.isFinite(r) || r < 1e-6) return 0
  return toDegrees(Math.atan((v * v) / (r * g)))
}

/* -------------------------------------------------------------------------- */
/* suspension                                                                  */
/* -------------------------------------------------------------------------- */

export interface AxleRide {
  /** Height of the body's reference line at x = 0. */
  heave: number
  /** Degrees, nose-up positive, where the nose is toward +x. */
  pitch: number
  /** Each axle's own travel: the surface under it, less the body line. */
  travel: number[]
}

/**
 * A rigid body carried on N axles over a surface. The body cannot follow every
 * bump, so it takes the least-squares line through the wheel contacts — the
 * heave and pitch a real body settles into — and each axle keeps the rest as
 * suspension travel. Exact: this is the normal equation, not a filter.
 */
export function axleRide(
  surface: (x: number) => number,
  positions: readonly number[],
): AxleRide {
  const xs = positions.map((value) => finite(value, 0))
  const ys = xs.map((x) => finite(surface(x), 0))
  const n = xs.length
  if (n === 0) return { heave: 0, pitch: 0, travel: [] }
  const meanX = xs.reduce((total, x) => total + x, 0) / n
  const meanY = ys.reduce((total, y) => total + y, 0) / n
  let numerator = 0
  let denominator = 0
  for (let index = 0; index < n; index += 1) {
    const dx = xs[index] - meanX
    numerator += dx * (ys[index] - meanY)
    denominator += dx * dx
  }
  const slope = denominator > 1e-9 ? numerator / denominator : 0
  const heave = meanY - slope * meanX
  return {
    heave,
    pitch: toDegrees(Math.atan(slope)),
    travel: xs.map((x, index) => ys[index] - (heave + slope * x)),
  }
}

/**
 * An illustrative road: two sines that do not share a period, so a body on it
 * never repeats over a short run. A stated shape, not a measured surface.
 */
export function roadProfile(x: number, amplitude = 1, wavelength = 40): number {
  const position = finite(x, 0)
  const height = finite(amplitude, 1)
  const length = span(wavelength, 40)
  return (
    Math.sin((position / length) * Math.PI * 2) * height * 0.6 +
    Math.sin((position / (length * 0.37)) * Math.PI * 2) * height * 0.4
  )
}

/* -------------------------------------------------------------------------- */
/* rockets                                                                     */
/* -------------------------------------------------------------------------- */

export interface RocketStage {
  /** Wet mass over dry mass, for this stage and everything above it. */
  massRatio: number
  /** Effective exhaust velocity, metres per second. */
  exhaustVelocity: number
}

/** The rocket equation: `Δv = vₑ ln(m₀/m₁)`. A ratio at or below one is no Δv. */
export function tsiolkovsky(massRatio: number, exhaustVelocity: number): number {
  const ratio = finite(massRatio, 1)
  const velocity = finite(exhaustVelocity, 0)
  if (ratio <= 1 || velocity <= 0) return 0
  return velocity * Math.log(ratio)
}

/** What a stack of stages is still worth, ideally: the sum over what is left. */
export function stackDeltaV(stages: readonly RocketStage[]): number {
  if (!Array.isArray(stages)) return 0
  return stages.reduce(
    (total, stage) =>
      total + tsiolkovsky(stage?.massRatio ?? 0, stage?.exhaustVelocity ?? 0),
    0,
  )
}

/**
 * **Illustrative.** A pitch program shaped like a gravity turn: vertical off
 * the pad, kicked over early, then most of the turn taken in the middle of the
 * ascent and very little of it at either end. Degrees from vertical, 0 on the
 * pad and 90 at insertion.
 *
 * This is a curve chosen to look like the real thing. It is not a solved
 * trajectory, and no machine here is flying it.
 */
export function pitchProgram(fraction: number, kick = 8): number {
  const f = clamp(finite(fraction, 0), 0, 1)
  const kicked = clamp(finite(kick, 8), 0, 45)
  const start = 0.04
  if (f <= start) return (f / start) * kicked
  const t = (f - start) / (1 - start)
  const rest = t * t * (3 - 2 * t)
  return clamp(kicked + (90 - kicked) * rest, 0, 90)
}

/* -------------------------------------------------------------------------- */
/* foils                                                                       */
/* -------------------------------------------------------------------------- */

/** Sea water, kilograms per cubic metre. */
const SEA_DENSITY = 1025

/**
 * The ideal lift equation, `½ ρ v² S C_L`. No drag, no wave-making, no
 * cavitation and no free-surface effect — the one relation that matters for
 * the drawing is that lift goes as the *square* of speed.
 */
export function foilLift(
  speed: number,
  area: number,
  coefficient = 0.9,
  density = SEA_DENSITY,
): number {
  const v = Math.abs(finite(speed, 0))
  const s = Math.abs(finite(area, 0))
  const cl = Math.abs(finite(coefficient, 0.9))
  const rho = Math.abs(finite(density, SEA_DENSITY))
  return 0.5 * rho * v * v * s * cl
}

/**
 * How far out of the water a surface-piercing foil carries the hull, 0 (hull
 * in the water) to 1 (fully foilborne).
 *
 * The equilibrium is the honest part: lift goes as v², so at a steady weight
 * the immersed area has to fall as 1/v². Below the takeoff speed the foil
 * cannot carry the boat at all and it stays hullborne; above it, the
 * proportion of the foil still wetted is `(takeoff/v)²` and the rest of it is
 * the rise.
 */
export function foilRise(speed: number, takeoff: number): number {
  const v = Math.abs(finite(speed, 0))
  const onset = span(takeoff, 1)
  if (v <= onset) return 0
  return clamp(1 - (onset / v) ** 2, 0, 1)
}

/* -------------------------------------------------------------------------- */
/* drawing geometry                                                            */
/* -------------------------------------------------------------------------- */

/**
 * A steered wheel as the solid it is: a disc standing in the wheel's own
 * plane, swept `halfWidth` either side of it. World axes — x starboard, y up,
 * z aft — so the corners go straight to `slabPath` and the wheel comes out
 * right from every camera. `steer` turns it about the vertical, positive to
 * starboard.
 */
export function wheelSolid(
  centre: Vec3,
  radius: number,
  halfWidth: number,
  steer = 0,
  steps = 16,
): Vec3[] {
  const cx = finite(centre?.x, 0)
  const cy = finite(centre?.y, 0)
  const cz = finite(centre?.z, 0)
  const r = span(radius, 1)
  const half = Math.abs(finite(halfWidth, 0))
  const turn = toRadians(clamp(finite(steer, 0), -90, 90))
  // The wheel rolls along its own forward vector and turns about its axle.
  const forward = { x: Math.sin(turn), z: -Math.cos(turn) }
  const axle = { x: Math.cos(turn), z: Math.sin(turn) }
  const count = Math.max(3, Math.round(finite(steps, 16)))
  return Array.from({ length: count }, (_, index) => {
    const angle = (index / count) * Math.PI * 2
    const along = Math.cos(angle) * r
    const up = Math.sin(angle) * r
    return { x: cx + forward.x * along, y: cy + up, z: cz + forward.z * along }
  }).flatMap((point) => [
    { x: point.x + axle.x * half, y: point.y, z: point.z + axle.z * half },
    { x: point.x - axle.x * half, y: point.y, z: point.z - axle.z * half },
  ])
}

/**
 * A point of a **profile elevation** drawing, lifted into the world and rolled
 * about the vehicle's own fore-aft axis.
 *
 * Drawing coordinates are the set's usual ones — x along the drawing toward
 * the nose, y up from the ground — and `depth` is out of the plane, positive
 * to starboard. `roll` follows the aircraft convention: positive puts the
 * starboard side down, about an axis at height `centre`.
 *
 * This is what lets one elevation drawing bank, heel or roll truthfully
 * instead of being redrawn: the body is modelled once and the roll is a rigid
 * rotation of it, so every length is preserved and every camera agrees.
 */
export function rollPoint(
  point: Vec2,
  depth: number,
  roll: number,
  centre = 0,
): Vec3 {
  const angle = toRadians(clamp(finite(roll, 0), -180, 180))
  const cos = Math.cos(angle)
  const sin = Math.sin(angle)
  const axis = finite(centre, 0)
  const height = finite(point?.y, 0) - axis
  const out = finite(depth, 0)
  return {
    x: out * cos + height * sin,
    y: axis - out * sin + height * cos,
    z: -finite(point?.x, 0),
  }
}