1use crate::{MotionPreference, MotionRunState, MotionSpec, MotionTimeline};
4use std::time::{Duration, Instant};
5
6const DEFAULT_MASS: f32 = 1.0;
7const DEFAULT_STIFFNESS: f32 = 260.0;
8const DEFAULT_DAMPING: f32 = 28.0;
9const DEFAULT_REST_DELTA: f32 = 0.001;
10const DEFAULT_REST_SPEED: f32 = 0.01;
11const MIN_POSITIVE: f32 = 0.000_001;
12const MAX_PHYSICS_VALUE: f32 = 1_000_000.0;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MotionSpringPreset {
17 Affordance,
19 Layout,
21 Continuity,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct MotionSpringPhysics {
28 mass: f32,
29 stiffness: f32,
30 damping: f32,
31 rest_delta: f32,
32 rest_speed: f32,
33 bounce: f32,
34 review_duration: Duration,
35}
36
37impl MotionSpringPhysics {
38 pub const MAX_REVIEWABLE_BOUNCE: f32 = 0.35;
40
41 pub fn new(mass: f32, stiffness: f32, damping: f32) -> Self {
43 Self {
44 mass: sanitize_positive(mass, DEFAULT_MASS),
45 stiffness: sanitize_positive(stiffness, DEFAULT_STIFFNESS),
46 damping: sanitize_non_negative(damping, DEFAULT_DAMPING),
47 rest_delta: DEFAULT_REST_DELTA,
48 rest_speed: DEFAULT_REST_SPEED,
49 bounce: 0.0,
50 review_duration: Duration::from_millis(180),
51 }
52 }
53
54 pub const fn mass(self) -> f32 {
56 self.mass
57 }
58
59 pub const fn stiffness(self) -> f32 {
61 self.stiffness
62 }
63
64 pub const fn damping(self) -> f32 {
66 self.damping
67 }
68
69 pub const fn rest_delta(self) -> f32 {
71 self.rest_delta
72 }
73
74 pub const fn rest_speed(self) -> f32 {
76 self.rest_speed
77 }
78
79 pub const fn bounce(self) -> f32 {
81 self.bounce
82 }
83
84 pub const fn review_duration(self) -> Duration {
86 self.review_duration
87 }
88
89 pub fn with_rest_delta(mut self, rest_delta: f32) -> Self {
91 self.rest_delta = sanitize_positive(rest_delta, DEFAULT_REST_DELTA);
92 self
93 }
94
95 pub fn with_rest_speed(mut self, rest_speed: f32) -> Self {
97 self.rest_speed = sanitize_positive(rest_speed, DEFAULT_REST_SPEED);
98 self
99 }
100
101 pub fn with_bounce(mut self, bounce: f32) -> Self {
103 self.bounce = if bounce.is_finite() {
104 bounce.clamp(0.0, Self::MAX_REVIEWABLE_BOUNCE)
105 } else {
106 0.0
107 };
108 self
109 }
110
111 pub fn with_review_duration(mut self, duration: Duration) -> Self {
113 self.review_duration = duration;
114 self
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq)]
120pub struct MotionSpringSpec {
121 preference: MotionPreference,
122 preset: Option<MotionSpringPreset>,
123 physics: MotionSpringPhysics,
124}
125
126impl MotionSpringSpec {
127 pub fn from_physics(preference: MotionPreference, physics: MotionSpringPhysics) -> Self {
129 Self {
130 preference,
131 preset: None,
132 physics,
133 }
134 }
135
136 pub fn affordance(preference: MotionPreference) -> Self {
138 Self::from_preset(
139 preference,
140 MotionSpringPreset::Affordance,
141 MotionSpringPhysics::new(1.0, 320.0, 34.0)
142 .with_bounce(0.08)
143 .with_review_duration(Duration::from_millis(120)),
144 )
145 }
146
147 pub fn layout(preference: MotionPreference) -> Self {
149 Self::from_preset(
150 preference,
151 MotionSpringPreset::Layout,
152 MotionSpringPhysics::new(1.0, 260.0, 28.0)
153 .with_bounce(0.12)
154 .with_review_duration(Duration::from_millis(180)),
155 )
156 }
157
158 pub fn continuity(preference: MotionPreference) -> Self {
160 Self::from_preset(
161 preference,
162 MotionSpringPreset::Continuity,
163 MotionSpringPhysics::new(1.0, 190.0, 23.0)
164 .with_bounce(0.10)
165 .with_review_duration(Duration::from_millis(260)),
166 )
167 }
168
169 fn from_preset(
170 preference: MotionPreference,
171 preset: MotionSpringPreset,
172 physics: MotionSpringPhysics,
173 ) -> Self {
174 Self {
175 preference,
176 preset: Some(preset),
177 physics,
178 }
179 }
180
181 pub const fn preference(self) -> MotionPreference {
183 self.preference
184 }
185
186 pub const fn preset(self) -> Option<MotionSpringPreset> {
188 self.preset
189 }
190
191 pub const fn physics(self) -> MotionSpringPhysics {
193 self.physics
194 }
195
196 pub const fn is_immediate(self) -> bool {
198 self.preference.is_immediate()
199 }
200
201 pub fn with_bounce(mut self, bounce: f32) -> Self {
203 self.physics = self.physics.with_bounce(bounce);
204 self
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq)]
210pub enum MotionPreset {
211 Immediate,
213 Timeline(MotionSpec),
215 Spring(MotionSpringSpec),
217 CommittedLayout(MotionPreference),
219 Continuity(MotionPreference),
221 Affordance(MotionPreference),
223}
224
225impl MotionPreset {
226 pub const fn immediate() -> Self {
228 Self::Immediate
229 }
230
231 pub const fn timeline(spec: MotionSpec) -> Self {
233 Self::Timeline(spec)
234 }
235
236 pub const fn spring(spec: MotionSpringSpec) -> Self {
238 Self::Spring(spec)
239 }
240
241 pub const fn committed_layout(preference: MotionPreference) -> Self {
243 Self::CommittedLayout(preference)
244 }
245
246 pub const fn continuity(preference: MotionPreference) -> Self {
248 Self::Continuity(preference)
249 }
250
251 pub const fn affordance(preference: MotionPreference) -> Self {
253 Self::Affordance(preference)
254 }
255
256 pub fn resolve_model(self) -> MotionModel {
258 match self {
259 Self::Immediate => MotionModel::timeline(MotionSpec::immediate()),
260 Self::Timeline(spec) => MotionModel::timeline(spec),
261 Self::Spring(spec) => MotionModel::spring(spec),
262 Self::CommittedLayout(preference) => {
263 MotionModel::spring(MotionSpringSpec::layout(preference))
264 }
265 Self::Continuity(preference) => {
266 MotionModel::spring(MotionSpringSpec::continuity(preference))
267 }
268 Self::Affordance(preference) => {
269 MotionModel::spring(MotionSpringSpec::affordance(preference))
270 }
271 }
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq)]
277pub struct MotionScalarSample {
278 state: MotionRunState,
279 elapsed: Duration,
280 value: f32,
281 velocity: f32,
282 target: f32,
283}
284
285impl MotionScalarSample {
286 pub const fn new(
288 state: MotionRunState,
289 elapsed: Duration,
290 value: f32,
291 velocity: f32,
292 target: f32,
293 ) -> Self {
294 Self {
295 state,
296 elapsed,
297 value,
298 velocity,
299 target,
300 }
301 }
302
303 pub const fn state(self) -> MotionRunState {
305 self.state
306 }
307
308 pub const fn elapsed(self) -> Duration {
310 self.elapsed
311 }
312
313 pub const fn value(self) -> f32 {
315 self.value
316 }
317
318 pub const fn velocity(self) -> f32 {
320 self.velocity
321 }
322
323 pub const fn target(self) -> f32 {
325 self.target
326 }
327
328 pub const fn is_active(self) -> bool {
330 self.state.is_active()
331 }
332
333 pub const fn reached_final_state(self) -> bool {
335 self.state.reached_final_state()
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq)]
341pub struct MotionSpring {
342 spec: MotionSpringSpec,
343 from: f32,
344 target: f32,
345 initial_velocity: f32,
346 started_at: Instant,
347 cancelled_at: Option<Instant>,
348}
349
350impl MotionSpring {
351 pub fn new(
353 spec: MotionSpringSpec,
354 from: f32,
355 target: f32,
356 initial_velocity: f32,
357 started_at: Instant,
358 ) -> Self {
359 let from = sanitize_number(from, 0.0);
360 Self {
361 spec,
362 from,
363 target: sanitize_number(target, from),
364 initial_velocity: sanitize_number(initial_velocity, 0.0),
365 started_at,
366 cancelled_at: None,
367 }
368 }
369
370 pub fn retarget_from_sample(
372 spec: MotionSpringSpec,
373 sample: MotionScalarSample,
374 target: f32,
375 started_at: Instant,
376 ) -> Self {
377 Self::new(spec, sample.value(), target, sample.velocity(), started_at)
378 }
379
380 pub const fn spec(self) -> MotionSpringSpec {
382 self.spec
383 }
384
385 pub const fn from(self) -> f32 {
387 self.from
388 }
389
390 pub const fn target(self) -> f32 {
392 self.target
393 }
394
395 pub const fn initial_velocity(self) -> f32 {
397 self.initial_velocity
398 }
399
400 pub const fn started_at(self) -> Instant {
402 self.started_at
403 }
404
405 pub const fn cancelled_at(self) -> Option<Instant> {
407 self.cancelled_at
408 }
409
410 pub fn cancel_at(&mut self, cancelled_at: Instant) {
412 self.cancelled_at = Some(cancelled_at);
413 }
414
415 pub fn sample(self, now: Instant) -> MotionScalarSample {
417 let effective_now = self.cancelled_at.unwrap_or(now);
418 let elapsed = effective_now.saturating_duration_since(self.started_at);
419 let mut sample = Self::sample_elapsed(
420 self.spec,
421 self.from,
422 self.target,
423 self.initial_velocity,
424 elapsed,
425 );
426 if self.cancelled_at.is_some() && !sample.reached_final_state() {
427 sample.state = MotionRunState::Cancelled;
428 }
429 sample
430 }
431
432 pub fn sample_elapsed(
434 spec: MotionSpringSpec,
435 from: f32,
436 target: f32,
437 initial_velocity: f32,
438 elapsed: Duration,
439 ) -> MotionScalarSample {
440 let from = sanitize_number(from, 0.0);
441 let target = sanitize_number(target, from);
442 let initial_velocity = sanitize_number(initial_velocity, 0.0);
443
444 if spec.is_immediate() {
445 return MotionScalarSample::new(
446 MotionRunState::Immediate,
447 elapsed,
448 target,
449 0.0,
450 target,
451 );
452 }
453
454 let physics = spec.physics();
455 if elapsed.is_zero() {
456 let state = if at_rest(from, target, initial_velocity, physics) {
457 MotionRunState::Completed
458 } else {
459 MotionRunState::Active
460 };
461 let value = if state.reached_final_state() {
462 target
463 } else {
464 from
465 };
466 let velocity = if state.reached_final_state() {
467 0.0
468 } else {
469 initial_velocity
470 };
471 return MotionScalarSample::new(state, elapsed, value, velocity, target);
472 }
473
474 let (value, velocity) =
475 sample_spring_value(physics, from, target, initial_velocity, elapsed);
476 if at_rest(value, target, velocity, physics) {
477 MotionScalarSample::new(MotionRunState::Completed, elapsed, target, 0.0, target)
478 } else {
479 MotionScalarSample::new(MotionRunState::Active, elapsed, value, velocity, target)
480 }
481 }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq)]
486pub enum MotionModel {
487 Timeline(MotionSpec),
489 Spring(MotionSpringSpec),
491}
492
493impl MotionModel {
494 pub const fn timeline(spec: MotionSpec) -> Self {
496 Self::Timeline(spec)
497 }
498
499 pub const fn spring(spec: MotionSpringSpec) -> Self {
501 Self::Spring(spec)
502 }
503
504 pub const fn preference(self) -> MotionPreference {
506 match self {
507 Self::Timeline(spec) => spec.preference(),
508 Self::Spring(spec) => spec.preference(),
509 }
510 }
511
512 pub const fn is_immediate(self) -> bool {
514 match self {
515 Self::Timeline(spec) => spec.is_immediate(),
516 Self::Spring(spec) => spec.is_immediate(),
517 }
518 }
519
520 pub const fn sequence_duration_hint(self) -> Duration {
525 match self {
526 Self::Timeline(spec) => spec.duration().as_duration(),
527 Self::Spring(spec) => spec.physics().review_duration(),
528 }
529 }
530
531 pub fn sample_scalar_elapsed(
533 self,
534 from: f32,
535 target: f32,
536 initial_velocity: f32,
537 elapsed: Duration,
538 ) -> MotionScalarSample {
539 match self {
540 Self::Timeline(spec) => {
541 let sample = MotionTimeline::sample_elapsed(spec, elapsed);
542 let from = sanitize_number(from, 0.0);
543 let target = sanitize_number(target, from);
544 let value = from + (target - from) * sample.progress();
545 let duration = spec.duration().as_duration().as_secs_f32();
546 let velocity = if sample.reached_final_state() || duration <= 0.0 {
547 0.0
548 } else {
549 (target - from) / duration
550 };
551 MotionScalarSample::new(sample.state(), sample.elapsed(), value, velocity, target)
552 }
553 Self::Spring(spec) => {
554 MotionSpring::sample_elapsed(spec, from, target, initial_velocity, elapsed)
555 }
556 }
557 }
558}
559
560fn sample_spring_value(
561 physics: MotionSpringPhysics,
562 from: f32,
563 target: f32,
564 initial_velocity: f32,
565 elapsed: Duration,
566) -> (f32, f32) {
567 let t = elapsed.as_secs_f32().max(0.0);
568 let displacement = from - target;
569 let mass = physics.mass();
570 let stiffness = physics.stiffness();
571 let damping = physics.damping();
572 let angular_frequency = (stiffness / mass).sqrt();
573 let critical_damping = 2.0 * (stiffness * mass).sqrt();
574 let damping_ratio = if critical_damping > 0.0 {
575 damping / critical_damping
576 } else {
577 1.0
578 };
579
580 let (displacement, velocity) = if damping_ratio < 1.0 {
581 sample_underdamped(
582 displacement,
583 initial_velocity,
584 angular_frequency,
585 damping_ratio,
586 t,
587 )
588 } else if (damping_ratio - 1.0).abs() <= f32::EPSILON {
589 sample_critically_damped(displacement, initial_velocity, angular_frequency, t)
590 } else {
591 sample_overdamped(
592 displacement,
593 initial_velocity,
594 angular_frequency,
595 damping_ratio,
596 t,
597 )
598 };
599
600 let value = sanitize_number(target + displacement, target);
601 let velocity = sanitize_number(velocity, 0.0);
602 (value, velocity)
603}
604
605fn sample_underdamped(
606 displacement: f32,
607 velocity: f32,
608 angular_frequency: f32,
609 damping_ratio: f32,
610 time: f32,
611) -> (f32, f32) {
612 let damped_frequency = angular_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
613 if damped_frequency <= MIN_POSITIVE {
614 return sample_critically_damped(displacement, velocity, angular_frequency, time);
615 }
616
617 let decay = (-damping_ratio * angular_frequency * time).exp();
618 let sin = (damped_frequency * time).sin();
619 let cos = (damped_frequency * time).cos();
620 let a = displacement;
621 let b = (velocity + damping_ratio * angular_frequency * displacement) / damped_frequency;
622 let oscillation = a * cos + b * sin;
623 let sampled_displacement = decay * oscillation;
624 let sampled_velocity = decay
625 * ((-a * damped_frequency * sin + b * damped_frequency * cos)
626 - damping_ratio * angular_frequency * oscillation);
627 (sampled_displacement, sampled_velocity)
628}
629
630fn sample_critically_damped(
631 displacement: f32,
632 velocity: f32,
633 angular_frequency: f32,
634 time: f32,
635) -> (f32, f32) {
636 let decay = (-angular_frequency * time).exp();
637 let c = velocity + angular_frequency * displacement;
638 let displacement_term = displacement + c * time;
639 let sampled_displacement = decay * displacement_term;
640 let sampled_velocity = decay * (c - angular_frequency * displacement_term);
641 (sampled_displacement, sampled_velocity)
642}
643
644fn sample_overdamped(
645 displacement: f32,
646 velocity: f32,
647 angular_frequency: f32,
648 damping_ratio: f32,
649 time: f32,
650) -> (f32, f32) {
651 let ratio_delta = (damping_ratio * damping_ratio - 1.0).sqrt();
652 let r1 = -angular_frequency * (damping_ratio - ratio_delta);
653 let r2 = -angular_frequency * (damping_ratio + ratio_delta);
654 if (r1 - r2).abs() <= MIN_POSITIVE {
655 return sample_critically_damped(displacement, velocity, angular_frequency, time);
656 }
657 let c1 = (velocity - r2 * displacement) / (r1 - r2);
658 let c2 = displacement - c1;
659 let e1 = (r1 * time).exp();
660 let e2 = (r2 * time).exp();
661 let sampled_displacement = c1 * e1 + c2 * e2;
662 let sampled_velocity = c1 * r1 * e1 + c2 * r2 * e2;
663 (sampled_displacement, sampled_velocity)
664}
665
666fn at_rest(value: f32, target: f32, velocity: f32, physics: MotionSpringPhysics) -> bool {
667 (value - target).abs() <= physics.rest_delta() && velocity.abs() <= physics.rest_speed()
668}
669
670fn sanitize_number(value: f32, default: f32) -> f32 {
671 if value.is_finite() { value } else { default }
672}
673
674fn sanitize_positive(value: f32, default: f32) -> f32 {
675 if value.is_finite() && value > 0.0 {
676 value.clamp(MIN_POSITIVE, MAX_PHYSICS_VALUE)
677 } else {
678 default
679 }
680}
681
682fn sanitize_non_negative(value: f32, default: f32) -> f32 {
683 if value.is_finite() && value >= 0.0 {
684 value.clamp(0.0, MAX_PHYSICS_VALUE)
685 } else {
686 default
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::MotionPreference;
694 use std::time::{Duration, Instant};
695
696 #[test]
697 fn layout_spring_samples_active_motion_and_reaches_exact_target_at_rest() {
698 let started_at = Instant::now();
699 let spring = MotionSpring::new(
700 MotionSpringSpec::layout(MotionPreference::Animated),
701 0.0,
702 1.0,
703 0.0,
704 started_at,
705 );
706
707 let start = spring.sample(started_at);
708 assert_eq!(start.state(), MotionRunState::Active);
709 assert_eq!(start.elapsed(), Duration::ZERO);
710 assert_eq!(start.value(), 0.0);
711 assert_eq!(start.target(), 1.0);
712
713 let midpoint = spring.sample(started_at + Duration::from_millis(90));
714 assert_eq!(midpoint.state(), MotionRunState::Active);
715 assert!(midpoint.value() > 0.0);
716 assert!(midpoint.value() < 1.08);
717 assert!(midpoint.velocity().is_finite());
718
719 let complete = spring.sample(started_at + Duration::from_secs(2));
720 assert_eq!(complete.state(), MotionRunState::Completed);
721 assert_eq!(complete.value(), 1.0);
722 assert_eq!(complete.velocity(), 0.0);
723 assert!(complete.reached_final_state());
724 }
725
726 #[test]
727 fn tiny_delta_uses_rest_thresholds_without_oscillating_forever() {
728 let sample = MotionSpring::sample_elapsed(
729 MotionSpringSpec::layout(MotionPreference::Animated),
730 10.0,
731 10.0001,
732 0.0,
733 Duration::from_millis(600),
734 );
735
736 assert_eq!(sample.state(), MotionRunState::Completed);
737 assert_eq!(sample.value(), 10.0001);
738 }
739
740 #[test]
741 fn spring_bounce_defaults_are_subtle_and_clamped() {
742 let layout = MotionSpringSpec::layout(MotionPreference::Animated);
743 assert!(layout.physics().bounce() <= 0.20);
744
745 let clamped = layout.with_bounce(2.0);
746 assert!(clamped.physics().bounce() <= MotionSpringPhysics::MAX_REVIEWABLE_BOUNCE);
747 }
748
749 #[test]
750 fn retargeted_spring_preserves_current_position_and_velocity() {
751 let started_at = Instant::now();
752 let spec = MotionSpringSpec::layout(MotionPreference::Animated);
753 let spring = MotionSpring::new(spec, 0.0, 1.0, 0.0, started_at);
754 let sampled_at = started_at + Duration::from_millis(80);
755 let sampled = spring.sample(sampled_at);
756
757 let retargeted = MotionSpring::retarget_from_sample(spec, sampled, 2.0, sampled_at);
758 let retarget_start = retargeted.sample(sampled_at);
759
760 assert_eq!(retarget_start.value(), sampled.value());
761 assert_eq!(retarget_start.velocity(), sampled.velocity());
762 assert_eq!(retarget_start.target(), 2.0);
763 }
764
765 #[test]
766 fn reduced_motion_spring_returns_final_semantic_sample() {
767 let sample = MotionSpring::sample_elapsed(
768 MotionSpringSpec::layout(MotionPreference::Reduced),
769 0.0,
770 1.0,
771 20.0,
772 Duration::from_millis(16),
773 );
774
775 assert_eq!(sample.state(), MotionRunState::Immediate);
776 assert_eq!(sample.value(), 1.0);
777 assert_eq!(sample.velocity(), 0.0);
778 assert!(sample.reached_final_state());
779 }
780
781 #[test]
782 fn invalid_physics_parameters_are_sanitized() {
783 let physics = MotionSpringPhysics::new(f32::NAN, -1.0, f32::INFINITY)
784 .with_rest_delta(f32::NAN)
785 .with_rest_speed(-20.0);
786 let sample = MotionSpring::sample_elapsed(
787 MotionSpringSpec::from_physics(MotionPreference::Animated, physics),
788 0.0,
789 1.0,
790 f32::NAN,
791 Duration::from_millis(80),
792 );
793
794 assert!(sample.value().is_finite());
795 assert!(sample.velocity().is_finite());
796 }
797
798 #[test]
799 fn motion_model_wraps_timeline_and_spring_specs() {
800 let timeline = MotionModel::timeline(crate::MotionSpec::layout(MotionPreference::Animated));
801 let spring = MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated));
802
803 assert!(!timeline.is_immediate());
804 assert!(!spring.is_immediate());
805 assert_eq!(spring.preference(), MotionPreference::Animated);
806 }
807
808 #[test]
809 fn motion_preset_resolves_default_springs_explicitly() {
810 let custom_timeline = MotionPreset::timeline(crate::MotionSpec::new(
811 MotionPreference::Animated,
812 crate::MotionDuration::Custom(Duration::from_millis(240)),
813 crate::MotionEasing::Linear,
814 ))
815 .resolve_model();
816 assert!(matches!(custom_timeline, MotionModel::Timeline(_)));
817
818 let committed = MotionPreset::committed_layout(MotionPreference::Animated).resolve_model();
819 assert!(matches!(
820 committed,
821 MotionModel::Spring(spec)
822 if spec.preset() == Some(MotionSpringPreset::Layout)
823 ));
824
825 let continuity = MotionPreset::continuity(MotionPreference::Animated).resolve_model();
826 assert!(matches!(
827 continuity,
828 MotionModel::Spring(spec)
829 if spec.preset() == Some(MotionSpringPreset::Continuity)
830 ));
831 }
832
833 #[test]
834 fn motion_model_samples_timeline_and_spring_as_scalar_values() {
835 let timeline = MotionModel::timeline(crate::MotionSpec::new(
836 MotionPreference::Animated,
837 crate::MotionDuration::Custom(Duration::from_millis(200)),
838 crate::MotionEasing::Linear,
839 ));
840 let timeline_sample =
841 timeline.sample_scalar_elapsed(0.0, 10.0, 0.0, Duration::from_millis(100));
842
843 assert_eq!(timeline_sample.state(), MotionRunState::Active);
844 assert_eq!(timeline_sample.value(), 5.0);
845 assert_eq!(timeline_sample.target(), 10.0);
846
847 let spring = MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated));
848 let spring_sample =
849 spring.sample_scalar_elapsed(0.0, 10.0, 0.0, Duration::from_millis(100));
850
851 assert_eq!(spring_sample.state(), MotionRunState::Active);
852 assert!(spring_sample.value() > 0.0);
853 assert_eq!(spring_sample.target(), 10.0);
854 assert!(spring_sample.velocity().is_finite());
855 }
856}