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}