1use frust_core::Curve;
27
28use super::{Simulation, SpringDescription, Tolerance};
29
30const DEFAULT_DISTANCE_TOLERANCE: f64 = 1e-3;
35
36fn signum_or_zero(value: f64) -> f64 {
39 if value == 0.0 { 0.0 } else { value.signum() }
40}
41
42#[derive(Debug, Clone)]
51pub struct FrictionSimulation {
52 drag: f64,
53 drag_log: f64,
54 position: f64,
55 velocity: f64,
56 constant_deceleration: f64,
59 final_time: f64,
62 tolerance: Tolerance,
63}
64
65impl FrictionSimulation {
66 pub fn new(
71 drag: f64,
72 position: f64,
73 velocity: f64,
74 tolerance: Tolerance,
75 constant_deceleration: f64,
76 ) -> Self {
77 let mut simulation = FrictionSimulation {
78 drag,
79 drag_log: drag.ln(),
80 position,
81 velocity,
82 constant_deceleration: constant_deceleration * signum_or_zero(velocity),
83 final_time: f64::INFINITY,
84 tolerance,
85 };
86 simulation.final_time = simulation.solve_final_time();
87 simulation
88 }
89
90 pub fn through(start_x: f64, end_x: f64, start_velocity: f64, end_velocity: f64) -> Self {
96 let drag = ((start_velocity - end_velocity) / (start_x - end_x)).exp();
97 FrictionSimulation::new(
98 drag,
99 start_x,
100 start_velocity,
101 Tolerance {
102 velocity: end_velocity.abs(),
103 distance: DEFAULT_DISTANCE_TOLERANCE,
104 },
105 0.0,
106 )
107 }
108
109 pub fn final_x(&self) -> f64 {
112 if self.constant_deceleration == 0.0 {
113 self.position - self.velocity / self.drag_log
114 } else {
115 self.position_at(self.final_time)
116 }
117 }
118
119 pub fn time_at_x(&self, x: f64) -> f64 {
127 if x == self.position {
128 return 0.0;
129 }
130 let final_x = self.final_x();
131 let out_of_reach = if self.velocity > 0.0 {
132 x < self.position || x > final_x
133 } else {
134 x > self.position || x < final_x
135 };
136 if self.velocity == 0.0 || out_of_reach {
137 return f64::INFINITY;
138 }
139 ((x - self.position + self.velocity / self.drag_log) * self.drag_log / self.velocity).ln()
140 / self.drag_log
141 }
142
143 fn position_at(&self, time: f64) -> f64 {
145 let drag_term = self.position + self.velocity * self.drag.powf(time) / self.drag_log
146 - self.velocity / self.drag_log;
147 if self.constant_deceleration == 0.0 {
148 drag_term
151 } else {
152 drag_term - self.constant_deceleration / 2.0 * time * time
153 }
154 }
155
156 fn velocity_at(&self, time: f64) -> f64 {
158 let drag_term = self.velocity * self.drag.powf(time);
159 if self.constant_deceleration == 0.0 {
160 drag_term
161 } else {
162 drag_term - self.constant_deceleration * time
163 }
164 }
165
166 fn solve_final_time(&self) -> f64 {
172 if self.constant_deceleration == 0.0 {
173 return f64::INFINITY;
174 }
175 let toward = signum_or_zero(self.velocity);
176 let mut lower = 0.0;
177 let mut upper = self.velocity.abs() / self.constant_deceleration.abs();
178 if self.velocity_at(upper) * toward > 0.0 {
179 return f64::INFINITY;
180 }
181 for _ in 0..64 {
182 let middle = 0.5 * (lower + upper);
183 if self.velocity_at(middle) * toward > 0.0 {
184 lower = middle;
185 } else {
186 upper = middle;
187 }
188 }
189 upper
190 }
191}
192
193impl Simulation for FrictionSimulation {
194 fn x(&self, time: f64) -> f64 {
195 self.position_at(time.min(self.final_time))
196 }
197
198 fn dx(&self, time: f64) -> f64 {
199 if time > self.final_time {
200 0.0
201 } else {
202 self.velocity_at(time)
203 }
204 }
205
206 fn is_done(&self, time: f64) -> bool {
211 time >= self.final_time || self.dx(time).abs() < self.tolerance.velocity
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SpringType {
219 Critical,
222 Overdamped,
224 Underdamped,
227}
228
229#[derive(Debug, Clone, Copy)]
232enum SpringSolution {
233 Critical { r: f64, c1: f64, c2: f64 },
234 Overdamped { r1: f64, r2: f64, c1: f64, c2: f64 },
235 Underdamped { w: f64, r: f64, c1: f64, c2: f64 },
236}
237
238impl SpringSolution {
239 fn new(spring: SpringDescription, distance: f64, velocity: f64) -> Self {
240 let discriminant = spring.damping * spring.damping - 4.0 * spring.mass * spring.stiffness;
241 if discriminant == 0.0 {
242 let r = -spring.damping / (2.0 * spring.mass);
243 SpringSolution::Critical {
244 r,
245 c1: distance,
246 c2: velocity - r * distance,
247 }
248 } else if discriminant > 0.0 {
249 let root = discriminant.sqrt();
250 let r1 = (-spring.damping - root) / (2.0 * spring.mass);
251 let r2 = (-spring.damping + root) / (2.0 * spring.mass);
252 let c2 = (velocity - r1 * distance) / (r2 - r1);
253 SpringSolution::Overdamped {
254 r1,
255 r2,
256 c1: distance - c2,
257 c2,
258 }
259 } else {
260 let w = (4.0 * spring.mass * spring.stiffness - spring.damping * spring.damping).sqrt()
261 / (2.0 * spring.mass);
262 let r = -spring.damping / (2.0 * spring.mass);
264 SpringSolution::Underdamped {
265 w,
266 r,
267 c1: distance,
268 c2: (velocity - r * distance) / w,
269 }
270 }
271 }
272
273 fn spring_type(&self) -> SpringType {
274 match self {
275 SpringSolution::Critical { .. } => SpringType::Critical,
276 SpringSolution::Overdamped { .. } => SpringType::Overdamped,
277 SpringSolution::Underdamped { .. } => SpringType::Underdamped,
278 }
279 }
280
281 fn x(&self, time: f64) -> f64 {
283 match *self {
284 SpringSolution::Critical { r, c1, c2 } => (c1 + c2 * time) * (r * time).exp(),
285 SpringSolution::Overdamped { r1, r2, c1, c2 } => {
286 c1 * (r1 * time).exp() + c2 * (r2 * time).exp()
287 }
288 SpringSolution::Underdamped { w, r, c1, c2 } => {
289 (r * time).exp() * (c1 * (w * time).cos() + c2 * (w * time).sin())
290 }
291 }
292 }
293
294 fn dx(&self, time: f64) -> f64 {
295 match *self {
296 SpringSolution::Critical { r, c1, c2 } => {
297 let decay = (r * time).exp();
298 r * (c1 + c2 * time) * decay + c2 * decay
299 }
300 SpringSolution::Overdamped { r1, r2, c1, c2 } => {
301 c1 * r1 * (r1 * time).exp() + c2 * r2 * (r2 * time).exp()
302 }
303 SpringSolution::Underdamped { w, r, c1, c2 } => {
304 let decay = (r * time).exp();
305 let cosine = (w * time).cos();
306 let sine = (w * time).sin();
307 decay * (c2 * w * cosine - c1 * w * sine) + r * decay * (c2 * sine + c1 * cosine)
308 }
309 }
310 }
311}
312
313#[derive(Debug, Clone)]
316pub struct SpringSimulation {
317 end: f64,
318 solution: SpringSolution,
319 tolerance: Tolerance,
320}
321
322impl SpringSimulation {
323 pub fn new(
326 spring: SpringDescription,
327 start: f64,
328 end: f64,
329 velocity: f64,
330 tolerance: Tolerance,
331 ) -> Self {
332 SpringSimulation {
333 end,
334 solution: SpringSolution::new(spring, start - end, velocity),
335 tolerance,
336 }
337 }
338
339 pub fn end(&self) -> f64 {
341 self.end
342 }
343
344 pub fn spring_type(&self) -> SpringType {
346 self.solution.spring_type()
347 }
348}
349
350impl Simulation for SpringSimulation {
351 fn x(&self, time: f64) -> f64 {
352 self.end + self.solution.x(time)
353 }
354
355 fn dx(&self, time: f64) -> f64 {
356 self.solution.dx(time)
357 }
358
359 fn is_done(&self, time: f64) -> bool {
360 self.solution.x(time).abs() < self.tolerance.distance
361 && self.solution.dx(time).abs() < self.tolerance.velocity
362 }
363}
364
365#[derive(Debug, Clone)]
372pub struct ScrollSpringSimulation {
373 inner: SpringSimulation,
374}
375
376impl ScrollSpringSimulation {
377 pub fn new(
380 spring: SpringDescription,
381 start: f64,
382 end: f64,
383 velocity: f64,
384 tolerance: Tolerance,
385 ) -> Self {
386 ScrollSpringSimulation {
387 inner: SpringSimulation::new(spring, start, end, velocity, tolerance),
388 }
389 }
390
391 pub fn end(&self) -> f64 {
393 self.inner.end()
394 }
395
396 pub fn spring_type(&self) -> SpringType {
398 self.inner.spring_type()
399 }
400}
401
402impl Simulation for ScrollSpringSimulation {
403 fn x(&self, time: f64) -> f64 {
404 if self.inner.is_done(time) {
405 self.inner.end()
406 } else {
407 self.inner.x(time)
408 }
409 }
410
411 fn dx(&self, time: f64) -> f64 {
412 self.inner.dx(time)
413 }
414
415 fn is_done(&self, time: f64) -> bool {
416 self.inner.is_done(time)
417 }
418}
419
420#[derive(Debug, Clone)]
424pub struct ClampingScrollSimulation {
425 position: f64,
426 velocity: f64,
427 duration: f64,
429 distance: f64,
431 tolerance: Tolerance,
432}
433
434impl ClampingScrollSimulation {
435 pub const DEFAULT_FRICTION: f64 = 0.015;
437
438 pub const INFLEXION: f64 = 0.35;
442
443 pub const PHYSICAL_COEFF: f64 = 9.80665 * 39.37 * 160.0 * 0.84;
447
448 #[inline]
454 pub fn deceleration_rate() -> f64 {
455 0.78_f64.ln() / 0.9_f64.ln()
456 }
457
458 pub fn new(position: f64, velocity: f64, friction: f64, tolerance: Tolerance) -> Self {
461 let deceleration =
462 (Self::INFLEXION * velocity.abs() / (friction * Self::PHYSICAL_COEFF)).ln();
463 let duration = (deceleration / (Self::deceleration_rate() - 1.0)).exp();
464 ClampingScrollSimulation {
465 position,
466 velocity,
467 duration,
468 distance: velocity * duration / Self::deceleration_rate(),
469 tolerance,
470 }
471 }
472
473 pub fn duration(&self) -> f64 {
475 self.duration
476 }
477
478 pub fn final_x(&self) -> f64 {
480 self.position + self.distance
481 }
482
483 fn progress(&self, time: f64) -> f64 {
487 if self.duration > 0.0 {
488 (time / self.duration).clamp(0.0, 1.0)
489 } else {
490 1.0
491 }
492 }
493}
494
495impl Simulation for ClampingScrollSimulation {
496 fn x(&self, time: f64) -> f64 {
497 let remaining = 1.0 - self.progress(time);
498 self.position + self.distance * (1.0 - remaining.powf(Self::deceleration_rate()))
499 }
500
501 fn dx(&self, time: f64) -> f64 {
502 let remaining = 1.0 - self.progress(time);
503 self.velocity * remaining.powf(Self::deceleration_rate() - 1.0)
504 }
505
506 fn is_done(&self, time: f64) -> bool {
507 time >= self.duration || self.dx(time).abs() < self.tolerance.velocity
508 }
509}
510
511#[derive(Debug, Clone)]
514enum BouncingPhase {
515 Spring(ScrollSpringSimulation),
518 Friction(FrictionSimulation),
520 FrictionThenSpring {
523 friction: FrictionSimulation,
524 spring: ScrollSpringSimulation,
525 spring_time: f64,
526 },
527}
528
529#[derive(Debug, Clone)]
532pub struct BouncingScrollSimulation {
533 phase: BouncingPhase,
534}
535
536impl BouncingScrollSimulation {
537 pub const MAX_SPRING_TRANSFER_VELOCITY: f64 = 5000.0;
541
542 pub const FRICTION_DRAG: f64 = 0.135;
545
546 pub fn new(
550 position: f64,
551 velocity: f64,
552 leading_extent: f64,
553 trailing_extent: f64,
554 spring: SpringDescription,
555 tolerance: Tolerance,
556 constant_deceleration: f64,
557 ) -> Self {
558 debug_assert!(
559 leading_extent <= trailing_extent,
560 "leading extent {leading_extent} must not exceed trailing extent {trailing_extent}"
561 );
562 let phase = if position < leading_extent {
563 BouncingPhase::Spring(Self::edge_spring(
564 spring,
565 position,
566 leading_extent,
567 velocity,
568 tolerance,
569 ))
570 } else if position > trailing_extent {
571 BouncingPhase::Spring(Self::edge_spring(
572 spring,
573 position,
574 trailing_extent,
575 velocity,
576 tolerance,
577 ))
578 } else {
579 let friction = FrictionSimulation::new(
580 Self::FRICTION_DRAG,
581 position,
582 velocity,
583 tolerance,
584 constant_deceleration,
585 );
586 let heading_for = if velocity > 0.0 {
587 trailing_extent
588 } else if velocity < 0.0 {
589 leading_extent
590 } else {
591 return BouncingScrollSimulation {
593 phase: BouncingPhase::Friction(friction),
594 };
595 };
596 let spring_time = friction.time_at_x(heading_for);
598 if spring_time.is_finite() {
599 let transfer = friction.dx(spring_time);
600 BouncingPhase::FrictionThenSpring {
601 spring: Self::edge_spring(
602 spring,
603 heading_for,
604 heading_for,
605 transfer,
606 tolerance,
607 ),
608 friction,
609 spring_time,
610 }
611 } else {
612 BouncingPhase::Friction(friction)
613 }
614 };
615 BouncingScrollSimulation { phase }
616 }
617
618 fn edge_spring(
621 spring: SpringDescription,
622 start: f64,
623 extent: f64,
624 velocity: f64,
625 tolerance: Tolerance,
626 ) -> ScrollSpringSimulation {
627 ScrollSpringSimulation::new(
628 spring,
629 start,
630 extent,
631 velocity.clamp(
632 -Self::MAX_SPRING_TRANSFER_VELOCITY,
633 Self::MAX_SPRING_TRANSFER_VELOCITY,
634 ),
635 tolerance,
636 )
637 }
638
639 fn active(&self, time: f64) -> (&dyn Simulation, f64) {
643 match &self.phase {
644 BouncingPhase::Spring(spring) => (spring, 0.0),
645 BouncingPhase::Friction(friction) => (friction, 0.0),
646 BouncingPhase::FrictionThenSpring {
647 friction,
648 spring,
649 spring_time,
650 } => {
651 if time >= *spring_time {
652 (spring, *spring_time)
653 } else {
654 (friction, 0.0)
655 }
656 }
657 }
658 }
659}
660
661impl Simulation for BouncingScrollSimulation {
662 fn x(&self, time: f64) -> f64 {
663 let (simulation, offset) = self.active(time);
664 simulation.x(time - offset)
665 }
666
667 fn dx(&self, time: f64) -> f64 {
668 let (simulation, offset) = self.active(time);
669 simulation.dx(time - offset)
670 }
671
672 fn is_done(&self, time: f64) -> bool {
673 let (simulation, offset) = self.active(time);
674 simulation.is_done(time - offset)
675 }
676}
677
678#[derive(Debug, Clone, Copy)]
682pub struct TweenSimulation {
683 start: f64,
684 end: f64,
685 duration: f64,
689 curve: Curve,
690}
691
692impl TweenSimulation {
693 pub fn new(start: f64, end: f64, duration: f64, curve: Curve) -> Self {
701 Self {
702 start,
703 end,
704 duration: if duration.is_finite() {
705 duration.max(0.0)
706 } else {
707 0.0
708 },
709 curve,
710 }
711 }
712}
713
714impl Simulation for TweenSimulation {
715 fn x(&self, time: f64) -> f64 {
716 if self.duration <= 0.0 {
717 return self.end;
718 }
719 let t = (time / self.duration).clamp(0.0, 1.0);
720 self.start + (self.end - self.start) * self.curve.transform(t)
721 }
722
723 fn dx(&self, time: f64) -> f64 {
724 if self.duration <= 0.0 || time < 0.0 || time >= self.duration {
725 return 0.0;
726 }
727 const DT: f64 = 1e-4;
733 let clamped_time = time.min(self.duration - DT);
734 (self.x(clamped_time + DT) - self.x(clamped_time)) / DT
735 }
736
737 fn is_done(&self, time: f64) -> bool {
738 self.duration <= 0.0 || time >= self.duration
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 fn tol() -> Tolerance {
748 Tolerance::for_device_pixel_ratio(1.0)
749 }
750
751 fn assert_close(actual: f64, expected: f64, epsilon: f64, what: &str) {
752 assert!(
753 (actual - expected).abs() < epsilon,
754 "{what}: {actual} is not within {epsilon} of {expected}"
755 );
756 }
757
758 #[test]
759 fn friction_final_x_matches_closed_form() {
760 let simulation = FrictionSimulation::new(0.135, 20.0, 1000.0, tol(), 0.0);
761 let expected = 20.0 - 1000.0 / 0.135_f64.ln();
762 assert_close(simulation.final_x(), expected, 1e-9, "final_x");
763 assert_close(simulation.x(f64::INFINITY), expected, 1e-9, "x at infinity");
764 assert_close(simulation.x(20.0), expected, 1e-6, "x long after release");
765 assert!(!simulation.is_done(0.0), "a live fling reported settled");
766 assert!(simulation.is_done(20.0), "a decayed fling never settled");
767 }
768
769 #[test]
770 fn friction_through_passes_endpoints() {
771 let simulation = FrictionSimulation::through(0.0, 100.0, 500.0, 100.0);
772 let arrival = simulation.time_at_x(100.0);
773 assert!(arrival.is_finite(), "end position unreachable at {arrival}");
774 assert_close(simulation.x(0.0), 0.0, 1e-9, "start position");
775 assert_close(simulation.dx(0.0), 500.0, 1e-9, "start velocity");
776 assert_close(simulation.x(arrival), 100.0, 1e-9, "end position");
777 assert_close(simulation.dx(arrival), 100.0, 1e-9, "end velocity");
778
779 let asymptotic = FrictionSimulation::through(0.0, 100.0, 500.0, 0.0);
782 assert_close(asymptotic.final_x(), 100.0, 1e-9, "asymptotic final_x");
783 assert!(
784 asymptotic.time_at_x(200.0).is_infinite(),
785 "a position past final_x must be unreachable"
786 );
787 assert!(
788 asymptotic.time_at_x(-10.0).is_infinite(),
789 "a position behind the start must be unreachable"
790 );
791 }
792
793 #[test]
794 fn friction_velocity_decays_exponentially() {
795 let simulation = FrictionSimulation::new(0.135, 0.0, 800.0, tol(), 0.0);
796 let ratio = simulation.dx(0.5) / simulation.dx(0.0);
797 assert_close(ratio, 0.135_f64.powf(0.5), 1e-9, "half-second decay ratio");
798 }
799
800 #[test]
801 fn friction_constant_deceleration_ends_the_motion() {
802 let plain = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 0.0);
803 let decelerated = FrictionSimulation::new(0.135, 0.0, 1000.0, tol(), 3000.0);
804 assert!(
805 decelerated.final_x() < plain.final_x(),
806 "constant deceleration travelled {}, no further than pure drag's {}",
807 decelerated.final_x(),
808 plain.final_x()
809 );
810 assert!(decelerated.is_done(2.0), "the motion never ended");
811 assert_eq!(decelerated.dx(5.0), 0.0, "velocity past the end");
812 assert_close(
813 decelerated.x(5.0),
814 decelerated.final_x(),
815 1e-9,
816 "position past the end",
817 );
818 }
819
820 #[test]
821 fn spring_critical_over_under_damped_all_converge_to_end() {
822 let cases = [
825 (20.0, SpringType::Critical),
826 (30.0, SpringType::Overdamped),
827 (5.0, SpringType::Underdamped),
828 ];
829 for (damping, expected_type) in cases {
830 let spring = SpringDescription {
831 mass: 1.0,
832 stiffness: 100.0,
833 damping,
834 };
835 let simulation = SpringSimulation::new(spring, 100.0, 0.0, 0.0, tol());
836 assert_eq!(simulation.spring_type(), expected_type);
837 assert_close(simulation.x(0.0), 100.0, 1e-9, "start position");
838 assert_close(simulation.dx(0.0), 0.0, 1e-9, "start velocity");
839 assert_close(simulation.x(10.0), 0.0, 1e-6, "converged position");
840 assert!(
841 !simulation.is_done(0.0),
842 "{expected_type:?} spring reported settled at release"
843 );
844 assert!(
845 simulation.is_done(10.0),
846 "{expected_type:?} spring never settled"
847 );
848 }
849 }
850
851 #[test]
852 fn spring_with_damping_ratio_1_1_is_overdamped_no_oscillation() {
853 let simulation = SpringSimulation::new(
854 SpringDescription::default_scroll_spring(),
855 100.0,
856 0.0,
857 0.0,
858 tol(),
859 );
860 assert_eq!(simulation.spring_type(), SpringType::Overdamped);
861 for step in 0..=2000 {
862 let time = f64::from(step) / 1000.0;
863 let displacement = simulation.x(time) - simulation.end();
864 assert!(
865 displacement >= 0.0,
866 "crossed the end position at t={time}s (displacement {displacement})"
867 );
868 }
869 }
870
871 #[test]
872 fn scroll_spring_snaps_to_end_once_settled() {
873 let spring = SpringDescription::default_scroll_spring();
874 let plain = SpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
875 let snapping = ScrollSpringSimulation::new(spring, -50.0, 0.0, 0.0, tol());
876
877 assert_eq!(snapping.x(0.05), plain.x(0.05));
879 assert_eq!(snapping.dx(0.05), plain.dx(0.05));
880
881 assert!(snapping.is_done(3.0));
884 assert_ne!(plain.x(3.0), 0.0);
885 assert_eq!(snapping.x(3.0), snapping.end());
886 }
887
888 #[test]
889 fn clamping_deceleration_rate_value() {
890 assert_close(
891 ClampingScrollSimulation::deceleration_rate(),
892 2.358_202,
893 1e-5,
894 "DECELERATION_RATE",
895 );
896 assert_close(
897 ClampingScrollSimulation::PHYSICAL_COEFF,
898 51_890.2,
899 0.1,
900 "PHYSICAL_COEFF",
901 );
902 assert_eq!(ClampingScrollSimulation::INFLEXION, 0.35);
903 assert_eq!(ClampingScrollSimulation::DEFAULT_FRICTION, 0.015);
904 }
905
906 #[test]
907 fn clamping_dx_at_zero_equals_initial_velocity() {
908 for velocity in [2000.0, -2000.0, 350.0] {
909 let simulation = ClampingScrollSimulation::new(
910 0.0,
911 velocity,
912 ClampingScrollSimulation::DEFAULT_FRICTION,
913 tol(),
914 );
915 assert_close(simulation.dx(0.0), velocity, 1e-9, "dx at release");
916 }
917 }
918
919 #[test]
920 fn clamping_stops_at_duration() {
921 let simulation = ClampingScrollSimulation::new(
922 10.0,
923 2000.0,
924 ClampingScrollSimulation::DEFAULT_FRICTION,
925 tol(),
926 );
927 let duration = simulation.duration();
928 let target = simulation.final_x();
929 assert!(
930 duration > 0.0 && duration.is_finite(),
931 "implausible duration {duration}"
932 );
933 assert!(target > 10.0, "a positive fling must travel forward");
934 assert!(!simulation.is_done(0.0), "a live fling reported settled");
935 assert!(simulation.is_done(duration), "the fling never stopped");
936 assert_close(simulation.x(duration), target, 1e-9, "position at duration");
937 assert_close(
938 simulation.x(duration * 2.0),
939 target,
940 1e-9,
941 "position past duration",
942 );
943 assert_close(simulation.dx(duration), 0.0, 1e-9, "velocity at duration");
944
945 let mut previous = simulation.x(0.0);
946 for step in 1..=100 {
947 let position = simulation.x(duration * f64::from(step) / 100.0);
948 assert!(position >= previous, "backtracked to {position}");
949 assert!(position <= target + 1e-9, "overshot the target: {position}");
950 previous = position;
951 }
952 }
953
954 #[test]
955 fn bouncing_switches_friction_to_spring_at_extent() {
956 let trailing = 100.0;
957 let simulation = BouncingScrollSimulation::new(
958 0.0,
959 2000.0,
960 0.0,
961 trailing,
962 SpringDescription::default_scroll_spring(),
963 tol(),
964 0.0,
965 );
966 let friction = FrictionSimulation::new(
967 BouncingScrollSimulation::FRICTION_DRAG,
968 0.0,
969 2000.0,
970 tol(),
971 0.0,
972 );
973 let handover = friction.time_at_x(trailing);
974 assert!(handover.is_finite(), "the fling must reach the extent");
975
976 let early = handover / 2.0;
978 assert_eq!(simulation.x(early), friction.x(early));
979 assert_eq!(simulation.dx(early), friction.dx(early));
980
981 assert_close(
984 simulation.x(handover),
985 trailing,
986 1e-9,
987 "position at handover",
988 );
989 assert_close(
990 simulation.dx(handover),
991 friction.dx(handover),
992 1e-9,
993 "velocity at handover",
994 );
995
996 let mut furthest = f64::MIN;
998 for step in 0..=500 {
999 furthest = furthest.max(simulation.x(handover + f64::from(step) / 100.0));
1000 }
1001 assert!(
1002 furthest > trailing,
1003 "the bounce never passed the extent (peaked at {furthest})"
1004 );
1005 assert!(
1006 furthest < trailing + 60.0,
1007 "the bounce ran away past the extent (peaked at {furthest})"
1008 );
1009 assert_close(
1010 simulation.x(handover + 5.0),
1011 trailing,
1012 1e-9,
1013 "settled position",
1014 );
1015 assert!(
1016 simulation.is_done(handover + 5.0),
1017 "the bounce never settled"
1018 );
1019 }
1020
1021 #[test]
1022 fn bouncing_stays_friction_when_no_extent_is_reached() {
1023 let simulation = BouncingScrollSimulation::new(
1024 0.0,
1025 100.0,
1026 0.0,
1027 10_000.0,
1028 SpringDescription::default_scroll_spring(),
1029 tol(),
1030 0.0,
1031 );
1032 let friction = FrictionSimulation::new(
1033 BouncingScrollSimulation::FRICTION_DRAG,
1034 0.0,
1035 100.0,
1036 tol(),
1037 0.0,
1038 );
1039 assert!(
1040 friction.time_at_x(10_000.0).is_infinite(),
1041 "this fling must stop short of the extent for the test to mean anything"
1042 );
1043 for step in 0..=100 {
1044 let time = f64::from(step) / 10.0;
1045 assert_eq!(simulation.x(time), friction.x(time));
1046 assert_eq!(simulation.dx(time), friction.dx(time));
1047 }
1048 }
1049
1050 #[test]
1051 fn bouncing_underscroll_starts_as_spring() {
1052 let leading = 0.0;
1053 let simulation = BouncingScrollSimulation::new(
1054 -50.0,
1055 0.0,
1056 leading,
1057 100.0,
1058 SpringDescription::default_scroll_spring(),
1059 tol(),
1060 0.0,
1061 );
1062 assert_close(simulation.x(0.0), -50.0, 1e-9, "starts where released");
1063 assert!(
1064 simulation.x(0.1) > simulation.x(0.0),
1065 "must travel back toward the leading extent"
1066 );
1067 assert!(!simulation.is_done(0.0), "an overscrolled rest is not done");
1068 assert_close(simulation.x(3.0), leading, 1e-9, "settled position");
1069 assert!(simulation.is_done(3.0), "the bounce-back never settled");
1070 }
1071
1072 #[test]
1073 fn bouncing_spring_transfer_velocity_capped() {
1074 let spring = SpringDescription::default_scroll_spring();
1075 let cap = BouncingScrollSimulation::MAX_SPRING_TRANSFER_VELOCITY;
1076
1077 let underscroll =
1079 BouncingScrollSimulation::new(-50.0, -20_000.0, 0.0, 100.0, spring, tol(), 0.0);
1080 assert_close(underscroll.dx(0.0), -cap, 1e-9, "clamped underscroll seed");
1081
1082 let fling = BouncingScrollSimulation::new(0.0, 50_000.0, 0.0, 100.0, spring, tol(), 0.0);
1084 let friction = FrictionSimulation::new(
1085 BouncingScrollSimulation::FRICTION_DRAG,
1086 0.0,
1087 50_000.0,
1088 tol(),
1089 0.0,
1090 );
1091 let handover = friction.time_at_x(100.0);
1092 assert!(
1093 friction.dx(handover) > cap,
1094 "the fling must arrive above the cap for the test to mean anything"
1095 );
1096 assert_close(fling.dx(handover), cap, 1e-9, "clamped handover seed");
1097 }
1098
1099 #[test]
1100 fn tween_reaches_the_target_exactly_at_its_duration() {
1101 let tween = TweenSimulation::new(0.0, 100.0, 0.5, Curve::Linear);
1102 assert_close(tween.x(0.0), 0.0, 1e-9, "starts at the source position");
1103 assert_close(tween.x(0.25), 50.0, 1e-9, "linear curve, halfway in time");
1104 assert!(!tween.is_done(0.25));
1105 assert_close(tween.x(0.5), 100.0, 1e-9, "lands exactly on the target");
1106 assert!(tween.is_done(0.5));
1107 assert_close(tween.x(1.0), 100.0, 1e-9, "clamped past the duration");
1110 assert!(tween.is_done(1.0));
1111 }
1112
1113 #[test]
1114 fn tween_velocity_is_positive_mid_flight_and_zero_once_done() {
1115 let tween = TweenSimulation::new(0.0, 100.0, 1.0, Curve::EaseInOut);
1116 assert!(
1117 tween.dx(0.5) > 0.0,
1118 "moving from start to end, mid-flight velocity is positive"
1119 );
1120 assert_eq!(tween.dx(1.0), 0.0, "settled — no velocity once done");
1121 assert_eq!(tween.dx(-1.0), 0.0, "never queried before its own start");
1122 }
1123
1124 #[test]
1125 fn tween_non_positive_duration_is_immediately_done() {
1126 for duration in [0.0, -5.0, f64::NAN] {
1129 let tween = TweenSimulation::new(10.0, 20.0, duration, Curve::Linear);
1130 assert_close(tween.x(0.0), 20.0, 1e-9, "immediately at the target");
1131 assert!(tween.is_done(0.0));
1132 }
1133 }
1134}