Skip to main content

truce_params/
range.rs

1/// Defines how a parameter maps between plain and normalized values.
2///
3/// `Copy` because every variant is POD (two scalar fields). Lets format
4/// wrappers pass `info.range` by value without `clone()` noise.
5#[derive(Clone, Copy, Debug)]
6pub enum ParamRange {
7    Linear { min: f64, max: f64 },
8    Logarithmic { min: f64, max: f64 },
9    Discrete { min: i64, max: i64 },
10    Enum { count: usize },
11}
12
13impl ParamRange {
14    /// Map a plain value to 0.0–1.0.
15    ///
16    /// Degenerate bounds - `min == max` for `Linear` / `Discrete`,
17    /// non-positive or empty for `Logarithmic`, `count <= 1` for
18    /// `Enum` - collapse to `0.0`. Combined with [`Self::denormalize`]
19    /// returning `min` on the same inputs, the pair is round-trip
20    /// stable: the result always converges to the bottom of the
21    /// (degenerate) range rather than producing NaN or wrapping into
22    /// nonsense.
23    // `min == max` detects mathematically zero-width ranges; an epsilon
24    // would mis-route a user-defined `Linear { 1.0, 1.0 + EPSILON }`.
25    // `i64 → f64` casts on `Discrete` bounds are lossless in practice
26    // (no sane param has > 2^52 steps).
27    #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
28    #[must_use]
29    pub fn normalize(&self, plain: f64) -> f64 {
30        match self {
31            Self::Linear { min, max } => {
32                if max == min {
33                    return 0.0;
34                }
35                ((plain - min) / (max - min)).clamp(0.0, 1.0)
36            }
37            Self::Logarithmic { min, max } => {
38                if *min <= 0.0 || *max <= 0.0 || min == max {
39                    return 0.0;
40                }
41                // `plain.ln()` returns NaN for `plain <= 0`; the
42                // post-clamp leaves the NaN intact and a host that
43                // briefly overshoots automation below `min` ends up
44                // with a NaN normalized value flowing into saved
45                // state and the GUI round-trip.
46                if plain <= *min {
47                    return 0.0;
48                }
49                if plain >= *max {
50                    return 1.0;
51                }
52                let min_log = min.ln();
53                let max_log = max.ln();
54                ((plain.ln() - min_log) / (max_log - min_log)).clamp(0.0, 1.0)
55            }
56            Self::Discrete { min, max } => {
57                if max == min {
58                    return 0.0;
59                }
60                ((plain - *min as f64) / (*max as f64 - *min as f64)).clamp(0.0, 1.0)
61            }
62            Self::Enum { count } => {
63                if *count <= 1 {
64                    return 0.0;
65                }
66                (plain / (*count as f64 - 1.0)).clamp(0.0, 1.0)
67            }
68        }
69    }
70
71    /// Map 0.0–1.0 back to a plain value.
72    ///
73    /// Degenerate bounds collapse to `min` (or `0.0` for `Enum` with
74    /// `count <= 1`). See [`Self::normalize`] for the round-trip
75    /// semantics.
76    // `min == max` detects mathematically zero-width ranges; matches
77    // `normalize`'s asymmetric handling so the pair stays stable.
78    // `i64 → f64` and `usize → f64` casts on `Discrete` / `Enum`
79    // bounds are lossless in practice (no sane param has > 2^52 steps).
80    #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
81    #[must_use]
82    pub fn denormalize(&self, normalized: f64) -> f64 {
83        let n = normalized.clamp(0.0, 1.0);
84        match self {
85            Self::Linear { min, max } => min + n * (max - min),
86            Self::Logarithmic { min, max } => {
87                // Match `normalize`'s asymmetric handling of bad bounds:
88                // if either end is non-positive or the range is empty,
89                // both directions collapse to `min` (round-trip stable).
90                if *min <= 0.0 || *max <= 0.0 || min == max {
91                    return *min;
92                }
93                let min_log = min.ln();
94                let max_log = max.ln();
95                (min_log + n * (max_log - min_log)).exp()
96            }
97            Self::Discrete { min, max } => {
98                ((*min as f64) + n * (*max as f64 - *min as f64)).round()
99            }
100            Self::Enum { count } => {
101                if *count <= 1 {
102                    return 0.0;
103                }
104                (n * (*count as f64 - 1.0)).round()
105            }
106        }
107    }
108
109    /// Plain-value minimum.
110    // `i64 → f64` is lossless for the bounds in practice (no sane
111    // param has > 2^52 steps).
112    #[allow(clippy::cast_precision_loss)]
113    #[must_use]
114    pub fn min(&self) -> f64 {
115        match self {
116            Self::Linear { min, .. } | Self::Logarithmic { min, .. } => *min,
117            Self::Discrete { min, .. } => *min as f64,
118            Self::Enum { .. } => 0.0,
119        }
120    }
121
122    /// Plain-value maximum.
123    // `i64 → f64` and `usize → f64` are lossless for the bounds in
124    // practice.
125    #[allow(clippy::cast_precision_loss)]
126    #[must_use]
127    pub fn max(&self) -> f64 {
128        match self {
129            Self::Linear { max, .. } | Self::Logarithmic { max, .. } => *max,
130            Self::Discrete { max, .. } => *max as f64,
131            Self::Enum { count } => (*count as f64 - 1.0).max(0.0),
132        }
133    }
134
135    /// Number of discrete steps for a quantized range.
136    ///
137    /// `None` means continuous (Linear / Logarithmic). `Some(n)` means
138    /// the range covers `n + 1` distinct values (a step count of 3 →
139    /// 4 picker positions). Cross-format wrappers that serialize a
140    /// `0 = continuous` sentinel into a C struct should call
141    /// `.map(NonZeroU32::get).unwrap_or(0)` at the FFI boundary.
142    ///
143    /// Discrete / Enum variants with degenerate bounds (`min > max`,
144    /// or `count <= 1`) return `None` - semantically continuous,
145    /// because there's nothing to step through.
146    #[must_use]
147    pub fn step_count(&self) -> Option<std::num::NonZeroU32> {
148        let raw: u32 = match self {
149            Self::Linear { .. } | Self::Logarithmic { .. } => 0,
150            // `max - min` as `i64` is fine, but `as u32` wraps for
151            // `min > max` or steps > u32::MAX. Saturate instead so a
152            // mis-specified `Discrete` range can't produce a bogus
153            // step count that callers might index with.
154            Self::Discrete { min, max } => {
155                // Result is `min`-clamped to `0..=u32::MAX`.
156                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
157                let n = (max.saturating_sub(*min)).max(0).min(i64::from(u32::MAX)) as u32;
158                n
159            }
160            // Enum variant counts are well below `u32::MAX` in practice
161            // (typical < 100); the saturating_sub keeps `count = 0` honest.
162            #[allow(clippy::cast_possible_truncation)]
163            Self::Enum { count } => (*count as u32).saturating_sub(1),
164        };
165        std::num::NonZeroU32::new(raw)
166    }
167
168    /// `step_count` widened to `usize` with the continuous case
169    /// flattened to `1`. Convenience for UI code that loops over
170    /// discrete values and falls back to a single step for continuous
171    /// ranges.
172    #[must_use]
173    pub fn step_count_usize(&self) -> usize {
174        self.step_count().map_or(1, |n| n.get() as usize)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    // Round-trip and degenerate-bounds tests assert exact float
181    // results (0.0, midpoints, fixed points) - equality is the
182    // contract being verified. Cast truncations in this module are
183    // bounded by the literal `count: 4` test fixtures.
184    #![allow(
185        clippy::float_cmp,
186        clippy::cast_possible_truncation,
187        clippy::cast_sign_loss,
188        clippy::cast_precision_loss
189    )]
190
191    use super::*;
192
193    #[test]
194    fn linear_round_trip() {
195        let range = ParamRange::Linear {
196            min: -60.0,
197            max: 24.0,
198        };
199        for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
200            let norm = range.normalize(plain);
201            let back = range.denormalize(norm);
202            assert!(
203                (back - plain).abs() < 1e-10,
204                "plain={plain}, norm={norm}, back={back}"
205            );
206        }
207    }
208
209    #[test]
210    fn log_round_trip() {
211        let range = ParamRange::Logarithmic {
212            min: 20.0,
213            max: 20000.0,
214        };
215        for plain in [20.0, 100.0, 1000.0, 10000.0, 20000.0] {
216            let norm = range.normalize(plain);
217            let back = range.denormalize(norm);
218            assert!(
219                (back - plain).abs() < 0.01,
220                "plain={plain}, norm={norm}, back={back}"
221            );
222        }
223    }
224
225    #[test]
226    fn enum_round_trip() {
227        let range = ParamRange::Enum { count: 4 };
228        for idx in 0..4 {
229            let norm = range.normalize(idx as f64);
230            let back = range.denormalize(norm);
231            assert_eq!(back as usize, idx);
232        }
233    }
234
235    /// Degenerate bounds (empty/non-positive/single-step) collapse the
236    /// round trip to a fixed point at `min` rather than producing NaN
237    /// or wrapping. Locks in `normalize → 0.0`, `denormalize(0.0) →
238    /// min`, and `normalize(min) → 0.0` for every range variant so a
239    /// future maintainer simplifying one branch can't accidentally
240    /// reintroduce divergent behavior.
241    #[test]
242    fn degenerate_bounds_round_trip_stable() {
243        let cases = [
244            ParamRange::Linear { min: 5.0, max: 5.0 },
245            ParamRange::Logarithmic {
246                min: 100.0,
247                max: 100.0,
248            },
249            ParamRange::Logarithmic {
250                min: -1.0,
251                max: 10.0,
252            },
253            ParamRange::Logarithmic { min: 1.0, max: 0.0 },
254            ParamRange::Discrete { min: 7, max: 7 },
255            ParamRange::Enum { count: 0 },
256            ParamRange::Enum { count: 1 },
257        ];
258        for range in cases {
259            let bottom = range.min();
260            assert_eq!(range.normalize(bottom), 0.0, "normalize(min) for {range:?}");
261            assert_eq!(
262                range.normalize(42.0),
263                0.0,
264                "normalize(arbitrary) for {range:?}"
265            );
266            assert_eq!(
267                range.denormalize(0.0),
268                bottom,
269                "denormalize(0.0) for {range:?}"
270            );
271            assert_eq!(
272                range.denormalize(0.5),
273                bottom,
274                "denormalize(mid) for {range:?}"
275            );
276            // Double round trip lands at the same fixed point.
277            let once = range.denormalize(range.normalize(42.0));
278            let twice = range.denormalize(range.normalize(once));
279            assert_eq!(once, twice, "round-trip not stable for {range:?}");
280        }
281    }
282
283    /// `normalize` must never return NaN. A host that briefly
284    /// overshoots automation below `min` (or hands us a fresh
285    /// uninitialized -1.0) would feed `(-1.0).ln()` (= NaN) into
286    /// saved state and the editor round-trip without the clamp.
287    #[test]
288    fn logarithmic_normalize_never_nan() {
289        let range = ParamRange::Logarithmic {
290            min: 20.0,
291            max: 20000.0,
292        };
293        for plain in [-1.0, 0.0, 0.5, 19.99, f64::NEG_INFINITY] {
294            let n = range.normalize(plain);
295            assert!(!n.is_nan(), "NaN from normalize({plain})");
296            assert_eq!(n, 0.0, "normalize({plain}) should clamp to 0.0");
297        }
298        for plain in [20000.0, 20001.0, 1e9, f64::INFINITY] {
299            let n = range.normalize(plain);
300            assert!(!n.is_nan(), "NaN from normalize({plain})");
301            assert_eq!(n, 1.0, "normalize({plain}) should clamp to 1.0");
302        }
303    }
304}