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