Skip to main content

ifc_properties/exact/
unit.rs

1//! Exact, fail-closed resolution of a measure's effective unit.
2//!
3//! A value's effective unit is its explicit `Unit` if stated, otherwise the
4//! project default from `IfcProject.UnitsInContext`. This module resolves
5//! either to an exact conversion into SI base units, or refuses. Every table
6//! it reads belongs to the release `FILE_SCHEMA` declares.
7//!
8//! ## Internal split
9//!
10//! - `resolve.rs`: one unit entity to its SI scale and dimensions, following
11//!   conversion chains and derived elements under a depth budget.
12
13mod resolve;
14
15use std::{fmt, sync::Arc};
16
17use ifc_model::{EntityId, Model};
18use ifc_schema::SchemaVersion;
19
20use super::measure::measure_unit;
21use super::refs::{ref_at, refs_at};
22use super::release::{validate_model, Release};
23use super::value::select_accepts_entity;
24use super::ExactPropertyError;
25use resolve::{declared_unit_type, Resolver};
26
27/// A measure's effective unit, resolved to SI base units.
28///
29/// `value_si = value * scale + offset`.
30#[derive(Debug, Clone, Copy, PartialEq)]
31#[non_exhaustive]
32pub struct ExactUnit {
33    /// The unit entity that applies, or `None` for a dimensionless measure
34    /// (`IFCCOUNTMEASURE`, `IFCRATIOMEASURE`, ...) that takes no unit.
35    pub unit: Option<EntityId>,
36    /// Whether `unit` is the project default rather than an explicit unit.
37    pub from_project: bool,
38    /// SI dimensional exponents `[L, M, T, I, Θ, N, J]`.
39    pub dimensions: [i32; 7],
40    /// Multiplier into the SI base unit (kilogram for mass, kelvin for
41    /// temperature, radian for plane angle).
42    pub scale: f64,
43    /// Added after scaling; nonzero only for `DEGREE_CELSIUS`.
44    pub offset: f64,
45}
46
47/// Why a measure's unit cannot be resolved exactly.
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum ExactUnitError {
51    /// The model, or a record the resolution traversed, failed a check that
52    /// [`crate::exact_property`] applies the same way: diagnostics, header,
53    /// missing references, slot arity, malformed aggregates, or a construct
54    /// the declared release does not define.
55    Structure(ExactPropertyError),
56    /// The declared release defines no type of this name.
57    MeasureNotInSchema {
58        /// The measure type as requested.
59        measure_type: Arc<str>,
60        /// The release the header declares.
61        schema: SchemaVersion,
62    },
63    /// The type is not a measure (`IFCLABEL`, `IFCBOOLEAN`, ...), so no unit
64    /// can apply to it.
65    NotAMeasure {
66        /// The type as requested.
67        measure_type: Arc<str>,
68    },
69    /// A measure this resolver has no verified unit correspondence for,
70    /// such as a monetary, descriptive, logarithmic or list-valued measure.
71    UnmappedMeasureType {
72        /// The measure type as requested.
73        measure_type: Arc<str>,
74    },
75    /// A dimensionless measure was given an explicit unit.
76    UnexpectedUnit {
77        /// The explicit unit.
78        unit: EntityId,
79    },
80    /// The model has no `IfcProject` to take a default unit from.
81    NoProject,
82    /// The model has several `IfcProject`s, so no one project default applies.
83    MultipleProjects {
84        /// The first project.
85        first: EntityId,
86        /// The second project.
87        second: EntityId,
88    },
89    /// No unit is stated and the project assigns none of the needed type.
90    NoProjectUnit {
91        /// The unit type the measure needs.
92        unit_type: Arc<str>,
93    },
94    /// The project assigns two units of the needed type, which
95    /// `IfcCorrectUnitAssignment` forbids; neither is "the" project unit.
96    DuplicateProjectUnit {
97        /// The unit type assigned twice.
98        unit_type: Arc<str>,
99        /// The first unit of that type.
100        first: EntityId,
101        /// The second unit of that type.
102        second: EntityId,
103    },
104    /// The entity is not a unit this resolver converts: not an `IfcUnit` at
105    /// all, or a context-dependent or monetary unit with no SI conversion.
106    UnsupportedUnit {
107        /// The rejected unit.
108        unit: EntityId,
109        /// Its IFC type name.
110        type_name: Arc<str>,
111    },
112    /// A unit attribute that must be an enumeration constant, a positive
113    /// number or a typed value is not.
114    MalformedUnit {
115        /// The unit entity or one of its parts.
116        entity: EntityId,
117        /// The offending attribute.
118        attribute: &'static str,
119    },
120    /// An `IfcSIUnit` prefix that is not an `IfcSIPrefix` constant.
121    UnknownPrefix {
122        /// The unit.
123        unit: EntityId,
124        /// The prefix as written.
125        prefix: Arc<str>,
126    },
127    /// An `IfcSIUnit` name that the release's `IfcDimensionsForSiUnit` does
128    /// not list, or a unit type its `IfcCorrectDimensions` does not list
129    /// (including `USERDEFINED`).
130    UnknownUnitName {
131        /// The unit.
132        unit: EntityId,
133        /// The name or unit type as written.
134        name: Arc<str>,
135    },
136    /// The unit declares another unit type than the measure needs.
137    UnitTypeMismatch {
138        /// The unit.
139        unit: EntityId,
140        /// The unit type the measure needs.
141        expected: Arc<str>,
142        /// The unit type the unit declares.
143        found: Arc<str>,
144    },
145    /// A unit's dimensions contradict its declared unit type, its declared
146    /// `Dimensions`, or the unit its conversion factor is expressed in.
147    DimensionMismatch {
148        /// The unit.
149        unit: EntityId,
150        /// The dimensions required.
151        expected: [i32; 7],
152        /// The dimensions found.
153        found: [i32; 7],
154    },
155    /// An offset unit: `IfcConversionBasedUnitWithOffset`, or a unit with an
156    /// offset used where only a scale can apply (inside a derived unit or as
157    /// a conversion factor). IFC4's prose on where the offset applies
158    /// contradicts its own Fahrenheit example (factor 1.8,
159    /// `f = k * 1.8 - 459.67`), so the offset is not applied until
160    /// buildingSMART settles it (IFC4.x-development#1193; tracked in #110).
161    UnsupportedOffset {
162        /// The unit carrying the offset.
163        unit: EntityId,
164    },
165    /// Conversion-based units form a cycle.
166    CyclicConversion {
167        /// The units on the cycle, closing on the first.
168        cycle: Vec<EntityId>,
169    },
170    /// The chain of conversion and derived units is deeper than the budget.
171    ConversionChainTooDeep {
172        /// The unit at which the budget ran out.
173        unit: EntityId,
174        /// The depth budget.
175        max_depth: usize,
176    },
177}
178
179impl From<ExactPropertyError> for ExactUnitError {
180    fn from(error: ExactPropertyError) -> Self {
181        Self::Structure(error)
182    }
183}
184
185impl fmt::Display for ExactUnitError {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        write!(f, "exact IFC unit resolution failed: {self:?}")
188    }
189}
190
191impl std::error::Error for ExactUnitError {}
192
193/// Resolve the effective unit of a measure to an exact SI conversion.
194///
195/// `measure_type` is the value's declared type, e.g. `IFCAREAMEASURE`;
196/// `explicit_unit` is `IfcPropertySingleValue.Unit` when stated. Without an
197/// explicit unit the project default of the needed type applies. The model
198/// binds to the release its `FILE_SCHEMA` declares, IFC2X3, IFC4 or IFC4X3,
199/// and every table is that release's.
200///
201/// # Errors
202///
203/// Any [`ExactUnitError`]: the answer is refused whenever the unit is
204/// missing, ambiguous, inconsistent, cyclic, or not convertible exactly.
205pub fn exact_unit(
206    model: &Model,
207    measure_type: &str,
208    explicit_unit: Option<EntityId>,
209) -> Result<ExactUnit, ExactUnitError> {
210    let release = validate_model(model)?;
211    let target = measure_unit(release, measure_type)?;
212    let Some(expected) = target.unit_type().cloned() else {
213        if let Some(unit) = explicit_unit {
214            return Err(ExactUnitError::UnexpectedUnit { unit });
215        }
216        return Ok(ExactUnit {
217            unit: None,
218            from_project: false,
219            dimensions: [0; 7],
220            scale: 1.0,
221            offset: 0.0,
222        });
223    };
224    let (unit, from_project) = match explicit_unit {
225        Some(unit) => (unit, false),
226        None => (project_unit(model, release, &expected)?, true),
227    };
228    let resolved = Resolver::new(model, release).resolve(unit)?;
229    if !resolved.unit_type.eq_ignore_ascii_case(&expected) {
230        return Err(ExactUnitError::UnitTypeMismatch {
231            unit,
232            expected,
233            found: resolved.unit_type,
234        });
235    }
236    Ok(ExactUnit {
237        unit: Some(unit),
238        from_project,
239        dimensions: resolved.dimensions,
240        scale: resolved.scale,
241        offset: resolved.offset,
242    })
243}
244
245/// The one unit of `unit_type` in the single project's unit assignment.
246fn project_unit(
247    model: &Model,
248    release: Release,
249    unit_type: &Arc<str>,
250) -> Result<EntityId, ExactUnitError> {
251    let project = match model.ids_of_type("IFCPROJECT") {
252        [] => return Err(ExactUnitError::NoProject),
253        [project] => *project,
254        [first, second, ..] => {
255            return Err(ExactUnitError::MultipleProjects {
256                first: *first,
257                second: *second,
258            })
259        }
260    };
261    let entity = model.get(project).expect("type index is current");
262    release.require_exact_slots(project, entity)?;
263    let slot = release
264        .schema
265        .attribute_names("IFCPROJECT")
266        .iter()
267        .position(|name| name.eq_ignore_ascii_case("UnitsInContext"))
268        .expect("IfcProject declares UnitsInContext in every bundled release");
269    let no_unit = || ExactUnitError::NoProjectUnit {
270        unit_type: unit_type.clone(),
271    };
272    // Optional in IFC4, mandatory in IFC2X3; a `$` states no units either way.
273    if matches!(entity.attributes.get(slot), Some(ifc_model::Value::Null)) {
274        return Err(no_unit());
275    }
276    let assignment = ref_at(project, entity.attributes.get(slot), "UnitsInContext")?;
277    let assignment_entity = model
278        .get(assignment)
279        .ok_or(ExactPropertyError::MissingReference {
280            from: project,
281            to: assignment,
282        })?;
283    release.require_exact_slots(assignment, assignment_entity)?;
284    if !assignment_entity.is_type("IFCUNITASSIGNMENT") {
285        return Err(ExactUnitError::UnsupportedUnit {
286            unit: assignment,
287            type_name: assignment_entity.type_name.clone(),
288        });
289    }
290    let units = refs_at(assignment, assignment_entity.attributes.first(), "Units")?;
291    let mut found = None;
292    for unit in units {
293        let unit_entity = model
294            .get(unit)
295            .ok_or(ExactPropertyError::MissingReference {
296                from: assignment,
297                to: unit,
298            })?;
299        release.require_exact_slots(unit, unit_entity)?;
300        if !select_accepts_entity(release.schema, "IFCUNIT", &unit_entity.type_name) {
301            return Err(ExactUnitError::UnsupportedUnit {
302                unit,
303                type_name: unit_entity.type_name.clone(),
304            });
305        }
306        let declared = declared_unit_type(release, unit, unit_entity)?;
307        if declared
308            .as_deref()
309            .is_some_and(|declared| declared.eq_ignore_ascii_case(unit_type))
310        {
311            if let Some(first) = found.replace(unit) {
312                return Err(ExactUnitError::DuplicateProjectUnit {
313                    unit_type: unit_type.clone(),
314                    first,
315                    second: unit,
316                });
317            }
318        }
319    }
320    found.ok_or_else(no_unit)
321}