ifc_lite_geometry/router/diagnostics/
unsupported_items.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//! Dropped-representation-item counter (split out of `diagnostics.rs`, #C1
6//! module-size ratchet). See `GeometryRouter::record_unsupported_item` /
7//! `take_unsupported_items` for the write/drain side and `GeometryDiagnostics`
8//! for the wire shape this feeds.
9
10use super::{GeometryRouter, ReasonCount};
11use ifc_lite_core::DecodedEntity;
12use rustc_hash::{FxHashMap, FxHashSet};
13
14/// All dropped-representation-item state, behind one `RefCell` on the router:
15/// the counts themselves, the sources already counted, and the open
16/// [`GeometryRouter::enter_unsupported_source`] scopes. One struct rather than
17/// three fields because `router/mod.rs` sits exactly at its module-size budget,
18/// and because the three are meaningless apart.
19#[derive(Default)]
20pub(crate) struct UnsupportedItemState {
21    /// Dropped items (no processor / errored), by `IfcType`. Drained per
22    /// element or batch by `take_unsupported_items`.
23    counts: FxHashMap<String, u64>,
24    /// `IfcRepresentationMap` source ids already counted, so a source is
25    /// counted ONCE however many `IfcMappedItem` occurrences walk it.
26    sources_recorded: FxHashSet<u32>,
27    /// One "record drops beneath this source?" decision per open scope.
28    scope: Vec<bool>,
29}
30
31impl UnsupportedItemState {
32    /// Forget which sources have been counted, so a re-walk counts them again.
33    pub(crate) fn forget_sources(&mut self) {
34        self.sources_recorded.clear();
35    }
36}
37
38impl GeometryRouter {
39    /// Enter a `RepresentationMap` source's item walk, returning a scope guard.
40    ///
41    /// Two things are decided once, here, instead of at each write site beneath:
42    ///
43    /// * **Per SOURCE, not per occurrence — WITHIN THIS ROUTER.** A drop inside
44    ///   a source is counted once however many `IfcMappedItem` occurrences this
45    ///   router walks, not once each. The mapped-item cache already delivers
46    ///   that for a source that produced geometry; it cannot for a source whose
47    ///   items ALL drop, because that source meshes to empty and the two SHARED
48    ///   cache inserts (`mapped_item.rs`, `instancing.rs`) are guarded on
49    ///   non-empty, so every occurrence re-walks it. `sources_recorded` closes
50    ///   that gap: the first walk claims the source and records, later walks by
51    ///   any path record nothing.
52    ///
53    ///   Read the scope of "once" carefully — it is per router, and NO shipped
54    ///   path gives one router a whole model. The native pool builds one per
55    ///   element (`processor/jobs.rs`) and the wasm path one per batch, and both
56    ///   then SUM the drained maps, so a total-loss source shared by N elements
57    ///   still reports N natively and once per batch in the viewer. The wire
58    ///   docs on `GeometryDiagnostics::total_unsupported_items` say so; do not
59    ///   restate the rule there without that qualifier.
60    ///
61    ///   (`mapped_item.rs` has a THIRD insert, the per-router `RefCell` fallback
62    ///   taken when no shared cache is armed, and that one is deliberately
63    ///   unguarded — it caches the empty mesh, so later occurrences do not
64    ///   re-walk and `sources_recorded` is not what does the work on that path.)
65    /// * **Body representations only.** A type's 2D 'FootPrint'/'Annotation' map
66    ///   carries `IfcAnnotationFillArea`/`IfcGeometricCurveSet`, which have no
67    ///   processor and are CORRECTLY absent from a 3D view; counting them warns
68    ///   on a clean model. `is_body` false suppresses records beneath the scope
69    ///   and does NOT claim the source, so the same source reached later under a
70    ///   Body representation still counts. Suppression is INNERMOST-WINS, not
71    ///   sticky: `record_unsupported_item` reads only `scope.last()`, so a Body
72    ///   map nested inside a non-Body one records its own drops. That matches
73    ///   the attribution rule on `UnsupportedSourceScope` (a drop belongs to the
74    ///   innermost source walking it) and needs a FootPrint map that nests a
75    ///   Body map to observe at all, which no export in the corpus does. The
76    ///   caller hands over the source's `IfcShapeRepresentation` rather than a
77    ///   bool, so the four walks
78    ///   cannot each spell the predicate differently — dropping the `is_none_or`
79    ///   (a CATIA-style blank type is body, see `effective_rep_type`) or reaching
80    ///   for `is_direct_body_representation` would compile and silently suppress
81    ///   real drops.
82    ///
83    /// Deciding it here rather than at each `record_unsupported_item` call is
84    /// what makes it hold on the OCCURRENCE path: a mapped source's unsupported
85    /// item is not recorded by the mapped-item branch at all, it is recorded by
86    /// `collect_submeshes_from_item_inner`'s own plain-item arm one recursion
87    /// level down. A per-site gate at the mapped-item branch would never see it.
88    ///
89    /// Scoped to this router, like `mapped_item_cache`: the native pool builds a
90    /// fresh router per element and the wasm path one per batch, so two elements
91    /// sharing a TOTAL-LOSS source still contribute one each. Every source that
92    /// yields any geometry is shared model-wide through
93    /// `shared_mapped_item_cache` and walked once.
94    pub(crate) fn enter_unsupported_source(
95        &self,
96        source_id: u32,
97        mapped_repr: &DecodedEntity,
98    ) -> UnsupportedSourceScope<'_> {
99        let is_body = crate::router::effective_rep_type(mapped_repr)
100            .is_none_or(crate::router::is_body_representation);
101        let mut state = self.unsupported.borrow_mut();
102        let record = is_body && state.sources_recorded.insert(source_id);
103        state.scope.push(record);
104        UnsupportedSourceScope { router: self }
105    }
106
107    /// Record one dropped representation item (unsupported type, or its
108    /// processor errored), keyed by `IfcType`.
109    ///
110    /// Suppressed while inside an [`enter_unsupported_source`] scope that
111    /// declined to record — a non-Body source, or one already counted. Outside
112    /// any scope (an element's own representation item) it always records.
113    ///
114    /// [`enter_unsupported_source`]: Self::enter_unsupported_source
115    pub(crate) fn record_unsupported_item(&self, ifc_type: ifc_lite_core::IfcType) {
116        let mut state = self.unsupported.borrow_mut();
117        if state.scope.last() == Some(&false) {
118            return;
119        }
120        // `name()` is `&'static str` and `entry` takes the key BY VALUE, so
121        // `entry(ifc_type.to_string())` would allocate on every hit, not just on
122        // first insert — once per dropped item, on exactly the models that drop
123        // many. `get_mut` first keeps it to one allocation per distinct type.
124        let name = ifc_type.name();
125        match state.counts.get_mut(name) {
126            Some(count) => *count += 1,
127            None => {
128                state.counts.insert(name.to_string(), 1);
129            }
130        }
131    }
132
133    /// Drain the dropped-item counts gathered since the last call.
134    ///
135    /// Does NOT forget the sources already counted: the drain moves counts to
136    /// the caller, it does not un-see them. That happens with the mapped-item
137    /// cache this mirrors (`set_tessellation_quality`), since a re-tessellation
138    /// re-walks every source.
139    pub fn take_unsupported_items(&self) -> FxHashMap<String, u64> {
140        std::mem::take(&mut self.unsupported.borrow_mut().counts)
141    }
142}
143
144/// Guard returned by [`GeometryRouter::enter_unsupported_source`]. Pops on drop,
145/// so a `?` out of the middle of a source walk cannot leave the scope stack
146/// unbalanced. Nested maps stack: a drop is attributed to the INNERMOST source
147/// being walked, which is the one that owns the item.
148pub(crate) struct UnsupportedSourceScope<'a> {
149    router: &'a GeometryRouter,
150}
151
152impl Drop for UnsupportedSourceScope<'_> {
153    fn drop(&mut self) {
154        self.router.unsupported.borrow_mut().scope.pop();
155    }
156}
157
158/// The human-facing `type=count` breakdown, count-desc then name, as one line.
159///
160/// Shared so the wasm console warning and the native `tracing::warn!` cannot
161/// drift: they previously each re-derived this and had already diverged on the
162/// separator (comma vs space). Both also sorted on count alone, which leaves
163/// ties ordered by `FxHashMap` iteration — so two runs over the same model could
164/// print different strings. [`summarize`] breaks ties by name, so this is stable.
165/// The per-surface prefix text stays at the call site; only the list is shared.
166pub fn format_unsupported_breakdown(items: &FxHashMap<String, u64>) -> String {
167    let (_total, by_type) = summarize(items);
168    by_type
169        .iter()
170        .map(|rc| format!("{}={}", rc.reason, rc.count))
171        .collect::<Vec<_>>()
172        .join(", ")
173}
174
175/// Fold a drained unsupported-item map into `(total, by_type sorted desc)`.
176pub fn summarize(items: &FxHashMap<String, u64>) -> (u64, Vec<ReasonCount>) {
177    let total = items.values().sum();
178    let mut by_type: Vec<ReasonCount> = items
179        .iter()
180        .map(|(ifc_type, count)| ReasonCount { reason: ifc_type.clone(), count: *count })
181        .collect();
182    by_type.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.reason.cmp(&b.reason)));
183    (total, by_type)
184}