Skip to main content

ifc_lite_processing/processor/
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//! #1623 Phase 2 "don't-bake" finalize: turn the geometry phase's collected
6//! [`RawInstanceOccurrence`]s into [`InstanceRecord`]s against the retained template
7//! meshes, recovering any (effectively unreachable) orphan from the shared source
8//! registry so geometry is never silently lost.
9
10use crate::types::mesh::{InstanceRecord, MeshData, RawInstanceOccurrence};
11use rustc_hash::FxHashMap;
12
13/// Resolve the collected don't-bake occurrences into [`InstanceRecord`]s against the
14/// retained template meshes. `meshes` is mutated ONLY by APPENDING recovered orphan
15/// flats — templates and every other mesh are left untouched (byte-identical) — so
16/// this is a no-op on the flat path (`raw` empty).
17pub(super) fn finalize_instances(
18    raw: Vec<RawInstanceOccurrence>,
19    meshes: &mut Vec<MeshData>,
20    mapped_item_cache: &ifc_lite_geometry::SharedMappedItemCache,
21    rtc: [f64; 3],
22) -> Vec<InstanceRecord> {
23    if raw.is_empty() {
24        return Vec::new();
25    }
26    // Group occurrences by shared-template key (IfcRepresentationMap id).
27    let mut by_source: FxHashMap<u128, Vec<RawInstanceOccurrence>> = FxHashMap::default();
28    for occ in raw {
29        by_source.entry(occ.rep_identity).or_default().push(occ);
30    }
31    // rep_identity ⇒ the template mesh (a retained, non-empty occurrence carrying
32    // this rep_identity in its InstanceMeta — the min-id occurrence that materialized).
33    let mut template_by_rep: FxHashMap<u128, usize> = FxHashMap::default();
34    for (i, m) in meshes.iter().enumerate() {
35        if m.positions.is_empty() {
36            continue;
37        }
38        if let Some(im) = m.instance.as_ref() {
39            if im.instanceable {
40                template_by_rep.entry(im.rep_identity).or_insert(i);
41            }
42        }
43    }
44
45    let mut records: Vec<InstanceRecord> = Vec::new();
46    let mut orphan_flats: Vec<MeshData> = Vec::new();
47    // Deterministic output order: sort the source groups by id.
48    let mut groups: Vec<(u128, Vec<RawInstanceOccurrence>)> = by_source.into_iter().collect();
49    groups.sort_by_key(|(rep, _)| *rep);
50    for (rep, occs) in groups {
51        // Template present (the common, expected case): emit template-relative records.
52        if let Some(&t_idx) = template_by_rep.get(&rep) {
53            if let Some(im) = meshes[t_idx].instance.as_ref() {
54                let m_ref = ifc_lite_geometry::compose_instance_world_row_major(im);
55                let template_express_id = meshes[t_idx].express_id;
56                let mut batch = Vec::with_capacity(occs.len());
57                let mut all_ok = true;
58                for occ in &occs {
59                    match ifc_lite_geometry::instance_rel_row_major_f32(
60                        &occ.world_transform,
61                        &m_ref,
62                        rtc,
63                    ) {
64                        Some(transform) => batch.push(InstanceRecord {
65                            express_id: occ.express_id,
66                            ifc_type: occ.ifc_type.clone(),
67                            global_id: occ.global_id.clone(),
68                            name: occ.name.clone(),
69                            presentation_layer: occ.presentation_layer.clone(),
70                            color: occ.color,
71                            template_express_id,
72                            rep_identity: rep,
73                            transform,
74                            geometry_item_id: occ.geometry_item_id,
75                        }),
76                        // Singular m_ref (degenerate placement) ⇒ recover flat instead.
77                        None => {
78                            all_ok = false;
79                            break;
80                        }
81                    }
82                }
83                if all_ok {
84                    records.extend(batch);
85                    continue;
86                }
87            }
88        }
89        // Orphan / degenerate recovery: reconstruct each occurrence as a flat mesh
90        // from the shared source registry. Effectively unreachable for the eligible
91        // single-solid type-instanced set (their template occurrence always
92        // materializes), but guarantees no geometry is ever dropped.
93        if !recover_occurrences_flat(rep, &occs, mapped_item_cache, rtc, &mut orphan_flats) {
94            tracing::warn!(
95                source_id = rep as u32,
96                occurrences = occs.len(),
97                "instancing: orphan mapped source missing from registry; occurrences dropped"
98            );
99        }
100    }
101    // The occurrences arrived in parallel-collection (nondeterministic) order; sort
102    // both outputs by element id so the instanced result is deterministic run to run.
103    records.sort_by_key(|r| (r.express_id, r.rep_identity));
104    orphan_flats.sort_by_key(|m| m.express_id);
105    meshes.append(&mut orphan_flats);
106    records
107}
108
109/// Rebuild each don't-bake occurrence of `rep` as a standalone flat [`MeshData`],
110/// baked from the shared mapped-source registry at the occurrence's world
111/// transform (post-RTC) — geometrically identical to the occurrence the flat path
112/// would have produced. No instancing benefit, but correct and never a silent loss.
113///
114/// ONE home for the occurrence → `MeshData` mapping. The native finalize below and
115/// the browser batch's `resolve_batch_occurrences`
116/// (`rust/wasm-bindings/src/api/gpu_meshes/instancing.rs`) are the same recovery on
117/// two targets, and were near-verbatim clones: the same lookup, the same bake, the
118/// same empty check, the same `MeshData` literal. #2985's dropped item id had to be
119/// found, fixed and tested twice because of that. The next per-occurrence field is
120/// added here once.
121///
122/// Returns `false` when `rep`'s source is absent from the registry, so nothing was
123/// recovered. That is the ONLY thing the two callers differ on, and it stays at the
124/// call sites: the native side logs it, the browser side has no tracing sink and
125/// cannot reach the case anyway.
126pub fn recover_occurrences_flat(
127    rep: u128,
128    occs: &[RawInstanceOccurrence],
129    mapped_item_cache: &ifc_lite_geometry::SharedMappedItemCache,
130    rtc: [f64; 3],
131    out: &mut Vec<MeshData>,
132) -> bool {
133    // Mapped rep_identity is the RepresentationMap source id (always < 2^32); a
134    // direct-solid tag never reaches this don't-bake path.
135    let source_id = rep as u32;
136    let source = mapped_item_cache
137        .lock()
138        .unwrap_or_else(|e| e.into_inner())
139        .get(&source_id)
140        .cloned();
141    let Some(source) = source else {
142        return false;
143    };
144    for occ in occs {
145        let (positions, normals, indices) =
146            ifc_lite_geometry::bake_source_at_world(&source, &occ.world_transform, rtc);
147        if positions.is_empty() || indices.is_empty() {
148            continue;
149        }
150        out.push(
151            MeshData::new(
152                occ.express_id,
153                occ.ifc_type.clone(),
154                positions,
155                normals,
156                indices,
157                occ.color,
158            )
159            .with_element_metadata(
160                occ.global_id.clone(),
161                occ.name.clone(),
162                occ.presentation_layer.clone(),
163            )
164            // #2985: a recovered occurrence renders FLAT, so its item id has to
165            // ride the mesh rather than the shard — without this the one path
166            // that produces no instance record also reports no source item, and
167            // the absence is indistinguishable from "this geometry has none".
168            // `false` = the id is a representation item, never a material (#3199).
169            .with_style_metadata(None, occ.geometry_item_id, false),
170        );
171    }
172    true
173}
174
175#[cfg(test)]
176mod orphan_recovery_tests {
177    //! #2985. The orphan branch is the ONE path here that emits no
178    //! [`InstanceRecord`], so it is the one place the item id cannot ride the
179    //! record and has to ride the recovered `MeshData` instead. Left unwired it
180    //! would fail silently: a consumer cannot tell "this occurrence lost its id"
181    //! from "this geometry has no item", which is exactly how the id was being
182    //! dropped before.
183    use super::*;
184    use std::sync::{Arc, Mutex};
185
186    const REP: u128 = 4242;
187    const ITEM_ID: u32 = 4638;
188
189    fn occurrence(express_id: u32, geometry_item_id: Option<u32>) -> RawInstanceOccurrence {
190        RawInstanceOccurrence {
191            express_id,
192            ifc_type: "IfcFlowFitting".to_string(),
193            global_id: None,
194            name: None,
195            presentation_layer: None,
196            color: [1.0, 1.0, 1.0, 1.0],
197            rep_identity: REP,
198            world_transform: [
199                1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
200            ],
201            geometry_item_id,
202        }
203    }
204
205    /// A registered source is REQUIRED: with an empty registry `recover_occurrences_flat`
206    /// returns before building any `MeshData`, and an assertion over zero recovered
207    /// meshes would pass no matter what the field held.
208    fn cache_with_source() -> ifc_lite_geometry::SharedMappedItemCache {
209        let mut source = ifc_lite_geometry::Mesh::new();
210        source.positions = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
211        source.normals = vec![0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
212        source.indices = vec![0, 1, 2];
213        let map: FxHashMap<u32, Arc<ifc_lite_geometry::Mesh>> =
214            [(REP as u32, Arc::new(source))].into_iter().collect();
215        Arc::new(Mutex::new(map))
216    }
217
218    #[test]
219    fn an_orphan_recovered_flat_reports_its_item_id() {
220        // No mesh carries this rep_identity ⇒ no template ⇒ the orphan branch.
221        let mut meshes: Vec<MeshData> = Vec::new();
222        let records = finalize_instances(
223            vec![occurrence(7, Some(ITEM_ID))],
224            &mut meshes,
225            &cache_with_source(),
226            [0.0, 0.0, 0.0],
227        );
228
229        assert!(records.is_empty(), "an orphan emits no InstanceRecord");
230        assert_eq!(meshes.len(), 1, "the orphan must be recovered flat, not dropped");
231        assert_eq!(
232            meshes[0].geometry_item_id,
233            Some(ITEM_ID),
234            "the recovered orphan lost the item id the occurrence carried"
235        );
236        // #3199 disjointness: the id is a representation item, never a material.
237        assert_eq!(meshes[0].material_id, None);
238    }
239
240    #[test]
241    fn an_orphan_with_no_item_stays_absent_rather_than_reporting_zero() {
242        // `with_style_metadata` filters a 0 source id to None on both fields; a
243        // recovered occurrence that genuinely had no item must land there too,
244        // not on a fabricated `#0` a host would follow to nothing.
245        let mut meshes: Vec<MeshData> = Vec::new();
246        finalize_instances(
247            vec![occurrence(7, None)],
248            &mut meshes,
249            &cache_with_source(),
250            [0.0, 0.0, 0.0],
251        );
252        assert_eq!(meshes.len(), 1);
253        assert_eq!(meshes[0].geometry_item_id, None);
254        assert_eq!(meshes[0].material_id, None);
255    }
256}