Skip to main content

blockworx_geom/
bounded.rs

1//! A clamped `f32` newtype, generic over its bounds. `Bounded<B>` can only
2//! hold values in `[B::MIN, B::MAX]`: the constructor clamps, every arithmetic
3//! op re-clamps, and NaN collapses to `B::MIN` — so the type is totally
4//! ordered and `Eq`/`Ord` are sound.
5
6use std::cmp::Ordering;
7use std::marker::PhantomData;
8
9/// The closed interval a [`Bounded`] value lives in. Implemented by empty
10/// marker types (`ProgressBounds`, `ZoomBounds`, …).
11pub trait Bounds {
12    const MIN: f32;
13    const MAX: f32;
14}
15
16pub struct Bounded<B: Bounds>(f32, PhantomData<B>);
17
18impl<B: Bounds> Bounded<B> {
19    /// Clamp `value` into the bounds; NaN collapses to `B::MIN`.
20    pub const fn new(value: f32) -> Self {
21        let value = if value.is_nan() || value < B::MIN {
22            B::MIN
23        } else if value > B::MAX {
24            B::MAX
25        } else {
26            value
27        };
28        Self(value, PhantomData)
29    }
30
31    pub const fn get(self) -> f32 {
32        self.0
33    }
34
35    pub const fn min_value() -> Self {
36        Self::new(B::MIN)
37    }
38
39    pub const fn max_value() -> Self {
40        Self::new(B::MAX)
41    }
42}
43
44// Manual impls so `B` needs no derives of its own — the marker never appears
45// in a value position.
46impl<B: Bounds> Copy for Bounded<B> {}
47
48impl<B: Bounds> Clone for Bounded<B> {
49    fn clone(&self) -> Self {
50        *self
51    }
52}
53
54impl<B: Bounds> std::fmt::Debug for Bounded<B> {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{:?}", self.0)
57    }
58}
59
60impl<B: Bounds> PartialEq for Bounded<B> {
61    fn eq(&self, other: &Self) -> bool {
62        self.0 == other.0
63    }
64}
65
66// Sound: construction excludes NaN, so `==` is reflexive and `total_cmp` a
67// total order.
68impl<B: Bounds> Eq for Bounded<B> {}
69
70impl<B: Bounds> PartialOrd for Bounded<B> {
71    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
72        Some(self.cmp(other))
73    }
74}
75
76impl<B: Bounds> Ord for Bounded<B> {
77    fn cmp(&self, other: &Self) -> Ordering {
78        self.0.total_cmp(&other.0)
79    }
80}
81
82impl<B: Bounds> From<Bounded<B>> for f32 {
83    fn from(value: Bounded<B>) -> f32 {
84        value.0
85    }
86}
87
88/// A value outside a [`Bounded`]'s interval, or NaN, offered where one is
89/// read rather than made — refused there instead of clamped, since it did
90/// not come from this type.
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub struct OutOfBounds {
93    pub value: f32,
94    pub min: f32,
95    pub max: f32,
96}
97
98impl std::fmt::Display for OutOfBounds {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        write!(f, "{} is outside [{}, {}]", self.value, self.min, self.max)
101    }
102}
103
104impl std::error::Error for OutOfBounds {}
105
106impl<B: Bounds> TryFrom<f32> for Bounded<B> {
107    type Error = OutOfBounds;
108    fn try_from(value: f32) -> Result<Self, Self::Error> {
109        if (B::MIN..=B::MAX).contains(&value) {
110            Ok(Self(value, PhantomData))
111        } else {
112            Err(OutOfBounds {
113                value,
114                min: B::MIN,
115                max: B::MAX,
116            })
117        }
118    }
119}
120
121// By hand rather than derived: a derive would ask the marker for serde
122// impls of its own.
123impl<B: Bounds> serde::Serialize for Bounded<B> {
124    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
125        serializer.serialize_f32(self.0)
126    }
127}
128
129impl<'de, B: Bounds> serde::Deserialize<'de> for Bounded<B> {
130    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
131        let value = f32::deserialize(deserializer)?;
132        Self::try_from(value).map_err(serde::de::Error::custom)
133    }
134}
135
136impl<B: Bounds> std::ops::Add for Bounded<B> {
137    type Output = Self;
138    fn add(self, rhs: Self) -> Self {
139        Self::new(self.0 + rhs.0)
140    }
141}
142
143impl<B: Bounds> std::ops::Sub for Bounded<B> {
144    type Output = Self;
145    fn sub(self, rhs: Self) -> Self {
146        Self::new(self.0 - rhs.0)
147    }
148}
149
150impl<B: Bounds> std::ops::Mul<f32> for Bounded<B> {
151    type Output = Self;
152    fn mul(self, rhs: f32) -> Self {
153        Self::new(self.0 * rhs)
154    }
155}
156
157impl<B: Bounds> std::ops::Div<f32> for Bounded<B> {
158    type Output = Self;
159    fn div(self, rhs: f32) -> Self {
160        Self::new(self.0 / rhs)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    // Deliberately underivable: proves `Bounded`'s manual `Copy`/`Clone`/`Eq`
169    // place no requirements on the marker.
170    struct UnitBounds;
171    impl Bounds for UnitBounds {
172        const MIN: f32 = 0.0;
173        const MAX: f32 = 1.0;
174    }
175    type Unit = Bounded<UnitBounds>;
176
177    struct WideBounds;
178    impl Bounds for WideBounds {
179        const MIN: f32 = -10.0;
180        const MAX: f32 = f32::INFINITY;
181    }
182    type Wide = Bounded<WideBounds>;
183
184    #[test]
185    fn a_value_read_outside_the_bounds_is_refused_not_clamped() {
186        assert_eq!(Unit::try_from(0.5), Ok(Unit::new(0.5)));
187        for refused in [-0.25, 1.5, f32::NAN] {
188            assert!(Unit::try_from(refused).is_err(), "{refused} was accepted");
189        }
190        assert_eq!(Wide::try_from(f32::INFINITY), Ok(Wide::new(f32::INFINITY)));
191    }
192
193    #[test]
194    fn construction_clamps_into_the_bounds() {
195        assert_eq!(Unit::new(0.5).get(), 0.5);
196        assert_eq!(Unit::new(-3.0).get(), 0.0);
197        assert_eq!(Unit::new(7.0).get(), 1.0);
198        assert_eq!(Unit::new(f32::NEG_INFINITY).get(), 0.0);
199        assert_eq!(Unit::new(f32::INFINITY).get(), 1.0);
200    }
201
202    #[test]
203    fn nan_collapses_to_the_minimum() {
204        assert_eq!(Unit::new(f32::NAN).get(), 0.0);
205        assert_eq!(Wide::new(f32::NAN).get(), -10.0);
206    }
207
208    #[test]
209    fn an_unbounded_maximum_passes_values_through() {
210        assert_eq!(Wide::new(1e30).get(), 1e30);
211        assert_eq!(Wide::new(f32::INFINITY).get(), f32::INFINITY);
212    }
213
214    #[test]
215    fn addition_and_subtraction_saturate() {
216        assert_eq!(Unit::new(0.75) + Unit::new(0.75), Unit::new(1.0));
217        assert_eq!(Unit::new(0.25) - Unit::new(0.75), Unit::new(0.0));
218        assert_eq!((Unit::new(0.25) + Unit::new(0.5)).get(), 0.75);
219    }
220
221    #[test]
222    fn scaling_clamps_and_survives_nan_producing_math() {
223        assert_eq!((Unit::new(0.5) * 4.0).get(), 1.0);
224        assert_eq!((Unit::new(0.5) * -1.0).get(), 0.0);
225        assert_eq!((Unit::new(0.5) / 0.5).get(), 1.0);
226        // 0/0 is NaN; the result must still be in-bounds.
227        assert_eq!((Unit::new(0.0) / 0.0).get(), 0.0);
228        assert_eq!((Unit::new(0.5) * f32::NAN).get(), 0.0);
229    }
230
231    #[test]
232    fn the_order_is_total_and_consistent_with_eq() {
233        let mut values = vec![Unit::new(0.9), Unit::new(0.1), Unit::new(0.5)];
234        values.sort();
235        assert_eq!(values, vec![Unit::new(0.1), Unit::new(0.5), Unit::new(0.9)]);
236        assert!(Unit::new(0.1) < Unit::new(0.2));
237        assert_eq!(Unit::new(2.0), Unit::new(1.0), "both clamp to MAX");
238        assert_eq!(Unit::new(0.3).cmp(&Unit::new(0.3)), Ordering::Equal);
239    }
240
241    #[test]
242    fn min_and_max_value_bracket_every_construction() {
243        assert_eq!(Unit::min_value().get(), 0.0);
244        assert_eq!(Unit::max_value().get(), 1.0);
245        assert!(Unit::min_value() <= Unit::new(-100.0));
246        assert!(Unit::max_value() >= Unit::new(100.0));
247    }
248
249    #[test]
250    fn into_f32_returns_the_clamped_value() {
251        let x: f32 = Unit::new(3.0).into();
252        assert_eq!(x, 1.0);
253    }
254
255    #[test]
256    fn copy_works_without_marker_derives() {
257        let a = Unit::new(0.4);
258        let b = a; // move would poison the next use if Copy were missing
259        assert_eq!(a, b);
260    }
261}