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}