1#[derive(Clone, Copy, Debug)]
7pub enum ParamRange {
8 Linear {
9 min: f64,
10 max: f64,
11 },
12 Logarithmic {
13 min: f64,
14 max: f64,
15 },
16 Skewed {
21 min: f64,
22 max: f64,
23 factor: f64,
24 },
25 SymmetricalSkewed {
31 min: f64,
32 max: f64,
33 factor: f64,
34 center: f64,
35 },
36 Discrete {
37 min: i64,
38 max: i64,
39 },
40 Enum {
41 count: usize,
42 },
43 Reversed(&'static ParamRange),
47}
48
49impl ParamRange {
50 #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
64 #[must_use]
65 pub fn normalize(&self, plain: f64) -> f64 {
66 match self {
67 Self::Linear { min, max } => {
68 if max == min {
69 return 0.0;
70 }
71 ((plain - min) / (max - min)).clamp(0.0, 1.0)
72 }
73 Self::Logarithmic { min, max } => {
74 if *min <= 0.0 || *max <= 0.0 || min == max {
75 return 0.0;
76 }
77 if plain <= *min {
83 return 0.0;
84 }
85 if plain >= *max {
86 return 1.0;
87 }
88 let min_log = min.ln();
89 let max_log = max.ln();
90 ((plain.ln() - min_log) / (max_log - min_log)).clamp(0.0, 1.0)
91 }
92 Self::Skewed { min, max, factor } => {
93 if max == min {
94 return 0.0;
95 }
96 let t = ((plain - min) / (max - min)).clamp(0.0, 1.0);
97 t.powf(*factor)
98 }
99 Self::SymmetricalSkewed {
100 min,
101 max,
102 factor,
103 center,
104 } => {
105 if max == min {
106 return 0.0;
107 }
108 let unscaled = ((plain - min) / (max - min)).clamp(0.0, 1.0);
109 let center_prop = ((center - min) / (max - min)).clamp(0.0, 1.0);
110 if center_prop <= 0.0 || center_prop >= 1.0 {
114 return unscaled;
115 }
116 if unscaled > center_prop {
117 let scaled = (unscaled - center_prop) / (1.0 - center_prop);
118 (scaled.powf(*factor) / 2.0) + 0.5
119 } else {
120 let scaled = (center_prop - unscaled) / center_prop;
121 (1.0 - scaled.powf(*factor)) / 2.0
122 }
123 }
124 Self::Reversed(inner) => 1.0 - inner.normalize(plain),
125 Self::Discrete { min, max } => {
126 if max == min {
127 return 0.0;
128 }
129 ((plain - *min as f64) / (*max as f64 - *min as f64)).clamp(0.0, 1.0)
130 }
131 Self::Enum { count } => {
132 if *count <= 1 {
133 return 0.0;
134 }
135 (plain / (*count as f64 - 1.0)).clamp(0.0, 1.0)
136 }
137 }
138 }
139
140 #[allow(clippy::float_cmp, clippy::cast_precision_loss)]
150 #[must_use]
151 pub fn denormalize(&self, normalized: f64) -> f64 {
152 let n = normalized.clamp(0.0, 1.0);
153 match self {
154 Self::Linear { min, max } => min + n * (max - min),
155 Self::Logarithmic { min, max } => {
156 if *min <= 0.0 || *max <= 0.0 || min == max {
160 return *min;
161 }
162 let min_log = min.ln();
163 let max_log = max.ln();
164 (min_log + n * (max_log - min_log)).exp()
165 }
166 Self::Skewed { min, max, factor } => {
167 if max == min {
168 return *min;
169 }
170 min + n.powf(factor.recip()) * (max - min)
171 }
172 Self::SymmetricalSkewed {
173 min,
174 max,
175 factor,
176 center,
177 } => {
178 if max == min {
179 return *min;
180 }
181 let center_prop = ((center - min) / (max - min)).clamp(0.0, 1.0);
182 if center_prop <= 0.0 || center_prop >= 1.0 {
183 return min + n * (max - min);
184 }
185 let skewed_prop = if n > 0.5 {
186 let scaled = (n - 0.5) * 2.0;
187 (scaled.powf(factor.recip()) * (1.0 - center_prop)) + center_prop
188 } else {
189 let inverse = (1.0 - n * 2.0).powf(factor.recip());
190 (1.0 - inverse) * center_prop
191 };
192 min + skewed_prop * (max - min)
193 }
194 Self::Reversed(inner) => inner.denormalize(1.0 - n),
195 Self::Discrete { min, max } => {
196 ((*min as f64) + n * (*max as f64 - *min as f64)).round()
197 }
198 Self::Enum { count } => {
199 if *count <= 1 {
200 return 0.0;
201 }
202 (n * (*count as f64 - 1.0)).round()
203 }
204 }
205 }
206
207 #[allow(clippy::cast_precision_loss)]
211 #[must_use]
212 pub fn min(&self) -> f64 {
213 match self {
214 Self::Linear { min, .. }
215 | Self::Logarithmic { min, .. }
216 | Self::Skewed { min, .. }
217 | Self::SymmetricalSkewed { min, .. } => *min,
218 Self::Discrete { min, .. } => *min as f64,
219 Self::Enum { .. } => 0.0,
220 Self::Reversed(inner) => inner.min(),
221 }
222 }
223
224 #[allow(clippy::cast_precision_loss)]
228 #[must_use]
229 pub fn max(&self) -> f64 {
230 match self {
231 Self::Linear { max, .. }
232 | Self::Logarithmic { max, .. }
233 | Self::Skewed { max, .. }
234 | Self::SymmetricalSkewed { max, .. } => *max,
235 Self::Discrete { max, .. } => *max as f64,
236 Self::Enum { count } => (*count as f64 - 1.0).max(0.0),
237 Self::Reversed(inner) => inner.max(),
238 }
239 }
240
241 #[must_use]
253 pub fn step_count(&self) -> Option<std::num::NonZeroU32> {
254 let raw: u32 = match self {
255 Self::Linear { .. }
256 | Self::Logarithmic { .. }
257 | Self::Skewed { .. }
258 | Self::SymmetricalSkewed { .. } => 0,
259 Self::Reversed(inner) => return inner.step_count(),
262 Self::Discrete { min, max } => {
267 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
269 let n = (max.saturating_sub(*min)).max(0).min(i64::from(u32::MAX)) as u32;
270 n
271 }
272 #[allow(clippy::cast_possible_truncation)]
275 Self::Enum { count } => (*count as u32).saturating_sub(1),
276 };
277 std::num::NonZeroU32::new(raw)
278 }
279
280 #[must_use]
285 pub fn step_count_usize(&self) -> usize {
286 self.step_count().map_or(1, |n| n.get() as usize)
287 }
288
289 #[must_use]
295 pub fn base(&self) -> &Self {
296 match self {
297 Self::Reversed(inner) => inner.base(),
298 other => other,
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 #![allow(
310 clippy::float_cmp,
311 clippy::cast_possible_truncation,
312 clippy::cast_sign_loss,
313 clippy::cast_precision_loss
314 )]
315
316 use super::*;
317
318 #[test]
319 fn linear_round_trip() {
320 let range = ParamRange::Linear {
321 min: -60.0,
322 max: 24.0,
323 };
324 for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
325 let norm = range.normalize(plain);
326 let back = range.denormalize(norm);
327 assert!(
328 (back - plain).abs() < 1e-10,
329 "plain={plain}, norm={norm}, back={back}"
330 );
331 }
332 }
333
334 #[test]
335 fn log_round_trip() {
336 let range = ParamRange::Logarithmic {
337 min: 20.0,
338 max: 20000.0,
339 };
340 for plain in [20.0, 100.0, 1000.0, 10000.0, 20000.0] {
341 let norm = range.normalize(plain);
342 let back = range.denormalize(norm);
343 assert!(
344 (back - plain).abs() < 0.01,
345 "plain={plain}, norm={norm}, back={back}"
346 );
347 }
348 }
349
350 #[test]
351 fn enum_round_trip() {
352 let range = ParamRange::Enum { count: 4 };
353 for idx in 0..4 {
354 let norm = range.normalize(idx as f64);
355 let back = range.denormalize(norm);
356 assert_eq!(back as usize, idx);
357 }
358 }
359
360 #[test]
361 fn skewed_round_trip() {
362 let range = ParamRange::Skewed {
363 min: 0.0,
364 max: 100.0,
365 factor: 0.5,
366 };
367 for plain in [0.0, 10.0, 50.0, 90.0, 100.0] {
368 let back = range.denormalize(range.normalize(plain));
369 assert!((back - plain).abs() < 1e-9, "plain={plain}, back={back}");
370 }
371 }
372
373 #[test]
374 fn skewed_factor_one_matches_linear() {
375 let skewed = ParamRange::Skewed {
376 min: -60.0,
377 max: 24.0,
378 factor: 1.0,
379 };
380 let linear = ParamRange::Linear {
381 min: -60.0,
382 max: 24.0,
383 };
384 for plain in [-60.0, -30.0, 0.0, 12.0, 24.0] {
385 assert!((skewed.normalize(plain) - linear.normalize(plain)).abs() < 1e-12);
386 }
387 }
388
389 #[test]
390 fn skewed_low_factor_gives_low_end_more_knob() {
391 let range = ParamRange::Skewed {
394 min: 0.0,
395 max: 100.0,
396 factor: 0.5,
397 };
398 assert!(range.denormalize(0.5) < 50.0);
399 }
400
401 #[test]
402 fn symmetrical_skewed_center_at_half() {
403 let range = ParamRange::SymmetricalSkewed {
406 min: -24.0,
407 max: 6.0,
408 factor: 0.5,
409 center: 0.0,
410 };
411 assert!((range.normalize(0.0) - 0.5).abs() < 1e-12);
412 assert!((range.denormalize(0.5) - 0.0).abs() < 1e-9);
413 }
414
415 #[test]
416 fn symmetrical_skewed_round_trip() {
417 let range = ParamRange::SymmetricalSkewed {
418 min: -1.0,
419 max: 1.0,
420 factor: 2.0,
421 center: 0.0,
422 };
423 for plain in [-1.0, -0.5, -0.1, 0.0, 0.1, 0.5, 1.0] {
424 let back = range.denormalize(range.normalize(plain));
425 assert!((back - plain).abs() < 1e-9, "plain={plain}, back={back}");
426 }
427 }
428
429 #[test]
430 fn symmetrical_skewed_is_symmetric_about_a_centered_center() {
431 let range = ParamRange::SymmetricalSkewed {
434 min: -1.0,
435 max: 1.0,
436 factor: 0.6,
437 center: 0.0,
438 };
439 for d in [0.25, 0.5, 0.75] {
440 let above = range.normalize(d) - 0.5;
441 let below = 0.5 - range.normalize(-d);
442 assert!((above - below).abs() < 1e-12, "asymmetric at d={d}");
443 }
444 }
445
446 #[test]
447 fn reversed_flips_the_axis() {
448 static INNER: ParamRange = ParamRange::Linear {
449 min: 0.0,
450 max: 100.0,
451 };
452 let range = ParamRange::Reversed(&INNER);
453 assert!((range.normalize(0.0) - 1.0).abs() < 1e-12, "min -> top");
454 assert!((range.normalize(100.0)).abs() < 1e-12, "max -> bottom");
455 assert!((range.denormalize(0.0) - 100.0).abs() < 1e-9);
456 assert!((range.denormalize(1.0)).abs() < 1e-9);
457 assert_eq!(range.min(), 0.0);
459 assert_eq!(range.max(), 100.0);
460 assert!(range.step_count().is_none());
461 }
462
463 #[test]
464 fn base_peels_reversed_so_shape_survives() {
465 static ENUM: ParamRange = ParamRange::Enum { count: 4 };
466 static ONCE: ParamRange = ParamRange::Reversed(&ENUM);
467
468 let reversed = ParamRange::Reversed(&ENUM);
471 assert!(matches!(reversed.base(), ParamRange::Enum { count: 4 }));
472
473 let twice = ParamRange::Reversed(&ONCE);
475 assert!(matches!(twice.base(), ParamRange::Enum { count: 4 }));
476
477 let linear = ParamRange::Linear { min: 0.0, max: 1.0 };
479 assert!(matches!(linear.base(), ParamRange::Linear { .. }));
480 }
481
482 #[test]
483 fn reversed_round_trip_over_log() {
484 static INNER: ParamRange = ParamRange::Logarithmic {
485 min: 20.0,
486 max: 20000.0,
487 };
488 let range = ParamRange::Reversed(&INNER);
489 for plain in [20.0, 200.0, 2000.0, 20000.0] {
490 let back = range.denormalize(range.normalize(plain));
491 assert!((back - plain).abs() < 0.01, "plain={plain}, back={back}");
492 }
493 }
494
495 #[test]
496 fn reversed_discrete_keeps_step_count() {
497 static INNER: ParamRange = ParamRange::Discrete { min: 0, max: 3 };
498 let range = ParamRange::Reversed(&INNER);
499 assert_eq!(range.step_count_usize(), 3);
500 }
501
502 #[test]
509 fn degenerate_bounds_round_trip_stable() {
510 let cases = [
511 ParamRange::Linear { min: 5.0, max: 5.0 },
512 ParamRange::Logarithmic {
513 min: 100.0,
514 max: 100.0,
515 },
516 ParamRange::Logarithmic {
517 min: -1.0,
518 max: 10.0,
519 },
520 ParamRange::Logarithmic { min: 1.0, max: 0.0 },
521 ParamRange::Discrete { min: 7, max: 7 },
522 ParamRange::Enum { count: 0 },
523 ParamRange::Enum { count: 1 },
524 ];
525 for range in cases {
526 let bottom = range.min();
527 assert_eq!(range.normalize(bottom), 0.0, "normalize(min) for {range:?}");
528 assert_eq!(
529 range.normalize(42.0),
530 0.0,
531 "normalize(arbitrary) for {range:?}"
532 );
533 assert_eq!(
534 range.denormalize(0.0),
535 bottom,
536 "denormalize(0.0) for {range:?}"
537 );
538 assert_eq!(
539 range.denormalize(0.5),
540 bottom,
541 "denormalize(mid) for {range:?}"
542 );
543 let once = range.denormalize(range.normalize(42.0));
545 let twice = range.denormalize(range.normalize(once));
546 assert_eq!(once, twice, "round-trip not stable for {range:?}");
547 }
548 }
549
550 #[test]
555 fn logarithmic_normalize_never_nan() {
556 let range = ParamRange::Logarithmic {
557 min: 20.0,
558 max: 20000.0,
559 };
560 for plain in [-1.0, 0.0, 0.5, 19.99, f64::NEG_INFINITY] {
561 let n = range.normalize(plain);
562 assert!(!n.is_nan(), "NaN from normalize({plain})");
563 assert_eq!(n, 0.0, "normalize({plain}) should clamp to 0.0");
564 }
565 for plain in [20000.0, 20001.0, 1e9, f64::INFINITY] {
566 let n = range.normalize(plain);
567 assert!(!n.is_nan(), "NaN from normalize({plain})");
568 assert_eq!(n, 1.0, "normalize({plain}) should clamp to 1.0");
569 }
570 }
571}