Skip to main content

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;