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}