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}