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}