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}