ifc_lite_geometry/instancing/
verify.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//! Per-group reconstruction verification for `collate_refs` (issue #3666).
6//!
7//! `rep_identity` is documented as a guarantee that every occurrence sharing
8//! it bakes from the SAME canonical geometry, but the guarantee is only as
9//! strong as the hash behind it. A run over a large multi-model merge found
10//! 28 of 6395 occurrences (11 `rep_identity` groups) where that did not hold:
11//! reconstructing an occurrence from its template and per-instance transform
12//! landed up to 2.0m away from the occurrence's own baked vertices — a
13//! `rep_identity` collision between unrelated geometry from different source
14//! models, with nothing on the path erroring, warning, or logging. The
15//! occurrence still rendered, just in the wrong place.
16//!
17//! `collate_refs` previously trusted `rep_identity` outright: it computed
18//! `rel` for every member of a group from placement metadata alone, with no
19//! way to notice a wrong pairing (the exact-tier guard only compared vertex
20//! and index COUNTS, which a same-shaped colliding pair still passes). This
21//! module reconstructs each occurrence's own baked world vertices from
22//! `(template, rel)` and compares them against the occurrence's OWN baked
23//! vertices — the same computation `verify_recomposition` already performs
24//! as an out-of-band diagnostic/test helper — but run inline, per candidate
25//! pairing, on the production path, so a failing group falls back to the
26//! flat (unshared) path instead of shipping a mis-grouped occurrence.
27//!
28//! Scoped to the exact tier only (see `collate_refs`): the RIGID tier
29//! substitutes a congruent-but-not-bit-identical template by design, so its
30//! members can legitimately carry a different raw vertex count than the
31//! template — this check would reject correct rigid groups, not just
32//! colliding ones. Rigid-tier congruence is established (and, per its own
33//! doc comment, verified) upstream of collation via `canonical_transform`.
34//!
35//! **Frame contract.** `verify_pairing` reconstructs by applying `rel` — which
36//! is always computed from `InstanceMeta.transform`, native-frame (IFC Z-up) —
37//! to the template's OWN baked vertices, so the comparison is only valid when
38//! the baked vertices it is handed (`template.positions`/`mesh.positions`) are
39//! ALSO in that native frame. `collate_refs_in_basis`'s `baked_basis`
40//! parameter exists because the glTF exporter's in-memory assembler converts
41//! every visible mesh's baked positions Z-up→Y-up BEFORE calling in (so the
42//! template/flat geometry it emits is already Y-up), while `InstanceMeta`
43//! itself stays Z-up throughout — the per-occurrence node matrix is
44//! independently recomposed and Y-up-conjugated downstream (`occurrence_node_matrix`
45//! in the export crate), never reading this module's `rel` back. The same
46//! parameter now also carries the site-local inverse rotation `Rᵀ` that
47//! `convert_mesh_to_site_local` bakes into positions after `InstanceMeta` was
48//! captured (#4118), and — since #4118 — it conjugates the EMITTED `rel` as
49//! well as the one checked here, for the consumers that read `rel` back
50//! instead of recomputing. Left
51//! unaccounted for, that frame mismatch reads as a `rep_identity` collision on
52//! nearly every rotated group in a real model (a Z-up `rel` reconstructing
53//! against Y-up vertices), which is what motivated this parameter rather than
54//! loosening [`tolerance`] — the mismatch is orders of magnitude larger than
55//! any plausible tolerance, in either direction, at every coordinate scale.
56
57use super::collate::Collated;
58use crate::mesh::Mesh;
59use nalgebra::{Matrix4, Vector4};
60
61/// Reconstruction tolerance at world-coordinate magnitude `mag` (metres):
62/// relative to the vertex's own magnitude (f32 ULP-scale — the same
63/// far-from-origin precedent `rect_fast.rs`'s watertight-cut volume check
64/// uses, where a wall 220km from the origin carries ~12mm of inherent f32
65/// error per coordinate and a fixed epsilon there would be meaningless) OR a
66/// micrometre-scale floor near the origin, whichever is larger. f32 stores
67/// positions at ~2^-23 relative precision; composing and applying `rel` (a
68/// handful of float ops) can accumulate a few ULPs of drift beyond that,
69/// hence the small multiplier. A genuine `rep_identity` collision (#3666)
70/// misplaces geometry by the FULL offset between two unrelated shapes —
71/// metres, per the issue's own measurement — so it clears this tolerance at
72/// any coordinate magnitude; only recomposition noise between truly-shared
73/// geometry stays under it.
74const ABS_FLOOR_M: f64 = 1e-6;
75// 8 * 2^-23 (f32 ULP), computed out-of-line: `powi` is not `const fn` here.
76const ULP_FACTOR: f64 = 9.5367431640625e-7;
77
78fn tolerance(mag: f64) -> f64 {
79    (mag * ULP_FACTOR).max(ABS_FLOOR_M)
80}
81
82/// Largest absolute component of vertex `v`'s STORED (origin-relative) position.
83fn stored_magnitude(positions: &[f32], v: usize) -> f64 {
84    (positions[v * 3] as f64)
85        .abs()
86        .max((positions[v * 3 + 1] as f64).abs())
87        .max((positions[v * 3 + 2] as f64).abs())
88}
89
90/// Reconstructed world-space error of vertex `v`: applies `rel` to the
91/// template's own baked world vertex (`template_origin + template_positions[v]`)
92/// and returns its distance from the occurrence's own baked world vertex
93/// (`target_origin + target_positions[v]`). This is the one reconstruction
94/// kernel both [`verify_pairing`] (the inline pass/fail gate) and
95/// [`verify_recomposition`] (the out-of-band diagnostic max-error scan) run —
96/// same math, different verdicts drawn from it — so a frame or precision fix
97/// here reaches both instead of needing to be made twice.
98fn reconstructed_vertex_error(
99    template_origin: [f64; 3],
100    template_positions: &[f32],
101    target_origin: [f64; 3],
102    target_positions: &[f32],
103    rel: &Matrix4<f64>,
104    v: usize,
105) -> f64 {
106    let tx = template_origin[0] + template_positions[v * 3] as f64;
107    let ty = template_origin[1] + template_positions[v * 3 + 1] as f64;
108    let tz = template_origin[2] + template_positions[v * 3 + 2] as f64;
109    let w = rel * Vector4::new(tx, ty, tz, 1.0);
110    let (rx, ry, rz) = (w.x / w.w, w.y / w.w, w.z / w.w);
111    let gx = target_origin[0] + target_positions[v * 3] as f64;
112    let gy = target_origin[1] + target_positions[v * 3 + 1] as f64;
113    let gz = target_origin[2] + target_positions[v * 3 + 2] as f64;
114    ((rx - gx).powi(2) + (ry - gy).powi(2) + (rz - gz).powi(2)).sqrt()
115}
116
117/// Verify a candidate template<->occurrence pairing: applying `rel` to every
118/// one of the template's own baked world vertices
119/// (`template_origin + template_positions`) must reproduce the occurrence's
120/// own baked world vertices (`target_origin + target_positions`) within
121/// [`tolerance`]. A vertex-count mismatch is treated as failure (an
122/// occurrence claiming a different raw vertex count than its template cannot
123/// be recomposed from it) rather than panicking on an out-of-bounds index.
124pub(super) fn verify_pairing(
125    template_origin: [f64; 3],
126    template_positions: &[f32],
127    target_origin: [f64; 3],
128    target_positions: &[f32],
129    rel: &Matrix4<f64>,
130) -> bool {
131    let n = template_positions.len() / 3;
132    if target_positions.len() / 3 != n {
133        return false;
134    }
135    for v in 0..n {
136        let err = reconstructed_vertex_error(
137            template_origin,
138            template_positions,
139            target_origin,
140            target_positions,
141            rel,
142            v,
143        );
144        // Scale by the STORED (origin-relative) position, not the absolute
145        // world coordinate (target_origin + position): the f32 quantization
146        // this tolerance accounts for lives in the stored position, and for
147        // a georeferenced mesh `origin` alone can carry a multi-million-metre
148        // offset while positions stay small local deltas. Scaling off the
149        // absolute coordinate inflates tolerance with the georeference
150        // itself, wide enough to wave through a genuine rep_identity
151        // collision (#3666 measured up to 2.0m) at real-world georeferenced
152        // magnitudes -- the same "scaled tolerance flips direction across
153        // the range" shape this project has hit before.
154        //
155        // The magnitude is the LARGER of the two stored positions, not the
156        // target's alone. The residual being bounded here is the f32 rounding
157        // in BOTH baked meshes, and the coarser grid dominates: a template
158        // stored at 80m rounds to a ~4e-6 m grid while a sibling stored at 2m
159        // rounds to a ~2e-7 one, so scaling off the near sibling alone charged
160        // it for an error the far template put there. That rejected genuinely
161        // shared geometry under a pure translation, and did it
162        // ORDER-DEPENDENTLY — the same pair verifies when the near mesh happens
163        // to be collated first and becomes the template.
164        let mag = stored_magnitude(template_positions, v).max(stored_magnitude(target_positions, v));
165        // A non-finite magnitude makes the tolerance infinite, and `err <= inf`
166        // holds for any finite error AND for `err == inf` — so a +/-inf baked
167        // position sailed through the comparison below (unlike a NaN, which the
168        // explicit `is_nan` check already rejects). Fail closed on it too.
169        if !mag.is_finite() {
170            return false;
171        }
172        // `err.is_nan()` must be checked explicitly: `err > tolerance(mag)`
173        // is false whenever `err` is NaN (a NaN transform or position
174        // propagates through the matrix multiply and subtraction into a NaN
175        // `err`), so an unguarded comparison here is fail-open in the one
176        // check that exists to fail closed on a bad reconstruction.
177        if err.is_nan() || err > tolerance(mag) {
178            return false;
179        }
180    }
181    true
182}
183
184/// Maximum per-vertex world-space error (in mesh units) when each occurrence is
185/// reconstructed by applying its instance transform to the template's baked
186/// world geometry, versus the occurrence's own baked world geometry. The
187/// template-relative transform operates on world coords, so each mesh's `origin`
188/// is folded in. Used by tests + as a runtime diagnostic — [`verify_pairing`]
189/// above runs the same [`reconstructed_vertex_error`] kernel inline, per
190/// candidate group, so a production caller never has to run this after the
191/// fact to find out its `Collated` shipped a mis-grouped occurrence.
192pub fn verify_recomposition(meshes: &[Mesh], collated: &Collated) -> f64 {
193    let mut max_err = 0.0f64;
194    for tmpl in &collated.templates {
195        let template = &meshes[tmpl.template_index];
196        for occ in &tmpl.occurrences {
197            let target = &meshes[occ.mesh_index];
198            let rel = Matrix4::from_row_slice(&occ.transform.map(|v| v as f64));
199            // A valid template↔occurrence pair shares the same geometry (same
200            // vertex count, different transform). If the counts differ the
201            // occurrence can't be recomposed from the template — flag it as an
202            // unbounded error instead of panicking on an out-of-bounds index,
203            // so the diagnostic surfaces the mismatch. (#1238 review)
204            let n = template.positions.len() / 3;
205            if target.positions.len() / 3 != n {
206                max_err = f64::INFINITY;
207                continue;
208            }
209            for v in 0..n {
210                let err = reconstructed_vertex_error(
211                    template.origin,
212                    &template.positions,
213                    target.origin,
214                    &target.positions,
215                    &rel,
216                    v,
217                );
218                // `err > max_err` is false whenever `err` is NaN, so an
219                // unguarded max-accumulation SILENTLY DROPS a NaN vertex
220                // error instead of it winning the max — verified empirically:
221                // an all-NaN occurrence still returns 0.0 here, not NaN, so a
222                // caller's `assert!(max_err < 1e-4)` passes as if every
223                // vertex reconstructed exactly. Flag it the same way the
224                // vertex-count mismatch above already does (unbounded error)
225                // so a NaN transform or position fails this diagnostic loudly
226                // instead of reading as a perfect match.
227                if err.is_nan() {
228                    max_err = f64::INFINITY;
229                    continue;
230                }
231                if err > max_err {
232                    max_err = err;
233                }
234            }
235        }
236    }
237    max_err
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn tolerance_scales_with_magnitude_above_the_floor() {
246        assert_eq!(tolerance(0.0), ABS_FLOOR_M);
247        assert_eq!(tolerance(1.0), ABS_FLOOR_M);
248        // At 220km (the rect_fast.rs far-from-origin precedent), the
249        // ULP-scale term dominates and lands in the low-decimetre range —
250        // wider than the origin floor, narrower than the metre-scale
251        // collision residual #3666 measured (so a genuine collision still
252        // fails this check by an order of magnitude even at this range).
253        let far = tolerance(220_000.0);
254        assert!(
255            far > ABS_FLOOR_M && far < 1.0,
256            "expected a sub-metre tolerance at 220km, got {far}"
257        );
258    }
259
260    #[test]
261    fn identical_geometry_through_identity_rel_passes() {
262        let positions: [f32; 6] = [0.0, 0.0, 0.0, 1.0, 2.0, 3.0];
263        let identity = Matrix4::identity();
264        assert!(verify_pairing(
265            [0.0, 0.0, 0.0],
266            &positions,
267            [0.0, 0.0, 0.0],
268            &positions,
269            &identity,
270        ));
271    }
272
273    #[test]
274    fn a_non_finite_transform_is_rejected() {
275        // A NaN anywhere in `rel` (or in either position buffer) makes every
276        // reconstructed coordinate NaN, so `err` is NaN — and NaN LOSES every
277        // comparison, so a plain `err > tolerance(mag)` was false and the
278        // pairing passed verification. The check must fail CLOSED: a group it
279        // cannot evaluate is not a group it has verified.
280        let positions: [f32; 6] = [0.0, 0.0, 0.0, 1.0, 2.0, 3.0];
281        let mut rel = Matrix4::identity();
282        rel[(0, 3)] = f64::NAN;
283        assert!(
284            !verify_pairing([0.0; 3], &positions, [0.0; 3], &positions, &rel),
285            "a NaN transform must not pass verification"
286        );
287    }
288
289    #[test]
290    fn a_non_finite_position_is_rejected() {
291        // Both buffers, because the NaN reaches `err` by a different route from
292        // each: through the reconstruction when it is in the template, through
293        // the comparison when it is in the target.
294        let finite: [f32; 3] = [0.0, 0.0, 0.0];
295        let nan: [f32; 3] = [f32::NAN, 0.0, 0.0];
296        assert!(
297            !verify_pairing([0.0; 3], &finite, [0.0; 3], &nan, &Matrix4::identity()),
298            "a NaN baked target position must not pass verification"
299        );
300        assert!(
301            !verify_pairing([0.0; 3], &nan, [0.0; 3], &finite, &Matrix4::identity()),
302            "a NaN baked template position must not pass verification"
303        );
304    }
305
306    #[test]
307    fn an_infinite_target_position_is_rejected() {
308        // `+/-inf` is the non-finite case the NaN guard does NOT cover: the
309        // reconstructed vertex stays finite, so `err` is `+inf` — but the
310        // magnitude is `inf` too, so the tolerance is `inf` and `err <= tol`
311        // HOLDS. The magnitude has to be checked for finiteness in its own right.
312        let template: [f32; 3] = [0.0, 0.0, 0.0];
313        let target: [f32; 3] = [f32::INFINITY, 0.0, 0.0];
314        assert!(
315            !verify_pairing(
316                [0.0; 3],
317                &template,
318                [0.0; 3],
319                &target,
320                &Matrix4::identity()
321            ),
322            "an infinite baked position must not pass verification"
323        );
324    }
325
326    #[test]
327    fn a_translated_target_fails_identity_rel() {
328        let template: [f32; 3] = [0.0, 0.0, 0.0];
329        // Same vertex COUNT, but genuinely different geometry (as a hash
330        // collision between two unrelated shapes would produce) — must not
331        // pass under the transform that maps the template onto itself.
332        let target: [f32; 3] = [2.0, 0.0, 0.0];
333        let identity = Matrix4::identity();
334        assert!(!verify_pairing(
335            [0.0, 0.0, 0.0],
336            &template,
337            [0.0, 0.0, 0.0],
338            &target,
339            &identity,
340        ));
341    }
342
343    #[test]
344    fn a_genuine_match_still_passes_after_the_nan_guard() {
345        // The NaN guard must not turn into a guard that rejects everything —
346        // a real matching pair (non-trivial transform, non-zero vertices)
347        // still has to clear verification.
348        let template: [f32; 6] = [0.0, 0.0, 0.0, 1.0, 2.0, 3.0];
349        let rel = Matrix4::new_translation(&nalgebra::Vector3::new(5.0, -2.0, 0.5));
350        let target: [f32; 6] = [5.0, -2.0, 0.5, 6.0, 0.0, 3.5];
351        assert!(
352            verify_pairing(
353                [0.0, 0.0, 0.0],
354                &template,
355                [0.0, 0.0, 0.0],
356                &target,
357                &rel,
358            ),
359            "a genuine matching pair must still pass verification"
360        );
361    }
362}