Gridiron geometry
The football family's dependency-free maths: the ball as a real prolate spheroid, drag-free ballistics, counter-rotating wheel exit conditions, a route tree sampled by arc length, a sprung pad arm at equilibrium, and the column pitch a hand on the turf implies.
view
variant
flight
spin6/cyc
Drag across to roll it and up or down to pitch it. Nose-on the outline is a circle, because the outline is the ellipsoid's own central section — and the laces go round the back rather than sliding across the front.
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/gridiron-geometry.jsonNotes
- No React, no three.js, no dependencies beyond the kinematics and skeleton cores, and nothing is mutated.
- Every trajectory is drag-free. That is an exact parabola of a ball that does not exist: a real punt goes a good deal less far, and the hang time is optimistic.
- The wheel launcher assumes no slip at either contact. A real one loses some of the surface speed to the ball skidding through the gap.
- There is no contact, no defender, no rule and no clock anywhere in it.
Usage
import { ballSilhouette, kickFlight, launcherExit, sampleRoute } from "@/lib/robocn/gridiron"
// The outline of an ellipsoid is a central section, not its equator.
ballSilhouette(ballFrame({ pitch: 8, roll: 120 }), defaultBall, viewDir)
kickFlight({ speed: 26, angle: 44 }) // { range, apex, hangTime, at, path }
launcherExit({ top: 48, bottom: 16 }) // { speed, spin, bias }
sampleRoute(routePath("post", { depth: 12 }), 13.4) // { point, heading, turn }API
| Prop | Type | Default | Description |
|---|---|---|---|
| ballFrame | (attitude?) => BallFrame | — | The ball's own axes in world space from a yaw, a pitch and a roll. At rest the nose points downfield and the laces face up. |
| ballSilhouette | (frame, shape, viewDir, steps?) => Vec3[] | — | The outline, exactly: the great circle whose pole is M⁻¹Rᵀd, pushed back out through R M. End-on it degenerates to a circle of the waist radius, and nothing special-cases it. |
| ballSeam / ballLaces / ballStripes | (frame, shape, viewDir, …) => marks | — | Surface marks, each carrying the sign of its own normal against the view, so what is on the far side is not drawn on the near one. |
| flightAttitude | (flight, t, options?) => BallAttitude | — | Spiral, wobble, tumble and snap as one mechanism: the nose walks a cone at a third of the roll rate, and the cone opens as the spin comes down. |
| kickFlight | (options?) => KickFlight | — | A drag-free parabola. The hang time, range, apex and impact angle are read off the one curve rather than typed in. |
| launcherExit | (options?) => LauncherExit | — | Exit speed is the mean of the two wheel surface speeds; spin is their difference over the ball's own diameter. |
| routePath / sampleRoute | (route, options?) => Vec2[] / (path, distance) => RouteSample | — | Eleven routes in yards, and the runner at an arc length along one: position, heading, and how hard it is turning at the nearest break. |
| playerSpine / playerUpperBody | (options) => Vec3[] / UpperBody | — | Equal segments at a constant curvature whose chord is the pitch asked for, plus the head's own axes so a helmet lays onto it as a solid. |
| stancePitch | (options) => number | — | The column pitch that puts the shoulder exactly one arm's length from a hand already on the turf. Bisection on the same column the drawing uses. |
| sledDeflection / sledSlide | (load, options?) => number / (drive, options?) => SledSlide | — | Static equilibrium of a pivoted pad arm against its return spring, and the friction threshold a frame will not move below. |
| helmetOutline / facemaskBars / shoulderYoke / padOutline | (…) => Vec2[] | — | The kit all four players wear. Illustration, shared so they match — nothing here is load-bearing. |
Source
src/lib/robocn/gridiron.ts
/**
* robocn — gridiron geometry.
*
* The maths behind the football family: the ball, what happens to it in the
* air, the two machines that put it there, the route tree the receiver runs,
* and the plated kit all four players wear.
*
* World axes are the skeleton's, because the players *are* `solveSkeleton`:
* `x` the machine's right, `y` up, `z` behind it. A player faces `-z`, so
* downfield is `-z` and the floor is `y = 0`. Lengths that leave this file for
* a trajectory or a route are **yards**, and times are seconds; a component
* scales them into its own viewBox once.
*
* The ball is a prolate spheroid — semi-axis `long` down its own axis, `waist`
* across — and two things follow from writing it that way rather than drawing
* an oval:
*
* - Its silhouette is exact from any angle. With the surface as `p = R M q` for
* a unit `q`, the normal is `R M⁻¹ q`, so the outline is the great circle of
* the unit sphere whose pole is `M⁻¹ Rᵀ d` pushed back out through `R M`.
* End-on that degenerates to a circle of radius `waist`, which is what the
* ball looks like coming at you, and nothing special-cases it.
* - The laces are *on the surface*, each with a real normal, so spinning the
* ball takes them round the back instead of sliding them across the front.
*
* What is not here: air. Every trajectory is drag-free, which is a real
* parabola of a ball that does not exist. It is exact, and it is optimistic.
* There is also no contact, no defender, no rule and no clock.
*/
import {
clamp,
lerp,
toRadians,
type Vec2,
type Vec3,
} from "@/lib/robocn/kinematics"
import {
defaultProportions,
type SkeletonProportions,
} from "@/lib/robocn/skeleton"
const finite = (value: number | undefined, fallback: number) =>
typeof value === "number" && Number.isFinite(value) ? value : fallback
/** A length: finite, and never zero or negative. */
const span = (value: number | undefined, fallback: number) =>
Math.max(1e-6, Math.abs(finite(value, fallback)))
const wrap = (value: number) => (Number.isFinite(value) ? ((value % 1) + 1) % 1 : 0)
const dot = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z
const cross = (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,
})
const unit = (v: Vec3, fallback: Vec3 = { x: 0, y: 1, z: 0 }): Vec3 => {
const length = Math.hypot(v.x, v.y, v.z)
return length < 1e-9
? fallback
: { x: v.x / length, y: v.y / length, z: v.z / length }
}
/** `a·x + b·y + c·z` for a frame's three columns. */
const combine = (u: Vec3, v: Vec3, w: Vec3, a: number, b: number, c: number): Vec3 => ({
x: u.x * a + v.x * b + w.x * c,
y: u.y * a + v.y * b + w.y * c,
z: u.z * a + v.z * b + w.z * c,
})
/** Rotate `p` about the unit axis `k` by `degrees` — Rodrigues, written out. */
function turnAbout(p: Vec3, k: Vec3, degrees: number): Vec3 {
const a = toRadians(finite(degrees, 0))
const c = Math.cos(a)
const s = Math.sin(a)
const kp = cross(k, p)
const kd = dot(k, p) * (1 - c)
return {
x: p.x * c + kp.x * s + k.x * kd,
y: p.y * c + kp.y * s + k.y * kd,
z: p.z * c + kp.z * s + k.z * kd,
}
}
/* -------------------------------------------------------------------------- */
/* the ball */
/* -------------------------------------------------------------------------- */
/** Semi-axes of the prolate spheroid: along its own axis, and across it. */
export interface BallShape {
long: number
waist: number
}
/**
* Eleven inches by six and three quarters across the middle, kept as a ratio
* rather than a real measurement — the components pick their own scale.
*/
export const defaultBall: BallShape = { long: 15, waist: 8.6 }
/** Nose direction and roll, in degrees. Yaw turns the nose right, pitch up. */
export interface BallAttitude {
yaw: number
pitch: number
roll: number
}
/** The ball's own axes in world space: `u` the long axis, `v` and `w` across. */
export interface BallFrame {
/** Nose direction — the long axis. */
u: Vec3
/** The ball's right, at zero roll. */
v: Vec3
/** The ball's up, at zero roll. The laces sit on the `w` meridian. */
w: Vec3
}
const shapeOf = (shape: Partial<BallShape> | undefined): BallShape => ({
long: span(shape?.long, defaultBall.long),
waist: span(shape?.waist, defaultBall.waist),
})
/**
* The ball's frame from its attitude: yaw about the world vertical, then pitch
* about the yawed cross axis, then roll about the long axis it ends up with.
* At rest the nose points downfield (`-z`) and the laces face up.
*/
export function ballFrame(attitude: Partial<BallAttitude> = {}): BallFrame {
const yaw = finite(attitude.yaw, 0)
const pitch = finite(attitude.pitch, 0)
const roll = finite(attitude.roll, 0)
const up: Vec3 = { x: 0, y: 1, z: 0 }
// Negated: the nose points down `-z`, so a positive turn about the world
// vertical has to swing it toward `+x` for "yaw right" to mean right.
let u = turnAbout({ x: 0, y: 0, z: -1 }, up, -yaw)
const v = turnAbout({ x: 1, y: 0, z: 0 }, up, -yaw)
let w = { x: 0, y: 1, z: 0 }
u = turnAbout(u, v, pitch)
w = turnAbout(w, v, pitch)
const axis = unit(u, { x: 0, y: 0, z: -1 })
return {
u: axis,
v: turnAbout(v, axis, roll),
w: turnAbout(w, axis, roll),
}
}
/**
* A point on the surface. `s` runs -1 at the tail to 1 at the nose; `theta` is
* degrees round the long axis, 0 on the lace meridian.
*/
export function ballPoint(
frame: BallFrame,
shape: Partial<BallShape>,
s: number,
theta: number,
): Vec3 {
const { long, waist } = shapeOf(shape)
const along = clamp(finite(s, 0), -1, 1)
const ring = Math.sqrt(Math.max(0, 1 - along * along))
const a = toRadians(finite(theta, 0))
return combine(
frame.u,
frame.v,
frame.w,
long * along,
waist * ring * Math.sin(a),
waist * ring * Math.cos(a),
)
}
/** The outward normal at the same surface point. */
export function ballNormal(
frame: BallFrame,
shape: Partial<BallShape>,
s: number,
theta: number,
): Vec3 {
const { long, waist } = shapeOf(shape)
const along = clamp(finite(s, 0), -1, 1)
const ring = Math.sqrt(Math.max(0, 1 - along * along))
const a = toRadians(finite(theta, 0))
return unit(
combine(
frame.u,
frame.v,
frame.w,
along / long,
(ring * Math.sin(a)) / waist,
(ring * Math.cos(a)) / waist,
),
frame.u,
)
}
/** Either tip: `+1` the nose, `-1` the tail. */
export function ballTip(frame: BallFrame, shape: Partial<BallShape>, end: number): Vec3 {
const { long } = shapeOf(shape)
const sign = finite(end, 1) < 0 ? -1 : 1
return { x: frame.u.x * long * sign, y: frame.u.y * long * sign, z: frame.u.z * long * sign }
}
/**
* The silhouette, exactly. `viewDir` points from the ball toward the camera.
*
* The outline of an ellipsoid under orthographic projection is a *central*
* section, not the equator: the set of surface points whose normal is
* perpendicular to the view. Written as `p = R M q`, the normal is `R M⁻¹ q`,
* so those `q` are the great circle with pole `M⁻¹ Rᵀ d`. Sampling that circle
* and pushing it back through `R M` is the outline, and it collapses to a
* circle of radius `waist` when the ball is end-on.
*/
export function ballSilhouette(
frame: BallFrame,
shape: Partial<BallShape>,
viewDir: Vec3,
steps = 48,
): Vec3[] {
const { long, waist } = shapeOf(shape)
const d = unit(viewDir, { x: 0, y: 0, z: 1 })
// The view direction in the ball's own axes, scaled by M⁻¹.
const pole = unit(
{ x: dot(d, frame.u) / long, y: dot(d, frame.v) / waist, z: dot(d, frame.w) / waist },
{ x: 1, y: 0, z: 0 },
)
// Any two unit vectors perpendicular to the pole span the great circle.
const seed: Vec3 = Math.abs(pole.x) < 0.9 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 }
const e1 = unit(cross(pole, seed))
const e2 = unit(cross(pole, e1))
const count = Math.max(8, Math.round(finite(steps, 48)))
return Array.from({ length: count }, (_, index) => {
const a = (index / count) * Math.PI * 2
const qx = e1.x * Math.cos(a) + e2.x * Math.sin(a)
const qy = e1.y * Math.cos(a) + e2.y * Math.sin(a)
const qz = e1.z * Math.cos(a) + e2.z * Math.sin(a)
return combine(frame.u, frame.v, frame.w, long * qx, waist * qy, waist * qz)
})
}
/** A surface point with the sign of its own visibility. */
export interface BallMark {
point: Vec3
/** Positive when the camera can see this bit of skin. */
facing: number
}
const markAt = (
frame: BallFrame,
shape: Partial<BallShape>,
view: Vec3,
s: number,
theta: number,
): BallMark => ({
point: ballPoint(frame, shape, s, theta),
facing: dot(ballNormal(frame, shape, s, theta), view),
})
/** The lace panel's centre line: the `theta = 0` meridian, nose to tail. */
export function ballSeam(
frame: BallFrame,
shape: Partial<BallShape>,
viewDir: Vec3,
steps = 24,
): BallMark[] {
const view = unit(viewDir, { x: 0, y: 0, z: 1 })
const count = Math.max(4, Math.round(finite(steps, 24)))
return Array.from({ length: count }, (_, index) =>
markAt(frame, shape, view, lerp(-0.82, 0.82, index / (count - 1)), 0),
)
}
/** One stitch across the seam. */
export interface BallLace {
a: Vec3
b: Vec3
facing: number
}
/** The cross stitches, straddling the seam over the middle third of the ball. */
export function ballLaces(
frame: BallFrame,
shape: Partial<BallShape>,
viewDir: Vec3,
count = 8,
reach = 16,
): BallLace[] {
const view = unit(viewDir, { x: 0, y: 0, z: 1 })
const stitches = Math.max(2, Math.round(finite(count, 8)))
const arc = clamp(finite(reach, 16), 2, 60)
return Array.from({ length: stitches }, (_, index) => {
const s = lerp(-0.36, 0.36, index / (stitches - 1))
return {
a: ballPoint(frame, shape, s, -arc),
b: ballPoint(frame, shape, s, arc),
facing: dot(ballNormal(frame, shape, s, 0), view),
}
})
}
/** The two bands round the ball near its ends, as rings of marks. */
export function ballStripes(
frame: BallFrame,
shape: Partial<BallShape>,
viewDir: Vec3,
at = 0.6,
steps = 28,
): BallMark[][] {
const view = unit(viewDir, { x: 0, y: 0, z: 1 })
const where = clamp(finite(at, 0.6), 0.1, 0.95)
const count = Math.max(6, Math.round(finite(steps, 28)))
return [where, -where].map((s) =>
Array.from({ length: count }, (_, index) =>
markAt(frame, shape, view, s, (index / count) * 360),
),
)
}
/* -------------------------------------------------------------------------- */
/* what the ball does in the air */
/* -------------------------------------------------------------------------- */
export type BallFlight = "spiral" | "wobble" | "tumble" | "snap" | "hold"
export interface FlightOptions {
/** Turns about the long axis per cycle. */
spin?: number
/** Half-angle of the precession cone, in degrees. */
wobble?: number
/** The attitude the flight is built around. */
pitch?: number
yaw?: number
/** Roll for the flights that do not drive it themselves. */
roll?: number
}
/**
* The ball's attitude at cycle time `t`, as a pure function of the clock.
*
* A spiral and a wobble are the same mechanism with different numbers: the
* nose walks round a cone while the ball rolls about it, and the cone is
* narrow and the roll fast in one case, open and slow in the other. The
* precession runs at a third of the roll rate, which is why a good spiral's
* nose drifts rather than strobes. A tumble takes the roll off and pitches the
* whole ball end over end instead.
*/
export function flightAttitude(
flight: BallFlight,
t: number,
options: FlightOptions = {},
): BallAttitude {
const cycle = wrap(t)
const pitch = finite(options.pitch, 0)
const yaw = finite(options.yaw, 0)
const spin = finite(options.spin, 6)
const cone = finite(options.wobble, 0)
switch (flight) {
case "spiral": {
const nose = 2 * Math.PI * cycle * (spin / 3)
const open = cone || 4
return {
yaw: yaw + open * Math.cos(nose),
pitch: pitch + open * Math.sin(nose),
roll: cycle * 360 * spin,
}
}
case "wobble": {
const slow = finite(options.spin, 2)
const nose = 2 * Math.PI * cycle * (slow / 3)
const open = cone || 24
return {
yaw: yaw + open * Math.cos(nose),
pitch: pitch + open * Math.sin(nose * 1.6),
roll: cycle * 360 * slow,
}
}
case "tumble":
return { yaw, pitch: pitch + cycle * 360, roll: finite(options.roll, 0) }
case "snap": {
// Off the turf and spinning up: the roll arrives quadratically and the
// nose comes round from lying across the field to pointing back.
const rise = Math.min(1, cycle * 2)
return {
yaw: lerp(yaw + 88, yaw, rise),
pitch: lerp(pitch - 16, pitch, rise),
roll: cycle * cycle * 360 * spin,
}
}
default:
return { yaw, pitch, roll: finite(options.roll, 0) }
}
}
/* -------------------------------------------------------------------------- */
/* ballistics */
/* -------------------------------------------------------------------------- */
/** Yards per second squared: 9.81 m/s² in the unit the rest of this file uses. */
export const GRAVITY = 10.73
export interface KickOptions {
/** Launch speed, yards per second. */
speed?: number
/** Launch angle above the horizontal, in degrees. */
angle?: number
/** Launch height above the ground, in yards. */
height?: number
gravity?: number
}
export interface FlightPoint {
x: number
y: number
vx: number
vy: number
/** Velocity direction in degrees above the horizontal. */
heading: number
}
export interface KickFlight {
speed: number
angle: number
height: number
gravity: number
/** Seconds from launch to the ball coming back to the ground. */
hangTime: number
/** Horizontal distance covered in that time, in yards. */
range: number
/** Highest point of the arc, in yards. */
apex: number
apexTime: number
/** Degrees below the horizontal as it lands. */
impactAngle: number
/** The ball `t` seconds after launch. Past the landing it stays landed. */
at(t: number): FlightPoint
/** The whole arc as a polyline, launch to landing. */
path(steps?: number): Vec2[]
}
/**
* A drag-free parabola: exact for a ball in a vacuum, and optimistic for one
* in air. Everything else about the flight — the hang time, the range, the
* angle it comes down at — is read off that one curve rather than typed in.
*/
export function kickFlight({
speed = 24,
angle = 42,
height = 0,
gravity = GRAVITY,
}: KickOptions = {}): KickFlight {
const v = Math.max(0, finite(speed, 24))
const a = clamp(finite(angle, 42), -85, 89)
const y0 = Math.max(0, finite(height, 0))
const g = span(gravity, GRAVITY)
const vx = v * Math.cos(toRadians(a))
const vy = v * Math.sin(toRadians(a))
// y(t) = y0 + vy t − ½gt², solved for the positive root.
const hangTime = (vy + Math.sqrt(Math.max(0, vy * vy + 2 * g * y0))) / g
const apexTime = Math.max(0, vy / g)
const apex = y0 + (vy > 0 ? (vy * vy) / (2 * g) : 0)
const landingVy = vy - g * hangTime
const at = (t: number): FlightPoint => {
const time = clamp(finite(t, 0), 0, hangTime)
const nowVy = vy - g * time
return {
x: vx * time,
y: Math.max(0, y0 + vy * time - 0.5 * g * time * time),
vx,
vy: nowVy,
heading: (Math.atan2(nowVy, vx) * 180) / Math.PI,
}
}
return {
speed: v,
angle: a,
height: y0,
gravity: g,
hangTime,
range: vx * hangTime,
apex,
apexTime,
impactAngle: (Math.atan2(-landingVy, vx) * 180) / Math.PI,
at,
path(steps = 32) {
const count = Math.max(2, Math.round(finite(steps, 32)))
return Array.from({ length: count + 1 }, (_, index) => {
const point = at((index / count) * hangTime)
return { x: point.x, y: point.y }
})
},
}
}
/* -------------------------------------------------------------------------- */
/* the wheel launcher */
/* -------------------------------------------------------------------------- */
export interface LauncherOptions {
/** Wheel speeds in turns per second, both driving the ball forward. */
top?: number
bottom?: number
wheelRadius?: number
ballRadius?: number
}
export interface LauncherExit {
/** Length units per second: the mean of the two contact speeds. */
speed: number
/** Turns per second, positive topspin. */
spin: number
topSurface: number
bottomSurface: number
/** 0 when the wheels are matched, 1 when one is stopped and the other is not. */
bias: number
}
/**
* Two counter-rotating wheels squeezing a ball between them. With no slip at
* either contact the ball's centre leaves at the mean of the two surface
* speeds and turns at their difference over its own diameter — so matched
* wheels throw it flat and fast, and a mismatch trades speed for spin. Both
* numbers come out of the same pair of inputs; neither is decoration.
*/
export function launcherExit({
top = 30,
bottom = 30,
wheelRadius = 5,
ballRadius = 2.6,
}: LauncherOptions = {}): LauncherExit {
const r = span(wheelRadius, 5)
const ball = span(ballRadius, 2.6)
const topRate = finite(top, 30)
const bottomRate = finite(bottom, 30)
const topSurface = 2 * Math.PI * r * topRate
const bottomSurface = 2 * Math.PI * r * bottomRate
const fastest = Math.max(Math.abs(topRate), Math.abs(bottomRate))
return {
speed: (topSurface + bottomSurface) / 2,
spin: (topSurface - bottomSurface) / (2 * ball * 2 * Math.PI),
topSurface,
bottomSurface,
bias: fastest < 1e-6 ? 0 : clamp(Math.abs(topRate - bottomRate) / fastest, 0, 1),
}
}
/* -------------------------------------------------------------------------- */
/* the route tree */
/* -------------------------------------------------------------------------- */
export type RouteName =
| "go"
| "hitch"
| "slant"
| "flat"
| "out"
| "in"
| "curl"
| "comeback"
| "post"
| "corner"
| "wheel"
export const routeNames: readonly RouteName[] = [
"go",
"hitch",
"slant",
"flat",
"out",
"in",
"curl",
"comeback",
"post",
"corner",
"wheel",
]
export interface RouteOptions {
/** How deep the break is, in yards. */
depth?: number
/** `1` aligned to the machine's right, `-1` mirrors the whole route. */
side?: number
/** Starting offset from the middle, in yards. */
split?: number
}
/**
* A route as a polyline in yards. `x` runs toward the sideline the receiver
* started on, `y` downfield from the line of scrimmage. Outside breaks are
* positive `x` and inside breaks negative, so mirroring the whole tree is one
* sign.
*/
export function routePath(route: RouteName, options: RouteOptions = {}): Vec2[] {
const d = clamp(finite(options.depth, 12), 2, 60)
const side = finite(options.side, 1) < 0 ? -1 : 1
const split = finite(options.split, 0)
const legs: Record<RouteName, Vec2[]> = {
go: [{ x: 0, y: 0 }, { x: 0, y: d + 6 }],
hitch: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: 0.6, y: d - 2.4 }],
slant: [{ x: 0, y: 0 }, { x: 0, y: 2.6 }, { x: -d * 0.72, y: 2.6 + d * 0.72 }],
flat: [{ x: 0, y: 0 }, { x: 0, y: 2 }, { x: d * 0.9, y: 4.4 }],
out: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: d * 0.6, y: d + 0.6 }],
in: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: -d * 0.6, y: d + 0.6 }],
curl: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: -d * 0.2, y: d - 2.4 }],
comeback: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: d * 0.24, y: d - 3.2 }],
post: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: -d * 0.66, y: d + d * 0.66 }],
corner: [{ x: 0, y: 0 }, { x: 0, y: d }, { x: d * 0.58, y: d + d * 0.58 }],
wheel: [
{ x: 0, y: 0 },
{ x: 0, y: 1.6 },
{ x: d * 0.42, y: 3.6 },
{ x: d * 0.5, y: d + 5 },
],
}
return (legs[route] ?? legs.go).map((point) => ({
x: split + point.x * side,
y: point.y,
}))
}
/** Total run, in yards. */
export function routeLength(path: readonly Vec2[]): number {
let total = 0
for (let index = 1; index < path.length; index += 1) {
total += Math.hypot(path[index].x - path[index - 1].x, path[index].y - path[index - 1].y)
}
return total
}
export interface RouteSample {
point: Vec2
/** Degrees, 0 straight downfield, positive turning toward `+x`. */
heading: number
/** Which leg of the route is being run. */
leg: number
/** 0 at the line of scrimmage, 1 at the end of the route. */
progress: number
/** Signed turn being taken here, -1 to 1. */
turn: number
/** How hard: the same number without its sign. */
breaking: number
}
/**
* Where the runner is after `distance` yards, and what he is doing with his
* body when he gets there. A break is a corner in the polyline, so `turn` is
* the exterior angle at the nearest one faded in over the `blend` yards either
* side of it — which is the lean, without anyone typing a lean.
*/
export function sampleRoute(
path: readonly Vec2[],
distance: number,
blend = 2.4,
): RouteSample {
const flat: RouteSample = {
point: path[0] ?? { x: 0, y: 0 },
heading: 0,
leg: 0,
progress: 0,
turn: 0,
breaking: 0,
}
if (path.length < 2) return flat
const total = routeLength(path)
if (total < 1e-6) return flat
const along = clamp(finite(distance, 0), 0, total)
let travelled = 0
let leg = path.length - 2
let within = 0
let legLength = 1
for (let index = 1; index < path.length; index += 1) {
const step = Math.hypot(
path[index].x - path[index - 1].x,
path[index].y - path[index - 1].y,
)
if (along <= travelled + step || index === path.length - 1) {
leg = index - 1
within = along - travelled
legLength = Math.max(1e-6, step)
break
}
travelled += step
}
const a = path[leg]
const b = path[leg + 1]
const t = clamp(within / legLength, 0, 1)
const headingOf = (from: Vec2, to: Vec2) =>
(Math.atan2(to.x - from.x, to.y - from.y) * 180) / Math.PI
const heading = headingOf(a, b)
// The nearest corner, and how close we are to it in yards.
const fade = Math.max(1e-6, finite(blend, 2.4))
let turn = 0
const cornerBefore = leg > 0 ? within : Infinity
const cornerAfter = leg + 2 < path.length ? legLength - within : Infinity
if (cornerBefore <= cornerAfter && cornerBefore < fade) {
const delta = angleDelta(headingOf(path[leg - 1], a), heading)
turn = (delta / 90) * (1 - cornerBefore / fade)
} else if (cornerAfter < fade) {
const delta = angleDelta(heading, headingOf(b, path[leg + 2]))
turn = (delta / 90) * (1 - cornerAfter / fade)
}
const bounded = clamp(turn, -1, 1)
return {
point: { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) },
heading,
leg,
progress: along / total,
turn: bounded,
breaking: Math.abs(bounded),
}
}
/** Shortest signed difference between two headings, in degrees. */
function angleDelta(from: number, to: number) {
return ((((to - from) % 360) + 540) % 360) - 180
}
/* -------------------------------------------------------------------------- */
/* stances */
/* -------------------------------------------------------------------------- */
export type GridironStance = "three-point" | "two-point" | "upright" | "set"
export interface StanceGeometry {
stance: GridironStance
/** Hip height, 0 to 1, ready for `solveSkeleton`. */
crouch: number
/**
* Column pitch from vertical when nothing else decides it. A stance with a
* hand on the turf ignores this: `stancePitch` works the pitch out instead.
*/
lean: number
/** Cycle fraction to freeze the walk at, which is what staggers the feet. */
step: number
/** Where the down hand goes in the body frame, or null when both are up. */
downHand: Vec3 | null
/** Where the other hand rests. */
offHand: Vec3
/** Share of the body on the down hand, 0 when nothing is. */
handLoad: number
}
/**
* The four postures the players stand in.
*
* The three-point stance is the interesting one, and the two numbers that make
* it are the crouch and the hand on the turf. The feet are staggered by
* freezing the walk solver at a cycle fraction where one foot has just landed
* and the other is still on its toe — a real sample of the gait rather than a
* second pose table.
*/
export function stanceGeometry(
stance: GridironStance,
options: { reach?: number } = {},
): StanceGeometry {
const reach = clamp(finite(options.reach, 1), 0.5, 1.5)
switch (stance) {
case "three-point":
return {
stance,
crouch: 0.14,
lean: 88,
step: 0.06,
downHand: { x: 9, y: 2.5, z: -40 * reach },
offHand: { x: -13, y: 20, z: -20 },
handLoad: 0.26,
}
case "two-point":
return {
stance,
crouch: 0.44,
lean: 42,
step: 0.06,
downHand: null,
offHand: { x: -13, y: 40, z: -26 },
handLoad: 0,
}
case "set":
return {
stance,
crouch: 0.66,
lean: 22,
step: 0.04,
downHand: null,
offHand: { x: -14, y: 62, z: -24 },
handLoad: 0,
}
default:
return {
stance: "upright",
crouch: 1,
lean: 4,
step: 0,
downHand: null,
offHand: { x: -16, y: 62, z: -8 },
handLoad: 0,
}
}
}
export interface PlayerSpineOptions {
/** Sacrum, in world space. */
base: Vec3
/** Sacrum to the shoulder line. */
length: number
segments?: number
/** Pitch of the column's **chord** from vertical, positive toward the nose. */
lean: number
/** Total curvature across the column, in degrees. A back is not a stick. */
arch?: number
/** Shoulders against the pelvis, in degrees. */
twist?: number
}
/**
* The column this family stands its shoulders on: equal segments swept through
* a constant curvature, centred so the **chord** comes out at exactly `lean`.
*
* `spineCurve` in `skeleton-kinematics` accumulates its pitch down the column
* instead, which puts the chord at about half the number asked for — correct
* for a machine standing up, and wrong here, where the interesting fact about
* a stance is that the back is flat and the shoulders are level with the hips.
* Making the chord the number is what lets `stancePitch` solve for it.
*/
export function playerSpine({
base,
length,
segments = 7,
lean,
arch = 18,
twist = 0,
}: PlayerSpineOptions): Vec3[] {
const count = Math.max(2, Math.round(finite(segments, 7)))
const step = span(length, 56) / count
const pitch = clamp(finite(lean, 0), -60, 130)
const bend = clamp(finite(arch, 18), -60, 60)
const turn = clamp(finite(twist, 0), -70, 70)
const start: Vec3 = {
x: finite(base?.x, 0),
y: finite(base?.y, 0),
z: finite(base?.z, 0),
}
const points: Vec3[] = [start]
for (let index = 0; index < count; index += 1) {
const heading = pitch - bend / 2 + (bend * (index + 0.5)) / count
const yaw = toRadians((turn * (index + 1)) / count)
const rise = Math.cos(toRadians(heading))
const run = Math.sin(toRadians(heading))
const previous = points[index]
points.push({
x: previous.x + run * Math.sin(yaw) * step,
y: previous.y + rise * step,
z: previous.z - run * Math.cos(yaw) * step,
})
}
return points
}
export interface StancePitchOptions {
/** Hip joint height above the floor. */
hipHeight: number
/** Sacrum to the shoulder line. */
spine: number
/** Shoulder to fingertip: humerus plus forearm. */
arm: number
/** The hand, already on the turf, in the body frame. */
hand: Vec3
/** Half the shoulder span, for the shoulder the hand belongs to. */
shoulderSpan?: number
segments?: number
arch?: number
}
/**
* The column pitch that puts the shoulder exactly one arm's length from a hand
* already on the turf — so the flat back of a three-point stance is an output
* of the hand being down, not a number someone typed.
*
* The shoulder swings forward and drops as the column pitches, so the distance
* to a fixed hand falls monotonically until the shoulder reaches it; that is
* one crossing, and a bisection finds it. It works on the same `playerSpine`
* the drawing uses, so the answer is the pitch that is actually drawn rather
* than one off a simplified column.
*/
export function stancePitch({
hipHeight,
spine,
arm,
hand,
shoulderSpan = 0,
segments = 7,
arch = 18,
}: StancePitchOptions): number {
const hip = finite(hipHeight, 60)
const limb = span(arm, 56)
const target: Vec3 = {
x: finite(hand?.x, 0),
y: finite(hand?.y, 0),
z: finite(hand?.z, 0),
}
const across = finite(shoulderSpan, 0)
const gap = (degrees: number) => {
const column = playerSpine({
base: { x: 0, y: hip, z: 0 },
length: spine,
segments,
lean: degrees,
arch,
})
const shoulder = column[column.length - 1]
return (
Math.hypot(shoulder.x + across - target.x, shoulder.y - target.y, shoulder.z - target.z) -
limb
)
}
if (gap(0) <= 0) return 0
if (gap(120) > 0) return 120
let low = 0
let high = 120
for (let step = 0; step < 40; step += 1) {
const mid = (low + high) / 2
if (gap(mid) > 0) low = mid
else high = mid
}
return (low + high) / 2
}
/* -------------------------------------------------------------------------- */
/* the upper body */
/* -------------------------------------------------------------------------- */
export interface UpperBodyOptions {
/** Pelvis in world space; `solveSkeleton` produces it. */
pelvis: Vec3
/** Column pitch from vertical, positive toward the nose. */
lean: number
/** Shoulders against the pelvis, in degrees. */
twist?: number
/** Head pitch relative to the column, positive looking up. */
gazePitch?: number
gazeYaw?: number
/** Total curvature across the column, in degrees. */
arch?: number
proportions?: SkeletonProportions
}
export interface UpperBody {
/** Vertebra centres, sacrum first, shoulder line last. */
spine: Vec3[]
shoulders: Vec3
shoulderYaw: number
neck: Vec3
head: Vec3
/** The head's own axes, so a helmet lays onto it as a solid. */
nose: Vec3
up: Vec3
right: Vec3
}
/**
* The column and the head for a player, on the same equal-segment spine the
* skeleton uses — but pitched as far as the stance asks for rather than as far
* as a standing machine would ever go, and handing back the head's own axes so
* the helmet can be laid on it instead of drawn beside it.
*/
export function playerUpperBody({
pelvis,
lean,
twist = 0,
gazePitch = 0,
gazeYaw = 0,
arch = 18,
proportions = defaultProportions,
}: UpperBodyOptions): UpperBody {
const p = proportions
const base: Vec3 = {
x: finite(pelvis?.x, 0),
y: finite(pelvis?.y, 0),
z: finite(pelvis?.z, 0),
}
const pitch = clamp(finite(lean, 0), -45, 120)
const turn = clamp(finite(twist, 0), -70, 70)
const spine = playerSpine({
base,
length: p.spine,
segments: Math.max(2, Math.round(finite(p.vertebrae, 7))),
lean: pitch,
arch,
twist: turn,
})
const shoulders = spine[spine.length - 1]
const below = spine[spine.length - 2]
// Degrees from straight up, positive toward the nose.
const heading = (Math.atan2(below.z - shoulders.z, shoulders.y - below.y) * 180) / Math.PI
const look = heading - clamp(finite(gazePitch, 0), -60, 110)
const advance = (from: Vec3, degrees: number, distance: number): Vec3 => {
const a = toRadians(degrees)
return {
x: from.x,
y: from.y + Math.cos(a) * distance,
z: from.z - Math.sin(a) * distance,
}
}
const neck = advance(shoulders, heading, p.neck)
const head = advance(neck, look, p.skull * 0.62)
const up = unit({ x: head.x - neck.x, y: head.y - neck.y, z: head.z - neck.z }, {
x: 0,
y: 1,
z: 0,
})
const across = turnAbout(
{ x: 1, y: 0, z: 0 },
{ x: 0, y: 1, z: 0 },
-(turn + clamp(finite(gazeYaw, 0), -70, 70)),
)
const nose = unit(cross(up, across), { x: 0, y: 0, z: -1 })
return { spine, shoulders, shoulderYaw: turn, neck, head, nose, up, right: unit(cross(nose, up)) }
}
/* -------------------------------------------------------------------------- */
/* the sled */
/* -------------------------------------------------------------------------- */
export interface SledArm {
/** Pivot to pad centre. */
arm: number
/** Return spring rate, torque per radian. */
stiffness: number
/** Spring wind-up already in it at rest, in degrees. */
preload: number
}
export const defaultSledArm: SledArm = { arm: 26, stiffness: 2600, preload: 6 }
/**
* How far a pad swings back under a load pushing square on it, in degrees.
*
* Static equilibrium about the pivot: the load's moment falls off as `cos θ`
* while the spring's climbs linearly in `θ`, so there is exactly one crossing
* and a bisection finds it. That is why the last few degrees cost so much more
* than the first few — the pad is running out of leverage at the same time as
* the spring is winding up.
*/
export function sledDeflection(load: number, options: Partial<SledArm> = {}): number {
const geometry: SledArm = {
arm: span(options.arm, defaultSledArm.arm),
stiffness: span(options.stiffness, defaultSledArm.stiffness),
preload: Math.abs(finite(options.preload, defaultSledArm.preload)),
}
const force = Math.max(0, finite(load, 0))
const preload = toRadians(geometry.preload)
const moment = (theta: number) =>
force * geometry.arm * Math.cos(theta) - geometry.stiffness * (theta + preload)
if (moment(0) <= 0) return 0
let low = 0
let high = Math.PI / 2
for (let step = 0; step < 48; step += 1) {
const mid = (low + high) / 2
if (moment(mid) > 0) low = mid
else high = mid
}
return ((low + high) / 2) * (180 / Math.PI)
}
export interface SledSlide {
/** Drive the frame will not move under, in the same units as `drive`. */
threshold: number
/** What it accelerates at once it does move. */
acceleration: number
sliding: boolean
}
/**
* The frame itself. Nothing happens at all until the drive beats the static
* friction under the skids; past that, the surplus is what accelerates it.
*/
export function sledSlide(
drive: number,
{ weight = 1, friction = 0.62, mass = 1 }: { weight?: number; friction?: number; mass?: number } = {},
): SledSlide {
const load = Math.max(0, finite(weight, 1))
const mu = Math.max(0, finite(friction, 0.62))
const m = span(mass, 1)
const push = Math.max(0, finite(drive, 0))
const threshold = mu * load
return {
threshold,
acceleration: Math.max(0, (push - threshold) / m),
sliding: push > threshold,
}
}
/* -------------------------------------------------------------------------- */
/* the kit */
/* -------------------------------------------------------------------------- */
export type FacemaskStyle = "cage" | "bar" | "shield"
/**
* The helmet shell in profile, in the head's own sagittal frame: `x` toward
* the nose, `y` up, origin at the centre of the head. A closed outline, so it
* extrudes into a solid from any camera.
*
* Shared so all four players wear the same shell. It is illustration — nothing
* about it is solved — and it is the last thing in this file that is not.
*/
export function helmetOutline(radius: number): Vec2[] {
const r = span(radius, 12)
const shell: Vec2[] = [
{ x: 0.9, y: 0.26 },
{ x: 0.66, y: 0.78 },
{ x: 0.18, y: 1.0 },
{ x: -0.4, y: 0.88 },
{ x: -0.84, y: 0.46 },
{ x: -0.96, y: -0.04 },
{ x: -0.88, y: -0.46 },
{ x: -0.54, y: -0.72 },
{ x: -0.08, y: -0.8 },
{ x: 0.36, y: -0.74 },
{ x: 0.66, y: -0.54 },
{ x: 0.84, y: -0.22 },
{ x: 0.94, y: 0.02 },
]
return shell.map((point) => ({ x: point.x * r, y: point.y * r }))
}
/** Where the ear hole sits on that shell, same frame. */
export function helmetEar(radius: number): Vec2 {
const r = span(radius, 12)
return { x: -0.24 * r, y: -0.18 * r }
}
/** The facemask as bars across the face opening, in the same frame. */
export function facemaskBars(radius: number, style: FacemaskStyle = "cage"): [Vec2, Vec2][] {
const r = span(radius, 12)
const at = (x: number, y: number): Vec2 => ({ x: x * r, y: y * r })
const horizontals: Record<FacemaskStyle, number[]> = {
cage: [-0.62, -0.34, -0.04],
bar: [-0.5, -0.12],
shield: [-0.58],
}
const bars: [Vec2, Vec2][] = (horizontals[style] ?? horizontals.cage).map((y) => [
at(0.52, y * 1.06),
at(1.12, y * 0.78),
])
if (style === "cage") bars.push([at(1.13, -0.62), at(1.0, 0.06)])
if (style === "shield") bars.push([at(0.58, 0.02), at(1.14, -0.2)], [at(1.14, -0.2), at(1.1, -0.55)])
return bars
}
/**
* The shoulder pad footprint in plan: `x` across the machine, `y` aft. Wide at
* the shoulders, notched at the neck, and tucked at the back.
*/
export function shoulderYoke(halfSpan: number, depth: number): Vec2[] {
const half = span(halfSpan, 26)
const deep = span(depth, 15)
return [
{ x: 0.16 * half, y: -0.86 * deep },
{ x: 0.62 * half, y: -0.78 * deep },
{ x: 0.95 * half, y: -0.34 * deep },
{ x: 1.0 * half, y: 0.22 * deep },
{ x: 0.82 * half, y: 0.78 * deep },
{ x: 0.3 * half, y: 1.0 * deep },
{ x: -0.3 * half, y: 1.0 * deep },
{ x: -0.82 * half, y: 0.78 * deep },
{ x: -1.0 * half, y: 0.22 * deep },
{ x: -0.95 * half, y: -0.34 * deep },
{ x: -0.62 * half, y: -0.78 * deep },
{ x: -0.16 * half, y: -0.86 * deep },
{ x: -0.13 * half, y: -0.52 * deep },
{ x: 0.13 * half, y: -0.52 * deep },
]
}
/** A rounded plate — thigh, knee, chest — as a closed outline about its centre. */
export function padOutline(halfWidth: number, halfHeight: number, steps = 14): Vec2[] {
const w = span(halfWidth, 6)
const h = span(halfHeight, 8)
const count = Math.max(6, Math.round(finite(steps, 14)))
return Array.from({ length: count }, (_, index) => {
const a = (index / count) * Math.PI * 2
// Squared off rather than elliptical: a pad is a plate, not a bubble.
const c = Math.cos(a)
const s = Math.sin(a)
const k = 0.72
return {
x: w * Math.sign(c) * Math.abs(c) ** k,
y: h * Math.sign(s) * Math.abs(s) ** k,
}
})
}