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