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}