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