Skip to main content

arris_math/
interval.rs

1//! Closed parameter intervals.
2
3use core::fmt;
4
5/// A closed interval `[lo, hi]` with `lo ≤ hi`, neither end NaN. Either end
6/// may be infinite: a line's domain is [`Interval::REAL`]. An interval may
7/// be longer than a curve's period — an edge range on a periodic curve may
8/// cross the period (`docs/DATA-MODEL.md` §Topology).
9///
10/// ```
11/// use arris_math::Interval;
12///
13/// let i = Interval::new(1.0, 3.0).unwrap();
14/// assert!(i.contains(3.0));
15/// assert_eq!(i.clamp(5.0), 3.0);
16/// assert_eq!(i.lerp(0.5), 2.0);
17/// assert!(i.overlaps(&Interval::new(3.0, 4.0).unwrap()));
18/// assert!(Interval::new(3.0, 1.0).is_err());
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "serde", serde(try_from = "IntervalRepr"))]
23pub struct Interval {
24    lo: f64,
25    hi: f64,
26}
27
28/// The wire form of an [`Interval`]: the two ends, validated by
29/// [`Interval::new`] on the way in so a decoded interval is one that could
30/// have been built.
31#[cfg(feature = "serde")]
32#[derive(serde::Deserialize)]
33struct IntervalRepr {
34    lo: f64,
35    hi: f64,
36}
37
38#[cfg(feature = "serde")]
39impl TryFrom<IntervalRepr> for Interval {
40    type Error = IntervalError;
41
42    fn try_from(r: IntervalRepr) -> Result<Self, IntervalError> {
43        Interval::new(r.lo, r.hi)
44    }
45}
46
47/// Why two numbers are not an [`Interval`].
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum IntervalError {
50    /// `lo > hi`.
51    Reversed {
52        /// The lower end given.
53        lo: f64,
54        /// The upper end given.
55        hi: f64,
56    },
57    /// An end is NaN.
58    NotANumber,
59}
60
61impl fmt::Display for IntervalError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            IntervalError::Reversed { lo, hi } => write!(f, "interval [{lo}, {hi}] is reversed"),
65            IntervalError::NotANumber => write!(f, "interval end is NaN"),
66        }
67    }
68}
69
70impl std::error::Error for IntervalError {}
71
72impl Interval {
73    /// The whole real line, `[−∞, +∞]`: the domain of a line or a plane's
74    /// parameter.
75    pub const REAL: Interval = Interval {
76        lo: f64::NEG_INFINITY,
77        hi: f64::INFINITY,
78    };
79    /// `[0, 1]`.
80    pub const UNIT: Interval = Interval { lo: 0.0, hi: 1.0 };
81    /// `[0, 2π]`: one turn of an angular parameter, both ends included —
82    /// the closed fundamental interval of every periodic direction.
83    pub const TURN: Interval = Interval {
84        lo: 0.0,
85        hi: core::f64::consts::TAU,
86    };
87
88    /// `[lo, hi]`; an error when `lo > hi` or either is NaN.
89    pub fn new(lo: f64, hi: f64) -> Result<Self, IntervalError> {
90        if lo.is_nan() || hi.is_nan() {
91            return Err(IntervalError::NotANumber);
92        }
93        if lo > hi {
94            return Err(IntervalError::Reversed { lo, hi });
95        }
96        Ok(Interval { lo, hi })
97    }
98
99    /// The lower end.
100    pub const fn lo(&self) -> f64 {
101        self.lo
102    }
103
104    /// The upper end.
105    pub const fn hi(&self) -> f64 {
106        self.hi
107    }
108
109    /// `hi − lo`; infinite for an unbounded interval, zero for a point.
110    pub fn length(&self) -> f64 {
111        self.hi - self.lo
112    }
113
114    /// `true` when both ends are finite.
115    pub fn is_bounded(&self) -> bool {
116        self.lo.is_finite() && self.hi.is_finite()
117    }
118
119    /// `(lo + hi) / 2`; NaN for [`Interval::REAL`], where no midpoint
120    /// exists.
121    pub fn midpoint(&self) -> f64 {
122        self.lerp(0.5)
123    }
124
125    /// `lo ≤ t ≤ hi`. Both ends are inside; NaN is not.
126    pub fn contains(&self, t: f64) -> bool {
127        self.lo <= t && t <= self.hi
128    }
129
130    /// The nearest parameter inside: `t` itself when it is contained, the
131    /// nearer end otherwise. NaN stays NaN.
132    pub fn clamp(&self, t: f64) -> f64 {
133        if t < self.lo {
134            self.lo
135        } else if t > self.hi {
136            self.hi
137        } else {
138            t
139        }
140    }
141
142    /// `lo` at `s = 0`, `hi` at `s = 1`, both exactly, linear between and
143    /// beyond. Bounded intervals only; an infinite end yields an infinite
144    /// or NaN result.
145    pub fn lerp(&self, s: f64) -> f64 {
146        (1.0 - s) * self.lo + s * self.hi
147    }
148
149    /// `true` when the two closed intervals share at least one point:
150    /// touching at an end counts.
151    pub fn overlaps(&self, other: &Interval) -> bool {
152        self.lo <= other.hi && other.lo <= self.hi
153    }
154
155    /// The common part, or `None` when they do not [`overlap`](Self::overlaps).
156    pub fn intersection(&self, other: &Interval) -> Option<Interval> {
157        self.overlaps(other).then(|| Interval {
158            lo: self.lo.max(other.lo),
159            hi: self.hi.min(other.hi),
160        })
161    }
162
163    /// The smallest interval containing both.
164    pub fn hull(&self, other: &Interval) -> Interval {
165        Interval {
166            lo: self.lo.min(other.lo),
167            hi: self.hi.max(other.hi),
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn iv(lo: f64, hi: f64) -> Interval {
177        Interval::new(lo, hi).unwrap()
178    }
179
180    #[test]
181    fn construction_rejects_reversed_and_nan() {
182        assert_eq!(
183            Interval::new(2.0, 1.0),
184            Err(IntervalError::Reversed { lo: 2.0, hi: 1.0 })
185        );
186        assert_eq!(Interval::new(f64::NAN, 1.0), Err(IntervalError::NotANumber));
187        assert!(Interval::new(1.0, 1.0).is_ok());
188        assert!(Interval::new(f64::NEG_INFINITY, 0.0).is_ok());
189    }
190
191    #[test]
192    fn clamp_and_contains() {
193        let i = iv(-1.0, 2.0);
194        assert!(i.contains(-1.0) && i.contains(2.0) && i.contains(0.5));
195        assert!(!i.contains(-1.0000001) && !i.contains(f64::NAN));
196        assert_eq!(i.clamp(-5.0), -1.0);
197        assert_eq!(i.clamp(5.0), 2.0);
198        assert_eq!(i.clamp(0.25), 0.25);
199        assert!(i.clamp(f64::NAN).is_nan());
200        assert_eq!(Interval::REAL.clamp(1e300), 1e300);
201        assert!(Interval::REAL.contains(f64::INFINITY));
202    }
203
204    #[test]
205    fn lerp_hits_the_ends_exactly() {
206        let i = iv(0.1, 0.7);
207        assert_eq!(i.lerp(0.0), 0.1);
208        assert_eq!(i.lerp(1.0), 0.7);
209        assert!((i.midpoint() - 0.4).abs() <= f64::EPSILON);
210        assert_eq!(i.length(), 0.7 - 0.1);
211        assert!(i.is_bounded() && !Interval::REAL.is_bounded());
212        assert!(Interval::REAL.midpoint().is_nan());
213    }
214
215    #[test]
216    fn overlaps_intersection_hull() {
217        let a = iv(0.0, 1.0);
218        let b = iv(1.0, 2.0);
219        let c = iv(1.5, 3.0);
220        assert!(a.overlaps(&b) && b.overlaps(&a));
221        assert!(!a.overlaps(&c) && !c.overlaps(&a));
222        assert_eq!(a.intersection(&b), Some(iv(1.0, 1.0)));
223        assert_eq!(a.intersection(&c), None);
224        assert_eq!(b.intersection(&c), Some(iv(1.5, 2.0)));
225        assert_eq!(a.hull(&c), iv(0.0, 3.0));
226        assert_eq!(Interval::REAL.intersection(&c), Some(c));
227        assert_eq!(Interval::UNIT, a);
228        assert_eq!(Interval::TURN.hi(), core::f64::consts::TAU);
229    }
230
231    #[cfg(feature = "serde")]
232    #[test]
233    fn serde_round_trips_and_rejects_a_reversed_interval() {
234        let i = iv(-1.5, 2.25);
235        let text = serde_json::to_string(&i).unwrap();
236        assert_eq!(text, r#"{"lo":-1.5,"hi":2.25}"#);
237        let back: Interval = serde_json::from_str(&text).unwrap();
238        assert_eq!(back, i);
239        assert!(serde_json::from_str::<Interval>(r#"{"lo":2.0,"hi":1.0}"#).is_err());
240    }
241}