Skip to main content

animato_js/
physics.rs

1//! Physics and gesture bindings.
2
3use crate::error::{JsResult, js_error, non_negative};
4use crate::tween::lock;
5use crate::types::{f32_array, normalize_name, vec2};
6use animato_core::Update;
7use animato_physics::{
8    DragAxis, DragConstraints, DragState as CoreDragState, Gesture, GestureConfig,
9    GestureRecognizer as CoreGestureRecognizer, Inertia as CoreInertia, InertiaBounds,
10    InertiaConfig, InertiaN, PointerData, SwipeDirection,
11};
12use js_sys::Float32Array;
13use std::sync::{Arc, Mutex};
14use wasm_bindgen::prelude::*;
15
16fn inertia_config(friction: f32, min_velocity: f32) -> InertiaConfig<f32> {
17    InertiaConfig::new(
18        non_negative(friction, 1400.0),
19        non_negative(min_velocity, 2.0),
20    )
21}
22
23fn inertia_config_2d(friction: f32, min_velocity: f32) -> InertiaConfig<[f32; 2]> {
24    InertiaConfig::new(
25        non_negative(friction, 1400.0),
26        non_negative(min_velocity, 2.0),
27    )
28}
29
30fn axis(name: &str) -> JsResult<DragAxis> {
31    match normalize_name(name).as_str() {
32        "both" | "xy" => Ok(DragAxis::Both),
33        "x" => Ok(DragAxis::X),
34        "y" => Ok(DragAxis::Y),
35        _ => Err(js_error(format!("unknown drag axis `{name}`"))),
36    }
37}
38
39/// Scalar inertia animation.
40#[wasm_bindgen(js_name = Inertia)]
41#[derive(Clone, Debug)]
42pub struct Inertia {
43    inner: Arc<Mutex<CoreInertia>>,
44}
45
46#[wasm_bindgen(js_class = Inertia)]
47impl Inertia {
48    /// Create inertia with initial position, velocity, friction, and min velocity.
49    #[wasm_bindgen(constructor)]
50    pub fn new(position: f32, velocity: f32, friction: f32, min_velocity: f32) -> Self {
51        let mut inertia =
52            CoreInertia::with_position(inertia_config(friction, min_velocity), position);
53        inertia.kick(velocity);
54        Self {
55            inner: Arc::new(Mutex::new(inertia)),
56        }
57    }
58
59    /// Use a named preset: `smooth`, `snappy`, or `heavy`.
60    #[wasm_bindgen(js_name = withPreset)]
61    pub fn with_preset(position: f32, velocity: f32, preset: &str) -> Result<Inertia, JsValue> {
62        let config = match normalize_name(preset).as_str() {
63            "smooth" => InertiaConfig::smooth(),
64            "snappy" => InertiaConfig::snappy(),
65            "heavy" => InertiaConfig::heavy(),
66            _ => return Err(js_error(format!("unknown inertia preset `{preset}`"))),
67        };
68        let mut inertia = CoreInertia::with_position(config, position);
69        inertia.kick(velocity);
70        Ok(Self {
71            inner: Arc::new(Mutex::new(inertia)),
72        })
73    }
74
75    /// Set inclusive bounds.
76    #[wasm_bindgen(js_name = setBounds)]
77    pub fn set_bounds(&self, min: f32, max: f32) {
78        lock(&self.inner).config.bounds = Some(InertiaBounds::new(min, max));
79    }
80
81    /// Start from a new velocity.
82    pub fn kick(&self, velocity: f32) {
83        lock(&self.inner).kick(velocity);
84    }
85
86    /// Advance by `dt` seconds.
87    pub fn update(&self, dt: f32) -> bool {
88        lock(&self.inner).update(dt)
89    }
90
91    /// Current position.
92    pub fn position(&self) -> f32 {
93        lock(&self.inner).position()
94    }
95
96    /// Current velocity.
97    pub fn velocity(&self) -> f32 {
98        lock(&self.inner).velocity()
99    }
100
101    /// Snap instantly.
102    #[wasm_bindgen(js_name = snapTo)]
103    pub fn snap_to(&self, position: f32) {
104        lock(&self.inner).snap_to(position);
105    }
106
107    /// Whether inertia has settled.
108    #[wasm_bindgen(js_name = isSettled)]
109    pub fn is_settled(&self) -> bool {
110        lock(&self.inner).is_settled()
111    }
112
113    pub(crate) fn shared(&self) -> SharedInertia {
114        SharedInertia {
115            inner: Arc::clone(&self.inner),
116        }
117    }
118}
119
120/// Shared scalar inertia update adapter.
121#[derive(Clone, Debug)]
122pub(crate) struct SharedInertia {
123    inner: Arc<Mutex<CoreInertia>>,
124}
125
126impl Update for SharedInertia {
127    fn update(&mut self, dt: f32) -> bool {
128        lock(&self.inner).update(dt)
129    }
130}
131
132/// 2D inertia animation.
133#[wasm_bindgen(js_name = Inertia2D)]
134#[derive(Clone, Debug)]
135pub struct Inertia2D {
136    inner: Arc<Mutex<InertiaN<[f32; 2]>>>,
137}
138
139impl Inertia2D {
140    fn from_core(inner: InertiaN<[f32; 2]>) -> Self {
141        Self {
142            inner: Arc::new(Mutex::new(inner)),
143        }
144    }
145}
146
147#[wasm_bindgen(js_class = Inertia2D)]
148impl Inertia2D {
149    /// Create 2D inertia.
150    #[wasm_bindgen(constructor)]
151    pub fn new(
152        x: f32,
153        y: f32,
154        velocity_x: f32,
155        velocity_y: f32,
156        friction: f32,
157        min_velocity: f32,
158    ) -> Self {
159        let mut inertia = InertiaN::new(inertia_config_2d(friction, min_velocity), [x, y]);
160        inertia.kick([velocity_x, velocity_y]);
161        Self::from_core(inertia)
162    }
163
164    /// Set inclusive 2D bounds.
165    #[wasm_bindgen(js_name = setBounds)]
166    pub fn set_bounds(&self, min_x: f32, max_x: f32, min_y: f32, max_y: f32) {
167        let mut current = lock(&self.inner);
168        let pos = current.position();
169        let vel = current.velocity();
170        let mut config = inertia_config_2d(1400.0, 2.0);
171        config.bounds = Some(InertiaBounds::new([min_x, min_y], [max_x, max_y]));
172        let mut next = InertiaN::new(config, pos);
173        next.kick(vel);
174        *current = next;
175    }
176
177    /// Start from a new velocity.
178    pub fn kick(&self, velocity_x: f32, velocity_y: f32) {
179        lock(&self.inner).kick([velocity_x, velocity_y]);
180    }
181
182    /// Advance by `dt` seconds.
183    pub fn update(&self, dt: f32) -> bool {
184        lock(&self.inner).update(dt)
185    }
186
187    /// Current position.
188    #[wasm_bindgen(js_name = toArray)]
189    pub fn to_array(&self) -> Float32Array {
190        let pos = lock(&self.inner).position();
191        f32_array(&pos)
192    }
193
194    /// Current velocity.
195    #[wasm_bindgen(js_name = velocityArray)]
196    pub fn velocity_array(&self) -> Float32Array {
197        let velocity = lock(&self.inner).velocity();
198        f32_array(&velocity)
199    }
200
201    /// Whether inertia has settled.
202    #[wasm_bindgen(js_name = isSettled)]
203    pub fn is_settled(&self) -> bool {
204        lock(&self.inner).is_settled()
205    }
206
207    pub(crate) fn shared(&self) -> SharedInertia2D {
208        SharedInertia2D {
209            inner: Arc::clone(&self.inner),
210        }
211    }
212}
213
214/// Shared 2D inertia update adapter.
215#[derive(Clone, Debug)]
216pub(crate) struct SharedInertia2D {
217    inner: Arc<Mutex<InertiaN<[f32; 2]>>>,
218}
219
220impl Update for SharedInertia2D {
221    fn update(&mut self, dt: f32) -> bool {
222        lock(&self.inner).update(dt)
223    }
224}
225
226/// Pointer drag tracker.
227#[wasm_bindgen(js_name = DragState)]
228#[derive(Clone, Debug)]
229pub struct DragState {
230    inner: Arc<Mutex<CoreDragState>>,
231}
232
233#[wasm_bindgen(js_class = DragState)]
234impl DragState {
235    /// Create a drag tracker at an initial position.
236    #[wasm_bindgen(constructor)]
237    pub fn new(x: f32, y: f32) -> Self {
238        Self {
239            inner: Arc::new(Mutex::new(CoreDragState::new([x, y]))),
240        }
241    }
242
243    /// Set axis filter.
244    #[wasm_bindgen(js_name = setAxis)]
245    pub fn set_axis(&self, axis_name: &str) -> Result<(), JsValue> {
246        let mut drag = lock(&self.inner);
247        let next =
248            core::mem::replace(&mut *drag, CoreDragState::new([0.0, 0.0])).axis(axis(axis_name)?);
249        *drag = next;
250        Ok(())
251    }
252
253    /// Set rectangular bounds.
254    #[wasm_bindgen(js_name = setBounds)]
255    pub fn set_bounds(&self, min_x: f32, max_x: f32, min_y: f32, max_y: f32) {
256        lock(&self.inner).set_constraints(DragConstraints::bounded(min_x, max_x, min_y, max_y));
257    }
258
259    /// Set grid snap size.
260    #[wasm_bindgen(js_name = setGridSnap)]
261    pub fn set_grid_snap(&self, grid: f32) {
262        lock(&self.inner).set_constraints(DragConstraints::unbounded().with_grid_snap(grid));
263    }
264
265    /// Current position.
266    #[wasm_bindgen(js_name = toArray)]
267    pub fn to_array(&self) -> Float32Array {
268        let pos = lock(&self.inner).position();
269        vec2(pos[0], pos[1])
270    }
271
272    /// Current velocity.
273    #[wasm_bindgen(js_name = velocityArray)]
274    pub fn velocity_array(&self) -> Float32Array {
275        let velocity = lock(&self.inner).velocity();
276        vec2(velocity[0], velocity[1])
277    }
278
279    /// Whether pointer is captured.
280    #[wasm_bindgen(js_name = isDragging)]
281    pub fn is_dragging(&self) -> bool {
282        lock(&self.inner).is_dragging()
283    }
284
285    /// Pointer down.
286    #[wasm_bindgen(js_name = pointerDown)]
287    pub fn pointer_down(&self, x: f32, y: f32, pointer_id: u32) {
288        lock(&self.inner).on_pointer_down(PointerData::new(x, y, pointer_id as u64));
289    }
290
291    /// Pointer move with seconds delta.
292    #[wasm_bindgen(js_name = pointerMove)]
293    pub fn pointer_move(&self, x: f32, y: f32, pointer_id: u32, dt: f32) {
294        lock(&self.inner).on_pointer_move(
295            PointerData::new(x, y, pointer_id as u64),
296            non_negative(dt, 0.0),
297        );
298    }
299
300    /// Pointer up. Returns inertia when release velocity is high enough.
301    #[wasm_bindgen(js_name = pointerUp)]
302    pub fn pointer_up(&self, x: f32, y: f32, pointer_id: u32) -> Option<Inertia2D> {
303        lock(&self.inner)
304            .on_pointer_up(PointerData::new(x, y, pointer_id as u64))
305            .map(Inertia2D::from_core)
306    }
307
308    /// Snap instantly to a position.
309    #[wasm_bindgen(js_name = snapTo)]
310    pub fn snap_to(&self, x: f32, y: f32) {
311        lock(&self.inner).snap_to([x, y]);
312    }
313}
314
315/// Pointer gesture recognizer.
316#[wasm_bindgen(js_name = GestureRecognizer)]
317#[derive(Clone, Debug)]
318pub struct GestureRecognizer {
319    inner: Arc<Mutex<CoreGestureRecognizer>>,
320}
321
322#[wasm_bindgen(js_class = GestureRecognizer)]
323impl GestureRecognizer {
324    /// Create a recognizer with default thresholds.
325    #[wasm_bindgen(constructor)]
326    pub fn new() -> Self {
327        Self {
328            inner: Arc::new(Mutex::new(CoreGestureRecognizer::default())),
329        }
330    }
331
332    /// Set custom thresholds.
333    #[wasm_bindgen(js_name = setConfig)]
334    pub fn set_config(
335        &self,
336        tap_max_distance: f32,
337        tap_max_duration: f32,
338        swipe_min_distance: f32,
339        long_press_duration: f32,
340        double_tap_max_interval: f32,
341    ) {
342        *lock(&self.inner) = CoreGestureRecognizer::new(GestureConfig {
343            tap_max_distance: non_negative(tap_max_distance, 8.0),
344            tap_max_duration: non_negative(tap_max_duration, 0.25),
345            swipe_min_distance: non_negative(swipe_min_distance, 40.0),
346            long_press_duration: non_negative(long_press_duration, 0.5),
347            double_tap_max_interval: non_negative(double_tap_max_interval, 0.3),
348        });
349    }
350
351    /// Pointer down at timestamp seconds.
352    #[wasm_bindgen(js_name = pointerDown)]
353    pub fn pointer_down(&self, x: f32, y: f32, pointer_id: u32, time_seconds: f32) {
354        lock(&self.inner).on_pointer_down(
355            PointerData::new(x, y, pointer_id as u64),
356            non_negative(time_seconds, 0.0),
357        );
358    }
359
360    /// Pointer move at timestamp seconds.
361    #[wasm_bindgen(js_name = pointerMove)]
362    pub fn pointer_move(&self, x: f32, y: f32, pointer_id: u32, time_seconds: f32) {
363        lock(&self.inner).on_pointer_move(
364            PointerData::new(x, y, pointer_id as u64),
365            non_negative(time_seconds, 0.0),
366        );
367    }
368
369    /// Pointer up. Returns a gesture object or `undefined`.
370    #[wasm_bindgen(js_name = pointerUp)]
371    pub fn pointer_up(
372        &self,
373        x: f32,
374        y: f32,
375        pointer_id: u32,
376        time_seconds: f32,
377    ) -> Result<JsValue, JsValue> {
378        let gesture = lock(&self.inner).on_pointer_up(
379            PointerData::new(x, y, pointer_id as u64),
380            non_negative(time_seconds, 0.0),
381        );
382        match gesture {
383            Some(gesture) => Ok(gesture_to_value(gesture)),
384            None => Ok(JsValue::UNDEFINED),
385        }
386    }
387}
388
389impl Default for GestureRecognizer {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395fn gesture_to_value(gesture: Gesture) -> JsValue {
396    match gesture {
397        Gesture::Tap { position } => object(&[
398            ("type", JsValue::from_str("tap")),
399            ("x", JsValue::from_f64(position[0] as f64)),
400            ("y", JsValue::from_f64(position[1] as f64)),
401        ]),
402        Gesture::DoubleTap { position } => object(&[
403            ("type", JsValue::from_str("doubleTap")),
404            ("x", JsValue::from_f64(position[0] as f64)),
405            ("y", JsValue::from_f64(position[1] as f64)),
406        ]),
407        Gesture::LongPress { position, duration } => object(&[
408            ("type", JsValue::from_str("longPress")),
409            ("x", JsValue::from_f64(position[0] as f64)),
410            ("y", JsValue::from_f64(position[1] as f64)),
411            ("duration", JsValue::from_f64(duration as f64)),
412        ]),
413        Gesture::Swipe {
414            direction,
415            velocity,
416            distance,
417        } => object(&[
418            ("type", JsValue::from_str("swipe")),
419            (
420                "direction",
421                JsValue::from_str(match direction {
422                    SwipeDirection::Up => "up",
423                    SwipeDirection::Down => "down",
424                    SwipeDirection::Left => "left",
425                    SwipeDirection::Right => "right",
426                }),
427            ),
428            ("velocity", JsValue::from_f64(velocity as f64)),
429            ("distance", JsValue::from_f64(distance as f64)),
430        ]),
431        Gesture::Pinch { scale, center } => object(&[
432            ("type", JsValue::from_str("pinch")),
433            ("scale", JsValue::from_f64(scale as f64)),
434            ("centerX", JsValue::from_f64(center[0] as f64)),
435            ("centerY", JsValue::from_f64(center[1] as f64)),
436        ]),
437        Gesture::Rotation {
438            angle_delta,
439            center,
440        } => object(&[
441            ("type", JsValue::from_str("rotation")),
442            ("angleDelta", JsValue::from_f64(angle_delta as f64)),
443            ("centerX", JsValue::from_f64(center[0] as f64)),
444            ("centerY", JsValue::from_f64(center[1] as f64)),
445        ]),
446    }
447}
448
449fn object(entries: &[(&str, JsValue)]) -> JsValue {
450    let object = js_sys::Object::new();
451    for (key, value) in entries {
452        let _ = js_sys::Reflect::set(&object, &JsValue::from_str(key), value);
453    }
454    object.into()
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn inertia_moves() {
463        let inertia = Inertia::new(0.0, 100.0, 1000.0, 1.0);
464        inertia.update(0.016);
465        assert!(inertia.position() > 0.0);
466    }
467
468    #[test]
469    fn drag_tracks_position() {
470        let drag = DragState::new(0.0, 0.0);
471        drag.pointer_down(0.0, 0.0, 1);
472        drag.pointer_move(20.0, 10.0, 1, 0.016);
473        assert!(drag.is_dragging());
474    }
475}