Skip to main content

ifc_lite_geometry/
material_layer_index.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 Index
6//!
7//! Maps building elements to the material buildup that lets us slice their
8//! single swept-solid mesh into per-layer sub-meshes (e.g. a wall's core,
9//! insulation, and finish showing up as separately coloured slabs).
10//!
11//! The index scans `IfcRelAssociatesMaterial` once per file and resolves each
12//! element to a [`LayerBuildup`] when the associated material is a
13//! [`IfcMaterialLayerSetUsage`] pointing at a plain
14//! [`IfcMaterialLayerSet`]. Other material representations (single
15//! `IfcMaterial`, `IfcMaterialConstituentSet`, `IfcMaterialProfileSet`,
16//! legacy `IfcMaterialList`, or layer sets with per-layer offsets used for
17//! tapered walls) do not map to a set of planar cutting planes and are
18//! recorded as [`LayerBuildup::NotSliceable`] so the caller can fall back
19//! to its existing path.
20
21use ifc_lite_core::{DecodedEntity, EntityDecoder, EntityScanner, IfcType};
22use rustc_hash::FxHashMap;
23
24/// Which local axis the material layers stack along.
25///
26/// Mirrors `IfcLayerSetDirectionEnum` in the spec:
27/// <https://standards.buildingsmart.org/IFC/RELEASE/IFC4_ADD2_TC1/HTML/schema/ifcmaterialresource/lexical/ifclayersetdirectionenum.htm>
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum LayerAxis {
30    /// Along local +X (rare for walls; used when the wall's "layers" run
31    /// along its length, e.g. horizontal segmentation).
32    Axis1,
33    /// Along local +Y — walls (thickness direction).
34    Axis2,
35    /// Along local +Z — slabs, roofs, coverings (through-depth).
36    Axis3,
37}
38
39impl LayerAxis {
40    /// Return the unit vector of this axis in the element's local frame.
41    pub fn unit_vector(self) -> [f64; 3] {
42        match self {
43            LayerAxis::Axis1 => [1.0, 0.0, 0.0],
44            LayerAxis::Axis2 => [0.0, 1.0, 0.0],
45            LayerAxis::Axis3 => [0.0, 0.0, 1.0],
46        }
47    }
48}
49
50/// One layer in a [`LayerBuildup`].
51///
52/// `material_id` is the express ID of the associated `IfcMaterial`, or `0`
53/// when the layer has no material reference (valid per spec — represents an
54/// air gap / ventilated cavity).
55#[derive(Debug, Clone, PartialEq)]
56pub struct LayerInfo {
57    /// `IfcMaterial` entity ID for color lookup. Zero means no material.
58    pub material_id: u32,
59    /// Layer thickness in the project's length unit (same unit as the IFC
60    /// file). The caller is responsible for applying the project unit scale
61    /// when mapping to world coordinates.
62    pub thickness: f64,
63}
64
65/// Layer buildup resolved for one element.
66///
67/// `Sliceable` carries everything needed to produce N-1 cutting planes in
68/// the element's local frame and color each slice by its material.
69/// `NotSliceable` is emitted when we identified a material association but
70/// it doesn't map cleanly to planar slicing (constituents, single material,
71/// profile set, tapered with offsets, etc.) — callers should fall back to
72/// the existing mesh path and apply a uniform element-level colour.
73#[derive(Debug, Clone, PartialEq)]
74pub enum LayerBuildup {
75    Sliceable {
76        /// Layers in the order they appear in `IfcMaterialLayerSet.MaterialLayers`.
77        layers: Vec<LayerInfo>,
78        /// Which local axis the layers stack along.
79        axis: LayerAxis,
80        /// `+1.0` for `POSITIVE`, `-1.0` for `NEGATIVE`.
81        direction_sense: f64,
82        /// Signed distance from the element's reference line to the start
83        /// face of the first layer, in the project's length unit.
84        offset_from_reference_line: f64,
85    },
86    NotSliceable,
87}
88
89impl LayerBuildup {
90    pub fn is_sliceable(&self) -> bool {
91        matches!(self, LayerBuildup::Sliceable { .. })
92    }
93}
94
95/// Map from element entity ID to its resolved [`LayerBuildup`].
96#[derive(Debug, Default, Clone, PartialEq)]
97pub struct MaterialLayerIndex {
98    element_to_buildup: FxHashMap<u32, LayerBuildup>,
99}
100
101/// Flat, wire-friendly encoding of a [`MaterialLayerIndex`], produced by
102/// [`MaterialLayerIndex::to_flat`] and reconstructed by
103/// [`MaterialLayerIndex::from_flat`].
104///
105/// The streaming pre-pass builds the index ONCE (from the `IfcRelAssociatesMaterial`
106/// spans it already collected) and ships this encoding to every geometry worker,
107/// so each worker's first `processGeometryBatch` skips the per-worker
108/// [`MaterialLayerIndex::from_content`] full-file decode scan. All fields are
109/// SoA parallel arrays so they cross the wasm/JS boundary as plain typed arrays:
110///
111/// * `element_ids[i]` — the element express id of record `i`.
112/// * `axis[i]` — `0` = `NotSliceable`; `1`/`2`/`3` = sliceable along `Axis1`/`Axis2`/`Axis3`.
113/// * `layer_counts[i]` — number of layers of record `i` (`0` for `NotSliceable`).
114/// * `direction_sense[i]`, `offset[i]` — the sliceable scalars (`0.0` for `NotSliceable`).
115/// * `layer_material_ids` / `layer_thicknesses` — per-layer values, concatenated in
116///   record order; record `i` consumes `layer_counts[i]` entries starting after the
117///   layers of every earlier record.
118#[derive(Debug, Default, Clone, PartialEq)]
119pub struct MaterialLayerFlat {
120    pub element_ids: Vec<u32>,
121    pub axis: Vec<u32>,
122    pub layer_counts: Vec<u32>,
123    pub direction_sense: Vec<f64>,
124    pub offset: Vec<f64>,
125    pub layer_material_ids: Vec<u32>,
126    pub layer_thicknesses: Vec<f64>,
127}
128
129impl MaterialLayerIndex {
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Scan `content` for `IfcRelAssociatesMaterial` and build the index.
135    ///
136    /// One pass over the file: for each association we resolve the
137    /// `RelatingMaterial` once and insert the result under every related
138    /// object ID. Elements that associate with a non-sliceable material are
139    /// still inserted (as `NotSliceable`) so callers can distinguish
140    /// "has a material, can't slice" from "no material association at all".
141    pub fn from_content<T>(content: &T, decoder: &mut EntityDecoder) -> Self
142    where
143        T: AsRef<[u8]> + ?Sized,
144    {
145        let content = content.as_ref();
146        let mut index = Self::new();
147        let mut scanner = EntityScanner::new(content);
148
149        while let Some((id, type_name, start, end)) = scanner.next_entity() {
150            if type_name != "IFCRELASSOCIATESMATERIAL" {
151                continue;
152            }
153            index.insert_association(id, start, end, decoder);
154        }
155
156        index
157    }
158
159    /// Build the index from PRE-COLLECTED `IfcRelAssociatesMaterial` spans
160    /// instead of re-walking the file. The streaming pre-pass already stashes
161    /// every association span during its single scan, so it builds the index
162    /// once here and ships it to the workers (each of which would otherwise
163    /// re-run [`Self::from_content`]'s full-file scan on its first batch).
164    ///
165    /// Byte-identical to [`Self::from_content`] on the same file: both feed the
166    /// exact same spans, in the exact same file order, through the shared
167    /// [`Self::insert_association`] step (whose only order-sensitivity — "prefer
168    /// Sliceable, never overwrite it with NotSliceable" — is preserved because
169    /// the pre-pass collects spans in scan order). `spans` are `(id, start, end)`.
170    pub fn from_spans(spans: &[(u32, usize, usize)], decoder: &mut EntityDecoder) -> Self {
171        let mut index = Self::new();
172        for &(id, start, end) in spans {
173            index.insert_association(id, start, end, decoder);
174        }
175        index
176    }
177
178    /// Resolve one `IfcRelAssociatesMaterial` span and fold it into the index.
179    /// Shared by [`Self::from_content`] (scanner-driven) and [`Self::from_spans`]
180    /// (span-driven) so the two paths cannot drift.
181    fn insert_association(
182        &mut self,
183        id: u32,
184        start: usize,
185        end: usize,
186        decoder: &mut EntityDecoder,
187    ) {
188        let entity = match decoder.decode_at_with_id(id, start, end) {
189            Ok(e) => e,
190            Err(_) => return,
191        };
192
193        // IfcRelAssociatesMaterial:
194        //   4: RelatedObjects (list)
195        //   5: RelatingMaterial (IfcMaterialSelect ref)
196        let relating_id = match entity.get_ref(5) {
197            Some(id) => id,
198            None => return,
199        };
200        let related_attr = match entity.get(4) {
201            Some(a) => a,
202            None => return,
203        };
204        let related_ids: Vec<u32> = match related_attr.as_list() {
205            Some(list) => list.iter().filter_map(|v| v.as_entity_ref()).collect(),
206            None => return,
207        };
208        if related_ids.is_empty() {
209            return;
210        }
211
212        let buildup = resolve_buildup(relating_id, decoder);
213        for obj_id in related_ids {
214            // The same element may be associated twice (once via element,
215            // once via its type). Prefer the Sliceable entry if we see
216            // one; never overwrite Sliceable with NotSliceable.
217            match self.element_to_buildup.get(&obj_id) {
218                Some(LayerBuildup::Sliceable { .. }) => continue,
219                _ => {
220                    self.element_to_buildup.insert(obj_id, buildup.clone());
221                }
222            }
223        }
224    }
225
226    /// Serialize the index into a flat [`MaterialLayerFlat`] for the wire.
227    /// Round-trips exactly through [`Self::from_flat`] (proven in this module's
228    /// tests): `from_flat(idx.to_flat()) == idx` for every index.
229    pub fn to_flat(&self) -> MaterialLayerFlat {
230        let mut flat = MaterialLayerFlat::default();
231        for (&element_id, buildup) in &self.element_to_buildup {
232            flat.element_ids.push(element_id);
233            match buildup {
234                LayerBuildup::NotSliceable => {
235                    flat.axis.push(0);
236                    flat.layer_counts.push(0);
237                    flat.direction_sense.push(0.0);
238                    flat.offset.push(0.0);
239                }
240                LayerBuildup::Sliceable {
241                    layers,
242                    axis,
243                    direction_sense,
244                    offset_from_reference_line,
245                } => {
246                    flat.axis.push(match axis {
247                        LayerAxis::Axis1 => 1,
248                        LayerAxis::Axis2 => 2,
249                        LayerAxis::Axis3 => 3,
250                    });
251                    flat.layer_counts.push(layers.len() as u32);
252                    flat.direction_sense.push(*direction_sense);
253                    flat.offset.push(*offset_from_reference_line);
254                    for layer in layers {
255                        flat.layer_material_ids.push(layer.material_id);
256                        flat.layer_thicknesses.push(layer.thickness);
257                    }
258                }
259            }
260        }
261        flat
262    }
263
264    /// Reconstruct an index from the flat SoA arrays produced by
265    /// [`Self::to_flat`]. Defensive against short/misaligned inputs (a
266    /// truncated wire buffer stops early rather than panicking), but on
267    /// well-formed input it is the exact inverse of `to_flat`.
268    #[allow(clippy::too_many_arguments)]
269    pub fn from_flat(
270        element_ids: &[u32],
271        axis: &[u32],
272        layer_counts: &[u32],
273        direction_sense: &[f64],
274        offset: &[f64],
275        layer_material_ids: &[u32],
276        layer_thicknesses: &[f64],
277    ) -> Self {
278        let mut index = Self::new();
279        let n = element_ids.len();
280        // Every per-record array must be at least as long as element_ids;
281        // bail on a malformed buffer instead of indexing out of bounds.
282        if axis.len() < n
283            || layer_counts.len() < n
284            || direction_sense.len() < n
285            || offset.len() < n
286        {
287            return index;
288        }
289        let mut cursor = 0usize;
290        for i in 0..n {
291            let count = layer_counts[i] as usize;
292            let buildup = if axis[i] == 0 {
293                LayerBuildup::NotSliceable
294            } else {
295                if cursor + count > layer_material_ids.len()
296                    || cursor + count > layer_thicknesses.len()
297                {
298                    return index;
299                }
300                let mut layers = Vec::with_capacity(count);
301                for k in 0..count {
302                    layers.push(LayerInfo {
303                        material_id: layer_material_ids[cursor + k],
304                        thickness: layer_thicknesses[cursor + k],
305                    });
306                }
307                LayerBuildup::Sliceable {
308                    layers,
309                    axis: match axis[i] {
310                        1 => LayerAxis::Axis1,
311                        3 => LayerAxis::Axis3,
312                        // 2 (and any stray value) map to Axis2, the wall default.
313                        _ => LayerAxis::Axis2,
314                    },
315                    direction_sense: direction_sense[i],
316                    offset_from_reference_line: offset[i],
317                }
318            };
319            cursor += count;
320            index.element_to_buildup.insert(element_ids[i], buildup);
321        }
322        index
323    }
324
325    /// Get the resolved buildup for an element, or `None` if the element
326    /// has no material association at all.
327    pub fn get(&self, element_id: u32) -> Option<&LayerBuildup> {
328        self.element_to_buildup.get(&element_id)
329    }
330
331    /// Returns `true` when the element has a recorded buildup that is
332    /// `LayerBuildup::Sliceable` — i.e. its single swept solid can be cut
333    /// into per-layer slabs.
334    ///
335    /// Used by the wasm-bindings layer to decide whether an aggregated
336    /// `IfcWall` parent already produces per-layer sub-meshes (so its
337    /// `IfcBuildingElementPart` children can be skipped when the
338    /// merge-layers toggle is on — see issue #540).
339    pub fn is_sliceable(&self, element_id: u32) -> bool {
340        matches!(
341            self.element_to_buildup.get(&element_id),
342            Some(LayerBuildup::Sliceable { .. })
343        )
344    }
345
346    /// Number of elements with a recorded buildup (sliceable or not).
347    pub fn len(&self) -> usize {
348        self.element_to_buildup.len()
349    }
350
351    pub fn is_empty(&self) -> bool {
352        self.element_to_buildup.is_empty()
353    }
354
355    /// Count how many of the recorded buildups are actually sliceable.
356    /// Useful for logging / statistics — not on the hot path.
357    pub fn sliceable_count(&self) -> usize {
358        self.element_to_buildup
359            .values()
360            .filter(|b| b.is_sliceable())
361            .count()
362    }
363}
364
365/// Resolve an `IfcMaterialSelect` ID into a [`LayerBuildup`].
366///
367/// Follows the one path that maps to planar cutting: `LayerSetUsage ->
368/// LayerSet -> Layers`. Anything else is `NotSliceable`.
369fn resolve_buildup(material_select_id: u32, decoder: &mut EntityDecoder) -> LayerBuildup {
370    let entity = match decoder.decode_by_id(material_select_id) {
371        Ok(e) => e,
372        Err(_) => return LayerBuildup::NotSliceable,
373    };
374
375    match entity.ifc_type {
376        IfcType::IfcMaterialLayerSetUsage => resolve_layer_set_usage(&entity, decoder),
377        // All other material representations either carry no geometry
378        // (IfcMaterial, IfcMaterialList, IfcMaterialConstituentSet) or
379        // describe cross-section rather than layers (IfcMaterialProfileSet,
380        // IfcMaterialProfileSetUsage). Caller falls back to uniform colour.
381        _ => LayerBuildup::NotSliceable,
382    }
383}
384
385/// Decode an `IfcMaterialLayerSetUsage` into a sliceable buildup.
386///
387/// Attribute layout
388/// (<https://standards.buildingsmart.org/IFC/RELEASE/IFC4_ADD2_TC1/HTML/schema/ifcproductextension/lexical/ifcmateriallayersetusage.htm>):
389///   0: ForLayerSet (ref IfcMaterialLayerSet)
390///   1: LayerSetDirection (IfcLayerSetDirectionEnum)
391///   2: DirectionSense (IfcDirectionSenseEnum)
392///   3: OffsetFromReferenceLine (IfcLengthMeasure)
393fn resolve_layer_set_usage(usage: &DecodedEntity, decoder: &mut EntityDecoder) -> LayerBuildup {
394    let layer_set_id = match usage.get_ref(0) {
395        Some(id) => id,
396        None => return LayerBuildup::NotSliceable,
397    };
398    let axis = match usage
399        .get(1)
400        .and_then(|a| a.as_enum())
401        .map(str::to_ascii_uppercase)
402    {
403        Some(s) if s == "AXIS1" => LayerAxis::Axis1,
404        Some(s) if s == "AXIS2" => LayerAxis::Axis2,
405        Some(s) if s == "AXIS3" => LayerAxis::Axis3,
406        // Missing or unrecognised → walls default to AXIS2 per spec, but
407        // rather than guess, treat as unsliceable.
408        _ => return LayerBuildup::NotSliceable,
409    };
410    let direction_sense = match usage
411        .get(2)
412        .and_then(|a| a.as_enum())
413        .map(str::to_ascii_uppercase)
414    {
415        Some(s) if s == "POSITIVE" => 1.0_f64,
416        Some(s) if s == "NEGATIVE" => -1.0_f64,
417        _ => return LayerBuildup::NotSliceable,
418    };
419    let offset = usage.get_float(3).unwrap_or(0.0);
420
421    let layer_set_entity = match decoder.decode_by_id(layer_set_id) {
422        Ok(e) => e,
423        Err(_) => return LayerBuildup::NotSliceable,
424    };
425    if layer_set_entity.ifc_type != IfcType::IfcMaterialLayerSet {
426        return LayerBuildup::NotSliceable;
427    }
428
429    // IfcMaterialLayerSet.MaterialLayers at attr 0
430    let layer_ids: Vec<u32> = match layer_set_entity.get(0).and_then(|a| a.as_list()) {
431        Some(list) => list.iter().filter_map(|v| v.as_entity_ref()).collect(),
432        None => return LayerBuildup::NotSliceable,
433    };
434    if layer_ids.is_empty() {
435        return LayerBuildup::NotSliceable;
436    }
437
438    let mut layers = Vec::with_capacity(layer_ids.len());
439    for layer_id in &layer_ids {
440        let layer = match decoder.decode_by_id(*layer_id) {
441            Ok(e) => e,
442            Err(_) => return LayerBuildup::NotSliceable,
443        };
444        // Tapered walls use IfcMaterialLayerWithOffsets (subtype of
445        // IfcMaterialLayer). The interface between such layers is a ruled
446        // surface, not a plane — bail to uniform fallback.
447        if layer.ifc_type != IfcType::IfcMaterialLayer {
448            return LayerBuildup::NotSliceable;
449        }
450        // IfcMaterialLayer:
451        //   0: Material (IfcMaterial ref, OPTIONAL)
452        //   1: LayerThickness (IfcPositiveLengthMeasure)
453        let material_id = layer.get_ref(0).unwrap_or(0);
454        let thickness = layer.get_float(1).unwrap_or(0.0);
455        if !thickness.is_finite() || thickness <= 0.0 {
456            // Spec forbids zero/negative thickness but malformed files exist.
457            // Skip the layer rather than the whole buildup.
458            continue;
459        }
460        layers.push(LayerInfo {
461            material_id,
462            thickness,
463        });
464    }
465
466    if layers.len() < 2 {
467        // A single-layer wall doesn't need slicing — uniform fallback is fine.
468        return LayerBuildup::NotSliceable;
469    }
470
471    LayerBuildup::Sliceable {
472        layers,
473        axis,
474        direction_sense,
475        offset_from_reference_line: offset,
476    }
477}
478
479#[cfg(test)]
480mod flat_roundtrip_tests {
481    use super::*;
482
483    fn sample_index() -> MaterialLayerIndex {
484        let mut index = MaterialLayerIndex::new();
485        // A three-layer sliceable wall (Axis2, POSITIVE, offset -0.15).
486        index.element_to_buildup.insert(
487            100,
488            LayerBuildup::Sliceable {
489                layers: vec![
490                    LayerInfo { material_id: 200, thickness: 0.05 },
491                    LayerInfo { material_id: 201, thickness: 0.2 },
492                    LayerInfo { material_id: 200, thickness: 0.05 },
493                ],
494                axis: LayerAxis::Axis2,
495                direction_sense: 1.0,
496                offset_from_reference_line: -0.15,
497            },
498        );
499        // A two-layer slab along Axis3, NEGATIVE, with a zero-material air gap.
500        index.element_to_buildup.insert(
501            101,
502            LayerBuildup::Sliceable {
503                layers: vec![
504                    LayerInfo { material_id: 0, thickness: 0.1 },
505                    LayerInfo { material_id: 300, thickness: 0.25 },
506                ],
507                axis: LayerAxis::Axis3,
508                direction_sense: -1.0,
509                offset_from_reference_line: 0.0,
510            },
511        );
512        // A NotSliceable association (single material / constituent set).
513        index
514            .element_to_buildup
515            .insert(102, LayerBuildup::NotSliceable);
516        index
517    }
518
519    #[test]
520    fn to_flat_from_flat_is_identity() {
521        let index = sample_index();
522        let flat = index.to_flat();
523        let restored = MaterialLayerIndex::from_flat(
524            &flat.element_ids,
525            &flat.axis,
526            &flat.layer_counts,
527            &flat.direction_sense,
528            &flat.offset,
529            &flat.layer_material_ids,
530            &flat.layer_thicknesses,
531        );
532        assert_eq!(
533            index, restored,
534            "flat round-trip must reproduce the index bit-for-bit"
535        );
536    }
537
538    #[test]
539    fn empty_index_round_trips_to_empty() {
540        let index = MaterialLayerIndex::new();
541        let flat = index.to_flat();
542        assert!(flat.element_ids.is_empty());
543        let restored = MaterialLayerIndex::from_flat(
544            &flat.element_ids,
545            &flat.axis,
546            &flat.layer_counts,
547            &flat.direction_sense,
548            &flat.offset,
549            &flat.layer_material_ids,
550            &flat.layer_thicknesses,
551        );
552        assert_eq!(index, restored);
553        assert!(restored.is_empty());
554    }
555
556    #[test]
557    fn from_flat_bails_on_truncated_buffer() {
558        // element_ids says one record but the per-record arrays are empty:
559        // reconstruction must stop cleanly, not panic or index OOB.
560        let restored = MaterialLayerIndex::from_flat(&[100], &[], &[], &[], &[], &[], &[]);
561        assert!(restored.is_empty());
562    }
563}