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}