Skip to main content

wowlab_types/
numeric.rs

1//! Explicit numeric operations and conversions shared across crate boundaries.
2
3use crate::constants::HUNDRED;
4
5const DECIMAL_RADIX: f64 = 10.0;
6const INTERPOLATION_WINDOW: usize = 2;
7const U32_RANGE_AS_F64: f64 = 4_294_967_296.0;
8const U8_MAX_AS_U32: u32 = 255;
9
10/// Floating-point comparison tolerance for condition checks.
11pub const EPSILON: f64 = 1e-6;
12
13/// `cur` as a percentage of `max`, returning `0.0` when `max <= 0`.
14#[inline]
15#[must_use]
16pub const fn pct_of(cur: f64, max: f64) -> f64 {
17    if max > 0.0 { cur / max * HUNDRED } else { 0.0 }
18}
19
20/// The non-negative shortfall of `cur` below `max`.
21#[inline]
22#[must_use]
23pub fn deficit_of(cur: f64, max: f64) -> f64 {
24    (max - cur).max(0.0)
25}
26
27/// Returns `0.0` on division by zero instead of panicking.
28///
29/// ```
30/// use wowlab_types::numeric::safe_div;
31///
32/// assert_eq!(safe_div(10.0, 2.0), 5.0);
33/// assert_eq!(safe_div(10.0, 0.0), 0.0);
34/// assert_eq!(safe_div(0.0, 0.0), 0.0);
35/// ```
36#[inline]
37#[must_use]
38pub fn safe_div(a: f64, b: f64) -> f64 {
39    if matches!(b.classify(), std::num::FpCategory::Zero) {
40        0.0
41    } else {
42        a / b
43    }
44}
45
46/// Compares two floats for equality within [`EPSILON`] tolerance.
47///
48/// ```
49/// use wowlab_types::numeric::float_eq;
50///
51/// assert!(float_eq(1.0, 1.0 + 1e-7));
52/// assert!(!float_eq(1.0, 1.1));
53/// ```
54#[inline]
55#[must_use]
56pub fn float_eq(a: f64, b: f64) -> bool {
57    (a - b).abs() < EPSILON
58}
59
60/// Inverse of [`float_eq`].
61#[inline]
62#[must_use]
63pub fn float_ne(a: f64, b: f64) -> bool {
64    (a - b).abs() >= EPSILON
65}
66
67/// True modulo: result has the sign of the divisor (unlike Rust's `%`); `0.0` on division by zero.
68///
69/// ```
70/// use wowlab_types::numeric::true_mod;
71///
72/// assert_eq!(true_mod(7.0, 3.0), 1.0);
73/// assert_eq!(true_mod(-7.0, 3.0), 2.0);
74/// assert_eq!(true_mod(7.0, -3.0), -2.0);
75/// assert_eq!(true_mod(-7.0, -3.0), -1.0);
76/// assert_eq!(true_mod(5.0, 0.0), 0.0);
77/// ```
78#[inline]
79#[must_use]
80pub fn true_mod(a: f64, b: f64) -> f64 {
81    if matches!(b.classify(), std::num::FpCategory::Zero) {
82        return 0.0;
83    }
84
85    ((a % b) + b) % b
86}
87
88/// Converts a `u64` to `f64` with the same rounding as Rust's native cast.
89#[must_use]
90pub fn u64_to_f64(value: u64) -> f64 {
91    let [low_0, low_1, low_2, low_3, high_0, high_1, high_2, high_3] = value.to_le_bytes();
92    let low = u32::from_le_bytes([low_0, low_1, low_2, low_3]);
93    let high = u32::from_le_bytes([high_0, high_1, high_2, high_3]);
94
95    f64::from(high).mul_add(U32_RANGE_AS_F64, f64::from(low))
96}
97
98/// Converts an `i64` to `f64` with the same rounding as Rust's native cast.
99#[must_use]
100pub fn i64_to_f64(value: i64) -> f64 {
101    let magnitude = u64_to_f64(value.unsigned_abs());
102
103    if value.is_negative() {
104        -magnitude
105    } else {
106        magnitude
107    }
108}
109
110/// Converts a `usize` to `f64` with the same rounding as Rust's native cast.
111#[expect(
112    clippy::cast_precision_loss,
113    reason = "this is the audited boundary for the native usize-to-f64 policy"
114)]
115#[must_use]
116pub const fn usize_to_f64(value: usize) -> f64 {
117    value as f64
118}
119
120/// Rounds an `f64` to the requested number of decimal places.
121///
122/// Halfway values follow [`f64::round`] and therefore round away from zero.
123#[must_use]
124pub fn round_to_decimals(value: f64, decimals: u8) -> f64 {
125    let scale = DECIMAL_RADIX.powi(i32::from(decimals));
126
127    (value * scale).round() / scale
128}
129
130/// Interpolates `x` over points sorted ascending by their first coordinate.
131#[must_use]
132pub fn interpolate_sorted(points: &[(f64, f64)], x: f64) -> Option<f64> {
133    let first = points.first()?;
134    let last = points.last()?;
135
136    if x <= first.0 {
137        return Some(first.1);
138    }
139
140    if x >= last.0 {
141        return Some(last.1);
142    }
143
144    for window in points.windows(INTERPOLATION_WINDOW) {
145        let (x0, y0) = window[0];
146        let (x1, y1) = window[1];
147
148        if x >= x0 && x <= x1 {
149            let t = (x - x0) / (x1 - x0);
150
151            return Some(y0 + t * (y1 - y0));
152        }
153    }
154
155    None
156}
157
158/// Truncates an `f64` and saturates it to `i32`.
159#[expect(
160    clippy::cast_possible_truncation,
161    reason = "this is the audited boundary for Rust's saturating float-to-i32 cast"
162)]
163#[must_use]
164pub const fn f64_to_i32_saturating_trunc(value: f64) -> i32 {
165    value as i32
166}
167
168/// Rounds an `f64` to the nearest integer and saturates it to `i32`.
169#[must_use]
170pub fn f64_to_i32_saturating_round(value: f64) -> i32 {
171    f64_to_i32_saturating_trunc(value.round())
172}
173
174/// Rounds an `f64` upward and saturates it to `i32`.
175#[must_use]
176pub fn f64_to_i32_saturating_ceil(value: f64) -> i32 {
177    f64_to_i32_saturating_trunc(value.ceil())
178}
179
180/// Truncates an `f64` and saturates it to `i64`.
181#[expect(
182    clippy::cast_possible_truncation,
183    reason = "this is the audited boundary for Rust's saturating float-to-i64 cast"
184)]
185#[must_use]
186pub const fn f64_to_i64_saturating_trunc(value: f64) -> i64 {
187    value as i64
188}
189
190/// Rounds an `f64` to the nearest integer and saturates it to `i64`.
191#[must_use]
192pub fn f64_to_i64_saturating_round(value: f64) -> i64 {
193    f64_to_i64_saturating_trunc(value.round())
194}
195
196/// Rounds an `f64` downward and saturates it to `i64`.
197#[must_use]
198pub fn f64_to_i64_saturating_floor(value: f64) -> i64 {
199    f64_to_i64_saturating_trunc(value.floor())
200}
201
202/// Truncates an `f64` and saturates it to `u8`.
203#[must_use]
204pub const fn f64_to_u8_saturating_trunc(value: f64) -> u8 {
205    let value = f64_to_u32_saturating_trunc(value);
206
207    if value > U8_MAX_AS_U32 {
208        u8::MAX
209    } else {
210        value.to_le_bytes()[0]
211    }
212}
213
214/// Truncates an `f64` and saturates it to `u32`.
215#[expect(
216    clippy::cast_possible_truncation,
217    clippy::cast_sign_loss,
218    reason = "this is the audited boundary for Rust's saturating float-to-u32 cast"
219)]
220#[must_use]
221pub const fn f64_to_u32_saturating_trunc(value: f64) -> u32 {
222    value as u32
223}
224
225/// Rounds an `f64` to the nearest integer and saturates it to `u32`.
226#[must_use]
227pub fn f64_to_u32_saturating_round(value: f64) -> u32 {
228    f64_to_u32_saturating_trunc(value.round())
229}
230
231/// Rounds an `f64` upward and saturates it to `u32`.
232#[must_use]
233pub fn f64_to_u32_saturating_ceil(value: f64) -> u32 {
234    f64_to_u32_saturating_trunc(value.ceil())
235}
236
237/// Truncates an `f64` and saturates it to `u64`.
238#[expect(
239    clippy::cast_possible_truncation,
240    clippy::cast_sign_loss,
241    reason = "this is the audited boundary for Rust's saturating float-to-u64 cast"
242)]
243#[must_use]
244pub const fn f64_to_u64_saturating_trunc(value: f64) -> u64 {
245    value as u64
246}
247
248/// Rounds an `f64` to the nearest integer and saturates it to `u64`.
249#[must_use]
250pub fn f64_to_u64_saturating_round(value: f64) -> u64 {
251    f64_to_u64_saturating_trunc(value.round())
252}
253
254/// Rounds an `f64` downward and saturates it to `u64`.
255#[must_use]
256pub fn f64_to_u64_saturating_floor(value: f64) -> u64 {
257    f64_to_u64_saturating_trunc(value.floor())
258}
259
260/// Rounds an `f64` upward and saturates it to `u64`.
261#[must_use]
262pub fn f64_to_u64_saturating_ceil(value: f64) -> u64 {
263    f64_to_u64_saturating_trunc(value.ceil())
264}
265
266/// Truncates an `f64` and saturates it to `usize`.
267#[expect(
268    clippy::cast_possible_truncation,
269    clippy::cast_sign_loss,
270    reason = "this is the audited boundary for Rust's saturating float-to-usize cast"
271)]
272#[must_use]
273pub const fn f64_to_usize_saturating_trunc(value: f64) -> usize {
274    value as usize
275}
276
277/// Rounds an `f64` to the nearest integer and saturates it to `usize`.
278#[must_use]
279pub fn f64_to_usize_saturating_round(value: f64) -> usize {
280    f64_to_usize_saturating_trunc(value.round())
281}
282
283/// Saturates an `i32` to the range representable by `u8`.
284#[must_use]
285pub fn i32_to_u8_saturating(value: i32) -> u8 {
286    u8::try_from(value).unwrap_or_else(|_| if value.is_negative() { 0 } else { u8::MAX })
287}
288
289/// Converts a nonnegative `i32` to `u32`, clamping negative values to zero.
290#[must_use]
291pub fn i32_to_u32_nonnegative(value: i32) -> u32 {
292    u32::try_from(value).unwrap_or_default()
293}
294
295/// Saturates a `u32` to the range representable by `i32`.
296#[must_use]
297pub fn u32_to_i32_saturating(value: u32) -> i32 {
298    i32::try_from(value).unwrap_or(i32::MAX)
299}
300
301/// Saturates a `usize` to the range representable by `u32`.
302#[must_use]
303pub fn usize_to_u32_saturating(value: usize) -> u32 {
304    u32::try_from(value).unwrap_or(u32::MAX)
305}
306
307#[cfg(test)]
308#[path = "numeric/tests.rs"]
309mod tests;