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}