1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
//! #1623 Phase 2 "don't-bake" router helpers: eligibility of a mapped source for
//! instancing, and the one-time source mesh into the shared registry that backs the
//! finalize's orphan recovery. The don't-bake decision itself lives at the top-level
//! mapped-item branch of `collect_submeshes_from_item_inner` (see `processing.rs`).
use super::GeometryRouter;
use crate::Mesh;
use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
use std::sync::Arc;
impl GeometryRouter {
/// #1623 Phase 2 eligibility: if this `MappedRepresentation` resolves to exactly
/// ONE direct (non-mapped) geometry item, return that item's express id, else
/// `None`. The don't-bake template↔instance model represents an occurrence with a
/// SINGLE placeholder / a SINGLE re-tagged sub-mesh, so it only applies when the
/// source is one solid. Multi-item sources (each carrying its own per-item
/// colour/rep_identity) and nested-mapped sources fall through to the normal flat
/// materialize — never instanced, never lost. The returned id is used as the
/// placeholder's `geometry_id` so colour resolves EXACTLY as the flat/template
/// sub-mesh does (both key on the nested solid's id).
pub(super) fn mapped_source_single_item(
&self,
mapped_repr: &DecodedEntity,
decoder: &mut EntityDecoder,
) -> Option<u32> {
let items_attr = mapped_repr.get(3)?;
let items = decoder.resolve_ref_list(items_attr).ok()?;
match items.as_slice() {
[only] if only.ifc_type != IfcType::IfcMappedItem => Some(only.id),
_ => None,
}
}
/// #1623 Phase 2: mesh a mapped source ONCE into the shared registry (source
/// coords, pre-`MappingTarget`, pre-placement), if not already present. Called on
/// the don't-bake instance path so the streaming finalize can recover an
/// occurrence's geometry from the registry in the (effectively unreachable) case
/// that the template occurrence never materialized — never a silent geometry loss.
/// The meshing runs OUTSIDE the brief lock (no join held under lock).
///
/// It shares `process_mapped_item_cached`'s insert guards — empty mesh and #1257
/// budget trip — because both write the same model-wide cache, but deliberately
/// NOT its recursion into nested mapped items: this is a flat walk. The only
/// caller reaches here having passed `mapped_source_single_item`, so the source
/// is exactly ONE non-mapped item and a nested one cannot occur. Should that ever
/// change, the walk below bails without inserting rather than registering a mesh
/// short of the source's real geometry — a truncated source under a key that does
/// not encode the truncation is served to every later occurrence.
pub(super) fn ensure_shared_mapped_source(
&self,
mapped_repr: &DecodedEntity,
source_id: u32,
decoder: &mut EntityDecoder,
) {
let Some(shared) = self.shared_mapped_item_cache.as_ref() else {
// Without a shared registry there is nothing to recover from; callers
// that arm output instancing always enable the shared cache too.
return;
};
if shared
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&source_id)
{
return;
}
// Same two rules the other three walks apply, from one place: a non-Body
// source's dropped items are not a content loss, and this source's drops
// are counted once rather than once per occurrence that re-walks it —
// which is exactly this function, since the insert below is skipped for a
// total-loss (empty) source and every later occurrence lands here again.
// See `GeometryRouter::enter_unsupported_source`.
let _drop_scope = self.enter_unsupported_source(source_id, mapped_repr);
let mut mesh = Mesh::new();
if let Some(items_attr) = mapped_repr.get(3) {
if let Ok(items) = decoder.resolve_ref_list(items_attr) {
for sub_item in items {
if sub_item.ifc_type == IfcType::IfcMappedItem {
// Unreachable via the only caller (see the doc above).
// Skipping it would leave `mesh` short of the source's real
// geometry, and the insert below would publish that model-wide.
//
// If it ever becomes reachable, note the second cost: the
// scope above has already CLAIMED `source_id` in
// `sources_recorded`, so a later, complete walk of the same
// source through `mapped_item.rs` would find it claimed and
// record nothing — losing its drops rather than duplicating
// them. Releasing the claim belongs with whatever makes this
// branch reachable.
return;
}
// A missing processor or a failing one leaves `mesh` short of
// the source's real geometry, and the insert below publishes
// that to EVERY instance of this source model-wide. The partial
// mesh itself is consistent with the per-occurrence path
// (`mapped_item.rs` also keeps going past a failed sub-item), so
// the drop is recorded rather than the source withheld — but it
// MUST be recorded, or the one place the loss is visible at all
// is the one place instancing removed.
match self.processors.get(&sub_item.ifc_type, self.schema) {
Some(processor) => match processor.process(
&sub_item,
decoder,
self.schema,
self.tessellation_quality,
) {
Ok(mut sub_mesh) => {
sub_mesh.validate_indices();
self.scale_mesh(&mut sub_mesh);
mesh.merge(&sub_mesh);
}
Err(_e) => {
self.record_unsupported_item(sub_item.ifc_type);
crate::diag::diag_debug!(
{ item_id = sub_item.id, ifc_type = ?sub_item.ifc_type,
error = %_e, "skipping unsupported shared-source item" }
else {
#[cfg(debug_assertions)]
eprintln!(
"[ifc-lite] Skipping unsupported shared-source item #{} ({:?}): {}",
sub_item.id, sub_item.ifc_type, _e
);
}
);
}
},
None => {
self.record_unsupported_item(sub_item.ifc_type);
crate::diag::diag_debug!(
{ item_id = sub_item.id, ifc_type = ?sub_item.ifc_type,
"skipping unsupported shared-source item (no processor)" }
else {
#[cfg(debug_assertions)]
eprintln!(
"[ifc-lite] Skipping unsupported shared-source item #{} ({:?}): no processor",
sub_item.id, sub_item.ifc_type
);
}
);
}
}
}
}
}
if !mesh.positions.is_empty() && !crate::kernel::budget::tripped() {
shared
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(source_id, Arc::new(mesh));
}
}
}
#[cfg(test)]
mod tests {
use crate::router::GeometryRouter;
use ifc_lite_core::EntityDecoder;
/// A Body source whose two items both fail to mesh: `#8` has no registered
/// processor at all (the `None` arm), `#7` has one that errors on a null
/// profile/placement (the `Err` arm). `ensure_shared_mapped_source` is the
/// don't-bake path's own flat walk of a source, structurally separate from
/// `mapped_item.rs`'s, and its two drop arms had no test: deleting both
/// `record_unsupported_item` calls left the whole suite green, so the one
/// place a don't-bake occurrence's loss is visible was unguarded.
const TWO_FAILING_ITEMS: &str = r#"
#7=IFCEXTRUDEDAREASOLID($,$,$,0.);
#8=IFCGEOMETRICSET(());
#9=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#7,#8));
"#;
#[test]
fn both_drop_arms_of_the_shared_source_walk_are_recorded() {
let mut decoder = EntityDecoder::new(TWO_FAILING_ITEMS);
let mut router = GeometryRouter::new();
router.enable_shared_mapped_item_cache(GeometryRouter::new_mapped_item_cache());
let rep = decoder.decode_by_id(9).expect("decode #9");
router.ensure_shared_mapped_source(&rep, 100, &mut decoder);
assert_eq!(
router.mapped_shared_unique_count(),
0,
"a source that meshes to nothing must not be published (unchanged)"
);
let unsupported = router.take_unsupported_items();
assert_eq!(
unsupported.get("IfcGeometricSet"),
Some(&1),
"the None arm (no processor for the type) must record: {unsupported:?}"
);
assert_eq!(
unsupported.get("IfcExtrudedAreaSolid"),
Some(&1),
"the Err arm (processor ran and failed) must record: {unsupported:?}"
);
}
/// The Body gate, on this walk. `plan_type_geometry` selects a type's
/// RepresentationMaps by reference and instantiation only, so a 2D
/// 'FootPrint'/'Annotation' map reaches the don't-bake path exactly as it
/// reaches the other three, carrying items no processor handles and which
/// are CORRECTLY absent from a 3D view. Counting them warns on a clean
/// model. The other three walks already gated; this one did not.
#[test]
fn a_footprint_source_records_nothing_on_the_shared_source_walk() {
let footprint = r#"
#8=IFCANNOTATIONFILLAREA($,());
#9=IFCSHAPEREPRESENTATION($,'FootPrint','Annotation2D',(#8));
"#;
let mut decoder = EntityDecoder::new(footprint);
let mut router = GeometryRouter::new();
router.enable_shared_mapped_item_cache(GeometryRouter::new_mapped_item_cache());
let rep = decoder.decode_by_id(9).expect("decode #9");
router.ensure_shared_mapped_source(&rep, 101, &mut decoder);
let unsupported = router.take_unsupported_items();
assert!(
unsupported.is_empty(),
"a 2D representation carries no 3D content to lose: {unsupported:?}"
);
}
/// The other half of that gate, so it cannot be satisfied by never counting:
/// the identical item under a Body representation IS a content loss.
#[test]
fn the_same_item_under_a_body_source_is_still_recorded_on_the_shared_source_walk() {
let body = r#"
#8=IFCANNOTATIONFILLAREA($,());
#9=IFCSHAPEREPRESENTATION($,'Body','SweptSolid',(#8));
"#;
let mut decoder = EntityDecoder::new(body);
let mut router = GeometryRouter::new();
router.enable_shared_mapped_item_cache(GeometryRouter::new_mapped_item_cache());
let rep = decoder.decode_by_id(9).expect("decode #9");
router.ensure_shared_mapped_source(&rep, 102, &mut decoder);
let unsupported = router.take_unsupported_items();
assert_eq!(
unsupported.values().sum::<u64>(),
1,
"the gate keys on the representation, not on the item type: {unsupported:?}"
);
}
fn fixture() -> String {
std::fs::read_to_string("tests/fixtures/nested_mapped_item.ifc")
.expect("read tests/fixtures/nested_mapped_item.ifc")
}
/// `ensure_shared_mapped_source` walks the source's items FLAT — it has no
/// depth/visited bound, so it cannot recurse into a nested `IfcMappedItem` the
/// way `process_mapped_item_cached` now does. Handed a nested source anyway, it
/// must publish NOTHING: the shared cache is keyed on the source id, so a mesh
/// missing the nested contribution would be served to every later occurrence,
/// including through `process_mapped_item_cached`'s own lookup.
///
/// `#20` is the outer map's representation: its own solid `#16` plus a nested
/// mapped item `#19` on map `#14`.
#[test]
fn nested_source_is_not_registered_flat() {
let content = fixture();
let entity_index = ifc_lite_core::build_entity_index(&content);
let mut decoder = EntityDecoder::with_index(&content, entity_index);
let mut router = GeometryRouter::with_units(&content, &mut decoder);
router.enable_shared_mapped_item_cache(GeometryRouter::new_mapped_item_cache());
let nested_rep = decoder.decode_by_id(20).expect("decode #20");
router.ensure_shared_mapped_source(&nested_rep, 21, &mut decoder);
assert_eq!(
router.mapped_shared_unique_count(),
0,
"a flat walk of a nested source must not be registered"
);
// Negative half: the inner map's representation `#13` is a single solid,
// the shape the caller actually reaches here, and it IS registered.
let flat_rep = decoder.decode_by_id(13).expect("decode #13");
router.ensure_shared_mapped_source(&flat_rep, 14, &mut decoder);
assert_eq!(router.mapped_shared_unique_count(), 1);
}
/// The empty-mesh guard: a source that decodes fine but walks to ZERO items
/// (`#40` is a well-formed `IfcShapeRepresentation` with an empty items list)
/// must not be published into the shared registry — an empty/truncated source
/// cached under `source_id` would be served to every later occurrence sharing
/// that `IfcRepresentationMap`, per the doc on `ensure_shared_mapped_source`.
///
/// The second half proves the guard doesn't poison the id going forward: a
/// later call with the SAME `source_id` but a real single-solid source (`#13`)
/// must still register, because the aborted empty attempt inserted nothing.
#[test]
fn empty_mesh_source_not_registered_then_real_source_still_is() {
let content = fixture();
let entity_index = ifc_lite_core::build_entity_index(&content);
let mut decoder = EntityDecoder::with_index(&content, entity_index);
let mut router = GeometryRouter::with_units(&content, &mut decoder);
router.enable_shared_mapped_item_cache(GeometryRouter::new_mapped_item_cache());
let empty_rep = decoder.decode_by_id(40).expect("decode #40");
router.ensure_shared_mapped_source(&empty_rep, 999, &mut decoder);
assert_eq!(
router.mapped_shared_unique_count(),
0,
"a source that meshes to zero positions must not be cached"
);
let flat_rep = decoder.decode_by_id(13).expect("decode #13");
router.ensure_shared_mapped_source(&flat_rep, 999, &mut decoder);
assert_eq!(
router.mapped_shared_unique_count(),
1,
"a real mesh for the same source id must still register after an \
aborted empty attempt didn't poison the entry"
);
}
}