Skip to main content

animato_physics/
inertia.rs

1//! Friction inertia for post-drag motion.
2
3#[cfg(any(feature = "std", feature = "alloc"))]
4use crate::decompose::Decompose;
5#[cfg(any(feature = "std", feature = "alloc"))]
6use alloc::vec::Vec;
7use animato_core::Update;
8#[cfg(any(feature = "std", feature = "alloc"))]
9use core::marker::PhantomData;
10
11/// Inclusive bounds for inertia position.
12///
13/// For 1D inertia use `InertiaBounds<f32>`. For multi-dimensional inertia,
14/// use the same component shape as the animated value, such as
15/// `InertiaBounds<[f32; 2]>`.
16#[derive(Clone, Debug, PartialEq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct InertiaBounds<T = f32> {
19    /// Minimum allowed position.
20    pub min: T,
21    /// Maximum allowed position.
22    pub max: T,
23}
24
25impl<T> InertiaBounds<T> {
26    /// Create bounds from a minimum and maximum value.
27    pub fn new(min: T, max: T) -> Self {
28        Self { min, max }
29    }
30}
31
32/// Configuration for friction inertia.
33///
34/// `friction` is a constant deceleration in units per second squared.
35/// `min_velocity` is the absolute velocity threshold below which inertia is
36/// considered settled.
37#[derive(Clone, Debug, PartialEq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct InertiaConfig<T = f32> {
40    /// Constant deceleration in units per second squared.
41    pub friction: f32,
42    /// Velocity threshold below which the inertia settles.
43    pub min_velocity: f32,
44    /// Optional inclusive bounds for the position.
45    pub bounds: Option<InertiaBounds<T>>,
46}
47
48impl<T> InertiaConfig<T> {
49    /// Create an inertia configuration.
50    pub fn new(friction: f32, min_velocity: f32) -> Self {
51        Self {
52            friction,
53            min_velocity,
54            bounds: None,
55        }
56    }
57
58    /// Attach inclusive position bounds.
59    pub fn with_bounds(mut self, bounds: InertiaBounds<T>) -> Self {
60        self.bounds = Some(bounds);
61        self
62    }
63
64    #[inline]
65    fn friction(&self) -> f32 {
66        if self.friction.is_finite() {
67            self.friction.max(0.0)
68        } else {
69            0.0
70        }
71    }
72
73    #[inline]
74    fn min_velocity(&self) -> f32 {
75        if self.min_velocity.is_finite() {
76            self.min_velocity.max(0.0)
77        } else {
78            0.0
79        }
80    }
81}
82
83impl Default for InertiaConfig<f32> {
84    fn default() -> Self {
85        Self::smooth()
86    }
87}
88
89impl InertiaConfig<f32> {
90    /// Smooth, long-running inertia for scroll and carousel-like movement.
91    pub fn smooth() -> Self {
92        Self {
93            friction: 1400.0,
94            min_velocity: 2.0,
95            bounds: None,
96        }
97    }
98
99    /// Short, responsive inertia for direct-manipulation UI.
100    pub fn snappy() -> Self {
101        Self {
102            friction: 3600.0,
103            min_velocity: 4.0,
104            bounds: None,
105        }
106    }
107
108    /// Heavy inertia with slower deceleration for large panels and canvases.
109    pub fn heavy() -> Self {
110        Self {
111            friction: 800.0,
112            min_velocity: 1.0,
113            bounds: None,
114        }
115    }
116}
117
118/// One-dimensional friction inertia.
119///
120/// `Inertia` starts at a position, receives an initial velocity through
121/// [`kick`](Self::kick), and decelerates until velocity falls below
122/// `InertiaConfig::min_velocity` or a bound is reached.
123#[derive(Clone, Debug)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125pub struct Inertia {
126    /// Runtime configuration.
127    pub config: InertiaConfig<f32>,
128    position: f32,
129    velocity: f32,
130}
131
132impl Inertia {
133    /// Create inertia at position `0.0`.
134    pub fn new(config: InertiaConfig<f32>) -> Self {
135        Self::with_position(config, 0.0)
136    }
137
138    /// Create inertia at a specific position.
139    pub fn with_position(config: InertiaConfig<f32>, position: f32) -> Self {
140        let mut this = Self {
141            config,
142            position: finite_or_zero(position),
143            velocity: 0.0,
144        };
145        this.apply_bounds();
146        this
147    }
148
149    /// Start inertia from an initial velocity.
150    pub fn kick(&mut self, velocity: f32) {
151        let velocity = finite_or_zero(velocity);
152        self.velocity = if velocity.abs() <= self.config.min_velocity() {
153            0.0
154        } else {
155            velocity
156        };
157    }
158
159    /// Current position.
160    pub fn position(&self) -> f32 {
161        self.position
162    }
163
164    /// Current velocity.
165    pub fn velocity(&self) -> f32 {
166        self.velocity
167    }
168
169    /// Teleport to `position` and clear velocity.
170    pub fn snap_to(&mut self, position: f32) {
171        self.position = finite_or_zero(position);
172        self.velocity = 0.0;
173        self.apply_bounds();
174    }
175
176    /// `true` when velocity is below the configured threshold.
177    pub fn is_settled(&self) -> bool {
178        self.velocity.abs() <= self.config.min_velocity()
179    }
180
181    #[inline]
182    fn apply_bounds(&mut self) -> bool {
183        if let Some(bounds) = &self.config.bounds {
184            let min = bounds.min.min(bounds.max);
185            let max = bounds.min.max(bounds.max);
186            if self.position < min {
187                self.position = min;
188                self.velocity = 0.0;
189                return true;
190            }
191            if self.position > max {
192                self.position = max;
193                self.velocity = 0.0;
194                return true;
195            }
196        }
197        false
198    }
199}
200
201impl Update for Inertia {
202    /// Advance inertia by `dt` seconds.
203    ///
204    /// Negative `dt` is treated as `0.0`. Bounds clamp and stop the simulated
205    /// axis immediately.
206    fn update(&mut self, dt: f32) -> bool {
207        let dt = dt.max(0.0);
208        if dt == 0.0 || self.is_settled() {
209            if self.is_settled() {
210                self.velocity = 0.0;
211            }
212            return !self.is_settled();
213        }
214
215        let friction = self.config.friction();
216        if friction <= 0.0 {
217            self.velocity = 0.0;
218            return false;
219        }
220
221        let sign = self.velocity.signum();
222        let speed = self.velocity.abs();
223        let stop_time = speed / friction;
224        let step = dt.min(stop_time);
225
226        self.position += self.velocity * step - 0.5 * sign * friction * step * step;
227
228        let next_speed = speed - friction * step;
229        self.velocity = if step >= stop_time || next_speed <= self.config.min_velocity() {
230            0.0
231        } else {
232            sign * next_speed
233        };
234
235        if self.apply_bounds() {
236            return false;
237        }
238
239        !self.is_settled()
240    }
241}
242
243/// Multi-dimensional friction inertia backed by one [`Inertia`] per component.
244///
245/// Requires the `alloc` or `std` feature.
246#[cfg(any(feature = "std", feature = "alloc"))]
247#[derive(Clone, Debug)]
248#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
249pub struct InertiaN<T: Decompose> {
250    components: Vec<Inertia>,
251    _marker: PhantomData<T>,
252}
253
254#[cfg(any(feature = "std", feature = "alloc"))]
255impl<T: Decompose> InertiaN<T> {
256    /// Create multi-dimensional inertia at `initial` position.
257    pub fn new(config: InertiaConfig<T>, initial: T) -> Self {
258        let count = T::component_count();
259        let mut initial_components = alloc::vec![0.0; count];
260        initial.write_components(&mut initial_components);
261
262        let mut min_components = alloc::vec![0.0; count];
263        let mut max_components = alloc::vec![0.0; count];
264        let has_bounds = if let Some(bounds) = &config.bounds {
265            bounds.min.write_components(&mut min_components);
266            bounds.max.write_components(&mut max_components);
267            true
268        } else {
269            false
270        };
271
272        let mut components = Vec::with_capacity(count);
273        for index in 0..count {
274            let mut component_config = InertiaConfig::new(config.friction, config.min_velocity);
275            if has_bounds {
276                component_config = component_config.with_bounds(InertiaBounds::new(
277                    min_components[index],
278                    max_components[index],
279                ));
280            }
281            components.push(Inertia::with_position(
282                component_config,
283                initial_components[index],
284            ));
285        }
286
287        Self {
288            components,
289            _marker: PhantomData,
290        }
291    }
292
293    /// Start inertia from a multi-dimensional velocity.
294    #[allow(clippy::useless_conversion)]
295    pub fn kick(&mut self, velocity: T) {
296        let count = T::component_count();
297        let mut velocity_components = alloc::vec![0.0; count];
298        velocity.write_components(&mut velocity_components);
299        for (component, velocity) in self
300            .components
301            .iter_mut()
302            .zip(velocity_components.into_iter())
303        {
304            component.kick(velocity);
305        }
306    }
307
308    /// Current position.
309    pub fn position(&self) -> T {
310        let values: Vec<f32> = self
311            .components
312            .iter()
313            .map(|component| component.position())
314            .collect();
315        T::from_components(&values)
316    }
317
318    /// Current velocity.
319    pub fn velocity(&self) -> T {
320        let values: Vec<f32> = self
321            .components
322            .iter()
323            .map(|component| component.velocity())
324            .collect();
325        T::from_components(&values)
326    }
327
328    /// Teleport to `position` and clear all component velocities.
329    #[allow(clippy::useless_conversion)]
330    pub fn snap_to(&mut self, position: T) {
331        let count = T::component_count();
332        let mut position_components = alloc::vec![0.0; count];
333        position.write_components(&mut position_components);
334        for (component, position) in self
335            .components
336            .iter_mut()
337            .zip(position_components.into_iter())
338        {
339            component.snap_to(position);
340        }
341    }
342
343    /// `true` when every component has settled.
344    pub fn is_settled(&self) -> bool {
345        self.components
346            .iter()
347            .all(|component| component.is_settled())
348    }
349}
350
351#[cfg(any(feature = "std", feature = "alloc"))]
352impl<T: Decompose> Update for InertiaN<T> {
353    fn update(&mut self, dt: f32) -> bool {
354        if self.is_settled() {
355            return false;
356        }
357        for component in self.components.iter_mut() {
358            component.update(dt);
359        }
360        !self.is_settled()
361    }
362}
363
364#[inline]
365fn finite_or_zero(value: f32) -> f32 {
366    if value.is_finite() { value } else { 0.0 }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    const DT: f32 = 1.0 / 60.0;
374
375    #[test]
376    fn inertia_settles_from_kick() {
377        let mut inertia = Inertia::new(InertiaConfig::smooth());
378        inertia.kick(600.0);
379        for _ in 0..10_000 {
380            if !inertia.update(DT) {
381                break;
382            }
383        }
384        assert!(inertia.is_settled());
385        assert_eq!(inertia.velocity(), 0.0);
386        assert!(inertia.position() > 0.0);
387    }
388
389    #[test]
390    fn negative_dt_is_noop() {
391        let mut inertia = Inertia::new(InertiaConfig::smooth());
392        inertia.kick(100.0);
393        inertia.update(-1.0);
394        assert_eq!(inertia.position(), 0.0);
395        assert_eq!(inertia.velocity(), 100.0);
396    }
397
398    #[test]
399    fn bounds_clamp_and_stop() {
400        let config = InertiaConfig::smooth().with_bounds(InertiaBounds::new(0.0, 10.0));
401        let mut inertia = Inertia::with_position(config, 5.0);
402        inertia.kick(1000.0);
403        for _ in 0..60 {
404            if !inertia.update(DT) {
405                break;
406            }
407        }
408        assert_eq!(inertia.position(), 10.0);
409        assert_eq!(inertia.velocity(), 0.0);
410        assert!(inertia.is_settled());
411    }
412
413    #[test]
414    fn snap_to_respects_bounds() {
415        let config = InertiaConfig::smooth().with_bounds(InertiaBounds::new(-5.0, 5.0));
416        let mut inertia = Inertia::new(config);
417        inertia.snap_to(20.0);
418        assert_eq!(inertia.position(), 5.0);
419    }
420
421    #[cfg(any(feature = "std", feature = "alloc"))]
422    #[test]
423    fn inertia_n_updates_independent_axes() {
424        let config = InertiaConfig::new(1000.0, 1.0)
425            .with_bounds(InertiaBounds::new([-100.0, -100.0], [100.0, 100.0]));
426        let mut inertia: InertiaN<[f32; 2]> = InertiaN::new(config, [0.0, 0.0]);
427        inertia.kick([400.0, -200.0]);
428        inertia.update(DT);
429        let position = inertia.position();
430        assert!(position[0] > 0.0);
431        assert!(position[1] < 0.0);
432    }
433
434    #[test]
435    fn presets_and_bounds_are_constructible() {
436        let bounds = InertiaBounds::new(-10.0, 10.0);
437        let config = InertiaConfig::snappy().with_bounds(bounds.clone());
438
439        assert_eq!(bounds.min, -10.0);
440        assert_eq!(bounds.max, 10.0);
441        assert_eq!(config.bounds, Some(bounds));
442        assert!(InertiaConfig::heavy().friction < InertiaConfig::snappy().friction);
443        assert_eq!(InertiaConfig::<f32>::default(), InertiaConfig::smooth());
444    }
445
446    #[test]
447    fn invalid_config_values_settle_immediately() {
448        let mut inertia = Inertia::with_position(InertiaConfig::new(f32::NAN, f32::NAN), f32::NAN);
449
450        inertia.kick(f32::INFINITY);
451
452        assert_eq!(inertia.position(), 0.0);
453        assert_eq!(inertia.velocity(), 0.0);
454        assert!(!inertia.update(DT));
455    }
456
457    #[test]
458    fn zero_friction_stops_on_first_update() {
459        let mut inertia = Inertia::new(InertiaConfig::new(0.0, 0.0));
460
461        inertia.kick(100.0);
462
463        assert!(!inertia.update(DT));
464        assert_eq!(inertia.velocity(), 0.0);
465    }
466
467    #[test]
468    fn reversed_bounds_are_normalized_when_applied() {
469        let config = InertiaConfig::new(1000.0, 1.0).with_bounds(InertiaBounds::new(10.0, -10.0));
470        let mut inertia = Inertia::with_position(config, 100.0);
471
472        assert_eq!(inertia.position(), 10.0);
473        inertia.snap_to(-100.0);
474        assert_eq!(inertia.position(), -10.0);
475    }
476
477    #[cfg(any(feature = "std", feature = "alloc"))]
478    #[test]
479    fn inertia_n_vec4_bounds_velocity_and_snap() {
480        let config = InertiaConfig::new(1000.0, 0.5).with_bounds(InertiaBounds::new(
481            [-10.0, -10.0, -10.0, -10.0],
482            [10.0, 10.0, 10.0, 10.0],
483        ));
484        let mut inertia: InertiaN<[f32; 4]> = InertiaN::new(config, [0.0, 0.0, 0.0, 0.0]);
485
486        inertia.kick([100.0, -50.0, 25.0, -10.0]);
487        assert_eq!(inertia.velocity(), [100.0, -50.0, 25.0, -10.0]);
488        assert!(inertia.update(DT));
489        inertia.snap_to([20.0, -20.0, 5.0, -5.0]);
490
491        assert_eq!(inertia.position(), [10.0, -10.0, 5.0, -5.0]);
492        assert_eq!(inertia.velocity(), [0.0, 0.0, 0.0, 0.0]);
493    }
494}