ifc_lite_geometry/instancing/
group.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//! The rep-identity grouping pass, split out of `collate.rs` so that file stays
6//! within its module-size budget: `collate.rs` owns the collation TYPES and the
7//! small frame helpers, this file owns the pass that walks the groups, verifies
8//! each candidate pairing (#3666) and decides what falls back to the flat path.
9
10use super::collate::{
11    compose_world, mat4_to_row_major_f32, to_post_rtc, Collated, InstanceMeshRef,
12    InstanceOccurrence, InstanceTemplate,
13};
14use super::verify::verify_pairing;
15use nalgebra::Matrix4;
16use rustc_hash::FxHashMap;
17
18/// Group instanceable meshes by representation identity into templates +
19/// per-instance transforms. `min_group` is the smallest occurrence count worth
20/// instancing (groups below it are emitted flat); use 2 to instance any repeat.
21///
22/// `rtc` is the model RTC offset (`InstanceMeta.transform` is documented pre-RTC
23/// at georeferenced magnitude, while each mesh's baked `origin` is post-RTC and
24/// small). The per-occurrence relative transform is computed in the post-RTC
25/// frame: on raw absolute placements `rel = m_k · m_ref⁻¹` lets a *rotated*
26/// occurrence's translation reach `T_k − R_rel·T_ref ≈ 2× rtc` (the two ~1e6 m
27/// terms add instead of cancel), which then places the occurrence at twice the
28/// georeference and collapses f32 GLB exports. Reducing both transforms by `rtc`
29/// first keeps `rel.translation` at building scale and consistent with the small
30/// template origin.
31///
32/// `InstanceMeta.transform` (hence `rel`) is native-frame (IFC Z-up); positions
33/// must share that frame — see [`collate_refs_in_basis`] otherwise.
34pub fn collate_refs(meshes: &[InstanceMeshRef], min_group: usize, rtc: [f64; 3]) -> Collated {
35    collate_refs_in_basis(meshes, min_group, rtc, None)
36}
37
38/// Compatibility entry point preserving the original verify-only emission
39/// contract. Use [`collate_refs_in_basis`] for baked-frame output.
40pub fn collate_refs_verified_in(
41    meshes: &[InstanceMeshRef],
42    min_group: usize,
43    rtc: [f64; 3],
44    verify_basis: Option<&Matrix4<f64>>,
45) -> Collated {
46    collate_refs_impl(meshes, min_group, rtc, verify_basis, false)
47}
48
49/// [`collate_refs`] against baked positions that are NOT in the native frame:
50/// `baked_basis` is what the baker did to the vertices after `InstanceMeta` was
51/// captured, so that `p_baked = B · p_native`.
52///
53/// `B` is applied to BOTH halves of this function's output, not only to the
54/// #3666 reconstruction check:
55///
56/// * the check compares `B · rel · B⁻¹` against the baked vertices (as it
57///   always did — see [`super::verify`]'s module doc), and
58/// * every emitted [`InstanceOccurrence::transform`] is that same conjugated
59///   `B · rel · B⁻¹`, so a consumer that uses the emitted `rel` directly places
60///   the occurrence in the frame its vertices are in.
61///
62/// This is the `#4118` behaviour change, exposed under a new name because
63/// `collate_refs_verified_in` promised that its basis touched only the check;
64/// that compatibility entry point retains its original output. `rtc` is the same kind of
65/// argument — `to_post_rtc` already applies it to output and check alike — and
66/// the site rotation simply had no slot until now.
67///
68/// Pass `None` (via [`collate_refs`]) when the baked positions ARE native; the
69/// multiplication is then not performed at all, so the bytes are unchanged.
70pub fn collate_refs_in_basis(
71    meshes: &[InstanceMeshRef],
72    min_group: usize,
73    rtc: [f64; 3],
74    baked_basis: Option<&Matrix4<f64>>,
75) -> Collated {
76    collate_refs_impl(meshes, min_group, rtc, baked_basis, true)
77}
78
79fn collate_refs_impl(
80    meshes: &[InstanceMeshRef],
81    min_group: usize,
82    rtc: [f64; 3],
83    baked_basis: Option<&Matrix4<f64>>,
84    emit_in_basis: bool,
85) -> Collated {
86    // A `baked_basis` that cannot be inverted has no conjugation `B · rel · B⁻¹`.
87    // This once degraded to `None` — "no basis given" — which compares an
88    // UNCONJUGATED `rel` against baked vertices the caller has just said are in a
89    // DIFFERENT frame: the one comparison known to be wrong, reported as verified.
90    // A singular basis is a caller bug, so reject outright: nothing is instanced
91    // and every drawable mesh still draws, flat. That costs sharing (loud, and
92    // visible in the export's size) rather than shipping a mis-grouped occurrence
93    // (silent, and wrong on screen).
94    let basis_conjugate = match baked_basis {
95        None => None,
96        Some(s) => match s.try_inverse() {
97            Some(s_inv) => Some((*s, s_inv)),
98            None => {
99                crate::diag::diag_warn!(
100                    { "instancing: baked_basis is singular; refusing the whole collation (nothing instanced, every drawable mesh still drawn flat)" }
101                    else {
102                        #[cfg(any(debug_assertions, test))]
103                        eprintln!(
104                            "[instancing] baked_basis is singular; refusing the whole \
105                             collation (nothing instanced, every drawable mesh drawn flat)"
106                        );
107                    }
108                );
109                return all_drawable_flat(meshes);
110            }
111        },
112    };
113    let emitted_basis = emit_in_basis.then_some(basis_conjugate.as_ref()).flatten();
114    // First-seen order keeps output deterministic regardless of hash iteration.
115    let mut order: Vec<u128> = Vec::new();
116    let mut groups: FxHashMap<u128, Vec<usize>> = FxHashMap::default();
117    // Non-instanceable meshes (void-cut walls, multi-item merges, site-rotated
118    // elements — anything carrying no usable InstanceMeta) still must be DRAWN, so
119    // they're routed to flat_indices and emitted as flat singleton templates.
120    // Dropping them here would silently lose geometry now that capture is always-on
121    // and real models feed the collator — the unit fixtures were all instanceable,
122    // which hid this. A non-empty non-instanceable mesh goes flat; an EMPTY mesh
123    // carries nothing to draw UNLESS it is a #1623 Phase 3 don't-bake occurrence
124    // placeholder (empty geometry + instanceable InstanceMeta) — the shared template
125    // supplies its geometry, so it joins its rep group like a materialized member.
126    let mut flat: Vec<usize> = Vec::new();
127    for (i, m) in meshes.iter().enumerate() {
128        match m.instance_meta {
129            Some(im) if im.instanceable => {
130                groups
131                    .entry(im.rep_identity)
132                    .or_insert_with(|| {
133                        order.push(im.rep_identity);
134                        Vec::new()
135                    })
136                    .push(i);
137            }
138            // Empty + non-instanceable = nothing to draw (skip); non-empty +
139            // non-instanceable = flat singleton.
140            _ if !m.positions.is_empty() => flat.push(i),
141            _ => {}
142        }
143    }
144
145    let mut out = Collated {
146        flat_indices: flat,
147        ..Collated::default()
148    };
149    for rep in order {
150        let members = &groups[&rep];
151        // Template = the first NON-EMPTY (materialized) member. Empty members are
152        // don't-bake placeholders whose geometry IS this template.
153        let Some(t_idx) = members
154            .iter()
155            .copied()
156            .find(|&i| !meshes[i].positions.is_empty())
157        else {
158            // All-empty group: no template geometry to draw against. Unreachable by
159            // the caller contract (a materialized template always accompanies its
160            // occurrences); there is nothing to emit, so report the loss instead of
161            // emitting empty singletons that draw nothing.
162            out.dropped_placeholders += members.len();
163            continue;
164        };
165        let template = &meshes[t_idx];
166        // Compose in the post-RTC frame so the georeferenced offset cancels
167        // exactly regardless of each occurrence's rotation (see fn docs).
168        let m_ref = to_post_rtc(compose_world(template.instance_meta.unwrap()), rtc);
169        // Computed BEFORE the threshold check: every refusal below needs it to
170        // place the group's pose-only placeholders (see `fall_back`).
171        let m_ref_inv = m_ref.try_inverse();
172
173        // The template is the geometry EVERY occurrence in this group will be
174        // drawn with, and it is the one member nothing checks: it pairs with
175        // itself trivially (the `i == t_idx` short-circuit below), while
176        // pose-only and rigid members are exempt by design. So a group of a
177        // non-finite template plus placeholders had no member the reconstruction
178        // check ever looked at, and substituted the NaN geometry once per
179        // occurrence. Check it once, here, and refuse the group — with `None`,
180        // because placing anything against untrustworthy geometry is the thing
181        // being prevented.
182        if !template.positions.iter().all(|p| p.is_finite()) {
183            // The check cannot move below the threshold: a below-threshold group
184            // with pose-only members is still instanced (that is what
185            // `fall_back` does with a valid `m_ref_inv`), so deferring it would
186            // put the NaN template back into exactly those occurrences. What the
187            // threshold DOES decide is whether the refusal changed anything —
188            // a group that was going flat regardless was never refused a
189            // template, so counting it would inflate the figure.
190            if members.len() >= min_group.max(1)
191                || members.iter().any(|&i| meshes[i].positions.is_empty())
192            {
193                out.verification_rejections += 1;
194            }
195            fall_back(&mut out, meshes, rep, members, t_idx, None, rtc, emitted_basis);
196            continue;
197        }
198        if members.len() < min_group.max(1) {
199            fall_back(&mut out, meshes, rep, members, t_idx, m_ref_inv.as_ref(), rtc, emitted_basis);
200            continue;
201        }
202        let Some(m_ref_inv) = m_ref_inv else {
203            // A singular template placement is the one refusal that genuinely
204            // cannot place a placeholder: there is no `rel` to compute for
205            // anyone. `fall_back` counts what it has to drop.
206            fall_back(&mut out, meshes, rep, members, t_idx, None, rtc, emitted_basis);
207            continue;
208        };
209
210        // Rigidity is a PER-MEMBER property, not a group-level one: a
211        // rep_identity group can legitimately mix a rigid-tier member
212        // (rotation-normalized, congruent but NOT bit-identical — the
213        // renderer substitutes the template's geometry at its pose, so
214        // rel_k is pose-only) alongside exact-tier members that ARE
215        // bit-identical to the template. Gating the count check / pairing
216        // verification on whether ANY member of the group is rigid would
217        // skip both for every non-rigid member too, just because one
218        // sibling happens to be rigid — check each member's own
219        // `canonical_transform` instead.
220        let vlen = template.positions.len();
221        let mut occurrences = Vec::with_capacity(members.len());
222        let mut shapes_match = true;
223        for &i in members {
224            let mesh = &meshes[i];
225            if i == t_idx {
226                // The template against itself: the shape check compares it with
227                // its own buffers and `rel` is the identity, so both checks are
228                // tautologies. Emit the identity occurrence without walking the
229                // template's vertices a second time.
230                occurrences.push(InstanceOccurrence {
231                    mesh_index: i,
232                    transform: mat4_to_row_major_f32(&(m_ref * m_ref_inv)),
233                });
234                continue;
235            }
236            let member_is_rigid = mesh
237                .instance_meta
238                .and_then(|m| m.canonical_transform)
239                .is_some();
240            // A #1623 Phase 3 don't-bake placeholder (empty geometry) is pose-only:
241            // its geometry IS the template, so skip the same-shape guard (like the
242            // rigid tier). Exact-tier materialized occurrences share the SAME local
243            // geometry, differing only by placement — their BAKED positions
244            // legitimately differ (verified below against `rel` instead), but their
245            // TOPOLOGY does not: baking transforms positions and never rewrites the
246            // index buffer, so a genuine group's indices are byte-identical. Compare
247            // them outright, not just their length — identical positions under a
248            // different connectivity would be handed the template's topology.
249            let pose_only = mesh.positions.is_empty();
250            if !pose_only
251                && !member_is_rigid
252                && (mesh.positions.len() != vlen || mesh.indices != template.indices)
253            {
254                shapes_match = false;
255                break;
256            }
257            let m_k = to_post_rtc(compose_world(mesh.instance_meta.unwrap()), rtc);
258            // `rel` in the frame the BAKED vertices are in. The check below and
259            // the occurrence emitted at the bottom are handed the SAME matrix,
260            // so "verified" and "shipped" cannot describe different frames.
261            let rel_native = m_k * m_ref_inv;
262            let rel_baked = basis_conjugate
263                .as_ref()
264                .map_or(rel_native, |(s, s_inv)| s * rel_native * s_inv);
265            // #3666: a shared `rep_identity` is not proof of shared geometry —
266            // a 128-bit direct-geometry hash collision has been measured on a
267            // real merged model. The count check above still lets a same-
268            // shaped colliding pair through, so reconstruct this occurrence
269            // from (template, rel) and verify it against its OWN baked
270            // vertices before trusting the pairing. Scoped to the exact tier
271            // (rigid-tier members can legitimately carry a different raw
272            // vertex count than the template by design — see module docs).
273            if !pose_only
274                && !member_is_rigid
275                && !verify_pairing(
276                    template.origin,
277                    template.positions,
278                    mesh.origin,
279                    mesh.positions,
280                    &rel_baked,
281                )
282            {
283                shapes_match = false;
284                break;
285            }
286            occurrences.push(InstanceOccurrence {
287                mesh_index: i,
288                transform: mat4_to_row_major_f32(if emit_in_basis {
289                    &rel_baked
290                } else {
291                    &rel_native
292                }),
293            });
294        }
295
296        if shapes_match {
297            out.templates.push(InstanceTemplate {
298                rep_identity: rep,
299                template_index: t_idx,
300                occurrences,
301            });
302            continue;
303        }
304
305        out.verification_rejections += 1;
306        fall_back(&mut out, meshes, rep, members, t_idx, Some(&m_ref_inv), rtc, emitted_basis);
307    }
308    out
309}
310
311/// What a group that will NOT be instanced as a whole leaves behind.
312///
313/// Every materialized member draws itself, flat. But a #1623 Phase 3 don't-bake
314/// placeholder (empty geometry, placement only) has nothing of its own to draw:
315/// the template IS its geometry. Routing the whole group flat dropped those
316/// placeholders entirely — neither instanced nor drawn, the occurrence gone from
317/// the output with nothing reporting it.
318///
319/// A placeholder is also never the member that failed: with no baked vertices it
320/// can never be verified in either direction, and the template is the only
321/// geometry it could ever be drawn with (before the #3666 check existed, that is
322/// exactly what it got). So when `place_with` (the template's inverse placement)
323/// exists, the template and its placeholders stay instanced and only the
324/// stand-alone members are flattened. When it does not, nothing can be placed,
325/// and the placeholders are counted rather than quietly discarded.
326fn fall_back(
327    out: &mut Collated,
328    meshes: &[InstanceMeshRef],
329    rep: u128,
330    members: &[usize],
331    t_idx: usize,
332    place_with: Option<&Matrix4<f64>>,
333    rtc: [f64; 3],
334    // The same `(B, B⁻¹)` the success path conjugates by. A refused group still
335    // EMITS occurrences (the template and its pose-only placeholders), so
336    // leaving them native here would ship a native `rel` against baked vertices
337    // on exactly the path nothing verifies.
338    basis_conjugate: Option<&(Matrix4<f64>, Matrix4<f64>)>,
339) {
340    let pose_only: Vec<usize> = members
341        .iter()
342        .copied()
343        .filter(|&i| meshes[i].positions.is_empty())
344        .collect();
345    let materialized = || members.iter().copied().filter(|&i| !meshes[i].positions.is_empty());
346    let Some(m_ref_inv) = place_with.filter(|_| !pose_only.is_empty()) else {
347        out.dropped_placeholders += pose_only.len();
348        out.flat_indices.extend(materialized());
349        return;
350    };
351    // The template rides along as its own occurrence (identity `rel`, exactly as
352    // on the success path) rather than going flat, so its geometry is uploaded
353    // once and drawn once.
354    let kept: Vec<InstanceOccurrence> = std::iter::once(t_idx)
355        .chain(pose_only)
356        .map(|i| {
357            let rel = to_post_rtc(compose_world(meshes[i].instance_meta.unwrap()), rtc) * m_ref_inv;
358            let rel = basis_conjugate.map_or(rel, |(s, s_inv)| s * rel * s_inv);
359            InstanceOccurrence {
360                mesh_index: i,
361                transform: mat4_to_row_major_f32(&rel),
362            }
363        })
364        .collect();
365    out.templates.push(InstanceTemplate {
366        rep_identity: rep,
367        template_index: t_idx,
368        occurrences: kept,
369    });
370    out.flat_indices.extend(materialized().filter(|&i| i != t_idx));
371}
372
373/// Every mesh that carries geometry, rendered flat: the whole-input fallback when
374/// collation is refused before any group is examined. Empty (pose-only) members
375/// cannot go flat — they have nothing to draw on their own and exist only as
376/// occurrences of a template, and this result has none — so they are counted in
377/// `dropped_placeholders` exactly as `fall_back` counts the ones it cannot place.
378/// Reporting the loss is the whole point of that field: an occurrence going
379/// missing must not look like an occurrence that was never fed in.
380fn all_drawable_flat(meshes: &[InstanceMeshRef]) -> Collated {
381    Collated {
382        flat_indices: (0..meshes.len())
383            .filter(|&i| !meshes[i].positions.is_empty())
384            .collect(),
385        // A placeholder is an EMPTY mesh with instanceable meta; an empty mesh
386        // without it carries nothing and was never an occurrence, so it is not a
387        // loss to report (the grouping loop skips it the same way).
388        dropped_placeholders: meshes
389            .iter()
390            .filter(|m| {
391                m.positions.is_empty() && m.instance_meta.is_some_and(|im| im.instanceable)
392            })
393            .count(),
394        ..Collated::default()
395    }
396}