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}