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. - Testing —
scene(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, ¶ms, &low_state, Vec2::new(0.55, 0.55), &mut out); sc::draw_robot( &cam, ¶ms, &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*Nin 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, ¶ms, &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, ¶ms, &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 = ¶ms.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.