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}