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}