1use core::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{GeoResult, GeometryError};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub enum LengthUnit {
17 #[default]
19 Millimetre,
20 Centimetre,
22 Metre,
24 Inch,
26 Foot,
28}
29
30impl LengthUnit {
31 pub const ALL: [Self; 5] = [Self::Millimetre, Self::Centimetre, Self::Metre, Self::Inch, Self::Foot];
33
34 #[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 #[must_use]
48 pub fn to_mm(self, v: f64) -> f64 {
49 v * self.mm_per_unit()
50 }
51
52 #[must_use]
54 pub fn from_mm(self, mm: f64) -> f64 {
55 mm / self.mm_per_unit()
56 }
57
58 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub enum AngleUnit {
87 #[default]
89 Radian,
90 Degree,
92}
93
94impl AngleUnit {
95 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub enum TimeUnit {
118 Millisecond,
120 #[default]
122 Second,
123 Minute,
125 Hour,
127 Day,
129}
130
131impl TimeUnit {
132 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)]
159pub struct Dim {
160 #[serde(default, rename = "L", skip_serializing_if = "is_zero")]
162 pub length: i8,
163 #[serde(default, rename = "A", skip_serializing_if = "is_zero")]
165 pub angle: i8,
166 #[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 pub const SCALAR: Self = Self { length: 0, angle: 0, time: 0 };
179 pub const LENGTH: Self = Self { length: 1, angle: 0, time: 0 };
181 pub const AREA: Self = Self { length: 2, angle: 0, time: 0 };
183 pub const ANGLE: Self = Self { length: 0, angle: 1, time: 0 };
185 pub const TIME: Self = Self { length: 0, angle: 0, time: 1 };
187
188 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
234pub struct Quantity {
235 pub value: f64,
237 pub dim: Dim,
239}
240
241impl Quantity {
242 #[must_use]
244 pub const fn scalar(v: f64) -> Self {
245 Self { value: v, dim: Dim::SCALAR }
246 }
247
248 #[must_use]
250 pub fn length(v: f64, unit: LengthUnit) -> Self {
251 Self { value: unit.to_mm(v), dim: Dim::LENGTH }
252 }
253
254 #[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 #[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 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 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 #[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 #[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
336pub struct TimeAxis {
337 pub origin_s: f64,
339 pub mm_per_second: f64,
341}
342
343impl TimeAxis {
344 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 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 #[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 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}