ifc_lite_core/
units.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//! Unit extraction and conversion for IFC files
6//!
7//! Handles parsing of IFCSIUNIT and IFCCONVERSIONBASEDUNIT (imperial units)
8//! and applying appropriate multipliers to geometry coordinates.
9
10use crate::decoder::EntityDecoder;
11use crate::error::Result;
12
13/// The unit-label tables live in [`crate::unit_labels`], shared with the
14/// georeferencing MapUnit reader; re-exported here so the `units::` paths
15/// callers already use keep working.
16pub use crate::unit_labels::{
17    get_conversion_based_unit_factor, get_si_prefix_multiplier, try_si_prefix_multiplier,
18};
19
20/// Extract length unit scale factor from IFC file
21///
22/// Follows the chain: IFCPROJECT → IFCUNITASSIGNMENT → IFCSIUNIT
23/// Returns the multiplier to convert coordinates to base meters
24///
25/// # Arguments
26/// * `decoder` - Entity decoder for the IFC file
27/// * `project_id` - Entity ID of the IFCPROJECT
28///
29/// # Returns
30/// Scale factor to apply to all coordinates (e.g., 0.001 for millimeters)
31pub fn extract_length_unit_scale(decoder: &mut EntityDecoder, project_id: u32) -> Result<f64> {
32    // Decode IFCPROJECT entity
33    let project = decoder.decode_by_id(project_id)?;
34
35    if project.ifc_type.as_str() != "IFCPROJECT" {
36        return Ok(1.0); // Not a project, default to meters
37    }
38
39    // IFCPROJECT structure:
40    // Attribute 0: GlobalId
41    // Attribute 1: OwnerHistory
42    // Attribute 2: Name
43    // Attribute 3: Description
44    // Attribute 4: ObjectType
45    // Attribute 5: LongName
46    // Attribute 6: Phase
47    // Attribute 7: RepresentationContexts
48    // Attribute 8: UnitsInContext (IFCUNITASSIGNMENT)
49
50    let units_attr = match project.get(8) {
51        Some(attr) => attr,
52        None => return Ok(1.0), // No units defined, default to meters
53    };
54
55    // Resolve IFCUNITASSIGNMENT reference
56    let units_ref = match units_attr.as_entity_ref() {
57        Some(ref_id) => ref_id,
58        None => return Ok(1.0), // No units reference
59    };
60
61    let unit_assignment = decoder.decode_by_id(units_ref)?;
62
63    if unit_assignment.ifc_type.as_str() != "IFCUNITASSIGNMENT" {
64        return Ok(1.0); // Wrong type
65    }
66
67    // IFCUNITASSIGNMENT has a single attribute: Units (list of IFCUNIT)
68    let units_list_attr = match unit_assignment.get(0) {
69        Some(attr) => attr,
70        None => return Ok(1.0), // No units list
71    };
72
73    let units_list = match units_list_attr.as_list() {
74        Some(list) => list,
75        None => return Ok(1.0), // Not a list
76    };
77
78    // Search for length unit (IFCSIUNIT or IFCCONVERSIONBASEDUNIT)
79    for unit_attr in units_list {
80        let unit_ref = match unit_attr.as_entity_ref() {
81            Some(ref_id) => ref_id,
82            None => continue,
83        };
84
85        let unit_entity = match decoder.decode_by_id(unit_ref) {
86            Ok(entity) => entity,
87            Err(_) => continue, // Failed to decode, skip
88        };
89
90        let unit_type_str = unit_entity.ifc_type.as_str();
91
92        // Handle IFCSIUNIT
93        if unit_type_str == "IFCSIUNIT" {
94            // IFCSIUNIT structure:
95            // Attribute 0: Dimensions (can be *)
96            // Attribute 1: UnitType (.LENGTHUNIT., .AREAUNIT., etc.)
97            // Attribute 2: Prefix (.MILLI., .CENTI., etc.) - THIS IS WHAT WE NEED!
98            // Attribute 3: Name (.METRE., .SQUARE_METRE., etc.)
99
100            // Check if this is a length unit
101            let unit_type_attr = match unit_entity.get(1) {
102                Some(attr) => attr,
103                None => continue,
104            };
105
106            let unit_type = match unit_type_attr.as_enum() {
107                Some(type_str) => type_str,
108                None => continue,
109            };
110
111            if unit_type != "LENGTHUNIT" {
112                continue; // Not a length unit, skip
113            }
114
115            // Extract the SI prefix (attribute 2)
116            let prefix_attr = match unit_entity.get(2) {
117                Some(attr) => attr,
118                None => return Ok(1.0), // No prefix = base meters
119            };
120
121            // Prefix can be an enum or null ($)
122            if prefix_attr.is_null() {
123                return Ok(1.0); // Null means no prefix = base meters
124            }
125
126            let prefix = match prefix_attr.as_enum() {
127                Some(prefix_str) => prefix_str,
128                None => return Ok(1.0), // Can't read prefix, assume meters
129            };
130
131            // Calculate and return the multiplier
132            return Ok(get_si_prefix_multiplier(prefix));
133        }
134
135        // Handle IFCCONVERSIONBASEDUNIT (imperial units like feet, inches)
136        if unit_type_str == "IFCCONVERSIONBASEDUNIT" {
137            // IFCCONVERSIONBASEDUNIT structure:
138            // Attribute 0: Dimensions (can be *)
139            // Attribute 1: UnitType (.LENGTHUNIT., .AREAUNIT., etc.)
140            // Attribute 2: Name (string like 'FOOT', 'INCH')
141            // Attribute 3: ConversionFactor (IFCMEASUREWITHUNIT reference)
142
143            // Check if this is a length unit
144            let unit_type_attr = match unit_entity.get(1) {
145                Some(attr) => attr,
146                None => continue,
147            };
148
149            let unit_type = match unit_type_attr.as_enum() {
150                Some(type_str) => type_str,
151                None => continue,
152            };
153
154            if unit_type != "LENGTHUNIT" {
155                continue; // Not a length unit, skip
156            }
157
158            // Try to get the unit name first for known conversion factors
159            if let Some(name_attr) = unit_entity.get(2) {
160                if let Some(name) = name_attr.as_string() {
161                    // Check if we have a known conversion factor for this unit
162                    if let Some(factor) = get_conversion_based_unit_factor(name) {
163                        return Ok(factor);
164                    }
165                }
166            }
167
168            // If name lookup fails, try to extract from ConversionFactor (IFCMEASUREWITHUNIT)
169            // Attribute 3: ConversionFactor
170            let conversion_factor_ref = match unit_entity.get_ref(3) {
171                Some(ref_id) => ref_id,
172                None => continue,
173            };
174
175            let measure_with_unit = match decoder.decode_by_id(conversion_factor_ref) {
176                Ok(entity) => entity,
177                Err(_) => continue,
178            };
179
180            // IFCMEASUREWITHUNIT structure:
181            // Attribute 0: ValueComponent (the numeric value - could be Real, Integer, or Ratio)
182            // Attribute 1: UnitComponent (reference to base unit)
183
184            // Extract the numeric value from ValueComponent
185            let value_attr = match measure_with_unit.get(0) {
186                Some(attr) => attr,
187                None => continue,
188            };
189
190            // The value could be stored as Real, Integer, or in nested form
191            let conversion_value = if let Some(val) = value_attr.as_float() {
192                val
193            } else if let Some(val) = value_attr.as_int() {
194                val as f64
195            } else {
196                // Default to 1.0 if we can't extract the value
197                1.0
198            };
199
200            // If we got a valid conversion value, use it
201            if conversion_value > 0.0 {
202                // IMPORTANT: ValueComponent is expressed in UnitComponent's units.
203                // If UnitComponent is a prefixed SI unit (e.g., millimeters),
204                // we must multiply by that unit's scale factor.
205                let mut unit_component_scale = 1.0;
206
207                if let Some(unit_component_ref) = measure_with_unit.get_ref(1) {
208                    if let Ok(unit_component) = decoder.decode_by_id(unit_component_ref) {
209                        if unit_component.ifc_type.as_str() == "IFCSIUNIT" {
210                            // IFCSIUNIT: [0] Dimensions, [1] UnitType, [2] Prefix, [3] Name
211                            if let Some(prefix_attr) = unit_component.get(2) {
212                                if !prefix_attr.is_null() {
213                                    if let Some(prefix) = prefix_attr.as_enum() {
214                                        unit_component_scale = get_si_prefix_multiplier(prefix);
215                                    }
216                                }
217                            }
218                        }
219                    }
220                }
221
222                return Ok(conversion_value * unit_component_scale);
223            }
224        }
225    }
226
227    // No length unit found, default to meters
228    Ok(1.0)
229}
230
231/// Resolve the length-unit scale ONLY when the full unit chain
232/// (IFCPROJECT → IFCUNITASSIGNMENT → IFCSIUNIT) is decodable from the
233/// decoder's current index.
234///
235/// Returns:
236/// - `Some(scale)` when resolution is definitive — either a length unit was
237///   found, or every referenced unit decoded and none was a length unit
238///   (genuine base-metres default).
239/// - `None` when a referenced entity in the chain could not be decoded from
240///   the current (possibly partial) index, OR a conversion-based length unit
241///   was hit. The caller should retry against a *complete* index before
242///   trusting a metres default.
243///
244/// This exists for the streaming pre-pass, which resolves units from a
245/// partial entity index built mid-scan. Many real exports (e.g. Revit) place
246/// the IFCPROJECT / IFCUNITASSIGNMENT *after* the bulk of geometry, so the
247/// partial index does not yet contain the assigned IFCSIUNIT — and silently
248/// defaulting to metres there renders a millimetre model 1000× oversized.
249pub fn try_extract_length_unit_scale(decoder: &mut EntityDecoder, project_id: u32) -> Option<f64> {
250    // Project must decode; the gate that calls us guarantees it is indexed.
251    let project = decoder.decode_by_id(project_id).ok()?;
252    if project.ifc_type.as_str() != "IFCPROJECT" {
253        return Some(1.0); // Not a project — matches extract_length_unit_scale.
254    }
255
256    // Attribute 8: UnitsInContext (IFCUNITASSIGNMENT). Absent/!ref ⇒ no units.
257    let units_ref = match project.get(8).and_then(|a| a.as_entity_ref()) {
258        Some(r) => r,
259        None => return Some(1.0),
260    };
261
262    // The assignment itself not being in the index means the chain is
263    // incomplete — signal the caller to retry with a full index.
264    let unit_assignment = decoder.decode_by_id(units_ref).ok()?;
265    if unit_assignment.ifc_type.as_str() != "IFCUNITASSIGNMENT" {
266        return Some(1.0);
267    }
268
269    let units_list = match unit_assignment.get(0).and_then(|a| a.as_list()) {
270        Some(list) => list,
271        None => return Some(1.0),
272    };
273
274    let mut saw_undecodable = false;
275    for unit_attr in units_list {
276        let unit_ref = match unit_attr.as_entity_ref() {
277            Some(r) => r,
278            None => continue,
279        };
280        let unit_entity = match decoder.decode_by_id(unit_ref) {
281            Ok(e) => e,
282            // A unit referenced by the assignment is missing from this index;
283            // it might be the length unit, so we cannot resolve confidently.
284            Err(_) => {
285                saw_undecodable = true;
286                continue;
287            }
288        };
289
290        match unit_entity.ifc_type.as_str() {
291            "IFCSIUNIT" => {
292                let is_length = unit_entity
293                    .get(1)
294                    .and_then(|a| a.as_enum())
295                    .map(|t| t == "LENGTHUNIT")
296                    .unwrap_or(false);
297                if !is_length {
298                    continue;
299                }
300                // Prefix (attr 2): null ⇒ base metres; enum ⇒ SI multiplier.
301                return match unit_entity.get(2) {
302                    None => Some(1.0),
303                    Some(p) if p.is_null() => Some(1.0),
304                    Some(p) => Some(p.as_enum().map(get_si_prefix_multiplier).unwrap_or(1.0)),
305                };
306            }
307            "IFCCONVERSIONBASEDUNIT" => {
308                let is_length = unit_entity
309                    .get(1)
310                    .and_then(|a| a.as_enum())
311                    .map(|t| t == "LENGTHUNIT")
312                    .unwrap_or(false);
313                if is_length {
314                    // Imperial/conversion length units need a deeper chain
315                    // (name + IFCMEASUREWITHUNIT). Defer to the full-index
316                    // path rather than reimplement that walk here.
317                    return None;
318                }
319            }
320            _ => {}
321        }
322    }
323
324    // No length unit found in the list. If everything decoded, the file
325    // genuinely uses base metres; otherwise the length unit may be among the
326    // entities we could not decode yet — ask the caller to retry.
327    if saw_undecodable {
328        None
329    } else {
330        Some(1.0)
331    }
332}
333
334/// Partial-index-safe sibling of [`extract_plane_angle_to_radians`].
335///
336/// The streaming meta gate resolves units against a PARTIAL entity index (only
337/// the file head scanned so far). [`extract_plane_angle_to_radians`] returns
338/// `Ok(1.0)` BOTH for a genuine radian/missing-unit file AND when a PLANEANGLEUNIT
339/// is declared but its conversion chain (`IFCMEASUREWITHUNIT`) is not yet indexed
340/// — the caller cannot tell "really radians" from "couldn't resolve yet". This
341/// variant returns `None` for the latter so `resolve_unit_scales` can retry
342/// against a full index, mirroring [`try_extract_length_unit_scale`]. Without it
343/// a Revit/EDM export whose DEGREE unit is early but whose `IFCMEASUREWITHUNIT`
344/// is at the file tail silently defaulted to radians → degree-trimmed arcs
345/// rendered as full circles (issue #1367).
346pub fn try_extract_plane_angle_to_radians(
347    decoder: &mut EntityDecoder,
348    project_id: u32,
349) -> Option<f64> {
350    // Project must decode; if not, the chain is incomplete on this index.
351    let project = decoder.decode_by_id(project_id).ok()?;
352    if project.ifc_type.as_str() != "IFCPROJECT" {
353        return Some(1.0); // Not a project — matches extract_plane_angle_to_radians.
354    }
355
356    let units_ref = match project.get(8).and_then(|a| a.as_entity_ref()) {
357        Some(r) => r,
358        None => return Some(1.0),
359    };
360    // Assignment not in the index ⇒ incomplete chain; signal a full-index retry.
361    let unit_assignment = decoder.decode_by_id(units_ref).ok()?;
362    if unit_assignment.ifc_type.as_str() != "IFCUNITASSIGNMENT" {
363        return Some(1.0);
364    }
365    let units_list = match unit_assignment.get(0).and_then(|a| a.as_list()) {
366        Some(list) => list,
367        None => return Some(1.0),
368    };
369
370    let mut saw_undecodable = false;
371    for unit_attr in units_list {
372        let unit_ref = match unit_attr.as_entity_ref() {
373            Some(r) => r,
374            None => continue,
375        };
376        let unit_entity = match decoder.decode_by_id(unit_ref) {
377            Ok(e) => e,
378            // A unit referenced by the assignment is missing from this index;
379            // it might be the plane-angle unit, so we cannot resolve confidently.
380            Err(_) => {
381                saw_undecodable = true;
382                continue;
383            }
384        };
385
386        match unit_entity.ifc_type.as_str() {
387            "IFCSIUNIT" => {
388                if unit_entity.get(1).and_then(|a| a.as_enum()) != Some("PLANEANGLEUNIT") {
389                    continue;
390                }
391                // SI plane-angle unit is .RADIAN.; honour exotic SI prefixes.
392                let prefix_scale = match unit_entity.get(2) {
393                    Some(p) if !p.is_null() => {
394                        p.as_enum().map(get_si_prefix_multiplier).unwrap_or(1.0)
395                    }
396                    _ => 1.0,
397                };
398                return Some(prefix_scale);
399            }
400            "IFCCONVERSIONBASEDUNIT" => {
401                if unit_entity.get(1).and_then(|a| a.as_enum()) != Some("PLANEANGLEUNIT") {
402                    continue;
403                }
404                // Resolve the conversion factor (IFCMEASUREWITHUNIT). A missing
405                // ref or one not yet indexed means we cannot resolve confidently
406                // — `None` makes `resolve_unit_scales` retry on a full index.
407                let conv_ref = unit_entity.get_ref(3)?;
408                let measure = decoder.decode_by_id(conv_ref).ok()?;
409                let value = measure.get(0).and_then(|a| a.as_float()).unwrap_or(0.0);
410                if value > 0.0 && value.is_finite() {
411                    return Some(value);
412                }
413                return None;
414            }
415            _ => {}
416        }
417    }
418
419    // No plane-angle unit found. If everything decoded, the file genuinely uses
420    // the radian default; otherwise it may be among the undecodable refs — retry.
421    if saw_undecodable {
422        None
423    } else {
424        Some(1.0)
425    }
426}
427
428/// Extract the multiplier that converts file plane-angle units to radians.
429///
430/// Follows the chain: IFCPROJECT → IFCUNITASSIGNMENT → IFCSIUNIT / IFCCONVERSIONBASEDUNIT.
431/// Returns the multiplier such that `value_in_file_units * multiplier = value_in_radians`.
432///
433/// - `IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)` → `1.0`
434/// - `IFCCONVERSIONBASEDUNIT(...,.PLANEANGLEUNIT.,'DEGREE',#m)` where `#m =
435///   IFCMEASUREWITHUNIT(IFCRATIOMEASURE(0.01745...), <radian>)` → `0.01745...`
436/// - Missing PLANEANGLEUNIT → `1.0` (radians, per IFC2x3/IFC4 spec default).
437///
438/// Without this, trim parameters on `IfcCircle`/`IfcEllipse` were
439/// unconditionally `.to_radians()`-converted in `profiles.rs`, which silently
440/// shrank 240° arcs to ~4° on files that declare `PLANEANGLEUNIT=.RADIAN.`
441/// (e.g. Renga, issue #820).
442pub fn extract_plane_angle_to_radians(decoder: &mut EntityDecoder, project_id: u32) -> Result<f64> {
443    let project = decoder.decode_by_id(project_id)?;
444    if project.ifc_type.as_str() != "IFCPROJECT" {
445        return Ok(1.0);
446    }
447
448    let units_attr = match project.get(8) {
449        Some(attr) => attr,
450        None => return Ok(1.0),
451    };
452    let units_ref = match units_attr.as_entity_ref() {
453        Some(ref_id) => ref_id,
454        None => return Ok(1.0),
455    };
456    let unit_assignment = decoder.decode_by_id(units_ref)?;
457    if unit_assignment.ifc_type.as_str() != "IFCUNITASSIGNMENT" {
458        return Ok(1.0);
459    }
460    let units_list_attr = match unit_assignment.get(0) {
461        Some(attr) => attr,
462        None => return Ok(1.0),
463    };
464    let units_list = match units_list_attr.as_list() {
465        Some(list) => list,
466        None => return Ok(1.0),
467    };
468
469    for unit_attr in units_list {
470        let unit_ref = match unit_attr.as_entity_ref() {
471            Some(ref_id) => ref_id,
472            None => continue,
473        };
474        let unit_entity = match decoder.decode_by_id(unit_ref) {
475            Ok(entity) => entity,
476            Err(_) => continue,
477        };
478
479        let unit_type_str = unit_entity.ifc_type.as_str();
480
481        if unit_type_str == "IFCSIUNIT" {
482            // [1]=UnitType, [2]=Prefix, [3]=Name
483            let kind = unit_entity.get(1).and_then(|a| a.as_enum());
484            if kind != Some("PLANEANGLEUNIT") {
485                continue;
486            }
487            // SI plane-angle unit is .RADIAN. by definition; SI prefixes
488            // (.MILLI. etc.) are legal but exotic for angles. Honor them.
489            let prefix_scale = match unit_entity.get(2) {
490                Some(p) if !p.is_null() => p
491                    .as_enum()
492                    .map(get_si_prefix_multiplier)
493                    .unwrap_or(1.0),
494                _ => 1.0,
495            };
496            return Ok(prefix_scale);
497        }
498
499        if unit_type_str == "IFCCONVERSIONBASEDUNIT" {
500            // [1]=UnitType, [2]=Name, [3]=ConversionFactor (IFCMEASUREWITHUNIT)
501            let kind = unit_entity.get(1).and_then(|a| a.as_enum());
502            if kind != Some("PLANEANGLEUNIT") {
503                continue;
504            }
505            // The conversion factor expresses (1 file-unit) in terms of its
506            // UnitComponent. For PLANEANGLEUNIT the UnitComponent is required
507            // by IFC to be the SI radian, so the value IS the multiplier to
508            // radians and we don't need to chase UnitComponent.
509            let conv_ref = match unit_entity.get_ref(3) {
510                Some(r) => r,
511                None => continue,
512            };
513            let measure = match decoder.decode_by_id(conv_ref) {
514                Ok(e) => e,
515                Err(_) => continue,
516            };
517            let value_attr = match measure.get(0) {
518                Some(a) => a,
519                None => continue,
520            };
521            // as_float() unwraps typed-value wrappers like
522            // IFCRATIOMEASURE(0.01745…) automatically.
523            let value = value_attr.as_float().unwrap_or(0.0);
524            if value > 0.0 && value.is_finite() {
525                return Ok(value);
526            }
527        }
528    }
529
530    // No plane-angle unit declared — IFC spec default is radian.
531    Ok(1.0)
532}
533
534#[cfg(test)]
535#[path = "units_tests.rs"]
536mod tests;