Skip to main content

ifc_lite_wasm/api/gpu_meshes/
prepass.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use crate::api::IfcAPI;
6use js_sys::Function;
7use wasm_bindgen::prelude::*;
8
9/// Reduce a 128-bit geometry hash to the 32-bit worker-affinity key the job
10/// stream carries. Jobs with the SAME key are routed to the same geometry worker,
11/// so their (byte-identical) geometry is meshed once per model instead of once per
12/// worker — the win the per-worker content-dedup cache can't get across separate
13/// WASM realms. A 32-bit collision only co-locates two unrelated geometries on one
14/// worker (harmless: the cache still keys them apart), so xor-folding the lanes is
15/// plenty.
16#[inline]
17fn fold_u128_to_u32(h: u128) -> u32 {
18    (h as u32) ^ ((h >> 32) as u32) ^ ((h >> 64) as u32) ^ ((h >> 96) as u32)
19}
20
21// The per-submesh #858 palette split lives inside the canonical per-element
22// producer (`ifc_lite_processing::element`) — shared with the native pipeline.
23
24#[wasm_bindgen]
25impl IfcAPI {
26    /// Run the pre-pass ONCE and return serialized results for worker distribution.
27    /// Takes raw bytes (&[u8]) to avoid TextDecoder overhead.
28    #[wasm_bindgen(js_name = buildPrePassOnce)]
29    pub fn build_pre_pass_once(&self, data: &[u8]) -> JsValue {
30        use crate::api::styling::combined_pre_pass;
31        use ifc_lite_core::EntityDecoder;
32        use ifc_lite_processing::stream_meta::{resolve_stream_meta, MetaMode};
33
34        // Load START on the serial/main-thread path: the previous load's
35        // pipeline diagnostics must not accumulate into this one. (The worker
36        // path resets via setEntityIndex; every load-start entry point resets.)
37        self.reset_pipeline_diagnostics();
38
39        let content = data;
40
41        // Build entity index — a compact columnar index (sorted u32 columns +
42        // binary search, #1682) wrapped in Arc for cheap reuse.
43        let entity_index = std::sync::Arc::new(ifc_lite_core::ColumnarEntityIndex::from_scan(content));
44        // Cache for reuse by processGeometryBatch.
45        // Mutex held only briefly to install the Arc; rayon helpers
46        // pick up clones below without re-locking. Panic on poison —
47        // an earlier panic with the lock held would mean the cached
48        // index is in an inconsistent state.
49        let mut slot = self
50            .cached_entity_index
51            .lock()
52            .unwrap_or_else(std::sync::PoisonError::into_inner);
53        *slot = Some(entity_index.clone());
54        drop(slot);
55        let mut decoder = EntityDecoder::with_arc_columnar_index(content, entity_index);
56
57        // Run combined pre-pass
58        let pre_pass = combined_pre_pass(content, &mut decoder);
59
60        // Resolve the load-time meta (unit scales + RTC offset + needs-shift +
61        // building rotation) via the shared resolver. This decoder already sees
62        // the FULL entity index, so the single-stage `SmallFileSingle` ladder is
63        // correct. It also seeds the decoder so nothing downstream re-pays the
64        // IFCPROJECT hunt.
65        let rtc_jobs: Vec<_> = pre_pass
66            .simple_jobs
67            .iter()
68            .take(25)
69            .chain(pre_pass.complex_jobs.iter().take(25))
70            .copied()
71            .collect();
72        let meta = resolve_stream_meta(
73            MetaMode::SmallFileSingle,
74            content,
75            pre_pass.project_id,
76            pre_pass.site_position,
77            &rtc_jobs,
78            &mut decoder,
79        );
80
81        // Build combined job list: simple first, then complex
82        let total_jobs = pre_pass.simple_jobs.len() + pre_pass.complex_jobs.len();
83
84        // Serialize jobs as flat Uint32Array: [id, start, end, id, start, end, ...]
85        let jobs_flat = js_sys::Uint32Array::new_with_length((total_jobs * 3) as u32);
86        let mut idx = 0u32;
87        for &(id, start, end, _ifc_type) in pre_pass
88            .simple_jobs
89            .iter()
90            .chain(pre_pass.complex_jobs.iter())
91        {
92            jobs_flat.set_index(idx, id);
93            jobs_flat.set_index(idx + 1, start as u32);
94            jobs_flat.set_index(idx + 2, end as u32);
95            idx += 3;
96        }
97
98        // Flat wire encodings from the shared resolver: styles (layered
99        // precedence), voids, and the #407 material colour lists.
100        let (style_ids_vec, style_colors_vec) = ifc_lite_processing::prepass::flat_styles_rgba8(
101            &pre_pass.resolved,
102            &mut decoder,
103        );
104        let (void_keys_vec, void_counts_vec, void_values_vec) =
105            ifc_lite_processing::prepass::flat_voids(&pre_pass.resolved.void_index);
106        let (mat_ids_vec, mat_counts_vec, mat_colors_vec) =
107            ifc_lite_processing::prepass::flat_material_colors(
108                &pre_pass.resolved.element_material_colors,
109            );
110
111        let void_keys = js_sys::Uint32Array::from(void_keys_vec.as_slice());
112        let void_counts = js_sys::Uint32Array::from(void_counts_vec.as_slice());
113        let void_values = js_sys::Uint32Array::from(void_values_vec.as_slice());
114        let style_ids = js_sys::Uint32Array::from(style_ids_vec.as_slice());
115        let style_colors = js_sys::Uint8Array::from(style_colors_vec.as_slice());
116        let material_element_ids = js_sys::Uint32Array::from(mat_ids_vec.as_slice());
117        let material_color_counts = js_sys::Uint32Array::from(mat_counts_vec.as_slice());
118        let material_colors = js_sys::Uint8Array::from(mat_colors_vec.as_slice());
119
120        // Build result object
121        let result = js_sys::Object::new();
122        crate::api::set_js_prop(&result, "jobs", &jobs_flat);
123        crate::api::set_js_prop(&result, "totalJobs", &(total_jobs as f64).into());
124        // unitScale / planeAngleToRadians / rtcOffset / needsShift / buildingRotation
125        // from the shared resolver.
126        super::prepass_sharded::set_stream_meta_props(&result, &meta);
127
128        crate::api::set_js_prop(&result, "voidKeys", &void_keys);
129        crate::api::set_js_prop(&result, "voidCounts", &void_counts);
130        crate::api::set_js_prop(&result, "voidValues", &void_values);
131        crate::api::set_js_prop(&result, "styleIds", &style_ids);
132        crate::api::set_js_prop(&result, "styleColors", &style_colors);
133        // #407/#913 §2.3: per-element material colour lists so the batch path
134        // can run the transparent/opaque sub-mesh alternation.
135        crate::api::set_js_prop(&result, "materialElementIds", &material_element_ids);
136        crate::api::set_js_prop(&result, "materialColorCounts", &material_color_counts);
137        crate::api::set_js_prop(&result, "materialColors", &material_colors);
138
139        result.into()
140    }
141
142    /// Streaming pre-pass: emits geometry jobs in chunks via a JS callback
143    /// instead of waiting for the full file scan to complete.
144    ///
145    /// Single linear walk over the file:
146    ///   1. Builds the entity index incrementally from the same scan that
147    ///      collects geometry jobs (a separate index scan would double
148    ///      wall-clock).
149    ///   2. As soon as `IFCPROJECT` has been seen, the unit scale and the
150    ///      first ~50 geometry jobs have been collected, resolves
151    ///      `unitScale` + `rtcOffset` and emits a `meta` callback so the
152    ///      JS host can spin up geometry process workers.
153    ///   3. Emits `jobs` callbacks every `chunk_size` jobs (or fewer if
154    ///      the meta phase already buffered some).
155    ///   4. Emits `complete` with the total job count at end of scan.
156    ///
157    /// On a 986 MB / 14 M-entity file this drops time-to-first-geometry
158    /// from ~17 s (full pre-pass + worker spawn + first batch) to ~3 s
159    /// (first 100 K bytes scanned + meta + first chunk).
160    ///
161    /// The callback receives a single `JsValue` argument shaped as one of:
162    ///   `{ type: "meta", unitScale, rtcOffset: [x,y,z], needsShift, buildingRotation? }`
163    ///   `{ type: "jobs", jobs: Uint32Array }`     // [id, start, end] triples
164    ///   `{ type: "complete", totalJobs }`
165    #[wasm_bindgen(js_name = buildPrePassStreaming)]
166    pub fn build_pre_pass_streaming(
167        &self,
168        data: &[u8],
169        on_event: &Function,
170        chunk_size: u32,
171        // #1097 perf: optional load-time visibility filter. `disabled_type_names`
172        // (uppercase STEP keywords, e.g. "IFCSPACE", "IFCANNOTATION") are skipped
173        // at job generation so their geometry is never decoded/meshed/uploaded;
174        // `skip_type_geometry` drops the #957 type-library (IfcTypeProduct) jobs.
175        // Both default to "load everything" (None / false) — callers that don't
176        // pass them keep the old behaviour. Toggling a type back ON requires a
177        // reload (the jobs were never produced).
178        disabled_type_names: Option<Vec<String>>,
179        skip_type_geometry: bool,
180    ) -> Result<JsValue, JsValue> {
181        self.pre_pass_streaming_impl(
182            data,
183            on_event,
184            chunk_size,
185            disabled_type_names,
186            skip_type_geometry,
187            None,
188            false,
189            None,
190        )
191    }
192
193    #[allow(clippy::too_many_arguments)]
194    pub(crate) fn pre_pass_streaming_impl(
195        &self,
196        data: &[u8],
197        on_event: &Function,
198        chunk_size: u32,
199        disabled_type_names: Option<Vec<String>>,
200        skip_type_geometry: bool,
201        prebuilt: Option<ifc_lite_core::ColumnarEntityIndex>,
202        external_styles: bool,
203        columns: Option<super::prepass_discovery::IndexColumns<'_>>,
204    ) -> Result<JsValue, JsValue> {
205        let prebuilt_arc: Option<std::sync::Arc<ifc_lite_core::ColumnarEntityIndex>> =
206            prebuilt.map(std::sync::Arc::new);
207        // Load START on the streaming pre-pass path (see build_pre_pass_once).
208        self.reset_pipeline_diagnostics();
209        use ifc_lite_core::{has_geometry_by_name, EntityDecoder, EntityScanner, IfcType};
210        use ifc_lite_geometry::GeometryRouter;
211        use ifc_lite_processing::stream_meta::{resolve_stream_meta, MetaMode};
212
213        let chunk_size = chunk_size.max(1024) as usize;
214        let content = data;
215
216        // Build the load-time skip set (uppercase STEP keywords). Empty when the
217        // caller passes nothing → no filtering.
218        let disabled_types: rustc_hash::FxHashSet<String> = disabled_type_names
219            .unwrap_or_default()
220            .into_iter()
221            .map(|s| s.to_ascii_uppercase())
222            .collect();
223
224        // Single-pass scan: gather (id, start, end, type) for everything,
225        // tag geometry-bearing rows so we can emit jobs incrementally.
226        // Entity index is built from the same pass — no second walk.
227        let mut scanner = EntityScanner::new(content);
228        // Cap the up-front index reservation. On wasm32 the whole `content` slice
229        // is already resident in the 4GB linear memory (wasm-bindgen copies the
230        // buffer in), so reserving `len/50` slots — ~82M entries (~1GB) for a
231        // ~4GB file — ON TOP of that exhausts the address space before the scan
232        // even starts, aborting with a bare `unreachable executed`. Reserve at
233        // most CAP entries; a rarer huge model grows the map via rehash (a
234        // one-time cost) instead of a fatal up-front OOM. Ordinary (<2GB) files
235        // are unaffected — their `len/50` estimate stays under the cap.
236        const PREPASS_INDEX_RESERVE_CAP: usize = 40_000_000; // ~0.5GB reserved
237        let estimated = (content.len() / 50).min(PREPASS_INDEX_RESERVE_CAP);
238        let mut entity_index: rustc_hash::FxHashMap<u32, (usize, usize)> =
239            rustc_hash::FxHashMap::with_capacity_and_hasher(estimated, Default::default());
240
241        let mut buffered_jobs: Vec<(u32, usize, usize, IfcType)> = Vec::with_capacity(chunk_size);
242        let mut total_jobs: u32 = 0;
243        let mut project_id: Option<u32> = None;
244        let mut site_position: Option<(u32, usize, usize)> = None;
245        let mut meta_emitted = false;
246
247        // #957 / #563 single-scan hoist: the workers each re-walk the whole file
248        // on their first batch to rebuild these three per-content structures
249        // (referenced RepresentationMaps, instantiated type ids, the material-
250        // layer index). Collect the spans they need HERE, during the one scan the
251        // pre-pass already runs, then build + ship each ONCE below so every
252        // worker skips its own full-file walk. `rel_associates_material` spans are
253        // already stashed in `prepass_spans`.
254        let mut mapped_item_spans: Vec<(u32, usize, usize)> = Vec::new();
255        let mut rel_defines_by_type_spans: Vec<(u32, usize, usize)> = Vec::new();
256        // #957/#962: IfcTypeProduct candidates (id, span, resolved type), stashed
257        // here so the orphan-type-geometry pass reuses THIS scan instead of a
258        // second full EntityScanner walk over the file. `IfcType` is captured
259        // from the scanner's `type_name` so it matches `collect_type_geometry_jobs`
260        // byte-for-byte.
261        let mut type_candidate_spans: Vec<(u32, usize, usize, IfcType)> = Vec::new();
262        // Mirror `get_or_build_material_layer_index`'s `IFCMATERIALLAYERSET`
263        // substring gate exactly, but detect it from the scan (a layer-set
264        // keyword only appears as an entity type) so we never re-scan the file:
265        // an `IfcMaterialLayerSet`/`...Usage` entity is present iff the gate fires.
266        let mut has_layer_set = false;
267        // Plane-angle scale, resolved with the meta by the shared resolver and
268        // carried on the meta event so workers seed their batch decoders.
269        let mut plane_angle_to_radians = 1.0f64;
270
271        // Hold a chunk buffer that we drain to JS — these are the last
272        // `chunk_size` jobs awaiting flush. After `meta` the buffer is
273        // drained as the first jobs event; subsequent flushes happen at
274        // every `chunk_size` boundary.
275        const RTC_SAMPLE_THRESHOLD: usize = 50;
276
277        // Emit a chunk of jobs to JS as a Uint32Array of [id, start, end] triples,
278        // PLUS a parallel `affinity` Uint32Array (one precomputed key per job). The
279        // host dispatcher routes all jobs sharing an affinity key to the SAME
280        // worker, so byte-identical geometry the exporter failed to share via
281        // IfcMappedItem is meshed once per model instead of once per worker (#1130
282        // follow-up). `affinity` must be the same length as `jobs`; keys are the
283        // element's exact geometry hash (see the post-scan pass that builds them).
284        fn emit_jobs_chunk(
285            on_event: &Function,
286            jobs: &[(u32, usize, usize, IfcType)],
287            affinity: &[u32],
288        ) -> Result<(), JsValue> {
289            if jobs.is_empty() {
290                return Ok(());
291            }
292            let arr = js_sys::Uint32Array::new_with_length((jobs.len() * 3) as u32);
293            let aff = js_sys::Uint32Array::new_with_length(jobs.len() as u32);
294            let mut idx = 0u32;
295            for (j, &(id, start, end, _)) in jobs.iter().enumerate() {
296                arr.set_index(idx, id);
297                arr.set_index(idx + 1, start as u32);
298                arr.set_index(idx + 2, end as u32);
299                idx += 3;
300                aff.set_index(j as u32, affinity.get(j).copied().unwrap_or(id));
301            }
302            let event = js_sys::Object::new();
303            crate::api::set_js_prop(&event, "type", &"jobs".into());
304            crate::api::set_js_prop(&event, "jobs", &arr);
305            crate::api::set_js_prop(&event, "affinity", &aff);
306            on_event.call1(&JsValue::NULL, &event.into())?;
307            Ok(())
308        }
309
310        // Spans of entities that need decoding for style collection — we
311        // can't decode mid-scan because the decoder borrows `content` and
312        // would need `entity_index` populated for any references it follows.
313        // Stash them in the SHARED span container and resolve after the scan
314        // with `ifc_lite_processing::prepass::resolve_prepass` — the exact
315        // resolver the native pipeline and `buildPrePassOnce` run.
316        let mut prepass_spans = ifc_lite_processing::prepass::PrepassSpans::default();
317
318        // STAGE 2: fill collectors from the class columns; no byte scan.
319        if let Some((cids, cstarts, clengths, cclasses)) = columns {
320            let d = super::prepass_discovery::discover_from_columns(
321                content, cids, cstarts, clengths, cclasses, &disabled_types,
322            );
323            buffered_jobs = d.buffered_jobs;
324            total_jobs = d.total_jobs;
325            project_id = d.project_id;
326            site_position = d.site_position;
327            prepass_spans = d.prepass_spans;
328            prepass_spans.styled_items = Vec::new(); // shards resolve styles
329            mapped_item_spans = d.mapped_item_spans;
330            rel_defines_by_type_spans = d.rel_defines_by_type_spans;
331            type_candidate_spans = d.type_candidate_spans;
332            has_layer_set = d.has_layer_set;
333        } else {
334        while let Some((id, type_name, start, end)) = scanner.next_entity() {
335            if prebuilt_arc.is_none() {
336                entity_index.insert(id, (start, end)); // prebuilt mode: map unused
337            }
338
339            match type_name {
340                "IFCPROJECT" => {
341                    if project_id.is_none() {
342                        project_id = Some(id);
343                    }
344                }
345                "IFCSITE" => {
346                    if site_position.is_none() {
347                        site_position = Some((id, start, end));
348                    }
349                    let ifc_type = IfcType::from_str(type_name);
350                    buffered_jobs.push((id, start, end, ifc_type));
351                    total_jobs += 1;
352                }
353                "IFCSTYLEDITEM" => {
354                    prepass_spans.styled_items.push((id, start, end));
355                }
356                "IFCINDEXEDCOLOURMAP" => {
357                    prepass_spans.indexed_colour_maps.push((id, start, end));
358                }
359                "IFCMATERIALDEFINITIONREPRESENTATION" => {
360                    prepass_spans.material_def_reprs.push((id, start, end));
361                }
362                "IFCRELASSOCIATESMATERIAL" => {
363                    prepass_spans.rel_associates_material.push((id, start, end));
364                }
365                "IFCRELVOIDSELEMENT" => {
366                    prepass_spans.void_rels.push((id, start, end));
367                }
368                "IFCRELFILLSELEMENT" => {
369                    prepass_spans.fills_rels.push((id, start, end));
370                }
371                "IFCRELAGGREGATES" => {
372                    prepass_spans.aggregate_rels.push((id, start, end));
373                }
374                "IFCMAPPEDITEM" => {
375                    mapped_item_spans.push((id, start, end));
376                }
377                "IFCRELDEFINESBYTYPE" => {
378                    rel_defines_by_type_spans.push((id, start, end));
379                }
380                "IFCMATERIALLAYERSET" | "IFCMATERIALLAYERSETUSAGE" => {
381                    has_layer_set = true;
382                }
383                _ => {
384                    // #957/#962: an IfcTypeProduct subtype (its geometry is
385                    // authored on RepresentationMaps, not the type itself, so it
386                    // never matches `has_geometry_by_name`). Stash it for the
387                    // orphan-type pass; the RepresentationMaps attr-6 decode +
388                    // referenced-filter happens later in
389                    // `collect_type_geometry_jobs_from_spans`.
390                    if type_name.ends_with("TYPE") || type_name.ends_with("STYLE") {
391                        let type_ty = IfcType::from_str(type_name);
392                        if type_ty.is_subtype_of(IfcType::IfcTypeProduct) {
393                            type_candidate_spans.push((id, start, end, type_ty));
394                        }
395                    }
396                    if has_geometry_by_name(type_name) && !disabled_types.contains(type_name) {
397                        let ifc_type = IfcType::from_str(type_name);
398                        // We don't bucket by simple/complex here — the host
399                        // distributes work across N geometry workers anyway,
400                        // and the simple/complex split was a heuristic for
401                        // RTC sampling that we now resolve once after
402                        // RTC_SAMPLE_THRESHOLD jobs have been collected.
403                        buffered_jobs.push((id, start, end, ifc_type));
404                        total_jobs += 1;
405                    } else if !disabled_types.contains(type_name)
406                        && ifc_lite_core::is_representationless_spatial_container_by_name(
407                            type_name,
408                        )
409                        && ifc_lite_core::nth_attribute_is_present(&content[start..end], 6)
410                    {
411                        // #1910: mirrors the identical exception in
412                        // `rust/processing/src/processor/mod.rs` — a spatial
413                        // container `has_geometry_by_name` blocks by name
414                        // (`IfcBuilding` et al.) that exceptionally carries a
415                        // real `Representation` (e.g. a DGM/terrain export
416                        // with no `IfcBuildingElement` children at all) must
417                        // still be scheduled for meshing, or the browser
418                        // viewer renders nothing despite a correct scene tree.
419                        let ifc_type = IfcType::from_str(type_name);
420                        buffered_jobs.push((id, start, end, ifc_type));
421                        total_jobs += 1;
422                    }
423                }
424            }
425
426            // Once enough sample jobs are buffered, resolve the meta (unit
427            // scales + RTC offset + building rotation) and emit it along with
428            // the buffered first chunk so workers can start. The gate
429            // deliberately does NOT wait for IFCPROJECT: IfcOpenShell/Revit
430            // exports emit it near the END of the file, and waiting would
431            // delay every worker until ~90% of the scan on such models. The
432            // shared resolver finds a not-yet-scanned project by SIMD
433            // substring search and resolves partial-index chains against a
434            // full index instead of silently defaulting (a millimetre model
435            // resolved as metres renders 1000× oversized).
436            if !meta_emitted && buffered_jobs.len() >= RTC_SAMPLE_THRESHOLD {
437                // MID-SCAN meta emission — the streaming win (~17 s → ~3 s
438                // time-to-first-geometry on a 986 MB file). The RESOLUTION logic
439                // (3-stage RTC ladder: partial-index detect → full-index
440                // re-detect when the partial index resolved no placement chain →
441                // placement-bounds last resort) lives in the shared
442                // `resolve_stream_meta` so it cannot drift from the tail /
443                // `buildPrePassOnce` paths. Emission STAYS HERE, unchanged: the
444                // meta event is dispatched the moment RTC_SAMPLE_THRESHOLD jobs
445                // are buffered, near the top of the file, so workers spin up
446                // early. Do NOT move this to a post-scan point — that regresses
447                // every large file.
448                // PREBUILT index (sharded): full index available, so run the
449                // single-stage full-index ladder (what the partial ladder
450                // escalates to anyway) — no mid-scan full-rescan detour.
451                let meta_res = if let Some(pi) = &prebuilt_arc {
452                    let mut decoder =
453                        EntityDecoder::with_arc_columnar_index(content, pi.clone());
454                    resolve_stream_meta(
455                        MetaMode::SmallFileSingle,
456                        content,
457                        project_id,
458                        site_position,
459                        &buffered_jobs,
460                        &mut decoder,
461                    )
462                } else {
463                    let mut decoder = EntityDecoder::with_index(content, entity_index.clone());
464                    resolve_stream_meta(
465                        MetaMode::StreamingPartial,
466                        content,
467                        project_id,
468                        site_position,
469                        &buffered_jobs,
470                        &mut decoder,
471                    )
472                };
473                plane_angle_to_radians = meta_res.plane_angle_to_radians;
474
475                // Emit meta event.
476                let meta = js_sys::Object::new();
477                crate::api::set_js_prop(&meta, "type", &"meta".into());
478                super::prepass_sharded::set_stream_meta_props(&meta, &meta_res);
479                on_event.call1(&JsValue::NULL, &meta.into())?;
480                meta_emitted = true;
481                // Jobs stay buffered through the scan; the post-scan pass
482                // emits them with exact geometry-hash affinity keys (workers
483                // gate on post-scan events anyway, so deferring is free).
484                continue;
485            }
486        }
487
488        }
489
490        // Tail meta: small files (scan path) + every columns-path file.
491        if !meta_emitted {
492            // Build a decoder lazily for unit/RTC/site lookups. With a
493            // sub-50-job file the scan is essentially instant anyway, so the
494            // full entity index is already complete here — the single-stage
495            // `SmallFileSingle` ladder (one detect_rtc_offset_with_fallback) is
496            // correct, sharing its resolution with `buildPrePassOnce`.
497            let meta_jobs = &buffered_jobs[..buffered_jobs.len().min(RTC_SAMPLE_THRESHOLD)];
498            let meta_res = if let Some(pi) = &prebuilt_arc {
499                let mut decoder = EntityDecoder::with_arc_columnar_index(content, pi.clone());
500                resolve_stream_meta(
501                    MetaMode::SmallFileSingle,
502                    content,
503                    project_id,
504                    site_position,
505                    meta_jobs,
506                    &mut decoder,
507                )
508            } else {
509                let mut decoder = EntityDecoder::with_index(content, entity_index.clone());
510                resolve_stream_meta(
511                    MetaMode::SmallFileSingle,
512                    content,
513                    project_id,
514                    site_position,
515                    &buffered_jobs,
516                    &mut decoder,
517                )
518            };
519            plane_angle_to_radians = meta_res.plane_angle_to_radians;
520
521            let meta = js_sys::Object::new();
522            crate::api::set_js_prop(&meta, "type", &"meta".into());
523            super::prepass_sharded::set_stream_meta_props(&meta, &meta_res);
524            on_event.call1(&JsValue::NULL, &meta.into())?;
525        }
526
527        // Cache for processGeometryBatch reuse. Convert the scan's FxHashMap
528        // into a compact columnar index (sorted u32 columns + binary search):
529        // ~229 MB vs the hashmap's ~436 MB on a 19.1 M-entity model (#1682).
530        // Consuming frees the map before sorting: one interleaved transient.
531        let entity_index_arc = match prebuilt_arc {
532            Some(pi) => pi,
533            None => std::sync::Arc::new(ifc_lite_core::ColumnarEntityIndex::from_hashmap_consuming(
534                entity_index,
535            )),
536        };
537        let have_prebuilt = external_styles; // sharded mode always passes both
538
539        // Mutex held only briefly to install the Arc.
540        {
541            let mut slot = self
542                .cached_entity_index
543                .lock()
544                .unwrap_or_else(std::sync::PoisonError::into_inner);
545            *slot = Some(entity_index_arc.clone());
546        }
547        // Hold a second clone for the post-scan entity-index export below;
548        // `with_arc_columnar_index` consumes the Arc so we'd lose the
549        // reference after the decoder is created.
550        let index_for_export = entity_index_arc.clone();
551
552        // ── FAST FIRST GEOMETRY ──
553        // Workers gate on entity-index + styles + the first jobs chunk; all
554        // three ship right here post-scan (a SMALL id-routed first wave, then
555        // the affinity pass over the REST — dedup distribution stays intact).
556
557        // (A) Entity-index — workers re-scan the whole file (~5 s) without it.
558        // Sharded mode skips this: the host delivered the stitched index first.
559        if !have_prebuilt {
560            // Bulk-copy in 3 boundary crossings, not ~8.4M per-entry set_index
561            // calls (workers' critical path). Columns are already sorted by id,
562            // so consumers hit ColumnarEntityIndex::from_columns' O(n)
563            // already-sorted fast path (no per-worker argsort).
564            let ids_arr = js_sys::Uint32Array::from(index_for_export.ids());
565            let starts_arr = js_sys::Uint32Array::from(index_for_export.starts());
566            let lengths_arr = js_sys::Uint32Array::from(index_for_export.lengths());
567            let index_event = js_sys::Object::new();
568            crate::api::set_js_prop(&index_event, "type", &"entity-index".into());
569            crate::api::set_js_prop(&index_event, "ids", &ids_arr);
570            crate::api::set_js_prop(&index_event, "starts", &starts_arr);
571            crate::api::set_js_prop(&index_event, "lengths", &lengths_arr);
572            on_event.call1(&JsValue::NULL, &index_event.into())?;
573        }
574
575        // (B) Styles + voids — workers also gate on this. Serial mode resolves
576        // + emits here (shared resolver; MaterialLayerIndex::from_content is
577        // deliberately skipped, aggregate void propagation included); sharded
578        // (external_styles) mode leaves styles to the worker shards + finalize.
579        // `decoder` stays in scope below for the orphan type-geometry pass.
580        let mut decoder = EntityDecoder::with_arc_columnar_index(content, entity_index_arc.clone());
581        decoder.seed_unit_scales(1.0, plane_angle_to_radians);
582        if !external_styles {
583            let resolved = ifc_lite_processing::prepass::resolve_prepass(
584                &prepass_spans,
585                &mut decoder,
586                ifc_lite_processing::prepass::ResolveOptions {
587                    collect_indexed_colour_full: false,
588                    defer_attached_styles: false,
589                },
590            );
591            let styles_event = super::prepass_sharded::styles_payload(&resolved, &mut decoder);
592            crate::api::set_js_prop(&styles_event, "type", &"styles".into());
593            on_event.call1(&JsValue::NULL, &styles_event.into())?;
594        }
595
596        // (B2) Pre-pass columns — the three per-content structures each geometry
597        // worker would otherwise rebuild with its OWN full-file walk on its first
598        // batch (issue #957 orphan/instanced type geometry + #563 material-layer
599        // slicing). Built ONCE here from spans this scan already collected, then
600        // installed on every worker via `set{ReferencedRepmaps,InstantiatedTypeIds,
601        // MaterialLayerIndex}`. Emitted BEFORE the first jobs chunk (below) and
602        // workers apply messages FIFO, so the injected data is always in place
603        // before any `processGeometryBatch` — the lazy per-worker build (the
604        // byte-identical fallback) never fires on the streaming path.
605        let referenced_repmaps =
606            crate::api::styling::build_referenced_representation_maps_from_spans(
607                &mapped_item_spans,
608                &mut decoder,
609            );
610        let instantiated_type_ids =
611            crate::api::styling::build_instantiated_type_ids_from_spans(
612                &rel_defines_by_type_spans,
613                &mut decoder,
614            );
615        // #1623 Phase 3 don't-bake plan: the RepresentationMap ids an IfcMappedItem
616        // instantiates >= 2 times, tallied from the SAME spans (no extra scan). The
617        // batch path arms its router with these so a repeated single-solid mapped
618        // source materializes once per batch and the rest ride as shard instances.
619        let mapped_instance_plan =
620            crate::api::styling::build_mapped_instance_plan_from_spans(
621                &mapped_item_spans,
622                &mut decoder,
623            );
624        // Gate on `has_layer_set` to stay bit-identical to
625        // `get_or_build_material_layer_index`, which ships an EMPTY index when the
626        // file authors no layer set (its substring bail-out). from_spans reuses
627        // the already-stashed IfcRelAssociatesMaterial spans — no extra walk.
628        let material_layer_flat = if has_layer_set {
629            ifc_lite_geometry::MaterialLayerIndex::from_spans(
630                &prepass_spans.rel_associates_material,
631                &mut decoder,
632            )
633            .to_flat()
634        } else {
635            ifc_lite_geometry::MaterialLayerFlat::default()
636        };
637
638        let repmaps_arr: Vec<u32> = referenced_repmaps.into_iter().collect();
639        let type_ids_arr: Vec<u32> = instantiated_type_ids.into_iter().collect();
640        let columns_event = js_sys::Object::new();
641        crate::api::set_js_prop(&columns_event, "type", &"prepass-columns".into());
642        crate::api::set_js_prop(
643            &columns_event,
644            "referencedRepmaps",
645            &js_sys::Uint32Array::from(repmaps_arr.as_slice()),
646        );
647        crate::api::set_js_prop(
648            &columns_event,
649            "instantiatedTypeIds",
650            &js_sys::Uint32Array::from(type_ids_arr.as_slice()),
651        );
652        crate::api::set_js_prop(
653            &columns_event,
654            "mappedInstancePlan",
655            &js_sys::Uint32Array::from(mapped_instance_plan.as_slice()),
656        );
657        crate::api::set_js_prop(
658            &columns_event,
659            "mliElementIds",
660            &js_sys::Uint32Array::from(material_layer_flat.element_ids.as_slice()),
661        );
662        crate::api::set_js_prop(
663            &columns_event,
664            "mliAxis",
665            &js_sys::Uint32Array::from(material_layer_flat.axis.as_slice()),
666        );
667        crate::api::set_js_prop(
668            &columns_event,
669            "mliLayerCounts",
670            &js_sys::Uint32Array::from(material_layer_flat.layer_counts.as_slice()),
671        );
672        crate::api::set_js_prop(
673            &columns_event,
674            "mliDirectionSense",
675            &js_sys::Float64Array::from(&material_layer_flat.direction_sense[..]),
676        );
677        crate::api::set_js_prop(
678            &columns_event,
679            "mliOffset",
680            &js_sys::Float64Array::from(&material_layer_flat.offset[..]),
681        );
682        crate::api::set_js_prop(
683            &columns_event,
684            "mliLayerMaterialIds",
685            &js_sys::Uint32Array::from(material_layer_flat.layer_material_ids.as_slice()),
686        );
687        crate::api::set_js_prop(
688            &columns_event,
689            "mliLayerThicknesses",
690            &js_sys::Float64Array::from(&material_layer_flat.layer_thicknesses[..]),
691        );
692        on_event.call1(&JsValue::NULL, &columns_event.into())?;
693
694        // (C) First wave — a small chunk routed by element id (no affinity hash)
695        // so workers get a quick, cheap first batch the instant the gate opens.
696        // Small enough to mesh in a fraction of a second across the pool; the
697        // dedup it forgoes on these few elements is negligible.
698        const FIRST_WAVE_JOBS: usize = 1024;
699        let first_n = buffered_jobs.len().min(FIRST_WAVE_JOBS);
700        if first_n > 0 {
701            let first_aff: Vec<u32> =
702                buffered_jobs[..first_n].iter().map(|&(id, ..)| id).collect();
703            emit_jobs_chunk(on_event, &buffered_jobs[..first_n], &first_aff)?;
704        }
705
706        // (D) Affinity-route the REST + stream the bulk. The host routes all jobs
707        // of a key to ONE worker so each unique geometry is meshed once per model;
708        // the per-worker dedup cache turns the rest into cheap hits. On decode
709        // failure the key falls back to the element id (its own bucket).
710        {
711            let mut akey_decoder =
712                EntityDecoder::with_arc_columnar_index(content, entity_index_arc.clone());
713            let akey_router = GeometryRouter::new();
714            let rest = &buffered_jobs[first_n..];
715            let mut affinity: Vec<u32> = Vec::with_capacity(rest.len());
716            for &(id, _s, _e, _t) in rest {
717                let key = match akey_decoder.decode_by_id(id) {
718                    Ok(ent) => akey_router
719                        .geometry_routing_key(&ent, &mut akey_decoder)
720                        .map(fold_u128_to_u32)
721                        .unwrap_or(id),
722                    Err(_) => id,
723                };
724                affinity.push(key);
725            }
726            for (jobs_chunk, aff_chunk) in
727                rest.chunks(chunk_size).zip(affinity.chunks(chunk_size))
728            {
729                emit_jobs_chunk(on_event, jobs_chunk, aff_chunk)?;
730            }
731        }
732        buffered_jobs.clear();
733
734        // (Style + void resolution and the entity-index export now run ABOVE,
735        // before the affinity bulk, so workers receive them as early as possible —
736        // see the FAST FIRST GEOMETRY block. `decoder` from there stays in scope
737        // for the orphan type-geometry pass below.)
738
739        // #957: emit orphan IfcTypeProduct geometry as a final jobs chunk so the
740        // browser renders annex-E type-only "tessellated shape with style" files
741        // (geometry on the type via RepresentationMaps, no occurrence). The
742        // entity index is complete here, so this resolves cleanly.
743        //
744        // #962 done: the mapped-item source + type-candidate spans were collected
745        // by the streaming scan above, so this resolves orphans from those stashes
746        // with NO second EntityScanner pass. The gate is the SAME predicate the old
747        // `collect_type_geometry_jobs` bailed on — an `IFCREPRESENTATIONMAP`
748        // substring (a SIMD memmem, not a full scan) — so behaviour stays byte-
749        // identical to the old path and `combined_pre_pass`, free on the no-type case.
750        // #1097 perf: the viewer's default Model view does not render the
751        // type-library (#957) geometry, so skip producing it at load when the
752        // caller asks (the Types view re-loads on demand).
753        if !skip_type_geometry
754            && memchr::memmem::find(content, b"IFCREPRESENTATIONMAP").is_some()
755        {
756            let type_jobs = crate::api::styling::collect_type_geometry_jobs_from_spans(
757                &mapped_item_spans,
758                &type_candidate_spans,
759                &mut decoder,
760            );
761            if !type_jobs.is_empty() {
762                total_jobs += type_jobs.len() as u32;
763                // Type-library geometry is a small, usually-suppressed tail; route
764                // each job to its own bucket (id key) so they spread round-robin.
765                let type_affinity: Vec<u32> = type_jobs.iter().map(|&(id, ..)| id).collect();
766                emit_jobs_chunk(on_event, &type_jobs, &type_affinity)?;
767            }
768        }
769
770        // Complete event.
771        let done = js_sys::Object::new();
772        crate::api::set_js_prop(&done, "type", &"complete".into());
773        crate::api::set_js_prop(&done, "totalJobs", &(total_jobs as f64).into());
774        on_event.call1(&JsValue::NULL, &done.into())?;
775
776        Ok(JsValue::UNDEFINED)
777    }
778}
779
780#[cfg(test)]
781mod affinity_tests {
782    use super::fold_u128_to_u32;
783
784    #[test]
785    fn fold_is_stable_and_mixes_all_lanes() {
786        // Identical hashes fold to identical keys (routing stickiness).
787        assert_eq!(fold_u128_to_u32(0x1234_5678_9abc_def0_1111_2222_3333_4444),
788                   fold_u128_to_u32(0x1234_5678_9abc_def0_1111_2222_3333_4444));
789        // A change confined to ANY single 32-bit lane changes the key — so two
790        // geometries differing only in their high bits still route apart.
791        let base = 0u128;
792        assert_ne!(fold_u128_to_u32(base), fold_u128_to_u32(base | (1u128 << 0)));
793        assert_ne!(fold_u128_to_u32(base), fold_u128_to_u32(base | (1u128 << 40)));
794        assert_ne!(fold_u128_to_u32(base), fold_u128_to_u32(base | (1u128 << 72)));
795        assert_ne!(fold_u128_to_u32(base), fold_u128_to_u32(base | (1u128 << 120)));
796    }
797}