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. Think twice before adding one: a
33    /// thread-local is not scoped to a router, so records a router never
34    /// drains go to whichever router drains next on that thread. A transient
35    /// processor with a router-held PARENT has a better route out — see
36    /// `CsgSolidProcessor::take_failures` (#3821).
37    static PENDING_MAPPED_BOOL_FAILURES: RefCell<Vec<BoolFailure>> =
38        const { RefCell::new(Vec::new()) };
39}
40
41/// Drain failures pushed from a context with no direct router handle (see
42/// `PENDING_MAPPED_BOOL_FAILURES`).
43pub fn take_pending_mapped_bool_failures() -> Vec<BoolFailure> {
44    PENDING_MAPPED_BOOL_FAILURES.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
45}
46
47/// Which boolean operation produced the failure.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BoolOp {
50    Difference,
51    Union,
52    Intersection,
53    /// `IfcBooleanResult.Operator` was an unrecognised value — used by the
54    /// boolean processor when classifying a failure for an unknown operator
55    /// so the diagnostic doesn't mis-label the op as `Difference`.
56    Unknown,
57}
58
59impl fmt::Display for BoolOp {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            BoolOp::Difference => f.write_str("DIFFERENCE"),
63            BoolOp::Union => f.write_str("UNION"),
64            BoolOp::Intersection => f.write_str("INTERSECTION"),
65            BoolOp::Unknown => f.write_str("UNKNOWN"),
66        }
67    }
68}
69
70/// Why a boolean operation failed or was skipped.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum BoolFailureReason {
73    /// HISTORICAL: at least one operand exceeded the deleted legacy BSP CSG
74    /// polygon cap. The pure-Rust exact kernel has no operand cap, so this is
75    /// no longer emitted by the boolean ops; the variant (and its JSON label)
76    /// is kept for the frozen diagnostics surface and void-router plumbing.
77    OperandTooLarge {
78        polys_a: usize,
79        polys_b: usize,
80    },
81    /// One or both operand meshes were empty before polygon extraction.
82    EmptyOperand,
83    /// Polygon extraction yielded an empty list (degenerate / non-finite vertices).
84    DegenerateOperand,
85    /// Operand bounding boxes don't overlap. Informational — host returned unchanged.
86    NoBoundsOverlap,
87    /// The CSG kernel returned malformed polygons (NaN / non-finite).
88    KernelOutputInvalid,
89    /// HISTORICAL: solid-vs-solid `IfcBooleanResult.DIFFERENCE` was not
90    /// attempted because the deleted legacy BSP could stack-overflow on
91    /// arbitrary solid combinations. No longer emitted — the exact kernel
92    /// always attempts the cut. Variant kept for the frozen label surface.
93    SolidSolidDifferenceSkipped,
94    /// `IfcPolygonalBoundedHalfSpace` prism-subtraction failed; the kernel
95    /// fell back to an unbounded plane clip, silently dropping the polygonal
96    /// boundary. The clip *is* applied but is a strict superset of the
97    /// requested cut.
98    PolygonalBoundedHalfSpaceFallback,
99    /// The chained-clip cutter prisms couldn't be unioned into one watertight
100    /// solid, so the single batched subtract (issue #960) was skipped and the
101    /// chain fell back to sequential per-cutter subtraction. The cuts *are*
102    /// applied, but abutting cutters may leave zero-thickness seam fins that
103    /// the batched path would have eliminated.
104    CutterUnionUnavailable,
105    /// `IfcBooleanResult` operator string didn't match any known op.
106    UnknownBooleanOperator(String),
107    /// The operand walk under one representation item entered more
108    /// boolean/CSG nodes than `processors::boolean::MAX_OPERAND_VISITS`
109    /// allows and was stopped. The item is dropped (the walk returns `Err`),
110    /// so this is the only trace of why. A shared SecondOperand re-meshed
111    /// down `m^levels` paths is the shape that gets here; a real item does
112    /// not.
113    OperandBudgetExhausted,
114    /// HISTORICAL: the deleted Manifold C++ kernel's `difference` returned
115    /// output implausibly small relative to the host (a Linux-x86_64-only
116    /// pathology). No longer emitted — the deterministic exact kernel
117    /// replaced Manifold. Variant kept for the frozen label surface.
118    ManifoldOutputDegenerate {
119        host_tris: usize,
120        result_tris: usize,
121    },
122    /// Catch-all for kernel-specific errors (free-form string).
123    KernelError(String),
124    /// `IfcBooleanResult.DIFFERENCE` produced an empty mesh from a non-empty
125    /// host. Almost always a buggy export — a clip plane authored AT the
126    /// wall's top with `AgreementFlag = .T.` (issue #821, Revit IFC2x3
127    /// TallBuilding.ifc) makes the half-space material region exactly cover
128    /// the wall body, so the strict-spec subtract yields nothing. The caller
129    /// falls back to the un-cut host (matching what BIMVision and similar
130    /// viewers do in practice) and records this so the loss surfaces in
131    /// diagnostics rather than as a silently missing element.
132    DifferenceEmptiedHost,
133    /// A boolean operand's `IfcRepresentationItem` type has no meshing branch
134    /// in `BooleanClippingProcessor::process_operand_checked`, so the operand
135    /// resolved to an EMPTY mesh. Carries the operand's IFC type name.
136    ///
137    /// As a FIRST operand this is a real element-geometry loss: the base solid
138    /// is empty, the whole boolean result is empty, and the element renders
139    /// nothing from that item. As a SECOND operand it means "unsupported
140    /// cutter" — the host renders un-cut, and the pre-existing `EmptyOperand`
141    /// record for the same step is also emitted (one record for the cause, one
142    /// for the consequence).
143    UnsupportedOperand(String),
144    /// #3440 step 2: the kernel result passed `validate_mesh` (finite,
145    /// in-bounds) but failed the directed-edge closure audit
146    /// (`topology_gate_reject` — same predicate `KernelError`'s step-1
147    /// informational record already checks) and was REJECTED rather than
148    /// merely flagged. Only ever constructed when this crate is built with
149    /// the `csg_topology_gate` feature, which is off by default and enabled
150    /// by nothing downstream — so this variant exists in every build (a
151    /// stable thing for consumers to match on, per the crate's convention for
152    /// its other rarely-emitted variants above) but is only ever actually
153    /// recorded in a `--features csg_topology_gate` build.
154    OpenTopologyRejected,
155    /// #3440 step 3: the kernel result passed `validate_mesh` AND the signed
156    /// closure audit, but carries an edge-multiplicity defect that audit
157    /// cannot express — an undirected edge used by more than two triangles, or
158    /// used twice the same way round (`edge_multiplicity_defects`). Rejected,
159    /// and the caller falls back exactly as it does for
160    /// `KernelOutputInvalid`.
161    ///
162    /// Like [`Self::OpenTopologyRejected`], only ever constructed under a
163    /// feature — its own `csg_manifold_gate`, kept separate from
164    /// `csg_topology_gate` so a census can attribute a flip to one defect
165    /// class rather than to whichever gate fired first. The variant exists in
166    /// every build, per this enum's convention for its other rarely-emitted
167    /// reasons.
168    ///
169    /// The gate reads a defect class benign tessellation cannot produce, so it
170    /// was tried as a default; the fallback a rejection reaches turned out to
171    /// be worse than the tear it replaces. The numbers behind that live in one
172    /// place — `csg_manifold_gate` in `Cargo.toml` — with the corpus sweep in
173    /// `tests/issue_3440_manifold_gate_census.rs`.
174    ///
175    /// Carries the two counts (as `OperandTooLarge` carries its operand sizes)
176    /// so the census can break the flip set down by defect class without a
177    /// second sweep: the two say different things about the kernel, and a bare
178    /// variant would force anyone measuring to re-derive them.
179    NonManifoldRejected {
180        /// Undirected edges used by more than two triangles.
181        over_used: usize,
182        /// Undirected edges used twice, both uses the same way round.
183        same_direction: usize,
184    },
185}
186
187impl BoolFailureReason {
188    /// Stable short label for per-reason aggregation. Single home shared by
189    /// the wasm console diagnostics and the server tracing summary so the
190    /// two surfaces cannot drift (Rust-first).
191    pub fn label(&self) -> &'static str {
192        match self {
193            BoolFailureReason::OperandTooLarge { .. } => "OperandTooLarge",
194            BoolFailureReason::EmptyOperand => "EmptyOperand",
195            BoolFailureReason::DegenerateOperand => "DegenerateOperand",
196            BoolFailureReason::NoBoundsOverlap => "NoBoundsOverlap",
197            BoolFailureReason::KernelOutputInvalid => "KernelOutputInvalid",
198            BoolFailureReason::SolidSolidDifferenceSkipped => "SolidSolidDifferenceSkipped",
199            BoolFailureReason::PolygonalBoundedHalfSpaceFallback => {
200                "PolygonalBoundedHalfSpaceFallback"
201            }
202            BoolFailureReason::CutterUnionUnavailable => "CutterUnionUnavailable",
203            BoolFailureReason::UnknownBooleanOperator(_) => "UnknownBooleanOperator",
204            BoolFailureReason::OperandBudgetExhausted => "OperandBudgetExhausted",
205            BoolFailureReason::ManifoldOutputDegenerate { .. } => "ManifoldOutputDegenerate",
206            BoolFailureReason::KernelError(_) => "KernelError",
207            BoolFailureReason::DifferenceEmptiedHost => "DifferenceEmptiedHost",
208            BoolFailureReason::OpenTopologyRejected => "OpenTopologyRejected",
209            BoolFailureReason::UnsupportedOperand(_) => "UnsupportedOperand",
210            BoolFailureReason::NonManifoldRejected { .. } => "NonManifoldRejected",
211        }
212    }
213}
214
215impl fmt::Display for BoolFailureReason {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        match self {
218            BoolFailureReason::OperandTooLarge { polys_a, polys_b } => write!(
219                f,
220                "operand polygon counts ({polys_a}, {polys_b}) exceed BSP cap"
221            ),
222            BoolFailureReason::EmptyOperand => f.write_str("operand mesh empty"),
223            BoolFailureReason::DegenerateOperand => f.write_str("operand polygons degenerate"),
224            BoolFailureReason::NoBoundsOverlap => f.write_str("operand bounds disjoint"),
225            BoolFailureReason::KernelOutputInvalid => {
226                f.write_str("CSG kernel output had non-finite vertices")
227            }
228            BoolFailureReason::SolidSolidDifferenceSkipped => {
229                f.write_str("solid-vs-solid IfcBooleanResult.DIFFERENCE skipped (BSP unsafe)")
230            }
231            BoolFailureReason::PolygonalBoundedHalfSpaceFallback => f.write_str(
232                "IfcPolygonalBoundedHalfSpace degraded to unbounded plane clip",
233            ),
234            BoolFailureReason::CutterUnionUnavailable => f.write_str(
235                "cutter union not watertight; deferred to sequential per-cutter subtraction",
236            ),
237            BoolFailureReason::UnknownBooleanOperator(op) => {
238                write!(f, "unknown IfcBooleanResult operator '{op}'")
239            }
240            BoolFailureReason::OperandBudgetExhausted => f.write_str(
241                "boolean operand walk exhausted its per-item node visit budget; the item is dropped",
242            ),
243            BoolFailureReason::DifferenceEmptiedHost => f.write_str(
244                "DIFFERENCE removed the entire host; reverted to un-cut",
245            ),
246            BoolFailureReason::ManifoldOutputDegenerate {
247                host_tris,
248                result_tris,
249            } => write!(
250                f,
251                "Manifold difference returned implausibly small result ({result_tris} triangles from {host_tris}-triangle host) — fell back to BSP"
252            ),
253            BoolFailureReason::KernelError(msg) => write!(f, "kernel error: {msg}"),
254            BoolFailureReason::OpenTopologyRejected => f.write_str(
255                "CSG kernel output passed validate_mesh but failed the closure audit; rejected under csg_topology_gate (#3440)",
256            ),
257            BoolFailureReason::UnsupportedOperand(ty) => {
258                write!(f, "boolean operand type '{ty}' has no processor; operand meshed empty")
259            }
260            BoolFailureReason::NonManifoldRejected {
261                over_used,
262                same_direction,
263            } => write!(
264                f,
265                "CSG kernel output passed validate_mesh but carries {over_used} non-manifold and {same_direction} reversed-winding edge(s); rejected (#3440)"
266            ),
267        }
268    }
269}
270
271/// Single boolean / CSG failure record.
272///
273/// `product_id` is optional because the CSG kernel itself doesn't know which
274/// IFC product it's operating on — the router fills that in when it drains
275/// failures after processing an element.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct BoolFailure {
278    pub op: BoolOp,
279    pub reason: BoolFailureReason,
280    pub product_id: Option<u32>,
281}
282
283impl BoolFailure {
284    pub fn new(op: BoolOp, reason: BoolFailureReason) -> Self {
285        Self {
286            op,
287            reason,
288            product_id: None,
289        }
290    }
291
292    /// Attach an IFC product express ID. Used by the router after the CSG
293    /// kernel returns, since the kernel itself is product-agnostic.
294    pub fn with_product_id(mut self, product_id: u32) -> Self {
295        self.product_id = Some(product_id);
296        self
297    }
298}
299
300impl fmt::Display for BoolFailure {
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        match self.product_id {
303            Some(id) => write!(f, "[product #{id}] {} failed: {}", self.op, self.reason),
304            None => write!(f, "{} failed: {}", self.op, self.reason),
305        }
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn display_includes_operands() {
315        let f = BoolFailure::new(
316            BoolOp::Difference,
317            BoolFailureReason::OperandTooLarge {
318                polys_a: 36,
319                polys_b: 12,
320            },
321        );
322        let rendered = f.to_string();
323        assert!(rendered.contains("DIFFERENCE"));
324        assert!(rendered.contains("36"));
325        assert!(rendered.contains("12"));
326    }
327
328    #[test]
329    fn with_product_id_attaches_id() {
330        let f = BoolFailure::new(BoolOp::Union, BoolFailureReason::EmptyOperand)
331            .with_product_id(12345);
332        assert_eq!(f.product_id, Some(12345));
333        assert!(f.to_string().contains("12345"));
334    }
335
336    #[test]
337    fn solid_solid_skip_renders_meaningfully() {
338        let f = BoolFailure::new(BoolOp::Difference, BoolFailureReason::SolidSolidDifferenceSkipped);
339        let rendered = f.to_string();
340        assert!(rendered.contains("solid-vs-solid"));
341        assert!(rendered.contains("DIFFERENCE"));
342    }
343}