Skip to main content

ifc_lite_geometry/
diagnostics.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//! Boolean / CSG failure diagnostics.
6//!
7//! Pre-T1.3, the CSG processor silently fell back to returning the un-cut host
8//! mesh whenever it couldn't run an operation (cap exceeded, kernel error,
9//! degenerate input, etc.). This left viewers rendering wrong geometry with no
10//! signal to the user.
11//!
12//! This module gives every fallback a structured failure record. Callers can
13//! drain failures off the `ClippingProcessor` after a sequence of operations
14//! and surface them — e.g. a debug overlay that highlights products with
15//! failed clips, or a CI assertion that no failures occurred on a known-good
16//! fixture.
17//!
18//! The runtime behaviour is unchanged: failures are recorded *in addition*
19//! to (not instead of) the existing fallback. The kernel regression tests
20//! rely on these records.
21
22use std::cell::RefCell;
23use std::fmt;
24
25thread_local! {
26    /// Pending boolean failures from contexts that have no direct router
27    /// handle. Historically fed by `MappedItemProcessor`'s transient
28    /// `BooleanClippingProcessor` (deleted as dead code — every dispatch site
29    /// special-cased `IfcMappedItem` before it could ever be reached; see the
30    /// D5 dead-code sweep), so nothing pushes into this today. Kept + still
31    /// drained by `take_csg_failures` in case a future non-router boolean
32    /// context needs the same escape hatch.
33    static PENDING_MAPPED_BOOL_FAILURES: RefCell<Vec<BoolFailure>> =
34        const { RefCell::new(Vec::new()) };
35}
36
37/// Drain failures pushed from a context with no direct router handle (see
38/// `PENDING_MAPPED_BOOL_FAILURES`).
39pub fn take_pending_mapped_bool_failures() -> Vec<BoolFailure> {
40    PENDING_MAPPED_BOOL_FAILURES.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
41}
42
43/// Which boolean operation produced the failure.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BoolOp {
46    Difference,
47    Union,
48    Intersection,
49    /// `IfcBooleanResult.Operator` was an unrecognised value — used by the
50    /// boolean processor when classifying a failure for an unknown operator
51    /// so the diagnostic doesn't mis-label the op as `Difference`.
52    Unknown,
53}
54
55impl fmt::Display for BoolOp {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            BoolOp::Difference => f.write_str("DIFFERENCE"),
59            BoolOp::Union => f.write_str("UNION"),
60            BoolOp::Intersection => f.write_str("INTERSECTION"),
61            BoolOp::Unknown => f.write_str("UNKNOWN"),
62        }
63    }
64}
65
66/// Why a boolean operation failed or was skipped.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum BoolFailureReason {
69    /// HISTORICAL: at least one operand exceeded the deleted legacy BSP CSG
70    /// polygon cap. The pure-Rust exact kernel has no operand cap, so this is
71    /// no longer emitted by the boolean ops; the variant (and its JSON label)
72    /// is kept for the frozen diagnostics surface and void-router plumbing.
73    OperandTooLarge {
74        polys_a: usize,
75        polys_b: usize,
76    },
77    /// One or both operand meshes were empty before polygon extraction.
78    EmptyOperand,
79    /// Polygon extraction yielded an empty list (degenerate / non-finite vertices).
80    DegenerateOperand,
81    /// Operand bounding boxes don't overlap. Informational — host returned unchanged.
82    NoBoundsOverlap,
83    /// The CSG kernel returned malformed polygons (NaN / non-finite).
84    KernelOutputInvalid,
85    /// HISTORICAL: solid-vs-solid `IfcBooleanResult.DIFFERENCE` was not
86    /// attempted because the deleted legacy BSP could stack-overflow on
87    /// arbitrary solid combinations. No longer emitted — the exact kernel
88    /// always attempts the cut. Variant kept for the frozen label surface.
89    SolidSolidDifferenceSkipped,
90    /// `IfcPolygonalBoundedHalfSpace` prism-subtraction failed; the kernel
91    /// fell back to an unbounded plane clip, silently dropping the polygonal
92    /// boundary. The clip *is* applied but is a strict superset of the
93    /// requested cut.
94    PolygonalBoundedHalfSpaceFallback,
95    /// The chained-clip cutter prisms couldn't be unioned into one watertight
96    /// solid, so the single batched subtract (issue #960) was skipped and the
97    /// chain fell back to sequential per-cutter subtraction. The cuts *are*
98    /// applied, but abutting cutters may leave zero-thickness seam fins that
99    /// the batched path would have eliminated.
100    CutterUnionUnavailable,
101    /// `IfcBooleanResult` operator string didn't match any known op.
102    UnknownBooleanOperator(String),
103    /// HISTORICAL: the deleted Manifold C++ kernel's `difference` returned
104    /// output implausibly small relative to the host (a Linux-x86_64-only
105    /// pathology). No longer emitted — the deterministic exact kernel
106    /// replaced Manifold. Variant kept for the frozen label surface.
107    ManifoldOutputDegenerate {
108        host_tris: usize,
109        result_tris: usize,
110    },
111    /// Catch-all for kernel-specific errors (free-form string).
112    KernelError(String),
113    /// `IfcBooleanResult.DIFFERENCE` produced an empty mesh from a non-empty
114    /// host. Almost always a buggy export — a clip plane authored AT the
115    /// wall's top with `AgreementFlag = .T.` (issue #821, Revit IFC2x3
116    /// TallBuilding.ifc) makes the half-space material region exactly cover
117    /// the wall body, so the strict-spec subtract yields nothing. The caller
118    /// falls back to the un-cut host (matching what BIMVision and similar
119    /// viewers do in practice) and records this so the loss surfaces in
120    /// diagnostics rather than as a silently missing element.
121    DifferenceEmptiedHost,
122}
123
124impl BoolFailureReason {
125    /// Stable short label for per-reason aggregation. Single home shared by
126    /// the wasm console diagnostics and the server tracing summary so the
127    /// two surfaces cannot drift (Rust-first).
128    pub fn label(&self) -> &'static str {
129        match self {
130            BoolFailureReason::OperandTooLarge { .. } => "OperandTooLarge",
131            BoolFailureReason::EmptyOperand => "EmptyOperand",
132            BoolFailureReason::DegenerateOperand => "DegenerateOperand",
133            BoolFailureReason::NoBoundsOverlap => "NoBoundsOverlap",
134            BoolFailureReason::KernelOutputInvalid => "KernelOutputInvalid",
135            BoolFailureReason::SolidSolidDifferenceSkipped => "SolidSolidDifferenceSkipped",
136            BoolFailureReason::PolygonalBoundedHalfSpaceFallback => {
137                "PolygonalBoundedHalfSpaceFallback"
138            }
139            BoolFailureReason::CutterUnionUnavailable => "CutterUnionUnavailable",
140            BoolFailureReason::UnknownBooleanOperator(_) => "UnknownBooleanOperator",
141            BoolFailureReason::ManifoldOutputDegenerate { .. } => "ManifoldOutputDegenerate",
142            BoolFailureReason::KernelError(_) => "KernelError",
143            BoolFailureReason::DifferenceEmptiedHost => "DifferenceEmptiedHost",
144        }
145    }
146}
147
148impl fmt::Display for BoolFailureReason {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            BoolFailureReason::OperandTooLarge { polys_a, polys_b } => write!(
152                f,
153                "operand polygon counts ({polys_a}, {polys_b}) exceed BSP cap"
154            ),
155            BoolFailureReason::EmptyOperand => f.write_str("operand mesh empty"),
156            BoolFailureReason::DegenerateOperand => f.write_str("operand polygons degenerate"),
157            BoolFailureReason::NoBoundsOverlap => f.write_str("operand bounds disjoint"),
158            BoolFailureReason::KernelOutputInvalid => {
159                f.write_str("CSG kernel output had non-finite vertices")
160            }
161            BoolFailureReason::SolidSolidDifferenceSkipped => {
162                f.write_str("solid-vs-solid IfcBooleanResult.DIFFERENCE skipped (BSP unsafe)")
163            }
164            BoolFailureReason::PolygonalBoundedHalfSpaceFallback => f.write_str(
165                "IfcPolygonalBoundedHalfSpace degraded to unbounded plane clip",
166            ),
167            BoolFailureReason::CutterUnionUnavailable => f.write_str(
168                "cutter union not watertight; deferred to sequential per-cutter subtraction",
169            ),
170            BoolFailureReason::UnknownBooleanOperator(op) => {
171                write!(f, "unknown IfcBooleanResult operator '{op}'")
172            }
173            BoolFailureReason::DifferenceEmptiedHost => f.write_str(
174                "DIFFERENCE removed the entire host; reverted to un-cut",
175            ),
176            BoolFailureReason::ManifoldOutputDegenerate {
177                host_tris,
178                result_tris,
179            } => write!(
180                f,
181                "Manifold difference returned implausibly small result ({result_tris} triangles from {host_tris}-triangle host) — fell back to BSP"
182            ),
183            BoolFailureReason::KernelError(msg) => write!(f, "kernel error: {msg}"),
184        }
185    }
186}
187
188/// Single boolean / CSG failure record.
189///
190/// `product_id` is optional because the CSG kernel itself doesn't know which
191/// IFC product it's operating on — the router fills that in when it drains
192/// failures after processing an element.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct BoolFailure {
195    pub op: BoolOp,
196    pub reason: BoolFailureReason,
197    pub product_id: Option<u32>,
198}
199
200impl BoolFailure {
201    pub fn new(op: BoolOp, reason: BoolFailureReason) -> Self {
202        Self {
203            op,
204            reason,
205            product_id: None,
206        }
207    }
208
209    /// Attach an IFC product express ID. Used by the router after the CSG
210    /// kernel returns, since the kernel itself is product-agnostic.
211    pub fn with_product_id(mut self, product_id: u32) -> Self {
212        self.product_id = Some(product_id);
213        self
214    }
215}
216
217impl fmt::Display for BoolFailure {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self.product_id {
220            Some(id) => write!(f, "[product #{id}] {} failed: {}", self.op, self.reason),
221            None => write!(f, "{} failed: {}", self.op, self.reason),
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn display_includes_operands() {
232        let f = BoolFailure::new(
233            BoolOp::Difference,
234            BoolFailureReason::OperandTooLarge {
235                polys_a: 36,
236                polys_b: 12,
237            },
238        );
239        let rendered = f.to_string();
240        assert!(rendered.contains("DIFFERENCE"));
241        assert!(rendered.contains("36"));
242        assert!(rendered.contains("12"));
243    }
244
245    #[test]
246    fn with_product_id_attaches_id() {
247        let f = BoolFailure::new(BoolOp::Union, BoolFailureReason::EmptyOperand)
248            .with_product_id(12345);
249        assert_eq!(f.product_id, Some(12345));
250        assert!(f.to_string().contains("12345"));
251    }
252
253    #[test]
254    fn solid_solid_skip_renders_meaningfully() {
255        let f = BoolFailure::new(BoolOp::Difference, BoolFailureReason::SolidSolidDifferenceSkipped);
256        let rendered = f.to_string();
257        assert!(rendered.contains("solid-vs-solid"));
258        assert!(rendered.contains("DIFFERENCE"));
259    }
260}