ifc_lite_geometry/router/processing.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//! Core element processing: resolving representations, processing items, and caching.
6
7use super::frame_parts::{frames_mergeable, mesh_bounds, single_frame, union_bounds, FrameParts};
8use super::transforms::{instancing_enabled, mat4_to_row_major};
9use super::{GeometryProcessor, GeometryRouter};
10use crate::{Error, InstanceMeta, Mesh, Result, SubMeshCollection};
11
12/// High tag bit distinguishing direct-solid rep_identity (a 128-bit local-mesh
13/// content hash) from mapped-item rep_identity (a RepresentationMap entity id,
14/// always < 2^32), so the two id spaces can never collide in `collate_instances`.
15/// Bit 127 is set on direct-solid ids and clear on mapped ids; it costs one hash
16/// bit (127 effective), still content-addressing grade.
17const DIRECT_SOLID_TAG: u128 = 1u128 << 127;
18
19/// Row-major 4x4 identity; placeholder `InstanceMeta::transform` before the
20/// element's world placement is folded in by `apply_placement`.
21pub(crate) const IDENTITY_ROW_MAJOR: [f64; 16] = [
22 1.0, 0.0, 0.0, 0.0, //
23 0.0, 1.0, 0.0, 0.0, //
24 0.0, 0.0, 1.0, 0.0, //
25 0.0, 0.0, 0.0, 1.0, //
26];
27use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
28use rustc_hash::FxHashSet;
29use std::rc::Rc;
30use std::sync::Arc;
31
32// Maximum nested IfcMappedItem depth for a single geometry item. Shared with
33// `ifc_lite_processing::element` and the wasm styling colour resolver, which
34// walk the same chain; the constant's own docs say why they must agree.
35use ifc_lite_core::MAX_MAPPED_ITEM_DEPTH;
36
37/// The nearest f32 that does not shrink the interval: rounds a minimum down
38/// and a maximum up, so an f32 box always encloses the f64 geometry.
39pub(super) fn enclosing_f32(value: f64, is_min: bool) -> f32 {
40 let rounded = value as f32;
41 match (is_min, (rounded as f64).partial_cmp(&value)) {
42 (true, Some(std::cmp::Ordering::Greater)) => rounded.next_down(),
43 (false, Some(std::cmp::Ordering::Less)) => rounded.next_up(),
44 _ => rounded,
45 }
46}
47
48/// Publish object-frame `local_bounds` for a mesh rebased by `offset`
49/// (metres, item frame). No-op for an empty mesh.
50///
51/// Public bounds stay in the pre-RTC object frame (the contract
52/// `local_to_world` pairs with), reconstituted from the rebased f32 positions
53/// in f64 and then rounded OUTWARD to the enclosing f32 box: at a 5,000,000 m
54/// offset the f32 ULP is 0.5 m, so a 0.125 m face would otherwise round both
55/// faces onto one value and publish a zero extent (#5026 review).
56fn publish_object_frame_bounds(mesh: &mut Mesh, offset: (f64, f64, f64)) {
57 if mesh.positions.is_empty() {
58 return;
59 }
60 let rebased = mesh_bounds(mesh);
61 let offset = [offset.0, offset.1, offset.2];
62 let mut bounds = [0.0f32; 6];
63 for axis in 0..3 {
64 let min = rebased[axis] as f64 + offset[axis];
65 let max = rebased[axis + 3] as f64 + offset[axis];
66 bounds[axis] = enclosing_f32(min, true);
67 bounds[axis + 3] = enclosing_f32(max, false);
68 if max > min && bounds[axis + 3] <= bounds[axis] {
69 bounds[axis + 3] = bounds[axis].next_up();
70 }
71 }
72 mesh.local_bounds = Some(bounds);
73}
74
75/// Which source hygiene an element's meshes get before placement (#5313).
76#[derive(Clone, Copy, PartialEq, Eq)]
77pub(super) enum SourceHygiene {
78 /// [`Mesh::clean_degenerate_watertight`]: drops slivers without opening a
79 /// T-junction. The default for output meshes.
80 Watertight,
81 /// [`Mesh::clean_degenerate`]: drops slivers, indices only. For meshes that
82 /// are about to be boolean operands (void hosts, opening cutters): moving
83 /// those inputs changed the cut on ~20 void hosts of the public corpus, so
84 /// it is a separate, measured change.
85 IndexOnly,
86}
87
88impl SourceHygiene {
89 /// `self`, downgraded to `IndexOnly` while the router is told to keep
90 /// triangle order (see `GeometryRouter::set_preserve_triangle_order`).
91 pub(super) fn for_router(self, router: &GeometryRouter) -> Self {
92 if router.preserve_triangle_order.get() { Self::IndexOnly } else { self }
93 }
94
95 pub(super) fn apply(self, mesh: &mut Mesh) {
96 match self {
97 Self::Watertight => mesh.clean_degenerate_watertight(),
98 Self::IndexOnly => mesh.clean_degenerate(),
99 }
100 }
101}
102
103impl GeometryRouter {
104 /// Process building element (IfcWall, IfcBeam, etc.) into mesh
105 /// Follows the representation chain:
106 /// Element → Representation → ShapeRepresentation → Items
107 ///
108 /// Errors when the items cannot share one f64 frame (#6349); use
109 /// [`Self::process_element_parts`] to keep such products at full precision.
110 #[inline]
111 pub fn process_element(
112 &self,
113 element: &DecodedEntity,
114 decoder: &mut EntityDecoder,
115 ) -> Result<Mesh> {
116 single_frame(element.id, self.process_element_parts(element, decoder)?)
117 }
118
119 /// [`Self::process_element`] as frame parts: one mesh, unless body items lie
120 /// in frames at least 1 km apart, which stay separate meshes (#6349).
121 pub fn process_element_parts(
122 &self,
123 element: &DecodedEntity,
124 decoder: &mut EntityDecoder,
125 ) -> Result<Vec<Mesh>> {
126 self.process_element_parts_with_hygiene(element, decoder, SourceHygiene::Watertight)
127 }
128
129 /// [`Self::process_element_parts`] with an explicit [`SourceHygiene`]; the
130 /// void path passes `IndexOnly` for hosts and cutters.
131 pub(super) fn process_element_parts_with_hygiene(
132 &self,
133 element: &DecodedEntity,
134 decoder: &mut EntityDecoder,
135 hygiene: SourceHygiene,
136 ) -> Result<Vec<Mesh>> {
137 // IfcAlignment carries its directrix curve in a dedicated `Axis`
138 // attribute (IFC4X1) instead of (or in addition to) a normal
139 // IfcShapeRepresentation. Route those through the alignment
140 // processor before the standard representation walk, since the
141 // Representation is often `$` in practice.
142 if element.ifc_type == IfcType::IfcAlignment {
143 if let Some(mesh) = self.try_alignment_mesh(element, decoder)? {
144 return Ok(vec![mesh]);
145 }
146 }
147
148 // Get representation (attribute 6 for most building elements)
149 // IfcProduct: GlobalId, OwnerHistory, Name, Description, ObjectType, ObjectPlacement, Representation, Tag
150 let representation_attr = element.get(6).ok_or_else(|| {
151 Error::geometry(format!(
152 "Element #{} has no representation attribute",
153 element.id
154 ))
155 })?;
156
157 if representation_attr.is_null() {
158 return Ok(vec![Mesh::new()]); // No geometry
159 }
160
161 let representation = decoder
162 .resolve_ref(representation_attr)?
163 .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
164
165 // IfcProductDefinitionShape has Representations attribute (list of IfcRepresentation)
166 if representation.ifc_type != IfcType::IfcProductDefinitionShape {
167 return Err(Error::geometry(format!(
168 "Expected IfcProductDefinitionShape, got {}",
169 representation.ifc_type
170 )));
171 }
172
173 // Get representations list (attribute 2)
174 let representations_attr = representation.get(2).ok_or_else(|| {
175 Error::geometry("IfcProductDefinitionShape missing Representations".to_string())
176 })?;
177
178 let representations = decoder.resolve_ref_list(representations_attr)?;
179
180 // Process all representations and merge meshes
181 let mut combined_mesh = Mesh::new();
182 let mut rebased_mesh = Mesh::new();
183 let mut captured_local_bounds: Option<[f32; 6]> = None;
184 // #6349: items too far from `combined_mesh`'s frame, and the RTC-frame items' own bounds.
185 let (mut far_parts, mut rebased_bounds) = (FrameParts::default(), None);
186
187 // Instancing: an element is cleanly shareable only when its whole body is
188 // exactly ONE representation item that itself carried instance metadata
189 // (a mapped item). `Mesh::merge` does not propagate the side-channel, so we
190 // capture the single item's metadata here and re-attach it below; any second
191 // item disqualifies the element (left as None -> rendered flat).
192 let mut single_instance_meta: Option<InstanceMeta> = None;
193 let mut instanceable_item_count: usize = 0;
194
195 // Body representations only ('Axis', 'FootPrint', 'Box' are skipped), and
196 // a MappedRepresentation is skipped when direct geometry duplicates it.
197 for shape_rep in super::meshed_representations(element, &representations) {
198 // Get items list (attribute 3)
199 let items_attr = shape_rep.get(3).ok_or_else(|| {
200 Error::geometry("IfcShapeRepresentation missing Items".to_string())
201 })?;
202
203 let items = decoder.resolve_ref_list(items_attr)?;
204 let fill_only = super::annotation::is_fill_only_representation(element, shape_rep);
205
206 // Process each representation item
207 // A mapped item whose source mixes frames yields one mesh per frame (#6446).
208 for item in items {
209 let item_parts = if element.ifc_type == IfcType::IfcAnnotation
210 && item.ifc_type == IfcType::IfcAnnotationFillArea
211 {
212 vec![self.process_annotation_fill(&item, decoder)?]
213 } else if fill_only {
214 continue; // symbolic annotation item, never meshed (#5389)
215 } else if let Some(mesh) =
216 self.process_raw_item_for_element(&item, element, decoder)?
217 {
218 vec![mesh]
219 } else {
220 self.process_representation_item_parts(&item, decoder)?
221 };
222 for mesh in item_parts {
223 if mesh.positions.is_empty() {
224 continue; // Mesh::merge would ignore it too
225 }
226 let bounds = mesh.local_bounds.unwrap_or_else(|| mesh_bounds(&mesh));
227 if instancing_enabled() {
228 instanceable_item_count += 1;
229 single_instance_meta = if instanceable_item_count == 1 {
230 mesh.instance_meta.clone()
231 } else {
232 None
233 };
234 }
235 // #5684: early f64 processors and ordinary f32 processors can
236 // return different RTC frames. Merge only like frames until
237 // placement has brought both into the world/RTC frame.
238 if mesh.rtc_applied {
239 rebased_mesh.merge(&mesh);
240 union_bounds(&mut rebased_bounds, bounds);
241 continue;
242 } else if combined_mesh.positions.is_empty()
243 || frames_mergeable(combined_mesh.origin, mesh.origin)
244 {
245 combined_mesh.merge(&mesh);
246 } else {
247 far_parts.merge(&mesh, bounds);
248 continue;
249 }
250 union_bounds(&mut captured_local_bounds, bounds);
251 }
252 }
253 }
254 if combined_mesh.positions.is_empty() {
255 std::mem::swap(&mut combined_mesh, &mut rebased_mesh);
256 captured_local_bounds = rebased_bounds.take();
257 }
258
259 // Re-attach single-item instance metadata so apply_placement can fold the
260 // element's world placement into `transform`.
261 if instancing_enabled() {
262 combined_mesh.instance_meta = single_instance_meta;
263 }
264 combined_mesh.local_bounds = captured_local_bounds;
265
266 // Source-triangle hygiene before placement (rigid transforms preserve
267 // degeneracy, while the element-local frame retains more f32 precision).
268 // This is the merged-mesh router's choke point: downstream facet weld /
269 // refinement passes canonicalize geometry but do not replace this input
270 // cleanup or promise hygienic output. See `SourceHygiene` (#5313).
271 // Cut-created candidates require separate, path-specific handling. See
272 // #4797.
273 hygiene.for_router(self).apply(&mut combined_mesh);
274
275 // Apply placement transformation
276 self.apply_placement(element, decoder, &mut combined_mesh)?;
277 let rebased = (rebased_mesh, rebased_bounds);
278 self.place_frame_parts(element, decoder, hygiene, combined_mesh, far_parts, rebased)
279 }
280
281 /// Process element and return sub-meshes with their geometry item IDs.
282 /// This preserves per-item identity for color/style lookup.
283 ///
284 /// For elements with multiple styled geometry items (like windows with frames + glass),
285 /// this returns separate sub-meshes that can receive different colors.
286 pub fn process_element_with_submeshes(
287 &self,
288 element: &DecodedEntity,
289 decoder: &mut EntityDecoder,
290 ) -> Result<SubMeshCollection> {
291 // Public entry: the ordinary (non-void) element path, so the #1623 Phase 2
292 // don't-bake instancing is allowed here. The void path
293 // (`process_element_with_submeshes_and_voids`) calls the impl below with
294 // `allow_instancing = false` — a voided occurrence must materialize its cut
295 // geometry, never instance an un-cut shared template.
296 self.process_element_with_submeshes_impl(element, decoder, true, None, SourceHygiene::Watertight)
297 }
298
299 /// [`Self::process_element_with_submeshes`] with an explicit don't-bake gate.
300 /// `allow_instancing` is `true` only on the ordinary (non-void) path; the void
301 /// path passes `false` so its occurrences always materialize. The don't-bake
302 /// additionally requires an armed [`GeometryRouter::enable_output_instancing`]
303 /// plan, so with no plan this is byte-identical to the historical flat path.
304 /// `texture_index` is `Some` only on the textured non-void path (#1781).
305 pub(super) fn process_element_with_submeshes_impl(
306 &self,
307 element: &DecodedEntity,
308 decoder: &mut EntityDecoder,
309 allow_instancing: bool,
310 texture_index: Option<
311 &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
312 >,
313 hygiene: SourceHygiene,
314 ) -> Result<SubMeshCollection> {
315 // If a material-layer buildup is attached, try slicing single-solid
316 // elements (walls / slabs with IfcMaterialLayerSetUsage) first so each
317 // layer gets its own sub-mesh keyed by IfcMaterial id. An empty void
318 // index is passed — the caller's has_openings branch takes the
319 // voids-aware path below.
320 if let Some(layered) = self.try_layered_sub_meshes(element, decoder, None) {
321 return Ok(layered);
322 }
323
324 // Get representation (attribute 6 for most building elements)
325 let representation_attr = element.get(6).ok_or_else(|| {
326 Error::geometry(format!(
327 "Element #{} has no representation attribute",
328 element.id
329 ))
330 })?;
331
332 if representation_attr.is_null() {
333 return Ok(SubMeshCollection::new()); // No geometry
334 }
335
336 let representation = decoder
337 .resolve_ref(representation_attr)?
338 .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
339
340 if representation.ifc_type != IfcType::IfcProductDefinitionShape {
341 return Err(Error::geometry(format!(
342 "Expected IfcProductDefinitionShape, got {}",
343 representation.ifc_type
344 )));
345 }
346
347 // Get representations list (attribute 2)
348 let representations_attr = representation.get(2).ok_or_else(|| {
349 Error::geometry("IfcProductDefinitionShape missing Representations".to_string())
350 })?;
351
352 let representations = decoder.resolve_ref_list(representations_attr)?;
353
354 let mut sub_meshes = SubMeshCollection::new();
355
356 for shape_rep in super::meshed_representations(element, &representations) {
357 // Get items list (attribute 3)
358 let items_attr = shape_rep.get(3).ok_or_else(|| {
359 Error::geometry("IfcShapeRepresentation missing Items".to_string())
360 })?;
361
362 let items = decoder.resolve_ref_list(items_attr)?;
363 let fill_only = super::annotation::is_fill_only_representation(element, shape_rep);
364
365 // Process each representation item, preserving geometry IDs
366 for item in items {
367 if element.ifc_type == IfcType::IfcAnnotation
368 && item.ifc_type == IfcType::IfcAnnotationFillArea
369 {
370 sub_meshes.add(item.id, self.process_annotation_fill(&item, decoder)?);
371 continue;
372 }
373 if fill_only {
374 continue; // symbolic annotation item, never meshed (#5389)
375 }
376 // A textured face set keeps its UV channel (#1781) and, like
377 // any raw item, rebases in its own frame (#5698). A failed
378 // textured build falls through to the untextured paths.
379 // A terrain TIN is a face set that only appends `Flags` (#5942).
380 let texture = texture_index
381 .and_then(|index| index.get(&item.id))
382 .filter(|_| matches!(
383 item.ifc_type,
384 IfcType::IfcTriangulatedFaceSet | IfcType::IfcTriangulatedIrregularNetwork
385 ));
386 if let Some(map) = texture {
387 let offset = self.element_frame_rtc(element, decoder)?;
388 if self.add_textured_face_set(&item, decoder, map, offset, &mut sub_meshes) {
389 continue;
390 }
391 }
392 if let Some(mesh) = self.process_raw_item_for_element(&item, element, decoder)? {
393 if !mesh.is_empty() {
394 sub_meshes.add(item.id, mesh);
395 }
396 continue;
397 }
398 self.collect_submeshes_from_item(
399 &item,
400 decoder,
401 &mut sub_meshes,
402 allow_instancing,
403 texture_index,
404 )?;
405 }
406 }
407
408 // Source-triangle hygiene before placement — the per-style counterpart
409 // to `process_element`'s choke point. Facet canonicalizers downstream do
410 // not replace this cleanup or promise hygienic output; cut-created
411 // candidates require path-specific handling. Positions are never
412 // removed, so unreferenced ones may remain. See `SourceHygiene`
413 // (#5313); a textured sub-mesh stays `IndexOnly` because an appended
414 // apex vertex would desynchronise its parallel UV array. The layered
415 // and textured early-return channels clean at their own sites and are
416 // not switched (#5313 left them unmeasured). See #4797.
417 for sub in &mut sub_meshes.sub_meshes {
418 let own =
419 if sub.uvs.is_some() { SourceHygiene::IndexOnly } else { hygiene.for_router(self) };
420 own.apply(&mut sub.mesh);
421 }
422
423 self.apply_submesh_placement(&mut sub_meshes, element, decoder)?;
424 Ok(sub_meshes)
425 }
426
427 /// Collect sub-meshes from a representation item, following MappedItem references.
428 /// `allow_instancing` enables the #1623 Phase 2 don't-bake path at the top-level
429 /// mapped item (see [`Self::collect_submeshes_from_item_inner`]).
430 fn collect_submeshes_from_item(
431 &self,
432 item: &DecodedEntity,
433 decoder: &mut EntityDecoder,
434 sub_meshes: &mut SubMeshCollection,
435 allow_instancing: bool,
436 texture_index: Option<
437 &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
438 >,
439 ) -> Result<()> {
440 let mut visited = FxHashSet::default();
441 self.collect_submeshes_from_item_inner(
442 item,
443 decoder,
444 sub_meshes,
445 0,
446 &mut visited,
447 allow_instancing,
448 texture_index,
449 )
450 }
451
452 #[allow(clippy::too_many_arguments)] // internal recursion carries per-walk state
453 fn collect_submeshes_from_item_inner(
454 &self,
455 item: &DecodedEntity,
456 decoder: &mut EntityDecoder,
457 sub_meshes: &mut SubMeshCollection,
458 depth: usize,
459 visited: &mut FxHashSet<u32>,
460 allow_instancing: bool,
461 texture_index: Option<
462 &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
463 >,
464 ) -> Result<()> {
465 if depth >= MAX_MAPPED_ITEM_DEPTH as usize {
466 return Err(Error::geometry(format!(
467 "MappedItem nesting exceeded maximum depth of {} at #{}",
468 MAX_MAPPED_ITEM_DEPTH, item.id
469 )));
470 }
471
472 // For MappedItem, recurse into the mapped representation
473 if item.ifc_type == IfcType::IfcMappedItem {
474 if !visited.insert(item.id) {
475 return Err(Error::geometry(format!(
476 "Detected cyclic IfcMappedItem reference at #{}",
477 item.id
478 )));
479 }
480
481 // Get MappingSource (RepresentationMap)
482 let source_attr = item
483 .get(0)
484 .ok_or_else(|| Error::geometry("MappedItem missing MappingSource".to_string()))?;
485
486 let source_entity = decoder
487 .resolve_ref(source_attr)?
488 .ok_or_else(|| Error::geometry("Failed to resolve MappingSource".to_string()))?;
489 let source_id = source_entity.id;
490
491 // Get MappedRepresentation from RepresentationMap (attribute 1)
492 let mapped_repr_attr = source_entity.get(1).ok_or_else(|| {
493 Error::geometry("RepresentationMap missing MappedRepresentation".to_string())
494 })?;
495
496 let mapped_repr = decoder.resolve_ref(mapped_repr_attr)?.ok_or_else(|| {
497 Error::geometry("Failed to resolve MappedRepresentation".to_string())
498 })?;
499
500 // MappingTarget · MappingOrigin (#1985: the origin used to be dropped).
501 let mapping_transform = self.mapped_item_transform(item, &source_entity, decoder)?;
502
503 // #1623 Phase 2/3 "don't-bake": if this top-level mapped item's source is
504 // a REPEATED (count >= 2) single-solid `IfcRepresentationMap` the armed
505 // plan lists, exactly ONE occurrence (the "template") materializes its
506 // geometry; every OTHER occurrence skips the per-occurrence vertex clone /
507 // MappingTarget bake / weld and emits an instance-only placeholder (empty
508 // geometry carrying the mapping transform + rep_identity in `InstanceMeta`).
509 // `apply_submesh_placement` folds the world placement into `im.transform`;
510 // the finalize turns the placeholder into an occurrence against the template.
511 //
512 // `instance_solid_id` is the nested SOLID's id (used as the placeholder's
513 // geometry_id so colour resolves EXACTLY as the flat/template sub-mesh).
514 // Only fires at the TOP level (`depth == 0`) — a mapped item nested inside
515 // another map is part of its parent's shared geometry, not an independent
516 // occurrence — and only when `allow_instancing` (the non-void path). With
517 // no armed plan this is skipped entirely, so the flat output is unchanged.
518 let instance_solid_id: Option<u32> = if allow_instancing && depth == 0 {
519 self.output_instancing_plan()
520 .and_then(|plan| plan.get(&source_id).copied())
521 .filter(|&(count, _)| count >= 2)
522 .and_then(|_| {
523 self.mapped_source_single_item(&mapped_repr, decoder)
524 // #858: a source whose single solid carries an
525 // IfcIndexedColourMap must materialize flat so
526 // emit_sub_meshes can split it into one mesh per palette
527 // group. An instance placeholder resolves ONE colour,
528 // collapsing the palette (WRONG vs the flat path); route
529 // to flat instead (byte-identical to instancing-off).
530 .filter(|&item_id| !self.is_indexed_colour_split_source(item_id))
531 // #1781: same rule for a TEXTURED single solid — an
532 // instance placeholder carries no UVs/texture, so the
533 // occurrence would render untextured. Materialize flat.
534 .filter(|&item_id| {
535 texture_index.is_none_or(|ti| !ti.contains_key(&item_id))
536 })
537 })
538 } else {
539 None
540 };
541 // Which occurrence MATERIALIZES the template. Native (global) mode: the
542 // plan's deterministic min-id occurrence, so all occurrences resolve
543 // against ONE model-wide template across the rayon pool. WASM batch-local
544 // mode: the FIRST occurrence of this source seen by this router/batch (the
545 // rest don't-bake), so each per-batch shard is self-contained. Both emit
546 // geometrically identical world triangles.
547 let is_template = match instance_solid_id {
548 None => true, // not eligible ⇒ materialize flat as usual
549 Some(_) if self.instancing_batch_local() => {
550 self.mark_source_materialized_if_first(source_id)
551 }
552 Some(_) => {
553 let template_item_id = self
554 .output_instancing_plan()
555 .and_then(|plan| plan.get(&source_id))
556 .map(|&(_, t)| t)
557 .unwrap_or(item.id);
558 item.id == template_item_id
559 }
560 };
561 if let Some(solid_item_id) = instance_solid_id {
562 if !is_template {
563 // NON-template occurrence: don't-bake. Ensure the shared registry
564 // holds the source geometry (meshed once model-wide) so the
565 // finalize can recover geometry even in the (effectively
566 // unreachable) case that the template occurrence never
567 // materialized, then push the instance-only placeholder. Its
568 // geometry_id is the nested SOLID's id (not the mapped-item id) so
569 // emit_sub_meshes resolves the occurrence colour identically to the
570 // flat/template sub-mesh.
571 self.ensure_shared_mapped_source(&mapped_repr, source_id, decoder);
572 let local_rm = mapping_transform.map(|mut t| {
573 self.scale_transform(&mut t);
574 mat4_to_row_major(&t)
575 });
576 let mut placeholder = Mesh::new();
577 placeholder.instance_meta = Some(InstanceMeta {
578 transform: IDENTITY_ROW_MAJOR,
579 local_transform: local_rm,
580 canonical_transform: None,
581 rep_identity: source_id as u128,
582 instanceable: true,
583 });
584 // Push directly (SubMeshCollection::add drops empty meshes; this
585 // placeholder is intentionally empty — its InstanceMeta is the payload).
586 sub_meshes
587 .sub_meshes
588 .push(crate::SubMesh::new(solid_item_id, placeholder));
589 visited.remove(&item.id);
590 return Ok(());
591 }
592 }
593 // Record where THIS mapped item's sub-meshes start, so the don't-bake
594 // TEMPLATE occurrence can be re-tagged with the source-id rep_identity
595 // after the normal materialize below (see the retag after the loop).
596 let mapped_items_start = sub_meshes.len();
597
598 // One scope for this source's walk. It covers the loop below AND the
599 // recursion beneath it: an unsupported item of this source is dropped
600 // one level down, by the plain-item arm at the end of this function,
601 // not by the loop here — so a gate written at this loop's own drop
602 // site would never see the case it exists for. Body-only, and once
603 // per source rather than once per occurrence; see
604 // `GeometryRouter::enter_unsupported_source`.
605 let _drop_scope = self.enter_unsupported_source(source_id, &mapped_repr);
606
607 // Get items from the mapped representation
608 if let Some(items_attr) = mapped_repr.get(3) {
609 let items = decoder.resolve_ref_list(items_attr)?;
610 for nested_item in items {
611 // Recursively collect sub-meshes (skip unsupported geometry types).
612 // Nested items never independently don't-bake (`allow_instancing =
613 // false`): they are this occurrence's own shared geometry.
614 let count_before = sub_meshes.len();
615 if let Err(_e) = self.collect_submeshes_from_item_inner(
616 &nested_item,
617 decoder,
618 sub_meshes,
619 depth + 1,
620 visited,
621 false,
622 texture_index,
623 ) {
624 self.record_unsupported_item(nested_item.ifc_type.clone());
625 crate::diag::diag_debug!(
626 { item_id = nested_item.id, ifc_type = ?nested_item.ifc_type,
627 error = %_e, "skipping unsupported nested geometry item" }
628 else {
629 #[cfg(debug_assertions)]
630 eprintln!(
631 "[ifc-lite] Skipping unsupported nested geometry #{} ({:?}): {}",
632 nested_item.id, nested_item.ifc_type, _e
633 );
634 }
635 );
636 continue;
637 }
638
639 // Apply MappedItem transform to newly added sub-meshes.
640 if let Some(mut transform) = mapping_transform {
641 self.scale_transform(&mut transform);
642 // The MappingTarget is a PER-OCCURRENCE transform: baked into the
643 // vertices here (flat output byte-for-byte unchanged), and for
644 // INSTANCING recorded in `local_transform` (keeping the canonical,
645 // pre-target `rep_identity`) — mirroring `process_mapped_item_cached`
646 // and the don't-bake TEMPLATE re-tag below — so occurrences sharing a
647 // map but differing by target collate under one template. Previously
648 // this RE-HASHED into `rep_identity`, giving every target a unique id
649 // and disabling instancing (GLB export #1443) for the MULTI-item class
650 // Phase 2 leaves flat (Tekla assemblies / MEP / metering skids). #1623
651 let nontrivial_target = !transform.is_identity(1e-9);
652 for sub in &mut sub_meshes.sub_meshes[count_before..] {
653 self.transform_mesh_local(&mut sub.mesh, &transform);
654 if nontrivial_target {
655 if let Some(im) =
656 sub.mesh.instance_meta.as_mut().filter(|im| im.instanceable)
657 {
658 im.local_transform = Some(match im.local_transform {
659 // Nested map: outer target ∘ inner, bake order.
660 Some(inner) => mat4_to_row_major(
661 &(transform * nalgebra::Matrix4::from_row_slice(&inner)),
662 ),
663 None => mat4_to_row_major(&transform),
664 });
665 }
666 }
667 }
668 }
669 }
670 }
671
672 // #1623 Phase 2/3: this is the don't-bake TEMPLATE occurrence. It
673 // materialized normally above (byte-identical to a flat occurrence — a
674 // single-solid source ⇒ exactly one sub-mesh). Re-tag its `rep_identity`
675 // to the source id and record the (scaled) MappingTarget as
676 // `local_transform`, MATCHING the instance placeholders so the finalize
677 // collates them onto this template. The baked geometry is untouched — the
678 // MappingTarget is already folded into both the vertices AND
679 // `local_transform`, which is consistent (the template's world geometry is
680 // `transform · local_transform · source`, so `m_ref` recovers the same
681 // `source` the placeholders reference). See the finalize in processor/mod.rs.
682 if instance_solid_id.is_some() && is_template {
683 let local_rm = mapping_transform.map(|mut t| {
684 self.scale_transform(&mut t);
685 mat4_to_row_major(&t)
686 });
687 for sub in &mut sub_meshes.sub_meshes[mapped_items_start..] {
688 if let Some(im) = sub.mesh.instance_meta.as_mut() {
689 im.rep_identity = source_id as u128;
690 im.local_transform = local_rm;
691 }
692 }
693 }
694
695 visited.remove(&item.id);
696 } else {
697 // Textured tessellated face set (#1781): mesh with per-vertex UVs so
698 // the occurrence path renders its image like the type-geometry path
699 // (#961) always did. Bypasses the content-dedup cache — the cached
700 // mesh has no UV channel, and UVs are per-face-set anyway. Falls
701 // through to the plain path if the textured build fails. A nested
702 // (mapped) face set rebases in the model frame (#5698).
703 if let Some(map) = texture_index.and_then(|ti| ti.get(&item.id)) {
704 if self.add_textured_face_set(item, decoder, map, Some(self.rtc_offset), sub_meshes)
705 {
706 return Ok(());
707 }
708 }
709 // Regular geometry item - process and record with its ID
710 // Skip unsupported geometry types (e.g. IfcGeometricSet) instead of failing
711 match self.process_representation_item(item, decoder) {
712 Ok(mesh) => {
713 if !mesh.is_empty() {
714 sub_meshes.add(item.id, mesh);
715 }
716 }
717 Err(_e) => {
718 self.record_unsupported_item(item.ifc_type.clone());
719 crate::diag::diag_debug!(
720 { item_id = item.id, ifc_type = ?item.ifc_type, error = %_e,
721 "skipping unsupported geometry item" }
722 else {
723 #[cfg(debug_assertions)]
724 eprintln!(
725 "[ifc-lite] Skipping unsupported geometry #{} ({:?}): {}",
726 item.id, item.ifc_type, _e
727 );
728 }
729 );
730 }
731 }
732 }
733
734 Ok(())
735 }
736
737 /// Process a single representation item (IfcExtrudedAreaSolid, etc.), with
738 /// content-dedup: a 128-bit structural hash of the item subtree skips the
739 /// meshing + CSG for geometry byte-identical to an item meshed earlier (e.g.
740 /// the thousands of Tekla connection plates/bolts an exporter failed to share
741 /// via `IfcMappedItem`). The cached mesh is colour-free and pre-placement; the
742 /// caller keeps this item's own `geometry_id` (so colour/palette/texture stay
743 /// per-instance) and applies voids + placement afterwards, so a cache hit is
744 /// indistinguishable from a fresh build.
745 #[inline]
746 pub fn process_representation_item(
747 &self,
748 item: &DecodedEntity,
749 decoder: &mut EntityDecoder,
750 ) -> Result<Mesh> {
751 // Every path below (mapped-item cache, dedup-cache hit, uncached
752 // build) can trip the thread-local curve-capped flag (#4901) -- a
753 // mapped source walks its own processors directly
754 // (`process_mapped_item_cached`), bypassing the inline drain this
755 // function used to have right after the uncached build, which left a
756 // capped edge inside a mapped item unreported AND the flag still set
757 // for whichever unrelated item happened to be processed next
758 // (Macroscope review). Draining exactly ONCE here, around every
759 // path, keeps the flag scoped to the item that actually set it,
760 // regardless of which branch below produced (or skipped) new work.
761 self.process_representation_item_in_frame(item, decoder, self.rtc_offset)
762 }
763
764 /// [`Self::process_representation_item`] with the model RTC offset
765 /// expressed in the item's own frame (`offset_meters`, #5698), so an
766 /// element-frame rebase shares the content-dedup cache and direct-solid
767 /// instancing of the model-frame path.
768 fn process_representation_item_in_frame(
769 &self,
770 item: &DecodedEntity,
771 decoder: &mut EntityDecoder,
772 offset_meters: (f64, f64, f64),
773 ) -> Result<Mesh> {
774 let result = self.process_representation_item_body(item, decoder, offset_meters);
775 if crate::processors::take_curve_capped() {
776 self.record_unsupported_item(IfcType::IfcBSplineCurveWithKnots);
777 }
778 result
779 }
780
781 /// Mesh a raw-coordinate item (tessellated, Brep, surface model or
782 /// direct face) in an element-local RTC frame.
783 ///
784 /// Model RTC is world-space; subtracting it directly from object-space
785 /// coordinates is only correct for an identity placement. Pull the RTC
786 /// vector through the placement's inverse linear transform so the later
787 /// placement produces `M(p) - rtc` for rotated/scaled elements as well.
788 /// `None` when the item is not a raw-coordinate item beyond the RTC
789 /// threshold; the ordinary item path then handles it.
790 pub(in crate::router) fn process_raw_item_for_element(
791 &self,
792 item: &DecodedEntity,
793 element: &DecodedEntity,
794 decoder: &mut EntityDecoder,
795 ) -> Result<Option<Mesh>> {
796 if !self.has_rtc_offset()
797 || !self
798 .representation_item_first_vertex_meters(item, decoder)
799 .is_some_and(crate::coord_is_large)
800 {
801 return Ok(None);
802 }
803 if self.processors.get(&item.ifc_type, self.schema).is_none() {
804 return Ok(None);
805 }
806 let Some(offset) = self.element_frame_rtc(element, decoder)? else {
807 return Ok(None);
808 };
809 // The ordinary item path, in the element's frame: it keeps content
810 // dedup and direct-solid instancing (keyed by the offset), and a
811 // declined rebase is decided in the right frame too (#5684).
812 self.process_representation_item_in_frame(item, decoder, offset).map(Some)
813 }
814
815 /// The model RTC offset expressed in `element`'s object frame (metres):
816 /// pulled through the placement's inverse linear transform, so placing
817 /// a mesh rebased by it yields `M(p) - rtc`. `None` for a singular
818 /// placement, where no object-frame offset exists.
819 fn element_frame_rtc(
820 &self,
821 element: &DecodedEntity,
822 decoder: &mut EntityDecoder,
823 ) -> Result<Option<(f64, f64, f64)>> {
824 let mut placement = self.get_placement_transform_from_element(element, decoder)?;
825 self.scale_transform(&mut placement);
826 let linear = placement.fixed_view::<3, 3>(0, 0).into_owned();
827 Ok(linear.try_inverse().map(|inverse| {
828 let rtc = inverse
829 * nalgebra::Vector3::new(self.rtc_offset.0, self.rtc_offset.1, self.rtc_offset.2);
830 (rtc.x, rtc.y, rtc.z)
831 }))
832 }
833
834 /// Add a textured `IfcTriangulatedFaceSet` as its own UV-carrying
835 /// sub-mesh (#1781), rebased by `offset_meters` (item frame) when that
836 /// preserves precision (#5698). `false` when the textured build fails or
837 /// is empty, so the caller falls through to the plain path.
838 fn add_textured_face_set(
839 &self,
840 item: &DecodedEntity,
841 decoder: &mut EntityDecoder,
842 map: &crate::processors::texture::ResolvedTextureMap,
843 offset_meters: Option<(f64, f64, f64)>,
844 sub_meshes: &mut SubMeshCollection,
845 ) -> bool {
846 // A terrain TIN is a face set that only appends `Flags` (#5942).
847 if !matches!(item.ifc_type, IfcType::IfcTriangulatedFaceSet | IfcType::IfcTriangulatedIrregularNetwork) {
848 return false;
849 }
850 let rtc = offset_meters.and_then(|offset| {
851 self.raw_item_rtc_file_units(item, decoder, offset)
852 .zip(Some(offset))
853 });
854 let proc = crate::processors::TriangulatedFaceSetProcessor::new();
855 let Ok((mut mesh, uvs)) = proc.process_with_texture_rebased(
856 item,
857 decoder,
858 map,
859 rtc.map(|(file_units, _)| file_units),
860 ) else {
861 return false;
862 };
863 if mesh.is_empty() {
864 return false;
865 }
866 self.scale_mesh(&mut mesh); // UVs are unaffected by scale
867 if let Some((_, offset)) = rtc {
868 publish_object_frame_bounds(&mut mesh, offset);
869 }
870 sub_meshes.add_textured(item.id, mesh, uvs, map.attachment());
871 true
872 }
873
874 /// Mesh one item, rebasing it by `offset_meters` (model RTC expressed in
875 /// the item's own coordinate frame) before f32 narrowing.
876 ///
877 /// The rebase runs only when it reduces the item's coordinate magnitude
878 /// (#5684: site-local vertices kilometres from a national-grid site must
879 /// stay local) and the processor implements
880 /// [`GeometryProcessor::process_in_rtc_frame`]. Every built-in
881 /// raw-coordinate processor does; they subtract in f64 so detail below one
882 /// f32 ULP at national-grid magnitude survives (#5698). Otherwise the
883 /// ordinary output keeps its object frame and final placement applies RTC
884 /// in f64: shifting already-f32 output cannot recover precision.
885 ///
886 /// Returns the unit-scaled mesh. A rebased mesh carries `rtc_applied`
887 /// and object-frame `local_bounds`.
888 fn process_item_in_rtc_frame(
889 &self,
890 processor: &dyn GeometryProcessor,
891 item: &DecodedEntity,
892 decoder: &mut EntityDecoder,
893 offset_meters: (f64, f64, f64),
894 ) -> Result<Mesh> {
895 let rebased = self
896 .raw_item_rtc_file_units(item, decoder, offset_meters)
897 .and_then(|rtc_file_units| {
898 processor.process_in_rtc_frame(
899 item,
900 decoder,
901 self.schema,
902 self.tessellation_quality,
903 rtc_file_units,
904 )
905 });
906 let rtc_applied = rebased.is_some();
907 let mut mesh = match rebased {
908 Some(result) => result?,
909 None => processor.process(item, decoder, self.schema, self.tessellation_quality)?,
910 };
911 // Safety net: strip any out-of-bounds indices before downstream use
912 mesh.validate_indices();
913 self.scale_mesh(&mut mesh);
914 if rtc_applied {
915 publish_object_frame_bounds(&mut mesh, offset_meters);
916 }
917 Ok(mesh)
918 }
919
920 /// `offset_meters` in file units when rebasing `item` by it before f32
921 /// narrowing reduces the item's coordinate magnitude; `None` when there
922 /// is no model RTC or the item is not raw-coordinate geometry (#5684).
923 fn raw_item_rtc_file_units(
924 &self,
925 item: &DecodedEntity,
926 decoder: &mut EntityDecoder,
927 offset_meters: (f64, f64, f64),
928 ) -> Option<(f64, f64, f64)> {
929 (self.has_rtc_offset()
930 && self.representation_item_benefits_from_rtc(item, decoder, offset_meters))
931 .then(|| {
932 (
933 offset_meters.0 / self.unit_scale,
934 offset_meters.1 / self.unit_scale,
935 offset_meters.2 / self.unit_scale,
936 )
937 })
938 }
939
940 fn process_representation_item_body(
941 &self,
942 item: &DecodedEntity,
943 decoder: &mut EntityDecoder,
944 offset_meters: (f64, f64, f64),
945 ) -> Result<Mesh> {
946 // MappedItem has its own instancing cache (the source representation is
947 // already shared), so it never enters the structural-hash path. It also
948 // sets its own instance_meta, so the direct-solid tagging below is skipped.
949 if item.ifc_type == IfcType::IfcMappedItem {
950 return self.process_mapped_item_cached(item, decoder);
951 }
952
953 // `None` ⇒ dedup disabled (no hash overhead). On a hit, clone the cached
954 // item mesh and stamp its STORED rep_identity (no per-occurrence re-hash);
955 // meshing is skipped entirely.
956 let dedup_key = self.item_dedup_key_in_frame(item, decoder, offset_meters);
957 if let (Some(key), Some(cache)) = (dedup_key, self.item_dedup_cache.as_ref()) {
958 let hit = cache
959 .meshes
960 .lock()
961 .unwrap_or_else(|e| e.into_inner())
962 .get(&key)
963 .cloned();
964 if let Some(entry) = hit {
965 let (mesh, rep) = (entry.0.clone(), entry.1);
966 return Ok(self.stamp_direct_instance(mesh, rep));
967 }
968 }
969
970 // #4083 (double-count half only — see the module-level cross-reference
971 // below): snapshot the item's processor's own failure log BEFORE the
972 // uncached build, so any record it adds during THIS call can be
973 // attributed to `dedup_key` and, if a racing router sharing this cache
974 // already claimed that key, retracted below. `None` when the item's
975 // type has no registered processor (nothing to snapshot, nothing to
976 // retract) or dedup is disabled (`dedup_key` is `None`).
977 let failure_mark = dedup_key.and_then(|_| {
978 self.processors
979 .get(&item.ifc_type, self.schema)
980 .map(|p| p.bool_failure_count())
981 });
982
983 // The curve-capped flag (#4901) is drained by the public
984 // `process_representation_item` wrapper around this whole function,
985 // not here — see its doc comment for why (mapped items bypass this
986 // uncached path entirely).
987 let mesh = self.process_representation_item_uncached(item, decoder, offset_meters)?;
988
989 // If this call's processor recorded anything new, decide whether THIS
990 // router keeps it: the item-dedup cache's `diagnostic_claimed` set
991 // (shared with every router built from the same
992 // `enable_content_dedup_shared` cache) lets exactly one racing router
993 // keep the diagnostic for one `item_dedup_key`; every other one
994 // retracts its own copy of the same logical operation's record.
995 //
996 // Fixes ONLY the double-count half of #4083: two racing MISSES each
997 // computing (and each initially recording) the same tear now collapse
998 // to one record. Does NOT fix the omission half — a cache HIT never
999 // reaches this code at all (it returns early above, before
1000 // `process_representation_item_uncached` runs), so it still reports
1001 // zero diagnostics regardless of what the warm MISS recorded.
1002 if let (Some(key), Some(before), Some(cache)) =
1003 (dedup_key, failure_mark, self.item_dedup_cache.as_ref())
1004 {
1005 if let Some(processor) = self.processors.get(&item.ifc_type, self.schema) {
1006 if processor.bool_failure_count() > before && !cache.claim_diagnostic(key) {
1007 processor.truncate_bool_failures_to(before);
1008 }
1009 }
1010 }
1011
1012 // Compute the instancing rep_identity ONCE for this unique shape so cache
1013 // hits can reuse it instead of re-hashing the full mesh per occurrence.
1014 let rep = self.direct_rep_identity(&mesh);
1015
1016 // Cache the freshly-meshed item under its structural hash. Two exclusions:
1017 // - empty meshes (unsupported/degenerate geometry);
1018 // - results produced once the per-element CSG budget has tripped. On a
1019 // trip the boolean bails and `subtract_mesh` returns the UNCUT host
1020 // (records `OperandTooLarge`); since the dedup key is budget-independent
1021 // (structure/quality/scale/RTC), caching that fallback would serve the
1022 // wrong (uncut) mesh to later identical booleans in a fresh-budget
1023 // element (`budget::begin_element()` resets per element). Correctness of
1024 // the cut wins over deduping a degraded result. (#1257 review P1.)
1025 if let (Some(key), Some(cache)) = (dedup_key, self.item_dedup_cache.as_ref()) {
1026 if !mesh.positions.is_empty() && !crate::kernel::budget::tripped() {
1027 // Clone into the Arc BEFORE locking: a mesh deep-copy inside the
1028 // single-Mutex critical section serializes the pool on every miss.
1029 let cached = Arc::new((mesh.clone(), rep));
1030 cache
1031 .meshes
1032 .lock()
1033 .unwrap_or_else(|e| e.into_inner())
1034 .insert(key, cached);
1035 }
1036 }
1037
1038 Ok(self.stamp_direct_instance(mesh, rep))
1039 }
1040
1041 /// Compute the direct-solid instancing `rep_identity` for a freshly-built,
1042 /// pre-placement item mesh, or `None` when instancing is off / the mesh is
1043 /// empty / it already carries metadata (mapped items). FULL 128-bit
1044 /// (non-sampling) hash: rep_identity has no downstream meshes_equal guard at
1045 /// the source and must be cross-worker consistent, so a sampled-hash collision
1046 /// (#833 family) would silently group non-identical geometry; 128-bit makes
1047 /// that ~2^-127. Computed ONCE per unique shape — cache hits reuse the stored
1048 /// value via [`Self::stamp_direct_instance`] instead of re-hashing.
1049 fn direct_rep_identity(&self, mesh: &Mesh) -> Option<u128> {
1050 if instancing_enabled() && mesh.instance_meta.is_none() && !mesh.positions.is_empty() {
1051 Some(Self::compute_mesh_hash_full(mesh) | DIRECT_SOLID_TAG)
1052 } else {
1053 None
1054 }
1055 }
1056
1057 /// Stamp a direct-solid item mesh with a KNOWN `rep_identity` (no re-hash) so
1058 /// identical representations collate into a single template + per-occurrence
1059 /// transforms. `rep` comes from [`Self::direct_rep_identity`] on a fresh build
1060 /// or from the dedup cache on a hit; `None` is a no-op (instancing off / empty
1061 /// / already tagged).
1062 fn stamp_direct_instance(&self, mut mesh: Mesh, rep: Option<u128>) -> Mesh {
1063 if let Some(exact_rep) = rep {
1064 mesh.instance_meta = Some(InstanceMeta {
1065 transform: IDENTITY_ROW_MAJOR,
1066 local_transform: None,
1067 canonical_transform: None,
1068 rep_identity: exact_rep,
1069 instanceable: true,
1070 });
1071 }
1072 mesh
1073 }
1074
1075 /// The meshing body of [`Self::process_representation_item`] (everything except
1076 /// the MappedItem path and the content-dedup wrapper).
1077 fn process_representation_item_uncached(
1078 &self,
1079 item: &DecodedEntity,
1080 decoder: &mut EntityDecoder,
1081 offset_meters: (f64, f64, f64),
1082 ) -> Result<Mesh> {
1083 // Raw-coordinate items rebase in the model frame here; element
1084 // walkers take `process_raw_item_for_element` first for their own
1085 // items, so this frame matters for mapped and opening items.
1086 if let Some(processor) = self.processors.get(&item.ifc_type, self.schema) {
1087 let mesh =
1088 self.process_item_in_rtc_frame(processor.as_ref(), item, decoder, offset_meters)?;
1089
1090 // Deduplicate by hash - buildings with repeated floors have identical geometry
1091 if !mesh.positions.is_empty() {
1092 let cached = self.get_or_cache_by_hash(mesh);
1093 return Ok((*cached).clone());
1094 }
1095 return Ok(mesh);
1096 }
1097
1098 // No processor is registered for this type. Every `GeometryCategory`
1099 // that has a real implementation (SweptSolid, ExplicitMesh, Boolean) is
1100 // already caught by the processor lookup above; `MappedItem` never
1101 // reaches here (`process_representation_item` intercepts it first, see
1102 // `process_mapped_item_cached`). So landing here means the type is
1103 // genuinely unsupported, not merely "not implemented yet".
1104 Err(Error::geometry(format!(
1105 "Unsupported representation type: {}",
1106 item.ifc_type
1107 )))
1108 }
1109
1110 /// Run an `IfcAlignment` through the dedicated alignment processor, then
1111 /// apply the standard unit scale + placement transform. Returns `None`
1112 /// when the alignment has no recognisable directrix curve (the caller
1113 /// falls back to normal representation processing).
1114 fn try_alignment_mesh(
1115 &self,
1116 element: &DecodedEntity,
1117 decoder: &mut EntityDecoder,
1118 ) -> Result<Option<Mesh>> {
1119 let processor = match self.processors.get(&IfcType::IfcAlignment, self.schema) {
1120 Some(p) => Rc::clone(p),
1121 None => return Ok(None),
1122 };
1123 let mut mesh =
1124 match processor.process(element, decoder, self.schema, self.tessellation_quality) {
1125 Ok(m) => m,
1126 // Missing Axis or unparseable curve isn't fatal — fall back so
1127 // the caller can still walk a normal representation if present.
1128 Err(_) => return Ok(None),
1129 };
1130 if mesh.positions.is_empty() {
1131 return Ok(None);
1132 }
1133 mesh.validate_indices();
1134 self.scale_mesh(&mut mesh);
1135 self.apply_placement(element, decoder, &mut mesh)?;
1136 Ok(Some(mesh))
1137 }
1138
1139 /// Drain refs the content-hash walk refused above `u32::MAX` (#3421/#3752).
1140 pub fn take_content_hash_oversized_ref_drops(&self) -> usize {
1141 std::mem::take(&mut *self.content_hash_oversized_ref_drops.borrow_mut())
1142 }
1143}
1144
1145#[cfg(test)]
1146mod shared_cap_tests {
1147 /// `MAX_MAPPED_ITEM_DEPTH` must be the one in `ifc_lite_core::limits`, not
1148 /// a private copy that happens to hold the same number today.
1149 ///
1150 /// This is not redundant with the constant being shared. Sharing removes
1151 /// the drift that EXISTS; it does not stop anyone reintroducing a local
1152 /// `const MAX_MAPPED_ITEM_DEPTH` that shadows the import. Verified by
1153 /// mutation: with the import replaced by a private `= 16`, 800 tests stayed
1154 /// green until this test existed. A mid-review revision of #2864 held
1155 /// exactly that value against the router's 32, so the shadow is not a
1156 /// hypothetical shape.
1157 ///
1158 /// The assertion is on the VALUE, not on identity, so a private copy
1159 /// holding the same 32 still passes. That is the honest ceiling: Rust has
1160 /// no cheap const-identity check, and a same-value shadow is harmless until
1161 /// the shared value is next tuned, at which point this fires.
1162 #[test]
1163 fn mapped_item_depth_is_the_shared_constant() {
1164 assert_eq!(
1165 super::MAX_MAPPED_ITEM_DEPTH,
1166 ifc_lite_core::MAX_MAPPED_ITEM_DEPTH,
1167 "use the shared cap from ifc_lite_core::limits, not a private copy"
1168 );
1169 }
1170}