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}