Skip to main content

ifc_lite_geometry/router/
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//! Diagnostics / telemetry layer: opening-classification + host-opening + CSG
6//! failure accumulators drained by the wasm bindings and tests.
7
8use super::GeometryRouter;
9use crate::BoolFailure;
10use rustc_hash::FxHashMap;
11
12/// Counts of opening classification outcomes during the most recent
13/// geometry pass. Useful for confirming whether the host-aware
14/// floor-opening classifier guard (commit `1e033f8`) is taking effect on
15/// a given model.
16#[derive(Debug, Default, Clone, Copy)]
17pub struct ClassificationStats {
18    /// Openings classified as `Rectangular` — fast AABB clip path.
19    pub rectangular: usize,
20    /// Openings classified as `DiagonalRectangular` — rotated AABB.
21    pub diagonal: usize,
22    /// Openings classified as `NonRectangular` — full CSG path
23    /// (no operand cap on the exact kernel).
24    pub non_rectangular: usize,
25}
26
27/// Per-host opening diagnostic captured during void processing.
28///
29/// Populated incrementally: `classify_openings` fills in `host_type` and
30/// the per-opening classification list; `apply_void_context` adds the
31/// CSG failure tally drained from the kernel. Surfaced through
32/// [`GeometryRouter::take_host_opening_diagnostics`] for the WASM
33/// bindings to forward to JS.
34#[derive(Debug, Clone, Default)]
35pub struct HostOpeningDiagnostic {
36    /// Stringified IFC type of the host (e.g. `"IfcWallStandardCase"`).
37    pub host_type: String,
38    /// Per-opening classification record.
39    pub openings: Vec<OpeningDiagnostic>,
40    /// Number of `BoolFailure` records the kernel emitted while
41    /// processing this host's voids.
42    pub csg_failure_count: usize,
43    /// First `BoolFailure` reason recorded for this host, as a short
44    /// string label. Useful for grouping at a glance.
45    pub first_failure_label: Option<String>,
46    /// Triangle count of the host's mesh BEFORE void subtraction.
47    /// `None` until `apply_void_context` runs (or doesn't, if there
48    /// were no openings to apply).
49    pub tris_before: Option<usize>,
50    /// Triangle count AFTER void subtraction. Compare with
51    /// `tris_before` to spot "cuts attempted, no effect" cases — the
52    /// classic silent-no-op signature when an opening box doesn't
53    /// actually intersect the host mesh.
54    pub tris_after: Option<usize>,
55    /// Number of axis-aligned rectangular openings synthesised into penetrating
56    /// box cutters and subtracted (exactly) for this host. Compare against
57    /// `tris_before == tris_after` to detect the "ran cuts, geometry unchanged"
58    /// silent-no-op.
59    pub rect_boxes_processed: usize,
60    /// Bounding box of the host mesh (min, max) in world coords. Useful
61    /// for confirming that an opening box should overlap.
62    pub host_bounds: Option<((f32, f32, f32), (f32, f32, f32))>,
63}
64
65/// One opening's worth of diagnostic data — what `classify_openings`
66/// observed about it.
67#[derive(Debug, Clone)]
68pub struct OpeningDiagnostic {
69    /// Express ID of the `IfcOpeningElement` itself.
70    pub opening_id: u32,
71    /// Branch the classifier took for this opening.
72    pub kind: OpeningKindDiag,
73    /// Vertex count of the opening's mesh — high counts (>100) force the
74    /// non-rectangular path regardless of extrusion direction.
75    pub vertex_count: usize,
76}
77
78/// Discriminator for [`OpeningDiagnostic::kind`]. Mirrors `OpeningType`
79/// without dragging the geometry data along.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum OpeningKindDiag {
82    Rectangular,
83    Diagonal,
84    NonRectangular,
85}
86
87impl OpeningKindDiag {
88    pub fn as_str(self) -> &'static str {
89        match self {
90            OpeningKindDiag::Rectangular => "Rectangular",
91            OpeningKindDiag::Diagonal => "Diagonal",
92            OpeningKindDiag::NonRectangular => "NonRectangular",
93        }
94    }
95}
96
97impl GeometryRouter {
98    /// Drain the boolean / CSG failures accumulated by the void-subtraction
99    /// path since the router was created (or the last `take_csg_failures`
100    /// call). Failures are keyed by IFC product express ID — the element
101    /// whose opening / clip operation tripped a fallback.
102    ///
103    /// Only the router-driven CSG path (multi-layer wall sub-meshes,
104    /// single-mesh `apply_voids_to_mesh`) is currently attributed. Standalone
105    /// `IfcBooleanResult` chains processed via the mapped-item path don't
106    /// yet flow their failures here.
107    pub fn take_csg_failures(&self) -> FxHashMap<u32, Vec<BoolFailure>> {
108        // Fold in any failures from a context without a direct router handle
109        // (see `PENDING_MAPPED_BOOL_FAILURES`). They have no product
110        // attribution, so we bucket them under product id 0 — keeps the
111        // diagnostics surface visible without inventing a fake host id.
112        let pending = crate::diagnostics::take_pending_mapped_bool_failures();
113        if !pending.is_empty() {
114            self.csg_failures
115                .borrow_mut()
116                .entry(0)
117                .or_default()
118                .extend(pending);
119        }
120        std::mem::take(&mut *self.csg_failures.borrow_mut())
121    }
122
123    /// Record why a layered-wall slice attempt did/didn't produce per-layer
124    /// sub-meshes (#563 diagnostic). Bounded — only sliceable elements reach it.
125    pub(crate) fn push_layer_slice_diag(&self, element_id: u32, reason: &'static str) {
126        self.layer_slice_diag.borrow_mut().push((element_id, reason));
127    }
128
129    /// Drain the per-element layered-slice diagnostics gathered since the last
130    /// call (wasm logs them to the browser console after each batch).
131    pub fn take_layer_slice_diag(&self) -> Vec<(u32, &'static str)> {
132        std::mem::take(&mut *self.layer_slice_diag.borrow_mut())
133    }
134
135    /// Number of products with at least one recorded CSG failure.
136    pub fn csg_failure_product_count(&self) -> usize {
137        self.csg_failures.borrow().len()
138    }
139
140    /// Total number of CSG failures across all products.
141    pub fn csg_failure_total(&self) -> usize {
142        self.csg_failures.borrow().values().map(|v| v.len()).sum()
143    }
144
145    /// Internal: mark a host product as fully consumed by a containing void, so
146    /// the element pipeline does NOT fall back to the un-cut host when the void
147    /// subtraction yields an empty mesh. See [`Self::host_consumed_by_void`].
148    pub(crate) fn record_void_consumed_host(&self, product_id: u32) {
149        self.voids_consumed_hosts.borrow_mut().insert(product_id);
150    }
151
152    /// Whether a host product was fully consumed by a containing void (its
153    /// opening's real solid engulfs the host). An empty void-cut result for
154    /// such a host is CORRECT — the element should render nothing — and must
155    /// not trigger the un-cut fallback.
156    pub fn host_consumed_by_void(&self, product_id: u32) -> bool {
157        self.voids_consumed_hosts.borrow().contains(&product_id)
158    }
159
160    /// Internal: record a batch of failures against a product. Existing
161    /// entries for the same product are appended to.
162    pub(crate) fn record_csg_failures(&self, product_id: u32, failures: Vec<BoolFailure>) {
163        if failures.is_empty() {
164            return;
165        }
166        let attributed: Vec<BoolFailure> = failures
167            .into_iter()
168            .map(|f| f.with_product_id(product_id))
169            .collect();
170        self.csg_failures
171            .borrow_mut()
172            .entry(product_id)
173            .or_default()
174            .extend(attributed);
175    }
176
177    /// Drain and return the cumulative opening-classification counters
178    /// since the router was created (or the last `take_classification_stats`
179    /// call). The internal counters are reset to zero.
180    pub fn take_classification_stats(&self) -> ClassificationStats {
181        std::mem::take(&mut *self.classification_stats.borrow_mut())
182    }
183
184    /// Drain and return the per-host opening diagnostic map.
185    pub fn take_host_opening_diagnostics(&self) -> FxHashMap<u32, HostOpeningDiagnostic> {
186        std::mem::take(&mut *self.host_opening_diagnostics.borrow_mut())
187    }
188
189    /// Accumulate one rect_fast cut's counters into THIS router (request-local).
190    /// Called from the void-cut fast paths instead of a process-global sink, so
191    /// concurrent native geometry passes never steal each other's counters.
192    pub(crate) fn record_rect_fast(&self, s: &crate::rect_fast::RectFastStats) {
193        let mut acc = self.rect_fast_stats.borrow_mut();
194        acc.fired += s.fired;
195        acc.openings_cut += s.openings_cut;
196        acc.defer_host_not_box += s.defer_host_not_box;
197        acc.defer_not_through += s.defer_not_through;
198        acc.defer_off_face += s.defer_off_face;
199        acc.defer_near_edge += s.defer_near_edge;
200        acc.defer_no_openings += s.defer_no_openings;
201        acc.defer_too_many_openings += s.defer_too_many_openings;
202    }
203
204    /// Drain and return this router's rect_fast counters (resets them to zero).
205    pub fn take_rect_fast_stats(&self) -> crate::rect_fast::RectFastStats {
206        std::mem::take(&mut *self.rect_fast_stats.borrow_mut())
207    }
208
209    /// Total number of hosts with diagnostic records (mostly for tests).
210    pub fn host_opening_diagnostic_count(&self) -> usize {
211        self.host_opening_diagnostics.borrow().len()
212    }
213
214    /// Internal: bump the classification stats. Called from
215    /// `classify_openings` for each opening it processes.
216    pub(crate) fn bump_classification(&self, kind: ClassificationKind) {
217        let mut s = self.classification_stats.borrow_mut();
218        match kind {
219            ClassificationKind::Rectangular => s.rectangular += 1,
220            ClassificationKind::Diagonal => s.diagonal += 1,
221            ClassificationKind::NonRectangular => s.non_rectangular += 1,
222        }
223    }
224
225    /// Internal: record / merge per-host opening diagnostic. Called from
226    /// `classify_openings` once per host with the host type + the list of
227    /// openings it observed. `apply_void_context` later adds the CSG
228    /// failure tally for the same host.
229    pub(crate) fn record_host_opening_diagnostic(
230        &self,
231        host_id: u32,
232        host_type: &str,
233        openings: Vec<OpeningDiagnostic>,
234    ) {
235        let mut log = self.host_opening_diagnostics.borrow_mut();
236        let entry = log.entry(host_id).or_default();
237        if entry.host_type.is_empty() {
238            entry.host_type = host_type.to_string();
239        }
240        entry.openings.extend(openings);
241    }
242
243    /// Internal: tag the per-host diagnostic with the cut-effect data
244    /// (triangle counts before/after, rectangular boxes processed, host
245    /// bounds). Lets callers spot the "rectangular cut attempted but
246    /// produced no change" case — the silent-no-op signature when an
247    /// opening box's geometry doesn't actually intersect the host mesh
248    /// despite passing the AABB classifier.
249    pub(crate) fn record_host_cut_effect(
250        &self,
251        host_id: u32,
252        tris_before: usize,
253        tris_after: usize,
254        rect_boxes_processed: usize,
255        host_bounds: ((f32, f32, f32), (f32, f32, f32)),
256    ) {
257        let mut log = self.host_opening_diagnostics.borrow_mut();
258        let entry = log.entry(host_id).or_default();
259        entry.tris_before = Some(tris_before);
260        entry.tris_after = Some(tris_after);
261        entry.rect_boxes_processed = rect_boxes_processed;
262        entry.host_bounds = Some(host_bounds);
263    }
264
265    /// Internal: tag the per-host diagnostic with the failure summary for
266    /// this host. Drained from `ClippingProcessor::take_failures` after
267    /// `apply_void_context` finishes.
268    pub(crate) fn record_host_failure_summary(&self, host_id: u32, failures: &[BoolFailure]) {
269        if failures.is_empty() {
270            return;
271        }
272        let mut log = self.host_opening_diagnostics.borrow_mut();
273        let entry = log.entry(host_id).or_default();
274        entry.csg_failure_count += failures.len();
275        if entry.first_failure_label.is_none() {
276            // Short label for at-a-glance grouping. Full BoolFailure list
277            // remains in `csg_failures` for callers that want detail.
278            let label = match &failures[0].reason {
279                crate::diagnostics::BoolFailureReason::OperandTooLarge { .. } => "OperandTooLarge",
280                crate::diagnostics::BoolFailureReason::EmptyOperand => "EmptyOperand",
281                crate::diagnostics::BoolFailureReason::DegenerateOperand => "DegenerateOperand",
282                crate::diagnostics::BoolFailureReason::NoBoundsOverlap => "NoBoundsOverlap",
283                crate::diagnostics::BoolFailureReason::KernelOutputInvalid => "KernelOutputInvalid",
284                crate::diagnostics::BoolFailureReason::SolidSolidDifferenceSkipped => {
285                    "SolidSolidDifferenceSkipped"
286                }
287                crate::diagnostics::BoolFailureReason::PolygonalBoundedHalfSpaceFallback => {
288                    "PolygonalBoundedHalfSpaceFallback"
289                }
290                crate::diagnostics::BoolFailureReason::CutterUnionUnavailable => {
291                    "CutterUnionUnavailable"
292                }
293                crate::diagnostics::BoolFailureReason::UnknownBooleanOperator(_) => {
294                    "UnknownBooleanOperator"
295                }
296                crate::diagnostics::BoolFailureReason::ManifoldOutputDegenerate { .. } => {
297                    "ManifoldOutputDegenerate"
298                }
299                crate::diagnostics::BoolFailureReason::KernelError(_) => "KernelError",
300                crate::diagnostics::BoolFailureReason::DifferenceEmptiedHost => {
301                    "DifferenceEmptiedHost"
302                }
303            };
304            entry.first_failure_label = Some(label.to_string());
305        }
306    }
307}
308
309/// Internal classification-branch tag for `bump_classification`. Mirrors
310/// the variants of `OpeningType`.
311#[derive(Debug, Clone, Copy)]
312pub(crate) enum ClassificationKind {
313    Rectangular,
314    Diagonal,
315    NonRectangular,
316}
317
318// ───────────────────────── Public diagnostics contract ─────────────────────
319// A serializable, wasm-free aggregate of the CSG / opening diagnostics computed
320// during a geometry pass. Built by `aggregate_diagnostics` from drained router
321// data. Today it is wired on the wasm/viewer path (the @ifc-lite/geometry
322// `complete` event); native / server `ProcessingStats` parity reuses this same
323// wasm-free aggregator and is a follow-up. camelCase JSON for the TS contract.
324
325/// Opening-classifier outcome counts (rectangular / diagonal / non-rectangular).
326#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct ClassificationSummary {
329    pub rectangular: u64,
330    pub diagonal: u64,
331    pub non_rectangular: u64,
332    pub total: u64,
333}
334
335/// One CSG failure reason and its occurrence count this pass. `reason` is one of
336/// the stable [`crate::diagnostics::BoolFailureReason::label`] strings.
337#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct ReasonCount {
340    pub reason: String,
341    pub count: u64,
342}
343
344/// rect_fast fast-path engagement counters (perf observability).
345#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct RectFastSummary {
348    pub fired: u64,
349    pub openings_cut: u64,
350    pub defer_host_not_box: u64,
351    pub defer_not_through: u64,
352    pub defer_off_face: u64,
353    pub defer_near_edge: u64,
354    pub defer_no_openings: u64,
355    pub defer_too_many_openings: u64,
356}
357
358/// Axis-aligned bounding box of a worst-failing host's mesh, world coords
359/// (post void-subtraction when a cut ran). Mirrors the `{min, max}` shape the
360/// rest of the geometry contract already uses for AABBs (see
361/// `packages/geometry/src/types.ts` `MeshData.localBounds`), so TS consumers
362/// don't need a second bbox convention.
363#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct HostBbox {
366    pub min: [f32; 3],
367    pub max: [f32; 3],
368}
369
370/// One of the worst-failing host elements (bounded top-N, opt-in detail).
371#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct WorstHost {
374    pub product_id: u32,
375    pub ifc_type: String,
376    pub openings: u64,
377    pub csg_failures: u64,
378    pub first_failure_label: Option<String>,
379    /// World-space AABB of the host mesh, when captured by
380    /// `record_host_cut_effect` (opt-in per-product detail, #C1). `None` when
381    /// no void cut touched this host (e.g. the failure came from a
382    /// non-router CSG path).
383    pub bbox: Option<HostBbox>,
384    /// Final triangle count of the host's mesh: post-cut (`tris_after`) when a
385    /// void subtraction ran, falling back to the pre-cut count
386    /// (`tris_before`) when it didn't (the un-cut host is what actually
387    /// renders in that case). `None` when neither was captured.
388    pub triangle_count: Option<u64>,
389}
390
391/// Compatibility handshake for the [`GeometryDiagnostics`] contract, serialized
392/// as `schemaVersion`. DISTINCT from the viewer cache `FORMAT_VERSION` (an
393/// invalidation token): this is a promise consumers can gate on.
394///
395/// Bump discipline: bump on any field rename, field removal, or
396/// count-semantics change; additive optional fields do NOT bump.
397///
398/// Changelog:
399/// - 1: initial versioned contract (the #1439 shape; a deserialized 0 means a
400///   pre-versioned producer).
401/// - 2: removed the permanently-dead `guard_saved` signal — every producer
402///   passed `false`, so `OpeningDiagnostic.guard_saved` and the
403///   `floor_opening_guard_saved` counter were always 0. Field removal, not
404///   just a rename, hence the bump.
405pub const GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION: u32 = 2;
406
407/// Aggregate CSG / opening diagnostics for one geometry pass — the public
408/// diagnostics contract. Built by [`aggregate_diagnostics`] from drained router
409/// data and serialized to the @ifc-lite/geometry `complete` event, and reused
410/// verbatim by the native `ProcessingStats` path
411/// (`rust/processing/src/processor/mod.rs` populates `geometry_diagnostics`).
412/// wasm-free (serde only).
413#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
414#[serde(rename_all = "camelCase")]
415pub struct GeometryDiagnostics {
416    /// Contract version ([`GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION`]); serialized
417    /// unconditionally so consumers can gate on it. Deserializes to 0 when the
418    /// producer predates versioning.
419    #[serde(default)]
420    pub schema_version: u32,
421    /// Total CSG boolean failures (un-cut openings, emptied hosts, fallbacks).
422    pub total_csg_failures: u64,
423    /// Distinct products (host elements) with at least one failure.
424    pub products_with_failures: u64,
425    /// Hosts that had openings processed.
426    pub hosts_with_openings: u64,
427    /// Opening-classifier outcome counts.
428    pub classification: ClassificationSummary,
429    /// Failure counts by stable reason label, sorted desc by count.
430    pub failures_by_reason: Vec<ReasonCount>,
431    /// Hosts where rectangular cutters ran but the triangle count was unchanged
432    /// (cut attempted, geometry not modified) — the highest-signal "looks wrong
433    /// but did not error" indicator.
434    pub silent_no_ops: u64,
435    /// rect_fast fast-path engagement.
436    pub rect_fast: RectFastSummary,
437    /// Bounded top-N worst-failing hosts (opt-in per-product detail).
438    pub worst_hosts: Vec<WorstHost>,
439}
440
441impl Default for GeometryDiagnostics {
442    fn default() -> Self {
443        Self {
444            schema_version: GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION,
445            total_csg_failures: 0,
446            products_with_failures: 0,
447            hosts_with_openings: 0,
448            classification: ClassificationSummary::default(),
449            failures_by_reason: Vec::new(),
450            silent_no_ops: 0,
451            rect_fast: RectFastSummary::default(),
452            worst_hosts: Vec::new(),
453        }
454    }
455}
456
457impl GeometryDiagnostics {
458    /// Whether any CSG failure or silent no-op was recorded — a cheap gate for
459    /// "should this be surfaced to the user".
460    pub fn has_issues(&self) -> bool {
461        self.total_csg_failures > 0 || self.silent_no_ops > 0
462    }
463
464    /// Whether nothing diagnostic-worthy happened this pass — no openings
465    /// classified, no failures, no silent no-ops, no rect_fast activity. Callers
466    /// skip attaching an all-zero object so a consumer can gate on presence
467    /// (`if event.diagnostics`) as well as on counts.
468    pub fn is_empty(&self) -> bool {
469        self.total_csg_failures == 0
470            && self.hosts_with_openings == 0
471            && self.classification.total == 0
472            && self.silent_no_ops == 0
473            && self.rect_fast.fired == 0
474    }
475}
476
477/// Build a [`GeometryDiagnostics`] from drained router data. wasm-free so both
478/// the wasm/viewer path and a future native path can produce the same contract.
479/// The caller owns draining: the router accessors are destructive (`mem::take`),
480/// so drain once and pass the results here — do not double-`take`.
481pub fn aggregate_diagnostics(
482    classification: ClassificationStats,
483    csg_failures: &FxHashMap<u32, Vec<BoolFailure>>,
484    host_diags: &FxHashMap<u32, HostOpeningDiagnostic>,
485    rect_fast: crate::rect_fast::RectFastStats,
486    worst_hosts_limit: usize,
487) -> GeometryDiagnostics {
488    let total_csg_failures = csg_failures.values().map(Vec::len).sum::<usize>() as u64;
489    let products_with_failures = csg_failures.len() as u64;
490    let hosts_with_openings = host_diags.len() as u64;
491
492    let classification = ClassificationSummary {
493        rectangular: classification.rectangular as u64,
494        diagonal: classification.diagonal as u64,
495        non_rectangular: classification.non_rectangular as u64,
496        total: (classification.rectangular
497            + classification.diagonal
498            + classification.non_rectangular) as u64,
499    };
500
501    let mut by_reason: FxHashMap<&'static str, u64> = FxHashMap::default();
502    for fails in csg_failures.values() {
503        for f in fails {
504            *by_reason.entry(f.reason.label()).or_insert(0) += 1;
505        }
506    }
507    let mut failures_by_reason: Vec<ReasonCount> = by_reason
508        .into_iter()
509        .map(|(reason, count)| ReasonCount { reason: reason.to_string(), count })
510        .collect();
511    failures_by_reason
512        .sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.reason.cmp(&b.reason)));
513
514    let silent_no_ops = host_diags
515        .values()
516        .filter(|hd| {
517            // A TRUE silent no-op: rect cutters ran, the triangle count was
518            // unchanged, AND the kernel recorded no failure. A host that already
519            // failed is a loud failure, not a silent one — excluding it keeps this
520            // the precise "ran clean but produced no change" signal (so it is not
521            // double-reported alongside total_csg_failures).
522            matches!((hd.tris_before, hd.tris_after), (Some(b), Some(a)) if b == a)
523                && hd.rect_boxes_processed > 0
524                && hd.csg_failure_count == 0
525        })
526        .count() as u64;
527
528    let mut worst: Vec<(&u32, &HostOpeningDiagnostic)> = host_diags
529        .iter()
530        .filter(|(_, hd)| hd.csg_failure_count > 0)
531        .collect();
532    worst.sort_by(|a, b| {
533        b.1.csg_failure_count.cmp(&a.1.csg_failure_count).then_with(|| a.0.cmp(b.0))
534    });
535    let worst_hosts: Vec<WorstHost> = worst
536        .into_iter()
537        .take(worst_hosts_limit)
538        .map(|(pid, hd)| WorstHost {
539            product_id: *pid,
540            ifc_type: hd.host_type.clone(),
541            openings: hd.openings.len() as u64,
542            csg_failures: hd.csg_failure_count as u64,
543            first_failure_label: hd.first_failure_label.clone(),
544            bbox: hd.host_bounds.map(|(min, max)| HostBbox {
545                min: [min.0, min.1, min.2],
546                max: [max.0, max.1, max.2],
547            }),
548            triangle_count: hd.tris_after.or(hd.tris_before).map(|t| t as u64),
549        })
550        .collect();
551
552    GeometryDiagnostics {
553        schema_version: GEOMETRY_DIAGNOSTICS_SCHEMA_VERSION,
554        total_csg_failures,
555        products_with_failures,
556        hosts_with_openings,
557        classification,
558        failures_by_reason,
559        silent_no_ops,
560        rect_fast: RectFastSummary {
561            fired: rect_fast.fired,
562            openings_cut: rect_fast.openings_cut,
563            defer_host_not_box: rect_fast.defer_host_not_box,
564            defer_not_through: rect_fast.defer_not_through,
565            defer_off_face: rect_fast.defer_off_face,
566            defer_near_edge: rect_fast.defer_near_edge,
567            defer_no_openings: rect_fast.defer_no_openings,
568            defer_too_many_openings: rect_fast.defer_too_many_openings,
569        },
570        worst_hosts,
571    }
572}
573
574#[cfg(test)]
575mod diagnostics_contract_tests;