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}