use std::sync::OnceLock;
use std::time::Duration;
use teksilo_tokens::ScrollPhysicsTokens;
pub trait ScrollSimulation: std::fmt::Debug {
fn position(&self, t: Duration) -> f32;
fn velocity(&self, t: Duration) -> f32;
fn is_done(&self, t: Duration) -> bool;
}
pub const SETTLE_DISTANCE_TOLERANCE: f32 = 0.1;
pub const SETTLE_VELOCITY_TOLERANCE: f32 = 20.0;
fn secs(t: Duration) -> f32 {
t.as_secs_f32()
}
fn normalize(min: f32, max: f32) -> (f32, f32) {
if min > max { (min, min) } else { (min, max) }
}
const GRAVITY_EARTH: f64 = 9.806_65;
const INCHES_PER_METER: f64 = 39.37;
const PPI_AT_DENSITY_ONE: f64 = 160.0;
const PHYSICAL_TUNING: f64 = 0.84;
fn physical_coeff() -> f64 {
GRAVITY_EARTH * INCHES_PER_METER * PPI_AT_DENSITY_ONE * PHYSICAL_TUNING
}
const NB_SAMPLES: usize = 100;
const START_TENSION: f64 = 0.5;
const END_TENSION: f64 = 1.0;
const MAX_BISECTION_STEPS: usize = 64;
fn spline_position_table() -> &'static [f32; NB_SAMPLES + 1] {
static TABLE: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
TABLE.get_or_init(|| {
let inflexion = f64::from(ScrollPhysicsTokens::DEFAULT.clamping_inflexion);
let p1 = START_TENSION * inflexion;
let p2 = 1.0 - END_TENSION * (1.0 - inflexion);
let mut table = [0.0f32; NB_SAMPLES + 1];
let mut x_min = 0.0f64;
for (i, slot) in table.iter_mut().take(NB_SAMPLES).enumerate() {
let alpha = i as f64 / NB_SAMPLES as f64;
let mut x_max = 1.0f64;
let mut x = 0.0f64;
let mut coef = 0.0f64;
for _ in 0..MAX_BISECTION_STEPS {
x = x_min + (x_max - x_min) / 2.0;
coef = 3.0 * x * (1.0 - x);
let tx = coef * ((1.0 - x) * p1 + x * p2) + x * x * x;
if (tx - alpha).abs() < 1e-5 {
break;
}
if tx > alpha {
x_max = x;
} else {
x_min = x;
}
}
*slot = (coef * ((1.0 - x) * START_TENSION + x) + x * x * x) as f32;
}
table[0] = 0.0;
table[NB_SAMPLES] = 1.0;
table
})
}
fn spline_coefficients(frac: f32) -> (f32, f32) {
let table = spline_position_table();
if frac.is_nan() || frac >= 1.0 {
return (1.0, 0.0);
}
let frac = frac.max(0.0);
let index = (NB_SAMPLES as f32 * frac) as usize;
if index >= NB_SAMPLES {
return (1.0, 0.0);
}
let t_inf = index as f32 / NB_SAMPLES as f32;
let t_sup = (index + 1) as f32 / NB_SAMPLES as f32;
let d_inf = table[index];
let d_sup = table[index + 1];
let velocity_coef = (d_sup - d_inf) / (t_sup - t_inf);
let distance_coef = d_inf + (frac - t_inf) * velocity_coef;
(distance_coef, velocity_coef)
}
fn spline_deceleration(velocity: f64, friction: f64, inflexion: f64) -> f64 {
(inflexion * velocity.abs() / (friction * physical_coeff())).ln()
}
pub fn fling_duration(velocity: f32, tokens: &ScrollPhysicsTokens) -> Duration {
let Some(l) = spline_l(velocity, tokens) else {
return Duration::ZERO;
};
let rate = f64::from(tokens.clamping_deceleration_rate);
if rate <= 1.0 {
return Duration::ZERO;
}
let seconds = (l / (rate - 1.0)).exp();
if seconds.is_finite() && seconds > 0.0 {
Duration::from_secs_f64(seconds.min(60.0))
} else {
Duration::ZERO
}
}
pub fn fling_distance(velocity: f32, tokens: &ScrollPhysicsTokens) -> f32 {
let Some(l) = spline_l(velocity, tokens) else {
return 0.0;
};
let rate = f64::from(tokens.clamping_deceleration_rate);
if rate <= 1.0 {
return 0.0;
}
let friction = f64::from(tokens.clamping_friction);
let distance = friction * physical_coeff() * (rate / (rate - 1.0) * l).exp();
if distance.is_finite() && distance > 0.0 {
distance as f32
} else {
0.0
}
}
fn spline_l(velocity: f32, tokens: &ScrollPhysicsTokens) -> Option<f64> {
let friction = f64::from(tokens.clamping_friction);
let inflexion = f64::from(tokens.clamping_inflexion);
if !velocity.is_finite() || velocity == 0.0 || friction <= 0.0 || inflexion <= 0.0 {
return None;
}
let l = spline_deceleration(f64::from(velocity), friction, inflexion);
l.is_finite().then_some(l)
}
#[derive(Clone, Debug)]
pub struct ClampingSimulation {
start: f32,
distance: f32,
duration: Duration,
duration_secs: f32,
min: f32,
max: f32,
}
impl ClampingSimulation {
pub fn new(
position: f32,
velocity: f32,
min: f32,
max: f32,
tokens: &ScrollPhysicsTokens,
) -> Self {
let (min, max) = normalize(min, max);
let duration = fling_duration(velocity, tokens);
let magnitude = fling_distance(velocity, tokens);
let distance = if velocity < 0.0 {
-magnitude
} else {
magnitude
};
Self {
start: position,
distance,
duration,
duration_secs: duration.as_secs_f32(),
min,
max,
}
}
pub fn duration(&self) -> Duration {
self.duration
}
pub fn distance(&self) -> f32 {
self.distance
}
fn unbounded_position(&self, t: Duration) -> f32 {
if self.duration_secs <= 0.0 {
return self.start + self.distance;
}
let frac = secs(t) / self.duration_secs;
let (distance_coef, _) = spline_coefficients(frac);
self.start + self.distance * distance_coef
}
}
impl ScrollSimulation for ClampingSimulation {
fn position(&self, t: Duration) -> f32 {
self.unbounded_position(t).clamp(self.min, self.max)
}
fn velocity(&self, t: Duration) -> f32 {
if self.duration_secs <= 0.0 || self.is_done(t) {
return 0.0;
}
let frac = secs(t) / self.duration_secs;
let (_, velocity_coef) = spline_coefficients(frac);
velocity_coef * self.distance / self.duration_secs
}
fn is_done(&self, t: Duration) -> bool {
if t >= self.duration {
return true;
}
let raw = self.unbounded_position(t);
if self.distance > 0.0 {
raw >= self.max
} else if self.distance < 0.0 {
raw <= self.min
} else {
true
}
}
}
#[derive(Clone, Copy, Debug)]
struct FrictionSimulation {
drag: f32,
drag_log: f32,
position: f32,
velocity: f32,
}
impl FrictionSimulation {
fn new(drag: f32, position: f32, velocity: f32) -> Self {
let drag = if drag.is_finite() && drag > 0.0 && drag < 1.0 {
drag
} else {
ScrollPhysicsTokens::DEFAULT.bouncing_decay_per_second
};
Self {
drag,
drag_log: drag.ln(),
position,
velocity,
}
}
fn x(&self, t: f32) -> f32 {
self.position + self.velocity * (self.drag.powf(t) - 1.0) / self.drag_log
}
fn dx(&self, t: f32) -> f32 {
self.velocity * self.drag.powf(t)
}
fn final_x(&self) -> f32 {
self.position - self.velocity / self.drag_log
}
fn time_at_x(&self, x: f32) -> f32 {
if x == self.position {
return 0.0;
}
let final_x = self.final_x();
let unreachable = if self.velocity > 0.0 {
x < self.position || x > final_x
} else {
x > self.position || x < final_x
};
if self.velocity == 0.0 || unreachable {
return f32::INFINITY;
}
((self.drag_log * (x - self.position) / self.velocity + 1.0).ln() / self.drag_log).max(0.0)
}
fn is_done(&self, t: f32) -> bool {
self.dx(t).abs() < SETTLE_VELOCITY_TOLERANCE
}
}
#[derive(Clone, Copy, Debug)]
enum SpringSolution {
Overdamped { r1: f32, r2: f32, c1: f32, c2: f32 },
Critical { r: f32, c1: f32, c2: f32 },
Underdamped { w: f32, r: f32, c1: f32, c2: f32 },
}
impl SpringSolution {
fn new(mass: f32, stiffness: f32, damping: f32, distance: f32, velocity: f32) -> Self {
let cmk = damping * damping - 4.0 * mass * stiffness;
if cmk > 0.0 {
let root = cmk.sqrt();
let r1 = (-damping - root) / (2.0 * mass);
let r2 = (-damping + root) / (2.0 * mass);
let c2 = (velocity - r1 * distance) / (r2 - r1);
let c1 = distance - c2;
SpringSolution::Overdamped { r1, r2, c1, c2 }
} else if cmk < 0.0 {
let w = (4.0 * mass * stiffness - damping * damping).sqrt() / (2.0 * mass);
let r = -damping / (2.0 * mass);
SpringSolution::Underdamped {
w,
r,
c1: distance,
c2: (velocity - r * distance) / w,
}
} else {
let r = -damping / (2.0 * mass);
SpringSolution::Critical {
r,
c1: distance,
c2: velocity - r * distance,
}
}
}
fn x(&self, t: f32) -> f32 {
match *self {
SpringSolution::Overdamped { r1, r2, c1, c2 } => {
c1 * (r1 * t).exp() + c2 * (r2 * t).exp()
}
SpringSolution::Critical { r, c1, c2 } => (c1 + c2 * t) * (r * t).exp(),
SpringSolution::Underdamped { w, r, c1, c2 } => {
(r * t).exp() * (c1 * (w * t).cos() + c2 * (w * t).sin())
}
}
}
fn dx(&self, t: f32) -> f32 {
match *self {
SpringSolution::Overdamped { r1, r2, c1, c2 } => {
c1 * r1 * (r1 * t).exp() + c2 * r2 * (r2 * t).exp()
}
SpringSolution::Critical { r, c1, c2 } => {
let power = (r * t).exp();
r * (c1 + c2 * t) * power + c2 * power
}
SpringSolution::Underdamped { w, r, c1, c2 } => {
let power = (r * t).exp();
let cos = (w * t).cos();
let sin = (w * t).sin();
power * (c2 * w * cos - c1 * w * sin) + r * power * (c2 * sin + c1 * cos)
}
}
}
}
#[derive(Clone, Copy, Debug)]
struct SpringSimulation {
end: f32,
solution: SpringSolution,
}
impl SpringSimulation {
fn new(tokens: &ScrollPhysicsTokens, start: f32, end: f32, velocity: f32) -> Self {
let mass = if tokens.spring_mass > 0.0 {
tokens.spring_mass
} else {
ScrollPhysicsTokens::DEFAULT.spring_mass
};
let stiffness = if tokens.spring_stiffness > 0.0 {
tokens.spring_stiffness
} else {
ScrollPhysicsTokens::DEFAULT.spring_stiffness
};
let damping = tokens.spring_damping_ratio * 2.0 * (mass * stiffness).sqrt();
Self {
end,
solution: SpringSolution::new(mass, stiffness, damping, start - end, velocity),
}
}
fn x(&self, t: f32) -> f32 {
self.end + self.solution.x(t)
}
fn dx(&self, t: f32) -> f32 {
self.solution.dx(t)
}
fn is_done(&self, t: f32) -> bool {
self.solution.x(t).abs() < SETTLE_DISTANCE_TOLERANCE
&& self.solution.dx(t).abs() < SETTLE_VELOCITY_TOLERANCE
}
}
#[derive(Clone, Copy, Debug)]
pub struct BouncingSimulation {
friction: FrictionSimulation,
spring: Option<SpringSimulation>,
spring_time: f32,
}
impl BouncingSimulation {
pub fn within(position: f32, velocity: f32, min: f32, max: f32) -> Self {
Self::new(position, velocity, min, max, min, max)
}
pub fn new(
position: f32,
velocity: f32,
min: f32,
max: f32,
leading: f32,
trailing: f32,
) -> Self {
Self::with_tokens(
position,
velocity,
min,
max,
leading,
trailing,
&ScrollPhysicsTokens::DEFAULT,
)
}
pub fn with_tokens(
position: f32,
velocity: f32,
min: f32,
max: f32,
leading: f32,
trailing: f32,
tokens: &ScrollPhysicsTokens,
) -> Self {
let (min, max) = normalize(min, max);
let friction =
FrictionSimulation::new(tokens.bouncing_decay_per_second, position, velocity);
if position < min {
return Self {
friction,
spring: Some(SpringSimulation::new(tokens, position, leading, velocity)),
spring_time: f32::NEG_INFINITY,
};
}
if position > max {
return Self {
friction,
spring: Some(SpringSimulation::new(tokens, position, trailing, velocity)),
spring_time: f32::NEG_INFINITY,
};
}
let final_x = friction.final_x();
if velocity > 0.0 && final_x > max {
let t = friction.time_at_x(max);
if t.is_finite() {
let transfer = cap_magnitude(friction.dx(t), MAX_SPRING_TRANSFER_VELOCITY);
return Self {
friction,
spring: Some(SpringSimulation::new(tokens, max, trailing, transfer)),
spring_time: t,
};
}
} else if velocity < 0.0 && final_x < min {
let t = friction.time_at_x(min);
if t.is_finite() {
let transfer = cap_magnitude(friction.dx(t), MAX_SPRING_TRANSFER_VELOCITY);
return Self {
friction,
spring: Some(SpringSimulation::new(tokens, min, leading, transfer)),
spring_time: t,
};
}
}
Self {
friction,
spring: None,
spring_time: f32::INFINITY,
}
}
pub fn spring_time(&self) -> f32 {
self.spring_time
}
fn phase(&self, t: f32) -> (bool, f32) {
if self.spring.is_some() && t > self.spring_time {
let offset = if self.spring_time.is_finite() {
self.spring_time
} else {
0.0
};
(true, offset)
} else {
(false, 0.0)
}
}
}
const MAX_SPRING_TRANSFER_VELOCITY: f32 = 5000.0;
fn cap_magnitude(v: f32, limit: f32) -> f32 {
v.clamp(-limit, limit)
}
impl ScrollSimulation for BouncingSimulation {
fn position(&self, t: Duration) -> f32 {
let t = secs(t);
match self.phase(t) {
(true, offset) => self
.spring
.expect("phase() only reports the spring when there is one")
.x(t - offset),
(false, _) => self.friction.x(t),
}
}
fn velocity(&self, t: Duration) -> f32 {
let t = secs(t);
match self.phase(t) {
(true, offset) => self
.spring
.expect("phase() only reports the spring when there is one")
.dx(t - offset),
(false, _) => self.friction.dx(t),
}
}
fn is_done(&self, t: Duration) -> bool {
let t = secs(t);
match self.phase(t) {
(true, offset) => self
.spring
.expect("phase() only reports the spring when there is one")
.is_done(t - offset),
(false, _) => self.friction.is_done(t),
}
}
}
pub fn rubber_band(offset: f32, extent: f32) -> f32 {
rubber_band_with(
offset,
extent,
ScrollPhysicsTokens::DEFAULT.rubber_band_factor,
)
}
pub fn rubber_band_with(offset: f32, extent: f32, factor: f32) -> f32 {
if offset.is_nan()
|| !extent.is_finite()
|| extent <= 0.0
|| !factor.is_finite()
|| factor <= 0.0
{
return 0.0;
}
if offset.is_infinite() {
return extent.copysign(offset);
}
let x = f64::from(offset.abs());
let extent = f64::from(extent);
let factor = f64::from(factor);
let damped = (extent * (1.0 - 1.0 / (factor * x / extent + 1.0))) as f32;
if offset < 0.0 { -damped } else { damped }
}
pub fn rubber_band_inverse(damped: f32, extent: f32) -> f32 {
rubber_band_inverse_with(
damped,
extent,
ScrollPhysicsTokens::DEFAULT.rubber_band_factor,
)
}
pub fn rubber_band_inverse_with(damped: f32, extent: f32, factor: f32) -> f32 {
if !damped.is_finite()
|| !extent.is_finite()
|| extent <= 0.0
|| !factor.is_finite()
|| factor <= 0.0
{
return 0.0;
}
let y = f64::from(damped.abs().min(extent * (1.0 - 1e-4)));
let extent = f64::from(extent);
let factor = f64::from(factor);
let raw = (extent * y / (factor * (extent - y))) as f32;
if damped < 0.0 { -raw } else { raw }
}
#[cfg(test)]
mod tests {
use super::*;
fn tokens() -> ScrollPhysicsTokens {
ScrollPhysicsTokens::DEFAULT
}
#[test]
fn the_spline_table_runs_from_zero_to_one_monotonically() {
let table = spline_position_table();
assert_eq!(table[0], 0.0, "SPLINE_POSITION[0]");
assert_eq!(table[NB_SAMPLES], 1.0, "SPLINE_POSITION[NB_SAMPLES]");
assert!(table[1] < 0.05, "SPLINE_POSITION[1] = {}", table[1]);
for w in table.windows(2) {
assert!(
w[1] >= w[0],
"the distance fraction went backwards: {} then {}",
w[0],
w[1]
);
}
assert!(table.iter().all(|d| (0.0..=1.0).contains(d)));
}
#[test]
fn the_spline_formulas_match_android_at_their_exact_point() {
let t = tokens();
let scaled_friction = f64::from(t.clamping_friction) * physical_coeff();
assert!(
(scaled_friction - 778.353_025_968).abs() < 1e-4,
"friction · mPhysicalCoeff = {scaled_friction}, expected 778.353025968"
);
let velocity = (scaled_friction / f64::from(t.clamping_inflexion)) as f32;
assert!(
(velocity - 2223.8658).abs() < 0.01,
"the exact-point velocity is {velocity}"
);
let duration = fling_duration(velocity, &t).as_secs_f64();
assert!(
(duration - 1.0).abs() < 1e-4,
"getSplineFlingDuration must be exactly 1 s here, got {duration}"
);
let distance = f64::from(fling_distance(velocity, &t));
assert!(
(distance - scaled_friction).abs() < 0.05,
"getSplineFlingDistance must be friction·mPhysicalCoeff = {scaled_friction}, got {distance}"
);
}
#[test]
fn a_four_thousand_dp_per_second_fling_matches_the_hand_derivation() {
let t = tokens();
let duration = fling_duration(4000.0, &t).as_secs_f64();
assert!(
(duration - 1.540_68).abs() < 1e-3,
"getSplineFlingDuration(4000) = {duration}, expected 1.54068 s"
);
let distance = f64::from(fling_distance(4000.0, &t));
assert!(
(distance - 2156.95).abs() < 1.0,
"getSplineFlingDistance(4000) = {distance}, expected 2156.95 dp"
);
let scaled_friction = f64::from(t.clamping_friction) * physical_coeff();
let rate = f64::from(t.clamping_deceleration_rate);
let implied = scaled_friction * duration.powf(rate);
assert!(
(implied - distance).abs() < 0.5,
"distance {distance} must equal friction·coeff·duration^DECEL = {implied}"
);
}
#[test]
fn the_deceleration_rate_token_is_the_android_ratio() {
let expected = 0.78f64.ln() / 0.9f64.ln();
assert!(
(f64::from(tokens().clamping_deceleration_rate) - expected).abs() < 1e-5,
"DECELERATION_RATE token vs ln(0.78)/ln(0.9) = {expected}"
);
}
#[test]
fn a_clamping_fling_lands_on_its_computed_distance() {
let t = tokens();
let sim = ClampingSimulation::new(0.0, 4000.0, -1.0e6, 1.0e6, &t);
let end = sim.position(sim.duration());
assert!(
(end - 2156.95).abs() < 1.0,
"landed at {end}, expected 2156.95"
);
assert!(sim.is_done(sim.duration()));
assert_eq!(sim.position(Duration::ZERO), 0.0, "starts where released");
}
#[test]
fn a_clamping_fling_is_monotone_and_decelerating() {
let t = tokens();
let sim = ClampingSimulation::new(0.0, 3000.0, -1.0e6, 1.0e6, &t);
let mut previous = sim.position(Duration::ZERO);
let mut previous_speed = sim.velocity(Duration::ZERO);
for ms in (0..1600).step_by(16) {
let now = Duration::from_millis(ms);
let p = sim.position(now);
let v = sim.velocity(now);
assert!(
p >= previous - 1e-3,
"reversed at {ms} ms: {previous} → {p}"
);
assert!(
v <= previous_speed + 1.0,
"accelerated at {ms} ms: {previous_speed} → {v}"
);
previous = p;
previous_speed = v;
}
}
#[test]
fn a_clamping_fling_stops_dead_at_the_boundary() {
let t = tokens();
let sim = ClampingSimulation::new(0.0, 4000.0, 0.0, 100.0, &t);
let late = Duration::from_millis(900);
assert_eq!(sim.position(late), 100.0, "must not pass the boundary");
assert!(sim.is_done(late), "arriving at the bound ends the fling");
assert_eq!(sim.velocity(late), 0.0, "and leaves no residual velocity");
}
#[test]
fn a_zero_velocity_clamping_fling_is_already_over() {
let t = tokens();
let sim = ClampingSimulation::new(42.0, 0.0, -1.0e6, 1.0e6, &t);
assert_eq!(sim.duration(), Duration::ZERO);
assert_eq!(sim.position(Duration::ZERO), 42.0);
assert!(sim.is_done(Duration::ZERO));
}
#[test]
fn a_negative_clamping_fling_travels_backwards() {
let t = tokens();
let sim = ClampingSimulation::new(0.0, -4000.0, -1.0e6, 1.0e6, &t);
let end = sim.position(sim.duration());
assert!(
(end + 2156.95).abs() < 1.0,
"landed at {end}, expected -2156.95"
);
}
#[test]
fn the_in_range_coast_matches_flutters_friction_simulation() {
let ln_drag = 0.135f64.ln();
assert!(
(ln_drag + 2.002_480_5).abs() < 1e-6,
"ln(0.135) = {ln_drag}, expected -2.0024805"
);
let sim = BouncingSimulation::within(0.0, 1000.0, -1.0e6, 1.0e6);
let x1 = sim.position(Duration::from_secs(1));
assert!(
(x1 - 431.964).abs() < 0.05,
"x(1 s) = {x1}, expected 431.964"
);
let v1 = sim.velocity(Duration::from_secs(1));
assert!((v1 - 135.0).abs() < 0.05, "dx(1 s) = {v1}, expected 135");
let far = sim.position(Duration::from_secs(30));
assert!(
(far - 499.381).abs() < 0.05,
"finalX = {far}, expected 499.381"
);
}
#[test]
fn the_settle_spring_matches_flutters_overdamped_solution() {
let t = tokens();
let damping = t.spring_damping_ratio * 2.0 * (t.spring_mass * t.spring_stiffness).sqrt();
assert!(
(damping - 15.556_349).abs() < 1e-3,
"damping = {damping}, expected 15.556349"
);
let cmk = damping * damping - 4.0 * t.spring_mass * t.spring_stiffness;
assert!(
cmk > 0.0,
"the shipped ratio 1.1 must be overdamped, cmk={cmk}"
);
assert!((cmk - 42.0).abs() < 1e-2, "cmk = {cmk}, expected 42");
let sim = BouncingSimulation::within(150.0, 0.0, 0.0, 100.0);
assert_eq!(sim.spring_time(), f32::NEG_INFINITY, "springs immediately");
assert!(
(sim.position(Duration::ZERO) - 150.0).abs() < 1e-3,
"starts where released"
);
let x = sim.position(Duration::from_millis(100));
assert!(
(x - 130.437).abs() < 0.05,
"x(0.1 s) = {x}, expected 100 + 30.437"
);
}
#[test]
fn the_coast_hands_off_to_the_spring_at_the_boundary_crossing() {
let sim = BouncingSimulation::within(0.0, 1000.0, -1.0e6, 100.0);
let handoff = sim.spring_time();
assert!(
(handoff - 0.111_600).abs() < 1e-4,
"handoff at {handoff} s, expected 0.111600"
);
let at = sim.position(Duration::from_secs_f32(handoff));
assert!((at - 100.0).abs() < 0.1, "position at handoff = {at}");
}
#[test]
fn an_in_range_bouncing_fling_settles_without_a_spring() {
let sim = BouncingSimulation::within(0.0, 300.0, -1.0e6, 1.0e6);
assert_eq!(sim.spring_time(), f32::INFINITY, "no boundary is crossed");
assert!(!sim.is_done(Duration::ZERO), "300 dp/s is still moving");
assert!(
sim.is_done(Duration::from_secs(2)),
"must be below {SETTLE_VELOCITY_TOLERANCE} dp/s after 2 s"
);
}
#[test]
fn an_overscrolling_bouncing_fling_overshoots_then_returns() {
let sim = BouncingSimulation::within(0.0, 2000.0, 0.0, 100.0);
let peak = (0..200)
.map(|i| sim.position(Duration::from_millis(i * 10)))
.fold(f32::MIN, f32::max);
assert!(peak > 100.0, "the content must overshoot, peaked at {peak}");
let rest = sim.position(Duration::from_secs(3));
assert!(
(rest - 100.0).abs() < 1.0,
"must come back to the bound, rested at {rest}"
);
assert!(sim.is_done(Duration::from_secs(3)));
}
#[test]
fn the_return_does_not_oscillate_back_past_the_bound() {
let sim = BouncingSimulation::within(150.0, 0.0, 0.0, 100.0);
for i in 0..300 {
let p = sim.position(Duration::from_millis(i * 10));
assert!(
p >= 100.0 - SETTLE_DISTANCE_TOLERANCE,
"undershot to {p} at {} ms",
i * 10
);
}
}
#[test]
fn a_spring_can_rest_somewhere_other_than_the_crossed_bound() {
let sim = BouncingSimulation::new(150.0, 0.0, 0.0, 100.0, 0.0, 120.0);
let rest = sim.position(Duration::from_secs(3));
assert!(
(rest - 120.0).abs() < 0.5,
"expected the snap target 120, rested at {rest}"
);
}
#[test]
fn the_rubber_band_matches_its_closed_form() {
let y = rubber_band(100.0, 400.0);
assert!((y - 46.0177).abs() < 1e-3, "got {y}, expected 46.0177");
assert_eq!(rubber_band(-100.0, 400.0), -y, "the curve is odd");
}
#[test]
fn the_initial_gain_is_the_flutter_friction_factor() {
let extent = 800.0;
let gain = rubber_band(0.01, extent) / 0.01;
assert!(
(gain - 0.52).abs() < 1e-3,
"d(damped)/d(offset) at 0 = {gain}, expected 0.52"
);
}
#[test]
fn the_gain_everywhere_is_the_flutter_friction_factor_of_the_current_fraction() {
let extent = 500.0;
for raw in [10.0f32, 60.0, 150.0, 400.0, 900.0] {
let h = 0.5;
let gain = (rubber_band(raw + h, extent) - rubber_band(raw, extent)) / h;
let fraction = rubber_band(raw + h / 2.0, extent) / extent;
let expected = 0.52 * (1.0 - fraction).powi(2);
assert!(
(gain - expected).abs() < 1e-3,
"at raw={raw}: measured gain {gain}, frictionFactor {expected}"
);
}
}
#[test]
fn the_rubber_band_is_the_identity_at_zero_and_never_reaches_the_extent() {
assert_eq!(rubber_band(0.0, 400.0), 0.0);
for raw in [1.0f32, 100.0, 10_000.0, 1.0e9] {
let y = rubber_band(raw, 400.0);
assert!(y < 400.0, "raw={raw} produced {y}, at or past the extent");
assert!(y > 0.0);
}
}
#[test]
fn the_rubber_band_refuses_degenerate_inputs_without_producing_nan() {
for (offset, extent) in [
(f32::NAN, 400.0f32),
(100.0, f32::NAN),
(100.0, 0.0),
(100.0, -5.0),
(100.0, f32::INFINITY),
] {
let y = rubber_band(offset, extent);
assert_eq!(y, 0.0, "rubber_band({offset}, {extent}) = {y}");
}
assert_eq!(rubber_band(f32::INFINITY, 400.0), 400.0);
assert_eq!(rubber_band(f32::NEG_INFINITY, 400.0), -400.0);
}
#[test]
fn the_rubber_band_inverse_undoes_the_rubber_band() {
let extent = 640.0;
for raw in [0.0f32, 3.0, 55.0, 200.0, 1200.0] {
let back = rubber_band_inverse(rubber_band(raw, extent), extent);
assert!(
(back - raw).abs() <= 1e-2 * raw.max(1.0),
"raw={raw} round-tripped to {back}"
);
}
assert_eq!(rubber_band_inverse(0.0, extent), 0.0);
assert!(rubber_band_inverse(extent, extent).is_finite());
assert!(rubber_band_inverse(extent * 10.0, extent).is_finite());
}
#[test]
fn the_documented_physics_constants_are_the_shipped_ones() {
use crate::kinetic::doc_table::{assert_value, rows};
const PAGE: &str = "kinetic-scrolling.md";
let t = teksilo_tokens::ScrollPhysicsTokens::DEFAULT;
let mut seen = Vec::new();
for row in rows(PAGE, "### Clamping physics") {
let key = row[0].trim_matches('`').to_string();
let cell = &row[1];
match key.as_str() {
"clamping_deceleration_rate" => {
assert_value(PAGE, cell, t.clamping_deceleration_rate as f64, &key)
}
"clamping_inflexion" => assert_value(PAGE, cell, t.clamping_inflexion as f64, &key),
"clamping_friction" => assert_value(PAGE, cell, t.clamping_friction as f64, &key),
"START_TENSION" => assert_value(PAGE, cell, START_TENSION, &key),
"END_TENSION" => assert_value(PAGE, cell, END_TENSION, &key),
"NB_SAMPLES" => assert_value(PAGE, cell, NB_SAMPLES as f64, &key),
"gravity" => assert_value(PAGE, cell, GRAVITY_EARTH, &key),
"inches per metre" => assert_value(PAGE, cell, INCHES_PER_METER, &key),
"pixels per inch" => assert_value(PAGE, cell, PPI_AT_DENSITY_ONE, &key),
"tuning factor" => assert_value(PAGE, cell, PHYSICAL_TUNING, &key),
other => panic!("an unchecked clamping row {other:?}"),
}
seen.push(key);
}
assert_eq!(seen.len(), 10, "the clamping table lost a row: {seen:?}");
seen.clear();
for row in rows(PAGE, "### Bouncing physics") {
let key = row[0].trim_matches('`').to_string();
let cell = &row[1];
match key.as_str() {
"bouncing_decay_per_second" => {
assert_value(PAGE, cell, t.bouncing_decay_per_second as f64, &key)
}
"spring_mass" => assert_value(PAGE, cell, t.spring_mass as f64, &key),
"spring_stiffness" => assert_value(PAGE, cell, t.spring_stiffness as f64, &key),
"spring_damping_ratio" => {
assert_value(PAGE, cell, t.spring_damping_ratio as f64, &key)
}
"maxSpringTransferVelocity" => {
assert_value(PAGE, cell, MAX_SPRING_TRANSFER_VELOCITY as f64, &key)
}
"rubber_band_factor" => assert_value(PAGE, cell, t.rubber_band_factor as f64, &key),
other => panic!("an unchecked bouncing row {other:?}"),
}
seen.push(key);
}
assert_eq!(seen.len(), 6, "the bouncing table lost a row: {seen:?}");
seen.clear();
for row in rows(PAGE, "### Settle tolerances") {
let key = row[0].trim_matches('`').to_string();
let cell = &row[1];
match key.as_str() {
"SETTLE_DISTANCE_TOLERANCE" => {
assert_value(PAGE, cell, SETTLE_DISTANCE_TOLERANCE as f64, &key)
}
"SETTLE_VELOCITY_TOLERANCE" => {
assert_value(PAGE, cell, SETTLE_VELOCITY_TOLERANCE as f64, &key)
}
"FLING_FRAME_INTERVAL" => assert_value(
PAGE,
cell,
crate::kinetic::scroller::FLING_FRAME_INTERVAL.as_micros() as f64,
&key,
),
other => panic!("an unchecked settle row {other:?}"),
}
seen.push(key);
}
assert_eq!(seen.len(), 3, "the settle table lost a row: {seen:?}");
seen.clear();
for row in rows(PAGE, "### Fling velocity gates") {
let key = row[0].trim_matches('`').to_string();
let cell = &row[1];
for profile in [
teksilo_tokens::GestureProfile::MOUSE,
teksilo_tokens::GestureProfile::TOUCH,
teksilo_tokens::GestureProfile::PEN,
] {
match key.as_str() {
"min_fling_velocity" => {
assert_value(PAGE, cell, profile.min_fling_velocity as f64, &key)
}
"max_fling_velocity" => {
assert_value(PAGE, cell, profile.max_fling_velocity as f64, &key)
}
other => panic!("an unchecked fling-gate row {other:?}"),
}
}
seen.push(key);
}
assert_eq!(seen.len(), 2, "the fling-gate table lost a row: {seen:?}");
}
}