Skip to main content

dotloom_geometry/
units.rs

1//! Units and dimensioned quantities.
2//!
3//! Canonical internal units (ADR-0002): length = millimetre, angle = radian,
4//! time = second. Quantities carry a [`Dim`] so that seconds, metres and degrees
5//! can never be added silently.
6
7use core::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{GeoResult, GeometryError};
12
13/// Length units with exact conversion factors to millimetres.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub enum LengthUnit {
17    /// Millimetre (canonical).
18    #[default]
19    Millimetre,
20    /// Centimetre.
21    Centimetre,
22    /// Metre.
23    Metre,
24    /// International inch (25.4 mm).
25    Inch,
26    /// International foot (304.8 mm).
27    Foot,
28}
29
30impl LengthUnit {
31    /// All units.
32    pub const ALL: [Self; 5] = [Self::Millimetre, Self::Centimetre, Self::Metre, Self::Inch, Self::Foot];
33
34    /// Millimetres per unit.
35    #[must_use]
36    pub const fn mm_per_unit(self) -> f64 {
37        match self {
38            Self::Millimetre => 1.0,
39            Self::Centimetre => 10.0,
40            Self::Metre => 1000.0,
41            Self::Inch => 25.4,
42            Self::Foot => 304.8,
43        }
44    }
45
46    /// Convert a value in this unit to millimetres.
47    #[must_use]
48    pub fn to_mm(self, v: f64) -> f64 {
49        v * self.mm_per_unit()
50    }
51
52    /// Convert millimetres to this unit.
53    #[must_use]
54    pub fn from_mm(self, mm: f64) -> f64 {
55        mm / self.mm_per_unit()
56    }
57
58    /// Unit symbol.
59    #[must_use]
60    pub const fn symbol(self) -> &'static str {
61        match self {
62            Self::Millimetre => "mm",
63            Self::Centimetre => "cm",
64            Self::Metre => "m",
65            Self::Inch => "in",
66            Self::Foot => "ft",
67        }
68    }
69
70    /// Parse a unit symbol.
71    pub fn parse(s: &str) -> GeoResult<Self> {
72        match s.trim() {
73            "mm" => Ok(Self::Millimetre),
74            "cm" => Ok(Self::Centimetre),
75            "m" => Ok(Self::Metre),
76            "in" | "\"" => Ok(Self::Inch),
77            "ft" | "'" => Ok(Self::Foot),
78            other => Err(GeometryError::UnknownUnit(other.to_owned())),
79        }
80    }
81}
82
83/// Angle units.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub enum AngleUnit {
87    /// Radian (canonical).
88    #[default]
89    Radian,
90    /// Degree.
91    Degree,
92}
93
94impl AngleUnit {
95    /// Radians per unit.
96    #[must_use]
97    pub const fn rad_per_unit(self) -> f64 {
98        match self {
99            Self::Radian => 1.0,
100            Self::Degree => core::f64::consts::PI / 180.0,
101        }
102    }
103
104    /// Unit symbol.
105    #[must_use]
106    pub const fn symbol(self) -> &'static str {
107        match self {
108            Self::Radian => "rad",
109            Self::Degree => "deg",
110        }
111    }
112}
113
114/// Time units.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub enum TimeUnit {
118    /// Millisecond.
119    Millisecond,
120    /// Second (canonical).
121    #[default]
122    Second,
123    /// Minute.
124    Minute,
125    /// Hour.
126    Hour,
127    /// Day (86 400 s).
128    Day,
129}
130
131impl TimeUnit {
132    /// Seconds per unit.
133    #[must_use]
134    pub const fn s_per_unit(self) -> f64 {
135        match self {
136            Self::Millisecond => 0.001,
137            Self::Second => 1.0,
138            Self::Minute => 60.0,
139            Self::Hour => 3600.0,
140            Self::Day => 86_400.0,
141        }
142    }
143
144    /// Unit symbol.
145    #[must_use]
146    pub const fn symbol(self) -> &'static str {
147        match self {
148            Self::Millisecond => "ms",
149            Self::Second => "s",
150            Self::Minute => "min",
151            Self::Hour => "h",
152            Self::Day => "d",
153        }
154    }
155}
156
157/// Physical dimension as exponents of length, angle and time.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)]
159pub struct Dim {
160    /// Length exponent.
161    #[serde(default, rename = "L", skip_serializing_if = "is_zero")]
162    pub length: i8,
163    /// Angle exponent.
164    #[serde(default, rename = "A", skip_serializing_if = "is_zero")]
165    pub angle: i8,
166    /// Time exponent.
167    #[serde(default, rename = "T", skip_serializing_if = "is_zero")]
168    pub time: i8,
169}
170
171#[allow(clippy::trivially_copy_pass_by_ref)]
172fn is_zero(v: &i8) -> bool {
173    *v == 0
174}
175
176impl Dim {
177    /// Dimensionless.
178    pub const SCALAR: Self = Self { length: 0, angle: 0, time: 0 };
179    /// Length.
180    pub const LENGTH: Self = Self { length: 1, angle: 0, time: 0 };
181    /// Area.
182    pub const AREA: Self = Self { length: 2, angle: 0, time: 0 };
183    /// Angle.
184    pub const ANGLE: Self = Self { length: 0, angle: 1, time: 0 };
185    /// Time.
186    pub const TIME: Self = Self { length: 0, angle: 0, time: 1 };
187
188    /// Dimension of a product.
189    #[must_use]
190    pub const fn mul(self, o: Self) -> Self {
191        Self {
192            length: self.length.saturating_add(o.length),
193            angle: self.angle.saturating_add(o.angle),
194            time: self.time.saturating_add(o.time),
195        }
196    }
197
198    /// Dimension of a quotient.
199    #[must_use]
200    pub const fn div(self, o: Self) -> Self {
201        Self {
202            length: self.length.saturating_sub(o.length),
203            angle: self.angle.saturating_sub(o.angle),
204            time: self.time.saturating_sub(o.time),
205        }
206    }
207}
208
209impl fmt::Display for Dim {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        if *self == Self::SCALAR {
212            return f.write_str("scalar");
213        }
214        let mut first = true;
215        for (name, e) in [("length", self.length), ("angle", self.angle), ("time", self.time)] {
216            if e != 0 {
217                if !first {
218                    f.write_str("·")?;
219                }
220                first = false;
221                if e == 1 {
222                    write!(f, "{name}")?;
223                } else {
224                    write!(f, "{name}^{e}")?;
225                }
226            }
227        }
228        Ok(())
229    }
230}
231
232/// A value with a physical dimension, stored in canonical units (mm, rad, s).
233#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
234pub struct Quantity {
235    /// Value in canonical units.
236    pub value: f64,
237    /// Dimension.
238    pub dim: Dim,
239}
240
241impl Quantity {
242    /// Dimensionless number.
243    #[must_use]
244    pub const fn scalar(v: f64) -> Self {
245        Self { value: v, dim: Dim::SCALAR }
246    }
247
248    /// Length from a value in `unit`.
249    #[must_use]
250    pub fn length(v: f64, unit: LengthUnit) -> Self {
251        Self { value: unit.to_mm(v), dim: Dim::LENGTH }
252    }
253
254    /// Angle from a value in `unit`.
255    #[must_use]
256    pub fn angle(v: f64, unit: AngleUnit) -> Self {
257        Self { value: v * unit.rad_per_unit(), dim: Dim::ANGLE }
258    }
259
260    /// Time from a value in `unit`.
261    #[must_use]
262    pub fn time(v: f64, unit: TimeUnit) -> Self {
263        Self { value: v * unit.s_per_unit(), dim: Dim::TIME }
264    }
265
266    fn same_dim(self, o: Self) -> GeoResult<()> {
267        if self.dim == o.dim {
268            Ok(())
269        } else {
270            Err(GeometryError::DimensionMismatch { left: self.dim.to_string(), right: o.dim.to_string() })
271        }
272    }
273
274    /// Checked addition (dimensions must match).
275    pub fn checked_add(self, o: Self) -> GeoResult<Self> {
276        self.same_dim(o)?;
277        Ok(Self { value: self.value + o.value, dim: self.dim })
278    }
279
280    /// Checked subtraction.
281    pub fn checked_sub(self, o: Self) -> GeoResult<Self> {
282        self.same_dim(o)?;
283        Ok(Self { value: self.value - o.value, dim: self.dim })
284    }
285
286    /// Product (dimensions multiply).
287    #[must_use]
288    pub fn times(self, o: Self) -> Self {
289        Self { value: self.value * o.value, dim: self.dim.mul(o.dim) }
290    }
291
292    /// Quotient (dimensions divide).
293    #[must_use]
294    pub fn per(self, o: Self) -> Self {
295        Self { value: self.value / o.value, dim: self.dim.div(o.dim) }
296    }
297
298    /// Parse strings like `"60 cm"`, `"2.5m"`, `"90 deg"`, `"15 min"`, `"3"`.
299    pub fn parse(s: &str) -> GeoResult<Self> {
300        let s = s.trim();
301        let split = s
302            .char_indices()
303            .find(|(_, c)| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')))
304            .map_or(s.len(), |(i, _)| i);
305        // Guard against "e" being taken as a unit start for inputs like "1e3".
306        let (num, unit) = s.split_at(split);
307        let v: f64 = num.trim().parse().map_err(|_| GeometryError::InvalidArgument("not a number"))?;
308        if !v.is_finite() {
309            return Err(GeometryError::NonFinite("quantity"));
310        }
311        let unit = unit.trim();
312        if unit.is_empty() {
313            return Ok(Self::scalar(v));
314        }
315        if let Ok(u) = LengthUnit::parse(unit) {
316            return Ok(Self::length(v, u));
317        }
318        match unit {
319            "deg" | "°" => Ok(Self::angle(v, AngleUnit::Degree)),
320            "rad" => Ok(Self::angle(v, AngleUnit::Radian)),
321            "ms" => Ok(Self::time(v, TimeUnit::Millisecond)),
322            "s" => Ok(Self::time(v, TimeUnit::Second)),
323            "min" => Ok(Self::time(v, TimeUnit::Minute)),
324            "h" => Ok(Self::time(v, TimeUnit::Hour)),
325            "d" => Ok(Self::time(v, TimeUnit::Day)),
326            other => Err(GeometryError::UnknownUnit(other.to_owned())),
327        }
328    }
329}
330
331/// Explicit mapping from a domain time axis to model X coordinates (timelines).
332///
333/// `x_mm = (t_seconds - origin_s) * mm_per_second`. The mapping is the only place
334/// where time becomes length; constraints work on time-dimensioned variables.
335#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
336pub struct TimeAxis {
337    /// Domain time shown at `x = 0`.
338    pub origin_s: f64,
339    /// Model millimetres per second.
340    pub mm_per_second: f64,
341}
342
343impl TimeAxis {
344    /// Create a validated axis.
345    pub fn new(origin_s: f64, mm_per_second: f64) -> GeoResult<Self> {
346        if !(origin_s.is_finite() && mm_per_second.is_finite()) {
347            return Err(GeometryError::NonFinite("time axis"));
348        }
349        if mm_per_second <= 0.0 {
350            return Err(GeometryError::InvalidArgument("time axis scale must be > 0"));
351        }
352        Ok(Self { origin_s, mm_per_second })
353    }
354
355    /// Model X for a time quantity.
356    pub fn to_x(self, t: Quantity) -> GeoResult<f64> {
357        if t.dim != Dim::TIME {
358            return Err(GeometryError::DimensionMismatch { left: t.dim.to_string(), right: Dim::TIME.to_string() });
359        }
360        Ok((t.value - self.origin_s) * self.mm_per_second)
361    }
362
363    /// Time at model X.
364    #[must_use]
365    pub fn time_at(self, x_mm: f64) -> Quantity {
366        Quantity { value: self.origin_s + x_mm / self.mm_per_second, dim: Dim::TIME }
367    }
368
369    /// Model width of a duration.
370    pub fn width_of(self, duration: Quantity) -> GeoResult<f64> {
371        if duration.dim != Dim::TIME {
372            return Err(GeometryError::DimensionMismatch {
373                left: duration.dim.to_string(),
374                right: Dim::TIME.to_string(),
375            });
376        }
377        Ok(duration.value * self.mm_per_second)
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn length_conversions_roundtrip() {
387        for u in LengthUnit::ALL {
388            let v = 123.456;
389            assert!((u.from_mm(u.to_mm(v)) - v).abs() < 1e-12);
390        }
391        assert_eq!(LengthUnit::Centimetre.to_mm(60.0), 600.0);
392        assert_eq!(LengthUnit::Metre.to_mm(1.8), 1800.0);
393        assert!((LengthUnit::Inch.to_mm(1.0) - 25.4).abs() < 1e-15);
394        assert!((LengthUnit::Foot.from_mm(304.8) - 1.0).abs() < 1e-15);
395    }
396
397    #[test]
398    fn mixed_dimensions_do_not_add() {
399        let a = Quantity::parse("2 m").unwrap();
400        let b = Quantity::parse("30 s").unwrap();
401        let c = Quantity::parse("90 deg").unwrap();
402        assert!(matches!(a.checked_add(b), Err(GeometryError::DimensionMismatch { .. })));
403        assert!(a.checked_add(c).is_err());
404        assert!(b.checked_sub(c).is_err());
405        let ok = a.checked_add(Quantity::parse("50 cm").unwrap()).unwrap();
406        assert_eq!(ok.value, 2500.0);
407        let area = a.times(a);
408        assert_eq!(area.dim, Dim::AREA);
409        let speed = a.per(b);
410        assert_eq!(speed.dim.to_string(), "length·time^-1");
411    }
412
413    #[test]
414    fn parse_variants() {
415        assert_eq!(Quantity::parse("60cm").unwrap().value, 600.0);
416        assert_eq!(Quantity::parse(" 3 ").unwrap(), Quantity::scalar(3.0));
417        assert!((Quantity::parse("180 deg").unwrap().value - core::f64::consts::PI).abs() < 1e-15);
418        assert_eq!(Quantity::parse("15 min").unwrap().value, 900.0);
419        assert!(Quantity::parse("5 parsec").is_err());
420        assert!(Quantity::parse("abc").is_err());
421        assert!(Quantity::parse("1e400 mm").is_err());
422    }
423
424    #[test]
425    fn time_axis_is_explicit() {
426        let axis = TimeAxis::new(3600.0, 2.0).unwrap();
427        let x = axis.to_x(Quantity::time(2.0, TimeUnit::Hour)).unwrap();
428        assert_eq!(x, 7200.0);
429        assert!(axis.to_x(Quantity::length(5.0, LengthUnit::Metre)).is_err());
430        assert_eq!(axis.time_at(7200.0).value, 7200.0);
431        assert!(TimeAxis::new(0.0, 0.0).is_err());
432    }
433}