ifc_lite_export/merged/units.rs
1// SPDX-License-Identifier: MPL-2.0
2//! Unit reconciliation for merged export. Ports the length-scale resolution and
3//! compatibility test of `merged-exporter.ts` (`resolveUnitScale`,
4//! `unitsCompatible`).
5//!
6//! Each model's length unit is resolved to an SI scale (metres-per-unit). Two
7//! models are *compatible* when their length scales match within a relative
8//! tolerance; only compatible models are unified into one project / unit space.
9//! Genuine cross-unit rescaling (`unitReconciliation: 'normalize'`) is not done
10//! natively in this iteration — the caller gates it to the JS path — so the
11//! native merge only ever emits a byte-faithful (unscaled) copy of each model,
12//! and an incompatible-unit model is federated (kept as its own project) rather
13//! than silently mis-scaled.
14
15use ifc_lite_core::EntityDecoder;
16
17use super::MergedModel;
18
19/// How to reconcile models whose length unit differs from the first model's.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
21pub enum UnitReconciliation {
22 /// Federate an incompatible-unit model as its own `IfcProject` (JS default).
23 #[default]
24 Auto,
25 /// Request rescaling into the first model's unit. Not performed natively in
26 /// this iteration: an incompatible model is federated and
27 /// [`super::MergedStats::unit_rescale_required`] is set for the caller to gate.
28 Normalize,
29 /// Treat every model as sharing the first model's unit (no compatibility
30 /// check) — unify regardless of the declared length unit.
31 AssumeShared,
32}
33
34/// Relative tolerance for comparing two length unit scale factors (JS `1e-6`).
35const UNIT_SCALE_TOLERANCE: f64 = 1e-6;
36
37/// The unit verdict for one input model of a merged export.
38pub struct ModelUnitMode {
39 /// The model unifies into the first model's project / unit space.
40 pub compatible: bool,
41 /// The unit scale the model's entities live in once merged.
42 pub scale: f64,
43 /// The model is kept as its own `IfcProject` (incompatible length unit).
44 pub federated: bool,
45 /// `Normalize` was requested but the model was federated instead.
46 pub rescale_required: bool,
47}
48
49/// Resolve the unit verdict of every input model in one place, so the emit loop
50/// and the empty-container pre-pass (which has to know which models unify before
51/// anything is written) cannot disagree about what unifies.
52pub fn resolve_model_modes(
53 models: &[MergedModel],
54 reconciliation: UnitReconciliation,
55 primary_scale: f64,
56) -> Vec<ModelUnitMode> {
57 models
58 .iter()
59 .enumerate()
60 .map(|(i, model)| {
61 let scale = resolve_length_scale(model.content);
62 if i == 0 {
63 return ModelUnitMode {
64 compatible: true,
65 scale: primary_scale,
66 federated: false,
67 rescale_required: false,
68 };
69 }
70 match reconciliation {
71 // AssumeShared unifies regardless of the declared unit, so the
72 // model's entities join the FIRST model's unit space: report
73 // `primary_scale`, not the model's own. Reporting `this_scale`
74 // would make a later model's duplicate GlobalId fail the
75 // `units_compatible(scale, primary_scale)` gate and be re-stamped
76 // instead of unified (CR #2952).
77 UnitReconciliation::AssumeShared => ModelUnitMode {
78 compatible: true,
79 scale: primary_scale,
80 federated: false,
81 rescale_required: false,
82 },
83 _ if units_compatible(scale, primary_scale) => ModelUnitMode {
84 compatible: true,
85 scale,
86 federated: false,
87 rescale_required: false,
88 },
89 UnitReconciliation::Normalize => ModelUnitMode {
90 compatible: false,
91 scale,
92 federated: true,
93 rescale_required: true,
94 },
95 UnitReconciliation::Auto => ModelUnitMode {
96 compatible: false,
97 scale,
98 federated: true,
99 rescale_required: false,
100 },
101 }
102 })
103 .collect()
104}
105
106/// Resolve a model's length unit SI scale (metres per length unit, e.g. `0.001`
107/// for a millimetre file). Falls back to `1.0` when no length unit is declared.
108pub fn resolve_length_scale(content: &[u8]) -> f64 {
109 let mut decoder = EntityDecoder::new(content);
110 let scale = decoder.length_unit_scale();
111 if scale > 0.0 && scale.is_finite() {
112 scale
113 } else {
114 1.0
115 }
116}
117
118/// The unit every compatible model merges into: the first model's. One home for
119/// the emit loop and the drop plan.
120pub(super) fn primary_scale(models: &[super::MergedModel]) -> f64 {
121 models.first().map_or(1.0, |m| resolve_length_scale(m.content))
122}
123
124/// True when two length scales are equal within [`UNIT_SCALE_TOLERANCE`] (JS
125/// `unitsCompatible`): identical, both zero, or within a relative epsilon.
126pub fn units_compatible(a: f64, b: f64) -> bool {
127 if a == b {
128 return true;
129 }
130 let max = a.abs().max(b.abs());
131 if max == 0.0 {
132 return true;
133 }
134 (a - b).abs() <= max * UNIT_SCALE_TOLERANCE
135}
136
137#[cfg(test)]
138mod units_tests {
139 use super::*;
140
141 const MM_FILE: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n#1=IFCPROJECT('g',$,$,$,$,$,$,$,#2);\n#2=IFCUNITASSIGNMENT((#3));\n#3=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);\nENDSEC;\nEND-ISO-10303-21;\n";
142 const M_FILE: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;\n#1=IFCPROJECT('g',$,$,$,$,$,$,$,#2);\n#2=IFCUNITASSIGNMENT((#3));\n#3=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);\nENDSEC;\nEND-ISO-10303-21;\n";
143
144 #[test]
145 fn resolves_millimetre_length_scale() {
146 assert!((resolve_length_scale(MM_FILE.as_bytes()) - 0.001).abs() < 1e-12);
147 }
148
149 #[test]
150 fn resolves_metre_length_scale() {
151 assert!((resolve_length_scale(M_FILE.as_bytes()) - 1.0).abs() < 1e-12);
152 }
153
154 #[test]
155 fn compatibility_within_tolerance() {
156 assert!(units_compatible(0.001, 0.001));
157 assert!(units_compatible(1.0, 1.0 + 1e-9));
158 assert!(!units_compatible(0.001, 1.0));
159 assert!(units_compatible(1.0, 1.0));
160 }
161}