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}