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}