ifc_lite_core/project_units/
symbols.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Display-symbol formatting for IFC units: `IfcSIUnitName` symbols, SI prefix
6//! symbols, `IfcDerivedUnit` composition (e.g. `m\u{00B3}/s`) and friendly
7//! symbols for common `IfcConversionBasedUnit` names (\u{00B0}, ft, in, ...).
8//!
9//! Mirrored by the TypeScript `packages/parser/src/project-units.ts` and pinned
10//! by the shared parity vectors.
11
12use crate::units::get_si_prefix_multiplier;
13
14/// Symbol for an SI prefix as it prepends a unit symbol (`k`, `m`, `\u{00B5}`, ...).
15/// Unknown / empty prefix yields the empty string.
16pub fn si_prefix_symbol(prefix: &str) -> &'static str {
17    match prefix.trim().to_ascii_uppercase().as_str() {
18        "EXA" => "E",
19        "PETA" => "P",
20        "TERA" => "T",
21        "GIGA" => "G",
22        "MEGA" => "M",
23        "KILO" => "k",
24        "HECTO" => "h",
25        "DECA" => "da",
26        "DECI" => "d",
27        "CENTI" => "c",
28        "MILLI" => "m",
29        "MICRO" => "\u{00B5}", // micro sign
30        "NANO" => "n",
31        "PICO" => "p",
32        "FEMTO" => "f",
33        "ATTO" => "a",
34        _ => "",
35    }
36}
37
38/// Descriptor for an `IfcSIUnitName`.
39#[derive(Clone, Copy, Debug)]
40pub struct SiUnitName {
41    /// Base display symbol at no prefix, e.g. `m`, `m\u{00B2}`, `g`, `Pa`.
42    pub symbol: &'static str,
43    /// Power the SI prefix is raised to for scale purposes: 1 normally, 2 for
44    /// `SQUARE_METRE`, 3 for `CUBIC_METRE` (a `CENTI` cubic-metre is `(10^-2)^3`).
45    pub prefix_power: i32,
46    /// Factor that converts a value in the unprefixed unit to the canonical SI
47    /// base of its dimension. `1.0` for everything except `GRAM` (base is `kg`,
48    /// so a gram is `1e-3`).
49    pub base_scale: f64,
50}
51
52/// Resolve an `IfcSIUnitName` enum token to its symbol descriptor.
53pub fn si_unit_name(name: &str) -> Option<SiUnitName> {
54    let n = name.trim().trim_matches('.').to_ascii_uppercase();
55    let d = |symbol| SiUnitName { symbol, prefix_power: 1, base_scale: 1.0 };
56    Some(match n.as_str() {
57        "METRE" => d("m"),
58        "SQUARE_METRE" => SiUnitName { symbol: "m\u{00B2}", prefix_power: 2, base_scale: 1.0 },
59        "CUBIC_METRE" => SiUnitName { symbol: "m\u{00B3}", prefix_power: 3, base_scale: 1.0 },
60        "GRAM" => SiUnitName { symbol: "g", prefix_power: 1, base_scale: 1e-3 },
61        "SECOND" => d("s"),
62        "AMPERE" => d("A"),
63        "KELVIN" => d("K"),
64        "MOLE" => d("mol"),
65        "CANDELA" => d("cd"),
66        "RADIAN" => d("rad"),
67        "STERADIAN" => d("sr"),
68        "HERTZ" => d("Hz"),
69        "NEWTON" => d("N"),
70        "PASCAL" => d("Pa"),
71        "JOULE" => d("J"),
72        "WATT" => d("W"),
73        "COULOMB" => d("C"),
74        "VOLT" => d("V"),
75        "FARAD" => d("F"),
76        "OHM" => d("\u{03A9}"),
77        "SIEMENS" => d("S"),
78        "WEBER" => d("Wb"),
79        "TESLA" => d("T"),
80        "HENRY" => d("H"),
81        "DEGREE_CELSIUS" => d("\u{00B0}C"),
82        "LUMEN" => d("lm"),
83        "LUX" => d("lx"),
84        "BECQUEREL" => d("Bq"),
85        "GRAY" => d("Gy"),
86        "SIEVERT" => d("Sv"),
87        _ => return None,
88    })
89}
90
91/// Symbol + SI scale for a prefixed `IfcSIUnit`.
92///
93/// `symbol` prepends the prefix symbol to the base (`c` + `m\u{00B2}` -> `cm\u{00B2}`);
94/// `si_scale` folds `base_scale * prefix_multiplier^prefix_power` (so `cm\u{00B2}`
95/// is `(10^-2)^2 = 1e-4`, `kg` is `1e-3 * 10^3 = 1.0`).
96pub fn si_unit_symbol_and_scale(name: &str, prefix: Option<&str>) -> Option<(String, f64)> {
97    let base = si_unit_name(name)?;
98    let (prefix_sym, prefix_mult) = match prefix {
99        Some(p) if !p.trim().trim_matches('.').is_empty() => {
100            (si_prefix_symbol(p), get_si_prefix_multiplier(p.trim().trim_matches('.')))
101        }
102        _ => ("", 1.0),
103    };
104    let symbol = format!("{prefix_sym}{}", base.symbol);
105    let scale = base.base_scale * prefix_mult.powi(base.prefix_power);
106    Some((symbol, scale))
107}
108
109/// A friendly symbol for a common `IfcConversionBasedUnit` name. Falls back to
110/// the cleaned name (quotes/whitespace trimmed) when unknown.
111pub fn conversion_unit_symbol(name: &str) -> String {
112    let clean = name.trim().trim_matches('\'').trim();
113    match clean.to_ascii_uppercase().as_str() {
114        "DEGREE" => "\u{00B0}".to_string(),
115        "GRAD" | "GON" => "gon".to_string(),
116        "MINUTE" => "\u{2032}".to_string(),
117        "SECOND" => "\u{2033}".to_string(),
118        "FOOT" | "FEET" => "ft".to_string(),
119        "INCH" => "in".to_string(),
120        "YARD" => "yd".to_string(),
121        "MILE" => "mi".to_string(),
122        "LITRE" | "LITER" => "L".to_string(),
123        "ACRE" => "acre".to_string(),
124        "POUND" | "POUND-MASS" | "LBM" => "lb".to_string(),
125        "POUND-FORCE" | "LBF" => "lbf".to_string(),
126        "OUNCE" => "oz".to_string(),
127        "TON-METRIC" | "TONNE" => "t".to_string(),
128        "PSI" => "psi".to_string(),
129        "BAR" => "bar".to_string(),
130        "KIP" => "kip".to_string(),
131        "MINUTE-TIME" | "MIN" => "min".to_string(),
132        "HOUR" => "h".to_string(),
133        "DAY" => "d".to_string(),
134        "BTU" => "Btu".to_string(),
135        "" => "".to_string(),
136        _ => clean.to_string(),
137    }
138}
139
140/// Compose a derived-unit display symbol from `(element_symbol, exponent)` pairs,
141/// e.g. `[("m", 3), ("s", -1)]` -> `m\u{00B3}/s`, `[("J",1),("kg",-1),("K",-1)]`
142/// -> `J/(kg\u{00B7}K)`. Elements with exponent 0 are dropped. Returns an empty
143/// string only when there are no non-zero elements.
144pub fn compose_derived(elements: &[(String, i32)]) -> String {
145    let mut num: Vec<String> = Vec::new();
146    let mut den: Vec<String> = Vec::new();
147    for (sym, exp) in elements {
148        if *exp == 0 || sym.is_empty() {
149            continue;
150        }
151        let piece = format!("{sym}{}", superscript(exp.unsigned_abs()));
152        if *exp > 0 {
153            num.push(piece);
154        } else {
155            den.push(piece);
156        }
157    }
158    let mid_dot = "\u{00B7}"; // middle dot
159    let numerator = if num.is_empty() { "1".to_string() } else { num.join(mid_dot) };
160    if den.is_empty() {
161        return if num.is_empty() { String::new() } else { numerator };
162    }
163    let denominator = den.join(mid_dot);
164    if den.len() > 1 {
165        format!("{numerator}/({denominator})")
166    } else {
167        format!("{numerator}/{denominator}")
168    }
169}
170
171/// Unicode superscript for an exponent MAGNITUDE (1 renders as empty).
172///
173/// Takes the magnitude as `u32`, which is what the caller already has from
174/// `unsigned_abs()`. Narrowing it back to `i32` made exactly `i32::MIN`
175/// negative again, and the digit map below then computed `'-' - '0'` in
176/// `u32`: an overflow panic under `cargo test` and debug builds, a garbage
177/// symbol in release.
178fn superscript(mag: u32) -> String {
179    match mag {
180        1 => String::new(),
181        0 => "\u{2070}".to_string(),
182        2 => "\u{00B2}".to_string(),
183        3 => "\u{00B3}".to_string(),
184        n if (4..=9).contains(&n) => {
185            // superscript 4-9 live at U+2074..U+2079
186            char::from_u32(0x2070 + n).map(String::from).unwrap_or_default()
187        }
188        n => {
189            // Multi-digit: build from digit superscripts. Every char of a
190            // `u32`'s decimal form is an ASCII digit, so the arithmetic arm
191            // cannot underflow.
192            n.to_string()
193                .chars()
194                .map(|c| match c {
195                    '0' => '\u{2070}',
196                    '1' => '\u{00B9}',
197                    '2' => '\u{00B2}',
198                    '3' => '\u{00B3}',
199                    d => char::from_u32(0x2070 + (d as u32 - '0' as u32)).unwrap_or(d),
200                })
201                .collect()
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn prefixed_length_symbol_and_scale() {
212        let (sym, scale) = si_unit_symbol_and_scale("METRE", Some("MILLI")).unwrap();
213        assert_eq!(sym, "mm");
214        assert!((scale - 1e-3).abs() < 1e-15);
215    }
216
217    #[test]
218    fn kilogram_folds_gram_base_scale() {
219        let (sym, scale) = si_unit_symbol_and_scale("GRAM", Some("KILO")).unwrap();
220        assert_eq!(sym, "kg");
221        assert!((scale - 1.0).abs() < 1e-12);
222    }
223
224    #[test]
225    fn prefixed_area_uses_squared_power() {
226        let (sym, scale) = si_unit_symbol_and_scale("SQUARE_METRE", Some("CENTI")).unwrap();
227        assert_eq!(sym, "cm\u{00B2}");
228        assert!((scale - 1e-4).abs() < 1e-18);
229    }
230
231    #[test]
232    fn prefixed_volume_uses_cubed_power() {
233        // A milli cubic-metre must scale by (1e-3)^3, not (1e-3)^1 or (1e-3)^2:
234        // `prefix_power` for CUBIC_METRE is untested at any non-default value
235        // without this, so a regression collapsing the cube to a square (or to
236        // no power at all) would pass every other test in this file.
237        let (sym, scale) = si_unit_symbol_and_scale("CUBIC_METRE", Some("MILLI")).unwrap();
238        assert_eq!(sym, "mm\u{00B3}");
239        assert!((scale - 1e-9).abs() < 1e-24, "expected 1e-9 (cubed), got {scale}");
240    }
241
242    #[test]
243    fn bare_square_metre() {
244        let (sym, scale) = si_unit_symbol_and_scale("SQUARE_METRE", None).unwrap();
245        assert_eq!(sym, "m\u{00B2}");
246        assert!((scale - 1.0).abs() < 1e-15);
247    }
248
249    #[test]
250    fn derived_volumetric_flow_rate() {
251        assert_eq!(
252            compose_derived(&[("m".into(), 3), ("s".into(), -1)]),
253            "m\u{00B3}/s"
254        );
255    }
256
257    #[test]
258    fn derived_specific_heat_parenthesises_denominator() {
259        assert_eq!(
260            compose_derived(&[("J".into(), 1), ("kg".into(), -1), ("K".into(), -1)]),
261            "J/(kg\u{00B7}K)"
262        );
263    }
264
265    #[test]
266    fn derived_pure_inverse() {
267        assert_eq!(compose_derived(&[("s".into(), -1)]), "1/s");
268    }
269
270    /// `i32::MIN` is the one exponent whose magnitude does not fit `i32`:
271    /// narrowing `unsigned_abs()` back to `i32` re-signed it and the digit
272    /// map hit `'-' - '0'`, an arithmetic overflow panic. The composed
273    /// symbol must be the superscript of 2147483648 in a denominator.
274    #[test]
275    fn derived_exponent_i32_min_does_not_overflow() {
276        let composed = compose_derived(&[("m".into(), i32::MIN)]);
277        assert_eq!(
278            composed,
279            "1/m\u{00B2}\u{00B9}\u{2074}\u{2077}\u{2074}\u{2078}\u{00B3}\u{2076}\u{2074}\u{2078}"
280        );
281        // And the positive twin, so the magnitude path is pinned both ways.
282        assert_eq!(
283            compose_derived(&[("m".into(), i32::MAX)]),
284            "m\u{00B2}\u{00B9}\u{2074}\u{2077}\u{2074}\u{2078}\u{00B3}\u{2076}\u{2074}\u{2077}"
285        );
286    }
287
288    #[test]
289    fn conversion_symbols() {
290        assert_eq!(conversion_unit_symbol("'DEGREE'"), "\u{00B0}");
291        assert_eq!(conversion_unit_symbol("FOOT"), "ft");
292        assert_eq!(conversion_unit_symbol("MYUNIT"), "MYUNIT");
293    }
294}