Skip to main content

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}