Skip to main content

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}