ifc_lite_wasm/api/mod.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! JavaScript API for IFC-Lite
6//!
7//! Modern async/await API for parsing IFC files.
8
9mod alignment_lines;
10mod bool2d;
11mod clash;
12mod clash_solid;
13mod csg_diagnostics;
14mod diagnose;
15mod export_data;
16mod export_dfjson;
17mod export_glb;
18mod export_hbjson;
19mod export_obj;
20mod export_step;
21mod extract_profiles;
22mod gpu_meshes;
23mod grid_lines;
24mod mesh_outline;
25mod parsing;
26mod pipeline_diagnostics;
27mod simplify;
28mod space_plate;
29pub(crate) mod styling;
30mod symbolic;
31mod zone_split;
32
33use csg_diagnostics::drain_and_log_csg_diagnostics;
34
35use ifc_lite_core::ColumnarEntityIndex;
36use wasm_bindgen::prelude::*;
37
38/// `TessellationQuality::Medium` as the atomic discriminant stored on
39/// [`IfcAPI::tessellation_quality`] (0 = Lowest … 4 = Highest).
40const TESSELLATION_QUALITY_MEDIUM: u8 = 2;
41
42/// Main IFC-Lite API
43#[wasm_bindgen]
44pub struct IfcAPI {
45 initialized: bool,
46 /// Cached entity index from buildPrePassOnce, reused by processGeometryBatch.
47 ///
48 /// A compact [`ColumnarEntityIndex`] (three sorted `u32` columns +
49 /// binary-search lookup) not an `FxHashMap`: a 19.1 M-entity hashmap rounds
50 /// up to `2^25` buckets ≈ 436 MB per worker realm; the columns are ~229 MB
51 /// (#1682). Wrapped in `Arc` so successive `processGeometryBatch` calls
52 /// reuse it without cloning the columns on every call.
53 ///
54 /// Phase 1.1 of the single-controller refactor: switched from
55 /// `RefCell` to `Mutex` so the API is `Sync`. Rayon helpers (added
56 /// in Phase 2) need to be able to call processGeometryBatch via
57 /// `&self` from multiple threads without UB. The lock is held only
58 /// at batch entry (lock → clone Arc → unlock → use cloned Arc) so
59 /// hot-path contention is negligible — each call locks once and
60 /// rayon helpers operate on the cloned Arc lock-free thereafter.
61 /// `RefCell` was unsafe here even on single-threaded WASM workers
62 /// because wasm-bindgen's `WasmRefCell` borrow counter underflows
63 /// under concurrent `&self` access.
64 cached_entity_index: std::sync::Mutex<Option<std::sync::Arc<ColumnarEntityIndex>>>,
65
66 /// Session source bytes (the whole IFC file) held ONCE per load, so the
67 /// streaming batch path stops re-copying the file into the wasm heap on
68 /// EVERY `processGeometryBatch*` call. `passArray8ToWasm0` mallocs + memcpys
69 /// the full `data` slice every call (~4 ms/call on 169 MB); a huge CSG-dense
70 /// model adapts down to 64-job batches and makes 600+ calls/worker, so the
71 /// per-call copy alone is 15-25 s/worker of pure memcpy. The bytes are
72 /// IDENTICAL across a worker's calls (one model per `IfcAPI`), so one copy
73 /// suffices: `setSourceBytes` stores it here and the `*FromSource` batch
74 /// variants read it instead of taking `data`. Set once per load (mirrors
75 /// `cached_entity_index`); dropped by `clearPrePassCache`. NOT dropped by
76 /// `setEntityIndex` — it is REPLACED wholesale by the next `setSourceBytes`,
77 /// and the JS worker always calls `setSourceBytes` for the current session
78 /// before any `*FromSource` batch, so the held bytes always match the index.
79 cached_source_bytes: std::sync::Mutex<Option<std::sync::Arc<Vec<u8>>>>,
80
81 /// Per-worker shared content-dedup cache (#1109 follow-up). The
82 /// `GeometryRouter` is rebuilt every `processGeometryBatch`, so its item-mesh
83 /// dedup cache would reset each batch. Holding ONE cache here and injecting it
84 /// into every batch router lets byte-identical geometry mesh once across the
85 /// whole worker's workload — e.g. Tekla connection plates/bolts the exporter
86 /// emitted as thousands of separate items instead of one `IfcMappedItem`.
87 /// Built lazily on first batch; one model per `IfcApi` instance, exactly like
88 /// `cached_entity_index`.
89 cached_item_dedup: std::sync::Mutex<Option<ifc_lite_geometry::ItemDedupCache>>,
90
91 /// Per-worker shared `IfcMappedItem` source cache (#1623). The `GeometryRouter`
92 /// is rebuilt every `processGeometryBatch`, so its per-router `mapped_item_cache`
93 /// would reset each batch and only dedup within a batch. Holding ONE cache here
94 /// and injecting it into every batch router meshes each RepresentationMap source
95 /// once across the whole worker's workload. Built lazily on first batch; one
96 /// model per `IfcApi` instance, exactly like `cached_item_dedup`. Dropped on a
97 /// content swap AND on `setTessellationQuality` (a quality change invalidates
98 /// the source-coord tessellation — the key is the source id, not the quality).
99 cached_mapped_item: std::sync::Mutex<Option<ifc_lite_geometry::SharedMappedItemCache>>,
100
101 /// When `true`, `processGeometryBatch` suppresses geometry emission for
102 /// every `IfcBuildingElementPart` whose `IfcRelAggregates` parent (a) has
103 /// its own `Representation` and (b) is marked `Sliceable` in
104 /// `MaterialLayerIndex`. The parent wall's single solid then carries the
105 /// per-layer colour slices instead of N separate part meshes. Defaults to
106 /// `false` (existing behaviour). See issue #540.
107 ///
108 /// Stored as an atomic so it can be toggled from JS between parse calls
109 /// without locking — all parse paths read it once at the top.
110 merge_layers: std::sync::atomic::AtomicBool,
111
112 /// Lazily-built skip set used by `processGeometryBatch` when `merge_layers` is on. The set
113 /// holds every `IfcBuildingElementPart` express ID whose parent wall
114 /// (a) has its own `Representation` and (b) is sliceable in
115 /// `MaterialLayerIndex` — i.e. the parts that should be suppressed
116 /// because the parent's single solid covers their geometry.
117 ///
118 /// Built on first batch call and shared with all subsequent calls on
119 /// the same content. Cleared by `clearPrePassCache` (between loads)
120 /// and by `setMergeLayers` (so toggling rebuilds against the latest
121 /// flag value).
122 cached_parts_to_skip: std::sync::Mutex<Option<std::sync::Arc<rustc_hash::FxHashSet<u32>>>>,
123
124 /// Lazily-built per-content `MaterialLayerIndex` (#563). Single-solid walls
125 /// and slabs carrying an `IfcMaterialLayerSetUsage` are sliced into one
126 /// sub-mesh per layer (geometry_id = the layer's `IfcMaterial`) so the
127 /// build-up is visible in 3D. Built once per load and attached to EVERY
128 /// batch router via `set_material_layer_index` so `try_layered_sub_meshes`
129 /// can fire — #874 dropped that wiring and silently disabled slicing for
130 /// the whole browser stream. Cleared by `clearPrePassCache` between loads.
131 cached_material_layer_index:
132 std::sync::Mutex<Option<std::sync::Arc<ifc_lite_geometry::MaterialLayerIndex>>>,
133
134 /// Lazily-built set of `IfcRepresentationMap` ids that an `IfcMappedItem`
135 /// instantiates (issue #957). `processGeometryBatch` uses it to decide which
136 /// of a type's RepresentationMaps are orphan and should be rendered directly
137 /// (the rest are drawn through their occurrence). Built once per worker on
138 /// the first type-product job and cleared by `clearPrePassCache`.
139 cached_referenced_repmaps: std::sync::Mutex<Option<std::sync::Arc<rustc_hash::FxHashSet<u32>>>>,
140
141 /// Lazily-built set of type ids that an `IfcRelDefinesByType` instantiates
142 /// (the type has an occurrence). `processGeometryBatch` uses it to suppress
143 /// type-only geometry for instanced types — their geometry already draws
144 /// through their occurrences, so rendering the type's RepresentationMap too
145 /// would double-render it at the MappingOrigin. Built once per worker and
146 /// cleared by `clearPrePassCache`.
147 cached_instantiated_type_ids:
148 std::sync::Mutex<Option<std::sync::Arc<rustc_hash::FxHashSet<u32>>>>,
149
150 /// #1623 Phase 3 don't-bake plan: `IfcRepresentationMap` id ⇒ `(count, min-id)`
151 /// for every source an `IfcMappedItem` instantiates >= 2 times (the streaming
152 /// pre-pass tallies it in the same scan that builds `cached_referenced_repmaps`).
153 /// When present AND the instanced/partitioned batch path runs, the batch router
154 /// is armed with it in BATCH-LOCAL mode so a repeated single-solid mapped source
155 /// meshes ONCE per batch (the first-seen occurrence) and the rest ride as
156 /// per-occurrence instances in the IFNS shard — killing the per-occurrence
157 /// vertex materialize. `None` (never installed) ⇒ the batch path materializes
158 /// every occurrence exactly as before (byte-identical). Cleared on content swap
159 /// (`setEntityIndex`) and `clearPrePassCache`, like the other pre-pass columns.
160 cached_mapped_instance_plan:
161 std::sync::Mutex<Option<ifc_lite_geometry::MappedInstancePlan>>,
162
163 /// Lazily-built surface-texture index keyed by face-set id (issue #961):
164 /// decoded RGBA images + per-triangle UV maps from
165 /// `IfcIndexedTriangleTextureMap`. Built once per worker (cheap substring
166 /// bail-out for untextured files) and cleared by `clearPrePassCache`.
167 cached_texture_index: std::sync::Mutex<
168 Option<std::sync::Arc<rustc_hash::FxHashMap<u32, ifc_lite_geometry::ResolvedTextureMap>>>,
169 >,
170
171 /// Lazily-built `IfcIndexedColourMap` index keyed by target geometry id,
172 /// used by `processGeometryBatch` to split a tessellated face set into one
173 /// sub-mesh per palette group (issue #858). The browser geometry path lost
174 /// this split in the #874 mesh-pipeline unification — it kept only the
175 /// dominant colour per geometry. Built once per worker on first batch call
176 /// (a single extra entity scan, cached) and cleared by `clearPrePassCache`.
177 cached_indexed_colour_maps: std::sync::Mutex<
178 Option<
179 std::sync::Arc<
180 rustc_hash::FxHashMap<u32, ifc_lite_processing::style::FullIndexedColourMap>,
181 >,
182 >,
183 >,
184
185 /// When `true`, `processGeometryBatch` computes a per-entity geometry
186 /// fingerprint (see `ifc_lite_geometry::geom_hash`) and returns it on the
187 /// `MeshCollection`. Powers the viewer's "compare two revisions" diff: an
188 /// unchanged element hashes identically across files, a moved/reshaped one
189 /// differs. Default `false` so normal rendering pays nothing.
190 ///
191 /// Atomic so it can be toggled from JS between parse calls without locking;
192 /// the batch path reads it once at the top.
193 compute_geometry_hashes: std::sync::atomic::AtomicBool,
194
195 /// Quantization grid (metres) used when `compute_geometry_hashes` is on.
196 /// Stored as `f64::to_bits` in a `u64` atomic so the `(enabled, tolerance)`
197 /// pair stays lock-free. Only read when `compute_geometry_hashes` is true.
198 geometry_hash_tolerance_bits: std::sync::atomic::AtomicU64,
199
200 /// Tessellation detail level applied by `processGeometryBatch` (issue #976,
201 /// step 4). Stored as the `TessellationQuality` discriminant (0 = Lowest …
202 /// 4 = Highest) in an atomic so JS can toggle it between parse calls
203 /// without locking — same contract as `merge_layers`. Default is Medium,
204 /// which reproduces the historical hardcoded densities byte-for-byte.
205 tessellation_quality: std::sync::atomic::AtomicU8,
206
207 /// Tier-independent small-cut skip switch (#1286). When set, `processGeometryBatch`
208 /// drops `IfcBooleanResult` differences whose cutter is tiny relative to its host
209 /// (steel copes/notches) WITHOUT lowering the tessellation tier, so curves keep
210 /// full density. Default off ⇒ every cut runs (byte-identical to before). Applied
211 /// to the per-batch `GeometryRouter` via `GeometryRouter::set_skip_small_cuts`.
212 skip_small_cuts: std::sync::atomic::AtomicBool,
213
214 /// Lazily-resolved plane-angle → radians scale for the current content,
215 /// seeded into every batch decoder via `EntityDecoder::seed_unit_scales`.
216 /// `EntityDecoder::plane_angle_to_radians()` walks the whole DATA section
217 /// to find the singleton `IFCPROJECT` — which IfcOpenShell emits near the
218 /// *end* of the file — and its cache is per-decoder, so without this
219 /// per-worker cache every `processGeometryBatch` call re-pays an O(file)
220 /// scan the moment any arc-bearing profile is tessellated (≈ the geometry
221 /// stream stall on large models with late IFCPROJECT). Content-scoped:
222 /// cleared by `clearPrePassCache` and on entity-index swap.
223 cached_plane_angle_to_radians: std::sync::Mutex<Option<f64>>,
224 /// #1097 perf: the geometry-style maps (style-entity-id → RGBA, and the
225 /// derived `GeometryStyleInfo` index the canonical producer consumes) are
226 /// rebuilt from the flat wire arrays on EVERY `processGeometryBatch` call,
227 /// but those arrays are session-constant (set once via the streaming
228 /// `styles` event). On a model with ~140 K styled entities that's two
229 /// 140 K-entry HashMaps built ~30×/worker (~18 M inserts each). Cache both,
230 /// keyed by a cheap (len, first_id, last_id) signature of the wire arrays —
231 /// rebuilt only when the signature changes.
232 #[allow(clippy::type_complexity)]
233 cached_geometry_styles: std::sync::Mutex<
234 Option<(
235 usize,
236 u32,
237 u32,
238 std::sync::Arc<(
239 rustc_hash::FxHashMap<u32, [f32; 4]>,
240 rustc_hash::FxHashMap<u32, ifc_lite_processing::style::GeometryStyleInfo>,
241 )>,
242 )>,
243 >,
244
245 /// Per-load structured pipeline diagnostics (`PipelineDiagnostics`
246 /// contract, see `api::pipeline_diagnostics`): every
247 /// `processGeometryBatch*` call folds one batch record in — cheap
248 /// counters plus per-batch JS wall time, so it is always on. Read by JS
249 /// via `getPipelineDiagnostics`; reset at load START by every entry point
250 /// that begins a new file on a reused IfcAPI (`buildPrePassOnce`,
251 /// `buildPrePassStreaming`, `setEntityIndex`) - deliberately NOT by
252 /// `clearPrePassCache`, which runs at end-of-load before a host reads the
253 /// diagnostics.
254 pipeline_diagnostics: std::sync::Mutex<ifc_lite_processing::PipelineDiagnostics>,
255}
256
257#[wasm_bindgen]
258impl IfcAPI {
259 /// Create and initialize the IFC API
260 #[wasm_bindgen(constructor)]
261 pub fn new() -> Self {
262 // MUST be `set_panic_hook()`, never `console_error_panic_hook::set_once()`
263 // directly — that would replace init()'s panic-location stash. Idempotent.
264 crate::utils::set_panic_hook();
265
266 Self {
267 initialized: true,
268 cached_entity_index: std::sync::Mutex::new(None),
269 cached_source_bytes: std::sync::Mutex::new(None),
270 cached_item_dedup: std::sync::Mutex::new(None),
271 cached_mapped_item: std::sync::Mutex::new(None),
272 merge_layers: std::sync::atomic::AtomicBool::new(false),
273 cached_parts_to_skip: std::sync::Mutex::new(None),
274 cached_material_layer_index: std::sync::Mutex::new(None),
275 cached_referenced_repmaps: std::sync::Mutex::new(None),
276 cached_instantiated_type_ids: std::sync::Mutex::new(None),
277 cached_mapped_instance_plan: std::sync::Mutex::new(None),
278 cached_texture_index: std::sync::Mutex::new(None),
279 cached_indexed_colour_maps: std::sync::Mutex::new(None),
280 compute_geometry_hashes: std::sync::atomic::AtomicBool::new(false),
281 geometry_hash_tolerance_bits: std::sync::atomic::AtomicU64::new(
282 ifc_lite_geometry::DEFAULT_GEOM_HASH_TOLERANCE.to_bits(),
283 ),
284 tessellation_quality: std::sync::atomic::AtomicU8::new(TESSELLATION_QUALITY_MEDIUM),
285 skip_small_cuts: std::sync::atomic::AtomicBool::new(false),
286 cached_plane_angle_to_radians: std::sync::Mutex::new(None),
287 cached_geometry_styles: std::sync::Mutex::new(None),
288 pipeline_diagnostics: std::sync::Mutex::new(
289 ifc_lite_processing::PipelineDiagnostics::default(),
290 ),
291 }
292 }
293
294 /// Check if API is initialized
295 #[wasm_bindgen(getter)]
296 pub fn is_ready(&self) -> bool {
297 self.initialized
298 }
299
300 /// Clear the cached entity index (call between loads when reusing
301 /// the same `IfcAPI` instance — e.g. the parser worker keeps one
302 /// `IfcAPI` alive across multiple `parse` requests).
303 ///
304 /// Recovers a poisoned cache Mutex instead of panicking; see `mod_tests.rs`.
305 #[wasm_bindgen(js_name = clearPrePassCache)]
306 pub fn clear_pre_pass_cache(&self) {
307 let mut slot = self
308 .cached_entity_index
309 .lock()
310 .unwrap_or_else(std::sync::PoisonError::into_inner);
311 slot.take();
312 // The parts-to-skip set is keyed off the content scanned during
313 // the previous load; drop it together with the entity index so the
314 // next file's first batch call rebuilds against fresh content.
315 let mut parts_slot = self
316 .cached_parts_to_skip
317 .lock()
318 .unwrap_or_else(std::sync::PoisonError::into_inner);
319 parts_slot.take();
320 // The material-layer index is keyed off the previous load's content.
321 self.cached_material_layer_index
322 .lock()
323 .unwrap_or_else(std::sync::PoisonError::into_inner)
324 .take();
325 // The referenced-RepresentationMap set is keyed off the previous load's
326 // content; drop it so the next file rebuilds against fresh content.
327 let mut repmap_slot = self
328 .cached_referenced_repmaps
329 .lock()
330 .unwrap_or_else(std::sync::PoisonError::into_inner);
331 repmap_slot.take();
332 // The instantiated-type-ids set is keyed off the previous load's content.
333 let mut inst_slot = self
334 .cached_instantiated_type_ids
335 .lock()
336 .unwrap_or_else(std::sync::PoisonError::into_inner);
337 inst_slot.take();
338 // The don't-bake mapped-instance plan is keyed off the previous load's
339 // IfcMappedItem scan (#1623 Phase 3); drop it so the next file rebuilds it.
340 self.cached_mapped_instance_plan
341 .lock()
342 .unwrap_or_else(std::sync::PoisonError::into_inner)
343 .take();
344 // The texture index is keyed off the previous load's content; drop it.
345 let mut texture_slot = self
346 .cached_texture_index
347 .lock()
348 .unwrap_or_else(std::sync::PoisonError::into_inner);
349 texture_slot.take();
350 // The indexed-colour-map index is also keyed off the previous load's
351 // content; drop it so the next file rebuilds against fresh content.
352 let mut icm_slot = self
353 .cached_indexed_colour_maps
354 .lock()
355 .unwrap_or_else(std::sync::PoisonError::into_inner);
356 icm_slot.take();
357 // The plane-angle scale belongs to the previous load's content.
358 self.cached_plane_angle_to_radians
359 .lock()
360 .unwrap_or_else(std::sync::PoisonError::into_inner)
361 .take();
362 // The geometry-style maps belong to the previous load's wire styles.
363 self.cached_geometry_styles
364 .lock()
365 .unwrap_or_else(std::sync::PoisonError::into_inner)
366 .take();
367 // The content-dedup cache holds the previous model's item meshes, keyed by
368 // a content hash of that model's entities. Drop it so a new file on the
369 // same reused IfcAPI starts with an empty cache (bounds memory across
370 // loads; defensive even though the key is content- not id-based).
371 self.cached_item_dedup
372 .lock()
373 .unwrap_or_else(std::sync::PoisonError::into_inner)
374 .take();
375 // The session source bytes are the previous load's whole file; drop them
376 // at end-of-load cleanup so a reused IfcAPI doesn't retain a ~100s-of-MB
377 // copy between loads (the next load's setSourceBytes re-installs its own).
378 self.cached_source_bytes
379 .lock()
380 .unwrap_or_else(std::sync::PoisonError::into_inner)
381 .take();
382 // The mapped-item source cache holds the previous model's source meshes,
383 // keyed by RepresentationMap id (baking in that load's unit scale /
384 // tessellation quality). Drop it so a new file on the same reused IfcAPI
385 // starts empty (#1623).
386 self.cached_mapped_item
387 .lock()
388 .unwrap_or_else(std::sync::PoisonError::into_inner)
389 .take();
390 // NB: do NOT reset pipeline_diagnostics here. clearPrePassCache runs in
391 // the JS load wrapper's `finally` AFTER the last processGeometryBatch
392 // (packages/geometry/src/index.ts), i.e. end-of-load cleanup; resetting
393 // here would erase the just-completed load's diagnostics before a host
394 // can read getPipelineDiagnostics(). Diagnostics are an accumulator, not
395 // a cache, so they reset only at load START (set_entity_index), unlike
396 // cached_item_dedup which is safe to drop on every clear.
397 }
398
399 /// Populate `cached_entity_index` from pre-extracted column arrays.
400 ///
401 /// Used by the streaming pre-pass to share its already-built entity
402 /// index across worker realms via SAB-backed Uint32Arrays — every
403 /// process worker would otherwise re-scan the entire file in
404 /// `processGeometryBatch`'s lazy build path (~5 s on a 1 GB IFC),
405 /// even though the pre-pass worker built the same index minutes
406 /// earlier.
407 ///
408 /// Builds a compact [`ColumnarEntityIndex`] from the three input slices
409 /// (sorted `u32` columns + binary search) instead of a per-worker
410 /// `FxHashMap` — ~229 MB vs ~436 MB on a 19.1 M-entity model (#1682).
411 /// [`ColumnarEntityIndex::from_columns`] verifies the id ordering once
412 /// (O(n)) and only argsorts if the producer did not emit sorted columns.
413 ///
414 /// `lengths[i]` is the byte length of entity `ids[i]`, so lookup returns
415 /// `(start, start + length)` to match the existing `(start, end)` layout.
416 ///
417 /// Idempotent in the sense that repeated calls REPLACE the cache —
418 /// supports the parser-worker pattern of reusing one IfcAPI across
419 /// multiple loads with different files.
420 #[wasm_bindgen(js_name = setEntityIndex)]
421 pub fn set_entity_index(&self, ids: &[u32], starts: &[u32], lengths: &[u32]) {
422 let n = ids.len();
423 if n == 0 || starts.len() != n || lengths.len() != n {
424 return;
425 }
426 let index = ColumnarEntityIndex::from_columns(ids, starts, lengths);
427 let mut slot = self
428 .cached_entity_index
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 *slot = Some(std::sync::Arc::new(index));
432 drop(slot);
433
434 // Swapping the entity index means a different file. The other caches are
435 // content-scoped (keyed off the previous load) — carrying them into the
436 // next file would wrongly suppress/keep orphan type geometry, reuse a
437 // stale texture index, or skip the wrong parts. Drop them so they
438 // rebuild against the new content (#962 review). Mirrors clearPrePassCache.
439 self.cached_parts_to_skip
440 .lock()
441 .unwrap_or_else(std::sync::PoisonError::into_inner)
442 .take();
443 self.cached_material_layer_index
444 .lock()
445 .unwrap_or_else(std::sync::PoisonError::into_inner)
446 .take();
447 self.cached_referenced_repmaps
448 .lock()
449 .unwrap_or_else(std::sync::PoisonError::into_inner)
450 .take();
451 self.cached_instantiated_type_ids
452 .lock()
453 .unwrap_or_else(std::sync::PoisonError::into_inner)
454 .take();
455 self.cached_mapped_instance_plan
456 .lock()
457 .unwrap_or_else(std::sync::PoisonError::into_inner)
458 .take();
459 self.cached_texture_index
460 .lock()
461 .unwrap_or_else(std::sync::PoisonError::into_inner)
462 .take();
463 self.cached_indexed_colour_maps
464 .lock()
465 .unwrap_or_else(std::sync::PoisonError::into_inner)
466 .take();
467 self.cached_plane_angle_to_radians
468 .lock()
469 .unwrap_or_else(std::sync::PoisonError::into_inner)
470 .take();
471 // The geometry-style maps belong to the previous load's wire styles —
472 // drop them on content swap so a reused IfcAPI can't reuse a stale map
473 // (the (len,first,last) signature would otherwise collide rarely).
474 self.cached_geometry_styles
475 .lock()
476 .unwrap_or_else(std::sync::PoisonError::into_inner)
477 .take();
478 // The content-dedup cache holds the previous model's item meshes — drop it
479 // on content swap so a reused IfcAPI starts the new file with an empty
480 // cache (bounds memory across loads).
481 self.cached_item_dedup
482 .lock()
483 .unwrap_or_else(std::sync::PoisonError::into_inner)
484 .take();
485 // The mapped-item source cache holds the previous model's source meshes —
486 // drop it on content swap so a reused IfcAPI starts the new file empty
487 // (bounds memory across loads; #1623).
488 self.cached_mapped_item
489 .lock()
490 .unwrap_or_else(std::sync::PoisonError::into_inner)
491 .take();
492 // A new entity index means a new file — the pipeline diagnostics
493 // describe the previous load, so start fresh.
494 self.reset_pipeline_diagnostics();
495 }
496
497 /// Install the pre-computed set of `IfcRepresentationMap` ids referenced by
498 /// an `IfcMappedItem` (issue #957), so the worker's first type-product batch
499 /// SKIPS the per-worker [`Self::get_or_build_referenced_repmaps`] full-file
500 /// walk. The streaming pre-pass built the same set once from the
501 /// `IfcMappedItem` spans it already scanned (see
502 /// `styling::build_referenced_representation_maps_from_spans`) and ships the
503 /// id list here — bit-identical to what each worker would compute, since a
504 /// set's membership is order-invariant and consumers only call `.contains`.
505 ///
506 /// Installed AFTER `setEntityIndex` (which clears this cache on content
507 /// swap), so the injected value survives. When this setter is never called
508 /// (native path, non-streaming callers), the lazy build path is unchanged.
509 #[wasm_bindgen(js_name = setReferencedRepmaps)]
510 pub fn set_referenced_repmaps(&self, ids: &[u32]) {
511 let set: rustc_hash::FxHashSet<u32> = ids.iter().copied().collect();
512 let mut slot = self
513 .cached_referenced_repmaps
514 .lock()
515 .unwrap_or_else(std::sync::PoisonError::into_inner);
516 *slot = Some(std::sync::Arc::new(set));
517 }
518
519 /// Install the pre-computed set of type ids that an `IfcRelDefinesByType`
520 /// instantiates (#957 follow-up), so the worker's first type-product batch
521 /// skips the per-worker [`Self::get_or_build_instantiated_type_ids`]
522 /// full-file walk. Same injection contract as [`Self::set_referenced_repmaps`].
523 #[wasm_bindgen(js_name = setInstantiatedTypeIds)]
524 pub fn set_instantiated_type_ids(&self, ids: &[u32]) {
525 let set: rustc_hash::FxHashSet<u32> = ids.iter().copied().collect();
526 let mut slot = self
527 .cached_instantiated_type_ids
528 .lock()
529 .unwrap_or_else(std::sync::PoisonError::into_inner);
530 *slot = Some(std::sync::Arc::new(set));
531 }
532
533 /// Install the pre-computed #1623 Phase 3 don't-bake plan: the flat list of
534 /// `IfcRepresentationMap` ids that an `IfcMappedItem` instantiates >= 2 times.
535 /// The streaming pre-pass tallies it in the SAME scan that builds the referenced-
536 /// repmap set (`styling::build_mapped_instance_plan_from_spans`) and ships the id
537 /// list here. The batch path arms its router with it (batch-local template mode),
538 /// so a repeated single-solid mapped source materializes ONCE per batch and the
539 /// rest ride as instances in the IFNS shard.
540 ///
541 /// Same injection contract as [`Self::set_referenced_repmaps`]: installed after
542 /// `setEntityIndex` (which clears it on content swap), and a no-op absence leaves
543 /// the batch path materializing every occurrence (byte-identical). Each id is
544 /// stored as `(2, id)` — the batch-local router only needs the eligibility set
545 /// (count >= 2); the min-id template slot is unused in batch-local mode.
546 #[wasm_bindgen(js_name = setMappedInstancePlan)]
547 pub fn set_mapped_instance_plan(&self, source_ids: &[u32]) {
548 if source_ids.is_empty() {
549 // Nothing repeated ⇒ leave the plan unset so the router never arms.
550 return;
551 }
552 let plan: rustc_hash::FxHashMap<u32, (u32, u32)> =
553 source_ids.iter().map(|&id| (id, (2u32, id))).collect();
554 let mut slot = self
555 .cached_mapped_instance_plan
556 .lock()
557 .unwrap_or_else(std::sync::PoisonError::into_inner);
558 *slot = Some(std::sync::Arc::new(plan));
559 }
560
561 /// Install the pre-computed [`ifc_lite_geometry::MaterialLayerIndex`] (#563)
562 /// from its flat SoA encoding, so the worker's first batch skips the
563 /// per-worker [`Self::get_or_build_material_layer_index`] full-file decode
564 /// scan (the dominant first-batch cost on layered architectural models,
565 /// which run this on the DEFAULT view). The streaming pre-pass built the
566 /// index once from the `IfcRelAssociatesMaterial` spans it already scanned
567 /// (`MaterialLayerIndex::from_spans`) and flat-encoded it here; the flat
568 /// encoding round-trips bit-for-bit (proven in `material_layer_index` tests),
569 /// so the injected index equals each worker's `from_content` result.
570 ///
571 /// Same injection contract as [`Self::set_referenced_repmaps`]: installed
572 /// after `setEntityIndex`, and a no-op absence leaves the lazy build intact.
573 #[allow(clippy::too_many_arguments)]
574 #[wasm_bindgen(js_name = setMaterialLayerIndex)]
575 pub fn set_material_layer_index(
576 &self,
577 element_ids: &[u32],
578 axis: &[u32],
579 layer_counts: &[u32],
580 direction_sense: &[f64],
581 offset: &[f64],
582 layer_material_ids: &[u32],
583 layer_thicknesses: &[f64],
584 ) {
585 let index = ifc_lite_geometry::MaterialLayerIndex::from_flat(
586 element_ids,
587 axis,
588 layer_counts,
589 direction_sense,
590 offset,
591 layer_material_ids,
592 layer_thicknesses,
593 );
594 let mut slot = self
595 .cached_material_layer_index
596 .lock()
597 .unwrap_or_else(std::sync::PoisonError::into_inner);
598 *slot = Some(std::sync::Arc::new(index));
599 }
600
601 /// Get WASM memory for zero-copy access
602 #[wasm_bindgen(js_name = getMemory)]
603 pub fn get_memory(&self) -> JsValue {
604 crate::zero_copy::get_memory()
605 }
606
607 /// Get version string
608 #[wasm_bindgen(getter)]
609 pub fn version(&self) -> String {
610 env!("CARGO_PKG_VERSION").to_string()
611 }
612
613 /// Toggle the "render multilayer walls as a single solid" mode (issue #540).
614 ///
615 /// When `enabled` is `true`, every subsequent `processGeometryBatch` call
616 /// will suppress geometry emission for `IfcBuildingElementPart` entities
617 /// whose `IfcRelAggregates` parent wall is sliceable (has an
618 /// `IfcMaterialLayerSetUsage`) AND has its own `Representation`. The
619 /// parent wall keeps its per-layer sub-mesh colouring, so the visual
620 /// result is the same as the layered render but with one mesh per wall
621 /// instead of one per layer part — much cheaper for both CPU and GPU.
622 ///
623 /// Default is `false`. Pass `true` before calling `processGeometryBatch`.
624 #[wasm_bindgen(js_name = setMergeLayers)]
625 pub fn set_merge_layers(&self, enabled: bool) {
626 self.merge_layers
627 .store(enabled, std::sync::atomic::Ordering::Relaxed);
628 // Drop any cached skip set so the next batch rebuilds against the
629 // current flag value — toggling off must immediately stop skipping.
630 let mut parts_slot = self
631 .cached_parts_to_skip
632 .lock()
633 .unwrap_or_else(std::sync::PoisonError::into_inner);
634 parts_slot.take();
635 }
636
637 /// Enable or disable the PARAMETRIC rectangular-opening fast path (the
638 /// placement-frame, ground-truth-exact analytic cut) for `processGeometryBatch`.
639 ///
640 /// DEFAULT ON (corpus-validated; native defaults ON too, and wasm has no env to
641 /// read `IFC_LITE_RECT_PARAM`, so both targets default in LOCKSTEP -- the
642 /// byte-identical native==wasm contract requires both take the same path). This
643 /// toggle is the wasm-side escape hatch mirroring `IFC_LITE_RECT_PARAM=0`.
644 /// The path subtracts rectangular openings as exact parametric boxes in the host's
645 /// own placement frame (rotated walls included), deferring any non-clean case to
646 /// the exact kernel. Pass `false` before `processGeometryBatch` to opt out.
647 #[wasm_bindgen(js_name = setRectParamFastPath)]
648 pub fn set_rect_param_fast_path(&self, enabled: bool) {
649 ifc_lite_geometry::rect_fast::param_set_enabled_override(Some(enabled));
650 }
651
652 /// Enable or disable per-entity geometry fingerprinting in
653 /// `processGeometryBatch`, used by the viewer's revision-diff feature.
654 ///
655 /// Pass a positive `tolerance` (metres) to enable — the quantization grid
656 /// positions snap to (larger tolerates more float noise, smaller catches
657 /// finer edits; below the `f32` precision floor of model-local coordinates,
658 /// ~1 mm, mostly hashes noise). Finer than
659 /// `ifc_lite_geometry::MIN_GEOM_HASH_TOLERANCE` (1e-6 m) is clamped up to it
660 /// — see that constant's doc for why (an `i128` overflow surface, not a
661 /// precision win). `null`/`undefined`/non-positive disables. Default: off.
662 #[wasm_bindgen(js_name = setComputeGeometryHashes)]
663 pub fn set_compute_geometry_hashes(&self, tolerance: Option<f64>) {
664 use std::sync::atomic::Ordering::Relaxed;
665 match tolerance {
666 Some(t) if t > 0.0 => {
667 self.geometry_hash_tolerance_bits
668 .store(t.to_bits(), Relaxed);
669 self.compute_geometry_hashes.store(true, Relaxed);
670 }
671 _ => self.compute_geometry_hashes.store(false, Relaxed),
672 }
673 }
674
675 /// Select the tessellation detail level applied by every subsequent
676 /// `processGeometryBatch` call (issue #976, step 4).
677 ///
678 /// `level` is one of `"lowest" | "low" | "medium" | "high" | "highest"`
679 /// (case-insensitive). `"medium"` is the default and reproduces the
680 /// engine's historical hardcoded densities byte-for-byte; lower levels
681 /// trade curved-surface smoothness for throughput, higher levels reduce
682 /// faceting on pipes / cylinders / NURBS at a triangle-count cost.
683 /// Pass `null`/`undefined` to reset to the default.
684 ///
685 /// Set BEFORE processing — meshes already emitted are not regenerated.
686 /// Throws on an unrecognized level so typos fail loudly instead of
687 /// silently rendering at the wrong density.
688 #[wasm_bindgen(js_name = setTessellationQuality)]
689 pub fn set_tessellation_quality(&self, level: Option<String>) -> Result<(), JsValue> {
690 let discriminant = match level.as_deref() {
691 None => TESSELLATION_QUALITY_MEDIUM,
692 Some(s) => match ifc_lite_geometry::TessellationQuality::parse_label(s) {
693 Some(q) => q.to_index(),
694 None => {
695 return Err(JsValue::from_str(&format!(
696 "Unknown tessellation quality '{s}' — expected \
697 lowest | low | medium | high | highest"
698 )))
699 }
700 },
701 };
702 self.tessellation_quality
703 .store(discriminant, std::sync::atomic::Ordering::Relaxed);
704 // The mapped-item source cache is keyed by RepresentationMap id, not by
705 // quality, and bakes in the tessellation density of its curved sub-items.
706 // A quality change would otherwise serve stale-density source meshes across
707 // subsequent batches, so drop it here — mirroring the router's own
708 // `set_tessellation_quality`, which clears its per-router `mapped_item_cache`
709 // for the same reason. (The content-dedup cache folds quality INTO its key,
710 // so it needs no such clear.)
711 self.cached_mapped_item
712 .lock()
713 .unwrap_or_else(std::sync::PoisonError::into_inner)
714 .take();
715 Ok(())
716 }
717
718 /// Toggle the tier-independent small-cut skip (#1286). When `true`,
719 /// `processGeometryBatch` drops `IfcBooleanResult` differences whose cutter is
720 /// tiny relative to its host (steel copes/notches) while keeping the
721 /// tessellation tier — so curves stay full-density. The viewer enables this for
722 /// the on-screen load; exports/drawings leave it off so their geometry keeps
723 /// every cut. Default off ⇒ byte-identical to before.
724 ///
725 /// Set BEFORE processing — meshes already emitted are not regenerated.
726 #[wasm_bindgen(js_name = setSkipSmallCuts)]
727 pub fn set_skip_small_cuts(&self, on: bool) {
728 self.skip_small_cuts
729 .store(on, std::sync::atomic::Ordering::Relaxed);
730 // `skip_small_cuts` swaps the boolean/CSG processors, so a mapped source
731 // containing IfcBooleanResult/IfcCsgSolid meshes differently under it. The
732 // source cache is keyed by RepresentationMap id, not by this flag, so a
733 // toggle would otherwise serve stale-fidelity source meshes (e.g. a worker
734 // reused by a full-fidelity export after a `fast` load). Drop it here,
735 // mirroring `set_tessellation_quality`.
736 self.cached_mapped_item
737 .lock()
738 .unwrap_or_else(std::sync::PoisonError::into_inner)
739 .take();
740 }
741}
742
743impl IfcAPI {
744 /// Internal accessor used by the parse pipelines to decide whether to
745 /// skip `IfcBuildingElementPart` emission. Not exposed to JS — JS
746 /// callers control the flag via [`Self::set_merge_layers`].
747 pub(crate) fn merge_layers(&self) -> bool {
748 self.merge_layers.load(std::sync::atomic::Ordering::Relaxed)
749 }
750
751 /// Active tessellation quality, read once at the top of
752 /// `processGeometryBatch`. JS controls it via
753 /// [`Self::set_tessellation_quality`].
754 pub(crate) fn tessellation_quality(&self) -> ifc_lite_geometry::TessellationQuality {
755 use ifc_lite_geometry::TessellationQuality;
756 TessellationQuality::from_index(
757 self.tessellation_quality
758 .load(std::sync::atomic::Ordering::Relaxed),
759 )
760 }
761
762 /// Active small-cut skip flag, applied to the per-batch `GeometryRouter` at
763 /// the top of `processGeometryBatch`. JS controls it via [`Self::set_skip_small_cuts`].
764 pub(crate) fn skip_small_cuts(&self) -> bool {
765 self.skip_small_cuts
766 .load(std::sync::atomic::Ordering::Relaxed)
767 }
768
769 /// Active geometry-hash tolerance (metres), or `None` when fingerprinting
770 /// is disabled. Read once at the top of `processGeometryBatch`. JS controls
771 /// it via [`Self::set_compute_geometry_hashes`].
772 pub(crate) fn geometry_hash_tolerance(&self) -> Option<f64> {
773 use std::sync::atomic::Ordering::Relaxed;
774 if self.compute_geometry_hashes.load(Relaxed) {
775 Some(f64::from_bits(
776 self.geometry_hash_tolerance_bits.load(Relaxed),
777 ))
778 } else {
779 None
780 }
781 }
782
783 /// Get or lazily build the cached parts-to-skip set used by
784 /// `processGeometryBatch` when the merge-layers toggle is on. Two
785 /// full-file scans (`MaterialLayerIndex` plus `propagate_voids_to_parts`)
786 /// are amortised across every batch on the same content; first-call cost
787 /// ~one IFC re-scan, subsequent calls are an `Arc::clone`.
788 ///
789 /// Returns an empty set when no eligible parts exist — callers can
790 /// still cheaply test `parts.contains(&id)` without a branch.
791 pub(crate) fn get_or_build_parts_to_skip(
792 &self,
793 content: &[u8],
794 decoder: &mut ifc_lite_core::EntityDecoder,
795 ) -> std::sync::Arc<rustc_hash::FxHashSet<u32>> {
796 {
797 let slot = self
798 .cached_parts_to_skip
799 .lock()
800 .unwrap_or_else(std::sync::PoisonError::into_inner);
801 if let Some(existing) = slot.as_ref() {
802 return std::sync::Arc::clone(existing);
803 }
804 }
805
806 // The layer/void driver now lives in the geometry crate next to its
807 // kernels (#913 Phase 4 / §2.6); this method just caches its result
808 // per content so it isn't recomputed on every batch.
809 let skip_set = ifc_lite_geometry::compute_parts_to_skip(content, decoder);
810
811 let arc = std::sync::Arc::new(skip_set);
812 let mut slot = self
813 .cached_parts_to_skip
814 .lock()
815 .unwrap_or_else(std::sync::PoisonError::into_inner);
816 *slot = Some(std::sync::Arc::clone(&arc));
817 arc
818 }
819
820 /// Get or lazily build the per-content [`MaterialLayerIndex`] (#563) used to
821 /// slice single-solid walls/slabs with an `IfcMaterialLayerSetUsage` into one
822 /// sub-mesh per layer. Built once per load (one IFCRELASSOCIATESMATERIAL
823 /// decode scan, with a cheap substring bail-out on files that carry no layer
824 /// set) and `Arc`-shared with every batch router so `try_layered_sub_meshes`
825 /// fires. Subsequent batches are an `Arc::clone`.
826 pub(crate) fn get_or_build_material_layer_index(
827 &self,
828 content: &[u8],
829 decoder: &mut ifc_lite_core::EntityDecoder,
830 ) -> std::sync::Arc<ifc_lite_geometry::MaterialLayerIndex> {
831 {
832 let slot = self
833 .cached_material_layer_index
834 .lock()
835 .unwrap_or_else(std::sync::PoisonError::into_inner);
836 if let Some(existing) = slot.as_ref() {
837 return std::sync::Arc::clone(existing);
838 }
839 }
840
841 // Most models carry no IfcMaterialLayerSet. A cheap raw-byte substring
842 // probe (no entity decode) lets us cache an EMPTY index without the
843 // per-`IfcRelAssociatesMaterial` decode scan `from_content` runs — the
844 // cost the streaming pre-pass deliberately avoided. Only layered files
845 // pay the full build; non-layered files behave identically (an absent
846 // entry and a `NotSliceable` entry both mean "don't slice").
847 const LAYER_SET_KW: &[u8] = b"IFCMATERIALLAYERSET";
848 // memmem (SIMD O(n)) not the naive O(n*k) `windows().any()`: this runs on
849 // the whole file on each worker's first batch call, so on a 200-340MB model
850 // the naive scan cost ~100-400ms per worker. Byte-identical boolean.
851 let has_layer_set = memchr::memmem::find(content, LAYER_SET_KW).is_some();
852 let index = if has_layer_set {
853 ifc_lite_geometry::MaterialLayerIndex::from_content(content, decoder)
854 } else {
855 ifc_lite_geometry::MaterialLayerIndex::new()
856 };
857 // Diagnostic (#563/#874): stay silent for the ~99% of models with no
858 // sliceable buildup (every load otherwise logged a line). Only speak up
859 // when there's something to slice — or when the layer-set keyword is
860 // present but NOTHING resolved as sliceable (e.g. an IfcMaterialLayerSet
861 // associated without a LayerSetUsage), which is the case worth flagging.
862 // The per-batch "sliced N wall(s)" line already reports success.
863 let sliceable = index.sliceable_count();
864 if sliceable > 0 {
865 web_sys::console::info_1(
866 &format!("[ifc-lite layers] {sliceable} sliceable buildup(s) of {} association(s)", index.len()).into(),
867 );
868 } else if has_layer_set {
869 web_sys::console::warn_1(
870 &"[ifc-lite layers] IfcMaterialLayerSet present but no sliceable buildup (LayerSetUsage missing?)".into(),
871 );
872 }
873 let arc = std::sync::Arc::new(index);
874 let mut slot = self
875 .cached_material_layer_index
876 .lock()
877 .unwrap_or_else(std::sync::PoisonError::into_inner);
878 *slot = Some(std::sync::Arc::clone(&arc));
879 arc
880 }
881
882 /// The installed #1623 Phase 3 don't-bake plan (see [`Self::set_mapped_instance_plan`]),
883 /// or `None` when the pre-pass shipped no repeated mapped sources. UNLIKE the
884 /// referenced-repmap set there is NO lazy full-file fallback: the plan is a pure
885 /// optimization, so absence just means "materialize every occurrence" (the
886 /// byte-identical default), never a correctness gap.
887 pub(crate) fn mapped_instance_plan(&self) -> Option<ifc_lite_geometry::MappedInstancePlan> {
888 self.cached_mapped_instance_plan
889 .lock()
890 .unwrap_or_else(std::sync::PoisonError::into_inner)
891 .as_ref()
892 .map(std::sync::Arc::clone)
893 }
894
895 /// Get or lazily build the set of `IfcRepresentationMap` ids instantiated by
896 /// an `IfcMappedItem` (issue #957). `processGeometryBatch` uses it to render
897 /// only the ORPHAN RepresentationMaps of a type-product (the rest are drawn
898 /// through their occurrence). Cached per worker so the scan is paid once.
899 pub(crate) fn get_or_build_referenced_repmaps(
900 &self,
901 content: &[u8],
902 decoder: &mut ifc_lite_core::EntityDecoder,
903 ) -> std::sync::Arc<rustc_hash::FxHashSet<u32>> {
904 {
905 let slot = self
906 .cached_referenced_repmaps
907 .lock()
908 .unwrap_or_else(std::sync::PoisonError::into_inner);
909 if let Some(existing) = slot.as_ref() {
910 return std::sync::Arc::clone(existing);
911 }
912 }
913
914 let referenced = styling::build_referenced_representation_maps(content, decoder);
915
916 let arc = std::sync::Arc::new(referenced);
917 let mut slot = self
918 .cached_referenced_repmaps
919 .lock()
920 .unwrap_or_else(std::sync::PoisonError::into_inner);
921 *slot = Some(std::sync::Arc::clone(&arc));
922 arc
923 }
924
925 /// Get or lazily build the set of type ids that an `IfcRelDefinesByType`
926 /// instantiates (#957 follow-up). `processGeometryBatch` uses it to suppress
927 /// type-only geometry for instanced types (the geometry already draws through
928 /// their occurrences). Cached per worker so the scan is paid once.
929 pub(crate) fn get_or_build_instantiated_type_ids(
930 &self,
931 content: &[u8],
932 decoder: &mut ifc_lite_core::EntityDecoder,
933 ) -> std::sync::Arc<rustc_hash::FxHashSet<u32>> {
934 {
935 let slot = self
936 .cached_instantiated_type_ids
937 .lock()
938 .unwrap_or_else(std::sync::PoisonError::into_inner);
939 if let Some(existing) = slot.as_ref() {
940 return std::sync::Arc::clone(existing);
941 }
942 }
943
944 let instantiated = styling::build_instantiated_type_ids(content, decoder);
945
946 let arc = std::sync::Arc::new(instantiated);
947 let mut slot = self
948 .cached_instantiated_type_ids
949 .lock()
950 .unwrap_or_else(std::sync::PoisonError::into_inner);
951 *slot = Some(std::sync::Arc::clone(&arc));
952 arc
953 }
954
955 /// Get or lazily build the surface-texture index keyed by face-set id
956 /// (issue #961): decoded RGBA images + per-triangle UV maps. Cached per
957 /// worker; `build_texture_index` bails out cheaply on untextured files.
958 pub(crate) fn get_or_build_texture_index(
959 &self,
960 content: &[u8],
961 decoder: &mut ifc_lite_core::EntityDecoder,
962 ) -> std::sync::Arc<rustc_hash::FxHashMap<u32, ifc_lite_geometry::ResolvedTextureMap>> {
963 {
964 let slot = self
965 .cached_texture_index
966 .lock()
967 .unwrap_or_else(std::sync::PoisonError::into_inner);
968 if let Some(existing) = slot.as_ref() {
969 return std::sync::Arc::clone(existing);
970 }
971 }
972
973 let index = ifc_lite_geometry::build_texture_index(content, decoder);
974
975 let arc = std::sync::Arc::new(index);
976 let mut slot = self
977 .cached_texture_index
978 .lock()
979 .unwrap_or_else(std::sync::PoisonError::into_inner);
980 *slot = Some(std::sync::Arc::clone(&arc));
981 arc
982 }
983
984 /// Get or lazily build the `IfcIndexedColourMap` index (geometry id →
985 /// full per-triangle palette) used by `processGeometryBatch` to split a
986 /// tessellated face set into one sub-mesh per palette group (issue #858).
987 ///
988 /// Mirrors the native processor's collection pass (processor.rs ~905):
989 /// one entity scan that decodes every `IFCINDEXEDCOLOURMAP` and resolves
990 /// it to a [`FullIndexedColourMap`]. Cached per worker so the scan is paid
991 /// once, not per batch. Returns an empty map when the file authors none
992 /// (the common case), so callers can cheaply `.get(&geometry_id)`.
993 pub(crate) fn get_or_build_indexed_colour_maps(
994 &self,
995 content: &[u8],
996 decoder: &mut ifc_lite_core::EntityDecoder,
997 ) -> std::sync::Arc<rustc_hash::FxHashMap<u32, ifc_lite_processing::style::FullIndexedColourMap>>
998 {
999 {
1000 let slot = self
1001 .cached_indexed_colour_maps
1002 .lock()
1003 .unwrap_or_else(std::sync::PoisonError::into_inner);
1004 if let Some(existing) = slot.as_ref() {
1005 return std::sync::Arc::clone(existing);
1006 }
1007 }
1008
1009 let mut map: rustc_hash::FxHashMap<u32, ifc_lite_processing::style::FullIndexedColourMap> =
1010 rustc_hash::FxHashMap::default();
1011 // Fast bail-out for the overwhelming common case: files with no
1012 // IfcIndexedColourMap pay only a single substring search (SIMD memmem),
1013 // not a full entity scan + decode, on the first batch of every worker.
1014 // The empty result is still cached so later batches skip even that.
1015 if memchr::memmem::find(content, b"IFCINDEXEDCOLOURMAP").is_none() {
1016 let arc = std::sync::Arc::new(map);
1017 let mut slot = self
1018 .cached_indexed_colour_maps
1019 .lock()
1020 .unwrap_or_else(std::sync::PoisonError::into_inner);
1021 *slot = Some(std::sync::Arc::clone(&arc));
1022 return arc;
1023 }
1024 let mut scanner = ifc_lite_core::EntityScanner::new(content);
1025 while let Some((_id, type_name, start, end)) = scanner.next_entity() {
1026 if type_name == "IFCINDEXEDCOLOURMAP" {
1027 if let Ok(icm) = decoder.decode_at(start, end) {
1028 if let Some(full) =
1029 ifc_lite_processing::style::resolve_indexed_colour_map_full(&icm, decoder)
1030 {
1031 map.entry(full.geometry_id).or_insert(full);
1032 }
1033 }
1034 }
1035 }
1036
1037 let arc = std::sync::Arc::new(map);
1038 let mut slot = self
1039 .cached_indexed_colour_maps
1040 .lock()
1041 .unwrap_or_else(std::sync::PoisonError::into_inner);
1042 *slot = Some(std::sync::Arc::clone(&arc));
1043 arc
1044 }
1045
1046 /// Resolve the file's plane-angle → radians scale once per worker and cache
1047 /// it. The underlying `EntityDecoder::plane_angle_to_radians()` walks the
1048 /// whole DATA section for `IFCPROJECT` (which IfcOpenShell emits near the
1049 /// end of the file) and caches only per-decoder — but `processGeometryBatch`
1050 /// builds a fresh decoder per call, so every batch would re-pay that
1051 /// O(file) scan. Callers seed the batch decoder with the cached value via
1052 /// `EntityDecoder::seed_unit_scales`.
1053 pub(crate) fn get_or_resolve_plane_angle(
1054 &self,
1055 decoder: &mut ifc_lite_core::EntityDecoder,
1056 ) -> f64 {
1057 {
1058 let slot = self
1059 .cached_plane_angle_to_radians
1060 .lock()
1061 .unwrap_or_else(std::sync::PoisonError::into_inner);
1062 if let Some(existing) = *slot {
1063 return existing;
1064 }
1065 }
1066
1067 let scale = decoder.plane_angle_to_radians();
1068
1069 let mut slot = self
1070 .cached_plane_angle_to_radians
1071 .lock()
1072 .unwrap_or_else(std::sync::PoisonError::into_inner);
1073 *slot = Some(scale);
1074 scale
1075 }
1076}
1077
1078impl Default for IfcAPI {
1079 fn default() -> Self {
1080 Self::new()
1081 }
1082}
1083
1084/// Safely set a property on a JavaScript object.
1085/// Returns true if successful, false otherwise.
1086/// This avoids panicking on edge cases like non-extensible objects.
1087#[inline]
1088fn set_js_prop(obj: &JsValue, key: &str, value: &JsValue) -> bool {
1089 js_sys::Reflect::set(obj, &JsValue::from_str(key), value).unwrap_or(false)
1090}
1091
1092#[cfg(test)] mod mod_tests;