ifc_lite_geometry/router/
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//! Geometry Router - Dynamic dispatch to geometry processors
6//!
7//! Routes IFC representation entities to appropriate processors based on type.
8
9mod annotation;
10mod annotation_polygon;
11mod annotation_style;
12mod caching;
13mod brep_signatures;
14mod rep_filter;
15mod content_hash;
16mod diagnostics;
17mod diagnostics_recording;
18mod frame_parts;
19mod instancing;
20mod item_dedup_cache;
21mod layers;
22mod mapped_item;
23mod processing;
24mod processor;
25mod rtc_offset;
26mod structural;
27mod textured;
28pub(crate) mod transforms;
29pub(crate) mod voids;
30
31pub use processor::GeometryProcessor;
32pub use brep_signatures::SharedBrepSignatureCache;
33pub use item_dedup_cache::{ItemDedupCache, ItemDedupCacheState};
34pub use transforms::local_frame_set_enabled_override;
35pub use voids::{take_bool2d_stats, take_prism_defers, take_prism_stats, RectParam};
36pub use diagnostics::{
37    aggregate_diagnostics, count_attributed_products, format_unsupported_breakdown,
38    ClassificationStats, ClassificationSummary, GeometryDiagnostics, HostOpeningDiagnostic,
39    OpeningDiagnostic, OpeningKindDiag, ReasonCount, RectFastSummary, WorstHost,
40    GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION, UNATTRIBUTED_PRODUCT_ID,
41};
42pub(crate) use diagnostics::ClassificationKind;
43pub(super) use rep_filter::{effective_element_rep_type, effective_rep_type, is_body_representation, is_direct_body_representation};
44pub use rep_filter::meshed_representations;
45pub use content_hash::FACETED_BREP_DEDUP_FACE_LIMIT;
46
47#[cfg(test)]
48mod tests;
49
50use crate::material_layer_index::MaterialLayerIndex;
51use crate::processors::{BooleanClippingProcessor, CsgSolidProcessor};
52mod processor_registry;
53pub(crate) use processor_registry::builtin_processor;
54use processor_registry::ProcessorRegistry;
55use crate::tessellation::TessellationQuality;
56use crate::{BoolFailure, Mesh, Result};
57use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
58use nalgebra::Matrix4;
59use rustc_hash::{FxHashMap, FxHashSet};
60use std::cell::RefCell;
61use std::rc::Rc;
62use std::sync::{Arc, Mutex, OnceLock};
63
64/// Test/env override for [`GeometryRouter::build_dedup_extra_enabled`]:
65/// -1 = env default, 0 = forced off, 1 = forced on.
66static DEDUP_EXTRA_OVERRIDE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
67
68/// Shared `IfcMappedItem` source cache: maps an `IfcRepresentationMap` express id
69/// to its SOURCE-coordinate (pre-`MappingTarget`, pre-placement, colour-free) item
70/// mesh. Build ONE per loaded model with
71/// [`GeometryRouter::new_mapped_item_cache`] and inject it into every per-element /
72/// per-batch router via [`GeometryRouter::enable_shared_mapped_item_cache`], so a
73/// source shared by many owning elements is meshed ONCE model-wide instead of once
74/// per element (a fresh router is built per element, so the per-router RefCell
75/// `mapped_item_cache` only dedups WITHIN one element — #1623). The value is the
76/// same source-coords mesh the RefCell would store; the per-occurrence
77/// `MappingTarget` transform + `instance_meta` are applied by the caller AFTER the
78/// lookup, so a cross-router cache hit is byte-identical to a fresh build.
79///
80/// `Arc<Mutex<_>>` so ONE cache outlives any single router (mirrors
81/// [`ItemDedupCache`]). The key is the source express id, stable per model; all
82/// routers in one pass share the same `unit_scale` / `tessellation_quality` / RTC
83/// (baked into the source mesh), so keying by id alone is sufficient within a pass
84/// — the wasm session drops this cache on a content swap AND on a
85/// `setTessellationQuality` change (which invalidates source-coord tessellation),
86/// exactly as the router's own `set_tessellation_quality` clears the RefCell.
87pub type SharedMappedItemCache = Arc<Mutex<FxHashMap<u32, Arc<Mesh>>>>;
88
89/// #1623 Phase 2 "don't-bake" instancing plan: `IfcRepresentationMap` express id ⇒
90/// `(occurrence_count, template_item_id)`, where `template_item_id` is the SMALLEST
91/// `IfcMappedItem` express id referencing that source (a deterministic, race-free
92/// choice of which occurrence materializes its geometry as the shared template).
93/// Only sources with `occurrence_count >= 2` appear. Built ONCE from the file scan
94/// and injected into every per-element / per-batch router via
95/// [`GeometryRouter::enable_output_instancing`]. When a mapped item's source is in
96/// this plan AND the occurrence is a single-solid ordinary product, the
97/// NON-template occurrences skip the per-occurrence vertex bake and emit an
98/// instance-only placeholder (empty geometry carrying [`crate::mesh::InstanceMeta`])
99/// instead of a full materialized mesh — the ~29s / 43M-vertex materialize this
100/// phase kills. `None` ⇒ every occurrence materializes (historical flat output,
101/// byte-identical); exporters and the determinism harness never arm it.
102pub type MappedInstancePlan = Arc<FxHashMap<u32, (u32, u32)>>;
103
104/// Geometry router - routes entities to processors
105pub struct GeometryRouter {
106    schema: &'static IfcSchema,
107    processors: ProcessorRegistry,
108    /// Cache for IfcRepresentationMap source geometry (MappedItem instancing)
109    /// Key: RepresentationMap entity ID, Value: Processed mesh.
110    ///
111    /// Per-router FALLBACK used when no [`SharedMappedItemCache`] is injected
112    /// (native single-shot / non-streaming callers, tests). A fresh router is
113    /// built per element, so this only dedups mapped sources WITHIN one element;
114    /// the shared cache below promotes reuse to model-wide (#1623).
115    mapped_item_cache: RefCell<FxHashMap<u32, Arc<Mesh>>>,
116    /// SHARED `IfcMappedItem` source cache (#1623). When present, takes precedence
117    /// over the per-router `mapped_item_cache` so a source shared by many owning
118    /// elements is meshed ONCE model-wide (the per-router RefCell above resets per
119    /// element). Keyed by `IfcRepresentationMap` id ⇒ SOURCE-coords mesh; the
120    /// per-occurrence `MappingTarget` transform + `instance_meta` are applied AFTER
121    /// the lookup, so a cross-router hit is byte-identical to a fresh build. The
122    /// lock is held only for a map get/clone (hit) or insert (miss) — the source
123    /// meshing (which nests faceted-brep's rayon `par_iter`) runs OUTSIDE the
124    /// lock, so it is never held across a nested join (#1587). `None` ⇒ fallback.
125    shared_mapped_item_cache: Option<SharedMappedItemCache>,
126    /// Cache for geometry deduplication by content hash (buildings with
127    /// repeated floors have 99% identical geometry). Key: mesh-content hash.
128    geometry_hash_cache: RefCell<FxHashMap<u64, Arc<Mesh>>>,
129    /// SHARED content-dedup of LOCAL (pre-placement, void-free) representation-ITEM
130    /// meshes, keyed by a 128-bit structural hash of the item subtree
131    /// (`content_hash::item_signature`). Skips the meshing + CSG for byte-identical
132    /// geometry the exporter failed to share via `IfcMappedItem` (Tekla connection
133    /// plates/bolts). The cached mesh is COLOUR-FREE; the per-instance
134    /// `geometry_id` (colour/palette/texture), voids and placement are applied by
135    /// the caller, so reuse never changes an instance's appearance.
136    ///
137    /// Shared across native per-element and wasm per-batch routers. A hit skips building;
138    /// so the lock is held only for a map get/clone (hit) or insert (miss); the
139    /// build runs outside it. `None` ⇒ dedup disabled (e.g. `new()` in tests).
140    item_dedup_cache: Option<ItemDedupCache>,
141    /// Generic recursive hashes stay local: depth/cycle context may differ.
142    content_sig_memo: RefCell<FxHashMap<u32, u128>>,
143    shared_brep_signatures: Option<SharedBrepSignatureCache>,
144    /// Content-hash refs refused above `u32::MAX` (#3421/#3752); diagnostic only.
145    content_hash_oversized_ref_drops: RefCell<usize>,
146    /// Unit scale factor (e.g., 0.001 for millimeters -> meters), applied to
147    /// all mesh positions after processing.
148    unit_scale: f64,
149    /// RTC (Relative-to-Center) offset, subtracted from world positions in f64
150    /// before converting to f32 to preserve precision (e.g. Swiss UTM).
151    rtc_offset: (f64, f64, f64),
152    /// Per-router local-frame choice. `None` retains the target/env default;
153    /// appearance authoring sets this explicitly so its canonical surface
154    /// identity cannot vary between native and wasm builds (#4550).
155    local_frame_enabled: Option<bool>,
156    /// Material-layer buildup index. When set, `process_element_with_submeshes`
157    /// and `process_element_with_submeshes_and_voids` first attempt to slice
158    /// single-solid elements by their `IfcMaterialLayerSetUsage` buildup.
159    material_layer_index: Option<Arc<MaterialLayerIndex>>,
160    /// Boolean / CSG failures attributed by IFC product express ID. Populated
161    /// by the void-subtraction path (`apply_void_context`) when the BSP
162    /// kernel falls back to the un-cut host. Drainable via
163    /// [`Self::take_csg_failures`].
164    csg_failures: RefCell<FxHashMap<u32, Vec<BoolFailure>>>,
165    /// Cumulative counters for opening classification (T1.1 / classifier fix
166    /// diagnostic). Tracks how many openings went through each branch of
167    /// `classify_openings` so a maintainer can verify the fix is firing on
168    /// real models. Drainable via [`Self::take_classification_stats`].
169    classification_stats: RefCell<ClassificationStats>,
170    /// Per-host opening diagnostic, keyed by host product express ID.
171    /// Captures everything the geometry pipeline knows about each host's
172    /// openings so a maintainer can answer "why didn't this wall's window
173    /// get cut?" from a console log alone. Drainable via
174    /// [`Self::take_host_opening_diagnostics`].
175    host_opening_diagnostics: RefCell<FxHashMap<u32, HostOpeningDiagnostic>>,
176    /// REQUEST-LOCAL rect_fast fast-path engagement counters. Accumulated per-cut
177    /// by [`Self::record_rect_fast`] into THIS router (not a process-global), so a
178    /// native server running concurrent geometry passes — each with its own router
179    /// — gets isolated per-load `rectFast` diagnostics. Drainable via
180    /// [`Self::take_rect_fast_stats`]; the wasm batch path drains its one router,
181    /// the native path drains each per-element router and sums.
182    rect_fast_stats: RefCell<crate::rect_fast::RectFastStats>,
183    /// Diagnostic (#563): per-element outcome of layered-wall slicing — why a
184    /// sliceable wall did or didn't split into per-layer sub-meshes. Drained by
185    /// the wasm layer per batch and logged to the browser console (the geometry
186    /// crate can't `web_sys`). Drainable via [`Self::take_layer_slice_diag`].
187    layer_slice_diag: RefCell<Vec<(u32, &'static str)>>,
188    /// Host product express IDs that a void subtraction fully CONSUMED — an
189    /// opening whose real solid contains the whole host, so the correct result
190    /// is an empty mesh. Without this flag the empty mesh reads as a failed cut
191    /// and the element pipeline falls back to the un-cut host, re-rendering a
192    /// spurious solid. Queried via [`Self::host_consumed_by_void`].
193    voids_consumed_hosts: RefCell<FxHashSet<u32>>,
194    /// Tessellation detail level. Immutable per router instance and passed to
195    /// every processor's `process`. Defaults to [`TessellationQuality::Medium`]
196    /// (historical hardcoded behavior).
197    tessellation_quality: TessellationQuality,
198    /// Per-build small-cut skip (#1286). Injected into the boolean / CSG /
199    /// mapped processors at construction so a solid-solid DIFFERENCE with a tiny
200    /// cutter can be dropped without forcing a preview tessellation tier. Scoped
201    /// to this router (one per loaded build) so concurrent native builds never
202    /// bleed the flag into one another — it used to be a process-wide static.
203    /// `false` (default) ⇒ every cut runs, byte-identical to before.
204    skip_small_cuts: bool,
205    /// #1623 Phase 2 don't-bake plan (see [`MappedInstancePlan`]). `None` (default)
206    /// ⇒ every mapped-item occurrence materializes a full mesh, byte-identical to
207    /// the historical flat path. When armed, single-solid ordinary occurrences of a
208    /// repeated `IfcRepresentationMap` skip the per-occurrence vertex bake and emit
209    /// an instance-only placeholder instead. Scoped to this router (one per loaded
210    /// build), like `skip_small_cuts`.
211    output_instancing_plan: Option<MappedInstancePlan>,
212    /// #858 don't-bake exclusion: geometry-item ids of mapped SOURCES whose single
213    /// solid carries an `IfcIndexedColourMap` (per-triangle palette). Such a source
214    /// must NOT don't-bake — the flat path splits it into one mesh per palette group
215    /// (`split_mesh_by_indexed_colour`), but an instance placeholder resolves ONE
216    /// colour, collapsing the palette. Armed alongside the plan; when a candidate's
217    /// single-solid id is in this set the router routes it to the normal flat
218    /// materialize (byte-identical to instancing-off). `None`/empty ⇒ no exclusion.
219    indexed_colour_split_ids: Option<Arc<FxHashSet<u32>>>,
220    /// Set per element by the producer while it meshes an element whose
221    /// triangle order is load-bearing (a face set with an `IfcIndexedColourMap`
222    /// is split by triangle index, #858). Source hygiene then stays index-only,
223    /// because the #5313 T-junction repair reorders and re-counts triangles.
224    preserve_triangle_order: std::cell::Cell<bool>,
225    /// #1623 Phase 3 template-selection mode for the don't-bake path. `false`
226    /// (default, NATIVE): the deterministic global template is the plan's min-id
227    /// occurrence (`item.id == template_item_id`), so every occurrence resolves
228    /// against ONE model-wide template across the rayon pool. `true` (the WASM
229    /// per-BATCH path): this router meshes ONE batch onto ONE thread serially, so
230    /// the FIRST occurrence of each source THIS router sees is the template (tracked
231    /// in [`Self::instanced_sources_materialized`]) and the rest don't-bake — each
232    /// per-batch shard is then self-contained (its occurrences' template is in the
233    /// same shard), so a batch never depends on a template materialized in another
234    /// batch. Both modes emit geometrically identical world triangles.
235    instancing_batch_local: bool,
236    /// #1623 Phase 3 (batch-local mode only): the `IfcRepresentationMap` source ids
237    /// this router has already materialized as a batch-local template. The first
238    /// occurrence of a source inserts its id (materializes); later occurrences see
239    /// the id present and don't-bake. Reset implicitly per batch — a fresh router is
240    /// built per `produce_batch`. Unused (stays empty) in the native global mode.
241    instanced_sources_materialized: RefCell<FxHashSet<u32>>,
242    unsupported: RefCell<diagnostics::UnsupportedItemState>, // dropped items by `IfcType` + per-source bookkeeping
243}
244
245impl GeometryRouter {
246    /// Create new router with default processors
247    pub fn new() -> Self {
248        // Routing metadata is immutable through this API. Keep one default
249        // per process/wasm instance; processor diagnostics remain router-local.
250        static SCHEMA: OnceLock<IfcSchema> = OnceLock::new();
251        let schema = SCHEMA.get_or_init(IfcSchema::new);
252        Self {
253            schema,
254            processors: ProcessorRegistry::new(),
255            mapped_item_cache: RefCell::new(FxHashMap::default()),
256            shared_mapped_item_cache: None, // armed by `enable_shared_mapped_item_cache`
257            geometry_hash_cache: RefCell::new(FxHashMap::default()),
258            item_dedup_cache: None, // armed by `with_units` / `enable_content_dedup_shared`
259            content_sig_memo: RefCell::new(FxHashMap::default()),
260            shared_brep_signatures: None,
261            content_hash_oversized_ref_drops: RefCell::new(0),
262            unit_scale: 1.0,             // Default to base meters
263            rtc_offset: (0.0, 0.0, 0.0), // Default to no offset
264            local_frame_enabled: None,
265            material_layer_index: None,
266            csg_failures: RefCell::new(FxHashMap::default()),
267            classification_stats: RefCell::new(ClassificationStats::default()),
268            host_opening_diagnostics: RefCell::new(FxHashMap::default()),
269            rect_fast_stats: RefCell::new(crate::rect_fast::RectFastStats::default()),
270            layer_slice_diag: RefCell::new(Vec::new()),
271            voids_consumed_hosts: RefCell::new(FxHashSet::default()),
272            tessellation_quality: TessellationQuality::Medium,
273            skip_small_cuts: false,
274            output_instancing_plan: None, // armed by `enable_output_instancing`
275            indexed_colour_split_ids: None, // armed by `enable_indexed_colour_split_guard`
276            preserve_triangle_order: std::cell::Cell::new(false),
277            instancing_batch_local: false, // native global-template mode by default
278            instanced_sources_materialized: RefCell::new(FxHashSet::default()),
279            unsupported: RefCell::new(Default::default()),
280        }
281    }
282
283    /// Create router and extract unit scale from IFC file
284    /// Automatically finds IFCPROJECT and extracts length unit conversion
285    pub fn with_units<T>(content: &T, decoder: &mut EntityDecoder) -> Self
286    where
287        T: AsRef<[u8]> + ?Sized,
288    {
289        let scale = Self::scan_unit_scale(content.as_ref(), decoder);
290        let mut router = Self::with_scale(scale);
291        router.arm_content_dedup();
292        router
293    }
294
295    /// Scan to the first `IFCPROJECT` and extract its length-unit scale (e.g.
296    /// `0.001` for millimetres → metres); `1.0` if none is found.
297    fn scan_unit_scale(content: &[u8], decoder: &mut EntityDecoder) -> f64 {
298        let mut scanner = ifc_lite_core::EntityScanner::new(content);
299        while let Some((id, type_name, _, _)) = scanner.next_entity() {
300            if ifc_lite_core::keyword_eq(type_name, "IFCPROJECT") {
301                if let Ok(s) = ifc_lite_core::extract_length_unit_scale(decoder, id) {
302                    return s;
303                }
304                break;
305            }
306        }
307        1.0
308    }
309
310    /// Create router with unit scale extracted from IFC file AND RTC offset for large coordinates
311    /// This is the recommended method for georeferenced models (Swiss UTM, etc.)
312    ///
313    /// # Arguments
314    /// * `content` - IFC file content
315    /// * `decoder` - Entity decoder
316    /// * `rtc_offset` - RTC offset to subtract from world coordinates (typically model centroid)
317    pub fn with_units_and_rtc<T>(
318        content: &T,
319        decoder: &mut ifc_lite_core::EntityDecoder,
320        rtc_offset: (f64, f64, f64),
321    ) -> Self
322    where
323        T: AsRef<[u8]> + ?Sized,
324    {
325        let scale = Self::scan_unit_scale(content.as_ref(), decoder);
326        let mut router = Self::with_scale_and_rtc(scale, rtc_offset);
327        router.arm_content_dedup();
328        router
329    }
330
331    /// Create router with pre-calculated unit scale
332    pub fn with_scale(unit_scale: f64) -> Self {
333        let mut router = Self::new();
334        router.unit_scale = unit_scale;
335        router
336    }
337
338    /// Create a router with an explicit per-mesh local-frame policy.
339    ///
340    /// This is intentionally a constructor rather than a mutable switch: mesh
341    /// caches are frame-dependent, so the policy must be fixed before any
342    /// geometry is produced.
343    pub fn with_scale_and_local_frame(unit_scale: f64, enabled: bool) -> Self {
344        let mut router = Self::with_scale(unit_scale);
345        router.local_frame_enabled = Some(enabled);
346        router
347    }
348
349    /// Arm content-dedup with a NEW empty cache. Used by the model constructors
350    /// (`with_units*`) where this router owns the only reference; multi-router
351    /// callers (native pool, wasm batches) should build ONE shared cache via
352    /// [`Self::new_dedup_cache`] and inject it into every router with
353    /// [`Self::enable_content_dedup_shared`] so the cache persists across them.
354    fn arm_content_dedup(&mut self) {
355        self.item_dedup_cache = Some(Self::new_dedup_cache());
356    }
357
358    /// A fresh empty shared item-dedup cache, to be cloned into every per-element /
359    /// per-batch router of ONE loaded model so they all dedup against it. Keep one
360    /// per model: the key is a per-model entity-structure hash, and the cached
361    /// meshes bake in this model's unit scale / tessellation quality.
362    pub fn new_dedup_cache() -> ItemDedupCache {
363        Arc::new(ItemDedupCacheState::default())
364    }
365
366    /// Whether content-dedup covers EXTRA item types beyond the proven default
367    /// set (faceset / surface-model families). `item_signature`'s generic byte
368    /// walk is already complete for them, so this is pure additive build savings
369    /// on models that repeat tessellated geometry — but it pays a hash on every
370    /// such item, so it stays OFF by default until corpus-validated (low-reuse
371    /// models would pay the hash for no payback, the #1177 trap). Opt in with
372    /// `IFC_LITE_DEDUP_EXTRA=1` or [`Self::set_build_dedup_extra_override`].
373    /// Default OFF keeps native==wasm identical.
374    pub fn build_dedup_extra_enabled() -> bool {
375        match DEDUP_EXTRA_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) {
376            0 => return false,
377            1 => return true,
378            _ => {}
379        }
380        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
381        *ON.get_or_init(|| std::env::var("IFC_LITE_DEDUP_EXTRA").as_deref() == Ok("1"))
382    }
383
384    /// Test-only: force [`Self::build_dedup_extra_enabled`] on/off (`None` = env).
385    pub fn set_build_dedup_extra_override(v: Option<bool>) {
386        DEDUP_EXTRA_OVERRIDE.store(
387            match v {
388                None => -1,
389                Some(false) => 0,
390                Some(true) => 1,
391            },
392            std::sync::atomic::Ordering::Relaxed,
393        );
394    }
395
396    /// Inject a shared item-dedup cache (see [`Self::new_dedup_cache`]) into this
397    /// router. All routers given the SAME `Arc` dedup against one cache, so
398    /// byte-identical geometry is meshed once across the whole model regardless of
399    /// how elements are partitioned across threads or batches.
400    pub fn enable_content_dedup_shared(&mut self, cache: ItemDedupCache) {
401        self.item_dedup_cache = Some(cache);
402    }
403
404    /// A fresh empty shared `IfcMappedItem` source cache, to be cloned into every
405    /// per-element / per-batch router of ONE loaded model so a mapped source shared
406    /// across owning elements is meshed once model-wide (#1623). Mirrors
407    /// [`Self::new_dedup_cache`]. Keep one per model/pass: the cached meshes bake in
408    /// this pass's unit scale / tessellation quality.
409    pub fn new_mapped_item_cache() -> SharedMappedItemCache {
410        Arc::new(Mutex::new(FxHashMap::default()))
411    }
412
413    /// Inject a shared `IfcMappedItem` source cache (see
414    /// [`Self::new_mapped_item_cache`]) into this router. All routers given the
415    /// SAME `Arc` mesh each unique `IfcRepresentationMap` source once across the
416    /// whole model, instead of once per owning element (a fresh router is built per
417    /// element). Takes precedence over the per-router RefCell `mapped_item_cache`;
418    /// leaving it unset keeps the per-router fallback for single-shot callers.
419    pub fn enable_shared_mapped_item_cache(&mut self, cache: SharedMappedItemCache) {
420        self.shared_mapped_item_cache = Some(cache);
421    }
422
423    /// Arm the #1623 Phase 2 don't-bake instancing plan (see [`MappedInstancePlan`])
424    /// on this router. Requires a shared mapped-item cache to be enabled too — the
425    /// don't-bake instance placeholders rely on the source being meshed once into it
426    /// (the orphan-recovery template at finalize reads it). Leaving it unset keeps
427    /// every occurrence materializing (byte-identical flat output).
428    pub fn enable_output_instancing(&mut self, plan: MappedInstancePlan) {
429        self.output_instancing_plan = Some(plan);
430    }
431
432    /// The armed don't-bake plan, if any (see [`Self::enable_output_instancing`]).
433    pub(super) fn output_instancing_plan(&self) -> Option<&MappedInstancePlan> {
434        self.output_instancing_plan.as_ref()
435    }
436
437    /// Arm the #858 don't-bake exclusion set: geometry-item ids of mapped sources
438    /// whose single solid carries an `IfcIndexedColourMap`. Such a source is routed
439    /// to the flat materialize (per-palette split) instead of don't-bake, so its
440    /// occurrences keep the per-triangle palette (an instance placeholder would
441    /// collapse it to one colour). Armed alongside [`Self::enable_output_instancing`]
442    /// when the model has any indexed-colour maps; unset ⇒ no exclusion.
443    pub fn enable_indexed_colour_split_guard(&mut self, ids: Arc<FxHashSet<u32>>) {
444        self.indexed_colour_split_ids = Some(ids);
445    }
446
447    /// Keep source hygiene index-only for the elements meshed until this is
448    /// called again with `false`, because a caller will map their triangles back
449    /// to source faces by index (#858's per-triangle colour split). Returns the
450    /// previous setting so a caller can restore it.
451    pub fn set_preserve_triangle_order(&self, on: bool) -> bool {
452        self.preserve_triangle_order.replace(on)
453    }
454
455    /// Whether `geometry_id` is a mapped-source solid carrying an `IfcIndexedColourMap`
456    /// (see [`Self::enable_indexed_colour_split_guard`]) — such a source must not
457    /// don't-bake, so its per-triangle palette survives the flat split.
458    pub(super) fn is_indexed_colour_split_source(&self, geometry_id: u32) -> bool {
459        self.indexed_colour_split_ids
460            .as_ref()
461            .is_some_and(|ids| ids.contains(&geometry_id))
462    }
463
464    /// Select the #1623 Phase 3 batch-local template mode (see
465    /// [`Self::instancing_batch_local`]). The WASM per-batch path sets this so each
466    /// batch materializes its OWN first-seen template per source and stays a
467    /// self-contained shard; the native path leaves it off (global min-id template).
468    pub fn set_instancing_batch_local(&mut self, on: bool) {
469        self.instancing_batch_local = on;
470    }
471
472    /// Whether batch-local template selection is active (see the field doc).
473    #[inline]
474    pub(super) fn instancing_batch_local(&self) -> bool {
475        self.instancing_batch_local
476    }
477
478    /// Batch-local don't-bake template decision: returns `true` (materialize as the
479    /// batch-local template) the FIRST time this router sees `source_id`, `false`
480    /// (don't-bake) every time after. Idempotent per source within one router/batch.
481    pub(super) fn mark_source_materialized_if_first(&self, source_id: u32) -> bool {
482        self.instanced_sources_materialized
483            .borrow_mut()
484            .insert(source_id)
485    }
486
487    /// Disable content-dedup (drops the cache reference so `item_dedup_key`
488    /// returns `None` and meshing is never skipped). Test/bench helper for an A/B
489    /// against the deduped path.
490    pub fn disable_content_dedup(&mut self) {
491        self.item_dedup_cache = None;
492    }
493
494    /// Number of unique item meshes cached by content-dedup so far — the reuse the
495    /// pipeline recovered (vs. the meshed-item count). Diagnostics.
496    pub fn dedup_unique_count(&self) -> usize {
497        self.item_dedup_cache
498            .as_ref()
499            .map(|c| c.meshes.lock().unwrap_or_else(|e| e.into_inner()).len())
500            .unwrap_or(0)
501    }
502
503    /// Number of unique `IfcRepresentationMap` sources meshed into the SHARED
504    /// mapped-item cache so far (0 when no shared cache is injected — the RefCell
505    /// fallback isn't counted). Diagnostics, mirroring [`Self::dedup_unique_count`];
506    /// used by the #1623 A/B test to confirm the shared cache actually captured
507    /// sources (so a cross-router hit is genuinely exercised, not a no-op).
508    pub fn mapped_shared_unique_count(&self) -> usize {
509        self.shared_mapped_item_cache
510            .as_ref()
511            .map(|c| c.lock().unwrap_or_else(|e| e.into_inner()).len())
512            .unwrap_or(0)
513    }
514
515    /// Content-routing key for an element: a 128-bit structural hash of its WHOLE
516    /// representation subtree (the `Representation` attribute, e.g. an
517    /// `IfcProductDefinitionShape`), or `None` if it has no geometry. Two elements
518    /// with byte-identical geometry — even renumbered — share a key, so a host can
519    /// route them to the same worker; combined with the per-worker dedup cache the
520    /// geometry is then meshed once per worker. Meshing-free (decode + fold) and
521    /// reuses the per-router signature memo so shared sub-entities are hashed once.
522    ///
523    /// (A per-instance shape-representation wrapper can make this finer than the
524    /// per-ITEM dedup unit, but a true 4-router simulation showed that costs
525    /// essentially no extra meshing — 1.01× — because the shared items still land
526    /// on one worker, so the simpler whole-representation hash is used.)
527    pub fn geometry_routing_key(
528        &self,
529        element: &DecodedEntity,
530        decoder: &mut EntityDecoder,
531    ) -> Option<u128> {
532        let rep = element.get(6)?.as_entity_ref()?;
533        let mut memo = self.content_sig_memo.borrow_mut();
534        let mut refused = self.content_hash_oversized_ref_drops.borrow_mut();
535        Some(content_hash::item_signature(decoder, rep, &mut memo, &mut refused))
536    }
537
538    /// Create router with RTC offset for large coordinate handling
539    /// Use this for georeferenced models (e.g., Swiss UTM coordinates)
540    pub fn with_rtc(rtc_offset: (f64, f64, f64)) -> Self {
541        let mut router = Self::new();
542        router.rtc_offset = rtc_offset;
543        router
544    }
545
546    /// Create router with both unit scale and RTC offset
547    pub fn with_scale_and_rtc(unit_scale: f64, rtc_offset: (f64, f64, f64)) -> Self {
548        let mut router = Self::new();
549        router.unit_scale = unit_scale;
550        router.rtc_offset = rtc_offset;
551        router
552    }
553
554    /// Create router with a specific tessellation quality level
555    pub fn with_quality(quality: TessellationQuality) -> Self {
556        let mut router = Self::new();
557        router.tessellation_quality = quality;
558        router
559    }
560
561    /// Create router with both unit scale and tessellation quality
562    pub fn with_scale_and_quality(unit_scale: f64, quality: TessellationQuality) -> Self {
563        let mut router = Self::new();
564        router.unit_scale = unit_scale;
565        router.tessellation_quality = quality;
566        router
567    }
568
569    /// Set the tessellation quality level.
570    ///
571    /// A quality change invalidates `mapped_item_cache` (keyed by RepresentationMap
572    /// id, not by quality) and the record of which sources' drops are already
573    /// counted, since every source is re-walked; both are cleared here. The
574    /// content-hash-keyed caches stay correct.
575    pub fn set_tessellation_quality(&mut self, quality: TessellationQuality) {
576        if self.tessellation_quality == quality {
577            return;
578        }
579        self.tessellation_quality = quality;
580        self.mapped_item_cache.get_mut().clear();
581        self.unsupported.get_mut().forget_sources();
582    }
583
584    /// Get the current tessellation quality level
585    #[inline]
586    pub fn tessellation_quality(&self) -> TessellationQuality {
587        self.tessellation_quality
588    }
589
590    /// Set the per-build small-cut skip (#1286) and re-register the boolean /
591    /// CSG / mapped processors so they carry it. Tier-independent: the viewer
592    /// turns this on to skip tiny steel copes/notches for fast first paint while
593    /// the tessellation tier stays at `Medium` (curves keep full density).
594    ///
595    /// Scoped to this router instance, so a concurrent native build with the
596    /// skip off is unaffected (this replaced a process-wide static that bled
597    /// across builds). `false` (default) keeps every cut, byte-identical to
598    /// before the optimization.
599    pub fn set_skip_small_cuts(&mut self, on: bool) {
600        if self.skip_small_cuts == on {
601            return;
602        }
603        self.skip_small_cuts = on;
604        self.register_skip_dependent_processors();
605    }
606
607    /// Get the current per-build small-cut skip flag.
608    #[inline]
609    pub fn skip_small_cuts(&self) -> bool {
610        self.skip_small_cuts
611    }
612
613    /// (Re)register the processors whose behavior depends on `skip_small_cuts`
614    /// so they pick up the current value. Called at construction and whenever
615    /// [`Self::set_skip_small_cuts`] flips the flag; `register` overwrites the
616    /// existing map entries keyed by IFC type.
617    /// The processors this replaces are DROPPED, and a dropped processor takes
618    /// its failure log with it. Sweep first, so flipping the flag mid-pass can
619    /// never discard records the pipeline had not drained yet (#3821).
620    fn register_skip_dependent_processors(&mut self) {
621        self.drain_processor_failures();
622        self.register(Box::new(BooleanClippingProcessor::with_skip_small_cuts(
623            self.skip_small_cuts,
624        )));
625        self.register(Box::new(CsgSolidProcessor::with_skip_small_cuts(
626            self.skip_small_cuts,
627        )));
628    }
629
630    /// Set the RTC offset for large coordinate handling
631    pub fn set_rtc_offset(&mut self, offset: (f64, f64, f64)) {
632        self.rtc_offset = offset;
633    }
634
635    /// Get the current RTC offset
636    pub fn rtc_offset(&self) -> (f64, f64, f64) {
637        self.rtc_offset
638    }
639
640    /// Check if RTC offset is active (non-zero)
641    #[inline]
642    pub fn has_rtc_offset(&self) -> bool {
643        self.rtc_offset.0 != 0.0 || self.rtc_offset.1 != 0.0 || self.rtc_offset.2 != 0.0
644    }
645
646    /// Get the current unit scale factor
647    pub fn unit_scale(&self) -> f64 {
648        self.unit_scale
649    }
650
651    /// Attach a material-layer buildup index. After this, sub-mesh processing
652    /// automatically slices single-solid elements whose buildup is sliceable
653    /// (walls with `IfcMaterialLayerSetUsage`, etc.) into per-layer slabs.
654    pub fn set_material_layer_index(&mut self, index: Arc<MaterialLayerIndex>) {
655        self.material_layer_index = Some(index);
656    }
657
658    #[inline]
659    pub(crate) fn material_layer_index(&self) -> Option<&MaterialLayerIndex> {
660        self.material_layer_index.as_deref()
661    }
662
663    /// True when `element_id` carries a sliceable `IfcMaterialLayerSetUsage`, i.e.
664    /// `process_element_with_submeshes` would split it into per-layer sub-meshes.
665    /// Lets the mesh producer render the wall as ONE solid in 3D while still
666    /// emitting the per-layer slices (tagged section-only) for the 2D cut.
667    #[inline]
668    pub fn is_material_layer_sliceable(&self, element_id: u32) -> bool {
669        self.material_layer_index()
670            .is_some_and(|idx| idx.is_sliceable(element_id))
671    }
672
673    /// Scale mesh positions from file units to meters
674    /// Only applies scaling if unit_scale != 1.0
675    #[inline]
676    fn scale_mesh(&self, mesh: &mut Mesh) {
677        if self.unit_scale != 1.0 {
678            let scale = self.unit_scale as f32;
679            for pos in mesh.positions.iter_mut() {
680                *pos *= scale;
681            }
682        }
683    }
684
685    /// Scale the translation component of a transform matrix from file units to meters
686    /// The rotation/scale part stays unchanged, only translation (column 3) is scaled
687    #[inline]
688    fn scale_transform(&self, transform: &mut Matrix4<f64>) {
689        if self.unit_scale != 1.0 {
690            transform[(0, 3)] *= self.unit_scale;
691            transform[(1, 3)] *= self.unit_scale;
692            transform[(2, 3)] *= self.unit_scale;
693        }
694    }
695
696    /// Register a geometry processor
697    pub fn register(&mut self, processor: Box<dyn GeometryProcessor>) {
698        let processor_rc: Rc<dyn GeometryProcessor> = Rc::from(processor);
699        for ifc_type in processor_rc.supported_types() {
700            self.processors.insert(ifc_type, Rc::clone(&processor_rc));
701        }
702    }
703
704    /// Resolve an element's ObjectPlacement to a scaled world-space transform matrix.
705    /// Returns the 4x4 matrix as a flat column-major array of 16 f64 values.
706    /// The translation component is scaled from file units to meters.
707    ///
708    /// Contributed by Mathias Søndergaard (Sonderwoods/Linkajou).
709    pub fn resolve_scaled_placement(
710        &self,
711        entity: &DecodedEntity,
712        decoder: &mut EntityDecoder,
713    ) -> Result<[f64; 16]> {
714        let mut transform = self.get_placement_transform_from_element(entity, decoder)?;
715        self.scale_transform(&mut transform);
716        let mut result = [0.0f64; 16];
717        result.copy_from_slice(transform.as_slice());
718        Ok(result)
719    }
720
721    /// Get schema reference
722    pub fn schema(&self) -> &IfcSchema {
723        self.schema
724    }
725}
726
727impl Default for GeometryRouter {
728    fn default() -> Self {
729        Self::new()
730    }
731}
732
733#[cfg(test)]
734mod reference_opening_tests;
735#[cfg(test)]
736mod annotation_tests;
737#[cfg(test)]
738mod structural_tests;