Skip to main content

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::transforms::{instancing_enabled, mat4_to_row_major};
8use super::GeometryRouter;
9use crate::{Error, InstanceMeta, Mesh, Result, SubMeshCollection};
10
11/// High tag bit distinguishing direct-solid rep_identity (a 128-bit local-mesh
12/// content hash) from mapped-item rep_identity (a RepresentationMap entity id,
13/// always < 2^32), so the two id spaces can never collide in `collate_instances`.
14/// Bit 127 is set on direct-solid ids and clear on mapped ids; it costs one hash
15/// bit (127 effective), still content-addressing grade.
16const DIRECT_SOLID_TAG: u128 = 1u128 << 127;
17
18/// Row-major 4x4 identity; placeholder `InstanceMeta::transform` before the
19/// element's world placement is folded in by `apply_placement`.
20const IDENTITY_ROW_MAJOR: [f64; 16] = [
21    1.0, 0.0, 0.0, 0.0, //
22    0.0, 1.0, 0.0, 0.0, //
23    0.0, 0.0, 1.0, 0.0, //
24    0.0, 0.0, 0.0, 1.0, //
25];
26use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
27use rustc_hash::FxHashSet;
28use std::sync::Arc;
29
30/// Maximum nested IfcMappedItem depth we will traverse for a single geometry item.
31const MAX_MAPPED_ITEM_DEPTH: usize = 32;
32
33impl GeometryRouter {
34    /// Process building element (IfcWall, IfcBeam, etc.) into mesh
35    /// Follows the representation chain:
36    /// Element → Representation → ShapeRepresentation → Items
37    #[inline]
38    pub fn process_element(
39        &self,
40        element: &DecodedEntity,
41        decoder: &mut EntityDecoder,
42    ) -> Result<Mesh> {
43        // IfcAlignment carries its directrix curve in a dedicated `Axis`
44        // attribute (IFC4X1) instead of (or in addition to) a normal
45        // IfcShapeRepresentation. Route those through the alignment
46        // processor before the standard representation walk, since the
47        // Representation is often `$` in practice.
48        if element.ifc_type == IfcType::IfcAlignment {
49            if let Some(mesh) = self.try_alignment_mesh(element, decoder)? {
50                return Ok(mesh);
51            }
52        }
53
54        // Get representation (attribute 6 for most building elements)
55        // IfcProduct: GlobalId, OwnerHistory, Name, Description, ObjectType, ObjectPlacement, Representation, Tag
56        let representation_attr = element.get(6).ok_or_else(|| {
57            Error::geometry(format!(
58                "Element #{} has no representation attribute",
59                element.id
60            ))
61        })?;
62
63        if representation_attr.is_null() {
64            return Ok(Mesh::new()); // No geometry
65        }
66
67        let representation = decoder
68            .resolve_ref(representation_attr)?
69            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
70
71        // IfcProductDefinitionShape has Representations attribute (list of IfcRepresentation)
72        if representation.ifc_type != IfcType::IfcProductDefinitionShape {
73            return Err(Error::geometry(format!(
74                "Expected IfcProductDefinitionShape, got {}",
75                representation.ifc_type
76            )));
77        }
78
79        // Get representations list (attribute 2)
80        let representations_attr = representation.get(2).ok_or_else(|| {
81            Error::geometry("IfcProductDefinitionShape missing Representations".to_string())
82        })?;
83
84        let representations = decoder.resolve_ref_list(representations_attr)?;
85
86        // Process all representations and merge meshes
87        let mut combined_mesh = Mesh::new();
88
89        // Instancing: an element is cleanly shareable only when its whole body is
90        // exactly ONE representation item that itself carried instance metadata
91        // (a mapped item). `Mesh::merge` does not propagate the side-channel, so we
92        // capture the single item's metadata here and re-attach it below; any second
93        // item disqualifies the element (left as None -> rendered flat).
94        let mut single_instance_meta: Option<InstanceMeta> = None;
95        let mut instanceable_item_count: usize = 0;
96
97        // First pass: check if we have any direct geometry representations
98        // This prevents duplication when both direct and MappedRepresentation exist
99        let has_direct_geometry = representations.iter().any(|rep| {
100            rep.ifc_type == IfcType::IfcShapeRepresentation
101                && super::effective_rep_type(rep)
102                    .map(super::is_direct_body_representation)
103                    .unwrap_or(false)
104        });
105
106        for shape_rep in representations {
107            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
108                continue;
109            }
110
111            // Check the effective representation type (RepresentationType, falling
112            // back to RepresentationIdentifier when the type is blank - #1661).
113            // Skip 'Axis', 'Curve2D', 'FootPrint', etc. - only process 'Body', 'SweptSolid', 'Brep', etc.
114            if let Some(rep_type) = super::effective_rep_type(&shape_rep) {
115                // Skip MappedRepresentation if we already have direct geometry
116                // This prevents duplication when an element has both direct and mapped representations
117                if rep_type == "MappedRepresentation" && has_direct_geometry {
118                    continue;
119                }
120
121                // Only process solid/surface geometry representations
122                if !super::is_body_representation(rep_type) {
123                    continue; // Skip non-solid representations like 'Axis', 'Curve2D', etc.
124                }
125            }
126
127            // Get items list (attribute 3)
128            let items_attr = shape_rep.get(3).ok_or_else(|| {
129                Error::geometry("IfcShapeRepresentation missing Items".to_string())
130            })?;
131
132            let items = decoder.resolve_ref_list(items_attr)?;
133
134            // Process each representation item
135            for item in items {
136                let mesh = self.process_representation_item(&item, decoder)?;
137                if instancing_enabled() && !mesh.positions.is_empty() {
138                    instanceable_item_count += 1;
139                    single_instance_meta = if instanceable_item_count == 1 {
140                        mesh.instance_meta.clone()
141                    } else {
142                        None
143                    };
144                }
145                combined_mesh.merge(&mesh);
146            }
147        }
148
149        // Re-attach single-item instance metadata so apply_placement can fold the
150        // element's world placement into `transform`.
151        if instancing_enabled() {
152            combined_mesh.instance_meta = single_instance_meta;
153        }
154
155        // Mesh hygiene before placement (rigid transform preserves geometry, so
156        // welding/dropping in local coords is identical and uses smaller f32
157        // magnitudes). Single chokepoint downstream of every per-item branch,
158        // incl. CSG output — restores the cleanup #1024 lost with Manifold:
159        // redundant/coincident source vertices that otherwise triangulate into
160        // visible needle spikes and jagged silhouettes. See clean_degenerate.
161        combined_mesh.clean_degenerate();
162
163        // Apply placement transformation
164        self.apply_placement(element, decoder, &mut combined_mesh)?;
165
166        Ok(combined_mesh)
167    }
168
169    /// Process element and return sub-meshes with their geometry item IDs.
170    /// This preserves per-item identity for color/style lookup.
171    ///
172    /// For elements with multiple styled geometry items (like windows with frames + glass),
173    /// this returns separate sub-meshes that can receive different colors.
174    pub fn process_element_with_submeshes(
175        &self,
176        element: &DecodedEntity,
177        decoder: &mut EntityDecoder,
178    ) -> Result<SubMeshCollection> {
179        // Public entry: the ordinary (non-void) element path, so the #1623 Phase 2
180        // don't-bake instancing is allowed here. The void path
181        // (`process_element_with_submeshes_and_voids`) calls the impl below with
182        // `allow_instancing = false` — a voided occurrence must materialize its cut
183        // geometry, never instance an un-cut shared template.
184        self.process_element_with_submeshes_impl(element, decoder, true, None)
185    }
186
187    /// [`Self::process_element_with_submeshes`] with an explicit don't-bake gate.
188    /// `allow_instancing` is `true` only on the ordinary (non-void) path; the void
189    /// path passes `false` so its occurrences always materialize. The don't-bake
190    /// additionally requires an armed [`GeometryRouter::enable_output_instancing`]
191    /// plan, so with no plan this is byte-identical to the historical flat path.
192    /// `texture_index` is `Some` only on the textured non-void path (#1781).
193    pub(super) fn process_element_with_submeshes_impl(
194        &self,
195        element: &DecodedEntity,
196        decoder: &mut EntityDecoder,
197        allow_instancing: bool,
198        texture_index: Option<
199            &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
200        >,
201    ) -> Result<SubMeshCollection> {
202        // If a material-layer buildup is attached, try slicing single-solid
203        // elements (walls / slabs with IfcMaterialLayerSetUsage) first so each
204        // layer gets its own sub-mesh keyed by IfcMaterial id. An empty void
205        // index is passed — the caller's has_openings branch takes the
206        // voids-aware path below.
207        if let Some(layered) = self.try_layered_sub_meshes(element, decoder, None) {
208            return Ok(layered);
209        }
210
211        // Get representation (attribute 6 for most building elements)
212        let representation_attr = element.get(6).ok_or_else(|| {
213            Error::geometry(format!(
214                "Element #{} has no representation attribute",
215                element.id
216            ))
217        })?;
218
219        if representation_attr.is_null() {
220            return Ok(SubMeshCollection::new()); // No geometry
221        }
222
223        let representation = decoder
224            .resolve_ref(representation_attr)?
225            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
226
227        if representation.ifc_type != IfcType::IfcProductDefinitionShape {
228            return Err(Error::geometry(format!(
229                "Expected IfcProductDefinitionShape, got {}",
230                representation.ifc_type
231            )));
232        }
233
234        // Get representations list (attribute 2)
235        let representations_attr = representation.get(2).ok_or_else(|| {
236            Error::geometry("IfcProductDefinitionShape missing Representations".to_string())
237        })?;
238
239        let representations = decoder.resolve_ref_list(representations_attr)?;
240
241        let mut sub_meshes = SubMeshCollection::new();
242
243        // Check if we have direct geometry
244        let has_direct_geometry = representations.iter().any(|rep| {
245            rep.ifc_type == IfcType::IfcShapeRepresentation
246                && super::effective_rep_type(rep)
247                    .map(super::is_direct_body_representation)
248                    .unwrap_or(false)
249        });
250
251        for shape_rep in representations {
252            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
253                continue;
254            }
255
256            if let Some(rep_type) = super::effective_rep_type(&shape_rep) {
257                // Skip MappedRepresentation if we have direct geometry
258                if rep_type == "MappedRepresentation" && has_direct_geometry {
259                    continue;
260                }
261
262                // Only process solid/surface geometry representations
263                if !super::is_body_representation(rep_type) {
264                    continue;
265                }
266            }
267
268            // Get items list (attribute 3)
269            let items_attr = shape_rep.get(3).ok_or_else(|| {
270                Error::geometry("IfcShapeRepresentation missing Items".to_string())
271            })?;
272
273            let items = decoder.resolve_ref_list(items_attr)?;
274
275            // Process each representation item, preserving geometry IDs
276            for item in items {
277                self.collect_submeshes_from_item(
278                    &item,
279                    decoder,
280                    &mut sub_meshes,
281                    allow_instancing,
282                    texture_index,
283                )?;
284            }
285        }
286
287        // Mesh hygiene before placement — same chokepoint as process_element,
288        // applied per sub-mesh for the multi-item (per-style) channel. Rigid
289        // placement preserves geometry, so order is immaterial. (The layered
290        // and textured channels are cleaned at their own sites:
291        // try_layered_sub_meshes and process_representation_map_with_texture.)
292        for sub in &mut sub_meshes.sub_meshes {
293            sub.mesh.clean_degenerate();
294        }
295
296        self.apply_submesh_placement(&mut sub_meshes, element, decoder)?;
297        Ok(sub_meshes)
298    }
299
300    /// Collect sub-meshes from a representation item, following MappedItem references.
301    /// `allow_instancing` enables the #1623 Phase 2 don't-bake path at the top-level
302    /// mapped item (see [`Self::collect_submeshes_from_item_inner`]).
303    fn collect_submeshes_from_item(
304        &self,
305        item: &DecodedEntity,
306        decoder: &mut EntityDecoder,
307        sub_meshes: &mut SubMeshCollection,
308        allow_instancing: bool,
309        texture_index: Option<
310            &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
311        >,
312    ) -> Result<()> {
313        let mut visited = FxHashSet::default();
314        self.collect_submeshes_from_item_inner(
315            item,
316            decoder,
317            sub_meshes,
318            0,
319            &mut visited,
320            allow_instancing,
321            texture_index,
322        )
323    }
324
325    #[allow(clippy::too_many_arguments)] // internal recursion carries per-walk state
326    fn collect_submeshes_from_item_inner(
327        &self,
328        item: &DecodedEntity,
329        decoder: &mut EntityDecoder,
330        sub_meshes: &mut SubMeshCollection,
331        depth: usize,
332        visited: &mut FxHashSet<u32>,
333        allow_instancing: bool,
334        texture_index: Option<
335            &rustc_hash::FxHashMap<u32, crate::processors::texture::ResolvedTextureMap>,
336        >,
337    ) -> Result<()> {
338        if depth >= MAX_MAPPED_ITEM_DEPTH {
339            return Err(Error::geometry(format!(
340                "MappedItem nesting exceeded maximum depth of {} at #{}",
341                MAX_MAPPED_ITEM_DEPTH, item.id
342            )));
343        }
344
345        // For MappedItem, recurse into the mapped representation
346        if item.ifc_type == IfcType::IfcMappedItem {
347            if !visited.insert(item.id) {
348                return Err(Error::geometry(format!(
349                    "Detected cyclic IfcMappedItem reference at #{}",
350                    item.id
351                )));
352            }
353
354            // Get MappingSource (RepresentationMap)
355            let source_attr = item
356                .get(0)
357                .ok_or_else(|| Error::geometry("MappedItem missing MappingSource".to_string()))?;
358
359            let source_entity = decoder
360                .resolve_ref(source_attr)?
361                .ok_or_else(|| Error::geometry("Failed to resolve MappingSource".to_string()))?;
362            let source_id = source_entity.id;
363
364            // Get MappedRepresentation from RepresentationMap (attribute 1)
365            let mapped_repr_attr = source_entity.get(1).ok_or_else(|| {
366                Error::geometry("RepresentationMap missing MappedRepresentation".to_string())
367            })?;
368
369            let mapped_repr = decoder.resolve_ref(mapped_repr_attr)?.ok_or_else(|| {
370                Error::geometry("Failed to resolve MappedRepresentation".to_string())
371            })?;
372
373            // MappingTarget · MappingOrigin (#1985: the origin used to be dropped).
374            let mapping_transform = self.mapped_item_transform(item, &source_entity, decoder)?;
375
376            // #1623 Phase 2/3 "don't-bake": if this top-level mapped item's source is
377            // a REPEATED (count >= 2) single-solid `IfcRepresentationMap` the armed
378            // plan lists, exactly ONE occurrence (the "template") materializes its
379            // geometry; every OTHER occurrence skips the per-occurrence vertex clone /
380            // MappingTarget bake / weld and emits an instance-only placeholder (empty
381            // geometry carrying the mapping transform + rep_identity in `InstanceMeta`).
382            // `apply_submesh_placement` folds the world placement into `im.transform`;
383            // the finalize turns the placeholder into an occurrence against the template.
384            //
385            // `instance_solid_id` is the nested SOLID's id (used as the placeholder's
386            // geometry_id so colour resolves EXACTLY as the flat/template sub-mesh).
387            // Only fires at the TOP level (`depth == 0`) — a mapped item nested inside
388            // another map is part of its parent's shared geometry, not an independent
389            // occurrence — and only when `allow_instancing` (the non-void path). With
390            // no armed plan this is skipped entirely, so the flat output is unchanged.
391            let instance_solid_id: Option<u32> = if allow_instancing && depth == 0 {
392                self.output_instancing_plan()
393                    .and_then(|plan| plan.get(&source_id).copied())
394                    .filter(|&(count, _)| count >= 2)
395                    .and_then(|_| {
396                        self.mapped_source_single_item(&mapped_repr, decoder)
397                            // #858: a source whose single solid carries an
398                            // IfcIndexedColourMap must materialize flat so
399                            // emit_sub_meshes can split it into one mesh per palette
400                            // group. An instance placeholder resolves ONE colour,
401                            // collapsing the palette (WRONG vs the flat path); route
402                            // to flat instead (byte-identical to instancing-off).
403                            .filter(|&item_id| !self.is_indexed_colour_split_source(item_id))
404                            // #1781: same rule for a TEXTURED single solid — an
405                            // instance placeholder carries no UVs/texture, so the
406                            // occurrence would render untextured. Materialize flat.
407                            .filter(|&item_id| {
408                                texture_index.is_none_or(|ti| !ti.contains_key(&item_id))
409                            })
410                    })
411            } else {
412                None
413            };
414            // Which occurrence MATERIALIZES the template. Native (global) mode: the
415            // plan's deterministic min-id occurrence, so all occurrences resolve
416            // against ONE model-wide template across the rayon pool. WASM batch-local
417            // mode: the FIRST occurrence of this source seen by this router/batch (the
418            // rest don't-bake), so each per-batch shard is self-contained. Both emit
419            // geometrically identical world triangles.
420            let is_template = match instance_solid_id {
421                None => true, // not eligible ⇒ materialize flat as usual
422                Some(_) if self.instancing_batch_local() => {
423                    self.mark_source_materialized_if_first(source_id)
424                }
425                Some(_) => {
426                    let template_item_id = self
427                        .output_instancing_plan()
428                        .and_then(|plan| plan.get(&source_id))
429                        .map(|&(_, t)| t)
430                        .unwrap_or(item.id);
431                    item.id == template_item_id
432                }
433            };
434            if let Some(solid_item_id) = instance_solid_id {
435                if !is_template {
436                    // NON-template occurrence: don't-bake. Ensure the shared registry
437                    // holds the source geometry (meshed once model-wide) so the
438                    // finalize can recover geometry even in the (effectively
439                    // unreachable) case that the template occurrence never
440                    // materialized, then push the instance-only placeholder. Its
441                    // geometry_id is the nested SOLID's id (not the mapped-item id) so
442                    // emit_sub_meshes resolves the occurrence colour identically to the
443                    // flat/template sub-mesh.
444                    self.ensure_shared_mapped_source(&mapped_repr, source_id, decoder);
445                    let local_rm = mapping_transform.map(|mut t| {
446                        self.scale_transform(&mut t);
447                        mat4_to_row_major(&t)
448                    });
449                    let mut placeholder = Mesh::new();
450                    placeholder.instance_meta = Some(InstanceMeta {
451                        transform: IDENTITY_ROW_MAJOR,
452                        local_transform: local_rm,
453                        canonical_transform: None,
454                        rep_identity: source_id as u128,
455                        instanceable: true,
456                    });
457                    // Push directly (SubMeshCollection::add drops empty meshes; this
458                    // placeholder is intentionally empty — its InstanceMeta is the payload).
459                    sub_meshes
460                        .sub_meshes
461                        .push(crate::SubMesh::new(solid_item_id, placeholder));
462                    visited.remove(&item.id);
463                    return Ok(());
464                }
465            }
466            // Record where THIS mapped item's sub-meshes start, so the don't-bake
467            // TEMPLATE occurrence can be re-tagged with the source-id rep_identity
468            // after the normal materialize below (see the retag after the loop).
469            let mapped_items_start = sub_meshes.len();
470
471            // Get items from the mapped representation
472            if let Some(items_attr) = mapped_repr.get(3) {
473                let items = decoder.resolve_ref_list(items_attr)?;
474                for nested_item in items {
475                    // Recursively collect sub-meshes (skip unsupported geometry types).
476                    // Nested items never independently don't-bake (`allow_instancing =
477                    // false`): they are this occurrence's own shared geometry.
478                    let count_before = sub_meshes.len();
479                    if let Err(_e) = self.collect_submeshes_from_item_inner(
480                        &nested_item,
481                        decoder,
482                        sub_meshes,
483                        depth + 1,
484                        visited,
485                        false,
486                        texture_index,
487                    ) {
488                        crate::diag::diag_debug!(
489                            { item_id = nested_item.id, ifc_type = ?nested_item.ifc_type,
490                              error = %_e, "skipping unsupported nested geometry item" }
491                            else {
492                                #[cfg(debug_assertions)]
493                                eprintln!(
494                                    "[ifc-lite] Skipping unsupported nested geometry #{} ({:?}): {}",
495                                    nested_item.id, nested_item.ifc_type, _e
496                                );
497                            }
498                        );
499                        continue;
500                    }
501
502                    // Apply MappedItem transform to newly added sub-meshes.
503                    if let Some(mut transform) = mapping_transform {
504                        self.scale_transform(&mut transform);
505                        // The MappingTarget is a PER-OCCURRENCE transform: baked into the
506                        // vertices here (flat output byte-for-byte unchanged), and for
507                        // INSTANCING recorded in `local_transform` (keeping the canonical,
508                        // pre-target `rep_identity`) — mirroring `process_mapped_item_cached`
509                        // and the don't-bake TEMPLATE re-tag below — so occurrences sharing a
510                        // map but differing by target collate under one template. Previously
511                        // this RE-HASHED into `rep_identity`, giving every target a unique id
512                        // and disabling instancing (GLB export #1443) for the MULTI-item class
513                        // Phase 2 leaves flat (Tekla assemblies / MEP / metering skids). #1623
514                        let nontrivial_target = !transform.is_identity(1e-9);
515                        for sub in &mut sub_meshes.sub_meshes[count_before..] {
516                            self.transform_mesh_local(&mut sub.mesh, &transform);
517                            if nontrivial_target {
518                                if let Some(im) =
519                                    sub.mesh.instance_meta.as_mut().filter(|im| im.instanceable)
520                                {
521                                    im.local_transform = Some(match im.local_transform {
522                                        // Nested map: outer target ∘ inner, bake order.
523                                        Some(inner) => mat4_to_row_major(
524                                            &(transform * nalgebra::Matrix4::from_row_slice(&inner)),
525                                        ),
526                                        None => mat4_to_row_major(&transform),
527                                    });
528                                }
529                            }
530                        }
531                    }
532                }
533            }
534
535            // #1623 Phase 2/3: this is the don't-bake TEMPLATE occurrence. It
536            // materialized normally above (byte-identical to a flat occurrence — a
537            // single-solid source ⇒ exactly one sub-mesh). Re-tag its `rep_identity`
538            // to the source id and record the (scaled) MappingTarget as
539            // `local_transform`, MATCHING the instance placeholders so the finalize
540            // collates them onto this template. The baked geometry is untouched — the
541            // MappingTarget is already folded into both the vertices AND
542            // `local_transform`, which is consistent (the template's world geometry is
543            // `transform · local_transform · source`, so `m_ref` recovers the same
544            // `source` the placeholders reference). See the finalize in processor/mod.rs.
545            if instance_solid_id.is_some() && is_template {
546                let local_rm = mapping_transform.map(|mut t| {
547                    self.scale_transform(&mut t);
548                    mat4_to_row_major(&t)
549                });
550                for sub in &mut sub_meshes.sub_meshes[mapped_items_start..] {
551                    if let Some(im) = sub.mesh.instance_meta.as_mut() {
552                        im.rep_identity = source_id as u128;
553                        im.local_transform = local_rm;
554                    }
555                }
556            }
557
558            visited.remove(&item.id);
559        } else {
560            // Textured tessellated face set (#1781): mesh with per-vertex UVs so
561            // the occurrence path renders its image like the type-geometry path
562            // (#961) always did. Bypasses the content-dedup cache — the cached
563            // mesh has no UV channel, and UVs are per-face-set anyway. Falls
564            // through to the plain path if the textured build fails.
565            if item.ifc_type == IfcType::IfcTriangulatedFaceSet {
566                if let Some(map) = texture_index.and_then(|ti| ti.get(&item.id)) {
567                    let proc = crate::processors::TriangulatedFaceSetProcessor::new();
568                    if let Ok((mut mesh, uvs)) = proc.process_with_texture(item, decoder, map) {
569                        if !mesh.is_empty() {
570                            self.scale_mesh(&mut mesh); // UVs are unaffected by scale
571                            sub_meshes.add_textured(item.id, mesh, uvs, map.attachment());
572                            return Ok(());
573                        }
574                    }
575                }
576            }
577            // Regular geometry item - process and record with its ID
578            // Skip unsupported geometry types (e.g. IfcGeometricSet) instead of failing
579            match self.process_representation_item(item, decoder) {
580                Ok(mesh) => {
581                    if !mesh.is_empty() {
582                        sub_meshes.add(item.id, mesh);
583                    }
584                }
585                Err(_e) => {
586                    crate::diag::diag_debug!(
587                        { item_id = item.id, ifc_type = ?item.ifc_type, error = %_e,
588                          "skipping unsupported geometry item" }
589                        else {
590                            #[cfg(debug_assertions)]
591                            eprintln!(
592                                "[ifc-lite] Skipping unsupported geometry #{} ({:?}): {}",
593                                item.id, item.ifc_type, _e
594                            );
595                        }
596                    );
597                }
598            }
599        }
600
601        Ok(())
602    }
603
604    /// Process a single representation item (IfcExtrudedAreaSolid, etc.), with
605    /// content-dedup: a 128-bit structural hash of the item subtree skips the
606    /// meshing + CSG for geometry byte-identical to an item meshed earlier (e.g.
607    /// the thousands of Tekla connection plates/bolts an exporter failed to share
608    /// via `IfcMappedItem`). The cached mesh is colour-free and pre-placement; the
609    /// caller keeps this item's own `geometry_id` (so colour/palette/texture stay
610    /// per-instance) and applies voids + placement afterwards, so a cache hit is
611    /// indistinguishable from a fresh build.
612    #[inline]
613    pub fn process_representation_item(
614        &self,
615        item: &DecodedEntity,
616        decoder: &mut EntityDecoder,
617    ) -> Result<Mesh> {
618        // MappedItem has its own instancing cache (the source representation is
619        // already shared), so it never enters the structural-hash path. It also
620        // sets its own instance_meta, so the direct-solid tagging below is skipped.
621        if item.ifc_type == IfcType::IfcMappedItem {
622            return self.process_mapped_item_cached(item, decoder);
623        }
624
625        // `None` ⇒ dedup disabled (no hash overhead). On a hit, clone the cached
626        // item mesh and stamp its STORED rep_identity (no per-occurrence re-hash);
627        // meshing is skipped entirely.
628        let dedup_key = self.item_dedup_key(item, decoder);
629        if let (Some(key), Some(cache)) = (dedup_key, self.item_dedup_cache.as_ref()) {
630            let hit = cache
631                .lock()
632                .unwrap_or_else(|e| e.into_inner())
633                .get(&key)
634                .cloned();
635            if let Some(entry) = hit {
636                let (mesh, rep) = (entry.0.clone(), entry.1);
637                return Ok(self.stamp_direct_instance(mesh, rep));
638            }
639        }
640
641        let mesh = self.process_representation_item_uncached(item, decoder)?;
642        // Compute the instancing rep_identity ONCE for this unique shape so cache
643        // hits can reuse it instead of re-hashing the full mesh per occurrence.
644        let rep = self.direct_rep_identity(&mesh);
645
646        // Cache the freshly-meshed item under its structural hash. Two exclusions:
647        //  - empty meshes (unsupported/degenerate geometry);
648        //  - results produced once the per-element CSG budget has tripped. On a
649        //    trip the boolean bails and `subtract_mesh` returns the UNCUT host
650        //    (records `OperandTooLarge`); since the dedup key is budget-independent
651        //    (structure/quality/scale/RTC), caching that fallback would serve the
652        //    wrong (uncut) mesh to later identical booleans in a fresh-budget
653        //    element (`budget::begin_element()` resets per element). Correctness of
654        //    the cut wins over deduping a degraded result. (#1257 review P1.)
655        if let (Some(key), Some(cache)) = (dedup_key, self.item_dedup_cache.as_ref()) {
656            if !mesh.positions.is_empty() && !crate::kernel::budget::tripped() {
657                // Clone into the Arc BEFORE locking: a mesh deep-copy inside the
658                // single-Mutex critical section serializes the pool on every miss.
659                let cached = Arc::new((mesh.clone(), rep));
660                cache
661                    .lock()
662                    .unwrap_or_else(|e| e.into_inner())
663                    .insert(key, cached);
664            }
665        }
666
667        Ok(self.stamp_direct_instance(mesh, rep))
668    }
669
670    /// Compute the direct-solid instancing `rep_identity` for a freshly-built,
671    /// pre-placement item mesh, or `None` when instancing is off / the mesh is
672    /// empty / it already carries metadata (mapped items). FULL 128-bit
673    /// (non-sampling) hash: rep_identity has no downstream meshes_equal guard at
674    /// the source and must be cross-worker consistent, so a sampled-hash collision
675    /// (#833 family) would silently group non-identical geometry; 128-bit makes
676    /// that ~2^-127. Computed ONCE per unique shape — cache hits reuse the stored
677    /// value via [`Self::stamp_direct_instance`] instead of re-hashing.
678    fn direct_rep_identity(&self, mesh: &Mesh) -> Option<u128> {
679        if instancing_enabled() && mesh.instance_meta.is_none() && !mesh.positions.is_empty() {
680            Some(Self::compute_mesh_hash_full(mesh) | DIRECT_SOLID_TAG)
681        } else {
682            None
683        }
684    }
685
686    /// Stamp a direct-solid item mesh with a KNOWN `rep_identity` (no re-hash) so
687    /// identical representations collate into a single template + per-occurrence
688    /// transforms. `rep` comes from [`Self::direct_rep_identity`] on a fresh build
689    /// or from the dedup cache on a hit; `None` is a no-op (instancing off / empty
690    /// / already tagged).
691    fn stamp_direct_instance(&self, mut mesh: Mesh, rep: Option<u128>) -> Mesh {
692        if let Some(exact_rep) = rep {
693            mesh.instance_meta = Some(InstanceMeta {
694                transform: IDENTITY_ROW_MAJOR,
695                local_transform: None,
696                canonical_transform: None,
697                rep_identity: exact_rep,
698                instanceable: true,
699            });
700        }
701        mesh
702    }
703
704    /// Cache key for an item: its structural hash combined with the router params
705    /// that change the meshed output (tessellation quality / unit scale / RTC), or
706    /// `None` when dedup is disabled (skips the hash walk so disabled = zero
707    /// overhead). The quality fold is what keeps `setTessellationQuality` correct —
708    /// the shared cache persists across quality changes on a worker, so the key
709    /// must distinguish them (#976).
710    fn item_dedup_key(&self, item: &DecodedEntity, decoder: &mut EntityDecoder) -> Option<u128> {
711        self.item_dedup_cache.as_ref()?;
712        // Dedup the geometry types whose repeated instances dominate real models:
713        // IfcFacetedBrep (tessellated steel) AND the procedural boolean/extrusion
714        // hot path (clipped beams/columns — IfcBooleanResult /
715        // IfcBooleanClippingResult / IfcExtrudedAreaSolid). #1177 had restricted
716        // this to IfcFacetedBrep because the structural hash re-decoded the subtree
717        // per item; it is now memoized (`content_sig_memo`), so shared subtrees
718        // (the same cutter/profile referenced by hundreds of parts) are hashed once
719        // and the dedup is a measured net win, byte-identical: a 20 MB boolean-clip
720        // steel model (170_KM) drops geometry 16.4 s → 2.8 s (5.8×), and procedural
721        // arch models improve too (advanced_model 3.1×, ISSUE_068 1.7×) with no
722        // regression on the tested corpus. The IfcMappedItem instancing cache is a
723        // separate path, always on.
724        let base = matches!(
725            item.ifc_type,
726            IfcType::IfcFacetedBrep
727                | IfcType::IfcBooleanResult
728                | IfcType::IfcBooleanClippingResult
729                | IfcType::IfcExtrudedAreaSolid
730        );
731        // Additive, flagged OFF by default: faceset / surface-model families. Their
732        // generic byte signature (`sig_walk_bytes`) is already complete; gated so a
733        // low-reuse model never pays the hash for no payback (the #1177 trap).
734        let extra = Self::build_dedup_extra_enabled()
735            && matches!(
736                item.ifc_type,
737                IfcType::IfcPolygonalFaceSet
738                    | IfcType::IfcTriangulatedFaceSet
739                    | IfcType::IfcShellBasedSurfaceModel
740                    | IfcType::IfcFaceBasedSurfaceModel
741            );
742        if !(base || extra) {
743            return None;
744        }
745        // Skip the hash walk entirely for a faceted BREP too large for dedup to
746        // ever pay off (#1909): `try_faceted_brep_signature` mirrors the
747        // mesher's own face/bound/loop/point traversal, so on a huge one-off
748        // BREP (a single ~2.5M-triangle import, no sibling item to match) the
749        // hash is a full second traversal with zero possible payback — it
750        // measured ~30s where the equivalent web-ifc load took ~2.85s, almost
751        // entirely this walk. The face-count probe is a cheap O(faces) prefix
752        // of the same walk (shell ref + face list, no per-point decode), so
753        // bailing here costs nothing extra. Below the threshold (Tekla-style
754        // small repeated parts, the case this cache exists for) behavior is
755        // unchanged. Skipping this pre-mesh cache does NOT disable dedup for a
756        // genuinely repeated large BREP: the post-mesh `get_or_cache_by_hash`
757        // (sampled, O(1) regardless of mesh size) and the instancing
758        // `rep_identity` (`direct_rep_identity`, computed unconditionally after
759        // meshing) both still run, so repeated large geometry still collapses
760        // to one GPU-instanced template — it just re-meshes each occurrence
761        // instead of skipping the mesh on a cache hit.
762        if item.ifc_type == IfcType::IfcFacetedBrep {
763            if let Some(face_count) = super::content_hash::faceted_brep_face_count(decoder, item.id) {
764                if face_count > super::content_hash::FACETED_BREP_DEDUP_FACE_LIMIT {
765                    return None;
766                }
767            }
768        }
769        let structural = {
770            let mut memo = self.content_sig_memo.borrow_mut();
771            super::content_hash::item_signature(decoder, item.id, &mut memo)
772        };
773        Some(super::content_hash::key_with_params(
774            structural,
775            self.tessellation_quality.to_index(),
776            self.unit_scale,
777            self.rtc_offset,
778        ))
779    }
780
781    /// The meshing body of [`Self::process_representation_item`] (everything except
782    /// the MappedItem path and the content-dedup wrapper).
783    fn process_representation_item_uncached(
784        &self,
785        item: &DecodedEntity,
786        decoder: &mut EntityDecoder,
787    ) -> Result<Mesh> {
788        // For raw world-coordinate FacetedBrep with RTC: subtract RTC from f64
789        // coordinates BEFORE f32 conversion. Do not use this path for ordinary
790        // local Breps whose large position comes from IfcObjectPlacement; those
791        // are shifted uniformly during the final world transform.
792        if item.ifc_type == IfcType::IfcFacetedBrep
793            && self.has_rtc_offset()
794            && self.representation_item_uses_raw_large_coordinates(item, decoder)
795        {
796            let processor = crate::processors::FacetedBrepProcessor::new();
797            let rtc_file_units = (
798                self.rtc_offset.0 / self.unit_scale,
799                self.rtc_offset.1 / self.unit_scale,
800                self.rtc_offset.2 / self.unit_scale,
801            );
802            let mut mesh =
803                processor.process_with_rtc(item, decoder, &self.schema, rtc_file_units)?;
804            mesh.validate_indices();
805            self.scale_mesh(&mut mesh);
806            // Mark positions as already RTC-shifted by setting a flag
807            // (positions are small values near origin, not world-space)
808            if !mesh.positions.is_empty() {
809                let cached = self.get_or_cache_by_hash(mesh);
810                return Ok((*cached).clone());
811            }
812            return Ok(mesh);
813        }
814
815        // Check if we have a processor for this type
816        if let Some(processor) = self.processors.get(&item.ifc_type) {
817            let mut mesh =
818                processor.process(item, decoder, &self.schema, self.tessellation_quality)?;
819            // Safety net: strip any out-of-bounds indices before downstream use
820            mesh.validate_indices();
821
822            // For raw world-coordinate meshes: apply RTC before unit scaling
823            // to avoid jitter from f32 truncation at world-space scale.
824            // This covers FaceBasedSurface, ShellBasedSurface, and any other
825            // processor that stores raw world-space coordinates as f32.
826            if self.has_rtc_offset()
827                && !mesh.rtc_applied
828                && !mesh.positions.is_empty()
829                && self.representation_item_uses_raw_large_coordinates(item, decoder)
830            {
831                // Positions are in file units (pre-scale). RTC offset is in meters.
832                // Convert RTC to file units for consistent subtraction.
833                let rtc_fu = (
834                    self.rtc_offset.0 / self.unit_scale,
835                    self.rtc_offset.1 / self.unit_scale,
836                    self.rtc_offset.2 / self.unit_scale,
837                );
838                for chunk in mesh.positions.chunks_exact_mut(3) {
839                    chunk[0] = (chunk[0] as f64 - rtc_fu.0) as f32;
840                    chunk[1] = (chunk[1] as f64 - rtc_fu.1) as f32;
841                    chunk[2] = (chunk[2] as f64 - rtc_fu.2) as f32;
842                }
843                mesh.rtc_applied = true;
844            }
845
846            self.scale_mesh(&mut mesh);
847
848            // Deduplicate by hash - buildings with repeated floors have identical geometry
849            if !mesh.positions.is_empty() {
850                let cached = self.get_or_cache_by_hash(mesh);
851                return Ok((*cached).clone());
852            }
853            return Ok(mesh);
854        }
855
856        // No processor is registered for this type. Every `GeometryCategory`
857        // that has a real implementation (SweptSolid, ExplicitMesh, Boolean) is
858        // already caught by the processor lookup above; `MappedItem` never
859        // reaches here (`process_representation_item` intercepts it first, see
860        // `process_mapped_item_cached`). So landing here means the type is
861        // genuinely unsupported, not merely "not implemented yet".
862        Err(Error::geometry(format!(
863            "Unsupported representation type: {}",
864            item.ifc_type
865        )))
866    }
867
868    /// Process MappedItem with caching for repeated geometry
869    #[inline]
870    pub(super) fn process_mapped_item_cached(
871        &self,
872        item: &DecodedEntity,
873        decoder: &mut EntityDecoder,
874    ) -> Result<Mesh> {
875        let mut visited = FxHashSet::default();
876        let mut truncated = false;
877        self.process_mapped_item_cached_inner(item, decoder, 0, &mut visited, &mut truncated)
878    }
879
880    /// Recursion body of [`Self::process_mapped_item_cached`]. `depth`/`visited`
881    /// bound the walk exactly as [`Self::collect_submeshes_from_item_inner`]
882    /// does, so a malformed model with a cyclic (or absurdly deep) mapped-item
883    /// chain terminates instead of overflowing the stack.
884    ///
885    /// `truncated` is set when this level's mesh is missing geometry a bound cut
886    /// off — either a nested item whose error this level swallowed, or a nested
887    /// item that was itself truncated. The caller ORs it into its own, so the
888    /// flag reaches every enclosing level whose merged mesh is short.
889    fn process_mapped_item_cached_inner(
890        &self,
891        item: &DecodedEntity,
892        decoder: &mut EntityDecoder,
893        depth: usize,
894        visited: &mut FxHashSet<u32>,
895        truncated: &mut bool,
896    ) -> Result<Mesh> {
897        if depth >= MAX_MAPPED_ITEM_DEPTH {
898            return Err(Error::geometry(format!(
899                "MappedItem nesting exceeded maximum depth of {} at #{}",
900                MAX_MAPPED_ITEM_DEPTH, item.id
901            )));
902        }
903        if !visited.insert(item.id) {
904            return Err(Error::geometry(format!(
905                "Detected cyclic IfcMappedItem reference at #{}",
906                item.id
907            )));
908        }
909        let result = self.process_mapped_item_cached_body(item, decoder, depth, visited, truncated);
910        visited.remove(&item.id);
911        result
912    }
913
914    fn process_mapped_item_cached_body(
915        &self,
916        item: &DecodedEntity,
917        decoder: &mut EntityDecoder,
918        depth: usize,
919        visited: &mut FxHashSet<u32>,
920        truncated: &mut bool,
921    ) -> Result<Mesh> {
922        // IfcMappedItem attributes:
923        // 0: MappingSource (IfcRepresentationMap)
924        // 1: MappingTarget (IfcCartesianTransformationOperator)
925
926        // Get mapping source (RepresentationMap)
927        let source_attr = item
928            .get(0)
929            .ok_or_else(|| Error::geometry("MappedItem missing MappingSource".to_string()))?;
930
931        let source_entity = decoder
932            .resolve_ref(source_attr)?
933            .ok_or_else(|| Error::geometry("Failed to resolve MappingSource".to_string()))?;
934
935        let source_id = source_entity.id;
936
937        // MappingTarget (attr 1) composed over the map's MappingOrigin (attr 0),
938        // which applies innermost. #1985
939        let mapping_transform = self.mapped_item_transform(item, &source_entity, decoder)?;
940
941        // Check cache first. The model-wide shared cache (#1623) takes precedence
942        // over the per-router RefCell fallback so a source shared across owning
943        // elements is meshed once model-wide (a fresh router — hence a fresh
944        // RefCell — is built per element). Only a brief get/clone runs under the
945        // shared lock; the source build below (which nests faceted-brep's rayon
946        // `par_iter`) runs OUTSIDE any lock, so a lock is never held across a nested
947        // join (the #1587 deadlock class).
948        let cached_source: Option<Arc<Mesh>> = match &self.shared_mapped_item_cache {
949            Some(shared) => shared
950                .lock()
951                .unwrap_or_else(|e| e.into_inner())
952                .get(&source_id)
953                .cloned(),
954            None => self.mapped_item_cache.borrow().get(&source_id).cloned(),
955        };
956        if let Some(cached_mesh) = cached_source {
957            let mut mesh = cached_mesh.as_ref().clone();
958            let mut local_rm = None;
959            if let Some(mut transform) = mapping_transform {
960                self.scale_transform(&mut transform);
961                if instancing_enabled() {
962                    local_rm = Some(mat4_to_row_major(&transform));
963                }
964                self.transform_mesh_local(&mut mesh, &transform);
965            }
966            // Instancing: all occurrences of this RepresentationMap share the
967            // cached source-coords geometry; `local_transform` is the mapping
968            // (canonical -> element-local), `transform` is filled later by the
969            // element's apply_placement (element-local -> world).
970            if instancing_enabled() {
971                mesh.instance_meta = Some(InstanceMeta {
972                    transform: IDENTITY_ROW_MAJOR,
973                    local_transform: local_rm,
974                    canonical_transform: None,
975                    rep_identity: source_id as u128,
976                    instanceable: true,
977                });
978            }
979            return Ok(mesh);
980        }
981
982        // Cache miss - process the geometry
983        // IfcRepresentationMap has:
984        // 0: MappingOrigin (IfcAxis2Placement)
985        // 1: MappedRepresentation (IfcRepresentation)
986
987        let mapped_rep_attr = source_entity.get(1).ok_or_else(|| {
988            Error::geometry("RepresentationMap missing MappedRepresentation".to_string())
989        })?;
990
991        let mapped_rep = decoder
992            .resolve_ref(mapped_rep_attr)?
993            .ok_or_else(|| Error::geometry("Failed to resolve MappedRepresentation".to_string()))?;
994
995        // Get representation items
996        let items_attr = mapped_rep
997            .get(3)
998            .ok_or_else(|| Error::geometry("Representation missing Items".to_string()))?;
999
1000        let items = decoder.resolve_ref_list(items_attr)?;
1001
1002        // Process all items and merge. A nested MappedItem recurses (bounded by
1003        // `depth`/`visited` above) — it used to be skipped outright, which
1004        // silently dropped its geometry. The recursive call returns an
1005        // already-scaled mesh with its own MappingTarget baked in, so composing
1006        // this level's (scaled) transform over the merge below is the same
1007        // algebra `collect_submeshes_from_item_inner` applies per sub-mesh.
1008        let mut mesh = Mesh::new();
1009        // Set when a bound cut this level's mesh short (see the shared-cache guard
1010        // below); ORed into the caller's flag on the way out.
1011        let mut level_truncated = false;
1012        for sub_item in items {
1013            if sub_item.ifc_type == IfcType::IfcMappedItem {
1014                match self.process_mapped_item_cached_inner(
1015                    &sub_item,
1016                    decoder,
1017                    depth + 1,
1018                    visited,
1019                    &mut level_truncated,
1020                ) {
1021                    Ok(sub_mesh) => mesh.merge(&sub_mesh),
1022                    Err(_e) => {
1023                        level_truncated = true;
1024                        crate::diag::diag_debug!(
1025                            { item_id = sub_item.id, error = %_e,
1026                              "skipping nested IfcMappedItem" }
1027                            else {
1028                                #[cfg(debug_assertions)]
1029                                eprintln!(
1030                                    "[ifc-lite] Skipping nested IfcMappedItem #{}: {}",
1031                                    sub_item.id, _e
1032                                );
1033                            }
1034                        );
1035                    }
1036                }
1037                continue;
1038            }
1039            if let Some(processor) = self.processors.get(&sub_item.ifc_type) {
1040                if let Ok(mut sub_mesh) =
1041                    processor.process(&sub_item, decoder, &self.schema, self.tessellation_quality)
1042                {
1043                    sub_mesh.validate_indices();
1044                    self.scale_mesh(&mut sub_mesh);
1045                    mesh.merge(&sub_mesh);
1046                }
1047            }
1048        }
1049        // The merge above is short, so every enclosing level's is too.
1050        *truncated |= level_truncated;
1051
1052        // Store in cache (before transformation, so cached mesh is in source
1053        // coordinates). Shared model-wide cache first (#1623), else the per-router
1054        // RefCell. A concurrent miss on the same source by another router rebuilds
1055        // an identical source-coords mesh, so an overwrite here is byte-identical.
1056        // Brief lock only — the source build above ran outside it (no join held).
1057        let source_arc = Arc::new(mesh.clone());
1058        match &self.shared_mapped_item_cache {
1059            Some(shared) => {
1060                // Mirror the item-dedup #1257 guard: a mapped source can contain
1061                // IfcBooleanResult/IfcCsgSolid, and on a per-element CSG-budget trip
1062                // the boolean bails and returns the UNCUT host. Caching that degraded
1063                // source MODEL-WIDE would serve the wrong (uncut) mesh to a later
1064                // occurrence in a fresh-budget element that would otherwise get the
1065                // full exact cut. Skip the shared insert on a trip (or empty mesh) —
1066                // the next occurrence re-meshes and a clean element caches it. The
1067                // RefCell fallback arm below stays UNGUARDED: it is per-element
1068                // (consistent budget within the element), reproducing main exactly.
1069                //
1070                // `level_truncated` is the same shape for the nesting bounds this
1071                // walk introduced: the depth cap and the visited set depend on where
1072                // in the walk the source was reached, which `source_id` does not
1073                // encode. A source first met at depth 31 loses everything below it,
1074                // and caching that model-wide would serve the short mesh to a later
1075                // occurrence reached at depth 0, which would otherwise walk the
1076                // whole chain. Non-empty and budget-clean, so only this catches it.
1077                if !mesh.positions.is_empty()
1078                    && !crate::kernel::budget::tripped()
1079                    && !level_truncated
1080                {
1081                    shared
1082                        .lock()
1083                        .unwrap_or_else(|e| e.into_inner())
1084                        .insert(source_id, source_arc);
1085                }
1086            }
1087            None => {
1088                self.mapped_item_cache.borrow_mut().insert(source_id, source_arc);
1089            }
1090        }
1091
1092        // Apply MappingTarget transformation to this instance
1093        let mut local_rm = None;
1094        if let Some(mut transform) = mapping_transform {
1095            self.scale_transform(&mut transform);
1096            if instancing_enabled() {
1097                local_rm = Some(mat4_to_row_major(&transform));
1098            }
1099            self.transform_mesh_local(&mut mesh, &transform);
1100        }
1101        if instancing_enabled() {
1102            mesh.instance_meta = Some(InstanceMeta {
1103                transform: IDENTITY_ROW_MAJOR,
1104                local_transform: local_rm,
1105                        canonical_transform: None,
1106                rep_identity: source_id as u128,
1107                instanceable: true,
1108            });
1109        }
1110
1111        Ok(mesh)
1112    }
1113
1114    /// Run an `IfcAlignment` through the dedicated alignment processor, then
1115    /// apply the standard unit scale + placement transform. Returns `None`
1116    /// when the alignment has no recognisable directrix curve (the caller
1117    /// falls back to normal representation processing).
1118    fn try_alignment_mesh(
1119        &self,
1120        element: &DecodedEntity,
1121        decoder: &mut EntityDecoder,
1122    ) -> Result<Option<Mesh>> {
1123        let processor = match self.processors.get(&IfcType::IfcAlignment) {
1124            Some(p) => Arc::clone(p),
1125            None => return Ok(None),
1126        };
1127        let mut mesh =
1128            match processor.process(element, decoder, &self.schema, self.tessellation_quality) {
1129            Ok(m) => m,
1130            // Missing Axis or unparseable curve isn't fatal — fall back so
1131            // the caller can still walk a normal representation if present.
1132            Err(_) => return Ok(None),
1133        };
1134        if mesh.positions.is_empty() {
1135            return Ok(None);
1136        }
1137        mesh.validate_indices();
1138        self.scale_mesh(&mut mesh);
1139        self.apply_placement(element, decoder, &mut mesh)?;
1140        Ok(Some(mesh))
1141    }
1142}