ifc_lite_wasm/api/gpu_meshes/batch.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 super::void_index::reconstruct_void_index;
6use crate::api::IfcAPI;
7use crate::zero_copy::{GeometryFingerprint, MeshCollection, MeshDataJs};
8use wasm_bindgen::prelude::*;
9
10/// Per-element output of [`IfcAPI::produce_batch`] — the canonical producer's
11/// meshes (with `instance` metadata intact, BEFORE the MeshDataJs Z-up→Y-up
12/// swap) plus everything the element's geometry-hash pass measured. The flat
13/// path converts each to MeshDataJs; the instanced path collates them into an
14/// IFNS shard.
15struct ElementMeshOutput {
16 id: u32,
17 meshes: Vec<ifc_lite_processing::MeshData>,
18 geometry_hash: Option<u64>,
19 /// World AABB from the same hashing pass, `Some` exactly when
20 /// `geometry_hash` is (see `ProducedElementMeshes::geometry_aabb`).
21 geometry_aabb: Option<[f64; 6]>,
22 /// Enclosed volume in m³, `Some` only for a provably closed single solid
23 /// (see `ProducedElementMeshes::geometry_volume`). `None` is normal.
24 geometry_volume: Option<f64>,
25 /// Packed closure verdict; `0` when nothing was hashed.
26 geometry_closure_bits: u8,
27}
28
29impl ElementMeshOutput {
30 /// The element's diff-engine record, or `None` when hashing was off / it
31 /// produced nothing. Built in ONE place so the two push sites (flat and
32 /// partitioned) cannot drift into filling different subsets of the
33 /// index-parallel arrays.
34 fn fingerprint(&self) -> Option<GeometryFingerprint> {
35 Some(GeometryFingerprint {
36 express_id: self.id,
37 hash: self.geometry_hash?,
38 aabb: self.geometry_aabb,
39 volume: self.geometry_volume,
40 closure_bits: self.geometry_closure_bits,
41 })
42 }
43}
44
45/// Session-constant style lookups shared across batches: colour map plus
46/// per-style `GeometryStyleInfo` index (see the #1097 cache note below).
47type StyleMaps = std::sync::Arc<(
48 rustc_hash::FxHashMap<u32, [f32; 4]>,
49 rustc_hash::FxHashMap<u32, ifc_lite_processing::style::GeometryStyleInfo>,
50)>;
51
52impl IfcAPI {
53 /// Shared core for both batch outputs: run the canonical per-element
54 /// producer over `jobs_flat` (setup + loop + CSG/layer diagnostics),
55 /// returning each element's meshes (instance metadata intact) + geometry
56 /// hash. `process_geometry_batch` (→ MeshCollection, flat) and
57 /// `process_geometry_batch_instanced` (→ IFNS shard) both call this so the
58 /// hot path is written once. The web path stays serial (no rayon in wasm);
59 /// the entity-index Arc, warm router, and per-worker style/void/material
60 /// caches are reused exactly as before.
61 #[allow(clippy::too_many_arguments)]
62 fn produce_batch(
63 &self,
64 data: &[u8],
65 jobs_flat: &[u32],
66 unit_scale: f64,
67 rtc_x: f64,
68 rtc_y: f64,
69 rtc_z: f64,
70 needs_shift: bool,
71 void_keys: &[u32],
72 void_counts: &[u32],
73 void_values: &[u32],
74 style_ids: &[u32], // geometry style entity IDs
75 style_colors: &[u8], // [r, g, b, a, r, g, b, a, ...] (0-255)
76 // Trailing optional wire fields (additive — older callers omit them):
77 // the prepass-resolved plane-angle scale (falls back to the per-worker
78 // cache when absent), and the #407 per-element material colour lists
79 // in `flat_material_colors` encoding.
80 plane_angle_to_radians: Option<f64>,
81 material_element_ids: Option<Vec<u32>>,
82 material_color_counts: Option<Vec<u32>>,
83 material_colors_rgba: Option<Vec<u8>>,
84 // #1623 Phase 3: when `true` AND a mapped-instance plan is installed AND the
85 // model is unshifted AND geometry hashing is off, arm the batch router in
86 // BATCH-LOCAL don't-bake mode so a repeated single-solid mapped source
87 // materializes once per batch and the rest ride the IFNS shard. Only the
88 // partitioned entry point (which holds a flat MeshCollection for the
89 // sub-threshold recovery) passes `true`; the flat / instanced-only entry
90 // points pass `false`, keeping their output byte-identical.
91 arm_instancing: bool,
92 ) -> (
93 Vec<ElementMeshOutput>,
94 ifc_lite_geometry::GeometryDiagnostics,
95 Vec<super::instancing::ShardOccurrence>,
96 ) {
97 use crate::api::styling::resolve_element_color;
98 use ifc_lite_core::EntityDecoder;
99 use ifc_lite_geometry::GeometryRouter;
100 use ifc_lite_processing::element::{
101 plan_type_geometry, produce_element_meshes, ElementJobKind, ElementMeshJob,
102 GeometryHashConfig, MeshProductionContext, MeshProductionOptions, TypeGeometryMode,
103 };
104 use ifc_lite_processing::style::GeometryStyleInfo;
105
106 // Batch wall-clock for the PipelineDiagnostics channel. std::time::Instant
107 // traps on wasm32, so use the JS clock (two Date.now() reads per batch —
108 // negligible, always on).
109 let batch_started_ms = js_sys::Date::now();
110
111 let content = data;
112
113 // Geometry fingerprinting for the viewer's revision-diff feature.
114 // When enabled we hash each entity's meshes *before* MeshDataJs::new
115 // applies the Z-up→Y-up swap, in the native IFC frame, reconstructing
116 // world coordinates as `local + rtc` so the file's RTC choice never
117 // registers as a change. Disabled (None) => zero overhead.
118 let hash_tolerance = self.geometry_hash_tolerance();
119 let hash_world_rtc: [f64; 3] = if needs_shift {
120 [rtc_x, rtc_y, rtc_z]
121 } else {
122 [0.0, 0.0, 0.0]
123 };
124
125 // Reuse the cached Arc<EntityIndex> across calls so we don't
126 // re-clone the 14 M-entry HashMap on every batch. On streaming
127 // paths this turns ~36 calls/worker into 1 build + 35 Arc::clone()
128 // (a single refcount bump) instead of 36 full HashMap clones.
129 //
130 // If the cache is empty (which happens on every process worker
131 // because they're separate WASM realms from the pre-pass worker),
132 // build once here and store under Arc so subsequent calls hit
133 // the fast path.
134 let entity_index_arc: std::sync::Arc<ifc_lite_core::ColumnarEntityIndex> = {
135 // Mutex briefly held: peek at cache, build-if-empty, clone Arc.
136 // The clone is what gets handed to rayon — no lock contention
137 // on the per-job hot path that follows. Poison panics here
138 // (an earlier panic-with-lock-held has corrupted the cache).
139 let mut slot = self
140 .cached_entity_index
141 .lock()
142 .unwrap_or_else(std::sync::PoisonError::into_inner);
143 if let Some(existing) = slot.as_ref() {
144 std::sync::Arc::clone(existing)
145 } else {
146 // No setEntityIndex was delivered (non-streaming path): scan the
147 // file straight into sorted columns, skipping the FxHashMap.
148 let built =
149 std::sync::Arc::new(ifc_lite_core::ColumnarEntityIndex::from_scan(content));
150 *slot = Some(std::sync::Arc::clone(&built));
151 built
152 }
153 };
154 let mut decoder = EntityDecoder::with_arc_columnar_index(content, entity_index_arc);
155 // Seed the unit-scale caches so curve/arc tessellation never re-pays the
156 // O(file) IFCPROJECT scan: this decoder is fresh on every batch call,
157 // and `plane_angle_to_radians()` would otherwise walk the whole DATA
158 // section per batch on files whose IFCPROJECT sits near the end
159 // (IfcOpenShell exports) — the geometry-stream stall on large models.
160 let plane_angle_to_radians = plane_angle_to_radians
161 .unwrap_or_else(|| self.get_or_resolve_plane_angle(&mut decoder));
162 decoder.seed_unit_scales(unit_scale, plane_angle_to_radians);
163
164 // Create geometry router with unit scale and the consumer-selected
165 // tessellation quality (issue #976) — Medium unless JS called
166 // `setTessellationQuality`, so default output is byte-for-byte
167 // identical to the pre-quality pipeline.
168 let mut router =
169 GeometryRouter::with_scale_and_quality(unit_scale, self.tessellation_quality());
170
171 // Apply the consumer-selected small-cut skip (#1286) for this batch.
172 // Independent of the tessellation tier so the viewer can skip tiny steel
173 // cuts while keeping full-density curves. Scoped to this router (not a
174 // process-wide flag), so an export's router, which never enables it,
175 // keeps every cut regardless of a concurrent display build.
176 router.set_skip_small_cuts(self.skip_small_cuts());
177
178 // Arm content-dedup against the per-worker shared cache so byte-identical
179 // geometry (e.g. Tekla parts the exporter failed to share via
180 // IfcMappedItem) is meshed ONCE across batches, not once per batch.
181 {
182 let mut slot = self
183 .cached_item_dedup
184 .lock()
185 .unwrap_or_else(std::sync::PoisonError::into_inner);
186 let cache = slot
187 .get_or_insert_with(GeometryRouter::new_dedup_cache)
188 .clone();
189 router.enable_content_dedup_shared(cache);
190 }
191
192 // Arm the shared IfcMappedItem source cache (#1623) against the per-worker
193 // cache so a RepresentationMap source shared across owning elements is
194 // meshed ONCE across batches, not once per batch. Held on the IfcApi like
195 // the item-dedup cache above (one per worker session). Keep a clone for the
196 // Phase 3 don't-bake finalize below (sub-threshold occurrences recover flat
197 // from this registry).
198 let mapped_item_cache = {
199 let mut slot = self
200 .cached_mapped_item
201 .lock()
202 .unwrap_or_else(std::sync::PoisonError::into_inner);
203 let cache = slot
204 .get_or_insert_with(GeometryRouter::new_mapped_item_cache)
205 .clone();
206 router.enable_shared_mapped_item_cache(cache.clone());
207 cache
208 };
209
210 // #1623 Phase 3 "don't-bake": arm the batch router in BATCH-LOCAL mode when
211 // the partitioned path asked for it AND a plan is installed. Gated on
212 // `!needs_shift` (unshifted models only): the browser shard math is
213 // coordinate-space-agnostic (collate reduces to the post-RTC frame and the
214 // renderer applies any site rotation uniformly), but georef/site-local
215 // byte-identity needs a headed-browser check, so — mirroring the native
216 // `coord_space != site_local` guard — georef routes to flat here for now
217 // (loosen this predicate once the coord space is verified in-browser). Also
218 // gated on hashing being OFF: the #924 per-element geometry-diff fingerprint
219 // is computed during the per-occurrence materialize, which the don't-bake
220 // path skips — so when the diff feature needs those hashes, every occurrence
221 // materializes exactly as before. `None` ⇒ router unarmed ⇒ every occurrence
222 // materializes (byte-identical to the pre-Phase-3 partitioned path).
223 let instancing_armed = arm_instancing && !needs_shift && hash_tolerance.is_none();
224 let instance_plan = if instancing_armed {
225 self.mapped_instance_plan()
226 } else {
227 None
228 };
229 if let Some(plan) = instance_plan.as_ref() {
230 router.enable_output_instancing(plan.clone());
231 router.set_instancing_batch_local(true);
232 }
233
234 // Attach the per-content material-layer index so single-solid walls and
235 // slabs carrying an IfcMaterialLayerSetUsage slice into one coloured
236 // sub-mesh per layer (#563). Built once per load and Arc-shared across
237 // batches; #874 dropped this wiring, silently disabling layered-wall
238 // rendering for the entire browser stream. Cheap on files with no layer
239 // set (substring bail-out inside the index builder).
240 //
241 // "Merge Multilayer Walls" (the merge_layers toggle, #540) means exactly
242 // "render walls as ONE solid": NOT attaching the index leaves each wall as
243 // its single swept solid (no per-layer slice). So gate the index on the
244 // flag — off (default) ⇒ slice into layers; on ⇒ one solid. The separate
245 // part-skip path below keeps its own index, so IfcBuildingElementPart
246 // merging is unaffected.
247 if !self.merge_layers() {
248 router.set_material_layer_index(self.get_or_build_material_layer_index(content, &mut decoder));
249 }
250
251 // Set RTC offset if needed
252 if needs_shift {
253 router.set_rtc_offset((rtc_x, rtc_y, rtc_z));
254 }
255
256 // Reconstruct void_index from the flat wire arrays. Length-guarded so
257 // upstream drift drops the index instead of trapping the whole wasm
258 // instance under panic=abort (see reconstruct_void_index).
259 let void_index = reconstruct_void_index(void_keys, void_counts, void_values);
260
261 // #1097: the wire styles are session-constant, so build the colour map
262 // AND the GeometryStyleInfo index the producer consumes ONCE per worker
263 // and reuse across batches (was ~18 M HashMap inserts each on a 140 K-
264 // styled model). Keyed by a cheap (len, first_id, last_id) signature.
265 let style_maps: StyleMaps = {
266 let sig_len = style_ids.len();
267 let sig_first = style_ids.first().copied().unwrap_or(0);
268 let sig_last = style_ids.last().copied().unwrap_or(0);
269 let mut slot = self
270 .cached_geometry_styles
271 .lock()
272 .unwrap_or_else(std::sync::PoisonError::into_inner);
273 match slot.as_ref() {
274 Some((l, f, la, arc)) if *l == sig_len && *f == sig_first && *la == sig_last => {
275 std::sync::Arc::clone(arc)
276 }
277 _ => {
278 let mut colors: rustc_hash::FxHashMap<u32, [f32; 4]> =
279 rustc_hash::FxHashMap::with_capacity_and_hasher(sig_len, Default::default());
280 for (i, &style_id) in style_ids.iter().enumerate() {
281 let base = i * 4;
282 if base + 3 < style_colors.len() {
283 colors.insert(
284 style_id,
285 [
286 style_colors[base] as f32 / 255.0,
287 style_colors[base + 1] as f32 / 255.0,
288 style_colors[base + 2] as f32 / 255.0,
289 style_colors[base + 3] as f32 / 255.0,
290 ],
291 );
292 }
293 }
294 let index: rustc_hash::FxHashMap<u32, GeometryStyleInfo> = colors
295 .iter()
296 .map(|(&id, &c)| (id, GeometryStyleInfo::from_color(c)))
297 .collect();
298 let arc = std::sync::Arc::new((colors, index));
299 *slot = Some((sig_len, sig_first, sig_last, std::sync::Arc::clone(&arc)));
300 arc
301 }
302 }
303 };
304 let geometry_styles = &style_maps.0;
305 // #1097: element colours were resolved in a separate pre-pass that
306 // re-decoded every job entity (a second full decode + deep-clone pass).
307 // That resolution is now folded into the main loop below — each entity
308 // is decoded ONCE (as an Arc, no deep clone), so we no longer build an
309 // `element_styles` map up front.
310
311 // Pre-allocate
312 let num_jobs = jobs_flat.len() / 3;
313 decoder.reserve_cache(num_jobs * 2);
314 let mut outputs: Vec<ElementMeshOutput> = Vec::with_capacity(num_jobs);
315
316 // When merge-layers is on, fetch (or lazily build) the set of
317 // IfcBuildingElementPart express IDs to skip. Built once per worker
318 // and reused across every subsequent batch on the same content via
319 // the cached_parts_to_skip slot on IfcAPI.
320 let parts_to_skip: std::sync::Arc<rustc_hash::FxHashSet<u32>> = if self.merge_layers() {
321 self.get_or_build_parts_to_skip(content, &mut decoder)
322 } else {
323 std::sync::Arc::new(rustc_hash::FxHashSet::default())
324 };
325
326 // IfcIndexedColourMap index (geometry id → full per-triangle palette),
327 // built once per worker (#858) — the canonical producer splits face
328 // sets per palette group so multi-coloured triangles don't collapse to
329 // the single dominant colour the prepass `geometry_styles` carries.
330 let indexed_colour_full = self.get_or_build_indexed_colour_maps(content, &mut decoder);
331
332 // The canonical styled-item index the shared producer consumes — built
333 // once per worker alongside `geometry_styles` above (#1097).
334 let geometry_style_index = &style_maps.1;
335 // Surface textures + UV maps (#961), built once per worker (cheap
336 // substring bail-out for untextured files).
337 let texture_index = self.get_or_build_texture_index(content, &mut decoder);
338 // #407/#913 §2.3: per-element material colour lists from the prepass
339 // wire, so the canonical producer's transparent/opaque sub-mesh
340 // alternation fires in the browser exactly like on the server.
341 // Absent (older callers) ⇒ empty map ⇒ alternation never fires.
342 let element_material_colors: rustc_hash::FxHashMap<u32, Vec<[f32; 4]>> = match (
343 material_element_ids.as_deref(),
344 material_color_counts.as_deref(),
345 material_colors_rgba.as_deref(),
346 ) {
347 (Some(ids), Some(counts), Some(rgba)) => {
348 ifc_lite_processing::prepass::material_colors_from_flat(ids, counts, rgba)
349 }
350 _ => rustc_hash::FxHashMap::default(),
351 };
352
353 let ctx = MeshProductionContext {
354 void_index: &void_index,
355 geometry_style_index,
356 indexed_colour_full: &indexed_colour_full,
357 element_material_colors: &element_material_colors,
358 texture_index: &texture_index,
359 // The browser's axis change (IFC Z-up → WebGL Y-up) happens at the
360 // FFI boundary in `MeshDataJs::from_mesh_data`, not here.
361 site_local_rotation: None,
362 };
363 let opts = MeshProductionOptions {
364 geometry_hash: hash_tolerance.map(|tolerance| GeometryHashConfig {
365 tolerance,
366 world_rtc: hash_world_rtc,
367 }),
368 };
369
370 // CSG diagnostics, aggregated across the batch: the canonical producer
371 // drains the (warm, batch-shared) router per element so one element's
372 // failures never bleed into the next; we collect them here and hand
373 // them to the logger below.
374 let mut batch_csg_failures: rustc_hash::FxHashMap<
375 u32,
376 Vec<ifc_lite_geometry::BoolFailure>,
377 > = rustc_hash::FxHashMap::default();
378
379 // PipelineDiagnostics tallies for this batch (elements actually run
380 // through the producer, degenerate-backstop drops).
381 let mut batch_elements: u64 = 0;
382 let mut batch_backstop: u64 = 0;
383
384 // #1623 Phase 3: don't-bake occurrences collected across the batch's
385 // elements (empty when the router is unarmed). Resolved after the loop into
386 // shard instances (kept) + recovered flat meshes (sub-threshold).
387 let mut all_occurrences: Vec<ifc_lite_processing::RawInstanceOccurrence> = Vec::new();
388
389 // Process only the entities specified in jobs_flat — every job runs
390 // THE canonical per-element producer (`ifc_lite_processing::element`),
391 // the same code the native pipeline runs.
392 for chunk in jobs_flat.chunks(3) {
393 if chunk.len() < 3 {
394 break;
395 }
396 let id = chunk[0];
397 let start = chunk[1] as usize;
398 let end = chunk[2] as usize;
399
400 if parts_to_skip.contains(&id) {
401 continue;
402 }
403
404 // #1097: decode_and_cache returns the cached Arc (cheap Arc::clone),
405 // not a deep clone of the DecodedEntity — was the dominant per-job
406 // marshalling cost across ~60-110 K jobs. produce_element_meshes
407 // takes `&DecodedEntity`, so we deref the Arc at the call site.
408 let Ok(entity) = decoder.decode_and_cache(id, start, end) else {
409 continue;
410 };
411 let ifc_type = entity.ifc_type;
412
413 // Resolve the element-level colour inline (folded from the deleted
414 // pre-pass) so the entity is decoded exactly once.
415 let element_color = if !geometry_styles.is_empty()
416 && entity.get(6).map(|a| !a.is_null()).unwrap_or(false)
417 {
418 resolve_element_color(entity.as_ref(), geometry_styles, &mut decoder)
419 } else {
420 None
421 };
422
423 // #957: type products render their planned RepresentationMaps. The
424 // viewer emits BOTH orphan (class 1) and instanced (class 2) maps —
425 // `EmitTagged` — so the Model/Types switch can filter at render
426 // time; the native pipeline plans the same jobs with
427 // `SuppressInstanced` (an export must not duplicate geometry).
428 let kind = if ifc_type.is_subtype_of(ifc_lite_core::IfcType::IfcTypeProduct) {
429 let rep_map_ids: Vec<u32> = entity
430 .get(6)
431 .and_then(|a| a.as_list())
432 .map(|list| list.iter().filter_map(|v| v.as_entity_ref()).collect())
433 .unwrap_or_default();
434 if rep_map_ids.is_empty() {
435 continue;
436 }
437 let referenced = self.get_or_build_referenced_repmaps(content, &mut decoder);
438 let instantiated = self.get_or_build_instantiated_type_ids(content, &mut decoder);
439 let rep_maps = plan_type_geometry(
440 &rep_map_ids,
441 &referenced,
442 instantiated.contains(&id),
443 TypeGeometryMode::EmitTagged,
444 );
445 if rep_maps.is_empty() {
446 continue;
447 }
448 ElementJobKind::TypeProduct { rep_maps }
449 } else {
450 ElementJobKind::Product
451 };
452
453 let produced = produce_element_meshes(
454 &ElementMeshJob {
455 id,
456 ifc_type,
457 entity: entity.as_ref(),
458 kind,
459 element_color,
460 // The viewer gets element metadata from the parser worker.
461 metadata: None,
462 },
463 &ctx,
464 &opts,
465 &mut decoder,
466 &router,
467 );
468
469 for (product_id, fails) in produced.csg_failures {
470 batch_csg_failures.entry(product_id).or_default().extend(fails);
471 }
472 batch_elements += 1;
473 batch_backstop += produced.degenerate_triangles_dropped;
474 if !produced.instance_occurrences.is_empty() {
475 all_occurrences.extend(produced.instance_occurrences);
476 }
477 outputs.push(ElementMeshOutput {
478 id,
479 meshes: produced.meshes,
480 geometry_hash: produced.geometry_hash,
481 geometry_aabb: produced.geometry_aabb,
482 geometry_volume: produced.geometry_volume,
483 geometry_closure_bits: produced
484 .geometry_closure
485 .map_or(0, |c| c.bits()),
486 });
487 }
488
489 // Surface the opening / CSG diagnostics. The viewer's large-file path
490 // goes processAdaptive -> processParallel -> Web Workers ->
491 // `processGeometryBatch`, so the log has to fire here or the
492 // diagnostic helper never runs for real-world files.
493 let csg_diag = crate::api::drain_and_log_csg_diagnostics(&router, batch_csg_failures);
494
495 // Layered-wall slicing diagnostics (#563): a quiet success summary, but a
496 // per-element warning (id + reason) when a sliceable wall fails to slice
497 // — so future regressions surface without spamming healthy loads. Reasons:
498 // not-single-unshifted-item / thin-layers-collapsed-to-1 /
499 // placement-unresolved / cut-produced-<2 / base-mesh-error.
500 let layer_diag = router.take_layer_slice_diag();
501 if !layer_diag.is_empty() {
502 let sliced = layer_diag.iter().filter(|(_, r)| r.starts_with("ok:")).count();
503 let not_sliced = layer_diag.len() - sliced;
504 if not_sliced == 0 {
505 web_sys::console::info_1(
506 &format!("[ifc-lite layers] batch: sliced {} wall(s) into layers", sliced)
507 .into(),
508 );
509 } else {
510 let detail: Vec<String> = layer_diag
511 .iter()
512 .filter(|(_, r)| !r.starts_with("ok:"))
513 .map(|(id, r)| format!("#{}={}", id, r))
514 .collect();
515 web_sys::console::warn_1(
516 &format!(
517 "[ifc-lite layers] batch: sliced {}, {} NOT sliced — {}",
518 sliced,
519 not_sliced,
520 detail.join(", ")
521 )
522 .into(),
523 );
524 }
525 }
526
527 // #1623 Phase 3: resolve the batch's don't-bake occurrences into shard
528 // instances (kept) + recovered flat meshes (sub-threshold / ineligible
529 // template). Build the per-rep template facts from the retained instanceable
530 // meshes (batch-local mode materialized one template per source), then split.
531 // Empty (unarmed / no occurrences) ⇒ nothing changes, byte-identical output.
532 let shard_occurrences = if all_occurrences.is_empty() {
533 Vec::new()
534 } else {
535 let mut template_by_rep: rustc_hash::FxHashMap<u128, super::instancing::TemplateInfo> =
536 rustc_hash::FxHashMap::default();
537 for out in &outputs {
538 for m in &out.meshes {
539 if m.positions.is_empty() {
540 continue;
541 }
542 if let Some(im) = m.instance.as_ref() {
543 if im.instanceable {
544 // Shard-eligible = the partition's candidate gate: opaque,
545 // untextured, ordinary-occurrence (class 0) geometry.
546 let eligible = m.color[3] >= INSTANCED_ALPHA_CUTOFF
547 && m.texture.is_none()
548 && m.geometry_class == 0;
549 template_by_rep
550 .entry(im.rep_identity)
551 .or_insert(super::instancing::TemplateInfo { eligible });
552 }
553 }
554 }
555 }
556 let rtc = if needs_shift {
557 [rtc_x, rtc_y, rtc_z]
558 } else {
559 [0.0, 0.0, 0.0]
560 };
561 let mut recovered_flats: Vec<ifc_lite_processing::MeshData> = Vec::new();
562 let shard = super::instancing::resolve_batch_occurrences(
563 std::mem::take(&mut all_occurrences),
564 &template_by_rep,
565 &mapped_item_cache,
566 rtc,
567 INSTANCE_MIN_OCCURRENCES as usize,
568 &mut recovered_flats,
569 );
570 // Recovered occurrences ride as their own single-mesh outputs; the
571 // partition routes them to the flat MeshCollection (instance meta is None
572 // ⇒ never instanced), byte-identical to the flat baseline for that element.
573 for m in recovered_flats {
574 let id = m.express_id;
575 outputs.push(ElementMeshOutput {
576 id,
577 meshes: vec![m],
578 geometry_hash: None,
579 geometry_aabb: None,
580 geometry_volume: None,
581 geometry_closure_bits: 0,
582 });
583 }
584 shard
585 };
586
587 // Fold this batch into the per-worker PipelineDiagnostics accumulator
588 // (read by JS via getPipelineDiagnostics). Counts + two Date.now()
589 // reads — cheap enough to stay on the normal load path unconditionally.
590 let batch_meshes: u64 = outputs.iter().map(|o| o.meshes.len() as u64).sum();
591 let batch_triangles: u64 = outputs
592 .iter()
593 .flat_map(|o| o.meshes.iter())
594 .map(|m| (m.indices.len() / 3) as u64)
595 .sum();
596 let batch_ms = (js_sys::Date::now() - batch_started_ms).max(0.0) as u64;
597 // The batch reuses ONE decoder across every element (the point cache is
598 // already hoisted here), so its cumulative point-cache stats ARE this
599 // batch's faceted-brep memoization tally.
600 let (point_cache_hits, point_cache_misses) = decoder.point_cache_stats();
601 self.record_pipeline_batch(
602 batch_elements,
603 batch_meshes,
604 batch_triangles,
605 batch_backstop,
606 point_cache_hits,
607 point_cache_misses,
608 batch_ms,
609 &csg_diag,
610 );
611
612 (outputs, csg_diag, shard_occurrences)
613 }
614}
615
616#[wasm_bindgen]
617impl IfcAPI {
618 /// Process geometry for a subset of pre-scanned entities → flat
619 /// MeshCollection. Takes raw bytes + pre-pass data from buildPrePassOnce.
620 /// Thin wrapper over [`IfcAPI::produce_batch`]; converts each produced mesh
621 /// to MeshDataJs (the IFC Z-up→WebGL Y-up swap + winding reversal happen
622 /// there). Output is byte-for-byte what the pre-refactor method produced.
623 #[wasm_bindgen(js_name = processGeometryBatch)]
624 #[allow(clippy::too_many_arguments)]
625 pub fn process_geometry_batch(
626 &self,
627 data: &[u8],
628 jobs_flat: &[u32],
629 unit_scale: f64,
630 rtc_x: f64,
631 rtc_y: f64,
632 rtc_z: f64,
633 needs_shift: bool,
634 void_keys: &[u32],
635 void_counts: &[u32],
636 void_values: &[u32],
637 style_ids: &[u32],
638 style_colors: &[u8],
639 plane_angle_to_radians: Option<f64>,
640 material_element_ids: Option<Vec<u32>>,
641 material_color_counts: Option<Vec<u32>>,
642 material_colors_rgba: Option<Vec<u8>>,
643 ) -> MeshCollection {
644 let num_jobs = jobs_flat.len() / 3;
645 // arm_instancing = false: the flat path has no IFNS shard to hold don't-bake
646 // occurrences, so every occurrence must materialize (byte-identical output).
647 let (outputs, csg_diag, _shard_occurrences) = self.produce_batch(
648 data, jobs_flat, unit_scale, rtc_x, rtc_y, rtc_z, needs_shift, void_keys,
649 void_counts, void_values, style_ids, style_colors, plane_angle_to_radians,
650 material_element_ids, material_color_counts, material_colors_rgba, false,
651 );
652 let mut mesh_collection = MeshCollection::with_capacity(num_jobs);
653 if needs_shift {
654 mesh_collection.set_rtc_offset(rtc_x, rtc_y, rtc_z);
655 }
656 for out in outputs {
657 // Taken BEFORE the meshes are moved out of `out` below.
658 let fingerprint = out.fingerprint();
659 for mesh_data in out.meshes {
660 mesh_collection.add(MeshDataJs::from_mesh_data(mesh_data));
661 }
662 if let Some(fp) = fingerprint {
663 mesh_collection.push_geometry_hash(fp);
664 }
665 }
666 mesh_collection.set_diagnostics(csg_diag);
667 mesh_collection
668 }
669
670 /// Like [`IfcAPI::process_geometry_batch`] but collates the batch's meshes
671 /// into a GPU-instancing shard (IFNS wire format) instead of a flat
672 /// MeshCollection. Repeated geometry collapses to one template + per-
673 /// occurrence transforms; non-instanceable meshes ride as flat singleton
674 /// templates so nothing is dropped. The shard stays in the producer-native
675 /// (IFC Z-up) frame — the renderer composes the constant Z-up→Y-up swap at
676 /// upload. Each batch shard renders independently: affinity routing already
677 /// co-locates identical geometry on one worker, so per-batch collation
678 /// captures ~all the dedup and no cross-batch merge is needed. Returns empty
679 /// bytes only when the batch produced zero non-empty meshes.
680 #[wasm_bindgen(js_name = processGeometryBatchInstanced)]
681 #[allow(clippy::too_many_arguments)]
682 pub fn process_geometry_batch_instanced(
683 &self,
684 data: &[u8],
685 jobs_flat: &[u32],
686 unit_scale: f64,
687 rtc_x: f64,
688 rtc_y: f64,
689 rtc_z: f64,
690 needs_shift: bool,
691 void_keys: &[u32],
692 void_counts: &[u32],
693 void_values: &[u32],
694 style_ids: &[u32],
695 style_colors: &[u8],
696 plane_angle_to_radians: Option<f64>,
697 material_element_ids: Option<Vec<u32>>,
698 material_color_counts: Option<Vec<u32>>,
699 material_colors_rgba: Option<Vec<u8>>,
700 ) -> Vec<u8> {
701 // NB: this instanced-only export returns raw shard bytes with no
702 // MeshCollection carrier, so CSG diagnostics are intentionally dropped. It
703 // is not the worker's default path (partitioned/flat carry the counts).
704 // arm_instancing = false: with no flat carrier there is nowhere to route the
705 // don't-bake sub-threshold recovery, so every occurrence materializes here
706 // and this export stays byte-identical (it collates the baked meshes as
707 // before). Only `process_geometry_batch_partitioned` arms the don't-bake path.
708 let (outputs, _, _shard_occurrences) = self.produce_batch(
709 data, jobs_flat, unit_scale, rtc_x, rtc_y, rtc_z, needs_shift, void_keys,
710 void_counts, void_values, style_ids, style_colors, plane_angle_to_radians,
711 material_element_ids, material_color_counts, material_colors_rgba, false,
712 );
713 let meshes: Vec<ifc_lite_processing::MeshData> =
714 outputs.into_iter().flat_map(|o| o.meshes).collect();
715 // `refs` borrows the geometry in `meshes`; both live to the end of this
716 // method and collate_and_encode consumes them synchronously below.
717 //
718 // ONLY ordinary occurrences (geometry_class == 0) are instanced. Type-
719 // product geometry — orphan type maps (class 1) and instanced type maps
720 // (class 2) — is left to the flat path, which the viewer's Model/Types
721 // view-mode filter gates (ViewportContainer drops class 2 in Model mode,
722 // class 0 in Types mode). The instanced path has no view-mode filter, so
723 // including class 1/2 here would render type geometry unconditionally
724 // (the opaque type-template shapes drawing over the real occurrences —
725 // the "blue windows/roof" + type geometry showing in Model mode).
726 let refs: Vec<ifc_lite_geometry::InstanceMeshRef> = meshes
727 .iter()
728 .filter(|m| m.geometry_class == 0)
729 .map(|m| ifc_lite_geometry::InstanceMeshRef {
730 positions: &m.positions,
731 normals: &m.normals,
732 indices: &m.indices,
733 origin: m.origin,
734 instance_meta: m.instance.as_ref(),
735 entity_id: m.express_id,
736 color: m.color,
737 })
738 .collect();
739 // min_group = 2: instance any repeat; singletons + non-instanceable flat.
740 // Pass the applied RTC so per-occurrence transforms are reduced to the
741 // post-RTC frame (matches the small baked origins; without it a rotated
742 // occurrence lands at 2× the georef offset and collapses GLB exports).
743 let rtc = if needs_shift { [rtc_x, rtc_y, rtc_z] } else { [0.0, 0.0, 0.0] };
744 ifc_lite_geometry::collate_and_encode(&refs, 2, rtc)
745 }
746
747 /// Produce a batch ONCE and PARTITION it (the instanced-ONLY path): opaque
748 /// ordinary occurrences (colour alpha >= 0.99 AND geometry_class == 0) are
749 /// collated into the instanced shard; everything else (transparent glass,
750 /// type-product geometry) goes to the flat MeshCollection. Each mesh takes
751 /// exactly ONE route, so produce_batch runs once (no emit-both 2× meshing)
752 /// and the renderer draws opaque occurrences via instancing instead of flat.
753 /// Partition mirrors the renderer gates: INSTANCED_ALPHA_CUTOFF (0.99 =
754 /// OPAQUE_ALPHA_CUTOFF) for transparency, geometry_class for the Model/Types
755 /// split.
756 ///
757 /// NOTE: the renderer must be instanced-feature-complete (picking / selection
758 /// / lens overlays on instanced geometry) before the worker calls this in
759 /// place of processGeometryBatch — otherwise those features break for the
760 /// opaque bulk. See the instanced-only follow-ups.
761 #[wasm_bindgen(js_name = processGeometryBatchPartitioned)]
762 #[allow(clippy::too_many_arguments)]
763 pub fn process_geometry_batch_partitioned(
764 &self,
765 data: &[u8],
766 jobs_flat: &[u32],
767 unit_scale: f64,
768 rtc_x: f64,
769 rtc_y: f64,
770 rtc_z: f64,
771 needs_shift: bool,
772 void_keys: &[u32],
773 void_counts: &[u32],
774 void_values: &[u32],
775 style_ids: &[u32],
776 style_colors: &[u8],
777 plane_angle_to_radians: Option<f64>,
778 material_element_ids: Option<Vec<u32>>,
779 material_color_counts: Option<Vec<u32>>,
780 material_colors_rgba: Option<Vec<u8>>,
781 ) -> PartitionedBatch {
782 let num_jobs = jobs_flat.len() / 3;
783 // arm_instancing = true (#1623 Phase 3): the partitioned path holds a flat
784 // MeshCollection, so it CAN route the don't-bake sub-threshold recovery to
785 // flat — the only entry point that arms it. `shard_occurrences` are the kept
786 // don't-bake occurrences (pose-only, no baked vertices); their template
787 // materialized once in `outputs`, and any recovered flats are already
788 // appended to `outputs`.
789 let (outputs, csg_diag, shard_occurrences) = self.produce_batch(
790 data, jobs_flat, unit_scale, rtc_x, rtc_y, rtc_z, needs_shift, void_keys,
791 void_counts, void_values, style_ids, style_colors, plane_angle_to_radians,
792 material_element_ids, material_color_counts, material_colors_rgba, true,
793 );
794 let mut mesh_collection = MeshCollection::with_capacity(num_jobs);
795 if needs_shift {
796 mesh_collection.set_rtc_offset(rtc_x, rtc_y, rtc_z);
797 }
798 // Route opaque + untextured + class-0 occurrences by per-batch REPETITION.
799 // Instancing trades 1 consolidated, frustum-culled flat draw for 1 drawIndexed
800 // per template. That only pays off when geometry repeats enough that the saved
801 // upload/memory is real and the per-template draw is amortized over many
802 // instances. Singleton / low-count geometry encoded as 1-instance templates was
803 // the orbit-FPS regression: it replaced the flat path's ~3-15 consolidated draws
804 // with O(unique-geometry) per-frame draws (e.g. an 8 MB-geom architectural model
805 // where memory was never the constraint). So: only rep_identity groups occurring
806 // >= INSTANCE_MIN_OCCURRENCES times in this batch go to the instanced shard;
807 // everything else (singletons, low-count, non-instanceable, no-meta) joins the
808 // flat MeshCollection and is consolidated + culled exactly as before the flip.
809 //
810 // Transparent (alpha < cutoff), textured (no UV slot in the instanced pipeline),
811 // and type-product (class 1/2) geometry are never instancing candidates — they
812 // must stay on the flat pipelines for correct blending / texturing / view-mode
813 // gating.
814 let mut candidates: Vec<ifc_lite_processing::MeshData> = Vec::new();
815 let mut counts: rustc_hash::FxHashMap<u128, u32> = rustc_hash::FxHashMap::default();
816 for out in outputs {
817 // Taken BEFORE the meshes are moved out of `out` below.
818 let fingerprint = out.fingerprint();
819 for mesh_data in out.meshes {
820 let opaque = mesh_data.color[3] >= INSTANCED_ALPHA_CUTOFF;
821 let untextured = mesh_data.texture.is_none();
822 if opaque && untextured && mesh_data.geometry_class == 0 {
823 // Count only instanceable metas — mirror collate_refs's match arm:
824 // a None meta or instanceable==false (void-cut walls, multi-item
825 // merges) can never instance, so it must not inflate a count.
826 if let Some(im) = mesh_data.instance.as_ref() {
827 if im.instanceable {
828 *counts.entry(im.rep_identity).or_insert(0) += 1;
829 }
830 }
831 candidates.push(mesh_data);
832 } else {
833 mesh_collection.add(MeshDataJs::from_mesh_data(mesh_data));
834 }
835 }
836 // The element-level geometry-diff record is path-independent metadata;
837 // keep it on the collection regardless of which path the meshes took.
838 if let Some(fp) = fingerprint {
839 mesh_collection.push_geometry_hash(fp);
840 }
841 }
842 // #1623 Phase 3: fold the kept don't-bake occurrence counts into the per-rep
843 // tally so a batch-local template (materialized ONCE, so count 1 among the
844 // candidates) plus its N shard occurrences clears INSTANCE_MIN_OCCURRENCES
845 // and routes to the instanced shard (the finalize already applied that gate,
846 // so this only confirms it). The template's geometry rides the shard once;
847 // the occurrences ride as pose-only instances against it.
848 for occ in &shard_occurrences {
849 *counts.entry(occ.rep_identity).or_insert(0) += 1;
850 }
851 let mut instanced: Vec<ifc_lite_processing::MeshData> = Vec::new();
852 for mesh_data in candidates {
853 let instance_it = mesh_data.instance.as_ref().is_some_and(|im| {
854 im.instanceable
855 && counts.get(&im.rep_identity).copied().unwrap_or(0)
856 >= INSTANCE_MIN_OCCURRENCES
857 });
858 if instance_it {
859 instanced.push(mesh_data);
860 } else {
861 mesh_collection.add(MeshDataJs::from_mesh_data(mesh_data));
862 }
863 }
864 // Each materialized instanced mesh is one shard instance; each kept don't-bake
865 // occurrence adds one more. Report the sum so the viewer's mesh total reflects
866 // ALL rendered geometry (flat + instanced), not just the flat MeshCollection.
867 let instanced_occurrences = instanced.len() + shard_occurrences.len();
868 // #1623 Phase 3: back the kept don't-bake occurrences with InstanceMeta storage
869 // that outlives `refs` — `collate_refs` reads each placeholder's PRE-RTC world
870 // transform (local/canonical identity) to derive its `rel_k` against the
871 // batch-local template, exactly as a materialized occurrence would. Building
872 // these EMPTY-geometry refs is the whole Phase 3 win: the occurrence vertices
873 // were never materialized.
874 let occ_metas: Vec<ifc_lite_geometry::InstanceMeta> = shard_occurrences
875 .iter()
876 .map(|o| ifc_lite_geometry::InstanceMeta {
877 transform: o.world_transform,
878 local_transform: None,
879 canonical_transform: None,
880 rep_identity: o.rep_identity,
881 instanceable: true,
882 })
883 .collect();
884 let mut refs: Vec<ifc_lite_geometry::InstanceMeshRef> = instanced
885 .iter()
886 .map(|m| ifc_lite_geometry::InstanceMeshRef {
887 positions: &m.positions,
888 normals: &m.normals,
889 indices: &m.indices,
890 origin: m.origin,
891 instance_meta: m.instance.as_ref(),
892 entity_id: m.express_id,
893 color: m.color,
894 })
895 .collect();
896 for (o, meta) in shard_occurrences.iter().zip(occ_metas.iter()) {
897 refs.push(ifc_lite_geometry::InstanceMeshRef {
898 positions: &[],
899 normals: &[],
900 indices: &[],
901 origin: [0.0, 0.0, 0.0],
902 instance_meta: Some(meta),
903 entity_id: o.entity_id,
904 color: o.color,
905 });
906 }
907 // min_group == the routing threshold so collate_refs never re-flattens a group
908 // that already passed the count gate; only its own try_inverse / shape-mismatch
909 // safety net can still drop a (rare, degenerate) group to a singleton template.
910 // Reduce occurrence transforms to the post-RTC frame (see the other call
911 // site) so rotated occurrences don't fly out to 2× the georef offset.
912 let rtc = if needs_shift { [rtc_x, rtc_y, rtc_z] } else { [0.0, 0.0, 0.0] };
913 let shard =
914 ifc_lite_geometry::collate_and_encode(&refs, INSTANCE_MIN_OCCURRENCES as usize, rtc);
915 mesh_collection.set_diagnostics(csg_diag);
916 PartitionedBatch {
917 meshes: Some(mesh_collection),
918 shard,
919 instanced_occurrences,
920 }
921 }
922}
923
924/// Opaque-alpha cutoff for the instanced-only partition. Mirrors the renderer's
925/// `OPAQUE_ALPHA_CUTOFF` (overlay-routing.ts) so the wasm partition and the
926/// renderer's flat opaque/transparent split agree: alpha >= this is opaque.
927const INSTANCED_ALPHA_CUTOFF: f32 = 0.99;
928
929/// Minimum per-batch occurrence count for a rep_identity group to be GPU-instanced.
930/// Below this, geometry rides the flat (consolidated, frustum-culled) path instead —
931/// one drawIndexed per template only pays off when amortized over many instances, and
932/// the saved upload/memory is negligible at low counts. Tuned for the draw-vs-memory
933/// tradeoff: 8 kills the singleton/low-count tail that defeated flat consolidation
934/// (the orbit-FPS regression) while leaving genuinely-repeated families (mullions,
935/// fasteners, identical steel parts — co-located by affinity routing, so dozens-to-
936/// hundreds per batch) instanced. Counting is PER-BATCH; a globally-repeated geometry
937/// thinly split across batches may fall below the gate and render flat — a benign
938/// missed optimization, never a correctness/FPS regression (flat IS the fast path for
939/// low counts). Lower to 4 if a large model's memory regresses; raise to 16 if orbit
940/// still drags.
941const INSTANCE_MIN_OCCURRENCES: u32 = 8;
942
943/// Result of [`IfcAPI::process_geometry_batch_partitioned`]: the flat
944/// MeshCollection (transparent + type geometry) and the instanced IFNS shard
945/// (opaque ordinary occurrences) from ONE produce_batch. Take-once accessors so
946/// the JS side moves each out without a clone.
947#[wasm_bindgen]
948pub struct PartitionedBatch {
949 meshes: Option<MeshCollection>,
950 shard: Vec<u8>,
951 instanced_occurrences: usize,
952}
953
954#[wasm_bindgen]
955impl PartitionedBatch {
956 /// The flat MeshCollection (transparent glass + type-product geometry).
957 /// Moves out — call once.
958 #[wasm_bindgen(js_name = takeMeshes)]
959 pub fn take_meshes(&mut self) -> Option<MeshCollection> {
960 self.meshes.take()
961 }
962
963 /// The instanced IFNS shard bytes (opaque ordinary occurrences). Moves out.
964 #[wasm_bindgen(js_name = takeShard)]
965 pub fn take_shard(&mut self) -> Vec<u8> {
966 std::mem::take(&mut self.shard)
967 }
968
969 /// Number of occurrences routed into the instanced shard this batch. The viewer
970 /// folds this into its total mesh count so the count reflects ALL rendered
971 /// geometry (flat + instanced), not just the flat MeshCollection.
972 #[wasm_bindgen(getter, js_name = instancedOccurrences)]
973 pub fn instanced_occurrences(&self) -> usize {
974 self.instanced_occurrences
975 }
976}