leafwing_input_manager/input_processing/dual_axis/
circle.rs

1//! Circular range processors for dual-axis inputs
2
3use std::fmt::Debug;
4use std::hash::{Hash, Hasher};
5
6use bevy::{
7    math::FloatOrd,
8    prelude::{Reflect, Vec2},
9};
10use serde::{Deserialize, Serialize};
11
12use super::DualAxisProcessor;
13
14/// Specifies a circular region defining acceptable ranges for valid dual-axis inputs,
15/// with a radius defining the maximum threshold magnitude,
16/// restricting all values stay within intended limits
17/// to avoid unexpected behavior caused by extreme inputs.
18///
19/// ```rust
20/// use bevy::prelude::*;
21/// use leafwing_input_manager::prelude::*;
22///
23/// // Restrict magnitudes to no greater than 2
24/// let bounds = CircleBounds::new(2.0);
25///
26/// for x in -300..300 {
27///     let x = x as f32 * 0.01;
28///     for y in -300..300 {
29///         let y = y as f32 * 0.01;
30///         let value = Vec2::new(x, y);
31///         assert_eq!(bounds.clamp(value), value.clamp_length_max(2.0));
32///     }
33/// }
34/// ```
35#[doc(alias = "RadialBounds")]
36#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)]
37#[must_use]
38pub struct CircleBounds {
39    /// The maximum radius of the circle.
40    pub(crate) radius: f32,
41}
42
43impl CircleBounds {
44    /// Unlimited [`CircleBounds`].
45    pub const FULL_RANGE: Self = Self { radius: f32::MAX };
46
47    /// Creates a [`CircleBounds`] that restricts input values to a maximum magnitude.
48    ///
49    /// # Requirements
50    ///
51    /// - `max` >= `0.0`.
52    ///
53    /// # Panics
54    ///
55    /// Panics if the requirements aren't met.
56    #[doc(alias = "magnitude")]
57    #[doc(alias = "from_radius")]
58    #[inline]
59    pub fn new(max: f32) -> Self {
60        assert!(max >= 0.0);
61        Self { radius: max }
62    }
63
64    /// Returns the radius of the bounds.
65    #[must_use]
66    #[inline]
67    pub fn radius(&self) -> f32 {
68        self.radius
69    }
70
71    /// Is the `input_value` is within the bounds?
72    #[must_use]
73    #[inline]
74    pub fn contains(&self, input_value: Vec2) -> bool {
75        input_value.length() <= self.radius
76    }
77
78    /// Clamps the magnitude of `input_value` within the bounds.
79    #[must_use]
80    #[inline]
81    pub fn clamp(&self, input_value: Vec2) -> Vec2 {
82        input_value.clamp_length_max(self.radius)
83    }
84}
85
86impl Default for CircleBounds {
87    /// Creates a [`CircleBounds`] that restricts the values to a maximum magnitude of `1.0`.
88    #[inline]
89    fn default() -> Self {
90        Self::new(1.0)
91    }
92}
93
94impl From<CircleBounds> for DualAxisProcessor {
95    fn from(value: CircleBounds) -> Self {
96        Self::CircleBounds(value)
97    }
98}
99
100impl Eq for CircleBounds {}
101
102impl Hash for CircleBounds {
103    fn hash<H: Hasher>(&self, state: &mut H) {
104        FloatOrd(self.radius).hash(state);
105    }
106}
107
108/// Specifies a cross-shaped region for excluding dual-axis inputs,
109/// with a radius defining the maximum excluded magnitude,
110/// helping filter out minor fluctuations and unintended movements.
111///
112/// ```rust
113/// use bevy::prelude::*;
114/// use leafwing_input_manager::prelude::*;
115///
116/// // Exclude magnitudes less than or equal to 0.2
117/// let exclusion = CircleExclusion::new(0.2);
118///
119/// for x in -300..300 {
120///     let x = x as f32 * 0.01;
121///     for y in -300..300 {
122///         let y = y as f32 * 0.01;
123///         let value = Vec2::new(x, y);
124///
125///         if value.length() <= 0.2 {
126///             assert!(exclusion.contains(value));
127///             assert_eq!(exclusion.exclude(value), Vec2::ZERO);
128///         } else {
129///             assert!(!exclusion.contains(value));
130///             assert_eq!(exclusion.exclude(value), value);
131///         }
132///     }
133/// }
134/// ```
135#[doc(alias = "RadialExclusion")]
136#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)]
137#[must_use]
138pub struct CircleExclusion {
139    /// Pre-calculated squared radius of the circle, prmessageing redundant calculations.
140    pub(crate) radius_squared: f32,
141}
142
143impl CircleExclusion {
144    /// Zero-size [`CircleExclusion`], leaving values as is.
145    pub const ZERO: Self = Self {
146        radius_squared: 0.0,
147    };
148
149    /// Creates a [`CircleExclusion`] that ignores input values below a minimum magnitude.
150    ///
151    /// # Requirements
152    ///
153    /// - `radius` >= `0.0`.
154    ///
155    /// # Panics
156    ///
157    /// Panics if the requirements aren't met.
158    #[doc(alias = "magnitude")]
159    #[doc(alias = "from_radius")]
160    #[inline]
161    pub fn new(threshold: f32) -> Self {
162        assert!(threshold >= 0.0);
163        Self {
164            radius_squared: threshold.powi(2),
165        }
166    }
167
168    /// Returns the radius of the circle.
169    #[must_use]
170    #[inline]
171    pub fn radius(&self) -> f32 {
172        self.radius_squared.sqrt()
173    }
174
175    /// Checks whether the `input_value` should be excluded.
176    #[must_use]
177    #[inline]
178    pub fn contains(&self, input_value: Vec2) -> bool {
179        input_value.length_squared() <= self.radius_squared
180    }
181
182    /// Creates a [`CircleDeadZone`] using `self` as the exclusion range.
183    #[inline]
184    pub fn scaled(self) -> CircleDeadZone {
185        CircleDeadZone::new(self.radius())
186    }
187
188    /// Excludes input values with a magnitude less than the `radius`.
189    #[must_use]
190    #[inline]
191    pub fn exclude(&self, input_value: Vec2) -> Vec2 {
192        if self.contains(input_value) {
193            Vec2::ZERO
194        } else {
195            input_value
196        }
197    }
198}
199
200impl Default for CircleExclusion {
201    /// Creates a [`CircleExclusion`] that ignores input values below a minimum magnitude of `0.1`.
202    fn default() -> Self {
203        Self::new(0.1)
204    }
205}
206
207impl From<CircleExclusion> for DualAxisProcessor {
208    fn from(value: CircleExclusion) -> Self {
209        Self::CircleExclusion(value)
210    }
211}
212
213impl Eq for CircleExclusion {}
214
215impl Hash for CircleExclusion {
216    fn hash<H: Hasher>(&self, state: &mut H) {
217        FloatOrd(self.radius_squared).hash(state);
218    }
219}
220
221/// A scaled version of [`CircleExclusion`] with the bounds
222/// set to [`CircleBounds::new(1.0)`](CircleBounds::default)
223/// that normalizes non-excluded input values into the "live zone",
224/// the remaining range within the bounds after dead zone exclusion.
225///
226/// It is worth considering that this normalizer reduces input values on diagonals.
227/// If that is not your goal, you might want to explore alternative normalizers.
228///
229/// ```rust
230/// use bevy::prelude::*;
231/// use leafwing_input_manager::prelude::*;
232///
233/// // Exclude magnitudes less than or equal to 0.2
234/// let deadzone = CircleDeadZone::new(0.2);
235///
236/// for x in -300..300 {
237///     let x = x as f32 * 0.01;
238///     for y in -300..300 {
239///         let y = y as f32 * 0.01;
240///         let value = Vec2::new(x, y);
241///
242///         // Values within the dead zone are treated as zeros.
243///         if value.length() <= 0.2 {
244///             assert!(deadzone.within_exclusion(value));
245///             assert_eq!(deadzone.normalize(value), Vec2::ZERO);
246///         }
247///
248///         // Values within the live zone are scaled linearly.
249///         else if value.length() <= 1.0 {
250///             assert!(deadzone.within_livezone(value));
251///
252///             let expected_scale = f32::inverse_lerp(0.2, 1.0, value.length());
253///             let expected = value.normalize() * expected_scale;
254///             let delta = (deadzone.normalize(value) - expected).abs();
255///             assert!(delta.x <= 0.00001);
256///             assert!(delta.y <= 0.00001);
257///         }
258///
259///         // Values outside the bounds are restricted to the region.
260///         else {
261///             assert!(!deadzone.within_bounds(value));
262///
263///             let expected = value.clamp_length_max(1.0);
264///             let delta = (deadzone.normalize(value) - expected).abs();
265///             assert!(delta.x <= 0.00001);
266///             assert!(delta.y <= 0.00001);
267///         }
268///     }
269/// }
270/// ```
271#[doc(alias = "RadialDeadZone")]
272#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)]
273#[must_use]
274pub struct CircleDeadZone {
275    /// The radius of the circle.
276    pub(crate) radius: f32,
277
278    /// Pre-calculated reciprocal of the live zone size, prmessageing division during normalization.
279    pub(crate) livezone_recip: f32,
280}
281
282impl CircleDeadZone {
283    /// Zero-size [`CircleDeadZone`], only restricting values to a maximum magnitude of `1.0`.
284    pub const ZERO: Self = Self {
285        radius: 0.0,
286        livezone_recip: 1.0,
287    };
288
289    /// Creates a [`CircleDeadZone`] that excludes input values below a minimum magnitude.
290    ///
291    /// # Requirements
292    ///
293    /// - `threshold` >= `0.0`.
294    ///
295    /// # Panics
296    ///
297    /// Panics if the requirements aren't met.
298    #[doc(alias = "magnitude")]
299    #[doc(alias = "from_radius")]
300    #[inline]
301    pub fn new(threshold: f32) -> Self {
302        let bounds = CircleBounds::default();
303        Self {
304            radius: threshold,
305            livezone_recip: (bounds.radius - threshold).recip(),
306        }
307    }
308
309    /// Returns the radius of the circle.
310    #[must_use]
311    #[inline]
312    pub fn radius(&self) -> f32 {
313        self.radius
314    }
315
316    /// Returns the [`CircleExclusion`] used by this deadzone.
317    #[inline]
318    pub fn exclusion(&self) -> CircleExclusion {
319        CircleExclusion::new(self.radius)
320    }
321
322    /// Returns the [`CircleBounds`] used by this deadzone.
323    #[inline]
324    pub fn bounds(&self) -> CircleBounds {
325        CircleBounds::default()
326    }
327
328    /// Returns the minimum and maximum radii of the live zone used by this deadzone.
329    ///
330    /// In simple terms, this returns `(self.radius, bounds.radius)`.
331    #[must_use]
332    #[inline]
333    pub fn livezone_min_max(&self) -> (f32, f32) {
334        (self.radius, self.bounds().radius)
335    }
336
337    /// Is the given `input_value` within the exclusion range?
338    #[must_use]
339    #[inline]
340    pub fn within_exclusion(&self, input_value: Vec2) -> bool {
341        self.exclusion().contains(input_value)
342    }
343
344    /// Is the given `input_value` within the bounds?
345    #[must_use]
346    #[inline]
347    pub fn within_bounds(&self, input_value: Vec2) -> bool {
348        self.bounds().contains(input_value)
349    }
350
351    /// Is the given `input_value` within the live zone?
352    #[must_use]
353    #[inline]
354    pub fn within_livezone(&self, input_value: Vec2) -> bool {
355        let input_length = input_value.length();
356        let (min, max) = self.livezone_min_max();
357        min <= input_length && input_length <= max
358    }
359
360    /// Normalizes input values into the live zone.
361    #[must_use]
362    pub fn normalize(&self, input_value: Vec2) -> Vec2 {
363        let input_length = input_value.length();
364        if input_length == 0.0 {
365            return Vec2::ZERO;
366        }
367
368        // Clamp out-of-bounds values to a maximum magnitude of 1.0,
369        // and then exclude values within the dead zone,
370        // and finally linearly scale the result to the live zone.
371        let (deadzone, bound) = self.livezone_min_max();
372        let clamped_input_length = input_length.min(bound);
373        let offset_to_deadzone = (clamped_input_length - deadzone).max(0.0);
374        let magnitude_scale = (offset_to_deadzone * self.livezone_recip) / input_length;
375        input_value * magnitude_scale
376    }
377}
378
379impl Default for CircleDeadZone {
380    /// Creates a [`CircleDeadZone`] that excludes input values below a minimum magnitude of `0.1`.
381    #[inline]
382    fn default() -> Self {
383        CircleDeadZone::new(0.1)
384    }
385}
386
387impl From<CircleDeadZone> for DualAxisProcessor {
388    fn from(value: CircleDeadZone) -> Self {
389        Self::CircleDeadZone(value)
390    }
391}
392
393impl Eq for CircleDeadZone {}
394
395impl Hash for CircleDeadZone {
396    fn hash<H: Hasher>(&self, state: &mut H) {
397        FloatOrd(self.radius).hash(state);
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use bevy::prelude::FloatExt;
405
406    #[test]
407    fn test_circle_value_bounds() {
408        fn test_bounds(bounds: CircleBounds, radius: f32) {
409            assert_eq!(bounds.radius(), radius);
410
411            let processor = DualAxisProcessor::CircleBounds(bounds);
412            assert_eq!(DualAxisProcessor::from(bounds), processor);
413
414            for x in -300..300 {
415                let x = x as f32 * 0.01;
416                for y in -300..300 {
417                    let y = y as f32 * 0.01;
418                    let value = Vec2::new(x, y);
419
420                    assert_eq!(processor.process(value), bounds.clamp(value));
421
422                    if value.length() <= radius {
423                        assert!(bounds.contains(value));
424                    } else {
425                        assert!(!bounds.contains(value));
426                    }
427
428                    let expected = value.clamp_length_max(radius);
429                    let delta = (bounds.clamp(value) - expected).abs();
430                    assert!(delta.x <= f32::EPSILON);
431                    assert!(delta.y <= f32::EPSILON);
432                }
433            }
434        }
435
436        let bounds = CircleBounds::FULL_RANGE;
437        test_bounds(bounds, f32::MAX);
438
439        let bounds = CircleBounds::default();
440        test_bounds(bounds, 1.0);
441
442        let bounds = CircleBounds::new(2.0);
443        test_bounds(bounds, 2.0);
444    }
445
446    #[test]
447    fn test_circle_exclusion() {
448        fn test_exclusion(exclusion: CircleExclusion, radius: f32) {
449            assert_eq!(exclusion.radius(), radius);
450
451            let processor = DualAxisProcessor::CircleExclusion(exclusion);
452            assert_eq!(DualAxisProcessor::from(exclusion), processor);
453
454            for x in -300..300 {
455                let x = x as f32 * 0.01;
456                for y in -300..300 {
457                    let y = y as f32 * 0.01;
458                    let value = Vec2::new(x, y);
459
460                    assert_eq!(processor.process(value), exclusion.exclude(value));
461
462                    if value.length() <= radius {
463                        assert!(exclusion.contains(value));
464                        assert_eq!(exclusion.exclude(value), Vec2::ZERO);
465                    } else {
466                        assert!(!exclusion.contains(value));
467                        assert_eq!(exclusion.exclude(value), value);
468                    }
469                }
470            }
471        }
472
473        let exclusion = CircleExclusion::ZERO;
474        test_exclusion(exclusion, 0.0);
475
476        let exclusion = CircleExclusion::default();
477        test_exclusion(exclusion, 0.1);
478
479        let exclusion = CircleExclusion::new(0.5);
480        test_exclusion(exclusion, 0.5);
481    }
482
483    #[test]
484    fn test_circle_deadzone() {
485        fn test_deadzone(deadzone: CircleDeadZone, radius: f32) {
486            assert_eq!(deadzone.radius(), radius);
487
488            let exclusion = CircleExclusion::new(radius);
489            assert_eq!(exclusion.scaled(), deadzone);
490
491            let processor = DualAxisProcessor::CircleDeadZone(deadzone);
492            assert_eq!(DualAxisProcessor::from(deadzone), processor);
493
494            for x in -300..300 {
495                let x = x as f32 * 0.01;
496                for y in -300..300 {
497                    let y = y as f32 * 0.01;
498                    let value = Vec2::new(x, y);
499
500                    assert_eq!(processor.process(value), deadzone.normalize(value));
501
502                    // Values within the dead zone are treated as zeros.
503                    if value.length() <= radius {
504                        assert!(deadzone.within_exclusion(value));
505                        assert_eq!(deadzone.normalize(value), Vec2::ZERO);
506                    }
507                    // Values within the live zone are scaled linearly.
508                    else if value.length() <= 1.0 {
509                        assert!(deadzone.within_livezone(value));
510
511                        let expected_scale = f32::inverse_lerp(radius, 1.0, value.length());
512                        let expected = value.normalize() * expected_scale;
513                        let delta = (deadzone.normalize(value) - expected).abs();
514                        assert!(delta.x <= 0.00001);
515                        assert!(delta.y <= 0.00001);
516                    }
517                    // Values outside the bounds are restricted to the region.
518                    else {
519                        assert!(!deadzone.within_bounds(value));
520
521                        let expected = value.clamp_length_max(1.0);
522                        let delta = (deadzone.normalize(value) - expected).abs();
523                        assert!(delta.x <= 0.00001);
524                        assert!(delta.y <= 0.00001);
525                    }
526                }
527            }
528        }
529
530        let deadzone = CircleDeadZone::ZERO;
531        test_deadzone(deadzone, 0.0);
532
533        let deadzone = CircleDeadZone::default();
534        test_deadzone(deadzone, 0.1);
535
536        let deadzone = CircleDeadZone::new(0.5);
537        test_deadzone(deadzone, 0.5);
538    }
539}