ifc_lite_geometry/router/
diagnostics_recording.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//! `GeometryRouter` diagnostics-recording API — the accumulator TYPES plus
6//! most of the accumulator methods (`take_*` / `record_*` / `push_*`) that
7//! write into and drain the per-router CSG-failure, layer-slice,
8//! void-consumption, classification, host-opening and rect_fast
9//! accumulators declared on [`super::GeometryRouter`].
10//!
11//! Split out of `diagnostics.rs` (which keeps the wasm-free
12//! [`super::diagnostics::aggregate_diagnostics`] public contract and
13//! `record_host_failure_summary`) to stay under the module-size ratchet
14//! budget.
15
16use super::diagnostics::{count_attributed_products, UNATTRIBUTED_PRODUCT_ID};
17use super::GeometryRouter;
18use crate::BoolFailure;
19use rustc_hash::FxHashMap;
20
21/// Counts of opening classification outcomes during the most recent
22/// geometry pass. Useful for confirming whether the host-aware
23/// floor-opening classifier guard (commit `1e033f8`) is taking effect on
24/// a given model.
25#[derive(Debug, Default, Clone, Copy)]
26pub struct ClassificationStats {
27    /// Openings classified as `Rectangular` — fast AABB clip path.
28    pub rectangular: usize,
29    /// Openings classified as `DiagonalRectangular` — rotated AABB.
30    pub diagonal: usize,
31    /// Openings classified as `NonRectangular` — full CSG path
32    /// (no operand cap on the exact kernel).
33    pub non_rectangular: usize,
34}
35
36/// Per-host opening diagnostic captured during void processing.
37///
38/// Populated incrementally: `classify_openings` fills in `host_type` and
39/// the per-opening classification list; `apply_void_context` adds the
40/// CSG failure tally drained from the kernel. Surfaced through
41/// [`GeometryRouter::take_host_opening_diagnostics`] for the WASM
42/// bindings to forward to JS.
43#[derive(Debug, Clone, Default)]
44pub struct HostOpeningDiagnostic {
45    /// Stringified IFC type of the host (e.g. `"IfcWallStandardCase"`).
46    pub host_type: String,
47    /// Per-opening classification record.
48    pub openings: Vec<OpeningDiagnostic>,
49    /// Number of `BoolFailure` records the kernel emitted while
50    /// processing this host's voids.
51    pub csg_failure_count: usize,
52    /// First `BoolFailure` reason recorded for this host, as a short
53    /// string label. Useful for grouping at a glance.
54    pub first_failure_label: Option<String>,
55    /// Triangle count of the host's mesh BEFORE void subtraction.
56    /// `None` until `apply_void_context` runs (or doesn't, if there
57    /// were no openings to apply).
58    pub tris_before: Option<usize>,
59    /// Triangle count AFTER void subtraction. Compare with
60    /// `tris_before` to spot "cuts attempted, no effect" cases — the
61    /// classic silent-no-op signature when an opening box doesn't
62    /// actually intersect the host mesh.
63    pub tris_after: Option<usize>,
64    /// Number of axis-aligned rectangular openings synthesised into penetrating
65    /// box cutters and subtracted (exactly) for this host. Compare against
66    /// `tris_before == tris_after` to detect the "ran cuts, geometry unchanged"
67    /// silent-no-op.
68    pub rect_boxes_processed: usize,
69    /// Bounding box of the host mesh (min, max) in world coords. Useful
70    /// for confirming that an opening box should overlap.
71    pub host_bounds: Option<((f32, f32, f32), (f32, f32, f32))>,
72}
73
74/// One opening's worth of diagnostic data — what `classify_openings`
75/// observed about it.
76///
77/// A new field here of ANY visibility is a BREAKING change: re-exported from the
78/// crate root and not `#[non_exhaustive]`, so a field breaks every downstream
79/// struct literal. `cargo-semver-checks` denies both halves and demands a major
80/// for each — `constructible_struct_adds_field` for a `pub` one, and
81/// `constructible_struct_adds_private_field` for the private-field-plus-accessor
82/// move that otherwise looks like the additive way out.
83// A Rust-only major IS expressible — raise `majorOffset` in
84// `rust-major-offset.json` (with `reason` and `refs`, both mandatory) and the
85// crates publish a major while the npm packages keep their own bump; see
86// docs/contributing/release.md, "Expressing a Rust-only major". What does NOT
87// work is assuming a changeset buys it: a changeset states an npm level, and
88// `scripts/check-rust-semver.mjs` (#3216) fails the release when the crate's
89// API moved further than the version did. That is what happened to the field
90// #4178 added here, which came back out to keep 9.4.1 a patch.
91//
92// To surface a new number without any of that, give the struct
93// `#[non_exhaustive]` plus a builder as part of a deliberate major, the shape
94// `ModelOptions` uses and `gltf.rs` reuses. Do NOT move the field to
95// `HostOpeningDiagnostic`: it is a plain re-exported struct with no accessors
96// and carries the identical hazard.
97#[derive(Debug, Clone)]
98pub struct OpeningDiagnostic {
99    /// Express ID of the `IfcOpeningElement` itself.
100    pub opening_id: u32,
101    /// Branch the classifier took for this opening.
102    pub kind: OpeningKindDiag,
103    /// Vertex count of the opening's mesh, for diagnostics only. NOT what the
104    /// classifier gates on — it gates on triangle count, which is invariant to
105    /// the vertex duplication and welding this number moves with (#4119). See
106    /// the gate in `router/voids/synthesis.rs`, which owns that reasoning.
107    pub vertex_count: usize,
108}
109
110/// Compile-time guard on the field list above. Adding a field of ANY visibility
111/// stops this destructuring pattern compiling, which is the cheapest possible
112/// place to catch it: `cargo-semver-checks` denies both
113/// `constructible_struct_adds_field` and `constructible_struct_adds_private_field`
114/// with `required_update: Major`, but `scripts/check-rust-semver.mjs` skips any
115/// crate already published at the workspace version, so on an ordinary PR it
116/// compares nothing and the break stays invisible until a release is in flight
117/// (#4192). That is exactly how the `triangle_count` field #4178 added here
118/// reached `chore: version packages` before anyone saw it.
119///
120/// It lives in THIS file, beside the struct, rather than in a test file. The
121/// revert-oracle reverts production files wholesale, so a guard in a test file
122/// would fail to COMPILE under revert and score INCONCLUSIVE instead of RED.
123/// Here, guard and struct revert together and the size assertion in
124/// `diagnostics_contract_tests.rs` is what goes red.
125///
126/// IF THIS STOPS COMPILING, DO NOT ADD THE FIELD TO THE PATTERN. Either drop the
127/// field, or raise `majorOffset` in `rust-major-offset.json` with its mandatory
128/// `reason` and `refs` (docs/contributing/release.md, "Expressing a Rust-only
129/// major") and update this pattern in the same commit.
130const _: () = {
131    #[allow(dead_code)]
132    fn field_list_is_pinned(d: OpeningDiagnostic) {
133        let OpeningDiagnostic {
134            opening_id: _,
135            kind,
136            vertex_count: _,
137        } = d;
138        // `kind: _` would bind a new VARIANT silently. `OpeningKindDiag` is also
139        // re-exported and not `#[non_exhaustive]`, so a new variant is
140        // `enum_variant_added` — a major, and the same class already recorded in
141        // `rust-major-offset.json` as "Rust-only break #3" for `BoolFailureReason`.
142        match kind {
143            OpeningKindDiag::Rectangular
144            | OpeningKindDiag::Diagonal
145            | OpeningKindDiag::NonRectangular => {}
146        }
147    }
148};
149
150/// Same pinning as [`OpeningDiagnostic`], for the same reason. This struct is
151/// `pub`, re-exported from the crate root and not `#[non_exhaustive]`, so a new
152/// field of any visibility is a MAJOR break — and unlike `OpeningDiagnostic` it
153/// derives `Default`, so every in-crate literal uses `..Default::default()` and
154/// would NOT break. Nothing else would catch the addition until a release PR
155/// (#4192). See the note on `OpeningDiagnostic` for what to do instead.
156const _: () = {
157    #[allow(dead_code)]
158    fn host_opening_diagnostic_field_list_is_pinned(d: HostOpeningDiagnostic) {
159        let HostOpeningDiagnostic {
160            host_type: _,
161            openings: _,
162            csg_failure_count: _,
163            first_failure_label: _,
164            tris_before: _,
165            tris_after: _,
166            rect_boxes_processed: _,
167            host_bounds: _,
168        } = d;
169    }
170};
171
172/// Same pinning as [`OpeningDiagnostic`]. `pub`, re-exported, not
173/// `#[non_exhaustive]`, and `Copy + Default`, so in-crate literals would survive
174/// a new field silently.
175const _: () = {
176    #[allow(dead_code)]
177    fn classification_stats_field_list_is_pinned(d: ClassificationStats) {
178        let ClassificationStats {
179            rectangular: _,
180            diagonal: _,
181            non_rectangular: _,
182        } = d;
183    }
184};
185
186/// Discriminator for [`OpeningDiagnostic::kind`]. Mirrors `OpeningType`
187/// without dragging the geometry data along.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum OpeningKindDiag {
190    Rectangular,
191    Diagonal,
192    NonRectangular,
193}
194
195impl OpeningKindDiag {
196    pub fn as_str(self) -> &'static str {
197        match self {
198            OpeningKindDiag::Rectangular => "Rectangular",
199            OpeningKindDiag::Diagonal => "Diagonal",
200            OpeningKindDiag::NonRectangular => "NonRectangular",
201        }
202    }
203}
204
205impl GeometryRouter {
206    /// Drain every boolean / CSG failure this router knows about since it was
207    /// created (or the last `take_csg_failures` call). The single drain point:
208    /// the native pipeline and the wasm batch path both call this and nothing
209    /// else.
210    ///
211    /// Two kinds of record come out, distinguished by their key:
212    ///
213    ///  * Keyed by IFC product express id — the void-subtraction path
214    ///    (multi-layer wall sub-meshes, single-mesh `process_element_with_voids_parts`),
215    ///    which knows the host element whose opening / clip tripped a fallback.
216    ///  * Keyed by [`UNATTRIBUTED_PRODUCT_ID`] — the registered processors' own
217    ///    logs (swept by [`Self::drain_processor_failures`], #3821, including
218    ///    what the transient boolean under an `IfcCsgSolid` hands back to
219    ///    `CsgSolidProcessor`) and the producer-less hatch. Standalone
220    ///    `IfcBooleanResult` chains DO flow here now; what they still lack is
221    ///    the owning product id. See `drain_processor_failures` for the cost.
222    pub fn take_csg_failures(&self) -> FxHashMap<u32, Vec<BoolFailure>> {
223        // Sweep the processors' own logs, then fold in any failures from a
224        // context with no router handle at all (`PENDING_MAPPED_BOOL_FAILURES`,
225        // which has no producer today). Neither carries a product attribution,
226        // so both land in the unattributed bucket, not a fake host id.
227        self.drain_processor_failures();
228        let pending = crate::diagnostics::take_pending_mapped_bool_failures();
229        if !pending.is_empty() {
230            self.csg_failures
231                .borrow_mut()
232                .entry(UNATTRIBUTED_PRODUCT_ID)
233                .or_default()
234                .extend(pending);
235        }
236        std::mem::take(&mut *self.csg_failures.borrow_mut())
237    }
238
239    /// Record why a layered-wall slice attempt did/didn't produce per-layer
240    /// sub-meshes (#563 diagnostic). Bounded — only sliceable elements reach it.
241    pub(crate) fn push_layer_slice_diag(&self, element_id: u32, reason: &'static str) {
242        self.layer_slice_diag.borrow_mut().push((element_id, reason));
243    }
244
245    /// Drain the per-element layered-slice diagnostics gathered since the last
246    /// call (wasm logs them to the browser console after each batch).
247    pub fn take_layer_slice_diag(&self) -> Vec<(u32, &'static str)> {
248        std::mem::take(&mut *self.layer_slice_diag.borrow_mut())
249    }
250
251    /// Products with at least one recorded CSG failure, under the same
252    /// [`count_attributed_products`] rule the pipelines' scalar uses (a bare
253    /// `.len()` would make this the one surface counting the synthetic
254    /// bucket). PEEKS: no processor sweep, unlike `take_csg_failures`.
255    pub fn csg_failure_product_count(&self) -> usize {
256        count_attributed_products(&self.csg_failures.borrow()) as usize
257    }
258
259    /// Total CSG failures, INCLUDING the unattributed bucket (real failures,
260    /// unknown owner). Peeks, like [`Self::csg_failure_product_count`].
261    pub fn csg_failure_total(&self) -> usize {
262        self.csg_failures.borrow().values().map(|v| v.len()).sum()
263    }
264
265    /// Internal: mark a host product as fully consumed by a containing void, so
266    /// the element pipeline does NOT fall back to the un-cut host when the void
267    /// subtraction yields an empty mesh. See [`Self::host_consumed_by_void`].
268    pub(crate) fn record_void_consumed_host(&self, product_id: u32) {
269        self.voids_consumed_hosts.borrow_mut().insert(product_id);
270    }
271
272    /// Whether a host product was fully consumed by a containing void (its
273    /// opening's real solid engulfs the host). An empty void-cut result for
274    /// such a host is CORRECT — the element should render nothing — and must
275    /// not trigger the un-cut fallback.
276    pub fn host_consumed_by_void(&self, product_id: u32) -> bool {
277        self.voids_consumed_hosts.borrow().contains(&product_id)
278    }
279
280    /// Internal: record a batch of failures against a product. Existing
281    /// entries for the same product are appended to.
282    pub(crate) fn record_csg_failures(&self, product_id: u32, failures: Vec<BoolFailure>) {
283        if failures.is_empty() {
284            return;
285        }
286        let attributed: Vec<BoolFailure> = failures
287            .into_iter()
288            .map(|f| f.with_product_id(product_id))
289            .collect();
290        self.csg_failures
291            .borrow_mut()
292            .entry(product_id)
293            .or_default()
294            .extend(attributed);
295    }
296
297    /// Drain and return the cumulative opening-classification counters
298    /// since the router was created (or the last `take_classification_stats`
299    /// call). The internal counters are reset to zero.
300    pub fn take_classification_stats(&self) -> ClassificationStats {
301        std::mem::take(&mut *self.classification_stats.borrow_mut())
302    }
303
304    /// Drain and return the per-host opening diagnostic map.
305    pub fn take_host_opening_diagnostics(&self) -> FxHashMap<u32, HostOpeningDiagnostic> {
306        std::mem::take(&mut *self.host_opening_diagnostics.borrow_mut())
307    }
308
309    /// Accumulate one rect_fast cut's counters into THIS router (request-local).
310    /// Called from the void-cut fast paths instead of a process-global sink, so
311    /// concurrent native geometry passes never steal each other's counters.
312    pub(crate) fn record_rect_fast(&self, s: &crate::rect_fast::RectFastStats) {
313        let mut acc = self.rect_fast_stats.borrow_mut();
314        acc.fired += s.fired;
315        acc.openings_cut += s.openings_cut;
316        acc.defer_host_not_box += s.defer_host_not_box;
317        acc.defer_not_through += s.defer_not_through;
318        acc.defer_off_face += s.defer_off_face;
319        acc.defer_near_edge += s.defer_near_edge;
320        acc.defer_no_openings += s.defer_no_openings;
321        acc.defer_too_many_openings += s.defer_too_many_openings;
322    }
323
324    /// Drain and return this router's rect_fast counters (resets them to zero).
325    pub fn take_rect_fast_stats(&self) -> crate::rect_fast::RectFastStats {
326        std::mem::take(&mut *self.rect_fast_stats.borrow_mut())
327    }
328
329    /// Total number of hosts with diagnostic records (mostly for tests).
330    pub fn host_opening_diagnostic_count(&self) -> usize {
331        self.host_opening_diagnostics.borrow().len()
332    }
333
334    /// Internal: bump the classification stats. Called from
335    /// `classify_openings` for each opening it processes.
336    pub(crate) fn bump_classification(&self, kind: ClassificationKind) {
337        let mut s = self.classification_stats.borrow_mut();
338        match kind {
339            ClassificationKind::Rectangular => s.rectangular += 1,
340            ClassificationKind::Diagonal => s.diagonal += 1,
341            ClassificationKind::NonRectangular => s.non_rectangular += 1,
342        }
343    }
344
345    /// Internal: record / merge per-host opening diagnostic. Called from
346    /// `classify_openings` once per host with the host type + the list of
347    /// openings it observed. `apply_void_context` later adds the CSG
348    /// failure tally for the same host.
349    pub(crate) fn record_host_opening_diagnostic(
350        &self,
351        host_id: u32,
352        host_type: &str,
353        openings: Vec<OpeningDiagnostic>,
354    ) {
355        let mut log = self.host_opening_diagnostics.borrow_mut();
356        let entry = log.entry(host_id).or_default();
357        if entry.host_type.is_empty() {
358            entry.host_type = host_type.to_string();
359        }
360        entry.openings.extend(openings);
361    }
362
363    /// Internal: tag the per-host diagnostic with the cut-effect data
364    /// (triangle counts before/after, rectangular boxes processed, host
365    /// bounds). Lets callers spot the "rectangular cut attempted but
366    /// produced no change" case — the silent-no-op signature when an
367    /// opening box's geometry doesn't actually intersect the host mesh
368    /// despite passing the AABB classifier.
369    pub(crate) fn record_host_cut_effect(
370        &self,
371        host_id: u32,
372        tris_before: usize,
373        tris_after: usize,
374        rect_boxes_processed: usize,
375        host_bounds: ((f32, f32, f32), (f32, f32, f32)),
376    ) {
377        let mut log = self.host_opening_diagnostics.borrow_mut();
378        let entry = log.entry(host_id).or_default();
379        entry.tris_before = Some(tris_before);
380        entry.tris_after = Some(tris_after);
381        entry.rect_boxes_processed = rect_boxes_processed;
382        entry.host_bounds = Some(host_bounds);
383    }
384
385}
386
387/// Internal classification-branch tag for `bump_classification`. Mirrors
388/// the variants of `OpeningType`.
389#[derive(Debug, Clone, Copy)]
390pub(crate) enum ClassificationKind {
391    Rectangular,
392    Diagonal,
393    NonRectangular,
394}