ifc_lite_server/services/parquet_instancing.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//! Rotation-aware instancing, shared by both Parquet transports (issues #3575,
6//! #3888).
7//!
8//! The ONE place that groups occurrences by representation identity, verifies
9//! each occurrence's derived rigid placement against its own baked geometry,
10//! and converts a verified placement into the Y-up origin + rotation a table
11//! emits. Split out of `parquet_optimized.rs` when it was `/optimized`-only;
12//! a sibling of both writers since the flat route started sharing shapes, so
13//! the module neither transport owns is not named after either of them.
14
15use crate::types::MeshData;
16use ifc_lite_geometry::{collate_refs_in_basis, InstanceMeshRef, Matrix4};
17use rustc_hash::FxHashMap;
18use std::borrow::Borrow;
19use std::hash::{Hash, Hasher};
20
21/// Content-hash fallback used by BOTH writers: `collate_rotation_aware_placements`
22/// runs first, this catches what it did not place. Two meshes with bit-identical
23/// (origin-relative) positions, indices AND normals collapse onto one shape.
24///
25/// Normals are keyed because the flat layout emits the shared shape's normals
26/// for every occurrence; `/optimized` ships none, so the stricter key can only
27/// split a group whose normals differ, which the pipeline never produces
28/// (normals derive from positions/indices) — no `/optimized` behaviour change.
29#[derive(Clone, Copy, PartialEq, Eq, Hash)]
30pub(crate) struct MeshGeometryKey {
31 /// Hash of the quantized positions.
32 positions_hash: u64,
33 /// Hash of the indices.
34 indices_hash: u64,
35 /// Hash of the normals.
36 normals_hash: u64,
37}
38
39/// Compute a fast hash of a u32 slice.
40fn hash_u32_slice(data: &[u32]) -> u64 {
41 use std::collections::hash_map::DefaultHasher;
42 let mut hasher = DefaultHasher::new();
43 for item in data {
44 item.hash(&mut hasher);
45 }
46 hasher.finish()
47}
48
49/// Compute a fast hash of a f32 slice (using bit representation).
50fn hash_f32_slice(data: &[f32]) -> u64 {
51 use std::collections::hash_map::DefaultHasher;
52 let mut hasher = DefaultHasher::new();
53 for item in data {
54 // Convert f32 to bits for hashing (handles NaN consistently)
55 item.to_bits().hash(&mut hasher);
56 }
57 hasher.finish()
58}
59
60/// Build the content-hash key for one mesh's (origin-relative) geometry.
61pub(crate) fn mesh_geometry_key(mesh: &MeshData) -> MeshGeometryKey {
62 MeshGeometryKey {
63 positions_hash: hash_f32_slice(&mesh.positions),
64 indices_hash: hash_u32_slice(&mesh.indices),
65 normals_hash: hash_f32_slice(&mesh.normals),
66 }
67}
68
69/// The frame this route's baked vertices are in, relative to the native frame
70/// `MeshData::instance` describes: `Rᵀ · T(-rtc)`, read straight off the
71/// `ProcessingResult` by `ifc_lite_processing::native_to_baked`.
72///
73/// Unlike the glTF exporter, this route CONSUMES the collator's emitted `rel`
74/// (see [`verify_and_derive_placement`]) rather than recomputing placement from
75/// `InstanceMeta`, so the basis is not optional decoration here: without it a
76/// `site_local` model's group fails the 1e-4 m residual check by `(I − Rᵀ)·d` —
77/// about 3.5 m for a 6 m sibling under a 34 degree site yaw — and silently
78/// falls back to content-hash dedup. Nothing breaks; the sharing just never
79/// happens (#4118).
80///
81/// Z-up: this route swaps to Y-up at emission time ([`rotation_zup_to_yup`]),
82/// not before collation, so no axis-swap factor belongs in this basis.
83pub(crate) fn baked_basis_zup(
84 mesh_coordinate_space: Option<ifc_lite_processing::MeshCoordinateSpace>,
85 site_transform: Option<&[f64]>,
86 origin_shift: [f64; 3],
87) -> Matrix4<f64> {
88 Matrix4::from_row_slice(&ifc_lite_processing::native_to_baked(
89 mesh_coordinate_space.unwrap_or(ifc_lite_processing::MeshCoordinateSpace::RawIfc),
90 site_transform,
91 origin_shift,
92 ))
93}
94
95/// Maximum reconstructed-vertex residual (metres) a rotation-aware instance
96/// placement may carry and still be trusted (issue #3575). Compared against
97/// the ORIGINAL f32/f64 positions, before quantization, so it is independent
98/// of `VERTEX_MULTIPLIER`.
99///
100/// On `/optimized` a residual under this bound is invisible on the wire, since
101/// 0.1mm is that route's quantization grain itself (`quantize_position` in
102/// `parquet_optimized.rs`). That argument does NOT carry to the flat route
103/// (#3888), which ships unquantized `Float32` metres and has no grain to hide
104/// behind: there, a residual just under tolerance is a real displacement of a
105/// repeated element, up to 0.1mm per vertex against the bit-exact geometry the
106/// route used to emit. Kept at one bound for both because 0.1mm is below any
107/// BIM tolerance the downstream consumers work to, but it is a bound, not an
108/// invisibility. A group whose residual exceeds this falls
109/// back to the pre-#3575 content-hash dedup (each occurrence keeps its own
110/// baked mesh) instead of shipping a placement nobody verified.
111const RECOMPOSITION_TOLERANCE_M: f64 = 1e-4;
112
113/// A verified rotation-aware placement for one occurrence: which unique mesh
114/// (identified by the index of its TEMPLATE occurrence in the input slice) it
115/// draws, and the origin/rotation (Z-up, pre-Y-up-swap) that places it there.
116pub(crate) struct RotatedPlacement {
117 pub(crate) template_mesh_index: usize,
118 pub(crate) origin_zup: [f64; 3],
119 /// Row-major 3x3, Z-up frame (converted to Y-up at emission time via
120 /// [`rotation_zup_to_yup`]).
121 pub(crate) rotation_zup: [f64; 9],
122}
123
124/// Row-major 3x3 apply: `R * p + t`.
125fn apply_r_t(r: &[[f64; 3]; 3], t: &[f64; 3], p: &[f64; 3]) -> [f64; 3] {
126 [
127 r[0][0] * p[0] + r[0][1] * p[1] + r[0][2] * p[2] + t[0],
128 r[1][0] * p[0] + r[1][1] * p[1] + r[1][2] * p[2] + t[1],
129 r[2][0] * p[0] + r[2][1] * p[1] + r[2][2] * p[2] + t[2],
130 ]
131}
132
133/// Given `rel` (row-major mat4, the `InstanceOccurrence::transform` that maps
134/// the TEMPLATE's baked world geometry onto this occurrence's baked world
135/// geometry — see `ifc_lite_geometry::instancing`), derive this occurrence's
136/// origin + rotation and verify them against the occurrence's OWN (ground
137/// truth) baked positions. Returns `(max_residual_m, origin_zup, rotation_zup)`;
138/// the caller must reject the placement when the residual exceeds
139/// [`RECOMPOSITION_TOLERANCE_M`] or is non-finite (mismatched vertex counts).
140fn verify_and_derive_placement(
141 template: &MeshData,
142 target: &MeshData,
143 rel: &[f64; 16],
144) -> (f64, [f64; 3], [f64; 9]) {
145 let r = [
146 [rel[0], rel[1], rel[2]],
147 [rel[4], rel[5], rel[6]],
148 [rel[8], rel[9], rel[10]],
149 ];
150 let t = [rel[3], rel[7], rel[11]];
151 let rotation_zup = [
152 r[0][0], r[0][1], r[0][2], r[1][0], r[1][1], r[1][2], r[2][0], r[2][1], r[2][2],
153 ];
154 // new_origin = R * template.origin + T: the template's own local-frame
155 // origin, carried through the same affine map applied to its vertices.
156 let origin_zup = apply_r_t(&r, &t, &template.origin);
157
158 let n = template.positions.len() / 3;
159 if target.positions.len() / 3 != n {
160 return (f64::INFINITY, origin_zup, rotation_zup);
161 }
162 let mut max_err = 0.0f64;
163 let mut identity_err = 0.0f64;
164 for v in 0..n {
165 let p = [
166 template.origin[0] + template.positions[v * 3] as f64,
167 template.origin[1] + template.positions[v * 3 + 1] as f64,
168 template.origin[2] + template.positions[v * 3 + 2] as f64,
169 ];
170 let world = apply_r_t(&r, &t, &p);
171 let g = [
172 target.origin[0] + target.positions[v * 3] as f64,
173 target.origin[1] + target.positions[v * 3 + 1] as f64,
174 target.origin[2] + target.positions[v * 3 + 2] as f64,
175 ];
176 let err =
177 ((world[0] - g[0]).powi(2) + (world[1] - g[1]).powi(2) + (world[2] - g[2]).powi(2))
178 .sqrt();
179 if err > max_err {
180 max_err = err;
181 }
182 // If the same verified placement is also representable by an exact
183 // identity rotation, prefer that canonical form. Conjugating a pure
184 // translation through a site basis can leave platform-dependent
185 // roundoff in the 3x3 block; comparing the f32 block to identity by
186 // exact bits then made the optimized route alternate between v2 and
187 // v3 across targets. This residual check is the safety condition: an
188 // actual small rotation is snapped only when omitting it stays within
189 // the route's existing recomposition tolerance.
190 let identity_world = [
191 origin_zup[0] + template.positions[v * 3] as f64,
192 origin_zup[1] + template.positions[v * 3 + 1] as f64,
193 origin_zup[2] + template.positions[v * 3 + 2] as f64,
194 ];
195 let err = ((identity_world[0] - g[0]).powi(2)
196 + (identity_world[1] - g[1]).powi(2)
197 + (identity_world[2] - g[2]).powi(2))
198 .sqrt();
199 if err > identity_err {
200 identity_err = err;
201 }
202 }
203 if identity_err <= RECOMPOSITION_TOLERANCE_M {
204 (
205 identity_err,
206 origin_zup,
207 [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
208 )
209 } else {
210 (max_err, origin_zup, rotation_zup)
211 }
212}
213
214/// Z-up → Y-up basis change (see `services::axis::zup_to_yup`), as a rotation
215/// matrix: `P * v = (v.x, v.z, -v.y)`. Orthogonal, so `P^-1 == P^T`.
216const P_ZUP_TO_YUP: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]];
217const P_ZUP_TO_YUP_T: [[f64; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]];
218
219fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
220 let mut out = [[0.0; 3]; 3];
221 for (i, out_row) in out.iter_mut().enumerate() {
222 for (j, out_cell) in out_row.iter_mut().enumerate() {
223 let mut s = 0.0;
224 for k in 0..3 {
225 s += a[i][k] * b[k][j];
226 }
227 *out_cell = s;
228 }
229 }
230 out
231}
232
233/// Conjugate a Z-up rotation into the Y-up wire frame: `P * R * P^T`. The swap
234/// is linear (`zup_to_yup`), so this is the same identity `mesh_to_yup_in_place`
235/// relies on for positions/origin, applied to a 3x3 instead of a 3-vector.
236pub(crate) fn rotation_zup_to_yup(rotation_zup: &[f64; 9]) -> [f32; 9] {
237 let r = [
238 [rotation_zup[0], rotation_zup[1], rotation_zup[2]],
239 [rotation_zup[3], rotation_zup[4], rotation_zup[5]],
240 [rotation_zup[6], rotation_zup[7], rotation_zup[8]],
241 ];
242 let m = mat3_mul(&mat3_mul(&P_ZUP_TO_YUP, &r), &P_ZUP_TO_YUP_T);
243 [
244 m[0][0] as f32,
245 m[0][1] as f32,
246 m[0][2] as f32,
247 m[1][0] as f32,
248 m[1][1] as f32,
249 m[1][2] as f32,
250 m[2][0] as f32,
251 m[2][1] as f32,
252 m[2][2] as f32,
253 ]
254}
255
256/// The optimized format's wire version, chosen by what the payload CONTAINS.
257///
258/// v2 is the pre-#3575 shape: an instance table without `rot0..rot8`, placed
259/// as `world = origin + template_position`. v3 adds the nine rotation columns
260/// and the placement becomes `world = origin + R * template_position`.
261///
262/// A response in which every instance came out identity IS a v2 payload in
263/// every observable respect, so it ships as one and an unchanged client keeps
264/// decoding every model it decoded before #3575. Only a payload that
265/// genuinely carries rotation declares 3, where a v2-only decoder fails loud
266/// on its version check (`Unsupported optimized Parquet version: 3`) instead
267/// of silently dropping the rotations and misplacing the geometry.
268///
269/// The argument is the rotation data actually emitted, NOT "the model has
270/// instance metadata" or "the feature is on": translation-only reuse runs the
271/// whole rotation-aware dedup and still produces identity everywhere.
272pub(crate) fn optimized_wire_version(has_rotation: bool) -> u8 {
273 if has_rotation {
274 3
275 } else {
276 2
277 }
278}
279
280pub(crate) const IDENTITY_ROTATION: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
281
282/// Group instanceable meshes by representation identity (`collate_refs`) and
283/// verify each occurrence's derived rigid placement against its own baked
284/// geometry (issue #3575). Returns a map from a mesh's index in `meshes` to
285/// the verified placement it should be emitted with; a mesh absent from the
286/// map falls back to the pre-#3575 content-hash dedup in `parquet_optimized.rs`.
287/// Verification is all-or-nothing per group: one occurrence over tolerance
288/// drops the ENTIRE representation group from the map, not just that
289/// occurrence, so a group never ships half-instanced.
290///
291/// Deliberately excludes the RIGID tier (`InstanceMeta::canonical_transform`
292/// set): those groups substitute a congruent-but-not-bit-identical template,
293/// so the per-vertex residual check above isn't meaningful the same way, and
294/// this endpoint has no way to signal "close enough, not exact" to a client
295/// decoding a lossless-looking mesh table. Only exact-bit groups (the
296/// `IfcMappedItem` / shared-`IfcRepresentationMap` case the issue reports)
297/// are deduplicated this way.
298///
299/// `baked_basis` is [`baked_basis_zup`] for the model these meshes came from,
300/// or `None` when the caller genuinely has no `ProcessingResult` to read it off
301/// (a per-batch streaming writer, a test over synthetic meshes) and the
302/// vertices are therefore native.
303///
304/// Generic over [`Borrow`] so the streaming route can put an earlier batch's
305/// retained templates in front of the current batch (#5407) as a slice of
306/// references, without cloning the batch to build one contiguous slice.
307pub(crate) fn collate_rotation_aware_placements<M: Borrow<MeshData>>(
308 meshes: &[M],
309 baked_basis: Option<&Matrix4<f64>>,
310) -> FxHashMap<usize, RotatedPlacement> {
311 let refs: Vec<InstanceMeshRef> = meshes
312 .iter()
313 .map(Borrow::borrow)
314 .map(|m: &MeshData| InstanceMeshRef {
315 positions: &m.positions,
316 normals: &m.normals,
317 indices: &m.indices,
318 origin: m.origin,
319 instance_meta: m.instance.as_ref(),
320 entity_id: m.express_id,
321 color: m.color,
322 item_id: None,
323 })
324 .collect();
325 // rtc = 0: `MeshData::instance` is populated per-element by the native
326 // pipeline, which already resolves to building-scale, non-georeferenced
327 // magnitude for every model this endpoint has been run against; the
328 // per-vertex residual check below is the real safety net regardless (a
329 // stale/large offset shows up as a residual over tolerance, not a
330 // silent misplacement), so an unhandled RTC rebase degrades to today's
331 // behaviour rather than shipping a wrong placement. The `baked_basis`
332 // carries the model's own `T(-rtc)` (and site rotation) instead, applied to
333 // the emitted `rel` as well as the check.
334 let collated = collate_refs_in_basis(&refs, 2, [0.0, 0.0, 0.0], baked_basis);
335
336 let mut placements: FxHashMap<usize, RotatedPlacement> = FxHashMap::default();
337 for tmpl in &collated.templates {
338 let is_rigid = tmpl.occurrences.iter().any(|o| {
339 meshes[o.mesh_index]
340 .borrow()
341 .instance
342 .as_ref()
343 .and_then(|m| m.canonical_transform)
344 .is_some()
345 });
346 if is_rigid {
347 continue;
348 }
349 let template = meshes[tmpl.template_index].borrow();
350 let mut group: Vec<(usize, [f64; 3], [f64; 9])> =
351 Vec::with_capacity(tmpl.occurrences.len());
352 let mut all_verified = true;
353 for occ in &tmpl.occurrences {
354 let target = meshes[occ.mesh_index].borrow();
355 let rel: [f64; 16] = occ.transform.map(|v| v as f64);
356 let (max_err, origin_zup, rotation_zup) =
357 verify_and_derive_placement(template, target, &rel);
358 if !(max_err <= RECOMPOSITION_TOLERANCE_M) {
359 all_verified = false;
360 break;
361 }
362 group.push((occ.mesh_index, origin_zup, rotation_zup));
363 }
364 if all_verified {
365 for (mesh_index, origin_zup, rotation_zup) in group {
366 placements.insert(
367 mesh_index,
368 RotatedPlacement {
369 template_mesh_index: tmpl.template_index,
370 origin_zup,
371 rotation_zup,
372 },
373 );
374 }
375 }
376 }
377 placements
378}