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            // Get MappingTarget transformation
374            let mapping_transform = if let Some(target_attr) = item.get(1) {
375                if !target_attr.is_null() {
376                    if let Some(target_entity) = decoder.resolve_ref(target_attr)? {
377                        Some(self.parse_cartesian_transformation_operator(&target_entity, decoder)?)
378                    } else {
379                        None
380                    }
381                } else {
382                    None
383                }
384            } else {
385                None
386            };
387
388            // #1623 Phase 2/3 "don't-bake": if this top-level mapped item's source is
389            // a REPEATED (count >= 2) single-solid `IfcRepresentationMap` the armed
390            // plan lists, exactly ONE occurrence (the "template") materializes its
391            // geometry; every OTHER occurrence skips the per-occurrence vertex clone /
392            // MappingTarget bake / weld and emits an instance-only placeholder (empty
393            // geometry carrying the mapping transform + rep_identity in `InstanceMeta`).
394            // `apply_submesh_placement` folds the world placement into `im.transform`;
395            // the finalize turns the placeholder into an occurrence against the template.
396            //
397            // `instance_solid_id` is the nested SOLID's id (used as the placeholder's
398            // geometry_id so colour resolves EXACTLY as the flat/template sub-mesh).
399            // Only fires at the TOP level (`depth == 0`) — a mapped item nested inside
400            // another map is part of its parent's shared geometry, not an independent
401            // occurrence — and only when `allow_instancing` (the non-void path). With
402            // no armed plan this is skipped entirely, so the flat output is unchanged.
403            let instance_solid_id: Option<u32> = if allow_instancing && depth == 0 {
404                self.output_instancing_plan()
405                    .and_then(|plan| plan.get(&source_id).copied())
406                    .filter(|&(count, _)| count >= 2)
407                    .and_then(|_| {
408                        self.mapped_source_single_item(&mapped_repr, decoder)
409                            // #858: a source whose single solid carries an
410                            // IfcIndexedColourMap must materialize flat so
411                            // emit_sub_meshes can split it into one mesh per palette
412                            // group. An instance placeholder resolves ONE colour,
413                            // collapsing the palette (WRONG vs the flat path); route
414                            // to flat instead (byte-identical to instancing-off).
415                            .filter(|&item_id| !self.is_indexed_colour_split_source(item_id))
416                            // #1781: same rule for a TEXTURED single solid — an
417                            // instance placeholder carries no UVs/texture, so the
418                            // occurrence would render untextured. Materialize flat.
419                            .filter(|&item_id| {
420                                texture_index.is_none_or(|ti| !ti.contains_key(&item_id))
421                            })
422                    })
423            } else {
424                None
425            };
426            // Which occurrence MATERIALIZES the template. Native (global) mode: the
427            // plan's deterministic min-id occurrence, so all occurrences resolve
428            // against ONE model-wide template across the rayon pool. WASM batch-local
429            // mode: the FIRST occurrence of this source seen by this router/batch (the
430            // rest don't-bake), so each per-batch shard is self-contained. Both emit
431            // geometrically identical world triangles.
432            let is_template = match instance_solid_id {
433                None => true, // not eligible ⇒ materialize flat as usual
434                Some(_) if self.instancing_batch_local() => {
435                    self.mark_source_materialized_if_first(source_id)
436                }
437                Some(_) => {
438                    let template_item_id = self
439                        .output_instancing_plan()
440                        .and_then(|plan| plan.get(&source_id))
441                        .map(|&(_, t)| t)
442                        .unwrap_or(item.id);
443                    item.id == template_item_id
444                }
445            };
446            if let Some(solid_item_id) = instance_solid_id {
447                if !is_template {
448                    // NON-template occurrence: don't-bake. Ensure the shared registry
449                    // holds the source geometry (meshed once model-wide) so the
450                    // finalize can recover geometry even in the (effectively
451                    // unreachable) case that the template occurrence never
452                    // materialized, then push the instance-only placeholder. Its
453                    // geometry_id is the nested SOLID's id (not the mapped-item id) so
454                    // emit_sub_meshes resolves the occurrence colour identically to the
455                    // flat/template sub-mesh.
456                    self.ensure_shared_mapped_source(&mapped_repr, source_id, decoder);
457                    let local_rm = mapping_transform.map(|mut t| {
458                        self.scale_transform(&mut t);
459                        mat4_to_row_major(&t)
460                    });
461                    let mut placeholder = Mesh::new();
462                    placeholder.instance_meta = Some(InstanceMeta {
463                        transform: IDENTITY_ROW_MAJOR,
464                        local_transform: local_rm,
465                        canonical_transform: None,
466                        rep_identity: source_id as u128,
467                        instanceable: true,
468                    });
469                    // Push directly (SubMeshCollection::add drops empty meshes; this
470                    // placeholder is intentionally empty — its InstanceMeta is the payload).
471                    sub_meshes
472                        .sub_meshes
473                        .push(crate::SubMesh::new(solid_item_id, placeholder));
474                    visited.remove(&item.id);
475                    return Ok(());
476                }
477            }
478            // Record where THIS mapped item's sub-meshes start, so the don't-bake
479            // TEMPLATE occurrence can be re-tagged with the source-id rep_identity
480            // after the normal materialize below (see the retag after the loop).
481            let mapped_items_start = sub_meshes.len();
482
483            // Get items from the mapped representation
484            if let Some(items_attr) = mapped_repr.get(3) {
485                let items = decoder.resolve_ref_list(items_attr)?;
486                for nested_item in items {
487                    // Recursively collect sub-meshes (skip unsupported geometry types).
488                    // Nested items never independently don't-bake (`allow_instancing =
489                    // false`): they are this occurrence's own shared geometry.
490                    let count_before = sub_meshes.len();
491                    if let Err(_e) = self.collect_submeshes_from_item_inner(
492                        &nested_item,
493                        decoder,
494                        sub_meshes,
495                        depth + 1,
496                        visited,
497                        false,
498                        texture_index,
499                    ) {
500                        crate::diag::diag_debug!(
501                            { item_id = nested_item.id, ifc_type = ?nested_item.ifc_type,
502                              error = %_e, "skipping unsupported nested geometry item" }
503                            else {
504                                #[cfg(debug_assertions)]
505                                eprintln!(
506                                    "[ifc-lite] Skipping unsupported nested geometry #{} ({:?}): {}",
507                                    nested_item.id, nested_item.ifc_type, _e
508                                );
509                            }
510                        );
511                        continue;
512                    }
513
514                    // Apply MappedItem transform to newly added sub-meshes.
515                    if let Some(mut transform) = mapping_transform {
516                        self.scale_transform(&mut transform);
517                        // The MappingTarget is a PER-OCCURRENCE transform: baked into the
518                        // vertices here (flat output byte-for-byte unchanged), and for
519                        // INSTANCING recorded in `local_transform` (keeping the canonical,
520                        // pre-target `rep_identity`) — mirroring `process_mapped_item_cached`
521                        // and the don't-bake TEMPLATE re-tag below — so occurrences sharing a
522                        // map but differing by target collate under one template. Previously
523                        // this RE-HASHED into `rep_identity`, giving every target a unique id
524                        // and disabling instancing (GLB export #1443) for the MULTI-item class
525                        // Phase 2 leaves flat (Tekla assemblies / MEP / metering skids). #1623
526                        let nontrivial_target = !transform.is_identity(1e-9);
527                        for sub in &mut sub_meshes.sub_meshes[count_before..] {
528                            self.transform_mesh_local(&mut sub.mesh, &transform);
529                            if nontrivial_target {
530                                if let Some(im) =
531                                    sub.mesh.instance_meta.as_mut().filter(|im| im.instanceable)
532                                {
533                                    im.local_transform = Some(match im.local_transform {
534                                        // Nested map: outer target ∘ inner, bake order.
535                                        Some(inner) => mat4_to_row_major(
536                                            &(transform * nalgebra::Matrix4::from_row_slice(&inner)),
537                                        ),
538                                        None => mat4_to_row_major(&transform),
539                                    });
540                                }
541                            }
542                        }
543                    }
544                }
545            }
546
547            // #1623 Phase 2/3: this is the don't-bake TEMPLATE occurrence. It
548            // materialized normally above (byte-identical to a flat occurrence — a
549            // single-solid source ⇒ exactly one sub-mesh). Re-tag its `rep_identity`
550            // to the source id and record the (scaled) MappingTarget as
551            // `local_transform`, MATCHING the instance placeholders so the finalize
552            // collates them onto this template. The baked geometry is untouched — the
553            // MappingTarget is already folded into both the vertices AND
554            // `local_transform`, which is consistent (the template's world geometry is
555            // `transform · local_transform · source`, so `m_ref` recovers the same
556            // `source` the placeholders reference). See the finalize in processor/mod.rs.
557            if instance_solid_id.is_some() && is_template {
558                let local_rm = mapping_transform.map(|mut t| {
559                    self.scale_transform(&mut t);
560                    mat4_to_row_major(&t)
561                });
562                for sub in &mut sub_meshes.sub_meshes[mapped_items_start..] {
563                    if let Some(im) = sub.mesh.instance_meta.as_mut() {
564                        im.rep_identity = source_id as u128;
565                        im.local_transform = local_rm;
566                    }
567                }
568            }
569
570            visited.remove(&item.id);
571        } else {
572            // Textured tessellated face set (#1781): mesh with per-vertex UVs so
573            // the occurrence path renders its image like the type-geometry path
574            // (#961) always did. Bypasses the content-dedup cache — the cached
575            // mesh has no UV channel, and UVs are per-face-set anyway. Falls
576            // through to the plain path if the textured build fails.
577            if item.ifc_type == IfcType::IfcTriangulatedFaceSet {
578                if let Some(map) = texture_index.and_then(|ti| ti.get(&item.id)) {
579                    let proc = crate::processors::TriangulatedFaceSetProcessor::new();
580                    if let Ok((mut mesh, uvs)) = proc.process_with_texture(item, decoder, map) {
581                        if !mesh.is_empty() {
582                            self.scale_mesh(&mut mesh); // UVs are unaffected by scale
583                            sub_meshes.add_textured(item.id, mesh, uvs, map.attachment());
584                            return Ok(());
585                        }
586                    }
587                }
588            }
589            // Regular geometry item - process and record with its ID
590            // Skip unsupported geometry types (e.g. IfcGeometricSet) instead of failing
591            match self.process_representation_item(item, decoder) {
592                Ok(mesh) => {
593                    if !mesh.is_empty() {
594                        sub_meshes.add(item.id, mesh);
595                    }
596                }
597                Err(_e) => {
598                    crate::diag::diag_debug!(
599                        { item_id = item.id, ifc_type = ?item.ifc_type, error = %_e,
600                          "skipping unsupported geometry item" }
601                        else {
602                            #[cfg(debug_assertions)]
603                            eprintln!(
604                                "[ifc-lite] Skipping unsupported geometry #{} ({:?}): {}",
605                                item.id, item.ifc_type, _e
606                            );
607                        }
608                    );
609                }
610            }
611        }
612
613        Ok(())
614    }
615
616    /// Process a single representation item (IfcExtrudedAreaSolid, etc.), with
617    /// content-dedup: a 128-bit structural hash of the item subtree skips the
618    /// meshing + CSG for geometry byte-identical to an item meshed earlier (e.g.
619    /// the thousands of Tekla connection plates/bolts an exporter failed to share
620    /// via `IfcMappedItem`). The cached mesh is colour-free and pre-placement; the
621    /// caller keeps this item's own `geometry_id` (so colour/palette/texture stay
622    /// per-instance) and applies voids + placement afterwards, so a cache hit is
623    /// indistinguishable from a fresh build.
624    #[inline]
625    pub fn process_representation_item(
626        &self,
627        item: &DecodedEntity,
628        decoder: &mut EntityDecoder,
629    ) -> Result<Mesh> {
630        // MappedItem has its own instancing cache (the source representation is
631        // already shared), so it never enters the structural-hash path. It also
632        // sets its own instance_meta, so the direct-solid tagging below is skipped.
633        if item.ifc_type == IfcType::IfcMappedItem {
634            return self.process_mapped_item_cached(item, decoder);
635        }
636
637        // `None` ⇒ dedup disabled (no hash overhead). On a hit, clone the cached
638        // item mesh and stamp its STORED rep_identity (no per-occurrence re-hash);
639        // meshing is skipped entirely.
640        let dedup_key = self.item_dedup_key(item, decoder);
641        if let (Some(key), Some(cache)) = (dedup_key, self.item_dedup_cache.as_ref()) {
642            let hit = cache
643                .lock()
644                .unwrap_or_else(|e| e.into_inner())
645                .get(&key)
646                .cloned();
647            if let Some(entry) = hit {
648                let (mesh, rep) = (entry.0.clone(), entry.1);
649                return Ok(self.stamp_direct_instance(mesh, rep));
650            }
651        }
652
653        let mesh = self.process_representation_item_uncached(item, decoder)?;
654        // Compute the instancing rep_identity ONCE for this unique shape so cache
655        // hits can reuse it instead of re-hashing the full mesh per occurrence.
656        let rep = self.direct_rep_identity(&mesh);
657
658        // Cache the freshly-meshed item under its structural hash. Two exclusions:
659        //  - empty meshes (unsupported/degenerate geometry);
660        //  - results produced once the per-element CSG budget has tripped. On a
661        //    trip the boolean bails and `subtract_mesh` returns the UNCUT host
662        //    (records `OperandTooLarge`); since the dedup key is budget-independent
663        //    (structure/quality/scale/RTC), caching that fallback would serve the
664        //    wrong (uncut) mesh to later identical booleans in a fresh-budget
665        //    element (`budget::begin_element()` resets per element). Correctness of
666        //    the cut wins over deduping a degraded result. (#1257 review P1.)
667        if let (Some(key), Some(cache)) = (dedup_key, self.item_dedup_cache.as_ref()) {
668            if !mesh.positions.is_empty() && !crate::kernel::budget::tripped() {
669                // Clone into the Arc BEFORE locking: a mesh deep-copy inside the
670                // single-Mutex critical section serializes the pool on every miss.
671                let cached = Arc::new((mesh.clone(), rep));
672                cache
673                    .lock()
674                    .unwrap_or_else(|e| e.into_inner())
675                    .insert(key, cached);
676            }
677        }
678
679        Ok(self.stamp_direct_instance(mesh, rep))
680    }
681
682    /// Compute the direct-solid instancing `rep_identity` for a freshly-built,
683    /// pre-placement item mesh, or `None` when instancing is off / the mesh is
684    /// empty / it already carries metadata (mapped items). FULL 128-bit
685    /// (non-sampling) hash: rep_identity has no downstream meshes_equal guard at
686    /// the source and must be cross-worker consistent, so a sampled-hash collision
687    /// (#833 family) would silently group non-identical geometry; 128-bit makes
688    /// that ~2^-127. Computed ONCE per unique shape — cache hits reuse the stored
689    /// value via [`Self::stamp_direct_instance`] instead of re-hashing.
690    fn direct_rep_identity(&self, mesh: &Mesh) -> Option<u128> {
691        if instancing_enabled() && mesh.instance_meta.is_none() && !mesh.positions.is_empty() {
692            Some(Self::compute_mesh_hash_full(mesh) | DIRECT_SOLID_TAG)
693        } else {
694            None
695        }
696    }
697
698    /// Stamp a direct-solid item mesh with a KNOWN `rep_identity` (no re-hash) so
699    /// identical representations collate into a single template + per-occurrence
700    /// transforms. `rep` comes from [`Self::direct_rep_identity`] on a fresh build
701    /// or from the dedup cache on a hit; `None` is a no-op (instancing off / empty
702    /// / already tagged).
703    fn stamp_direct_instance(&self, mut mesh: Mesh, rep: Option<u128>) -> Mesh {
704        if let Some(exact_rep) = rep {
705            mesh.instance_meta = Some(InstanceMeta {
706                transform: IDENTITY_ROW_MAJOR,
707                local_transform: None,
708                canonical_transform: None,
709                rep_identity: exact_rep,
710                instanceable: true,
711            });
712        }
713        mesh
714    }
715
716    /// Cache key for an item: its structural hash combined with the router params
717    /// that change the meshed output (tessellation quality / unit scale / RTC), or
718    /// `None` when dedup is disabled (skips the hash walk so disabled = zero
719    /// overhead). The quality fold is what keeps `setTessellationQuality` correct —
720    /// the shared cache persists across quality changes on a worker, so the key
721    /// must distinguish them (#976).
722    fn item_dedup_key(&self, item: &DecodedEntity, decoder: &mut EntityDecoder) -> Option<u128> {
723        self.item_dedup_cache.as_ref()?;
724        // Dedup the geometry types whose repeated instances dominate real models:
725        // IfcFacetedBrep (tessellated steel) AND the procedural boolean/extrusion
726        // hot path (clipped beams/columns — IfcBooleanResult /
727        // IfcBooleanClippingResult / IfcExtrudedAreaSolid). #1177 had restricted
728        // this to IfcFacetedBrep because the structural hash re-decoded the subtree
729        // per item; it is now memoized (`content_sig_memo`), so shared subtrees
730        // (the same cutter/profile referenced by hundreds of parts) are hashed once
731        // and the dedup is a measured net win, byte-identical: a 20 MB boolean-clip
732        // steel model (170_KM) drops geometry 16.4 s → 2.8 s (5.8×), and procedural
733        // arch models improve too (advanced_model 3.1×, ISSUE_068 1.7×) with no
734        // regression on the tested corpus. The IfcMappedItem instancing cache is a
735        // separate path, always on.
736        let base = matches!(
737            item.ifc_type,
738            IfcType::IfcFacetedBrep
739                | IfcType::IfcBooleanResult
740                | IfcType::IfcBooleanClippingResult
741                | IfcType::IfcExtrudedAreaSolid
742        );
743        // Additive, flagged OFF by default: faceset / surface-model families. Their
744        // generic byte signature (`sig_walk_bytes`) is already complete; gated so a
745        // low-reuse model never pays the hash for no payback (the #1177 trap).
746        let extra = Self::build_dedup_extra_enabled()
747            && matches!(
748                item.ifc_type,
749                IfcType::IfcPolygonalFaceSet
750                    | IfcType::IfcTriangulatedFaceSet
751                    | IfcType::IfcShellBasedSurfaceModel
752                    | IfcType::IfcFaceBasedSurfaceModel
753            );
754        if !(base || extra) {
755            return None;
756        }
757        let structural = {
758            let mut memo = self.content_sig_memo.borrow_mut();
759            super::content_hash::item_signature(decoder, item.id, &mut memo)
760        };
761        Some(super::content_hash::key_with_params(
762            structural,
763            self.tessellation_quality.to_index(),
764            self.unit_scale,
765            self.rtc_offset,
766        ))
767    }
768
769    /// The meshing body of [`Self::process_representation_item`] (everything except
770    /// the MappedItem path and the content-dedup wrapper).
771    fn process_representation_item_uncached(
772        &self,
773        item: &DecodedEntity,
774        decoder: &mut EntityDecoder,
775    ) -> Result<Mesh> {
776        // For raw world-coordinate FacetedBrep with RTC: subtract RTC from f64
777        // coordinates BEFORE f32 conversion. Do not use this path for ordinary
778        // local Breps whose large position comes from IfcObjectPlacement; those
779        // are shifted uniformly during the final world transform.
780        if item.ifc_type == IfcType::IfcFacetedBrep
781            && self.has_rtc_offset()
782            && self.representation_item_uses_raw_large_coordinates(item, decoder)
783        {
784            let processor = crate::processors::FacetedBrepProcessor::new();
785            let rtc_file_units = (
786                self.rtc_offset.0 / self.unit_scale,
787                self.rtc_offset.1 / self.unit_scale,
788                self.rtc_offset.2 / self.unit_scale,
789            );
790            let mut mesh =
791                processor.process_with_rtc(item, decoder, &self.schema, rtc_file_units)?;
792            mesh.validate_indices();
793            self.scale_mesh(&mut mesh);
794            // Mark positions as already RTC-shifted by setting a flag
795            // (positions are small values near origin, not world-space)
796            if !mesh.positions.is_empty() {
797                let cached = self.get_or_cache_by_hash(mesh);
798                return Ok((*cached).clone());
799            }
800            return Ok(mesh);
801        }
802
803        // Check if we have a processor for this type
804        if let Some(processor) = self.processors.get(&item.ifc_type) {
805            let mut mesh =
806                processor.process(item, decoder, &self.schema, self.tessellation_quality)?;
807            // Safety net: strip any out-of-bounds indices before downstream use
808            mesh.validate_indices();
809
810            // For raw world-coordinate meshes: apply RTC before unit scaling
811            // to avoid jitter from f32 truncation at world-space scale.
812            // This covers FaceBasedSurface, ShellBasedSurface, and any other
813            // processor that stores raw world-space coordinates as f32.
814            if self.has_rtc_offset()
815                && !mesh.rtc_applied
816                && !mesh.positions.is_empty()
817                && self.representation_item_uses_raw_large_coordinates(item, decoder)
818            {
819                // Positions are in file units (pre-scale). RTC offset is in meters.
820                // Convert RTC to file units for consistent subtraction.
821                let rtc_fu = (
822                    self.rtc_offset.0 / self.unit_scale,
823                    self.rtc_offset.1 / self.unit_scale,
824                    self.rtc_offset.2 / self.unit_scale,
825                );
826                for chunk in mesh.positions.chunks_exact_mut(3) {
827                    chunk[0] = (chunk[0] as f64 - rtc_fu.0) as f32;
828                    chunk[1] = (chunk[1] as f64 - rtc_fu.1) as f32;
829                    chunk[2] = (chunk[2] as f64 - rtc_fu.2) as f32;
830                }
831                mesh.rtc_applied = true;
832            }
833
834            self.scale_mesh(&mut mesh);
835
836            // Deduplicate by hash - buildings with repeated floors have identical geometry
837            if !mesh.positions.is_empty() {
838                let cached = self.get_or_cache_by_hash(mesh);
839                return Ok((*cached).clone());
840            }
841            return Ok(mesh);
842        }
843
844        // No processor is registered for this type. Every `GeometryCategory`
845        // that has a real implementation (SweptSolid, ExplicitMesh, Boolean) is
846        // already caught by the processor lookup above; `MappedItem` never
847        // reaches here (`process_representation_item` intercepts it first, see
848        // `process_mapped_item_cached`). So landing here means the type is
849        // genuinely unsupported, not merely "not implemented yet".
850        Err(Error::geometry(format!(
851            "Unsupported representation type: {}",
852            item.ifc_type
853        )))
854    }
855
856    /// Process MappedItem with caching for repeated geometry
857    #[inline]
858    pub(super) fn process_mapped_item_cached(
859        &self,
860        item: &DecodedEntity,
861        decoder: &mut EntityDecoder,
862    ) -> Result<Mesh> {
863        // IfcMappedItem attributes:
864        // 0: MappingSource (IfcRepresentationMap)
865        // 1: MappingTarget (IfcCartesianTransformationOperator)
866
867        // Get mapping source (RepresentationMap)
868        let source_attr = item
869            .get(0)
870            .ok_or_else(|| Error::geometry("MappedItem missing MappingSource".to_string()))?;
871
872        let source_entity = decoder
873            .resolve_ref(source_attr)?
874            .ok_or_else(|| Error::geometry("Failed to resolve MappingSource".to_string()))?;
875
876        let source_id = source_entity.id;
877
878        // Get MappingTarget transformation (attribute 1: CartesianTransformationOperator)
879        let mapping_transform = if let Some(target_attr) = item.get(1) {
880            if !target_attr.is_null() {
881                if let Some(target_entity) = decoder.resolve_ref(target_attr)? {
882                    Some(self.parse_cartesian_transformation_operator(&target_entity, decoder)?)
883                } else {
884                    None
885                }
886            } else {
887                None
888            }
889        } else {
890            None
891        };
892
893        // Check cache first. The model-wide shared cache (#1623) takes precedence
894        // over the per-router RefCell fallback so a source shared across owning
895        // elements is meshed once model-wide (a fresh router — hence a fresh
896        // RefCell — is built per element). Only a brief get/clone runs under the
897        // shared lock; the source build below (which nests faceted-brep's rayon
898        // `par_iter`) runs OUTSIDE any lock, so a lock is never held across a nested
899        // join (the #1587 deadlock class).
900        let cached_source: Option<Arc<Mesh>> = match &self.shared_mapped_item_cache {
901            Some(shared) => shared
902                .lock()
903                .unwrap_or_else(|e| e.into_inner())
904                .get(&source_id)
905                .cloned(),
906            None => self.mapped_item_cache.borrow().get(&source_id).cloned(),
907        };
908        if let Some(cached_mesh) = cached_source {
909            let mut mesh = cached_mesh.as_ref().clone();
910            let mut local_rm = None;
911            if let Some(mut transform) = mapping_transform {
912                self.scale_transform(&mut transform);
913                if instancing_enabled() {
914                    local_rm = Some(mat4_to_row_major(&transform));
915                }
916                self.transform_mesh_local(&mut mesh, &transform);
917            }
918            // Instancing: all occurrences of this RepresentationMap share the
919            // cached source-coords geometry; `local_transform` is the mapping
920            // (canonical -> element-local), `transform` is filled later by the
921            // element's apply_placement (element-local -> world).
922            if instancing_enabled() {
923                mesh.instance_meta = Some(InstanceMeta {
924                    transform: IDENTITY_ROW_MAJOR,
925                    local_transform: local_rm,
926                    canonical_transform: None,
927                    rep_identity: source_id as u128,
928                    instanceable: true,
929                });
930            }
931            return Ok(mesh);
932        }
933
934        // Cache miss - process the geometry
935        // IfcRepresentationMap has:
936        // 0: MappingOrigin (IfcAxis2Placement)
937        // 1: MappedRepresentation (IfcRepresentation)
938
939        let mapped_rep_attr = source_entity.get(1).ok_or_else(|| {
940            Error::geometry("RepresentationMap missing MappedRepresentation".to_string())
941        })?;
942
943        let mapped_rep = decoder
944            .resolve_ref(mapped_rep_attr)?
945            .ok_or_else(|| Error::geometry("Failed to resolve MappedRepresentation".to_string()))?;
946
947        // Get representation items
948        let items_attr = mapped_rep
949            .get(3)
950            .ok_or_else(|| Error::geometry("Representation missing Items".to_string()))?;
951
952        let items = decoder.resolve_ref_list(items_attr)?;
953
954        // Process all items and merge
955        // Skip nested MappedItems AND IfcBooleanClippingResult that reference MappedItems
956        // to prevent stack overflow from deeply nested recursive geometry
957        let mut mesh = Mesh::new();
958        for sub_item in items {
959            if sub_item.ifc_type == IfcType::IfcMappedItem {
960                continue;
961            }
962            if let Some(processor) = self.processors.get(&sub_item.ifc_type) {
963                if let Ok(mut sub_mesh) =
964                    processor.process(&sub_item, decoder, &self.schema, self.tessellation_quality)
965                {
966                    sub_mesh.validate_indices();
967                    self.scale_mesh(&mut sub_mesh);
968                    mesh.merge(&sub_mesh);
969                }
970            }
971        }
972
973        // Store in cache (before transformation, so cached mesh is in source
974        // coordinates). Shared model-wide cache first (#1623), else the per-router
975        // RefCell. A concurrent miss on the same source by another router rebuilds
976        // an identical source-coords mesh, so an overwrite here is byte-identical.
977        // Brief lock only — the source build above ran outside it (no join held).
978        let source_arc = Arc::new(mesh.clone());
979        match &self.shared_mapped_item_cache {
980            Some(shared) => {
981                // Mirror the item-dedup #1257 guard: a mapped source can contain
982                // IfcBooleanResult/IfcCsgSolid, and on a per-element CSG-budget trip
983                // the boolean bails and returns the UNCUT host. Caching that degraded
984                // source MODEL-WIDE would serve the wrong (uncut) mesh to a later
985                // occurrence in a fresh-budget element that would otherwise get the
986                // full exact cut. Skip the shared insert on a trip (or empty mesh) —
987                // the next occurrence re-meshes and a clean element caches it. The
988                // RefCell fallback arm below stays UNGUARDED: it is per-element
989                // (consistent budget within the element), reproducing main exactly.
990                if !mesh.positions.is_empty() && !crate::kernel::budget::tripped() {
991                    shared
992                        .lock()
993                        .unwrap_or_else(|e| e.into_inner())
994                        .insert(source_id, source_arc);
995                }
996            }
997            None => {
998                self.mapped_item_cache.borrow_mut().insert(source_id, source_arc);
999            }
1000        }
1001
1002        // Apply MappingTarget transformation to this instance
1003        let mut local_rm = None;
1004        if let Some(mut transform) = mapping_transform {
1005            self.scale_transform(&mut transform);
1006            if instancing_enabled() {
1007                local_rm = Some(mat4_to_row_major(&transform));
1008            }
1009            self.transform_mesh_local(&mut mesh, &transform);
1010        }
1011        if instancing_enabled() {
1012            mesh.instance_meta = Some(InstanceMeta {
1013                transform: IDENTITY_ROW_MAJOR,
1014                local_transform: local_rm,
1015                        canonical_transform: None,
1016                rep_identity: source_id as u128,
1017                instanceable: true,
1018            });
1019        }
1020
1021        Ok(mesh)
1022    }
1023
1024    /// Run an `IfcAlignment` through the dedicated alignment processor, then
1025    /// apply the standard unit scale + placement transform. Returns `None`
1026    /// when the alignment has no recognisable directrix curve (the caller
1027    /// falls back to normal representation processing).
1028    fn try_alignment_mesh(
1029        &self,
1030        element: &DecodedEntity,
1031        decoder: &mut EntityDecoder,
1032    ) -> Result<Option<Mesh>> {
1033        let processor = match self.processors.get(&IfcType::IfcAlignment) {
1034            Some(p) => Arc::clone(p),
1035            None => return Ok(None),
1036        };
1037        let mut mesh =
1038            match processor.process(element, decoder, &self.schema, self.tessellation_quality) {
1039            Ok(m) => m,
1040            // Missing Axis or unparseable curve isn't fatal — fall back so
1041            // the caller can still walk a normal representation if present.
1042            Err(_) => return Ok(None),
1043        };
1044        if mesh.positions.is_empty() {
1045            return Ok(None);
1046        }
1047        mesh.validate_indices();
1048        self.scale_mesh(&mut mesh);
1049        self.apply_placement(element, decoder, &mut mesh)?;
1050        Ok(Some(mesh))
1051    }
1052}