Skip to main content

ifc_lite_geometry/router/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Geometry Router - Dynamic dispatch to geometry processors
6//!
7//! Routes IFC representation entities to appropriate processors based on type.
8
9mod caching;
10mod clipping;
11mod layers;
12mod processing;
13mod transforms;
14mod voids;
15mod voids_2d;
16
17#[cfg(test)]
18mod tests;
19
20use crate::material_layer_index::MaterialLayerIndex;
21use crate::processors::{
22    AdvancedBrepProcessor, BSplineSurfaceProcessor, BlockProcessor, BooleanClippingProcessor,
23    CsgSolidProcessor, ExtrudedAreaSolidProcessor, ExtrudedAreaSolidTaperedProcessor,
24    FaceBasedSurfaceModelProcessor, FacetedBrepProcessor, IfcAlignmentProcessor,
25    MappedItemProcessor, PolygonalFaceSetProcessor, RevolvedAreaSolidProcessor,
26    SectionedSolidHorizontalProcessor, ShellBasedSurfaceModelProcessor, SphereProcessor,
27    SweptDiskSolidProcessor, TriangulatedFaceSetProcessor,
28};
29use crate::tessellation::TessellationQuality;
30use crate::{BoolFailure, Mesh, Result};
31use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
32use nalgebra::Matrix4;
33use rustc_hash::FxHashMap;
34use std::cell::RefCell;
35use std::collections::HashMap;
36use std::sync::Arc;
37
38/// Geometry processor trait
39/// Each processor handles one type of IFC representation
40pub trait GeometryProcessor {
41    /// Process entity into mesh.
42    ///
43    /// `quality` selects tessellation detail; processors that approximate
44    /// curves derive their segment counts from it via
45    /// [`crate::tessellation::scale_segments`]. Processors with no curved
46    /// geometry ignore it. [`TessellationQuality::Medium`] reproduces the
47    /// engine's historical hardcoded behavior.
48    fn process(
49        &self,
50        entity: &DecodedEntity,
51        decoder: &mut EntityDecoder,
52        schema: &IfcSchema,
53        quality: TessellationQuality,
54    ) -> Result<Mesh>;
55
56    /// Get supported IFC types
57    fn supported_types(&self) -> Vec<IfcType>;
58}
59
60/// Geometry router - routes entities to processors
61pub struct GeometryRouter {
62    schema: IfcSchema,
63    processors: HashMap<IfcType, Arc<dyn GeometryProcessor>>,
64    /// Cache for IfcRepresentationMap source geometry (MappedItem instancing)
65    /// Key: RepresentationMap entity ID, Value: Processed mesh
66    mapped_item_cache: RefCell<FxHashMap<u32, Arc<Mesh>>>,
67    /// Cache for geometry deduplication by content hash
68    /// Buildings with repeated floors have 99% identical geometry
69    /// Key: Hash of mesh content, Value: Processed mesh
70    geometry_hash_cache: RefCell<FxHashMap<u64, Arc<Mesh>>>,
71    /// Unit scale factor (e.g., 0.001 for millimeters -> meters)
72    /// Applied to all mesh positions after processing
73    unit_scale: f64,
74    /// RTC (Relative-to-Center) offset for handling large coordinates
75    /// Subtracted from all world positions in f64 before converting to f32
76    /// This preserves precision for georeferenced models (e.g., Swiss UTM)
77    rtc_offset: (f64, f64, f64),
78    /// Material-layer buildup index. When set, `process_element_with_submeshes`
79    /// and `process_element_with_submeshes_and_voids` first attempt to slice
80    /// single-solid elements by their `IfcMaterialLayerSetUsage` buildup.
81    material_layer_index: Option<Arc<MaterialLayerIndex>>,
82    /// Boolean / CSG failures attributed by IFC product express ID. Populated
83    /// by the void-subtraction path (`apply_void_context`) when the BSP
84    /// kernel falls back to the un-cut host. Drainable via
85    /// [`Self::take_csg_failures`].
86    csg_failures: RefCell<FxHashMap<u32, Vec<BoolFailure>>>,
87    /// Cumulative counters for opening classification (T1.1 / classifier fix
88    /// diagnostic). Tracks how many openings went through each branch of
89    /// `classify_openings` so a maintainer can verify the fix is firing on
90    /// real models. Drainable via [`Self::take_classification_stats`].
91    classification_stats: RefCell<ClassificationStats>,
92    /// Per-host opening diagnostic, keyed by host product express ID.
93    /// Captures everything the geometry pipeline knows about each host's
94    /// openings so a maintainer can answer "why didn't this wall's window
95    /// get cut?" from a console log alone. Drainable via
96    /// [`Self::take_host_opening_diagnostics`].
97    host_opening_diagnostics: RefCell<FxHashMap<u32, HostOpeningDiagnostic>>,
98    /// Tessellation detail level. Immutable per router instance and passed to
99    /// every processor's `process`. Defaults to [`TessellationQuality::Medium`]
100    /// (historical hardcoded behavior).
101    tessellation_quality: TessellationQuality,
102}
103
104/// Counts of opening classification outcomes during the most recent
105/// geometry pass. Useful for confirming whether the host-aware
106/// floor-opening classifier guard (commit `1e033f8`) is taking effect on
107/// a given model.
108#[derive(Debug, Default, Clone, Copy)]
109pub struct ClassificationStats {
110    /// Openings classified as `Rectangular` — fast AABB clip path.
111    pub rectangular: usize,
112    /// Openings classified as `DiagonalRectangular` — rotated AABB.
113    pub diagonal: usize,
114    /// Openings classified as `NonRectangular` — full CSG path
115    /// (no operand cap on the exact kernel).
116    pub non_rectangular: usize,
117    /// Openings the OLD heuristic would have flagged as floor-opening
118    /// (vertical extrusion, dir.z.abs() > 0.95) but the host is a
119    /// wall-class element — so the classifier fix kept them on the
120    /// rectangular path. Non-zero here = the fix activated.
121    pub floor_opening_guard_saved: usize,
122}
123
124/// Per-host opening diagnostic captured during void processing.
125///
126/// Populated incrementally: `classify_openings` fills in `host_type` and
127/// the per-opening classification list; `apply_void_context` adds the
128/// CSG failure tally drained from the kernel. Surfaced through
129/// [`GeometryRouter::take_host_opening_diagnostics`] for the WASM
130/// bindings to forward to JS.
131#[derive(Debug, Clone, Default)]
132pub struct HostOpeningDiagnostic {
133    /// Stringified IFC type of the host (e.g. `"IfcWallStandardCase"`).
134    pub host_type: String,
135    /// Per-opening classification record.
136    pub openings: Vec<OpeningDiagnostic>,
137    /// Number of `BoolFailure` records the kernel emitted while
138    /// processing this host's voids.
139    pub csg_failure_count: usize,
140    /// First `BoolFailure` reason recorded for this host, as a short
141    /// string label. Useful for grouping at a glance.
142    pub first_failure_label: Option<String>,
143    /// Triangle count of the host's mesh BEFORE void subtraction.
144    /// `None` until `apply_void_context` runs (or doesn't, if there
145    /// were no openings to apply).
146    pub tris_before: Option<usize>,
147    /// Triangle count AFTER void subtraction. Compare with
148    /// `tris_before` to spot "cuts attempted, no effect" cases — the
149    /// classic silent-no-op signature when an opening box doesn't
150    /// actually intersect the host mesh.
151    pub tris_after: Option<usize>,
152    /// Number of axis-aligned rectangular openings synthesised into penetrating
153    /// box cutters and subtracted (exactly) for this host. Compare against
154    /// `tris_before == tris_after` to detect the "ran cuts, geometry unchanged"
155    /// silent-no-op.
156    pub rect_boxes_processed: usize,
157    /// Bounding box of the host mesh (min, max) in world coords. Useful
158    /// for confirming that an opening box should overlap.
159    pub host_bounds: Option<((f32, f32, f32), (f32, f32, f32))>,
160}
161
162/// One opening's worth of diagnostic data — what `classify_openings`
163/// observed about it.
164#[derive(Debug, Clone)]
165pub struct OpeningDiagnostic {
166    /// Express ID of the `IfcOpeningElement` itself.
167    pub opening_id: u32,
168    /// Branch the classifier took for this opening.
169    pub kind: OpeningKindDiag,
170    /// Vertex count of the opening's mesh — high counts (>100) force the
171    /// non-rectangular path regardless of extrusion direction.
172    pub vertex_count: usize,
173    /// Whether the host-aware floor-opening guard saved this opening
174    /// from being mis-routed onto the CSG path.
175    pub guard_saved: bool,
176}
177
178/// Discriminator for [`OpeningDiagnostic::kind`]. Mirrors `OpeningType`
179/// without dragging the geometry data along.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum OpeningKindDiag {
182    Rectangular,
183    Diagonal,
184    NonRectangular,
185}
186
187impl OpeningKindDiag {
188    pub fn as_str(self) -> &'static str {
189        match self {
190            OpeningKindDiag::Rectangular => "Rectangular",
191            OpeningKindDiag::Diagonal => "Diagonal",
192            OpeningKindDiag::NonRectangular => "NonRectangular",
193        }
194    }
195}
196
197impl GeometryRouter {
198    /// Create new router with default processors
199    pub fn new() -> Self {
200        let schema = IfcSchema::new();
201        let schema_clone = schema.clone();
202        let mut router = Self {
203            schema,
204            processors: HashMap::new(),
205            mapped_item_cache: RefCell::new(FxHashMap::default()),
206            geometry_hash_cache: RefCell::new(FxHashMap::default()),
207            unit_scale: 1.0,             // Default to base meters
208            rtc_offset: (0.0, 0.0, 0.0), // Default to no offset
209            material_layer_index: None,
210            csg_failures: RefCell::new(FxHashMap::default()),
211            classification_stats: RefCell::new(ClassificationStats::default()),
212            host_opening_diagnostics: RefCell::new(FxHashMap::default()),
213            tessellation_quality: TessellationQuality::Medium,
214        };
215
216        // Register default P0 processors
217        router.register(Box::new(ExtrudedAreaSolidProcessor::new(
218            schema_clone.clone(),
219        )));
220        router.register(Box::new(ExtrudedAreaSolidTaperedProcessor::new(
221            schema_clone.clone(),
222        )));
223        router.register(Box::new(TriangulatedFaceSetProcessor::new()));
224        router.register(Box::new(PolygonalFaceSetProcessor::new()));
225        router.register(Box::new(MappedItemProcessor::new()));
226        router.register(Box::new(FacetedBrepProcessor::new()));
227        router.register(Box::new(BooleanClippingProcessor::new()));
228        router.register(Box::new(SweptDiskSolidProcessor::new(schema_clone.clone())));
229        router.register(Box::new(RevolvedAreaSolidProcessor::new(
230            schema_clone.clone(),
231        )));
232        router.register(Box::new(SectionedSolidHorizontalProcessor::new(
233            schema_clone.clone(),
234        )));
235        router.register(Box::new(AdvancedBrepProcessor::new()));
236        router.register(Box::new(BSplineSurfaceProcessor::new()));
237        router.register(Box::new(ShellBasedSurfaceModelProcessor::new()));
238        router.register(Box::new(FaceBasedSurfaceModelProcessor::new()));
239        router.register(Box::new(BlockProcessor::new()));
240        router.register(Box::new(SphereProcessor::new()));
241        router.register(Box::new(CsgSolidProcessor::new()));
242        router.register(Box::new(IfcAlignmentProcessor::new()));
243
244        router
245    }
246
247    /// Create router and extract unit scale from IFC file
248    /// Automatically finds IFCPROJECT and extracts length unit conversion
249    pub fn with_units<T>(content: &T, decoder: &mut EntityDecoder) -> Self
250    where
251        T: AsRef<[u8]> + ?Sized,
252    {
253        let content = content.as_ref();
254        let mut scanner = ifc_lite_core::EntityScanner::new(content);
255        let mut scale = 1.0;
256
257        // Scan through file to find IFCPROJECT
258        while let Some((id, type_name, _, _)) = scanner.next_entity() {
259            if type_name == "IFCPROJECT" {
260                if let Ok(s) = ifc_lite_core::extract_length_unit_scale(decoder, id) {
261                    scale = s;
262                }
263                break;
264            }
265        }
266
267        Self::with_scale(scale)
268    }
269
270    /// Create router with unit scale extracted from IFC file AND RTC offset for large coordinates
271    /// This is the recommended method for georeferenced models (Swiss UTM, etc.)
272    ///
273    /// # Arguments
274    /// * `content` - IFC file content
275    /// * `decoder` - Entity decoder
276    /// * `rtc_offset` - RTC offset to subtract from world coordinates (typically model centroid)
277    pub fn with_units_and_rtc<T>(
278        content: &T,
279        decoder: &mut ifc_lite_core::EntityDecoder,
280        rtc_offset: (f64, f64, f64),
281    ) -> Self
282    where
283        T: AsRef<[u8]> + ?Sized,
284    {
285        let content = content.as_ref();
286        let mut scanner = ifc_lite_core::EntityScanner::new(content);
287        let mut scale = 1.0;
288
289        // Scan through file to find IFCPROJECT
290        while let Some((id, type_name, _, _)) = scanner.next_entity() {
291            if type_name == "IFCPROJECT" {
292                if let Ok(s) = ifc_lite_core::extract_length_unit_scale(decoder, id) {
293                    scale = s;
294                }
295                break;
296            }
297        }
298
299        Self::with_scale_and_rtc(scale, rtc_offset)
300    }
301
302    /// Create router with pre-calculated unit scale
303    pub fn with_scale(unit_scale: f64) -> Self {
304        let mut router = Self::new();
305        router.unit_scale = unit_scale;
306        router
307    }
308
309    /// Create router with RTC offset for large coordinate handling
310    /// Use this for georeferenced models (e.g., Swiss UTM coordinates)
311    pub fn with_rtc(rtc_offset: (f64, f64, f64)) -> Self {
312        let mut router = Self::new();
313        router.rtc_offset = rtc_offset;
314        router
315    }
316
317    /// Create router with both unit scale and RTC offset
318    pub fn with_scale_and_rtc(unit_scale: f64, rtc_offset: (f64, f64, f64)) -> Self {
319        let mut router = Self::new();
320        router.unit_scale = unit_scale;
321        router.rtc_offset = rtc_offset;
322        router
323    }
324
325    /// Create router with a specific tessellation quality level
326    pub fn with_quality(quality: TessellationQuality) -> Self {
327        let mut router = Self::new();
328        router.tessellation_quality = quality;
329        router
330    }
331
332    /// Create router with both unit scale and tessellation quality
333    pub fn with_scale_and_quality(unit_scale: f64, quality: TessellationQuality) -> Self {
334        let mut router = Self::new();
335        router.unit_scale = unit_scale;
336        router.tessellation_quality = quality;
337        router
338    }
339
340    /// Set the tessellation quality level.
341    ///
342    /// Reusing one router across a quality change invalidates `mapped_item_cache`
343    /// (keyed by RepresentationMap id, not by quality), so it is cleared here to
344    /// avoid serving meshes tessellated at the previous level. The other caches
345    /// are content-hash keyed (`geometry_hash_cache`), so they stay correct.
346    pub fn set_tessellation_quality(&mut self, quality: TessellationQuality) {
347        if self.tessellation_quality == quality {
348            return;
349        }
350        self.tessellation_quality = quality;
351        self.mapped_item_cache.get_mut().clear();
352    }
353
354    /// Get the current tessellation quality level
355    #[inline]
356    pub fn tessellation_quality(&self) -> TessellationQuality {
357        self.tessellation_quality
358    }
359
360    /// Set the RTC offset for large coordinate handling
361    pub fn set_rtc_offset(&mut self, offset: (f64, f64, f64)) {
362        self.rtc_offset = offset;
363    }
364
365    /// Get the current RTC offset
366    pub fn rtc_offset(&self) -> (f64, f64, f64) {
367        self.rtc_offset
368    }
369
370    /// Check if RTC offset is active (non-zero)
371    #[inline]
372    pub fn has_rtc_offset(&self) -> bool {
373        self.rtc_offset.0 != 0.0 || self.rtc_offset.1 != 0.0 || self.rtc_offset.2 != 0.0
374    }
375
376    /// Get the current unit scale factor
377    pub fn unit_scale(&self) -> f64 {
378        self.unit_scale
379    }
380
381    /// Attach a material-layer buildup index. After this, sub-mesh processing
382    /// automatically slices single-solid elements whose buildup is sliceable
383    /// (walls with `IfcMaterialLayerSetUsage`, etc.) into per-layer slabs.
384    pub fn set_material_layer_index(&mut self, index: Arc<MaterialLayerIndex>) {
385        self.material_layer_index = Some(index);
386    }
387
388    #[inline]
389    pub(crate) fn material_layer_index(&self) -> Option<&MaterialLayerIndex> {
390        self.material_layer_index.as_deref()
391    }
392
393    /// Scale mesh positions from file units to meters
394    /// Only applies scaling if unit_scale != 1.0
395    #[inline]
396    fn scale_mesh(&self, mesh: &mut Mesh) {
397        if self.unit_scale != 1.0 {
398            let scale = self.unit_scale as f32;
399            for pos in mesh.positions.iter_mut() {
400                *pos *= scale;
401            }
402        }
403    }
404
405    /// Scale the translation component of a transform matrix from file units to meters
406    /// The rotation/scale part stays unchanged, only translation (column 3) is scaled
407    #[inline]
408    fn scale_transform(&self, transform: &mut Matrix4<f64>) {
409        if self.unit_scale != 1.0 {
410            transform[(0, 3)] *= self.unit_scale;
411            transform[(1, 3)] *= self.unit_scale;
412            transform[(2, 3)] *= self.unit_scale;
413        }
414    }
415
416    /// Register a geometry processor
417    pub fn register(&mut self, processor: Box<dyn GeometryProcessor>) {
418        let processor_arc: Arc<dyn GeometryProcessor> = Arc::from(processor);
419        for ifc_type in processor_arc.supported_types() {
420            self.processors.insert(ifc_type, Arc::clone(&processor_arc));
421        }
422    }
423
424    /// Resolve an element's ObjectPlacement to a scaled world-space transform matrix.
425    /// Returns the 4x4 matrix as a flat column-major array of 16 f64 values.
426    /// The translation component is scaled from file units to meters.
427    ///
428    /// Contributed by Mathias Søndergaard (Sonderwoods/Linkajou).
429    pub fn resolve_scaled_placement(
430        &self,
431        entity: &DecodedEntity,
432        decoder: &mut EntityDecoder,
433    ) -> Result<[f64; 16]> {
434        let mut transform = self.get_placement_transform_from_element(entity, decoder)?;
435        self.scale_transform(&mut transform);
436        let mut result = [0.0f64; 16];
437        result.copy_from_slice(transform.as_slice());
438        Ok(result)
439    }
440
441    /// Get schema reference
442    pub fn schema(&self) -> &IfcSchema {
443        &self.schema
444    }
445
446    /// Drain the boolean / CSG failures accumulated by the void-subtraction
447    /// path since the router was created (or the last `take_csg_failures`
448    /// call). Failures are keyed by IFC product express ID — the element
449    /// whose opening / clip operation tripped a fallback.
450    ///
451    /// Only the router-driven CSG path (multi-layer wall sub-meshes,
452    /// single-mesh `apply_voids_to_mesh`) is currently attributed. Standalone
453    /// `IfcBooleanResult` chains processed via the mapped-item path don't
454    /// yet flow their failures here.
455    pub fn take_csg_failures(&self) -> FxHashMap<u32, Vec<BoolFailure>> {
456        // Fold in any failures from contexts without a direct router handle
457        // (notably the transient `BooleanClippingProcessor` inside
458        // `MappedItemProcessor`). They have no product attribution, so we
459        // bucket them under product id 0 — keeps the diagnostics surface
460        // visible without inventing a fake host id.
461        let pending = crate::diagnostics::take_pending_mapped_bool_failures();
462        if !pending.is_empty() {
463            self.csg_failures
464                .borrow_mut()
465                .entry(0)
466                .or_default()
467                .extend(pending);
468        }
469        std::mem::take(&mut *self.csg_failures.borrow_mut())
470    }
471
472    /// Number of products with at least one recorded CSG failure.
473    pub fn csg_failure_product_count(&self) -> usize {
474        self.csg_failures.borrow().len()
475    }
476
477    /// Total number of CSG failures across all products.
478    pub fn csg_failure_total(&self) -> usize {
479        self.csg_failures.borrow().values().map(|v| v.len()).sum()
480    }
481
482    /// Internal: record a batch of failures against a product. Existing
483    /// entries for the same product are appended to.
484    pub(crate) fn record_csg_failures(&self, product_id: u32, failures: Vec<BoolFailure>) {
485        if failures.is_empty() {
486            return;
487        }
488        let attributed: Vec<BoolFailure> = failures
489            .into_iter()
490            .map(|f| f.with_product_id(product_id))
491            .collect();
492        self.csg_failures
493            .borrow_mut()
494            .entry(product_id)
495            .or_default()
496            .extend(attributed);
497    }
498
499    /// Drain and return the cumulative opening-classification counters
500    /// since the router was created (or the last `take_classification_stats`
501    /// call). The internal counters are reset to zero.
502    pub fn take_classification_stats(&self) -> ClassificationStats {
503        std::mem::take(&mut *self.classification_stats.borrow_mut())
504    }
505
506    /// Drain and return the per-host opening diagnostic map.
507    pub fn take_host_opening_diagnostics(&self) -> FxHashMap<u32, HostOpeningDiagnostic> {
508        std::mem::take(&mut *self.host_opening_diagnostics.borrow_mut())
509    }
510
511    /// Total number of hosts with diagnostic records (mostly for tests).
512    pub fn host_opening_diagnostic_count(&self) -> usize {
513        self.host_opening_diagnostics.borrow().len()
514    }
515
516    /// Internal: bump the classification stats. Called from
517    /// `classify_openings` for each opening it processes.
518    pub(crate) fn bump_classification(&self, kind: ClassificationKind) {
519        let mut s = self.classification_stats.borrow_mut();
520        match kind {
521            ClassificationKind::Rectangular => s.rectangular += 1,
522            ClassificationKind::Diagonal => s.diagonal += 1,
523            ClassificationKind::NonRectangular => s.non_rectangular += 1,
524            ClassificationKind::FloorOpeningGuardSaved => s.floor_opening_guard_saved += 1,
525        }
526    }
527
528    /// Internal: record / merge per-host opening diagnostic. Called from
529    /// `classify_openings` once per host with the host type + the list of
530    /// openings it observed. `apply_void_context` later adds the CSG
531    /// failure tally for the same host.
532    pub(crate) fn record_host_opening_diagnostic(
533        &self,
534        host_id: u32,
535        host_type: &str,
536        openings: Vec<OpeningDiagnostic>,
537    ) {
538        let mut log = self.host_opening_diagnostics.borrow_mut();
539        let entry = log.entry(host_id).or_default();
540        if entry.host_type.is_empty() {
541            entry.host_type = host_type.to_string();
542        }
543        entry.openings.extend(openings);
544    }
545
546    /// Internal: tag the per-host diagnostic with the cut-effect data
547    /// (triangle counts before/after, rectangular boxes processed, host
548    /// bounds). Lets callers spot the "rectangular cut attempted but
549    /// produced no change" case — the silent-no-op signature when an
550    /// opening box's geometry doesn't actually intersect the host mesh
551    /// despite passing the AABB classifier.
552    pub(crate) fn record_host_cut_effect(
553        &self,
554        host_id: u32,
555        tris_before: usize,
556        tris_after: usize,
557        rect_boxes_processed: usize,
558        host_bounds: ((f32, f32, f32), (f32, f32, f32)),
559    ) {
560        let mut log = self.host_opening_diagnostics.borrow_mut();
561        let entry = log.entry(host_id).or_default();
562        entry.tris_before = Some(tris_before);
563        entry.tris_after = Some(tris_after);
564        entry.rect_boxes_processed = rect_boxes_processed;
565        entry.host_bounds = Some(host_bounds);
566    }
567
568    /// Internal: tag the per-host diagnostic with the failure summary for
569    /// this host. Drained from `ClippingProcessor::take_failures` after
570    /// `apply_void_context` finishes.
571    pub(crate) fn record_host_failure_summary(&self, host_id: u32, failures: &[BoolFailure]) {
572        if failures.is_empty() {
573            return;
574        }
575        let mut log = self.host_opening_diagnostics.borrow_mut();
576        let entry = log.entry(host_id).or_default();
577        entry.csg_failure_count += failures.len();
578        if entry.first_failure_label.is_none() {
579            // Short label for at-a-glance grouping. Full BoolFailure list
580            // remains in `csg_failures` for callers that want detail.
581            let label = match &failures[0].reason {
582                crate::diagnostics::BoolFailureReason::OperandTooLarge { .. } => "OperandTooLarge",
583                crate::diagnostics::BoolFailureReason::EmptyOperand => "EmptyOperand",
584                crate::diagnostics::BoolFailureReason::DegenerateOperand => "DegenerateOperand",
585                crate::diagnostics::BoolFailureReason::NoBoundsOverlap => "NoBoundsOverlap",
586                crate::diagnostics::BoolFailureReason::KernelOutputInvalid => "KernelOutputInvalid",
587                crate::diagnostics::BoolFailureReason::SolidSolidDifferenceSkipped => {
588                    "SolidSolidDifferenceSkipped"
589                }
590                crate::diagnostics::BoolFailureReason::PolygonalBoundedHalfSpaceFallback => {
591                    "PolygonalBoundedHalfSpaceFallback"
592                }
593                crate::diagnostics::BoolFailureReason::CutterUnionUnavailable => {
594                    "CutterUnionUnavailable"
595                }
596                crate::diagnostics::BoolFailureReason::UnknownBooleanOperator(_) => {
597                    "UnknownBooleanOperator"
598                }
599                crate::diagnostics::BoolFailureReason::ManifoldOutputDegenerate { .. } => {
600                    "ManifoldOutputDegenerate"
601                }
602                crate::diagnostics::BoolFailureReason::KernelError(_) => "KernelError",
603                crate::diagnostics::BoolFailureReason::DifferenceEmptiedHost => {
604                    "DifferenceEmptiedHost"
605                }
606            };
607            entry.first_failure_label = Some(label.to_string());
608        }
609    }
610}
611
612/// Internal classification-branch tag for `bump_classification`. Mirrors
613/// the variants of `OpeningType` plus the "the host-aware guard saved
614/// this opening from the floor-opening path" sentinel.
615#[derive(Debug, Clone, Copy)]
616pub(crate) enum ClassificationKind {
617    Rectangular,
618    Diagonal,
619    NonRectangular,
620    /// Retained for backwards compatibility. After main's per-item geometry
621    /// classification superseded the host-aware floor-opening heuristic this
622    /// variant is no longer bumped (the per-item path makes the same call
623    /// without the global guard). The field on `Stats` remains so older
624    /// JSON consumers don't see schema breakage.
625    #[allow(dead_code)]
626    FloorOpeningGuardSaved,
627}
628
629impl Default for GeometryRouter {
630    fn default() -> Self {
631        Self::new()
632    }
633}