Skip to main content

animato_physics/
drag.rs

1//! Pointer drag tracking with constraints and velocity estimation.
2
3#[cfg(any(feature = "std", feature = "alloc"))]
4use crate::inertia::{InertiaBounds, InertiaConfig, InertiaN};
5
6/// Pointer sample used by drag and gesture systems.
7#[derive(Clone, Copy, Debug, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub struct PointerData {
10    /// Pointer x coordinate.
11    pub x: f32,
12    /// Pointer y coordinate.
13    pub y: f32,
14    /// Pointer pressure, usually in `[0.0, 1.0]`.
15    pub pressure: f32,
16    /// Stable pointer identifier supplied by the input backend.
17    pub pointer_id: u64,
18}
19
20impl PointerData {
21    /// Create a pointer sample with default pressure `1.0`.
22    pub fn new(x: f32, y: f32, pointer_id: u64) -> Self {
23        Self {
24            x,
25            y,
26            pressure: 1.0,
27            pointer_id,
28        }
29    }
30
31    /// Return the pointer position as `[x, y]`.
32    pub fn position(&self) -> [f32; 2] {
33        [self.x, self.y]
34    }
35}
36
37/// Axis filter for drag movement.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub enum DragAxis {
41    /// Drag on both x and y axes.
42    Both,
43    /// Drag only on the x axis.
44    X,
45    /// Drag only on the y axis.
46    Y,
47}
48
49/// Constraints applied to drag position.
50#[derive(Clone, Copy, Debug, PartialEq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub struct DragConstraints {
53    /// Optional minimum x position.
54    pub min_x: Option<f32>,
55    /// Optional maximum x position.
56    pub max_x: Option<f32>,
57    /// Optional minimum y position.
58    pub min_y: Option<f32>,
59    /// Optional maximum y position.
60    pub max_y: Option<f32>,
61    /// Optional grid size used to snap both axes.
62    pub grid_snap: Option<f32>,
63}
64
65impl Default for DragConstraints {
66    fn default() -> Self {
67        Self::unbounded()
68    }
69}
70
71impl DragConstraints {
72    /// Create unconstrained drag bounds.
73    pub fn unbounded() -> Self {
74        Self {
75            min_x: None,
76            max_x: None,
77            min_y: None,
78            max_y: None,
79            grid_snap: None,
80        }
81    }
82
83    /// Create rectangular drag bounds.
84    pub fn bounded(min_x: f32, max_x: f32, min_y: f32, max_y: f32) -> Self {
85        Self {
86            min_x: Some(min_x),
87            max_x: Some(max_x),
88            min_y: Some(min_y),
89            max_y: Some(max_y),
90            grid_snap: None,
91        }
92    }
93
94    /// Add grid snapping.
95    pub fn with_grid_snap(mut self, grid: f32) -> Self {
96        self.grid_snap = if grid.is_finite() && grid > 0.0 {
97            Some(grid)
98        } else {
99            None
100        };
101        self
102    }
103
104    /// Apply axis filtering, bounds, and grid snapping.
105    pub fn constrain(
106        &self,
107        position: [f32; 2],
108        axis: DragAxis,
109        locked_origin: [f32; 2],
110    ) -> [f32; 2] {
111        let mut x = finite_or_zero(position[0]);
112        let mut y = finite_or_zero(position[1]);
113
114        match axis {
115            DragAxis::Both => {}
116            DragAxis::X => y = locked_origin[1],
117            DragAxis::Y => x = locked_origin[0],
118        }
119
120        x = clamp_optional(x, self.min_x, self.max_x);
121        y = clamp_optional(y, self.min_y, self.max_y);
122
123        if let Some(grid) = self.grid_snap {
124            x = snap(x, grid);
125            y = snap(y, grid);
126            x = clamp_optional(x, self.min_x, self.max_x);
127            y = clamp_optional(y, self.min_y, self.max_y);
128        }
129
130        [x, y]
131    }
132}
133
134/// Stateful drag tracker.
135///
136/// Requires the `alloc` or `std` feature because releasing a drag can create
137/// an [`InertiaN<[f32; 2]>`].
138#[cfg(any(feature = "std", feature = "alloc"))]
139#[derive(Clone, Debug)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub struct DragState {
142    position: [f32; 2],
143    start_position: [f32; 2],
144    start_pointer: [f32; 2],
145    last_position: [f32; 2],
146    velocity: [f32; 2],
147    active_pointer_id: Option<u64>,
148    axis: DragAxis,
149    constraints: DragConstraints,
150    inertia_config: InertiaConfig<[f32; 2]>,
151    velocity_smoothing: f32,
152}
153
154#[cfg(any(feature = "std", feature = "alloc"))]
155impl DragState {
156    /// Create a drag tracker at an initial position.
157    pub fn new(position: [f32; 2]) -> Self {
158        Self {
159            position,
160            start_position: position,
161            start_pointer: [0.0, 0.0],
162            last_position: position,
163            velocity: [0.0, 0.0],
164            active_pointer_id: None,
165            axis: DragAxis::Both,
166            constraints: DragConstraints::unbounded(),
167            inertia_config: InertiaConfig::new(1400.0, 2.0),
168            velocity_smoothing: 0.35,
169        }
170    }
171
172    /// Set the drag axis filter.
173    pub fn axis(mut self, axis: DragAxis) -> Self {
174        self.axis = axis;
175        self
176    }
177
178    /// Set drag constraints.
179    pub fn constraints(mut self, constraints: DragConstraints) -> Self {
180        self.constraints = constraints;
181        self
182    }
183
184    /// Replace drag constraints and clamp the current position into them.
185    pub fn set_constraints(&mut self, constraints: DragConstraints) {
186        self.constraints = constraints;
187        self.position = self
188            .constraints
189            .constrain(self.position, self.axis, self.position);
190        self.last_position = self.position;
191    }
192
193    /// Set the inertia configuration used on pointer release.
194    pub fn inertia_config(mut self, config: InertiaConfig<[f32; 2]>) -> Self {
195        self.inertia_config = config;
196        self
197    }
198
199    /// Set velocity EMA smoothing in `[0.0, 1.0]`.
200    pub fn velocity_smoothing(mut self, smoothing: f32) -> Self {
201        self.velocity_smoothing = smoothing.clamp(0.0, 1.0);
202        self
203    }
204
205    /// Current constrained drag position.
206    pub fn position(&self) -> [f32; 2] {
207        self.position
208    }
209
210    /// Current estimated drag velocity.
211    pub fn velocity(&self) -> [f32; 2] {
212        self.velocity
213    }
214
215    /// Move instantly to a position, applying axis filters and constraints.
216    pub fn snap_to(&mut self, position: [f32; 2]) {
217        self.active_pointer_id = None;
218        self.velocity = [0.0, 0.0];
219        self.position = self
220            .constraints
221            .constrain(position, self.axis, self.position);
222        self.start_position = self.position;
223        self.last_position = self.position;
224    }
225
226    /// `true` while a pointer is captured.
227    pub fn is_dragging(&self) -> bool {
228        self.active_pointer_id.is_some()
229    }
230
231    /// Captured pointer id, if any.
232    pub fn active_pointer_id(&self) -> Option<u64> {
233        self.active_pointer_id
234    }
235
236    /// Capture a pointer and start tracking movement.
237    pub fn on_pointer_down(&mut self, data: PointerData) {
238        self.active_pointer_id = Some(data.pointer_id);
239        self.start_pointer = data.position();
240        self.start_position = self.position;
241        self.last_position = self.position;
242        self.velocity = [0.0, 0.0];
243    }
244
245    /// Update position and velocity from a pointer move.
246    pub fn on_pointer_move(&mut self, data: PointerData, dt: f32) {
247        if self.active_pointer_id != Some(data.pointer_id) {
248            return;
249        }
250
251        let delta = [
252            data.x - self.start_pointer[0],
253            data.y - self.start_pointer[1],
254        ];
255        let raw_position = [
256            self.start_position[0] + delta[0],
257            self.start_position[1] + delta[1],
258        ];
259        let constrained = self
260            .constraints
261            .constrain(raw_position, self.axis, self.start_position);
262
263        let dt = dt.max(0.0);
264        if dt > 0.0 {
265            let instant = [
266                (constrained[0] - self.last_position[0]) / dt,
267                (constrained[1] - self.last_position[1]) / dt,
268            ];
269            let alpha = self.velocity_smoothing;
270            self.velocity = [
271                self.velocity[0] * (1.0 - alpha) + instant[0] * alpha,
272                self.velocity[1] * (1.0 - alpha) + instant[1] * alpha,
273            ];
274        }
275
276        self.position = constrained;
277        self.last_position = constrained;
278    }
279
280    /// Release the captured pointer and create inertia if velocity is high enough.
281    pub fn on_pointer_up(&mut self, data: PointerData) -> Option<InertiaN<[f32; 2]>> {
282        if self.active_pointer_id != Some(data.pointer_id) {
283            return None;
284        }
285
286        self.active_pointer_id = None;
287        let velocity = match self.axis {
288            DragAxis::Both => self.velocity,
289            DragAxis::X => [self.velocity[0], 0.0],
290            DragAxis::Y => [0.0, self.velocity[1]],
291        };
292
293        if velocity[0].abs() <= self.inertia_config.min_velocity
294            && velocity[1].abs() <= self.inertia_config.min_velocity
295        {
296            self.velocity = [0.0, 0.0];
297            return None;
298        }
299
300        let mut config = self.inertia_config.clone();
301        config.bounds = self.bounds_for_inertia();
302        let mut inertia = InertiaN::new(config, self.position);
303        inertia.kick(velocity);
304        Some(inertia)
305    }
306
307    fn bounds_for_inertia(&self) -> Option<InertiaBounds<[f32; 2]>> {
308        match (
309            self.constraints.min_x,
310            self.constraints.max_x,
311            self.constraints.min_y,
312            self.constraints.max_y,
313        ) {
314            (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) => {
315                Some(InertiaBounds::new([min_x, min_y], [max_x, max_y]))
316            }
317            _ => self.inertia_config.bounds.clone(),
318        }
319    }
320}
321
322#[inline]
323fn finite_or_zero(value: f32) -> f32 {
324    if value.is_finite() { value } else { 0.0 }
325}
326
327#[inline]
328fn clamp_optional(value: f32, min: Option<f32>, max: Option<f32>) -> f32 {
329    match (min, max) {
330        (Some(a), Some(b)) => value.clamp(a.min(b), a.max(b)),
331        (Some(min), None) => value.max(min),
332        (None, Some(max)) => value.min(max),
333        (None, None) => value,
334    }
335}
336
337#[inline]
338fn snap(value: f32, grid: f32) -> f32 {
339    if grid > 0.0 {
340        libm::roundf(value / grid) * grid
341    } else {
342        value
343    }
344}
345
346#[cfg(all(test, any(feature = "std", feature = "alloc")))]
347mod tests {
348    use super::*;
349
350    #[cfg(any(feature = "std", feature = "alloc"))]
351    #[test]
352    fn drag_respects_axis_and_constraints() {
353        let mut drag = DragState::new([0.0, 5.0])
354            .axis(DragAxis::X)
355            .constraints(DragConstraints::bounded(-10.0, 10.0, -10.0, 10.0));
356
357        drag.on_pointer_down(PointerData::new(0.0, 0.0, 1));
358        drag.on_pointer_move(PointerData::new(30.0, 40.0, 1), 0.016);
359
360        assert_eq!(drag.position(), [10.0, 5.0]);
361    }
362
363    #[cfg(any(feature = "std", feature = "alloc"))]
364    #[test]
365    fn drag_ignores_wrong_pointer_id() {
366        let mut drag = DragState::new([0.0, 0.0]);
367        drag.on_pointer_down(PointerData::new(0.0, 0.0, 7));
368        drag.on_pointer_move(PointerData::new(20.0, 0.0, 8), 0.016);
369        assert_eq!(drag.position(), [0.0, 0.0]);
370    }
371
372    #[cfg(any(feature = "std", feature = "alloc"))]
373    #[test]
374    fn drag_estimates_velocity_with_ema() {
375        let mut drag = DragState::new([0.0, 0.0]).velocity_smoothing(1.0);
376        drag.on_pointer_down(PointerData::new(0.0, 0.0, 1));
377        drag.on_pointer_move(PointerData::new(16.0, 0.0, 1), 0.016);
378        assert!((drag.velocity()[0] - 1000.0).abs() < 0.01);
379        assert_eq!(drag.velocity()[1], 0.0);
380    }
381
382    #[cfg(any(feature = "std", feature = "alloc"))]
383    #[test]
384    fn grid_snap_applies_to_position() {
385        let mut drag = DragState::new([0.0, 0.0])
386            .constraints(DragConstraints::unbounded().with_grid_snap(10.0));
387        drag.on_pointer_down(PointerData::new(0.0, 0.0, 1));
388        drag.on_pointer_move(PointerData::new(16.0, 24.0, 1), 0.016);
389        assert_eq!(drag.position(), [20.0, 20.0]);
390    }
391
392    #[cfg(any(feature = "std", feature = "alloc"))]
393    #[test]
394    fn snap_to_and_updated_constraints_clamp_position() {
395        let mut drag = DragState::new([0.0, 0.0])
396            .constraints(DragConstraints::bounded(-10.0, 10.0, -8.0, 8.0));
397
398        drag.snap_to([40.0, -30.0]);
399        assert_eq!(drag.position(), [10.0, -8.0]);
400
401        drag.set_constraints(DragConstraints::bounded(-4.0, 4.0, -3.0, 3.0));
402        assert_eq!(drag.position(), [4.0, -3.0]);
403        assert!(!drag.is_dragging());
404        assert_eq!(drag.velocity(), [0.0, 0.0]);
405    }
406}