anim

A tiny manim-like animation library in Rust. The MVP renders a fn(t: f64) -> DrawList scene at 60 fps in the browser via WASM + Canvas2D.

The purity model

Every scene is a pure function of time. Nothing in anim-core or the scene layer holds mutable state; the harness drives t, calls scene(t), and hands the returned DrawList to the renderer.

This purity is load-bearing:

  • Hot reload — reload the wasm module and re-drive from any t; the picture is identical.
  • Scrubbing — the scrub bar under every demo below is just calling set_time(t) and re-drawing. Time is not simulated forward; the scene is a lookup.
  • Testingscene(t) is a plain function; write ordinary unit tests.

The flagship demo

The arc-length reparameterization is the demo that most sharply shows what this library is about — see ArcLen.

Layering

anim-web  →  scenes / anim-examples  →  anim-core

anim-core has zero third-party dependencies. Only the web crate touches wasm-bindgen / web-sys.

Vec2

#![allow(unused)]
fn main() {
pub struct Vec2 { pub x: f64, pub y: f64 }

impl Vec2 {
    pub fn new(x: f64, y: f64) -> Self;
    pub fn dot(self, o: Self) -> f64;
    pub fn length(self) -> f64;
    pub fn lerp(self, o: Self, t: f64) -> Self;
}
}

The 2D point / vector. f64 throughout — memory is cheap, drift is not. World space is y-up; the renderer flips y for the screen. Add, Sub, Neg, and Mul<f64> are implemented for the ergonomic uses (a + b, v * 0.5, etc.).

lerp(o, t) is the workhorse: linear interpolation between two points at parameter t. The demo below animates a.lerp(b, ease_in_out_cubic(phase)).

Source

#![allow(unused)]
fn main() {
use anim_core::*;

pub const DURATION: f64 = 2.0;

pub fn scene(t: f64) -> DrawList {
    let a = Vec2::new(-220.0, -60.0);
    let b = Vec2::new(220.0, 60.0);
    let phase = (t / DURATION).clamp(0.0, 1.0);
    let s = ease_in_out_cubic(phase);
    let p = a.lerp(b, s);

    // Draw the segment as a degenerate cubic (straight line: control points on the line).
    let seg = CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b);

    vec![
        Cmd::Stroke {
            path: seg,
            width: 2.0,
            color: Color::rgb(120, 120, 130),
        },
        Cmd::FillCircle {
            center: a,
            r: 5.0,
            color: Color::rgb(120, 120, 130),
        },
        Cmd::FillCircle {
            center: b,
            r: 5.0,
            color: Color::rgb(120, 120, 130),
        },
        Cmd::FillCircle {
            center: p,
            r: 10.0,
            color: Color::rgb(255, 127, 80),
        },
    ]
}
}

CubicBez

#![allow(unused)]
fn main() {
pub struct CubicBez { pub p0: Vec2, pub p1: Vec2, pub p2: Vec2, pub p3: Vec2 }

impl CubicBez {
    pub fn eval(&self, t: f64) -> Vec2;
    pub fn deriv(&self, t: f64) -> Vec2;
}
}

A cubic Bézier segment. eval evaluates the curve in Bernstein form; deriv returns the exact derivative (also a quadratic Bézier reinterpreted as a vector).

Note that eval(t) at even t does not move at constant screen speed — it moves at constant parametric speed, which bunches up in curvy regions and rushes through straight ones. If you want constant speed, see ArcLen.

Source

#![allow(unused)]
fn main() {
use anim_core::*;

pub const DURATION: f64 = 3.0;

fn curve() -> CubicBez {
    CubicBez::new(
        Vec2::new(-240.0, -60.0),
        Vec2::new(-80.0, 120.0),
        Vec2::new(80.0, -120.0),
        Vec2::new(240.0, 60.0),
    )
}

// Degenerate cubics used to draw a straight line segment via the Stroke command.
fn line(a: Vec2, b: Vec2) -> CubicBez {
    CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b)
}

pub fn scene(t: f64) -> DrawList {
    let bez = curve();
    let phase = (t / DURATION).clamp(0.0, 1.0);
    let p = bez.eval(phase);

    let faint = Color::rgba(140, 140, 150, 0.35);
    let curve_col = Color::rgb(160, 160, 170);
    let dot = Color::rgb(255, 127, 80);

    vec![
        Cmd::Stroke {
            path: line(bez.p0, bez.p1),
            width: 1.0,
            color: faint,
        },
        Cmd::Stroke {
            path: line(bez.p1, bez.p2),
            width: 1.0,
            color: faint,
        },
        Cmd::Stroke {
            path: line(bez.p2, bez.p3),
            width: 1.0,
            color: faint,
        },
        Cmd::Stroke {
            path: bez,
            width: 2.0,
            color: curve_col,
        },
        Cmd::FillCircle {
            center: bez.p0,
            r: 4.0,
            color: faint,
        },
        Cmd::FillCircle {
            center: bez.p1,
            r: 4.0,
            color: faint,
        },
        Cmd::FillCircle {
            center: bez.p2,
            r: 4.0,
            color: faint,
        },
        Cmd::FillCircle {
            center: bez.p3,
            r: 4.0,
            color: faint,
        },
        Cmd::FillCircle {
            center: p,
            r: 10.0,
            color: dot,
        },
    ]
}
}

Derivative

deriv(t) gives the tangent vector — direction × instantaneous speed. Handy for orientation (a car following the road should face the way it's going).

#![allow(unused)]
fn main() {
use anim_core::*;

pub const DURATION: f64 = 3.0;

fn curve() -> CubicBez {
    CubicBez::new(
        Vec2::new(-240.0, -60.0),
        Vec2::new(-80.0, 120.0),
        Vec2::new(80.0, -120.0),
        Vec2::new(240.0, 60.0),
    )
}

fn line(a: Vec2, b: Vec2) -> CubicBez {
    CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b)
}

pub fn scene(t: f64) -> DrawList {
    let bez = curve();
    let phase = (t / DURATION).clamp(0.0, 1.0);
    let p = bez.eval(phase);
    let d = bez.deriv(phase);

    // Normalize and scale the tangent for display.
    let len = d.length().max(1e-6);
    let tangent = d * (60.0 / len);

    vec![
        Cmd::Stroke {
            path: bez,
            width: 2.0,
            color: Color::rgb(160, 160, 170),
        },
        Cmd::Stroke {
            path: line(p - tangent, p + tangent),
            width: 2.0,
            color: Color::rgb(120, 200, 255),
        },
        Cmd::FillCircle {
            center: p,
            r: 8.0,
            color: Color::rgb(255, 127, 80),
        },
    ]
}
}

ArcLen

#![allow(unused)]
fn main() {
pub struct ArcLen { /* ... */ }

impl ArcLen {
    pub fn new(c: &CubicBez) -> Self;
    pub fn total(&self) -> f64;
    pub fn t_at_frac(&self, s: f64) -> f64;
}
}

Precomputes a chord-length LUT of a cubic Bézier (256 segments) so you can convert "I want to be halfway along the arc" into the parameter t that puts you there. t_at_frac(s) maps s ∈ [0,1] (fraction of total arc length) to t ∈ [0,1] (curve parameter) via binary search + linear interpolation between samples.

Compare a dot driven by eval(t) (top) with one driven by eval(t_at_frac(t)) (bottom). The top dot slows down and speeds up as the curve bends; the bottom dot glides at constant screen speed.

Source

#![allow(unused)]
fn main() {
use anim_core::*;

pub const DURATION: f64 = 4.0;

fn base() -> CubicBez {
    CubicBez::new(
        Vec2::new(-260.0, 0.0),
        Vec2::new(-120.0, 220.0),
        Vec2::new(120.0, -220.0),
        Vec2::new(260.0, 0.0),
    )
}

fn shift(c: CubicBez, dy: f64) -> CubicBez {
    let s = Vec2::new(0.0, dy);
    CubicBez::new(c.p0 + s, c.p1 + s, c.p2 + s, c.p3 + s)
}

pub fn scene(t: f64) -> DrawList {
    let top = shift(base(), 70.0);
    let bot = shift(base(), -70.0);
    let al_bot = ArcLen::new(&bot);

    let phase = (t / DURATION).clamp(0.0, 1.0);

    // Top: raw parameter — bunches through curvy regions.
    let p_top = top.eval(phase);
    // Bottom: arc-length reparameterization — constant screen speed.
    let p_bot = bot.eval(al_bot.t_at_frac(phase));

    let curve_col = Color::rgb(150, 150, 160);
    let dot_raw = Color::rgb(255, 180, 80);
    let dot_arc = Color::rgb(80, 200, 255);

    vec![
        Cmd::Stroke {
            path: top,
            width: 2.0,
            color: curve_col,
        },
        Cmd::Stroke {
            path: bot,
            width: 2.0,
            color: curve_col,
        },
        Cmd::FillCircle {
            center: p_top,
            r: 10.0,
            color: dot_raw,
        },
        Cmd::FillCircle {
            center: p_bot,
            r: 10.0,
            color: dot_arc,
        },
    ]
}
}

Easing

#![allow(unused)]
fn main() {
pub fn ease_in_out_cubic(t: f64) -> f64;
}

Easing functions remap a linear time t ∈ [0,1] into a curve that starts and ends slow. ease_in_out_cubic is the default: two cubic segments meeting at (0.5, 0.5), continuous in position and slope.

Input outside [0,1] is clamped, so it's safe to feed the raw phase in from the harness without a preceding clamp.

Source

#![allow(unused)]
fn main() {
use anim_core::*;

pub const DURATION: f64 = 2.5;

type Lane = (&'static str, fn(f64) -> f64, Color);
const LANES: &[Lane] = &[(
    "ease_in_out_cubic",
    ease_in_out_cubic,
    Color::rgb(255, 127, 80),
)];

fn line(a: Vec2, b: Vec2) -> CubicBez {
    CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b)
}

pub fn scene(t: f64) -> DrawList {
    let phase = (t / DURATION).clamp(0.0, 1.0);
    let x_left = -260.0;
    let x_right = 260.0;
    let mut out: DrawList = Vec::new();

    let n = LANES.len() as f64;
    for (i, (_name, easing, color)) in LANES.iter().enumerate() {
        let y = if n <= 1.0 {
            0.0
        } else {
            80.0 - (i as f64 / (n - 1.0)) * 160.0
        };
        let a = Vec2::new(x_left, y);
        let b = Vec2::new(x_right, y);
        out.push(Cmd::Stroke {
            path: line(a, b),
            width: 1.0,
            color: Color::rgba(140, 140, 150, 0.5),
        });
        let s = easing(phase);
        let p = a.lerp(b, s);
        out.push(Cmd::FillCircle {
            center: p,
            r: 9.0,
            color: *color,
        });
    }
    out
}
}

DrawList

#![allow(unused)]
fn main() {
pub struct Color { pub r: u8, pub g: u8, pub b: u8, pub a: f64 }

pub enum Cmd {
    Stroke { path: CubicBez, width: f64, color: Color },
    FillCircle { center: Vec2, r: f64, color: Color },
}

pub type DrawList = Vec<Cmd>;
}

The intermediate representation a scene returns. It is deliberately not a scene graph: each frame is a fresh flat list of primitives. Renderers consume it top-to-bottom (later commands paint over earlier ones).

Two primitives so far — cubic-Bezier stroke and filled circle — are enough to build every demo in this book, because straight segments are cubics with collinear control points. As new geometry becomes worth first-classing (paths, polygons, text) it lands here.

Color is plain RGB(A) with r/g/b: u8 and a: f64. Pick mid-saturation colors so the demos read on both light and dark mdBook themes.

Source

The DrawList type is used by every example in this book — see any of the Vec2, CubicBez, ArcLen, or Easing pages for concrete uses.

Kinematics

#![allow(unused)]
fn main() {
pub fn ik1(twist: Twist, positions: &[Vec2; 4]) -> [Setpoint1; 4];
pub fn ik2(twist: Twist, acc: ChassisAccel, positions: &[Vec2; 4]) -> [Setpoint2; 4];
pub fn ik3(twist: Twist, acc: ChassisAccel, jerk: ChassisJerk, positions: &[Vec2; 4]) -> [Setpoint3; 4];
pub fn fk1(measured: &[(f64, f64); 4], positions: &[Vec2; 4]) -> Twist;
}

Pure kinematics up to third order: chassis twist / accel / jerk in → per-module (azimuth, speed) plus their time derivatives out. ik2 and ik3 assume acc and jerk are the time derivatives of twist in the same frame — the FD-based test suite is the arbiter of the analytic formulas.

fk1 inverts ik1 in the least-squares sense, so measured per-module (angle, speed) pairs collapse back into a chassis twist.

The flagship kinematics demo

See Scene C for order-1 vs order-3 feedforward on an S-curve — the same input, one controller using only ik1, the other adding ik2/ik3 feedforward.

Source (Scene C)

#![allow(unused)]
fn main() {
//! Scene C — first-order vs third-order feedforward on an S-curve accel profile.
//!
//! Same commanded profile fed to two twin sims: one whose module controller
//! only knows about ik1 (order 1), one that adds ik2 rate/accel feedforward
//! terms (order 3). The double-integrated profile is the "ideal" trajectory.
//! We render both robots on the ideal path and per-robot drift bars.

use std::sync::OnceLock;

use anim_core::{arrow, Cmd, Color, DrawList, Vec2};
use anim_timeline::{simulate, Interpolate, System, Track};
use kinematics::controllers::ff_ho::{FfMem, HigherOrderFF, ProfileInput};
use kinematics::sim::SimState;
use kinematics::state::ChassisAccel;
use kinematics::{RobotParams, SwerveState, SwerveSystem};

use super::swerve_common as sc;

pub const DURATION: f64 = 4.0;

/// A smooth S-curve profile: lateral acceleration ramps up and back down,
/// with a small yaw acceleration on top. Deterministic function of t.
fn profile(t: f64) -> ChassisAccel {
    // Gentle S-curve; the order-1 controller has no drive-side feedback, so
    // absolute errors stay small enough for the drift ratio to be legible.
    let ax = 0.30 * (t * std::f64::consts::PI / 2.0).sin();
    let ay = 0.20 * ((t - 1.0) * std::f64::consts::PI / 2.0).sin();
    let alpha = 0.20 * (t * std::f64::consts::PI / 3.0).sin();
    ChassisAccel {
        a: Vec2::new(ax, ay),
        alpha,
    }
}

#[derive(Clone, Copy, Debug, Default)]
struct CompareState {
    low: SimState<FfMem>,
    high: SimState<FfMem>,
}

impl Interpolate for CompareState {
    fn lerp(a: &Self, b: &Self, t: f64) -> Self {
        CompareState {
            low: SimState::<FfMem>::lerp(&a.low, &b.low, t),
            high: SimState::<FfMem>::lerp(&a.high, &b.high, t),
        }
    }
}

struct CompareSystem {
    low: SwerveSystem<HigherOrderFF>,
    high: SwerveSystem<HigherOrderFF>,
}

impl System for CompareSystem {
    type State = CompareState;
    fn init(&self) -> Self::State {
        CompareState {
            low: self.low.init(),
            high: self.high.init(),
        }
    }
    fn step(&self, s: &Self::State, t: f64, dt: f64) -> Self::State {
        CompareState {
            low: self.low.step(&s.low, t, dt),
            high: self.high.step(&s.high, t, dt),
        }
    }
}

fn bake() -> &'static Track<CompareState> {
    static T: OnceLock<Track<CompareState>> = OnceLock::new();
    T.get_or_init(|| {
        let params = RobotParams::default_frc();
        let dt = 1.0 / 1000.0;
        let make = |order: u8| HigherOrderFF {
            input: ProfileInput::Accel(profile),
            order,
            params,
            dt,
            steer_kv: 0.05,
            steer_ka: 0.0,
            drive_ka: 0.8,
            steer_p: 30.0,
        };
        let low = SwerveSystem::new(params, make(1), SwerveState::default());
        let high = SwerveSystem::new(params, make(3), SwerveState::default());
        let sys = CompareSystem { low, high };
        simulate(&sys, DURATION, 1000.0, 120.0)
    })
}

fn camera(track: &Track<CompareState>) -> sc::Camera {
    let mut pts = Vec::new();
    for s in track.samples() {
        pts.push(s.low.swerve.chassis.pos);
        pts.push(s.high.swerve.chassis.pos);
        pts.push(s.high.mem.ideal_pos);
    }
    let trajectory_rect = sc::viewport_rect(0.0, 0.28, 1.0, 0.72);
    sc::Camera::fit_in_rect(&pts, trajectory_rect, 32.0, 3.0)
}

pub fn scene(t: f64) -> DrawList {
    let track = bake();
    let params = RobotParams::default_frc();
    let cam = camera(track);
    let state = track.lerp(t);

    let mut out = DrawList::new();
    out.extend(sc::field_grid(&cam, 5.0));

    // Ideal path (dashed) up to now.
    let dt = track.dt();
    let idx = ((t / dt) as usize).min(track.samples().len() - 1);
    let stride = ((track.samples().len() / 200).max(1)).max(1);
    let ideal_pts = sc::build_trail(
        track.samples(),
        |s: &CompareState| s.high.mem.ideal_pos,
        idx,
        stride,
    );
    sc::draw_trail(&cam, &ideal_pts, sc::GHOST_TRAIL_COL, true, &mut out);

    // Two robots.
    let low_col = Color::rgba(255, 130, 80, 0.5);
    let high_col = Color::rgba(120, 220, 200, 0.9);
    // Low-order robot slightly desaturated.
    let mut low_state = state.low.swerve;
    // Draw low first (under high).
    sc::draw_robot(&cam, &params, &low_state, Vec2::new(0.55, 0.55), &mut out);
    sc::draw_robot(
        &cam,
        &params,
        &state.high.swerve,
        Vec2::new(0.55, 0.55),
        &mut out,
    );
    // Ideal marker (dot).
    out.push(Cmd::FillCircle {
        center: cam.m(state.high.mem.ideal_pos),
        r: 5.0,
        color: sc::GHOST_COL,
    });
    let _ = &mut low_state;
    let _ = low_col;
    let _ = high_col;

    // Commanded accel arrow (coral) at ideal position.
    sc::draw_overlay_arrow(
        &cam,
        state.high.mem.ideal_pos,
        state.high.mem.accel.a,
        0.2,
        2.0,
        sc::ACC_COL,
        &mut out,
    );

    // Drift arrows: ideal -> low (orange), ideal -> high (teal).
    let s_low = cam.m(state.low.swerve.chassis.pos) - cam.m(state.high.mem.ideal_pos);
    let s_high = cam.m(state.high.swerve.chassis.pos) - cam.m(state.high.mem.ideal_pos);
    out.extend(arrow(
        cam.m(state.high.mem.ideal_pos),
        s_low,
        1.5,
        Color::rgba(255, 130, 80, 0.9),
    ));
    out.extend(arrow(
        cam.m(state.high.mem.ideal_pos),
        s_high,
        1.5,
        Color::rgba(120, 220, 200, 0.9),
    ));

    // Drift-over-time chart with both series overlaid so the reader sees
    // the whole comparison, not just the current instant.
    let xs: Vec<f64> = (0..track.samples().len()).map(|k| k as f64 * dt).collect();
    let ys_low: Vec<f64> = track
        .samples()
        .iter()
        .map(|s| (s.low.swerve.chassis.pos - s.high.mem.ideal_pos).length())
        .collect();
    let ys_high: Vec<f64> = track
        .samples()
        .iter()
        .map(|s| (s.high.swerve.chassis.pos - s.high.mem.ideal_pos).length())
        .collect();
    let all_ys = ys_low.iter().chain(ys_high.iter()).copied();
    let (y_lo, y_hi) = anim_core::auto_range(all_ys, true);
    let chart = anim_core::LineChart {
        rect: sc::viewport_rect(0.03, 0.03, 0.94, 0.20),
        x_range: (0.0, DURATION),
        y_range: (y_lo, y_hi),
        now: Some(t),
        ..anim_core::LineChart::default()
    };
    out.extend(chart.render(&[
        anim_core::Series {
            xs: &xs,
            ys: &ys_low,
            color: Color::rgba(255, 130, 80, 0.95),
            width: 2.0,
        },
        anim_core::Series {
            xs: &xs,
            ys: &ys_high,
            color: Color::rgba(120, 220, 200, 0.95),
            width: 2.0,
        },
    ]));

    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn order3_beats_order1() {
        let track = bake();
        let last = track.samples().last().unwrap();
        let ideal = last.high.mem.ideal_pos;
        let err_low = (last.low.swerve.chassis.pos - ideal).length();
        let err_high = (last.high.swerve.chassis.pos - ideal).length();
        eprintln!("err_low = {err_low}, err_high = {err_high}");
        assert!(err_low.is_finite() && err_high.is_finite());
        assert!(
            err_high < 0.5 * err_low,
            "order-3 err {err_high} not < 0.5 * order-1 err {err_low}"
        );
    }

    #[test]
    fn no_nans() {
        let track = bake();
        for s in track.samples() {
            assert!(s.low.swerve.chassis.pos.x.is_finite());
            assert!(s.high.swerve.chassis.pos.x.is_finite());
        }
    }
}
}

Tire

#![allow(unused)]
fn main() {
pub struct TireParams { pub mu: f64, pub c_stiff: f64 }

/// F = -mu*N * tanh(c_stiff * |slip| / (mu*N)) * slip.normalized()
pub fn tire_force(p: &TireParams, n_force: f64, slip_vel: Vec2) -> Vec2;
}

The tire model is a 2D slip-velocity law with a tanh saturation. Two useful properties fall out for free:

  • Friction circle — combined longitudinal and lateral limit is automatic. |F| ≤ mu*N in every direction, always.
  • Smooth linear regime — for small slip, |F| ≈ c_stiff * |slip|, matching what a linear cornering stiffness gives you.

Force opposes slip direction, so a wheel skidding forward gets pushed backward, a wheel scrubbing sideways gets pushed sideways-back.

PD tracking under limited grip

The pink line at the bottom is the cross-track error over time.

Source (Scene B)

#![allow(unused)]
fn main() {
//! Scene B — PD path following.
//!
//! A reference dot advances along a cubic Bezier at constant ground speed.
//! The controller commands `twist = kp*err + kd*(ref_vel - vel) + ref_vel`,
//! then chews it up with the same naive open-loop module servo as Scene A.
//! We overlay the P and D vector contributions separately so their roles are
//! legible.

use std::sync::OnceLock;

use anim_core::{arrow, auto_range, Cmd, Color, CubicBez, DrawList, LineChart, Series, Vec2};
use anim_timeline::{simulate, Track};
use kinematics::controllers::pd_path::{PdMem, PdPathFollower};
use kinematics::sim::SimState;
use kinematics::{RobotParams, SwerveState, SwerveSystem};

use super::swerve_common as sc;

pub const DURATION: f64 = 6.0;

type PdState = SimState<PdMem>;

fn path() -> CubicBez {
    CubicBez::new(
        Vec2::new(-2.5, -0.5),
        Vec2::new(-0.5, 1.8),
        Vec2::new(1.0, -1.8),
        Vec2::new(2.5, 0.6),
    )
}

fn bake() -> &'static Track<PdState> {
    static T: OnceLock<Track<PdState>> = OnceLock::new();
    T.get_or_init(|| {
        let params = RobotParams::default_frc();
        let p = path();
        let ctrl = PdPathFollower::new(p, 0.4, 8.0, 4.0, 6.0, 1.6, params);
        // Robot starts 0.5 m off path start (below) to make the capture visible.
        let mut initial = SwerveState::default();
        initial.chassis.pos = p.p0 + Vec2::new(0.0, -0.5);
        let sys = SwerveSystem::new(params, ctrl, initial);
        simulate(&sys, DURATION, 1000.0, 120.0)
    })
}

fn camera(track: &Track<PdState>) -> sc::Camera {
    let p = path();
    let mut pts: Vec<Vec2> = vec![p.p0, p.p1, p.p2, p.p3];
    for s in track.samples() {
        pts.push(s.swerve.chassis.pos);
    }
    let trajectory_rect = sc::viewport_rect(0.0, 0.28, 1.0, 0.72);
    sc::Camera::fit_in_rect(&pts, trajectory_rect, 32.0, 3.0)
}

pub fn scene(t: f64) -> DrawList {
    let track = bake();
    let params = RobotParams::default_frc();
    let cam = camera(track);
    let state = track.lerp(t);

    let mut out = DrawList::new();
    out.extend(sc::field_grid(&cam, 5.0));

    // Path (map control points through camera).
    let p = path();
    let mapped_path = CubicBez::new(cam.m(p.p0), cam.m(p.p1), cam.m(p.p2), cam.m(p.p3));
    out.push(Cmd::Stroke {
        path: mapped_path,
        width: 2.0,
        color: Color::rgba(160, 160, 170, 0.7),
    });

    // Trail so far.
    let dt = track.dt();
    let idx = ((t / dt) as usize).min(track.samples().len() - 1);
    let stride = ((track.samples().len() / 200).max(1)).max(1);
    let trail = sc::build_trail(
        track.samples(),
        |s: &PdState| s.swerve.chassis.pos,
        idx,
        stride,
    );
    sc::draw_trail(&cam, &trail, sc::TRAIL_COL, false, &mut out);

    // Reference dot.
    out.push(Cmd::FillCircle {
        center: cam.m(state.mem.ref_pos),
        r: 6.0,
        color: sc::GHOST_COL,
    });

    // Robot.
    sc::draw_robot(&cam, &params, &state.swerve, Vec2::new(0.6, 0.6), &mut out);

    // Error vector (gray) reference → robot.
    let err_screen = cam.m(state.swerve.chassis.pos) - cam.m(state.mem.ref_pos);
    out.extend(arrow(
        cam.m(state.mem.ref_pos),
        err_screen,
        1.5,
        Color::rgba(180, 180, 190, 0.6),
    ));

    // P and D contributions as arrows from the robot's centre.
    let p_col = Color::rgb(190, 130, 255);
    let d_col = Color::rgb(120, 220, 200);
    let p_scale = 0.35;
    let d_scale = 0.35;
    sc::draw_overlay_arrow(
        &cam,
        state.swerve.chassis.pos,
        state.mem.p_term,
        p_scale,
        2.0,
        p_col,
        &mut out,
    );
    sc::draw_overlay_arrow(
        &cam,
        state.swerve.chassis.pos,
        state.mem.d_term,
        d_scale,
        2.0,
        d_col,
        &mut out,
    );

    // Cross-track error chart along the bottom of the viewport. Axis is
    // computed from the full baked track so playback doesn't rescale.
    let xs: Vec<f64> = (0..track.samples().len()).map(|k| k as f64 * dt).collect();
    let ys: Vec<f64> = track.samples().iter().map(|s| s.mem.cross_track).collect();
    let (y_lo, y_hi) = auto_range(ys.iter().copied(), true);
    let chart = LineChart {
        rect: sc::viewport_rect(0.03, 0.03, 0.94, 0.20),
        x_range: (0.0, DURATION),
        y_range: (y_lo, y_hi),
        now: Some(t),
        ..LineChart::default()
    };
    out.extend(chart.render(&[Series {
        xs: &xs,
        ys: &ys,
        color: Color::rgba(240, 120, 200, 0.95),
        width: 2.0,
    }]));

    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn capture_and_track() {
        let track = bake();
        // After 1.5 s the capture transient should be over; max cross-track
        // error over the remaining duration stays < 0.12 m.
        let dt = track.dt();
        let after = (1.5 / dt).ceil() as usize;
        let mut max_ct = 0.0f64;
        for s in track.samples().iter().skip(after) {
            max_ct = max_ct.max(s.mem.cross_track);
            assert!(s.swerve.chassis.pos.x.is_finite());
        }
        assert!(max_ct < 0.12, "max cross-track after capture = {max_ct} m");
    }
}
}

Swerve

The full dynamic model:

#![allow(unused)]
fn main() {
pub struct RobotParams { pub mass: f64, pub inertia_z: f64, pub drive: MotorParams,
                         pub steer: MotorParams, pub tire: TireParams, pub modules: [ModuleConfig; 4] }
pub struct SwerveState { pub chassis: ChassisState, pub modules: [ModuleState; 4] }

pub struct SwerveSystem<C: Controller> { /* ... */ }
impl<C: Controller> System for SwerveSystem<C> { /* pure step */ }
}

The motor model exposes a single closed-form motor_torque(&MotorParams, v, omega) — same function used for both the drive and steer motors, since a DC motor doesn't care which shaft it's spinning. The stationary "do nothing" controller Zero is what you use for a rest-stays-at-rest test.

Every dynamic scene has a kinematic twin — a GhostState integrated by ghost_step on the same commanded twist. The gap between the ghost and the physically simulated chassis is the drift a real controller has to close.

Each step at 1 kHz: controller emits per-module ModuleCommands; steering dynamics integrate under motor torque and friction; per-module slip and tire forces are computed in the world frame; drive dynamics update wheel_omega; the chassis sums forces and torques and integrates its pose via semi-implicit Euler. Diagnostics (slip_vel, tire_force, cmd) land back on each ModuleState, so scenes never re-derive physics — they read the baked state directly.

The Controller trait folds any per-step memory the controller wants into SimState<Memory>, so every stored state is a valid resume point (verified by a checkpoint-resume test).

Scene A — first-order IK drift

The star scene: open-loop ik1 commands vs a kinematic ghost. The drift vector between them is the whole story.

Source (Scene A)

#![allow(unused)]
fn main() {
//! Scene A — open-loop first-order IK vs a kinematic ghost.
//!
//! The dynamic robot uses [`OpenLoopIk1`], a naive controller: `drive_v =
//! speed / kv_ground`, `steer_v = P * (target - actual)`. No slip
//! compensation, no accel FF — that's the whole point. The ghost integrates
//! the same commanded twist exactly. The drift vector between them is the
//! story of this scene.

use std::sync::OnceLock;

#[cfg(test)]
use anim_core::Cmd;
use anim_core::{arrow, auto_range, DrawList, LineChart, Series, Vec2};
use anim_timeline::{simulate, Track};
use kinematics::drift::{DriftFullState, DriftSystem};
use kinematics::{RobotParams, Twist};

use super::swerve_common as sc;

pub const DURATION: f64 = 6.0;

fn bake() -> &'static Track<DriftFullState> {
    static T: OnceLock<Track<DriftFullState>> = OnceLock::new();
    T.get_or_init(|| {
        let params = RobotParams::default_frc();
        let cmd = Twist {
            v: Vec2::new(1.5, 0.0),
            w: 1.8,
        };
        let sys = DriftSystem::new(params, cmd, 12.0);
        simulate(&sys, DURATION, 1000.0, 120.0)
    })
}

fn camera(track: &Track<DriftFullState>) -> sc::Camera {
    let mut pts: Vec<Vec2> = Vec::with_capacity(track.samples().len() * 2);
    for s in track.samples() {
        pts.push(s.swerve.chassis.pos);
        pts.push(s.ghost.pos);
    }
    // Reserve the top 70 % of the viewport for the trajectory; bottom for
    // the drift chart. `min_world_span` keeps things zoomed out enough to
    // show context around a small trajectory.
    let trajectory_rect = sc::viewport_rect(0.0, 0.28, 1.0, 0.72);
    sc::Camera::fit_in_rect(&pts, trajectory_rect, 32.0, 4.0)
}

fn drift_series(track: &Track<DriftFullState>) -> (Vec<f64>, Vec<f64>) {
    let xs: Vec<f64> = (0..track.samples().len())
        .map(|k| k as f64 * track.dt())
        .collect();
    let ys: Vec<f64> = track
        .samples()
        .iter()
        .map(|s| (s.swerve.chassis.pos - s.ghost.pos).length())
        .collect();
    (xs, ys)
}

pub fn scene(t: f64) -> DrawList {
    let track = bake();
    let params = RobotParams::default_frc();
    let cam = camera(track);
    let state = track.lerp(t);

    let mut out = DrawList::new();
    out.extend(sc::field_grid(&cam, 5.0));

    // Trails up to current t.
    let dt = track.dt();
    let idx = ((t / dt) as usize).min(track.samples().len() - 1);
    let stride = ((track.samples().len() / 200).max(1)).max(1);
    let trail_robot = sc::build_trail(
        track.samples(),
        |s: &DriftFullState| s.swerve.chassis.pos,
        idx,
        stride,
    );
    let trail_ghost = sc::build_trail(
        track.samples(),
        |s: &DriftFullState| s.ghost.pos,
        idx,
        stride,
    );
    sc::draw_trail(&cam, &trail_ghost, sc::GHOST_TRAIL_COL, true, &mut out);
    sc::draw_trail(&cam, &trail_robot, sc::TRAIL_COL, false, &mut out);

    // Ghost + robot.
    let size = Vec2::new(0.7, 0.7);
    sc::draw_ghost(&cam, state.ghost.pos, state.ghost.heading, size, &mut out);
    sc::draw_robot(&cam, &params, &state.swerve, size, &mut out);

    // Overlays: velocity (teal), acceleration (coral), per-wheel slip (red).
    let vel_scale = 0.5;
    let acc_scale = 0.1;
    let slip_scale = 0.15;
    sc::draw_overlay_arrow(
        &cam,
        state.swerve.chassis.pos,
        state.swerve.chassis.twist.v,
        vel_scale,
        2.0,
        sc::VEL_COL,
        &mut out,
    );
    sc::draw_overlay_arrow(
        &cam,
        state.swerve.chassis.pos,
        state.swerve.chassis.accel.a,
        acc_scale,
        2.0,
        sc::ACC_COL,
        &mut out,
    );
    for (i, m) in state.swerve.modules.iter().enumerate() {
        let cfg = &params.modules[i];
        let hub_world = state.swerve.chassis.pos + cfg.pos.rotate(state.swerve.chassis.heading);
        sc::draw_overlay_arrow(
            &cam,
            hub_world,
            m.slip_vel,
            slip_scale,
            1.5,
            sc::SLIP_COL,
            &mut out,
        );
    }

    // Drift vector ghost -> robot.
    let drift_screen = cam.m(state.swerve.chassis.pos) - cam.m(state.ghost.pos);
    out.extend(arrow(
        cam.m(state.ghost.pos),
        drift_screen,
        2.0,
        sc::DRIFT_COL,
    ));

    // Drift-magnitude chart along the bottom of the viewport.
    let (xs, ys) = drift_series(track);
    let (y_lo, y_hi) = auto_range(ys.iter().copied(), true);
    let chart = LineChart {
        rect: sc::viewport_rect(0.03, 0.03, 0.94, 0.20),
        x_range: (0.0, DURATION),
        y_range: (y_lo, y_hi),
        now: Some(t),
        ..LineChart::default()
    };
    out.extend(chart.render(&[Series {
        xs: &xs,
        ys: &ys,
        color: sc::DRIFT_COL,
        width: 2.0,
    }]));

    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn drift_grows() {
        let track = bake();
        let s0 = track.lerp(0.0);
        let sf = track.lerp(DURATION);
        let d0 = (s0.swerve.chassis.pos - s0.ghost.pos).length();
        let df = (sf.swerve.chassis.pos - sf.ghost.pos).length();
        assert!(d0 < 1e-9, "drift at t=0 = {d0}");
        assert!(df > 0.25, "drift at end = {df} m, expected > 0.25 m");
    }

    #[test]
    fn scene_produces_finite_coordinates() {
        for i in 0..=60 {
            let t = DURATION * i as f64 / 60.0;
            let cmds = scene(t);
            assert!(!cmds.is_empty());
            for c in &cmds {
                match c {
                    Cmd::Line { a, b, .. } => {
                        for p in [a, b] {
                            assert!(p.x.is_finite() && p.y.is_finite(), "t={t}");
                        }
                    }
                    Cmd::FillCircle { center, .. } => {
                        assert!(center.x.is_finite() && center.y.is_finite());
                    }
                    Cmd::Stroke { path, .. } => {
                        for p in [path.p0, path.p1, path.p2, path.p3] {
                            assert!(p.x.is_finite() && p.y.is_finite());
                        }
                    }
                    Cmd::Polyline { pts, .. } | Cmd::FillPolygon { pts, .. } => {
                        for p in pts {
                            assert!(p.x.is_finite() && p.y.is_finite());
                        }
                    }
                }
            }
        }
    }
}
}

Playground

Pick any registered example from the dropdown. The URL hash reflects your selection so you can share or bookmark a demo.