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    /// `None` for a declared unit whose scale could not be resolved (#4690):
51    /// it shows no unit rather than the SI default, which would mislabel it.
52    by_type: BTreeMap<String, Option<ResolvedUnit>>,
53    monetary: Option<ResolvedUnit>,
54}
55
56impl ProjectUnits {
57    /// Resolve the full unit assignment reachable from `project_id`. Never
58    /// fails: an absent / malformed assignment yields an empty table (all
59    /// measures then fall back to their SI default symbols).
60    pub fn resolve(decoder: &mut EntityDecoder, project_id: u32) -> Self {
61        let mut units = ProjectUnits::default();
62        let Ok(project) = decoder.decode_by_id(project_id) else {
63            return units;
64        };
65        if project.ifc_type.as_str() != "IFCPROJECT" {
66            return units;
67        }
68        // IFCPROJECT attribute 8 = UnitsInContext (IFCUNITASSIGNMENT).
69        let Some(units_ref) = project.get(8).and_then(|a| a.as_entity_ref()) else {
70            return units;
71        };
72        let Ok(assignment) = decoder.decode_by_id(units_ref) else {
73            return units;
74        };
75        if assignment.ifc_type.as_str() != "IFCUNITASSIGNMENT" {
76            return units;
77        }
78        // IFCUNITASSIGNMENT.Units (attribute 0) is a list of unit refs. Collect
79        // the ids first so we release the borrow on `assignment` before the
80        // decoder is borrowed mutably again inside the resolve loop.
81        let refs: Vec<u32> = match assignment.get(0).and_then(|a| a.as_list()) {
82            Some(list) => list.iter().filter_map(|a| a.as_entity_ref()).collect(),
83            None => return units,
84        };
85        for unit_ref in refs {
86            if let Some((unit_type, resolved, monetary)) = resolve_declared_unit(decoder, unit_ref) {
87                if monetary {
88                    units.monetary = resolved;
89                } else if let Some(t) = unit_type {
90                    // First declaration of a unit-type wins (IFC allows only one
91                    // per type anyway); don't let a later duplicate clobber it.
92                    units.by_type.entry(t).or_insert(resolved);
93                }
94            }
95        }
96        units
97    }
98
99    /// The display unit for a property/quantity whose IFC measure value type is
100    /// `measure_type` (e.g. `"IfcVolumetricFlowRateMeasure"`). Prefers the
101    /// file's declared unit for the measure's unit-type and otherwise falls back
102    /// to the IFC-canonical SI default. Returns `None` for dimensionless
103    /// measures (ratios, counts) and non-measure value types (labels, ...).
104    pub fn unit_for_measure(&self, measure_type: &str) -> Option<ResolvedUnit> {
105        match measure_unit(measure_type)? {
106            MeasureUnit::Typed { unit_type, default_symbol } => match self.by_type.get(unit_type) {
107                Some(declared) => declared.clone(),
108                None => Some(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    /// A declared unit whose scale could not be resolved is `None` here too.
117    pub fn resolved_for_unit_type(&self, unit_type: &str) -> Option<&ResolvedUnit> {
118        self.by_type.get(unit_type)?.as_ref()
119    }
120
121    /// The resolved monetary (currency) unit, if the file declares one.
122    pub fn monetary(&self) -> Option<&ResolvedUnit> {
123        self.monetary.as_ref()
124    }
125
126    /// Number of declared unit-types (excluding monetary). Test/telemetry aid.
127    pub fn declared_len(&self) -> usize {
128        self.by_type.len()
129    }
130}
131
132/// Resolve a single unit entity (referenced from a `IfcUnitAssignment`, or from
133/// a per-property / per-quantity `Unit` override). Returns
134/// `(unit_type_token, resolved, is_monetary)`.
135///
136/// Exposed so the per-property `IfcPropertySingleValue.Unit` and per-quantity
137/// `IfcPhysicalSimpleQuantity.Unit` overrides can be resolved against the same
138/// canonical logic.
139pub fn resolve_unit_by_ref(
140    decoder: &mut EntityDecoder,
141    unit_ref: u32,
142) -> Option<(Option<String>, ResolvedUnit, bool)> {
143    let (unit_type, resolved, monetary) = resolve_declared_unit(decoder, unit_ref)?;
144    Some((unit_type, resolved?, monetary))
145}
146
147/// `(unit_type_token, resolved, is_monetary)` where `resolved` is `None` for a
148/// unit whose type is readable but whose scale is not (#4690).
149type DeclaredUnit = (Option<String>, Option<ResolvedUnit>, bool);
150
151/// [`resolve_unit_by_ref`], keeping a declared unit whose scale is unresolved.
152fn resolve_declared_unit(decoder: &mut EntityDecoder, unit_ref: u32) -> Option<DeclaredUnit> {
153    let mut walk = UnitWalk::default();
154    let out = resolve_unit_by_ref_walk(decoder, unit_ref, &mut walk);
155    // A budget trip stops the walk part-way through some element list, so
156    // whatever was composed depends on where it stopped. Refuse the unit
157    // rather than hand back a symbol that is missing the elements it never
158    // reached.
159    if walk.was_refused() {
160        return None;
161    }
162    out
163}
164
165/// Max depth for the IFCDERIVEDUNIT -> element -> unit recursion. Real derived
166/// units nest ~2 levels; a malformed file can form a reference cycle (an
167/// IFCDERIVEDUNIT whose element's Unit points back to it), so cap the recursion
168/// to keep it from overflowing the stack, which is an uncatchable abort.
169///
170/// The cap bounds one path's LENGTH only. A cycle is refused by
171/// [`UnitWalk::path`] and a fan-out by [`UnitWalk::decodes`]; see AGENTS.md
172/// "Bounding walks over file-supplied references". With the cap alone, a
173/// cycle whose derived unit lists `k` elements costs `O(k^16)`: seconds at
174/// k=3 from a file of a few hundred bytes.
175const MAX_UNIT_RESOLVE_DEPTH: usize = 16;
176
177/// Entity decodes one `resolve_unit_by_ref` call may spend before giving up.
178/// A real derived unit (`m³/s`, `W/(m·K)`) decodes under ten entities; the
179/// walk over `IfcDerivedUnit -> IfcDerivedUnitElement -> Unit` is a tree, so
180/// an ACYCLIC file that fans out `k` ways per level (which the path set cannot
181/// see) still costs `k^depth` without this.
182///
183/// Not a memo: a unit's resolution is a pure function of its id, but its
184/// SYMBOL is the concatenation of its elements' symbols, so a fan-out that a
185/// memo made cheap to walk would still compose an output exponential in the
186/// depth (`4^12` copies of `m` from twelve small records). Bounding decodes
187/// bounds the elements composed, and with them the output.
188const MAX_UNIT_RESOLVE_DECODES: u32 = 256;
189
190/// Bookkeeping for one top-level unit resolution.
191#[derive(Default)]
192struct UnitWalk {
193    /// Unit entity ids on the CURRENT chain, innermost last; pushed on entry,
194    /// popped on exit, so its length is the recursion depth and a repeat is a
195    /// cycle.
196    path: Vec<u32>,
197    /// Entity decodes spent so far; past [`MAX_UNIT_RESOLVE_DECODES`] the walk
198    /// refuses the rest, and the top level refuses the whole unit rather than
199    /// hand back a symbol missing the elements it never reached.
200    decodes: u32,
201    /// A cycle, depth trip, or decode-budget trip invalidates the whole
202    /// authored unit. Returning the sound siblings would fabricate a
203    /// truncated symbol and SI scale.
204    refused: bool,
205}
206
207impl UnitWalk {
208    /// Charge one entity decode; `false` once the budget is spent.
209    fn charge(&mut self) -> bool {
210        self.decodes += 1;
211        if self.decodes > MAX_UNIT_RESOLVE_DECODES {
212            self.refused = true;
213            return false;
214        }
215        true
216    }
217
218    fn was_refused(&self) -> bool {
219        self.refused
220    }
221}
222
223fn resolve_unit_by_ref_walk(
224    decoder: &mut EntityDecoder,
225    unit_ref: u32,
226    walk: &mut UnitWalk,
227) -> Option<DeclaredUnit> {
228    if walk.path.len() >= MAX_UNIT_RESOLVE_DEPTH || walk.path.contains(&unit_ref) {
229        walk.refused = true;
230        return None;
231    }
232    if !walk.charge() {
233        return None;
234    }
235    walk.path.push(unit_ref);
236    let out = resolve_unit_entity(decoder, unit_ref, walk);
237    walk.path.pop();
238    out
239}
240
241fn resolve_unit_entity(
242    decoder: &mut EntityDecoder,
243    unit_ref: u32,
244    walk: &mut UnitWalk,
245) -> Option<DeclaredUnit> {
246    let entity = decoder.decode_by_id(unit_ref).ok()?;
247    match entity.ifc_type.as_str() {
248        "IFCSIUNIT" => {
249            // [1]=UnitType, [2]=Prefix, [3]=Name
250            let unit_type = entity.get(1).and_then(|a| a.as_enum()).map(str_token);
251            let name = entity.get(3).and_then(|a| a.as_enum())?;
252            let prefix = entity
253                .get(2)
254                .filter(|a| !a.is_null())
255                .and_then(|a| a.as_enum());
256            let (symbol, scale) = si_unit_symbol_and_scale(name, prefix)?;
257            Some((unit_type, Some(ResolvedUnit::new(symbol, scale)), false))
258        }
259        "IFCCONVERSIONBASEDUNIT" => {
260            // [1]=UnitType, [2]=Name, [3]=ConversionFactor (IFCMEASUREWITHUNIT)
261            let unit_type = entity.get(1).and_then(|a| a.as_enum()).map(str_token);
262            let name = entity.get(2).and_then(|a| a.as_string()).unwrap_or("");
263            // A factor the file does not resolve falls back to the name's known
264            // factor, from the linear table the geometry length scale reads, so
265            // only for a LENGTHUNIT; anything else is left unresolved rather
266            // than guessed at 1.0 (#4690).
267            let scale = entity
268                .get_ref(3)
269                .and_then(|r| conversion_factor_scale(decoder, r, walk))
270                .or_else(|| match unit_type.as_deref() {
271                    Some("LENGTHUNIT") => crate::unit_labels::get_conversion_based_unit_factor(name),
272                    _ => None,
273                });
274            let resolved = scale.map(|scale| ResolvedUnit::new(conversion_unit_symbol(name), scale));
275            Some((unit_type, resolved, false))
276        }
277        "IFCDERIVEDUNIT" => {
278            // [0]=Elements (list of IFCDERIVEDUNITELEMENT), [1]=UnitType
279            let unit_type = entity.get(1).and_then(|a| a.as_enum()).map(str_token);
280            let elem_refs: Vec<u32> = entity
281                .get(0)
282                .and_then(|a| a.as_list())
283                .map(|l| l.iter().filter_map(|a| a.as_entity_ref()).collect())
284                .unwrap_or_default();
285            let mut parts: Vec<(String, i32)> = Vec::new();
286            let mut scale = 1.0f64;
287            for er in elem_refs {
288                if let Some((sym, unit_scale, exponent)) =
289                    resolve_derived_element(decoder, er, walk)
290                {
291                    scale *= unit_scale.powi(exponent);
292                    parts.push((sym, exponent));
293                }
294            }
295            let symbol = compose_derived(&parts);
296            if symbol.is_empty() {
297                return None;
298            }
299            Some((unit_type, Some(ResolvedUnit::new(symbol, scale)), false))
300        }
301        "IFCMONETARYUNIT" => {
302            // [0]=Currency (IfcLabel string in IFC4+, IfcCurrencyEnum in IFC2x3).
303            let currency = entity
304                .get(0)
305                .and_then(|a| a.as_string().or_else(|| a.as_enum()))
306                .unwrap_or("");
307            Some((None, Some(ResolvedUnit::new(currency_symbol(currency), 1.0)), true))
308        }
309        _ => None,
310    }
311}
312
313/// Resolve one `IFCDERIVEDUNITELEMENT` into `(base_symbol, unit_si_scale, exponent)`.
314fn resolve_derived_element(
315    decoder: &mut EntityDecoder,
316    elem_ref: u32,
317    walk: &mut UnitWalk,
318) -> Option<(String, f64, i32)> {
319    if !walk.charge() {
320        return None;
321    }
322    let elem = decoder.decode_by_id(elem_ref).ok()?;
323    if elem.ifc_type.as_str() != "IFCDERIVEDUNITELEMENT" {
324        return None;
325    }
326    // [0]=Unit (IfcNamedUnit), [1]=Exponent
327    let unit_ref = elem.get_ref(0)?;
328    // The file's exponent is an `i64`; saturate into `i32` rather than
329    // truncate, so an out-of-range value cannot wrap to an unrelated small
330    // exponent (or, at exactly 2^31, to `i32::MIN`).
331    let exponent = elem
332        .get(1)
333        .and_then(|a| a.as_int())
334        .unwrap_or(1)
335        .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
336    let resolved = resolve_unit_by_ref_walk(decoder, unit_ref, walk)?.1?;
337    Some((resolved.symbol, resolved.si_scale, exponent))
338}
339
340/// The SI scale of an `IFCCONVERSIONBASEDUNIT.ConversionFactor`
341/// (`IFCMEASUREWITHUNIT`): the value component expressed in its `UnitComponent`,
342/// which per `IfcMeasureWithUnit` is any `IfcUnit` — a (possibly prefixed)
343/// `IfcSIUnit`, an `IfcDerivedUnit`, or *another* `IfcConversionBasedUnit`
344/// (a real-world chain, e.g. YARD defined as 3 FOOT where FOOT is itself
345/// conversion-based). Resolving through the shared dispatcher folds in every
346/// case uniformly instead of silently treating a non-SI component as scale 1.0.
347fn conversion_factor_scale(
348    decoder: &mut EntityDecoder,
349    measure_ref: u32,
350    walk: &mut UnitWalk,
351) -> Option<f64> {
352    if !walk.charge() {
353        return None;
354    }
355    let measure = decoder.decode_by_id(measure_ref).ok()?;
356    if measure.ifc_type.as_str() != "IFCMEASUREWITHUNIT" {
357        return None;
358    }
359    // [0]=ValueComponent, [1]=UnitComponent
360    let value = measure.get(0).and_then(|a| a.as_float())?;
361    if !(value.is_finite() && value > 0.0) {
362        return None;
363    }
364    // An unresolved UnitComponent leaves the factor unknown, not SI (#4690).
365    let component = resolve_unit_by_ref_walk(decoder, measure.get_ref(1)?, walk)?.1?;
366    Some(value * component.si_scale)
367}
368
369/// Normalise a STEP enum token (`.LENGTHUNIT.`) to a bare uppercase token.
370fn str_token(s: &str) -> String {
371    s.trim().trim_matches('.').to_ascii_uppercase()
372}
373
374/// Friendly currency symbol for a common ISO-4217 code; falls back to the code.
375fn currency_symbol(code: &str) -> String {
376    let c = code.trim().trim_matches('\'').trim_matches('.').trim();
377    match c.to_ascii_uppercase().as_str() {
378        "EUR" => "\u{20AC}".to_string(),
379        "USD" => "$".to_string(),
380        "GBP" => "\u{00A3}".to_string(),
381        "JPY" | "CNY" | "RMB" => "\u{00A5}".to_string(),
382        "" => "".to_string(),
383        _ => c.to_string(),
384    }
385}
386
387#[cfg(test)]
388mod tests;