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)]
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)]
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)]
97pub struct MaterialLayerIndex {
98 element_to_buildup: FxHashMap<u32, LayerBuildup>,
99}
100
101impl MaterialLayerIndex {
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 /// Scan `content` for `IfcRelAssociatesMaterial` and build the index.
107 ///
108 /// One pass over the file: for each association we resolve the
109 /// `RelatingMaterial` once and insert the result under every related
110 /// object ID. Elements that associate with a non-sliceable material are
111 /// still inserted (as `NotSliceable`) so callers can distinguish
112 /// "has a material, can't slice" from "no material association at all".
113 pub fn from_content<T>(content: &T, decoder: &mut EntityDecoder) -> Self
114 where
115 T: AsRef<[u8]> + ?Sized,
116 {
117 let content = content.as_ref();
118 let mut index = Self::new();
119 let mut scanner = EntityScanner::new(content);
120
121 while let Some((id, type_name, start, end)) = scanner.next_entity() {
122 if type_name != "IFCRELASSOCIATESMATERIAL" {
123 continue;
124 }
125 let entity = match decoder.decode_at_with_id(id, start, end) {
126 Ok(e) => e,
127 Err(_) => continue,
128 };
129
130 // IfcRelAssociatesMaterial:
131 // 4: RelatedObjects (list)
132 // 5: RelatingMaterial (IfcMaterialSelect ref)
133 let relating_id = match entity.get_ref(5) {
134 Some(id) => id,
135 None => continue,
136 };
137 let related_attr = match entity.get(4) {
138 Some(a) => a,
139 None => continue,
140 };
141 let related_ids: Vec<u32> = match related_attr.as_list() {
142 Some(list) => list.iter().filter_map(|v| v.as_entity_ref()).collect(),
143 None => continue,
144 };
145 if related_ids.is_empty() {
146 continue;
147 }
148
149 let buildup = resolve_buildup(relating_id, decoder);
150 for obj_id in related_ids {
151 // The same element may be associated twice (once via element,
152 // once via its type). Prefer the Sliceable entry if we see
153 // one; never overwrite Sliceable with NotSliceable.
154 match index.element_to_buildup.get(&obj_id) {
155 Some(LayerBuildup::Sliceable { .. }) => continue,
156 _ => {
157 index.element_to_buildup.insert(obj_id, buildup.clone());
158 }
159 }
160 }
161 }
162
163 index
164 }
165
166 /// Get the resolved buildup for an element, or `None` if the element
167 /// has no material association at all.
168 pub fn get(&self, element_id: u32) -> Option<&LayerBuildup> {
169 self.element_to_buildup.get(&element_id)
170 }
171
172 /// Returns `true` when the element has a recorded buildup that is
173 /// `LayerBuildup::Sliceable` — i.e. its single swept solid can be cut
174 /// into per-layer slabs.
175 ///
176 /// Used by the wasm-bindings layer to decide whether an aggregated
177 /// `IfcWall` parent already produces per-layer sub-meshes (so its
178 /// `IfcBuildingElementPart` children can be skipped when the
179 /// merge-layers toggle is on — see issue #540).
180 pub fn is_sliceable(&self, element_id: u32) -> bool {
181 matches!(
182 self.element_to_buildup.get(&element_id),
183 Some(LayerBuildup::Sliceable { .. })
184 )
185 }
186
187 /// Number of elements with a recorded buildup (sliceable or not).
188 pub fn len(&self) -> usize {
189 self.element_to_buildup.len()
190 }
191
192 pub fn is_empty(&self) -> bool {
193 self.element_to_buildup.is_empty()
194 }
195
196 /// Count how many of the recorded buildups are actually sliceable.
197 /// Useful for logging / statistics — not on the hot path.
198 pub fn sliceable_count(&self) -> usize {
199 self.element_to_buildup
200 .values()
201 .filter(|b| b.is_sliceable())
202 .count()
203 }
204}
205
206/// Resolve an `IfcMaterialSelect` ID into a [`LayerBuildup`].
207///
208/// Follows the one path that maps to planar cutting: `LayerSetUsage ->
209/// LayerSet -> Layers`. Anything else is `NotSliceable`.
210fn resolve_buildup(material_select_id: u32, decoder: &mut EntityDecoder) -> LayerBuildup {
211 let entity = match decoder.decode_by_id(material_select_id) {
212 Ok(e) => e,
213 Err(_) => return LayerBuildup::NotSliceable,
214 };
215
216 match entity.ifc_type {
217 IfcType::IfcMaterialLayerSetUsage => resolve_layer_set_usage(&entity, decoder),
218 // All other material representations either carry no geometry
219 // (IfcMaterial, IfcMaterialList, IfcMaterialConstituentSet) or
220 // describe cross-section rather than layers (IfcMaterialProfileSet,
221 // IfcMaterialProfileSetUsage). Caller falls back to uniform colour.
222 _ => LayerBuildup::NotSliceable,
223 }
224}
225
226/// Decode an `IfcMaterialLayerSetUsage` into a sliceable buildup.
227///
228/// Attribute layout
229/// (<https://standards.buildingsmart.org/IFC/RELEASE/IFC4_ADD2_TC1/HTML/schema/ifcproductextension/lexical/ifcmateriallayersetusage.htm>):
230/// 0: ForLayerSet (ref IfcMaterialLayerSet)
231/// 1: LayerSetDirection (IfcLayerSetDirectionEnum)
232/// 2: DirectionSense (IfcDirectionSenseEnum)
233/// 3: OffsetFromReferenceLine (IfcLengthMeasure)
234fn resolve_layer_set_usage(usage: &DecodedEntity, decoder: &mut EntityDecoder) -> LayerBuildup {
235 let layer_set_id = match usage.get_ref(0) {
236 Some(id) => id,
237 None => return LayerBuildup::NotSliceable,
238 };
239 let axis = match usage
240 .get(1)
241 .and_then(|a| a.as_enum())
242 .map(str::to_ascii_uppercase)
243 {
244 Some(s) if s == "AXIS1" => LayerAxis::Axis1,
245 Some(s) if s == "AXIS2" => LayerAxis::Axis2,
246 Some(s) if s == "AXIS3" => LayerAxis::Axis3,
247 // Missing or unrecognised → walls default to AXIS2 per spec, but
248 // rather than guess, treat as unsliceable.
249 _ => return LayerBuildup::NotSliceable,
250 };
251 let direction_sense = match usage
252 .get(2)
253 .and_then(|a| a.as_enum())
254 .map(str::to_ascii_uppercase)
255 {
256 Some(s) if s == "POSITIVE" => 1.0_f64,
257 Some(s) if s == "NEGATIVE" => -1.0_f64,
258 _ => return LayerBuildup::NotSliceable,
259 };
260 let offset = usage.get_float(3).unwrap_or(0.0);
261
262 let layer_set_entity = match decoder.decode_by_id(layer_set_id) {
263 Ok(e) => e,
264 Err(_) => return LayerBuildup::NotSliceable,
265 };
266 if layer_set_entity.ifc_type != IfcType::IfcMaterialLayerSet {
267 return LayerBuildup::NotSliceable;
268 }
269
270 // IfcMaterialLayerSet.MaterialLayers at attr 0
271 let layer_ids: Vec<u32> = match layer_set_entity.get(0).and_then(|a| a.as_list()) {
272 Some(list) => list.iter().filter_map(|v| v.as_entity_ref()).collect(),
273 None => return LayerBuildup::NotSliceable,
274 };
275 if layer_ids.is_empty() {
276 return LayerBuildup::NotSliceable;
277 }
278
279 let mut layers = Vec::with_capacity(layer_ids.len());
280 for layer_id in &layer_ids {
281 let layer = match decoder.decode_by_id(*layer_id) {
282 Ok(e) => e,
283 Err(_) => return LayerBuildup::NotSliceable,
284 };
285 // Tapered walls use IfcMaterialLayerWithOffsets (subtype of
286 // IfcMaterialLayer). The interface between such layers is a ruled
287 // surface, not a plane — bail to uniform fallback.
288 if layer.ifc_type != IfcType::IfcMaterialLayer {
289 return LayerBuildup::NotSliceable;
290 }
291 // IfcMaterialLayer:
292 // 0: Material (IfcMaterial ref, OPTIONAL)
293 // 1: LayerThickness (IfcPositiveLengthMeasure)
294 let material_id = layer.get_ref(0).unwrap_or(0);
295 let thickness = layer.get_float(1).unwrap_or(0.0);
296 if !thickness.is_finite() || thickness <= 0.0 {
297 // Spec forbids zero/negative thickness but malformed files exist.
298 // Skip the layer rather than the whole buildup.
299 continue;
300 }
301 layers.push(LayerInfo {
302 material_id,
303 thickness,
304 });
305 }
306
307 if layers.len() < 2 {
308 // A single-layer wall doesn't need slicing — uniform fallback is fine.
309 return LayerBuildup::NotSliceable;
310 }
311
312 LayerBuildup::Sliceable {
313 layers,
314 axis,
315 direction_sense,
316 offset_from_reference_line: offset,
317 }
318}