Skip to main content

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}