use super::{Simulation, SpringDescription, Tolerance};
const DEFAULT_DISTANCE_TOLERANCE: f64 = 1e-3;
fn signum_or_zero(value: f64) -> f64 {
if value == 0.0 { 0.0 } else { value.signum() }
}
#[derive(Debug, Clone)]
pub struct FrictionSimulation {
drag: f64,
drag_log: f64,
position: f64,
velocity: f64,
constant_deceleration: f64,
final_time: f64,
tolerance: Tolerance,
}
impl FrictionSimulation {
pub fn new(
drag: f64,
position: f64,
velocity: f64,
tolerance: Tolerance,
constant_deceleration: f64,
) -> Self {
let mut simulation = FrictionSimulation {
drag,
drag_log: drag.ln(),
position,
velocity,
constant_deceleration: constant_deceleration * signum_or_zero(velocity),
final_time: f64::INFINITY,
tolerance,
};
simulation.final_time = simulation.solve_final_time();
simulation
}
pub fn through(start_x: f64, end_x: f64, start_velocity: f64, end_velocity: f64) -> Self {
let drag = ((start_velocity - end_velocity) / (start_x - end_x)).exp();
FrictionSimulation::new(
drag,
start_x,
start_velocity,
Tolerance {
velocity: end_velocity.abs(),
distance: DEFAULT_DISTANCE_TOLERANCE,
},
0.0,
)
}
pub fn final_x(&self) -> f64 {
if self.constant_deceleration == 0.0 {
self.position - self.velocity / self.drag_log
} else {
self.position_at(self.final_time)
}
}
pub fn time_at_x(&self, x: f64) -> f64 {
if x == self.position {
return 0.0;
}
let final_x = self.final_x();
let out_of_reach = if self.velocity > 0.0 {
x < self.position || x > final_x
} else {
x > self.position || x < final_x
};
if self.velocity == 0.0 || out_of_reach {
return f64::INFINITY;
}
((x - self.position + self.velocity / self.drag_log) * self.drag_log / self.velocity).ln()
/ self.drag_log
}
fn position_at(&self, time: f64) -> f64 {
let drag_term = self.position + self.velocity * self.drag.powf(time) / self.drag_log
- self.velocity / self.drag_log;
if self.constant_deceleration == 0.0 {
drag_term
} else {
drag_term - self.constant_deceleration / 2.0 * time * time
}
}
fn velocity_at(&self, time: f64) -> f64 {
let drag_term = self.velocity * self.drag.powf(time);
if self.constant_deceleration == 0.0 {
drag_term
} else {
drag_term - self.constant_deceleration * time
}
}
fn solve_final_time(&self) -> f64 {
if self.constant_deceleration == 0.0 {
return f64::INFINITY;
}
let toward = signum_or_zero(self.velocity);
let mut lower = 0.0;
let mut upper = self.velocity.abs() / self.constant_deceleration.abs();
if self.velocity_at(upper) * toward > 0.0 {
return f64::INFINITY;
}
for _ in 0..64 {
let middle = 0.5 * (lower + upper);
if self.velocity_at(middle) * toward > 0.0 {
lower = middle;
} else {
upper = middle;
}
}
upper
}
}
impl Simulation for FrictionSimulation {
fn x(&self, time: f64) -> f64 {
self.position_at(time.min(self.final_time))
}
fn dx(&self, time: f64) -> f64 {
if time > self.final_time {
0.0
} else {
self.velocity_at(time)
}
}
fn is_done(&self, time: f64) -> bool {
time >= self.final_time || self.dx(time).abs() < self.tolerance.velocity
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpringType {
Critical,
Overdamped,
Underdamped,
}
#[derive(Debug, Clone, Copy)]
enum SpringSolution {
Critical { r: f64, c1: f64, c2: f64 },
Overdamped { r1: f64, r2: f64, c1: f64, c2: f64 },
Underdamped { w: f64, r: f64, c1: f64, c2: f64 },
}
impl SpringSolution {
fn new(spring: SpringDescription, distance: f64, velocity: f64) -> Self {
let discriminant = spring.damping * spring.damping - 4.0 * spring.mass * spring.stiffness;
if discriminant == 0.0 {
let r = -spring.damping / (2.0 * spring.mass);
SpringSolution::Critical {
r,
c1: distance,
c2: velocity - r * distance,
}
} else if discriminant > 0.0 {
let root = discriminant.sqrt();
let r1 = (-spring.damping - root) / (2.0 * spring.mass);
let r2 = (-spring.damping + root) / (2.0 * spring.mass);
let c2 = (velocity - r1 * distance) / (r2 - r1);
SpringSolution::Overdamped {
r1,
r2,
c1: distance - c2,
c2,
}
} else {
let w = (4.0 * spring.mass * spring.stiffness - spring.damping * spring.damping).sqrt()
/ (2.0 * spring.mass);
let r = -spring.damping / (2.0 * spring.mass);
SpringSolution::Underdamped {
w,
r,
c1: distance,
c2: (velocity - r * distance) / w,
}
}
}
fn spring_type(&self) -> SpringType {
match self {
SpringSolution::Critical { .. } => SpringType::Critical,
SpringSolution::Overdamped { .. } => SpringType::Overdamped,
SpringSolution::Underdamped { .. } => SpringType::Underdamped,
}
}
fn x(&self, time: f64) -> f64 {
match *self {
SpringSolution::Critical { r, c1, c2 } => (c1 + c2 * time) * (r * time).exp(),
SpringSolution::Overdamped { r1, r2, c1, c2 } => {
c1 * (r1 * time).exp() + c2 * (r2 * time).exp()
}
SpringSolution::Underdamped { w, r, c1, c2 } => {
(r * time).exp() * (c1 * (w * time).cos() + c2 * (w * time).sin())
}
}
}
fn dx(&self, time: f64) -> f64 {
match *self {
SpringSolution::Critical { r, c1, c2 } => {
let decay = (r * time).exp();
r * (c1 + c2 * time) * decay + c2 * decay
}
SpringSolution::Overdamped { r1, r2, c1, c2 } => {
c1 * r1 * (r1 * time).exp() + c2 * r2 * (r2 * time).exp()
}
SpringSolution::Underdamped { w, r, c1, c2 } => {
let decay = (r * time).exp();
let cosine = (w * time).cos();
let sine = (w * time).sin();
decay * (c2 * w * cosine - c1 * w * sine) + r * decay * (c2 * sine + c1 * cosine)
}
}
}
}
#[derive(Debug, Clone)]
pub struct SpringSimulation {
end: f64,
solution: SpringSolution,
tolerance: Tolerance,
}
impl SpringSimulation {
pub fn new(
spring: SpringDescription,
start: f64,
end: f64,
velocity: f64,
tolerance: Tolerance,
) -> Self {
SpringSimulation {
end,
solution: SpringSolution::new(spring, start - end, velocity),
tolerance,
}
}
pub fn end(&self) -> f64 {
self.end
}
pub fn spring_type(&self) -> SpringType {
self.solution.spring_type()
}
}
impl Simulation for SpringSimulation {
fn x(&self, time: f64) -> f64 {
self.end + self.solution.x(time)
}
fn dx(&self, time: f64) -> f64 {
self.solution.dx(time)
}
fn is_done(&self, time: f64) -> bool {
self.solution.x(time).abs() < self.tolerance.distance
&& self.solution.dx(time).abs() < self.tolerance.velocity
}
}
#[derive(Debug, Clone)]
pub struct ScrollSpringSimulation {
inner: SpringSimulation,
}
impl ScrollSpringSimulation {
pub fn new(
spring: SpringDescription,
start: f64,
end: f64,
velocity: f64,
tolerance: Tolerance,
) -> Self {
ScrollSpringSimulation {
inner: SpringSimulation::new(spring, start, end, velocity, tolerance),
}
}
pub fn end(&self) -> f64 {
self.inner.end()
}
pub fn spring_type(&self) -> SpringType {
self.inner.spring_type()
}
}
impl Simulation for ScrollSpringSimulation {
fn x(&self, time: f64) -> f64 {
if self.inner.is_done(time) {
self.inner.end()
} else {
self.inner.x(time)
}
}
fn dx(&self, time: f64) -> f64 {
self.inner.dx(time)
}
fn is_done(&self, time: f64) -> bool {
self.inner.is_done(time)
}
}
#[derive(Debug, Clone)]
pub struct ClampingScrollSimulation {
position: f64,
velocity: f64,
duration: f64,
distance: f64,
tolerance: Tolerance,
}
impl ClampingScrollSimulation {
pub const DEFAULT_FRICTION: f64 = 0.015;
pub const INFLEXION: f64 = 0.35;
pub const PHYSICAL_COEFF: f64 = 9.80665 * 39.37 * 160.0 * 0.84;
#[inline]
pub fn deceleration_rate() -> f64 {
0.78_f64.ln() / 0.9_f64.ln()
}
pub fn new(position: f64, velocity: f64, friction: f64, tolerance: Tolerance) -> Self {
let deceleration =
(Self::INFLEXION * velocity.abs() / (friction * Self::PHYSICAL_COEFF)).ln();
let duration = (deceleration / (Self::deceleration_rate() - 1.0)).exp();
ClampingScrollSimulation {
position,
velocity,
duration,
distance: velocity * duration / Self::deceleration_rate(),
tolerance,
}
}
pub fn duration(&self) -> f64 {
self.duration
}
pub fn final_x(&self) -> f64 {
self.position + self.distance
}
fn progress(&self, time: f64) -> f64 {
if self.duration > 0.0 {
(time / self.duration).clamp(0.0, 1.0)
} else {
1.0
}
}
}
impl Simulation for ClampingScrollSimulation {
fn x(&self, time: f64) -> f64 {
let remaining = 1.0 - self.progress(time);
self.position + self.distance * (1.0 - remaining.powf(Self::deceleration_rate()))
}
fn dx(&self, time: f64) -> f64 {
let remaining = 1.0 - self.progress(time);
self.velocity * remaining.powf(Self::deceleration_rate() - 1.0)
}
fn is_done(&self, time: f64) -> bool {
time >= self.duration || self.dx(time).abs() < self.tolerance.velocity
}
}
#[derive(Debug, Clone)]
enum BouncingPhase {
Spring(ScrollSpringSimulation),
Friction(FrictionSimulation),
FrictionThenSpring {
friction: FrictionSimulation,
spring: ScrollSpringSimulation,
spring_time: f64,
},
}
#[derive(Debug, Clone)]
pub struct BouncingScrollSimulation {
phase: BouncingPhase,
}
impl BouncingScrollSimulation {
pub const MAX_SPRING_TRANSFER_VELOCITY: f64 = 5000.0;
pub const FRICTION_DRAG: f64 = 0.135;
pub fn new(
position: f64,
velocity: f64,
leading_extent: f64,
trailing_extent: f64,
spring: SpringDescription,
tolerance: Tolerance,
constant_deceleration: f64,
) -> Self {
debug_assert!(
leading_extent <= trailing_extent,
"leading extent {leading_extent} must not exceed trailing extent {trailing_extent}"
);
let phase = if position < leading_extent {
BouncingPhase::Spring(Self::edge_spring(
spring,
position,
leading_extent,
velocity,
tolerance,
))
} else if position > trailing_extent {
BouncingPhase::Spring(Self::edge_spring(
spring,
position,
trailing_extent,
velocity,
tolerance,
))
} else {
let friction = FrictionSimulation::new(
Self::FRICTION_DRAG,
position,
velocity,
tolerance,
constant_deceleration,
);
let heading_for = if velocity > 0.0 {
trailing_extent
} else if velocity < 0.0 {
leading_extent
} else {
return BouncingScrollSimulation {
phase: BouncingPhase::Friction(friction),
};
};
let spring_time = friction.time_at_x(heading_for);
if spring_time.is_finite() {
let transfer = friction.dx(spring_time);
BouncingPhase::FrictionThenSpring {
spring: Self::edge_spring(
spring,
heading_for,
heading_for,
transfer,
tolerance,
),
friction,
spring_time,
}
} else {
BouncingPhase::Friction(friction)
}
};
BouncingScrollSimulation { phase }
}
fn edge_spring(
spring: SpringDescription,
start: f64,
extent: f64,
velocity: f64,
tolerance: Tolerance,
) -> ScrollSpringSimulation {
ScrollSpringSimulation::new(
spring,
start,
extent,
velocity.clamp(
-Self::MAX_SPRING_TRANSFER_VELOCITY,
Self::MAX_SPRING_TRANSFER_VELOCITY,
),
tolerance,
)
}
fn active(&self, time: f64) -> (&dyn Simulation, f64) {
match &self.phase {
BouncingPhase::Spring(spring) => (spring, 0.0),
BouncingPhase::Friction(friction) => (friction, 0.0),
BouncingPhase::FrictionThenSpring {
friction,
spring,
spring_time,
} => {
if time >= *spring_time {
(spring, *spring_time)
} else {
(friction, 0.0)
}
}
}
}
}
impl Simulation for BouncingScrollSimulation {
fn x(&self, time: f64) -> f64 {
let (simulation, offset) = self.active(time);
simulation.x(time - offset)
}
fn dx(&self, time: f64) -> f64 {
let (simulation, offset) = self.active(time);
simulation.dx(time - offset)
}
fn is_done(&self, time: f64) -> bool {
let (simulation, offset) = self.active(time);
simulation.is_done(time - offset)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tol() -> Tolerance {
Tolerance::for_device_pixel_ratio(1.0)
}
fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
assert!(
(actual - expected).abs() < epsilon,
"{what}: {actual} is not within {epsilon} of {expected}"
);
}
#[test]
fn friction_final_x_matches_closed_form() {
let simulation = FrictionSimulation::new(0.135, 20.0, 1000.0, tol(), 0.0);
let expected = 20.0 - 1000.0 / 0.135_f64.ln();
assert_close(simulation.final_x(), expected, 1e-9, "final_x");
assert_close(simulation.x(f64::INFINITY), expected, 1e-9, "x at infinity");
assert_close(simulation.x(20.0), expected, 1e-6, "x long after release");
assert!(!simulation.is_done(0.0), "a live fling reported settled");
assert!(simulation.is_done(20.0), "a decayed fling never settled");
}
#[test]
fn friction_through_passes_endpoints() {
let simulation = FrictionSimulation::through(0.0, 100.0, 500.0, 100.0);
let arrival = simulation.time_at_x(100.0);
assert!(arrival.is_finite(), "end position unreachable at {arrival}");
assert_close(simulation.x(0.0), 0.0, 1e-9, "start position");
assert_close(simulation.dx(0.0), 500.0, 1e-9, "start velocity");
assert_close(simulation.x(arrival), 100.0, 1e-9, "end position");
assert_close(simulation.dx(arrival), 100.0, 1e-9, "end velocity");
let asymptotic = FrictionSimulation::through(0.0, 100.0, 500.0, 0.0);
assert_close(asymptotic.final_x(), 100.0, 1e-9, "asymptotic final_x");
assert!(
asymptotic.time_at_x(200.0).is_infinite(),
"a position past final_x must be unreachable"
);
assert!(
asymptotic.time_at_x(-10.0).is_infinite(),
"a position behind the start must be unreachable"
);
}
#[test]
fn friction_velocity_decays_exponentially() {
let simulation = FrictionSimulation::new(0.135, 0.0, 800.0, tol(), 0.0);
let ratio = simulation.dx(0.5) / simulation.dx(0.0);
assert_close(ratio, 0.135_f64.powf(0.5), 1e-9, "half-second decay ratio");
}
#[test]
fn friction_constant_deceleration_ends_the_motion() {
let plain = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 0.0);
let decelerated = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 3000.0);
assert!(
decelerated.final_x() < plain.final_x(),
"constant deceleration travelled {}, no further than pure drag's {}",
decelerated.final_x(),
plain.final_x()
);
assert!(decelerated.is_done(2.0), "the motion never ended");
assert_eq!(decelerated.dx(5.0), 0.0, "velocity past the end");
assert_close(
decelerated.x(5.0),
decelerated.final_x(),
1e-9,
"position past the end",
);
}
#[test]
fn spring_critical_over_under_damped_all_converge_to_end() {
let cases = [
(20.0, SpringType::Critical),
(30.0, SpringType::Overdamped),
(5.0, SpringType::Underdamped),
];
for (damping, expected_type) in cases {
let spring = SpringDescription {
mass: 1.0,
stiffness: 100.0,
damping,
};
let simulation = SpringSimulation::new(spring, 100.0, 0.0, 0.0, tol());
assert_eq!(simulation.spring_type(), expected_type);
assert_close(simulation.x(0.0), 100.0, 1e-9, "start position");
assert_close(simulation.dx(0.0), 0.0, 1e-9, "start velocity");
assert_close(simulation.x(10.0), 0.0, 1e-6, "converged position");
assert!(
!simulation.is_done(0.0),
"{expected_type:?} spring reported settled at release"
);
assert!(
simulation.is_done(10.0),
"{expected_type:?} spring never settled"
);
}
}
#[test]
fn spring_with_damping_ratio_1_1_is_overdamped_no_oscillation() {
let simulation = SpringSimulation::new(
SpringDescription::default_scroll_spring(),
100.0,
0.0,
0.0,
tol(),
);
assert_eq!(simulation.spring_type(), SpringType::Overdamped);
for step in 0..=2000 {
let time = f64::from(step) / 1000.0;
let displacement = simulation.x(time) - simulation.end();
assert!(
displacement >= 0.0,
"crossed the end position at t={time}s (displacement {displacement})"
);
}
}
#[test]
fn scroll_spring_snaps_to_end_once_settled() {
let spring = SpringDescription::default_scroll_spring();
let plain = SpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
let snapping = ScrollSpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
assert_eq!(snapping.x(0.05), plain.x(0.05));
assert_eq!(snapping.dx(0.05), plain.dx(0.05));
assert!(snapping.is_done(3.0));
assert_ne!(plain.x(3.0), 0.0);
assert_eq!(snapping.x(3.0), snapping.end());
}
#[test]
fn clamping_deceleration_rate_value() {
assert_close(
ClampingScrollSimulation::deceleration_rate(),
2.358_202,
1e-5,
"DECELERATION_RATE",
);
assert_close(
ClampingScrollSimulation::PHYSICAL_COEFF,
51_890.2,
0.1,
"PHYSICAL_COEFF",
);
assert_eq!(ClampingScrollSimulation::INFLEXION, 0.35);
assert_eq!(ClampingScrollSimulation::DEFAULT_FRICTION, 0.015);
}
#[test]
fn clamping_dx_at_zero_equals_initial_velocity() {
for velocity in [2000.0, -2000.0, 350.0] {
let simulation = ClampingScrollSimulation::new(
0.0,
velocity,
ClampingScrollSimulation::DEFAULT_FRICTION,
tol(),
);
assert_close(simulation.dx(0.0), velocity, 1e-9, "dx at release");
}
}
#[test]
fn clamping_stops_at_duration() {
let simulation = ClampingScrollSimulation::new(
10.0,
2000.0,
ClampingScrollSimulation::DEFAULT_FRICTION,
tol(),
);
let duration = simulation.duration();
let target = simulation.final_x();
assert!(
duration > 0.0 && duration.is_finite(),
"implausible duration {duration}"
);
assert!(target > 10.0, "a positive fling must travel forward");
assert!(!simulation.is_done(0.0), "a live fling reported settled");
assert!(simulation.is_done(duration), "the fling never stopped");
assert_close(simulation.x(duration), target, 1e-9, "position at duration");
assert_close(
simulation.x(duration * 2.0),
target,
1e-9,
"position past duration",
);
assert_close(simulation.dx(duration), 0.0, 1e-9, "velocity at duration");
let mut previous = simulation.x(0.0);
for step in 1..=100 {
let position = simulation.x(duration * f64::from(step) / 100.0);
assert!(position >= previous, "backtracked to {position}");
assert!(position <= target + 1e-9, "overshot the target: {position}");
previous = position;
}
}
#[test]
fn bouncing_switches_friction_to_spring_at_extent() {
let trailing = 100.0;
let simulation = BouncingScrollSimulation::new(
0.0,
2000.0,
0.0,
trailing,
SpringDescription::default_scroll_spring(),
tol(),
0.0,
);
let friction = FrictionSimulation::new(
BouncingScrollSimulation::FRICTION_DRAG,
0.0,
2000.0,
tol(),
0.0,
);
let handover = friction.time_at_x(trailing);
assert!(handover.is_finite(), "the fling must reach the extent");
let early = handover / 2.0;
assert_eq!(simulation.x(early), friction.x(early));
assert_eq!(simulation.dx(early), friction.dx(early));
assert_close(
simulation.x(handover),
trailing,
1e-9,
"position at handover",
);
assert_close(
simulation.dx(handover),
friction.dx(handover),
1e-9,
"velocity at handover",
);
let mut furthest = f64::MIN;
for step in 0..=500 {
furthest = furthest.max(simulation.x(handover + f64::from(step) / 100.0));
}
assert!(
furthest > trailing,
"the bounce never passed the extent (peaked at {furthest})"
);
assert!(
furthest < trailing + 60.0,
"the bounce ran away past the extent (peaked at {furthest})"
);
assert_close(
simulation.x(handover + 5.0),
trailing,
1e-9,
"settled position",
);
assert!(
simulation.is_done(handover + 5.0),
"the bounce never settled"
);
}
#[test]
fn bouncing_stays_friction_when_no_extent_is_reached() {
let simulation = BouncingScrollSimulation::new(
0.0,
100.0,
0.0,
10_000.0,
SpringDescription::default_scroll_spring(),
tol(),
0.0,
);
let friction = FrictionSimulation::new(
BouncingScrollSimulation::FRICTION_DRAG,
0.0,
100.0,
tol(),
0.0,
);
assert!(
friction.time_at_x(10_000.0).is_infinite(),
"this fling must stop short of the extent for the test to mean anything"
);
for step in 0..=100 {
let time = f64::from(step) / 10.0;
assert_eq!(simulation.x(time), friction.x(time));
assert_eq!(simulation.dx(time), friction.dx(time));
}
}
#[test]
fn bouncing_underscroll_starts_as_spring() {
let leading = 0.0;
let simulation = BouncingScrollSimulation::new(
-50.0,
0.0,
leading,
100.0,
SpringDescription::default_scroll_spring(),
tol(),
0.0,
);
assert_close(simulation.x(0.0), -50.0, 1e-9, "starts where released");
assert!(
simulation.x(0.1) > simulation.x(0.0),
"must travel back toward the leading extent"
);
assert!(!simulation.is_done(0.0), "an overscrolled rest is not done");
assert_close(simulation.x(3.0), leading, 1e-9, "settled position");
assert!(simulation.is_done(3.0), "the bounce-back never settled");
}
#[test]
fn bouncing_spring_transfer_velocity_capped() {
let spring = SpringDescription::default_scroll_spring();
let cap = BouncingScrollSimulation::MAX_SPRING_TRANSFER_VELOCITY;
let underscroll =
BouncingScrollSimulation::new(-50.0, -20_000.0, 0.0, 100.0, spring, tol(), 0.0);
assert_close(underscroll.dx(0.0), -cap, 1e-9, "clamped underscroll seed");
let fling = BouncingScrollSimulation::new(0.0, 50_000.0, 0.0, 100.0, spring, tol(), 0.0);
let friction = FrictionSimulation::new(
BouncingScrollSimulation::FRICTION_DRAG,
0.0,
50_000.0,
tol(),
0.0,
);
let handover = friction.time_at_x(100.0);
assert!(
friction.dx(handover) > cap,
"the fling must arrive above the cap for the test to mean anything"
);
assert_close(fling.dx(handover), cap, 1e-9, "clamped handover seed");
}
}