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;