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}