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 = match self.process_element_with_material_layers(element, decoder, buildup, voids) {
61            Ok(Some(c)) => c,
62            Ok(None) => return None,
63            Err(_e) => {
64                // A sliceable wall whose base-mesh build errored falls back to a
65                // single solid. Record for the browser; warn for native.
66                self.push_layer_slice_diag(element.id, "skip:base-mesh-error");
67                crate::diag::diag_warn!(
68                    { element_id = element.id, reason = "base-mesh-error",
69                      "material-layers: sliceable element failed to slice, keeping single solid" }
70                    else {
71                        eprintln!("[material-layers] #{}: sliceable but slicing errored", element.id);
72                    }
73                );
74                return None;
75            }
76        };
77        if collection.sub_meshes.len() < 2 {
78            crate::diag::diag_warn!(
79                { element_id = element.id, sub_meshes = collection.sub_meshes.len(),
80                  reason = "fewer-than-two-sub-meshes",
81                  "material-layers: sliceable element produced too few sub-meshes, keeping single solid" }
82                else {
83                    eprintln!(
84                        "[material-layers] #{}: sliceable but produced {} sub-mesh(es) (<2) — keeping single solid",
85                        element.id,
86                        collection.sub_meshes.len()
87                    );
88                }
89            );
90            return None;
91        }
92        crate::diag::diag_debug!(
93            { element_id = element.id, sub_meshes = collection.sub_meshes.len(),
94              "material-layers: sliced element into layer sub-meshes" }
95            else {
96                eprintln!(
97                    "[material-layers] #{}: sliced into {} layer sub-meshes",
98                    element.id,
99                    collection.sub_meshes.len()
100                );
101            }
102        );
103        // Mesh hygiene: slicing the base mesh by layer-interface planes can
104        // introduce zero-area/collinear slivers at the cut, and this layered
105        // path early-returns to its callers (process_element_with_submeshes /
106        // _and_voids) BEFORE their own cleanup loop runs — so clean here, the
107        // single gateway both layered call sites share. See clean_degenerate.
108        let mut collection = collection;
109        for sub in &mut collection.sub_meshes {
110            sub.mesh.clean_degenerate();
111        }
112        Some(collection)
113    }
114
115    /// Process an element into per-layer sub-meshes, subtracting any
116    /// openings first.
117    ///
118    /// Returns `Ok(None)` when the buildup isn't sliceable (single material,
119    /// constituent set, profile set, degenerate) so the caller can fall back
120    /// to the existing sub-mesh-voids path without duplicating work.
121    ///
122    /// Each emitted [`SubMesh`] carries the layer's `IfcMaterial` entity ID
123    /// as its `geometry_id` — callers key colour lookup on that.
124    pub fn process_element_with_material_layers(
125        &self,
126        element: &DecodedEntity,
127        decoder: &mut EntityDecoder,
128        buildup: &LayerBuildup,
129        void_index: &FxHashMap<u32, Vec<u32>>,
130    ) -> Result<Option<SubMeshCollection>> {
131        let (layers, axis, direction_sense, offset) = match buildup {
132            LayerBuildup::Sliceable {
133                layers,
134                axis,
135                direction_sense,
136                offset_from_reference_line,
137            } => (layers, *axis, *direction_sense, *offset_from_reference_line),
138            LayerBuildup::NotSliceable => return Ok(None),
139        };
140
141        if layers.len() < 2 {
142            self.push_layer_slice_diag(element.id, "skip:fewer-than-2-layers");
143            return Ok(None);
144        }
145
146        // Bail when the representation isn't a single item with identity
147        // Position — otherwise layer planes (built from element placement
148        // only) would be in a different frame than the mesh. Callers fall
149        // through to the unsliced path in that case.
150        if !element_is_single_unshifted_item(element, decoder) {
151            self.push_layer_slice_diag(element.id, "skip:not-single-unshifted-item");
152            return Ok(None);
153        }
154
155        // Merge sub-mm layers into their thick neighbours before any
156        // geometry work so cutting planes never sit on degenerate
157        // interfaces. When everything collapses to one visual layer there
158        // is nothing to slice.
159        let visual_layers = merge_thin_layers(layers, self.unit_scale);
160        if visual_layers.len() < 2 {
161            self.push_layer_slice_diag(element.id, "skip:thin-layers-collapsed-to-1");
162            return Ok(None);
163        }
164
165        // Void subtraction happens on the merged mesh (cheap + topology-safe).
166        let base_mesh = self.process_element_with_voids(element, decoder, void_index)?;
167        if base_mesh.is_empty() {
168            self.push_layer_slice_diag(element.id, "skip:empty-base-mesh");
169            return Ok(None);
170        }
171
172        // Build the interface planes in the SAME frame as `base_mesh` (world −
173        // rtc − per-element local origin). Returns None when we can't resolve the
174        // element's placement — fall back.
175        let planes = match self.build_layer_planes(
176            element,
177            decoder,
178            &visual_layers,
179            axis,
180            direction_sense,
181            offset,
182            base_mesh.origin,
183        ) {
184            Some(p) => p,
185            None => {
186                self.push_layer_slice_diag(element.id, "skip:placement-unresolved");
187                return Ok(None);
188            }
189        };
190        if planes.is_empty() {
191            self.push_layer_slice_diag(element.id, "skip:no-interface-planes");
192            return Ok(None);
193        }
194
195        let collection = slice_mesh_into_layers(&base_mesh, &visual_layers, &planes);
196        self.push_layer_slice_diag(
197            element.id,
198            if collection.sub_meshes.len() >= 2 { "ok:sliced" } else { "skip:cut-produced-<2" },
199        );
200        Ok(Some(collection))
201    }
202
203    /// Convert layer thicknesses + axis/offset into N-1 world-space planes
204    /// aligned with the layer interfaces.
205    ///
206    /// All plane normals point in the `direction_sense` direction so
207    /// slicing logic is uniform: "keep front of plane i" = "beyond interface
208    /// i, deeper into the stack".
209    fn build_layer_planes(
210        &self,
211        element: &DecodedEntity,
212        decoder: &mut EntityDecoder,
213        visual_layers: &[VisualLayer],
214        axis: LayerAxis,
215        direction_sense: f64,
216        offset: f64,
217        // Per-element local-frame origin the base mesh was relativized by
218        // (#1114; `[0,0,0]` when local frame is off). The mesh stores vertices as
219        // `world - rtc - origin`, so the planes must subtract it too or they'd
220        // sit a whole building-placement away from the relativized mesh and slice
221        // nothing.
222        mesh_origin: [f64; 3],
223    ) -> Option<Vec<Plane>> {
224        // Use the same placement the mesh was built with: placement ×
225        // scale_transform (scales translation only).
226        let mut placement = self.get_placement_transform_from_element(element, decoder).ok()?;
227        self.scale_transform(&mut placement);
228
229        let scale = self.unit_scale;
230        let rtc = self.rtc_offset;
231
232        // Axis unit vector in local coordinates.
233        let axis_local = {
234            let v = axis.unit_vector();
235            Vector3::new(v[0], v[1], v[2])
236        };
237
238        // World-space normal (rotation only; translation irrelevant for directions).
239        // Direction sense flips the normal so "front" always means "deeper
240        // into the layer stack".
241        let rotation = placement.fixed_view::<3, 3>(0, 0);
242        let world_normal = (rotation * axis_local)
243            .try_normalize(1e-12)?
244            * direction_sense;
245
246        let offset_m = offset * scale;
247
248        let mut planes = Vec::with_capacity(visual_layers.len().saturating_sub(1));
249        let mut cumulative_m = 0.0_f64;
250        for (i, layer) in visual_layers.iter().enumerate() {
251            cumulative_m += layer.thickness_m;
252            // Skip the last layer — there are only N-1 interfaces.
253            if i + 1 == visual_layers.len() {
254                break;
255            }
256
257            // Distance from reference line along the axis, in meters.
258            let d = offset_m + direction_sense * cumulative_m;
259            // Local-frame plane origin: the axis scaled to distance `d`.
260            let local_origin = Point3::new(
261                axis_local.x * d,
262                axis_local.y * d,
263                axis_local.z * d,
264            );
265            // Transform to world, then subtract RTC offset so the plane sits
266            // in the same frame as the mesh (which already had RTC applied).
267            let world_origin = placement.transform_point(&local_origin);
268            // Match the mesh frame: world − rtc − per-element local origin.
269            let frame_origin = Point3::new(
270                world_origin.x - rtc.0 - mesh_origin[0],
271                world_origin.y - rtc.1 - mesh_origin[1],
272                world_origin.z - rtc.2 - mesh_origin[2],
273            );
274            planes.push(Plane::new(frame_origin, world_normal));
275        }
276
277        Some(planes)
278    }
279}
280
281/// A collapsed view of the layer stack after merging sub-mm layers into
282/// their thick neighbours. Each entry represents one slab that will be
283/// emitted as a sub-mesh.
284#[derive(Debug, Clone)]
285pub(crate) struct VisualLayer {
286    /// `IfcMaterial` id that colours the slab. Taken from the dominant
287    /// (thickest) source layer in the merge group so thin vapour barriers
288    /// don't hijack the slab's colour.
289    pub(crate) material_id: u32,
290    /// Total thickness of the slab in meters (sum of merged source layers).
291    pub(crate) thickness_m: f64,
292}
293
294/// Fold sub-mm layers into an adjacent visible layer so every emitted
295/// cutting plane sits on a real interface between two slabs that are
296/// both thick enough for stable clipping.
297///
298/// Strategy: start with one slab per source layer. Repeatedly pick the
299/// thinnest slab that is still below the clip-stable threshold and fold
300/// its thickness into the thicker of its two neighbours (the thicker
301/// neighbour's material wins because it dominates the merged slab's
302/// appearance). Stops once every slab is above threshold or only one slab
303/// remains.
304pub(crate) fn merge_thin_layers(layers: &[LayerInfo], unit_scale: f64) -> Vec<VisualLayer> {
305    let thresh = MIN_SLICEABLE_THICKNESS_M;
306    let mut slabs: Vec<VisualLayer> = layers
307        .iter()
308        .map(|l| VisualLayer {
309            material_id: l.material_id,
310            thickness_m: l.thickness * unit_scale,
311        })
312        .collect();
313
314    loop {
315        if slabs.len() <= 1 {
316            break;
317        }
318        // Find the thinnest sub-threshold slab.
319        let mut victim: Option<usize> = None;
320        let mut victim_thickness = thresh;
321        for (i, s) in slabs.iter().enumerate() {
322            if s.thickness_m < victim_thickness {
323                victim = Some(i);
324                victim_thickness = s.thickness_m;
325            }
326        }
327        let Some(v) = victim else { break };
328
329        // Fold into the thicker neighbour; its material dominates the slab.
330        let prev = if v > 0 { Some(v - 1) } else { None };
331        let next = if v + 1 < slabs.len() {
332            Some(v + 1)
333        } else {
334            None
335        };
336        let target = match (prev, next) {
337            (Some(p), Some(n)) => {
338                if slabs[p].thickness_m >= slabs[n].thickness_m {
339                    p
340                } else {
341                    n
342                }
343            }
344            (Some(p), None) => p,
345            (None, Some(n)) => n,
346            (None, None) => break,
347        };
348        slabs[target].thickness_m += slabs[v].thickness_m;
349        // Adjust target index when removing a slab that preceded it.
350        slabs.remove(v);
351    }
352
353    slabs
354}
355
356/// True when the element's Body representation has exactly one item and
357/// that item carries no additional transform relative to the element's
358/// own placement. Only in that case do the layer planes (built from the
359/// element placement alone) sit in the same frame as the generated mesh.
360///
361/// We walk the IfcProductDefinitionShape → IfcShapeRepresentation tree,
362/// looking at the first representation that will actually contribute to
363/// the Body mesh. Any MappedItem, multi-item list, or item with a
364/// non-identity `Position` disqualifies the element from layer slicing.
365fn element_is_single_unshifted_item(
366    element: &DecodedEntity,
367    decoder: &mut EntityDecoder,
368) -> bool {
369    // Element attr 6 = Representation (IfcProductDefinitionShape).
370    let rep_attr = match element.get(6) {
371        Some(a) if !a.is_null() => a,
372        _ => return false,
373    };
374    let rep = match decoder.resolve_ref(rep_attr) {
375        Ok(Some(r)) => r,
376        _ => return false,
377    };
378    if rep.ifc_type != IfcType::IfcProductDefinitionShape {
379        return false;
380    }
381    // attr 2 = Representations (list of IfcShapeRepresentation).
382    let reps_attr = match rep.get(2) {
383        Some(a) => a,
384        None => return false,
385    };
386    let reps = match decoder.resolve_ref_list(reps_attr) {
387        Ok(r) => r,
388        Err(_) => return false,
389    };
390
391    for shape_rep in &reps {
392        if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
393            continue;
394        }
395        // Only inspect body-style representations — axis/curve/footprint
396        // don't contribute to the sliced mesh. Uses the single canonical
397        // body-geometry predicate so this stays in lockstep with the meshing
398        // path (a mapped/surface item disqualifies slicing below regardless,
399        // via item_has_identity_position).
400        let is_body = super::effective_rep_type(shape_rep)
401            .map(super::is_body_representation)
402            .unwrap_or(false);
403        if !is_body {
404            continue;
405        }
406
407        // attr 3 = Items.
408        let items = match shape_rep.get(3).and_then(|a| a.as_list()) {
409            Some(l) => l,
410            None => return false,
411        };
412        if items.len() != 1 {
413            return false;
414        }
415        let item_id = match items.first().and_then(|v| v.as_entity_ref()) {
416            Some(id) => id,
417            None => return false,
418        };
419        let item = match decoder.decode_by_id(item_id) {
420            Ok(e) => e,
421            Err(_) => return false,
422        };
423
424        return item_has_identity_position(&item, decoder);
425    }
426
427    // No body-style representation found — nothing to slice.
428    false
429}
430
431/// True when the representation item carries no Position transform (or the
432/// Position is the identity). Supports the item types that actually show
433/// up with IfcMaterialLayerSetUsage in practice (extrusions, revolved /
434/// advanced swept solids, boolean clipping on top of those). Anything
435/// exotic returns false so we bail safely.
436fn item_has_identity_position(item: &DecodedEntity, decoder: &mut EntityDecoder) -> bool {
437    match item.ifc_type {
438        // Solid primitives with a Position at attribute 1.
439        IfcType::IfcExtrudedAreaSolid
440        | IfcType::IfcRevolvedAreaSolid
441        | IfcType::IfcSurfaceCurveSweptAreaSolid
442        | IfcType::IfcFixedReferenceSweptAreaSolid => {
443            attribute_placement_is_identity(item, 1, decoder)
444        }
445        // Boolean results wrap another operand; recurse on the first
446        // operand which carries the visible geometry.
447        IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
448            let first_operand_id = match item.get_ref(1) {
449                Some(id) => id,
450                None => return false,
451            };
452            match decoder.decode_by_id(first_operand_id) {
453                Ok(inner) => item_has_identity_position(&inner, decoder),
454                Err(_) => false,
455            }
456        }
457        // MappedItem applies a target transform by definition — always bail.
458        IfcType::IfcMappedItem => false,
459        // Tessellated / Brep / surface-model items have no Position
460        // attribute; the mesh already sits in the element's local frame.
461        IfcType::IfcFacetedBrep
462        | IfcType::IfcFacetedBrepWithVoids
463        | IfcType::IfcAdvancedBrep
464        | IfcType::IfcAdvancedBrepWithVoids
465        | IfcType::IfcTriangulatedFaceSet
466        | IfcType::IfcTriangulatedIrregularNetwork
467        | IfcType::IfcPolygonalFaceSet
468        | IfcType::IfcFaceBasedSurfaceModel
469        | IfcType::IfcShellBasedSurfaceModel => true,
470        _ => false,
471    }
472}
473
474/// Resolve a placement attribute and compare the resulting 4×4 to the
475/// identity matrix within a small tolerance. Returns true when the
476/// attribute is absent (treated as implicit identity).
477fn attribute_placement_is_identity(
478    entity: &DecodedEntity,
479    attr_index: usize,
480    decoder: &mut EntityDecoder,
481) -> bool {
482    let attr = match entity.get(attr_index) {
483        Some(a) => a,
484        None => return true,
485    };
486    if attr.is_null() {
487        return true;
488    }
489    let placement_id = match attr.as_entity_ref() {
490        Some(id) => id,
491        None => return false,
492    };
493    match crate::transform::parse_axis2_placement_3d_from_id(placement_id, decoder) {
494        Ok(m) => matrix_is_identity(&m),
495        Err(_) => false,
496    }
497}
498
499#[inline]
500fn matrix_is_identity(m: &Matrix4<f64>) -> bool {
501    const EPS: f64 = 1e-9;
502    let id = Matrix4::<f64>::identity();
503    for i in 0..4 {
504        for j in 0..4 {
505            if (m[(i, j)] - id[(i, j)]).abs() > EPS {
506                return false;
507            }
508        }
509    }
510    true
511}
512
513/// Cut `mesh` into one slab per layer using the pre-computed interface
514/// planes. Returns a [`SubMeshCollection`] where each entry's
515/// `geometry_id` is the corresponding layer's `material_id` (0 if the
516/// layer was an air gap / had no associated material).
517///
518/// Empty slabs (plane missed the mesh, or clipper returned nothing) are
519/// dropped — callers should treat an empty result as "fall back to
520/// unsliced mesh".
521fn slice_mesh_into_layers(
522    mesh: &Mesh,
523    visual_layers: &[VisualLayer],
524    planes: &[Plane],
525) -> SubMeshCollection {
526    debug_assert_eq!(planes.len() + 1, visual_layers.len());
527
528    let clipper = ClippingProcessor::new();
529    let mut out = SubMeshCollection::new();
530
531    // Carve each layer's band off a running REMAINDER at the interface planes,
532    // and DO NOT cap the cut. Two design choices, one fix:
533    //
534    //  - No cap. Capping closed every slab, so each SHARED interface became a
535    //    doubled, coincident, oppositely-wound full-cross-section sheet: the wall
536    //    rendered solid (the interior caps are backface-culled) but the emitted
537    //    mesh was non-watertight (degree-4 interface edges) and ~3x the triangles
538    //    — the "ghost face" on opening-cut layered walls. Uncapped, each band is
539    //    the wall's outer skin within its layer range; the union of the bands is
540    //    exactly the wall's watertight outer shell, partitioned per material. The
541    //    interface is no longer a 3D sheet; the 2D section re-closes each band's
542    //    open contour at the interface chord (its loop builder is bidirectional,
543    //    see `drawing-2d` `PolygonBuilder`), so per-layer section fills are intact.
544    //
545    //  - Progressive carve, not a fresh clone per band. Both sides of every
546    //    interface are produced by the SAME clip of the SAME remainder, so their
547    //    cut tessellations are identical and the bands weld edge-for-edge (no
548    //    T-junctions, no hairline cracks). Clipping independent clones instead let
549    //    a twice-clipped middle band diverge from its neighbour at the second
550    //    interface, leaving open T-junction edges.
551    //
552    // `clip_mesh` keeps the half-space the plane normal points INTO and builds a
553    // fresh `Mesh` (origin [0,0,0]); the input mesh + planes are in the element's
554    // local frame (#1114), so the origin is restored on each band below.
555    let mut remainder = mesh.clone();
556
557    for (i, layer) in visual_layers.iter().enumerate() {
558        let before_next: Option<&Plane> = if i + 1 == visual_layers.len() {
559            None
560        } else {
561            planes.get(i)
562        };
563
564        let mut slab = match before_next {
565            Some(plane) => {
566                let flipped = Plane::new(plane.point, -plane.normal);
567                // band = remainder below the interface; remainder = above it.
568                match (
569                    clipper.clip_mesh(&remainder, &flipped),
570                    clipper.clip_mesh(&remainder, plane),
571                ) {
572                    (Ok(band), Ok(rest)) => {
573                        remainder = rest;
574                        band
575                    }
576                    // Degenerate interface clip: emit the whole remainder for this
577                    // layer rather than dropping geometry, and stop carving.
578                    _ => std::mem::take(&mut remainder),
579                }
580            }
581            // Last layer: everything left in the remainder.
582            None => std::mem::take(&mut remainder),
583        };
584
585        slab.origin = mesh.origin;
586
587        if !slab.is_empty() {
588            out.sub_meshes.push(SubMesh::new(layer.material_id, slab));
589        }
590    }
591
592    out
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    fn li(material: u32, thickness: f64) -> LayerInfo {
600        LayerInfo { material_id: material, thickness }
601    }
602
603    #[test]
604    fn thin_middle_layer_folded_into_thicker_neighbour() {
605        // 100 mm core, 1 mm vapour barrier, 50 mm insulation — unit_scale
606        // = 0.001 so values are in meters after scaling.
607        let layers = vec![li(1, 100.0), li(2, 1.0), li(3, 50.0)];
608        let merged = merge_thin_layers(&layers, 0.001);
609        assert_eq!(merged.len(), 2, "3-layer stack with a sub-mm middle should collapse to 2 slabs");
610        // First slab absorbed the 1 mm barrier; thicker contributor keeps its material.
611        assert_eq!(merged[0].material_id, 1);
612        assert!((merged[0].thickness_m - 0.101).abs() < 1e-9);
613        assert_eq!(merged[1].material_id, 3);
614        assert!((merged[1].thickness_m - 0.050).abs() < 1e-9);
615    }
616
617    #[test]
618    fn all_thick_layers_stay_separate() {
619        let layers = vec![li(1, 50.0), li(2, 80.0), li(3, 30.0)];
620        let merged = merge_thin_layers(&layers, 0.001);
621        assert_eq!(merged.len(), 3);
622        assert_eq!(merged[0].material_id, 1);
623        assert_eq!(merged[1].material_id, 2);
624        assert_eq!(merged[2].material_id, 3);
625    }
626
627    #[test]
628    fn trailing_thin_layer_folds_into_previous_slab() {
629        let layers = vec![li(1, 50.0), li(2, 80.0), li(3, 1.0)];
630        let merged = merge_thin_layers(&layers, 0.001);
631        assert_eq!(merged.len(), 2, "sub-mm trailing layer merges into the previous slab");
632        assert_eq!(merged[1].material_id, 2);
633        assert!((merged[1].thickness_m - 0.081).abs() < 1e-9);
634    }
635
636    #[test]
637    fn leading_thin_layer_folds_into_next_slab() {
638        let layers = vec![li(1, 1.0), li(2, 80.0), li(3, 50.0)];
639        let merged = merge_thin_layers(&layers, 0.001);
640        assert_eq!(merged.len(), 2);
641        // First emitted slab is dominated by layer 2 (thicker than the 1 mm lead-in).
642        assert_eq!(merged[0].material_id, 2);
643        assert!((merged[0].thickness_m - 0.081).abs() < 1e-9);
644        assert_eq!(merged[1].material_id, 3);
645    }
646}