ifc_lite_geometry/instancing/collate.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
5use crate::mesh::{InstanceMeta, Mesh};
6use nalgebra::Matrix4;
7use rustc_hash::FxHashMap;
8
9const IDENTITY16: [f64; 16] = [
10 1.0, 0.0, 0.0, 0.0, //
11 0.0, 1.0, 0.0, 0.0, //
12 0.0, 0.0, 1.0, 0.0, //
13 0.0, 0.0, 0.0, 1.0, //
14];
15
16/// Full world transform `transform · local_transform` for an occurrence.
17fn compose_world(meta: &InstanceMeta) -> Matrix4<f64> {
18 let t = Matrix4::from_row_slice(&meta.transform);
19 let l = Matrix4::from_row_slice(meta.local_transform.as_ref().unwrap_or(&IDENTITY16));
20 // Rigid tier: canonical->local transform, composed innermost. For occurrences
21 // grouped by congruence (not bit-identity) this carries the recovered rotation
22 // so the shared template reproduces this occurrence's baked geometry.
23 let c = Matrix4::from_row_slice(meta.canonical_transform.as_ref().unwrap_or(&IDENTITY16));
24 t * l * c
25}
26
27/// Flatten a column-major nalgebra matrix into a row-major `[f32; 16]`.
28pub(super) fn mat4_to_row_major_f32(m: &Matrix4<f64>) -> [f32; 16] {
29 let mut out = [0.0f32; 16];
30 for r in 0..4 {
31 for c in 0..4 {
32 out[r * 4 + c] = m[(r, c)] as f32;
33 }
34 }
35 out
36}
37
38/// One occurrence of a template geometry.
39#[derive(Debug, Clone)]
40pub struct InstanceOccurrence {
41 /// Index of the original mesh in the input slice (carries entity id / colour).
42 pub mesh_index: usize,
43 /// Row-major mat4 mapping the template's baked world geometry onto this
44 /// occurrence. The template occurrence's transform is identity.
45 pub transform: [f32; 16],
46}
47
48/// A unique geometry shared by two or more occurrences.
49#[derive(Debug, Clone)]
50pub struct InstanceTemplate {
51 /// Representation-identity key (RepresentationMap id for mapped items).
52 pub rep_identity: u128,
53 /// Index of the mesh whose geometry is the template to upload.
54 pub template_index: usize,
55 /// Every occurrence (including the template itself, with identity transform).
56 pub occurrences: Vec<InstanceOccurrence>,
57}
58
59/// Result of collation: instanced templates + the meshes left to render flat.
60#[derive(Debug, Clone, Default)]
61pub struct Collated {
62 /// Unique geometries with their per-instance transforms.
63 pub templates: Vec<InstanceTemplate>,
64 /// Indices of input meshes rendered without instancing (non-instanceable,
65 /// singleton groups, or groups that failed the geometry-shape guard).
66 pub flat_indices: Vec<usize>,
67}
68
69impl Collated {
70 /// Total number of unique geometries that would be uploaded (templates +
71 /// flat meshes) — the figure that bounds browser ingestion.
72 pub fn unique_geometry_count(&self) -> usize {
73 self.templates.len() + self.flat_indices.len()
74 }
75
76 /// Total occurrences represented across all templates (excludes flat meshes).
77 pub fn instanced_occurrence_count(&self) -> usize {
78 self.templates.iter().map(|t| t.occurrences.len()).sum()
79 }
80}
81
82/// A borrowed view of a mesh for collation/encoding — lets callers feed geometry
83/// from any owner (geometry's `Mesh`, processing's `MeshData`) WITHOUT cloning the
84/// vertex data (cloning 219k meshes' geometry risks the build-container OOM).
85pub struct InstanceMeshRef<'a> {
86 pub positions: &'a [f32],
87 pub normals: &'a [f32],
88 pub indices: &'a [u32],
89 pub origin: [f64; 3],
90 pub instance_meta: Option<&'a InstanceMeta>,
91 /// Per-occurrence entity id (used only by the encoder).
92 pub entity_id: u32,
93 /// Per-occurrence RGBA (used only by the encoder).
94 pub color: [f32; 4],
95}
96
97impl<'a> InstanceMeshRef<'a> {
98 /// Build a view over a geometry `Mesh` (encoder id/colour default to 0).
99 pub fn from_mesh(m: &'a Mesh) -> Self {
100 InstanceMeshRef {
101 positions: &m.positions,
102 normals: &m.normals,
103 indices: &m.indices,
104 origin: m.origin,
105 instance_meta: m.instance_meta.as_ref(),
106 entity_id: 0,
107 color: [0.0; 4],
108 }
109 }
110}
111
112/// Subtract the model RTC offset from a composed (pre-RTC) world transform's
113/// translation column, giving the post-RTC placement that matches the post-RTC
114/// per-mesh `origin` the renderer applies the relative transform to.
115fn to_post_rtc(mut m: Matrix4<f64>, rtc: [f64; 3]) -> Matrix4<f64> {
116 m[(0, 3)] -= rtc[0];
117 m[(1, 3)] -= rtc[1];
118 m[(2, 3)] -= rtc[2];
119 m
120}
121
122/// Group instanceable meshes by representation identity into templates +
123/// per-instance transforms. `min_group` is the smallest occurrence count worth
124/// instancing (groups below it are emitted flat); use 2 to instance any repeat.
125///
126/// `rtc` is the model RTC offset (`InstanceMeta.transform` is documented pre-RTC
127/// at georeferenced magnitude, while each mesh's baked `origin` is post-RTC and
128/// small). The per-occurrence relative transform is computed in the post-RTC
129/// frame: on raw absolute placements `rel = m_k · m_ref⁻¹` lets a *rotated*
130/// occurrence's translation reach `T_k − R_rel·T_ref ≈ 2× rtc` (the two ~1e6 m
131/// terms add instead of cancel), which then places the occurrence at twice the
132/// georeference and collapses f32 GLB exports. Reducing both transforms by `rtc`
133/// first keeps `rel.translation` at building scale and consistent with the small
134/// template origin.
135pub fn collate_refs(meshes: &[InstanceMeshRef], min_group: usize, rtc: [f64; 3]) -> Collated {
136 // First-seen order keeps output deterministic regardless of hash iteration.
137 let mut order: Vec<u128> = Vec::new();
138 let mut groups: FxHashMap<u128, Vec<usize>> = FxHashMap::default();
139 // Non-instanceable meshes (void-cut walls, multi-item merges, site-rotated
140 // elements — anything carrying no usable InstanceMeta) still must be DRAWN, so
141 // they're routed to flat_indices and emitted as flat singleton templates.
142 // Dropping them here would silently lose geometry now that capture is always-on
143 // and real models feed the collator — the unit fixtures were all instanceable,
144 // which hid this. A non-empty non-instanceable mesh goes flat; an EMPTY mesh
145 // carries nothing to draw UNLESS it is a #1623 Phase 3 don't-bake occurrence
146 // placeholder (empty geometry + instanceable InstanceMeta) — the shared template
147 // supplies its geometry, so it joins its rep group like a materialized member.
148 let mut flat: Vec<usize> = Vec::new();
149 for (i, m) in meshes.iter().enumerate() {
150 match m.instance_meta {
151 Some(im) if im.instanceable => {
152 groups
153 .entry(im.rep_identity)
154 .or_insert_with(|| {
155 order.push(im.rep_identity);
156 Vec::new()
157 })
158 .push(i);
159 }
160 // Empty + non-instanceable = nothing to draw (skip); non-empty +
161 // non-instanceable = flat singleton.
162 _ if !m.positions.is_empty() => flat.push(i),
163 _ => {}
164 }
165 }
166
167 // Route only DRAWABLE members flat: an empty don't-bake placeholder carries no
168 // geometry, so it can never render flat — the caller (the wasm don't-bake
169 // finalize) recovers such occurrences flat itself before collation, so a group
170 // it feeds here always has a materialized template and passes the count gate.
171 // Filtering defends against ever silently emitting an empty flat singleton.
172 let drawable = |members: &[usize]| -> Vec<usize> {
173 members
174 .iter()
175 .copied()
176 .filter(|&i| !meshes[i].positions.is_empty())
177 .collect::<Vec<_>>()
178 };
179
180 let mut out = Collated {
181 flat_indices: flat,
182 ..Collated::default()
183 };
184 for rep in order {
185 let members = &groups[&rep];
186 if members.len() < min_group.max(1) {
187 out.flat_indices.extend(drawable(members));
188 continue;
189 }
190 // Template = the first NON-EMPTY (materialized) member. Empty members are
191 // don't-bake placeholders whose geometry IS this template.
192 let Some(t_idx) = members
193 .iter()
194 .copied()
195 .find(|&i| !meshes[i].positions.is_empty())
196 else {
197 // All-empty group: no template geometry to draw against. Unreachable by
198 // the caller contract (a materialized template always accompanies its
199 // occurrences); drop rather than emit empty singletons that draw nothing.
200 continue;
201 };
202 let template = &meshes[t_idx];
203 // Compose in the post-RTC frame so the georeferenced offset cancels
204 // exactly regardless of each occurrence's rotation (see fn docs).
205 let m_ref = to_post_rtc(compose_world(template.instance_meta.unwrap()), rtc);
206 let Some(m_ref_inv) = m_ref.try_inverse() else {
207 out.flat_indices.extend(drawable(members));
208 continue;
209 };
210
211 // A rigid-tier group (rotation-normalized) holds occurrences that are
212 // congruent but NOT bit-identical, so their raw vertex counts can differ —
213 // the renderer substitutes the template's geometry at each occurrence's
214 // pose (rel_k is pose-only). The exact-bit tier keeps the defensive
215 // same-count check (a mismatch there means something is wrong).
216 let is_rigid = members
217 .iter()
218 .any(|&i| meshes[i].instance_meta.and_then(|m| m.canonical_transform).is_some());
219 let (vlen, ilen) = (template.positions.len(), template.indices.len());
220 let mut occurrences = Vec::with_capacity(members.len());
221 let mut shapes_match = true;
222 for &i in members {
223 let mesh = &meshes[i];
224 // A #1623 Phase 3 don't-bake placeholder (empty geometry) is pose-only:
225 // its geometry IS the template, so skip the same-shape guard (like the
226 // rigid tier). Exact-tier materialized occurrences share the SAME local
227 // geometry (so same counts), differing only by placement — we can't
228 // byte-compare their BAKED positions (those legitimately differ).
229 // Content-equality is guaranteed upstream: rep_identity is a FULL 128-bit
230 // content hash (or the RepresentationMap id), so a same-counts/
231 // different-content collision is ~2^-127. The count check stays a cheap
232 // guard. Rigid-tier occurrences are intentionally non-identical (verified).
233 let pose_only = mesh.positions.is_empty();
234 if !pose_only
235 && !is_rigid
236 && (mesh.positions.len() != vlen || mesh.indices.len() != ilen)
237 {
238 shapes_match = false;
239 break;
240 }
241 let m_k = to_post_rtc(compose_world(mesh.instance_meta.unwrap()), rtc);
242 let rel = m_k * m_ref_inv;
243 occurrences.push(InstanceOccurrence {
244 mesh_index: i,
245 transform: mat4_to_row_major_f32(&rel),
246 });
247 }
248
249 if shapes_match {
250 out.templates.push(InstanceTemplate {
251 rep_identity: rep,
252 template_index: t_idx,
253 occurrences,
254 });
255 } else {
256 out.flat_indices.extend(drawable(members));
257 }
258 }
259 out
260}
261
262/// Compose an occurrence's full PRE-RTC world transform `transform·local·canonical`
263/// as a row-major `[f64; 16]`. Public so the processing crate's don't-bake finalize
264/// (#1623 Phase 2) can record the SAME world placement `collate_refs` computes for a
265/// baked occurrence — without materializing the occurrence's vertices.
266pub fn compose_instance_world_row_major(meta: &InstanceMeta) -> [f64; 16] {
267 let m = compose_world(meta);
268 let mut out = [0.0f64; 16];
269 for r in 0..4 {
270 for c in 0..4 {
271 out[r * 4 + c] = m[(r, c)];
272 }
273 }
274 out
275}
276
277/// Template-relative instance transform `rel = post_rtc(M_k) · post_rtc(M_ref)⁻¹`
278/// as a row-major `[f32; 16]`, or `None` when `M_ref` is singular. `m_k` / `m_ref`
279/// are PRE-RTC row-major world transforms (see [`compose_instance_world_row_major`]);
280/// `rtc` is the model offset. This is EXACTLY `collate_refs`' per-occurrence `rel`,
281/// exposed for the don't-bake finalize where the occurrence carries no geometry to
282/// group — the template's baked world geometry placed by `rel` reproduces the
283/// occurrence's world geometry (bounded by `verify_recomposition`). #1623 Phase 2.
284pub fn instance_rel_row_major_f32(
285 m_k: &[f64; 16],
286 m_ref: &[f64; 16],
287 rtc: [f64; 3],
288) -> Option<[f32; 16]> {
289 let mk = to_post_rtc(Matrix4::from_row_slice(m_k), rtc);
290 let mref = to_post_rtc(Matrix4::from_row_slice(m_ref), rtc);
291 let mref_inv = mref.try_inverse()?;
292 Some(mat4_to_row_major_f32(&(mk * mref_inv)))
293}
294
295/// Bake a SOURCE-coords `Mesh` at a PRE-RTC row-major world transform into absolute
296/// POST-RTC world geometry `(positions, normals, indices)` — the #1623 Phase 2
297/// finalize fallback for a don't-bake instance whose template occurrence never
298/// materialized (an orphan; effectively unreachable for the eligible single-solid
299/// type-instanced set, but kept so geometry is NEVER silently lost). The affine part
300/// transforms positions; the inverse-transpose of the linear part transforms normals
301/// (renormalized). Geometrically equal to the baked flat occurrence (same triangles);
302/// the registry source is pre-weld, so vertices are unwelded — that changes only the
303/// vertex count, not the rendered surface.
304pub fn bake_source_at_world(
305 source: &Mesh,
306 world_row_major: &[f64; 16],
307 rtc: [f64; 3],
308) -> (Vec<f32>, Vec<f32>, Vec<u32>) {
309 let m = to_post_rtc(Matrix4::from_row_slice(world_row_major), rtc);
310 let vcount = source.positions.len() / 3;
311 let mut positions = Vec::with_capacity(source.positions.len());
312 for v in 0..vcount {
313 let p = m * nalgebra::Vector4::new(
314 source.positions[v * 3] as f64,
315 source.positions[v * 3 + 1] as f64,
316 source.positions[v * 3 + 2] as f64,
317 1.0,
318 );
319 positions.push((p.x / p.w) as f32);
320 positions.push((p.y / p.w) as f32);
321 positions.push((p.z / p.w) as f32);
322 }
323 let linear = m.fixed_view::<3, 3>(0, 0).into_owned();
324 let nmat = linear
325 .try_inverse()
326 .map(|inv| inv.transpose())
327 .unwrap_or(linear);
328 let ncount = source.normals.len() / 3;
329 let mut normals = Vec::with_capacity(source.normals.len());
330 for v in 0..ncount {
331 let nv = nmat
332 * nalgebra::Vector3::new(
333 source.normals[v * 3] as f64,
334 source.normals[v * 3 + 1] as f64,
335 source.normals[v * 3 + 2] as f64,
336 );
337 let nv = nv.try_normalize(0.0).unwrap_or(nv);
338 normals.push(nv.x as f32);
339 normals.push(nv.y as f32);
340 normals.push(nv.z as f32);
341 }
342 (positions, normals, source.indices.clone())
343}
344
345/// `collate_refs` over geometry `Mesh` values (thin wrapper, no geometry clone).
346pub fn collate_instances(meshes: &[Mesh], min_group: usize, rtc: [f64; 3]) -> Collated {
347 let refs: Vec<InstanceMeshRef> = meshes.iter().map(InstanceMeshRef::from_mesh).collect();
348 collate_refs(&refs, min_group, rtc)
349}
350
351/// Maximum per-vertex world-space error (in mesh units) when each occurrence is
352/// reconstructed by applying its instance transform to the template's baked
353/// world geometry, versus the occurrence's own baked world geometry. The
354/// template-relative transform operates on world coords, so each mesh's `origin`
355/// is folded in. Used by tests + as a runtime diagnostic.
356pub fn verify_recomposition(meshes: &[Mesh], collated: &Collated) -> f64 {
357 let mut max_err = 0.0f64;
358 for tmpl in &collated.templates {
359 let template = &meshes[tmpl.template_index];
360 for occ in &tmpl.occurrences {
361 let target = &meshes[occ.mesh_index];
362 let rel = Matrix4::from_row_slice(&occ.transform.map(|v| v as f64));
363 // A valid template↔occurrence pair shares the same geometry (same
364 // vertex count, different transform). If the counts differ the
365 // occurrence can't be recomposed from the template — flag it as an
366 // unbounded error instead of panicking on an out-of-bounds index,
367 // so the diagnostic surfaces the mismatch. (#1238 review)
368 let n = template.positions.len() / 3;
369 if target.positions.len() / 3 != n {
370 max_err = f64::INFINITY;
371 continue;
372 }
373 for v in 0..n {
374 // Template world vertex = template.origin + position.
375 let tx = template.origin[0] + template.positions[v * 3] as f64;
376 let ty = template.origin[1] + template.positions[v * 3 + 1] as f64;
377 let tz = template.origin[2] + template.positions[v * 3 + 2] as f64;
378 let w = rel * nalgebra::Vector4::new(tx, ty, tz, 1.0);
379 let (rx, ry, rz) = (w.x / w.w, w.y / w.w, w.z / w.w);
380 // Target world vertex.
381 let gx = target.origin[0] + target.positions[v * 3] as f64;
382 let gy = target.origin[1] + target.positions[v * 3 + 1] as f64;
383 let gz = target.origin[2] + target.positions[v * 3 + 2] as f64;
384 let err = ((rx - gx).powi(2) + (ry - gy).powi(2) + (rz - gz).powi(2)).sqrt();
385 if err > max_err {
386 max_err = err;
387 }
388 }
389 }
390 }
391 max_err
392}