Skip to main content

ifc_geometry/
units.rs

1//! Unit resolution: what one coordinate unit actually means.
2//!
3//! # Why this is not optional
4//!
5//! IFC coordinates are bare numbers. `IFCCARTESIANPOINT((3000.,0.,0.))` is
6//! three metres or three millimetres depending on the project's
7//! `IfcUnitAssignment`, and nothing in the geometry says which. A viewer that
8//! assumes metres renders a 1000x oversized building; one that assumes
9//! millimetres renders a speck.
10//!
11//! The resolved [`UnitScale`] is therefore an input to every lowering
12//! operation, not an afterthought.
13//!
14//! # What is resolved
15//!
16//! - `IfcSIUnit` with an `IfcSIPrefix` (`MILLI`, `CENTI`, `KILO`, ...)
17//! - `IfcConversionBasedUnit` (inch, foot) via its conversion factor
18//! - Angle units, because `IfcPlaneAngleMeasure` may be degrees or radians and
19//!   every rotation depends on knowing which
20
21use crate::error::{GeometryError, GeometryResult};
22use crate::slots::Slots;
23use ifc_model::{EntityId, Model};
24
25/// Multipliers converting file units into SI base units.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct UnitScale {
28    /// Multiply a stored length by this to get metres.
29    pub length_to_metres: f64,
30    /// Multiply a stored plane angle by this to get radians.
31    pub angle_to_radians: f64,
32}
33
34impl Default for UnitScale {
35    /// SI defaults: metres and radians, both factor 1.
36    ///
37    /// Used only when a file declares no units at all. That is malformed, but
38    /// refusing to load such a file is worse than assuming SI and saying so.
39    fn default() -> Self {
40        Self {
41            length_to_metres: 1.0,
42            angle_to_radians: 1.0,
43        }
44    }
45}
46
47impl UnitScale {
48    /// Convert a stored length to metres.
49    pub fn length(&self, value: f64) -> f64 {
50        value * self.length_to_metres
51    }
52
53    /// Convert a stored plane angle to radians.
54    pub fn angle(&self, value: f64) -> f64 {
55        value * self.angle_to_radians
56    }
57
58    /// Are lengths already in metres?
59    pub fn is_metric_identity(&self) -> bool {
60        (self.length_to_metres - 1.0).abs() < f64::EPSILON
61    }
62}
63
64/// Resolve the project's unit assignment.
65///
66/// Finds `IfcProject.UnitsInContext` and reads the length and angle units from
67/// it. A file with no project, or no unit assignment, yields
68/// [`UnitScale::default`] rather than an error: geometry is still readable, it
69/// is merely unscaled, and the caller can check
70/// [`UnitScale::is_metric_identity`].
71pub fn resolve(model: &Model) -> UnitScale {
72    let Some(assignment) = find_unit_assignment(model) else {
73        return UnitScale::default();
74    };
75
76    let mut scale = UnitScale::default();
77    let Some(entity) = model.get(assignment) else {
78        return scale;
79    };
80    let slots = Slots::new(assignment, entity);
81
82    for unit_id in slots.opt_ref_list(0) {
83        let Some(unit) = model.get(unit_id) else {
84            continue;
85        };
86        let unit_slots = Slots::new(unit_id, unit);
87
88        match unit_type(&unit_slots) {
89            Some("LENGTHUNIT") => {
90                if let Ok(factor) = length_factor(model, unit_id, &unit_slots) {
91                    scale.length_to_metres = factor;
92                }
93            }
94            Some("PLANEANGLEUNIT") => {
95                if let Ok(factor) = angle_factor(model, unit_id, &unit_slots) {
96                    scale.angle_to_radians = factor;
97                }
98            }
99            _ => {}
100        }
101    }
102    scale
103}
104
105/// Locate `IfcProject.UnitsInContext` (attribute 8).
106fn find_unit_assignment(model: &Model) -> Option<EntityId> {
107    let (id, project) = model.of_type("IFCPROJECT").next()?;
108    Slots::new(id, project).opt_ref(8)
109}
110
111/// The `UnitType` enum, present on both `IfcSIUnit` and `IfcDerivedUnit`.
112fn unit_type<'m>(slots: &Slots<'m>) -> Option<&'m str> {
113    match slots.type_name() {
114        // IfcSIUnit: (Dimensions, UnitType, Prefix, Name)
115        "IFCSIUNIT" => slots.opt_enum(1),
116        // IfcConversionBasedUnit: (Dimensions, UnitType, Name, ConversionFactor)
117        "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => slots.opt_enum(1),
118        _ => None,
119    }
120}
121
122/// Metres per stored length unit.
123fn length_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
124    match slots.type_name() {
125        "IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))),
126        "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
127            conversion_factor(model, id, slots)
128        }
129        other => Err(GeometryError::Units(format!(
130            "{id} is {other}, which is not a length unit this build understands"
131        ))),
132    }
133}
134
135/// Radians per stored angle unit.
136///
137/// `IfcSIUnit` for an angle is the radian, so the prefix (almost always absent)
138/// is the only scaling. Degrees arrive as an `IfcConversionBasedUnit` whose
139/// factor is pi/180.
140fn angle_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
141    match slots.type_name() {
142        "IFCSIUNIT" => Ok(prefix_factor(slots.opt_enum(2))),
143        "IFCCONVERSIONBASEDUNIT" | "IFCCONVERSIONBASEDUNITWITHOFFSET" => {
144            conversion_factor(model, id, slots)
145        }
146        other => Err(GeometryError::Units(format!(
147            "{id} is {other}, which is not an angle unit this build understands"
148        ))),
149    }
150}
151
152/// Read `IfcConversionBasedUnit.ConversionFactor` (attribute 3).
153///
154/// The factor is an `IfcMeasureWithUnit`, whose attribute 0 is the value.
155fn conversion_factor(model: &Model, id: EntityId, slots: &Slots<'_>) -> GeometryResult<f64> {
156    let measure_id = slots.req_ref(3, "ConversionFactor")?;
157    let measure = slots.resolve(model, measure_id)?;
158    let measure_slots = Slots::new(measure_id, measure);
159    measure_slots
160        .req_f64(0, "ValueComponent")
161        .map_err(|_| GeometryError::Units(format!("{id} has an unreadable conversion factor")))
162}
163
164/// Multiplier for an `IfcSIPrefix`.
165///
166/// Values from ISO 80000; `EXA` through `ATTO` are all legal in IFC even where
167/// nonsensical for a building, so the whole table is present rather than the
168/// three prefixes that occur in practice.
169fn prefix_factor(prefix: Option<&str>) -> f64 {
170    match prefix {
171        None => 1.0,
172        Some(p) => match p {
173            "EXA" => 1e18,
174            "PETA" => 1e15,
175            "TERA" => 1e12,
176            "GIGA" => 1e9,
177            "MEGA" => 1e6,
178            "KILO" => 1e3,
179            "HECTO" => 1e2,
180            "DECA" => 1e1,
181            "DECI" => 1e-1,
182            "CENTI" => 1e-2,
183            "MILLI" => 1e-3,
184            "MICRO" => 1e-6,
185            "NANO" => 1e-9,
186            "PICO" => 1e-12,
187            "FEMTO" => 1e-15,
188            "ATTO" => 1e-18,
189            _ => 1.0,
190        },
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use ifc_model::{Entity, Value};
198
199    fn model_with_length_unit(prefix: Option<&str>) -> Model {
200        let mut model = Model::new();
201        let prefix_value = match prefix {
202            Some(p) => Value::Enum(p.into()),
203            None => Value::Null,
204        };
205        model.insert(
206            EntityId(1),
207            Entity::new(
208                "IFCSIUNIT",
209                vec![
210                    Value::Derived,
211                    Value::Enum("LENGTHUNIT".into()),
212                    prefix_value,
213                    Value::Enum("METRE".into()),
214                ],
215            ),
216        );
217        model.insert(
218            EntityId(2),
219            Entity::new(
220                "IFCUNITASSIGNMENT",
221                vec![Value::List(vec![Value::Ref(EntityId(1))])],
222            ),
223        );
224        let mut project = vec![Value::Null; 9];
225        project[8] = Value::Ref(EntityId(2));
226        model.insert(EntityId(3), Entity::new("IFCPROJECT", project));
227        model
228    }
229
230    #[test]
231    fn metres_are_the_identity() {
232        let scale = resolve(&model_with_length_unit(None));
233        assert_eq!(scale.length_to_metres, 1.0);
234        assert!(scale.is_metric_identity());
235    }
236
237    /// The case that silently breaks viewers: a 3000 mm wall is 3 m.
238    #[test]
239    fn millimetres_scale_coordinates_down_by_a_thousand() {
240        let scale = resolve(&model_with_length_unit(Some("MILLI")));
241        assert_eq!(scale.length_to_metres, 1e-3);
242        assert_eq!(scale.length(3000.0), 3.0);
243        assert!(!scale.is_metric_identity());
244    }
245
246    #[test]
247    fn handles_the_whole_si_prefix_table() {
248        assert_eq!(prefix_factor(Some("KILO")), 1e3);
249        assert_eq!(prefix_factor(Some("CENTI")), 1e-2);
250        assert_eq!(prefix_factor(Some("MICRO")), 1e-6);
251        assert_eq!(prefix_factor(Some("UNRECOGNIZED")), 1.0);
252    }
253
254    /// A file without units still loads; it is merely unscaled.
255    #[test]
256    fn missing_unit_assignment_falls_back_to_si_rather_than_failing() {
257        let scale = resolve(&Model::new());
258        assert_eq!(scale, UnitScale::default());
259    }
260
261    /// Degrees arrive as a conversion-based unit of pi/180.
262    #[test]
263    fn resolves_conversion_based_angle_units() {
264        let mut model = Model::new();
265        model.insert(
266            EntityId(1),
267            Entity::new(
268                "IFCMEASUREWITHUNIT",
269                vec![
270                    Value::Typed {
271                        type_name: "IFCPLANEANGLEMEASURE".into(),
272                        value: Box::new(Value::Real(0.017453292519943295)),
273                    },
274                    Value::Null,
275                ],
276            ),
277        );
278        model.insert(
279            EntityId(2),
280            Entity::new(
281                "IFCCONVERSIONBASEDUNIT",
282                vec![
283                    Value::Null,
284                    Value::Enum("PLANEANGLEUNIT".into()),
285                    Value::Text("DEGREE".into()),
286                    Value::Ref(EntityId(1)),
287                ],
288            ),
289        );
290        model.insert(
291            EntityId(3),
292            Entity::new(
293                "IFCUNITASSIGNMENT",
294                vec![Value::List(vec![Value::Ref(EntityId(2))])],
295            ),
296        );
297        let mut project = vec![Value::Null; 9];
298        project[8] = Value::Ref(EntityId(3));
299        model.insert(EntityId(4), Entity::new("IFCPROJECT", project));
300
301        let scale = resolve(&model);
302        assert!((scale.angle(90.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
303    }
304}