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;