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;