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}