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}