ifc_lite_geometry/router/layers.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//! Material-layer slicing.
6//!
7//! Produces one sub-mesh per [`LayerInfo`][crate::LayerInfo] for elements
8//! whose geometry is a single swept solid but whose buildup is described by
9//! an `IfcMaterialLayerSetUsage`. The sub-mesh `geometry_id` is set to the
10//! layer's `IfcMaterial` entity ID so the styling layer can resolve colour
11//! through the existing material-style index.
12//!
13//! Flow:
14//! 1. Build the base mesh via [`GeometryRouter::process_element_with_voids`].
15//! Subtracting voids FIRST and slicing AFTER is cheaper than slicing first
16//! and subtracting per-slab: layer planes don't affect opening topology.
17//! 2. Transform each layer-interface plane from the element's local frame
18//! into the same world-RTC frame the mesh lives in.
19//! 3. Cut the base mesh into N slabs with N-1 planes using the shared
20//! [`ClippingProcessor`][crate::csg::ClippingProcessor].
21
22use super::GeometryRouter;
23use crate::csg::{ClippingProcessor, Plane};
24use crate::material_layer_index::{LayerAxis, LayerBuildup, LayerInfo};
25use crate::mesh::{SubMesh, SubMeshCollection};
26use crate::{Mesh, Point3, Result, Vector3};
27use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
28use nalgebra::Matrix4;
29use rustc_hash::FxHashMap;
30
31/// Minimum layer thickness (in meters) below which slicing is skipped for
32/// that interface. Sub-millimetre layers (vapor barriers etc.) destabilise
33/// the triangle clipper and aren't visible at typical render scales.
34const MIN_SLICEABLE_THICKNESS_M: f64 = 0.002;
35
36impl GeometryRouter {
37 /// Helper that consults the attached [`MaterialLayerIndex`][crate::MaterialLayerIndex]
38 /// (if any) and returns per-layer sub-meshes for elements whose buildup
39 /// is sliceable. Used internally by `process_element_with_submeshes` and
40 /// `process_element_with_submeshes_and_voids` — with `void_index = None`
41 /// the sliced mesh is built without void subtraction.
42 ///
43 /// Returns `None` when the router has no layer index, the element has no
44 /// recorded buildup, the buildup is not sliceable, or slicing produced
45 /// fewer than two non-empty sub-meshes (in which case callers should
46 /// fall through to their single-mesh / multi-item paths).
47 pub(crate) fn try_layered_sub_meshes(
48 &self,
49 element: &DecodedEntity,
50 decoder: &mut EntityDecoder,
51 void_index: Option<&FxHashMap<u32, Vec<u32>>>,
52 ) -> Option<SubMeshCollection> {
53 let index = self.material_layer_index()?;
54 let buildup = index.get(element.id)?;
55 if !buildup.is_sliceable() {
56 return None;
57 }
58 let empty: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
59 let voids = void_index.unwrap_or(&empty);
60 let collection = self
61 .process_element_with_material_layers(element, decoder, buildup, voids)
62 .ok()
63 .flatten()?;
64 if collection.sub_meshes.len() < 2 {
65 return None;
66 }
67 Some(collection)
68 }
69
70 /// Process an element into per-layer sub-meshes, subtracting any
71 /// openings first.
72 ///
73 /// Returns `Ok(None)` when the buildup isn't sliceable (single material,
74 /// constituent set, profile set, degenerate) so the caller can fall back
75 /// to the existing sub-mesh-voids path without duplicating work.
76 ///
77 /// Each emitted [`SubMesh`] carries the layer's `IfcMaterial` entity ID
78 /// as its `geometry_id` — callers key colour lookup on that.
79 pub fn process_element_with_material_layers(
80 &self,
81 element: &DecodedEntity,
82 decoder: &mut EntityDecoder,
83 buildup: &LayerBuildup,
84 void_index: &FxHashMap<u32, Vec<u32>>,
85 ) -> Result<Option<SubMeshCollection>> {
86 let (layers, axis, direction_sense, offset) = match buildup {
87 LayerBuildup::Sliceable {
88 layers,
89 axis,
90 direction_sense,
91 offset_from_reference_line,
92 } => (layers, *axis, *direction_sense, *offset_from_reference_line),
93 LayerBuildup::NotSliceable => return Ok(None),
94 };
95
96 if layers.len() < 2 {
97 return Ok(None);
98 }
99
100 // Bail when the representation isn't a single item with identity
101 // Position — otherwise layer planes (built from element placement
102 // only) would be in a different frame than the mesh. Callers fall
103 // through to the unsliced path in that case.
104 if !element_is_single_unshifted_item(element, decoder) {
105 return Ok(None);
106 }
107
108 // Merge sub-mm layers into their thick neighbours before any
109 // geometry work so cutting planes never sit on degenerate
110 // interfaces. When everything collapses to one visual layer there
111 // is nothing to slice.
112 let visual_layers = merge_thin_layers(&layers, self.unit_scale);
113 if visual_layers.len() < 2 {
114 return Ok(None);
115 }
116
117 // Void subtraction happens on the merged mesh (cheap + topology-safe).
118 let base_mesh = self.process_element_with_voids(element, decoder, void_index)?;
119 if base_mesh.is_empty() {
120 return Ok(None);
121 }
122
123 // Build the interface planes in world-RTC coordinates. Returns None
124 // when we can't resolve the element's placement — fall back.
125 let planes = match self.build_layer_planes(
126 element,
127 decoder,
128 &visual_layers,
129 axis,
130 direction_sense,
131 offset,
132 ) {
133 Some(p) => p,
134 None => return Ok(None),
135 };
136 if planes.is_empty() {
137 return Ok(None);
138 }
139
140 Ok(Some(slice_mesh_into_layers(
141 &base_mesh,
142 &visual_layers,
143 &planes,
144 )))
145 }
146
147 /// Convert layer thicknesses + axis/offset into N-1 world-space planes
148 /// aligned with the layer interfaces.
149 ///
150 /// All plane normals point in the `direction_sense` direction so
151 /// slicing logic is uniform: "keep front of plane i" = "beyond interface
152 /// i, deeper into the stack".
153 fn build_layer_planes(
154 &self,
155 element: &DecodedEntity,
156 decoder: &mut EntityDecoder,
157 visual_layers: &[VisualLayer],
158 axis: LayerAxis,
159 direction_sense: f64,
160 offset: f64,
161 ) -> Option<Vec<Plane>> {
162 // Use the same placement the mesh was built with: placement ×
163 // scale_transform (scales translation only).
164 let mut placement = self.get_placement_transform_from_element(element, decoder).ok()?;
165 self.scale_transform(&mut placement);
166
167 let scale = self.unit_scale;
168 let rtc = self.rtc_offset;
169
170 // Axis unit vector in local coordinates.
171 let axis_local = {
172 let v = axis.unit_vector();
173 Vector3::new(v[0], v[1], v[2])
174 };
175
176 // World-space normal (rotation only; translation irrelevant for directions).
177 // Direction sense flips the normal so "front" always means "deeper
178 // into the layer stack".
179 let rotation = placement.fixed_view::<3, 3>(0, 0);
180 let world_normal = (rotation * axis_local)
181 .try_normalize(1e-12)?
182 * direction_sense;
183
184 let offset_m = offset * scale;
185
186 let mut planes = Vec::with_capacity(visual_layers.len().saturating_sub(1));
187 let mut cumulative_m = 0.0_f64;
188 for (i, layer) in visual_layers.iter().enumerate() {
189 cumulative_m += layer.thickness_m;
190 // Skip the last layer — there are only N-1 interfaces.
191 if i + 1 == visual_layers.len() {
192 break;
193 }
194
195 // Distance from reference line along the axis, in meters.
196 let d = offset_m + direction_sense * cumulative_m;
197 // Local-frame plane origin: the axis scaled to distance `d`.
198 let local_origin = Point3::new(
199 axis_local.x * d,
200 axis_local.y * d,
201 axis_local.z * d,
202 );
203 // Transform to world, then subtract RTC offset so the plane sits
204 // in the same frame as the mesh (which already had RTC applied).
205 let world_origin = placement.transform_point(&local_origin);
206 let rtc_origin = Point3::new(
207 world_origin.x - rtc.0,
208 world_origin.y - rtc.1,
209 world_origin.z - rtc.2,
210 );
211 planes.push(Plane::new(rtc_origin, world_normal));
212 }
213
214 Some(planes)
215 }
216}
217
218/// A collapsed view of the layer stack after merging sub-mm layers into
219/// their thick neighbours. Each entry represents one slab that will be
220/// emitted as a sub-mesh.
221#[derive(Debug, Clone)]
222pub(crate) struct VisualLayer {
223 /// `IfcMaterial` id that colours the slab. Taken from the dominant
224 /// (thickest) source layer in the merge group so thin vapour barriers
225 /// don't hijack the slab's colour.
226 pub(crate) material_id: u32,
227 /// Total thickness of the slab in meters (sum of merged source layers).
228 pub(crate) thickness_m: f64,
229}
230
231/// Fold sub-mm layers into an adjacent visible layer so every emitted
232/// cutting plane sits on a real interface between two slabs that are
233/// both thick enough for stable clipping.
234///
235/// Strategy: start with one slab per source layer. Repeatedly pick the
236/// thinnest slab that is still below the clip-stable threshold and fold
237/// its thickness into the thicker of its two neighbours (the thicker
238/// neighbour's material wins because it dominates the merged slab's
239/// appearance). Stops once every slab is above threshold or only one slab
240/// remains.
241pub(crate) fn merge_thin_layers(layers: &[LayerInfo], unit_scale: f64) -> Vec<VisualLayer> {
242 let thresh = MIN_SLICEABLE_THICKNESS_M;
243 let mut slabs: Vec<VisualLayer> = layers
244 .iter()
245 .map(|l| VisualLayer {
246 material_id: l.material_id,
247 thickness_m: l.thickness * unit_scale,
248 })
249 .collect();
250
251 loop {
252 if slabs.len() <= 1 {
253 break;
254 }
255 // Find the thinnest sub-threshold slab.
256 let mut victim: Option<usize> = None;
257 let mut victim_thickness = thresh;
258 for (i, s) in slabs.iter().enumerate() {
259 if s.thickness_m < victim_thickness {
260 victim = Some(i);
261 victim_thickness = s.thickness_m;
262 }
263 }
264 let Some(v) = victim else { break };
265
266 // Fold into the thicker neighbour; its material dominates the slab.
267 let prev = if v > 0 { Some(v - 1) } else { None };
268 let next = if v + 1 < slabs.len() {
269 Some(v + 1)
270 } else {
271 None
272 };
273 let target = match (prev, next) {
274 (Some(p), Some(n)) => {
275 if slabs[p].thickness_m >= slabs[n].thickness_m {
276 p
277 } else {
278 n
279 }
280 }
281 (Some(p), None) => p,
282 (None, Some(n)) => n,
283 (None, None) => break,
284 };
285 slabs[target].thickness_m += slabs[v].thickness_m;
286 // Adjust target index when removing a slab that preceded it.
287 slabs.remove(v);
288 }
289
290 slabs
291}
292
293/// True when the element's Body representation has exactly one item and
294/// that item carries no additional transform relative to the element's
295/// own placement. Only in that case do the layer planes (built from the
296/// element placement alone) sit in the same frame as the generated mesh.
297///
298/// We walk the IfcProductDefinitionShape → IfcShapeRepresentation tree,
299/// looking at the first representation that will actually contribute to
300/// the Body mesh. Any MappedItem, multi-item list, or item with a
301/// non-identity `Position` disqualifies the element from layer slicing.
302fn element_is_single_unshifted_item(
303 element: &DecodedEntity,
304 decoder: &mut EntityDecoder,
305) -> bool {
306 // Element attr 6 = Representation (IfcProductDefinitionShape).
307 let rep_attr = match element.get(6) {
308 Some(a) if !a.is_null() => a,
309 _ => return false,
310 };
311 let rep = match decoder.resolve_ref(rep_attr) {
312 Ok(Some(r)) => r,
313 _ => return false,
314 };
315 if rep.ifc_type != IfcType::IfcProductDefinitionShape {
316 return false;
317 }
318 // attr 2 = Representations (list of IfcShapeRepresentation).
319 let reps_attr = match rep.get(2) {
320 Some(a) => a,
321 None => return false,
322 };
323 let reps = match decoder.resolve_ref_list(reps_attr) {
324 Ok(r) => r,
325 Err(_) => return false,
326 };
327
328 for shape_rep in &reps {
329 if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
330 continue;
331 }
332 // Only inspect body-style representations — axis/curve/footprint
333 // don't contribute to the sliced mesh.
334 let is_body = shape_rep
335 .get(2)
336 .and_then(|a| a.as_string())
337 .map(|s| {
338 matches!(
339 s,
340 "Body"
341 | "SweptSolid"
342 | "SolidModel"
343 | "Brep"
344 | "CSG"
345 | "Clipping"
346 | "SurfaceModel"
347 | "Tessellation"
348 | "AdvancedSweptSolid"
349 | "AdvancedBrep"
350 )
351 })
352 .unwrap_or(false);
353 if !is_body {
354 continue;
355 }
356
357 // attr 3 = Items.
358 let items = match shape_rep.get(3).and_then(|a| a.as_list()) {
359 Some(l) => l,
360 None => return false,
361 };
362 if items.len() != 1 {
363 return false;
364 }
365 let item_id = match items.first().and_then(|v| v.as_entity_ref()) {
366 Some(id) => id,
367 None => return false,
368 };
369 let item = match decoder.decode_by_id(item_id) {
370 Ok(e) => e,
371 Err(_) => return false,
372 };
373
374 return item_has_identity_position(&item, decoder);
375 }
376
377 // No body-style representation found — nothing to slice.
378 false
379}
380
381/// True when the representation item carries no Position transform (or the
382/// Position is the identity). Supports the item types that actually show
383/// up with IfcMaterialLayerSetUsage in practice (extrusions, revolved /
384/// advanced swept solids, boolean clipping on top of those). Anything
385/// exotic returns false so we bail safely.
386fn item_has_identity_position(item: &DecodedEntity, decoder: &mut EntityDecoder) -> bool {
387 match item.ifc_type {
388 // Solid primitives with a Position at attribute 1.
389 IfcType::IfcExtrudedAreaSolid
390 | IfcType::IfcRevolvedAreaSolid
391 | IfcType::IfcSurfaceCurveSweptAreaSolid
392 | IfcType::IfcFixedReferenceSweptAreaSolid => {
393 attribute_placement_is_identity(item, 1, decoder)
394 }
395 // Boolean results wrap another operand; recurse on the first
396 // operand which carries the visible geometry.
397 IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
398 let first_operand_id = match item.get_ref(1) {
399 Some(id) => id,
400 None => return false,
401 };
402 match decoder.decode_by_id(first_operand_id) {
403 Ok(inner) => item_has_identity_position(&inner, decoder),
404 Err(_) => false,
405 }
406 }
407 // MappedItem applies a target transform by definition — always bail.
408 IfcType::IfcMappedItem => false,
409 // Tessellated / Brep / surface-model items have no Position
410 // attribute; the mesh already sits in the element's local frame.
411 IfcType::IfcFacetedBrep
412 | IfcType::IfcFacetedBrepWithVoids
413 | IfcType::IfcAdvancedBrep
414 | IfcType::IfcAdvancedBrepWithVoids
415 | IfcType::IfcTriangulatedFaceSet
416 | IfcType::IfcPolygonalFaceSet
417 | IfcType::IfcFaceBasedSurfaceModel
418 | IfcType::IfcShellBasedSurfaceModel => true,
419 _ => false,
420 }
421}
422
423/// Resolve a placement attribute and compare the resulting 4×4 to the
424/// identity matrix within a small tolerance. Returns true when the
425/// attribute is absent (treated as implicit identity).
426fn attribute_placement_is_identity(
427 entity: &DecodedEntity,
428 attr_index: usize,
429 decoder: &mut EntityDecoder,
430) -> bool {
431 let attr = match entity.get(attr_index) {
432 Some(a) => a,
433 None => return true,
434 };
435 if attr.is_null() {
436 return true;
437 }
438 let placement_id = match attr.as_entity_ref() {
439 Some(id) => id,
440 None => return false,
441 };
442 match crate::transform::parse_axis2_placement_3d_from_id(placement_id, decoder) {
443 Ok(m) => matrix_is_identity(&m),
444 Err(_) => false,
445 }
446}
447
448#[inline]
449fn matrix_is_identity(m: &Matrix4<f64>) -> bool {
450 const EPS: f64 = 1e-9;
451 let id = Matrix4::<f64>::identity();
452 for i in 0..4 {
453 for j in 0..4 {
454 if (m[(i, j)] - id[(i, j)]).abs() > EPS {
455 return false;
456 }
457 }
458 }
459 true
460}
461
462/// Cut `mesh` into one slab per layer using the pre-computed interface
463/// planes. Returns a [`SubMeshCollection`] where each entry's
464/// `geometry_id` is the corresponding layer's `material_id` (0 if the
465/// layer was an air gap / had no associated material).
466///
467/// Empty slabs (plane missed the mesh, or clipper returned nothing) are
468/// dropped — callers should treat an empty result as "fall back to
469/// unsliced mesh".
470fn slice_mesh_into_layers(
471 mesh: &Mesh,
472 visual_layers: &[VisualLayer],
473 planes: &[Plane],
474) -> SubMeshCollection {
475 debug_assert_eq!(planes.len() + 1, visual_layers.len());
476
477 let clipper = ClippingProcessor::new();
478 let mut out = SubMeshCollection::new();
479
480 for (i, layer) in visual_layers.iter().enumerate() {
481 let after_prev: Option<&Plane> = if i == 0 { None } else { planes.get(i - 1) };
482 let before_next: Option<&Plane> = if i + 1 == visual_layers.len() {
483 None
484 } else {
485 planes.get(i)
486 };
487
488 let mut slab = mesh.clone();
489
490 if let Some(plane) = after_prev {
491 if let Ok(clipped) = clipper.clip_mesh(&slab, plane) {
492 slab = clipped;
493 }
494 }
495 if let Some(plane) = before_next {
496 let flipped = Plane::new(plane.point, -plane.normal);
497 if let Ok(clipped) = clipper.clip_mesh(&slab, &flipped) {
498 slab = clipped;
499 }
500 }
501
502 if !slab.is_empty() {
503 out.sub_meshes.push(SubMesh::new(layer.material_id, slab));
504 }
505 }
506
507 out
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn li(material: u32, thickness: f64) -> LayerInfo {
515 LayerInfo { material_id: material, thickness }
516 }
517
518 #[test]
519 fn thin_middle_layer_folded_into_thicker_neighbour() {
520 // 100 mm core, 1 mm vapour barrier, 50 mm insulation — unit_scale
521 // = 0.001 so values are in meters after scaling.
522 let layers = vec![li(1, 100.0), li(2, 1.0), li(3, 50.0)];
523 let merged = merge_thin_layers(&layers, 0.001);
524 assert_eq!(merged.len(), 2, "3-layer stack with a sub-mm middle should collapse to 2 slabs");
525 // First slab absorbed the 1 mm barrier; thicker contributor keeps its material.
526 assert_eq!(merged[0].material_id, 1);
527 assert!((merged[0].thickness_m - 0.101).abs() < 1e-9);
528 assert_eq!(merged[1].material_id, 3);
529 assert!((merged[1].thickness_m - 0.050).abs() < 1e-9);
530 }
531
532 #[test]
533 fn all_thick_layers_stay_separate() {
534 let layers = vec![li(1, 50.0), li(2, 80.0), li(3, 30.0)];
535 let merged = merge_thin_layers(&layers, 0.001);
536 assert_eq!(merged.len(), 3);
537 assert_eq!(merged[0].material_id, 1);
538 assert_eq!(merged[1].material_id, 2);
539 assert_eq!(merged[2].material_id, 3);
540 }
541
542 #[test]
543 fn trailing_thin_layer_folds_into_previous_slab() {
544 let layers = vec![li(1, 50.0), li(2, 80.0), li(3, 1.0)];
545 let merged = merge_thin_layers(&layers, 0.001);
546 assert_eq!(merged.len(), 2, "sub-mm trailing layer merges into the previous slab");
547 assert_eq!(merged[1].material_id, 2);
548 assert!((merged[1].thickness_m - 0.081).abs() < 1e-9);
549 }
550
551 #[test]
552 fn leading_thin_layer_folds_into_next_slab() {
553 let layers = vec![li(1, 1.0), li(2, 80.0), li(3, 50.0)];
554 let merged = merge_thin_layers(&layers, 0.001);
555 assert_eq!(merged.len(), 2);
556 // First emitted slab is dominated by layer 2 (thicker than the 1 mm lead-in).
557 assert_eq!(merged[0].material_id, 2);
558 assert!((merged[0].thickness_m - 0.081).abs() < 1e-9);
559 assert_eq!(merged[1].material_id, 3);
560 }
561}