Skip to main content

ifc_properties/unit/
assignment.rs

1//! SI, conversion-based and derived units, and the project unit context.
2//!
3//! # Slots, verified against the IFC4 EXPRESS schema
4//!
5//! ```text
6//! IfcUnitAssignment        0 = Units
7//! IfcNamedUnit             0 = Dimensions   1 = UnitType
8//! IfcSIUnit                2 = Prefix       3 = Name
9//! IfcConversionBasedUnit   2 = Name         3 = ConversionFactor
10//! IfcConversionBasedUnitWithOffset          4 = ConversionOffset (IFC4)
11//! IfcMeasureWithUnit       0 = ValueComponent  1 = UnitComponent
12//! IfcDerivedUnit           0 = Elements     1 = UnitType  2 = UserDefinedType
13//! IfcDerivedUnitElement    0 = Unit         1 = Exponent
14//! IfcMonetaryUnit          0 = Currency
15//! ```
16//!
17//! `IfcSIUnit` inherits `Dimensions`/`UnitType` from `IfcNamedUnit`, so its
18//! own `Prefix` and `Name` start at slot 2. `IfcDerivedUnit` is NOT a
19//! `IfcNamedUnit`, so its slots start at 0 -- the two cannot share a reader.
20//!
21//! # Prefixes are exact
22//!
23//! `MILLI` is 1e-3 exactly as a decimal, and `f64` cannot hold it exactly.
24//! The factor is therefore returned as a power of ten and applied by the
25//! caller, so `mm -> m` is one multiplication rather than a chain of
26//! roundings.
27//!
28//! A prefix applies to the unit before its power: `MILLI SQUARE_METRE` is
29//! (10⁻³ m)², so its scale is 1e-6, not 1e-3 (ISO 80000-1).
30
31use std::sync::Arc;
32
33use ifc_model::{EntityId, Model, Value};
34
35const NAMED_UNIT_TYPE: usize = 1;
36const SI_PREFIX: usize = 2;
37const SI_NAME: usize = 3;
38const CONVERSION_NAME: usize = 2;
39const CONVERSION_FACTOR: usize = 3;
40const CONVERSION_OFFSET: usize = 4;
41const MEASURE_VALUE: usize = 0;
42const MEASURE_UNIT: usize = 1;
43const DERIVED_ELEMENTS: usize = 0;
44const DERIVED_TYPE: usize = 1;
45const DERIVED_ELEMENT_UNIT: usize = 0;
46const DERIVED_ELEMENT_EXPONENT: usize = 1;
47const MONETARY_CURRENCY: usize = 0;
48const ASSIGNMENT_UNITS: usize = 0;
49
50/// A unit as the file states it.
51#[derive(Debug, Clone, PartialEq)]
52pub enum UnitKind {
53    /// `IfcSIUnit`: a base SI unit with an optional decimal prefix.
54    Si {
55        /// `IfcUnitEnum`, e.g. `LENGTHUNIT`.
56        unit_type: Arc<str>,
57        /// `IfcSIUnitName`, e.g. `METRE`.
58        name: Arc<str>,
59        /// `IfcSIPrefix`, e.g. `MILLI`. `None` means unprefixed.
60        prefix: Option<Arc<str>>,
61        /// Decimal exponent of the prefix: `MILLI` is `Some(-3)`, absent is
62        /// `Some(0)`, and a prefix that is not an `IfcSIPrefix` is `None`.
63        ///
64        /// Exposed as an exponent rather than a factor so callers can apply
65        /// it exactly instead of multiplying by a rounded 0.001. An unknown
66        /// prefix is not read as "unprefixed": that would silently rescale
67        /// every value in the unit.
68        prefix_exponent: Option<i32>,
69    },
70    /// `IfcConversionBasedUnit`: a named unit defined by a factor.
71    Conversion {
72        /// `IfcUnitEnum`.
73        unit_type: Arc<str>,
74        /// The unit's name, e.g. `inch`.
75        name: Option<Arc<str>>,
76        /// The numeric conversion factor, when readable.
77        factor: Option<f64>,
78        /// The unit the factor is expressed in.
79        factor_unit: Option<EntityId>,
80        /// `IfcConversionBasedUnitWithOffset.ConversionOffset`, as stated.
81        ///
82        /// `None` for a plain conversion-based unit. Kept raw: IFC4's own
83        /// documentation contradicts itself on which direction the offset
84        /// applies, so this crate does not apply it.
85        offset: Option<f64>,
86    },
87    /// `IfcDerivedUnit`: a product of powers of other units.
88    Derived {
89        /// `IfcDerivedUnitEnum`, e.g. `VOLUMETRICFLOWRATEUNIT`.
90        unit_type: Arc<str>,
91        /// The (unit, exponent) elements.
92        elements: Vec<(EntityId, i64)>,
93    },
94    /// `IfcMonetaryUnit`: a currency, with no dimension.
95    Monetary {
96        /// ISO currency code as stated.
97        currency: Option<Arc<str>>,
98    },
99    /// `IfcContextDependentUnit` or another named unit form.
100    ContextDependent {
101        /// `IfcUnitEnum`.
102        unit_type: Arc<str>,
103    },
104}
105
106impl UnitKind {
107    /// The `IfcUnitEnum` this unit declares, when it has one.
108    ///
109    /// `IfcMonetaryUnit` has none: it is not an `IfcNamedUnit`.
110    pub fn unit_type(&self) -> Option<&str> {
111        match self {
112            Self::Si { unit_type, .. }
113            | Self::Conversion { unit_type, .. }
114            | Self::ContextDependent { unit_type } => Some(unit_type),
115            Self::Derived { unit_type, .. } => Some(unit_type),
116            Self::Monetary { .. } => None,
117        }
118    }
119
120    /// Multiplier converting a value in this unit to the unprefixed SI unit.
121    ///
122    /// The prefix is raised to the unit's power: `MILLI SQUARE_METRE` gives
123    /// 1e-6 and `MILLI CUBIC_METRE` 1e-9. `None` for an unknown prefix.
124    ///
125    /// Only defined for `Si`: a conversion-based unit needs its factor unit
126    /// resolved too, and a derived unit needs its elements combined, so
127    /// neither can answer honestly on its own. [`crate::exact_unit`] resolves
128    /// all three to the SI base unit.
129    pub fn si_scale(&self) -> Option<f64> {
130        match self {
131            Self::Si {
132                name,
133                prefix_exponent,
134                ..
135            } => prefix_exponent.map(|exponent| 10f64.powi(exponent * super::si::name_power(name))),
136            _ => None,
137        }
138    }
139}
140
141/// The decimal exponent of an `IfcSIPrefix`.
142///
143/// Returns `None` for an unrecognised constant rather than assuming 0: a
144/// silent 1.0 would misreport every value using it.
145pub fn prefix_exponent(prefix: &str) -> Option<i32> {
146    Some(match prefix {
147        "EXA" => 18,
148        "PETA" => 15,
149        "TERA" => 12,
150        "GIGA" => 9,
151        "MEGA" => 6,
152        "KILO" => 3,
153        "HECTO" => 2,
154        "DECA" => 1,
155        "DECI" => -1,
156        "CENTI" => -2,
157        "MILLI" => -3,
158        "MICRO" => -6,
159        "NANO" => -9,
160        "PICO" => -12,
161        "FEMTO" => -15,
162        "ATTO" => -18,
163        _ => return None,
164    })
165}
166
167/// Read one unit by id.
168pub fn unit(model: &Model, id: EntityId) -> Option<UnitKind> {
169    let entity = model.get(id)?;
170    let ty = entity.type_name.to_ascii_uppercase();
171    match ty.as_str() {
172        "IFCSIUNIT" => {
173            let prefix = entity.attributes.get(SI_PREFIX).and_then(enum_text);
174            Some(UnitKind::Si {
175                unit_type: entity
176                    .attributes
177                    .get(NAMED_UNIT_TYPE)
178                    .and_then(enum_text)
179                    .unwrap_or_else(|| "".into()),
180                name: entity
181                    .attributes
182                    .get(SI_NAME)
183                    .and_then(enum_text)
184                    .unwrap_or_else(|| "".into()),
185                prefix_exponent: prefix.as_deref().map_or(Some(0), prefix_exponent),
186                prefix,
187            })
188        }
189        "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
190            let factor_entity = entity.attributes.get(CONVERSION_FACTOR).and_then(one_ref);
191            let (factor, factor_unit) = match factor_entity.and_then(|f| model.get(f)) {
192                Some(measure) => (
193                    measure
194                        .attributes
195                        .get(MEASURE_VALUE)
196                        .and_then(|v| v.unwrap_typed().as_f64()),
197                    measure.attributes.get(MEASURE_UNIT).and_then(one_ref),
198                ),
199                None => (None, None),
200            };
201            Some(UnitKind::Conversion {
202                unit_type: entity
203                    .attributes
204                    .get(NAMED_UNIT_TYPE)
205                    .and_then(enum_text)
206                    .unwrap_or_else(|| "".into()),
207                name: entity.attributes.get(CONVERSION_NAME).and_then(text),
208                factor,
209                factor_unit,
210                offset: if ty == "IFCCONVERSIONBASEDUNITWITHOFFSET" {
211                    entity
212                        .attributes
213                        .get(CONVERSION_OFFSET)
214                        .and_then(|v| v.unwrap_typed().as_f64())
215                } else {
216                    None
217                },
218            })
219        }
220        "IFCDERIVEDUNIT" => {
221            let elements = entity
222                .attributes
223                .get(DERIVED_ELEMENTS)
224                .and_then(refs)
225                .unwrap_or_default()
226                .into_iter()
227                .filter_map(|e| {
228                    let element = model.get(e)?;
229                    let unit = element
230                        .attributes
231                        .get(DERIVED_ELEMENT_UNIT)
232                        .and_then(one_ref)?;
233                    let exponent = match element
234                        .attributes
235                        .get(DERIVED_ELEMENT_EXPONENT)?
236                        .unwrap_typed()
237                    {
238                        Value::Integer(v) => *v,
239                        Value::Real(v) => *v as i64,
240                        _ => return None,
241                    };
242                    Some((unit, exponent))
243                })
244                .collect();
245            Some(UnitKind::Derived {
246                unit_type: entity
247                    .attributes
248                    .get(DERIVED_TYPE)
249                    .and_then(enum_text)
250                    .unwrap_or_else(|| "".into()),
251                elements,
252            })
253        }
254        "IFCMONETARYUNIT" => Some(UnitKind::Monetary {
255            currency: entity.attributes.get(MONETARY_CURRENCY).and_then(text),
256        }),
257        "IFCCONTEXTDEPENDENTUNIT" => Some(UnitKind::ContextDependent {
258            unit_type: entity
259                .attributes
260                .get(NAMED_UNIT_TYPE)
261                .and_then(enum_text)
262                .unwrap_or_else(|| "".into()),
263        }),
264        _ => None,
265    }
266}
267
268/// The `IfcUnitEnum` a unit declares, without building the whole value.
269///
270/// Used by the quantity reader to check `WR21` cheaply.
271pub fn unit_type(model: &Model, id: EntityId) -> Option<Arc<str>> {
272    let entity = model.get(id)?;
273    let ty = entity.type_name.to_ascii_uppercase();
274    let slot = if ty == "IFCDERIVEDUNIT" {
275        DERIVED_TYPE
276    } else {
277        NAMED_UNIT_TYPE
278    };
279    entity.attributes.get(slot).and_then(enum_text)
280}
281
282/// Project default units, from `IfcProject.UnitsInContext`.
283///
284/// Returned in file order. These are the units a measure without its own
285/// unit is expressed in, so a consumer needs them to interpret bare values.
286pub fn project_units(model: &Model) -> Vec<(EntityId, UnitKind)> {
287    let mut out = Vec::new();
288    let mut ids: Vec<_> = model.ids_of_type("IFCUNITASSIGNMENT").to_vec();
289    ids.sort_unstable();
290    for id in ids {
291        let Some(assignment) = model.get(id) else {
292            continue;
293        };
294        for unit_id in assignment
295            .attributes
296            .get(ASSIGNMENT_UNITS)
297            .and_then(refs)
298            .unwrap_or_default()
299        {
300            if let Some(kind) = unit(model, unit_id) {
301                out.push((unit_id, kind));
302            }
303        }
304    }
305    out
306}
307
308/// The project default unit for a given `IfcUnitEnum`.
309///
310/// `IfcCorrectUnitAssignment` makes at most one named unit per type, so the
311/// first match is the only match in a well-formed file. In a malformed one
312/// this permissive view still returns the first; [`crate::exact_unit`]
313/// refuses the duplicate instead.
314pub fn project_unit_for(model: &Model, unit_type_name: &str) -> Option<(EntityId, UnitKind)> {
315    project_units(model)
316        .into_iter()
317        .find(|(_, kind)| kind.unit_type() == Some(unit_type_name))
318}
319
320fn text(value: &Value) -> Option<Arc<str>> {
321    match value.unwrap_typed() {
322        Value::Text(t) => Some(t.clone()),
323        _ => None,
324    }
325}
326
327fn enum_text(value: &Value) -> Option<Arc<str>> {
328    match value.unwrap_typed() {
329        Value::Enum(t) | Value::Text(t) => Some(t.clone()),
330        _ => None,
331    }
332}
333
334fn one_ref(value: &Value) -> Option<EntityId> {
335    match value.unwrap_typed() {
336        Value::Ref(id) => Some(*id),
337        _ => None,
338    }
339}
340
341fn refs(value: &Value) -> Option<Vec<EntityId>> {
342    match value {
343        Value::List(items) => Some(items.iter().filter_map(one_ref).collect()),
344        _ => None,
345    }
346}