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"); } } }