Skip to main content

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