Skip to main content

ifc_lite_core/project_units/
mod.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//! Canonical resolution of a file's declared units for **display**.
6//!
7//! Where [`crate::units`] resolves only the LENGTH and PLANEANGLE *scale
8//! factors* needed to normalise geometry, this module resolves the file's whole
9//! `IfcUnitAssignment` into a per-unit-type table of display symbols + SI scale
10//! factors, covering `IfcSIUnit` (with prefixes), `IfcDerivedUnit` (composed,
11//! e.g. `m\u{00B3}/s`), `IfcConversionBasedUnit` (\u{00B0}, ft, ...) and
12//! `IfcMonetaryUnit`. It also maps a property's IFC measure value type (e.g.
13//! `IfcVolumetricFlowRateMeasure`) onto the unit it is shown in.
14//!
15//! This is the source of truth for unit *display*; the viewer mirrors it in
16//! `packages/parser/src/project-units.ts`, pinned by the shared parity vectors
17//! in `rust/core/tests/fixtures/unit_symbol_vectors.json`.
18
19pub mod measure;
20pub mod symbols;
21
22use std::collections::BTreeMap;
23
24use crate::decoder::EntityDecoder;
25pub use measure::{measure_unit, MeasureUnit};
26use symbols::{compose_derived, conversion_unit_symbol, si_unit_symbol_and_scale};
27
28/// A resolved display unit: the symbol to render plus the factor that converts a
29/// value expressed in this unit to its canonical SI base (`mm` -> `1e-3`,
30/// `m\u{00B3}/h` -> `1/3600`, `\u{00B0}` -> `0.01745...`). `si_scale` is `1.0`
31/// for units already at the SI base and for monetary units.
32#[derive(Clone, Debug, PartialEq)]
33pub struct ResolvedUnit {
34    pub symbol: String,
35    pub si_scale: f64,
36}
37
38impl ResolvedUnit {
39    fn new(symbol: impl Into<String>, si_scale: f64) -> Self {
40        Self { symbol: symbol.into(), si_scale }
41    }
42}
43
44/// The set of units a file declares in its `IfcUnitAssignment`, keyed by
45/// unit-type token (`"LENGTHUNIT"`, `"VOLUMETRICFLOWRATEUNIT"`, ...). Only the
46/// unit-types the file actually declares are present; anything else falls back
47/// to the IFC-canonical SI default in [`ProjectUnits::unit_for_measure`].
48#[derive(Clone, Debug, Default, PartialEq)]
49pub struct ProjectUnits {
50    by_type: BTreeMap<String, ResolvedUnit>,
51    monetary: Option<ResolvedUnit>,
52}
53
54impl ProjectUnits {
55    /// Resolve the full unit assignment reachable from `project_id`. Never
56    /// fails: an absent / malformed assignment yields an empty table (all
57    /// measures then fall back to their SI default symbols).
58    pub fn resolve(decoder: &mut EntityDecoder, project_id: u32) -> Self {
59        let mut units = ProjectUnits::default();
60        let Ok(project) = decoder.decode_by_id(project_id) else {
61            return units;
62        };
63        if project.ifc_type.as_str() != "IFCPROJECT" {
64            return units;
65        }
66        // IFCPROJECT attribute 8 = UnitsInContext (IFCUNITASSIGNMENT).
67        let Some(units_ref) = project.get(8).and_then(|a| a.as_entity_ref()) else {
68            return units;
69        };
70        let Ok(assignment) = decoder.decode_by_id(units_ref) else {
71            return units;
72        };
73        if assignment.ifc_type.as_str() != "IFCUNITASSIGNMENT" {
74            return units;
75        }
76        // IFCUNITASSIGNMENT.Units (attribute 0) is a list of unit refs. Collect
77        // the ids first so we release the borrow on `assignment` before the
78        // decoder is borrowed mutably again inside the resolve loop.
79        let refs: Vec<u32> = match assignment.get(0).and_then(|a| a.as_list()) {
80            Some(list) => list.iter().filter_map(|a| a.as_entity_ref()).collect(),
81            None => return units,
82        };
83        for unit_ref in refs {
84            if let Some((unit_type, resolved, monetary)) = resolve_unit_by_ref(decoder, unit_ref) {
85                if monetary {
86                    units.monetary = Some(resolved);
87                } else if let Some(t) = unit_type {
88                    // First declaration of a unit-type wins (IFC allows only one
89                    // per type anyway); don't let a later duplicate clobber it.
90                    units.by_type.entry(t).or_insert(resolved);
91                }
92            }
93        }
94        units
95    }
96
97    /// The display unit for a property/quantity whose IFC measure value type is
98    /// `measure_type` (e.g. `"IfcVolumetricFlowRateMeasure"`). Prefers the
99    /// file's declared unit for the measure's unit-type and otherwise falls back
100    /// to the IFC-canonical SI default. Returns `None` for dimensionless
101    /// measures (ratios, counts) and non-measure value types (labels, ...).
102    pub fn unit_for_measure(&self, measure_type: &str) -> Option<ResolvedUnit> {
103        match measure_unit(measure_type)? {
104            MeasureUnit::Typed { unit_type, default_symbol } => Some(
105                self.by_type
106                    .get(unit_type)
107                    .cloned()
108                    .unwrap_or_else(|| ResolvedUnit::new(default_symbol, 1.0)),
109            ),
110            MeasureUnit::Monetary => self.monetary.clone(),
111            MeasureUnit::Dimensionless => None,
112        }
113    }
114
115    /// The resolved unit the file declares for a raw unit-type token, if any.
116    pub fn resolved_for_unit_type(&self, unit_type: &str) -> Option<&ResolvedUnit> {
117        self.by_type.get(unit_type)
118    }
119
120    /// The resolved monetary (currency) unit, if the file declares one.
121    pub fn monetary(&self) -> Option<&ResolvedUnit> {
122        self.monetary.as_ref()
123    }
124
125    /// Number of declared unit-types (excluding monetary). Test/telemetry aid.
126    pub fn declared_len(&self) -> usize {
127        self.by_type.len()
128    }
129}
130
131/// Resolve a single unit entity (referenced from a `IfcUnitAssignment`, or from
132/// a per-property / per-quantity `Unit` override). Returns
133/// `(unit_type_token, resolved, is_monetary)`.
134///
135/// Exposed so the per-property `IfcPropertySingleValue.Unit` and per-quantity
136/// `IfcPhysicalSimpleQuantity.Unit` overrides can be resolved against the same
137/// canonical logic.
138pub fn resolve_unit_by_ref(
139    decoder: &mut EntityDecoder,
140    unit_ref: u32,
141) -> Option<(Option<String>, ResolvedUnit, bool)> {
142    resolve_unit_by_ref_depth(decoder, unit_ref, 0)
143}
144
145/// Max depth for the IFCDERIVEDUNIT -> element -> unit recursion. Real derived
146/// units nest ~2 levels; a malformed file can form a reference cycle (an
147/// IFCDERIVEDUNIT whose element's Unit points back to it), so cap the recursion
148/// to keep it from overflowing the stack, which is an uncatchable abort.
149const MAX_UNIT_RESOLVE_DEPTH: u32 = 16;
150
151fn resolve_unit_by_ref_depth(
152    decoder: &mut EntityDecoder,
153    unit_ref: u32,
154    depth: u32,
155) -> Option<(Option<String>, ResolvedUnit, bool)> {
156    if depth > MAX_UNIT_RESOLVE_DEPTH {
157        return None;
158    }
159    let entity = decoder.decode_by_id(unit_ref).ok()?;
160    match entity.ifc_type.as_str() {
161        "IFCSIUNIT" => {
162            // [1]=UnitType, [2]=Prefix, [3]=Name
163            let unit_type = entity.get(1).and_then(|a| a.as_enum()).map(str_token);
164            let name = entity.get(3).and_then(|a| a.as_enum())?;
165            let prefix = entity
166                .get(2)
167                .filter(|a| !a.is_null())
168                .and_then(|a| a.as_enum());
169            let (symbol, scale) = si_unit_symbol_and_scale(name, prefix)?;
170            Some((unit_type, ResolvedUnit::new(symbol, scale), false))
171        }
172        "IFCCONVERSIONBASEDUNIT" => {
173            // [1]=UnitType, [2]=Name, [3]=ConversionFactor (IFCMEASUREWITHUNIT)
174            let unit_type = entity.get(1).and_then(|a| a.as_enum()).map(str_token);
175            let name = entity.get(2).and_then(|a| a.as_string()).unwrap_or("");
176            let symbol = conversion_unit_symbol(name);
177            let conv_ref = entity.get_ref(3);
178            let scale = conv_ref
179                .and_then(|r| conversion_factor_scale(decoder, r))
180                .unwrap_or(1.0);
181            Some((unit_type, ResolvedUnit::new(symbol, scale), false))
182        }
183        "IFCDERIVEDUNIT" => {
184            // [0]=Elements (list of IFCDERIVEDUNITELEMENT), [1]=UnitType
185            let unit_type = entity.get(1).and_then(|a| a.as_enum()).map(str_token);
186            let elem_refs: Vec<u32> = entity
187                .get(0)
188                .and_then(|a| a.as_list())
189                .map(|l| l.iter().filter_map(|a| a.as_entity_ref()).collect())
190                .unwrap_or_default();
191            let mut parts: Vec<(String, i32)> = Vec::new();
192            let mut scale = 1.0f64;
193            for er in elem_refs {
194                if let Some((sym, unit_scale, exponent)) =
195                    resolve_derived_element(decoder, er, depth)
196                {
197                    scale *= unit_scale.powi(exponent);
198                    parts.push((sym, exponent));
199                }
200            }
201            let symbol = compose_derived(&parts);
202            if symbol.is_empty() {
203                return None;
204            }
205            Some((unit_type, ResolvedUnit::new(symbol, scale), false))
206        }
207        "IFCMONETARYUNIT" => {
208            // [0]=Currency (IfcLabel string in IFC4+, IfcCurrencyEnum in IFC2x3).
209            let currency = entity
210                .get(0)
211                .and_then(|a| a.as_string().or_else(|| a.as_enum()))
212                .unwrap_or("");
213            Some((None, ResolvedUnit::new(currency_symbol(currency), 1.0), true))
214        }
215        _ => None,
216    }
217}
218
219/// Resolve one `IFCDERIVEDUNITELEMENT` into `(base_symbol, unit_si_scale, exponent)`.
220fn resolve_derived_element(
221    decoder: &mut EntityDecoder,
222    elem_ref: u32,
223    depth: u32,
224) -> Option<(String, f64, i32)> {
225    let elem = decoder.decode_by_id(elem_ref).ok()?;
226    if elem.ifc_type.as_str() != "IFCDERIVEDUNITELEMENT" {
227        return None;
228    }
229    // [0]=Unit (IfcNamedUnit), [1]=Exponent
230    let unit_ref = elem.get_ref(0)?;
231    let exponent = elem.get(1).and_then(|a| a.as_int()).unwrap_or(1) as i32;
232    let (_ut, resolved, _mon) = resolve_unit_by_ref_depth(decoder, unit_ref, depth + 1)?;
233    Some((resolved.symbol, resolved.si_scale, exponent))
234}
235
236/// The SI scale of an `IFCCONVERSIONBASEDUNIT.ConversionFactor`
237/// (`IFCMEASUREWITHUNIT`): the value component expressed in the (possibly
238/// prefixed) SI unit component.
239fn conversion_factor_scale(decoder: &mut EntityDecoder, measure_ref: u32) -> Option<f64> {
240    let measure = decoder.decode_by_id(measure_ref).ok()?;
241    if measure.ifc_type.as_str() != "IFCMEASUREWITHUNIT" {
242        return None;
243    }
244    // [0]=ValueComponent, [1]=UnitComponent
245    let value = measure.get(0).and_then(|a| a.as_float())?;
246    if !(value.is_finite() && value > 0.0) {
247        return None;
248    }
249    // Fold the unit component's own SI scale (e.g. a value stated in millimetres).
250    let component_scale = measure
251        .get_ref(1)
252        .and_then(|r| {
253            let comp = decoder.decode_by_id(r).ok()?;
254            if comp.ifc_type.as_str() == "IFCSIUNIT" {
255                let name = comp.get(3).and_then(|a| a.as_enum())?;
256                let prefix = comp.get(2).filter(|a| !a.is_null()).and_then(|a| a.as_enum());
257                si_unit_symbol_and_scale(name, prefix).map(|(_, s)| s)
258            } else {
259                None
260            }
261        })
262        .unwrap_or(1.0);
263    Some(value * component_scale)
264}
265
266/// Normalise a STEP enum token (`.LENGTHUNIT.`) to a bare uppercase token.
267fn str_token(s: &str) -> String {
268    s.trim().trim_matches('.').to_ascii_uppercase()
269}
270
271/// Friendly currency symbol for a common ISO-4217 code; falls back to the code.
272fn currency_symbol(code: &str) -> String {
273    let c = code.trim().trim_matches('\'').trim_matches('.').trim();
274    match c.to_ascii_uppercase().as_str() {
275        "EUR" => "\u{20AC}".to_string(),
276        "USD" => "$".to_string(),
277        "GBP" => "\u{00A3}".to_string(),
278        "JPY" | "CNY" | "RMB" => "\u{00A5}".to_string(),
279        "" => "".to_string(),
280        _ => c.to_string(),
281    }
282}
283
284#[cfg(test)]
285mod tests;