Skip to main content

ifc_lite_processing/style/
material.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-chain colour resolution (issue #913 / #407).
6//!
7//! Ported from the browser pipeline so the backend colours elements whose
8//! appearance lives on the *material* rather than on the geometry. Most BIM
9//! authoring tools (ArchiCAD IFC2x3, etc.) assign glass-vs-frame appearance via
10//!
11//! ```text
12//! element ─IfcRelAssociatesMaterial→ material select
13//!   material ─IfcMaterialDefinitionRepresentation→ IfcStyledRepresentation
14//!     └─ orphan IfcStyledItem (null Item) → IfcSurfaceStyle → IfcColourRgb
15//! ```
16//!
17//! Pre-fix the backend only read `IfcStyledItem` attached directly to geometry,
18//! so material-only-styled files rendered as the default type colour.
19//!
20//! The orphan-styled-item colours are extracted by the caller (the processor's
21//! existing `IfcStyledItem` walk) and passed in as `orphan_styled_items`; this
22//! module walks the material `SELECT` graph and joins the two.
23
24use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
25use rustc_hash::FxHashMap;
26
27use super::TRANSPARENCY_ALPHA_THRESHOLD;
28
29/// Maximum recursion depth for material resolution (guards malformed cycles).
30const MAX_MATERIAL_RESOLVE_DEPTH: u8 = 4;
31
32/// Build the `element id → material colours` map: every colour each element
33/// inherits from its associated material(s), in resolution order. The single
34/// general-path fallback picks the first opaque colour ([`pick_opaque_first`]);
35/// the opening sub-mesh path alternates transparent/opaque
36/// ([`pick_material_style_for_submesh`]) to split glass vs frame.
37///
38/// - `material_def_reprs`: material id → its `IfcStyledRepresentation` ids.
39/// - `orphan_styled_items`: styled-item id → colour, for styled items with a
40///   null `Item` (i.e. material appearances).
41/// - `element_to_material`: element id → material `SELECT` id.
42pub fn build_element_material_colors(
43    material_def_reprs: &FxHashMap<u32, Vec<u32>>,
44    orphan_styled_items: &FxHashMap<u32, [f32; 4]>,
45    element_to_material: &FxHashMap<u32, u32>,
46    decoder: &mut EntityDecoder,
47) -> FxHashMap<u32, Vec<[f32; 4]>> {
48    if element_to_material.is_empty() || orphan_styled_items.is_empty() {
49        return FxHashMap::default();
50    }
51
52    let material_styles = build_material_style_index(material_def_reprs, orphan_styled_items, decoder);
53    if material_styles.is_empty() {
54        return FxHashMap::default();
55    }
56
57    let mut out: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
58    for (&element_id, &material_select_id) in element_to_material {
59        let mut colors: Vec<[f32; 4]> = Vec::new();
60        for material_id in resolve_material_ids(material_select_id, decoder) {
61            if let Some(mat_colors) = material_styles.get(&material_id) {
62                colors.extend(mat_colors);
63            }
64        }
65        if !colors.is_empty() {
66            out.insert(element_id, colors);
67        }
68    }
69    out
70}
71
72/// Flatten a `material id → colours` map into `material id → colour` by picking
73/// the first opaque colour per material ([`pick_opaque_first`]). Used to key
74/// layered sub-mesh colour lookups on material id — each layer slice's
75/// `geometry_id` is its `IfcMaterial` entity id.
76pub fn flatten_material_color_index(
77    material_styles: &FxHashMap<u32, Vec<[f32; 4]>>,
78) -> FxHashMap<u32, [f32; 4]> {
79    material_styles
80        .iter()
81        .filter_map(|(&mat_id, colors)| pick_opaque_first(colors).map(|c| (mat_id, c)))
82        .collect()
83}
84
85/// Pick the first opaque colour (alpha ≥ threshold), else the first colour.
86pub fn pick_opaque_first(colors: &[[f32; 4]]) -> Option<[f32; 4]> {
87    if colors.is_empty() {
88        return None;
89    }
90    Some(
91        colors
92            .iter()
93            .find(|c| c[3] >= TRANSPARENCY_ALPHA_THRESHOLD)
94            .copied()
95            .unwrap_or(colors[0]),
96    )
97}
98
99/// Pick a material colour for one sub-mesh, alternating preference so a window
100/// distributes its frame (opaque) and glazing (transparent) colours across
101/// sub-meshes instead of painting every part the same. `prefer_transparent`
102/// is toggled by the caller per sub-mesh.
103pub fn pick_material_style_for_submesh(
104    colors: &[[f32; 4]],
105    prefer_transparent: bool,
106) -> Option<[f32; 4]> {
107    if colors.is_empty() {
108        return None;
109    }
110    let matched = if prefer_transparent {
111        colors.iter().find(|c| c[3] < TRANSPARENCY_ALPHA_THRESHOLD)
112    } else {
113        colors.iter().find(|c| c[3] >= TRANSPARENCY_ALPHA_THRESHOLD)
114    };
115    Some(matched.copied().unwrap_or(colors[0]))
116}
117
118/// Resolve one sub-mesh's colour, given its already-resolved direct-style colour.
119///
120/// This owns the precedence *below* the direct geometry style and the stateful
121/// transparent/opaque alternation, so every consumer (the native pipeline and
122/// the browser) applies the identical rule — the §4.2 "one place for the colour
123/// decision" guarantee. The caller resolves `direct_color` itself (its
124/// geometry-style index + `IfcMappedItem` traversal differ by data layout —
125/// `GeometryStyleInfo` vs `[f32; 4]` — so that one step stays at the boundary):
126///
127/// 1. `direct_color` (a direct `IfcStyledItem`, incl. mapped geometry) wins;
128/// 2. else the material chain, alternating transparent/opaque per sub-mesh via
129///    `mat_color_idx` (incremented here, only when a material list is present),
130///    so a window's glass and frame split across its parts;
131/// 3. else the element colour (already defaulted to `default_color_for_type`).
132pub fn resolve_submesh_color(
133    direct_color: Option<[f32; 4]>,
134    material_colors: Option<&[[f32; 4]]>,
135    mat_color_idx: &mut usize,
136    element_color: [f32; 4],
137) -> [f32; 4] {
138    if let Some(color) = direct_color {
139        return color;
140    }
141    if let Some(colors) = material_colors {
142        let prefer_transparent = *mat_color_idx % 2 == 0;
143        *mat_color_idx += 1;
144        if let Some(color) = pick_material_style_for_submesh(colors, prefer_transparent) {
145            return color;
146        }
147    }
148    element_color
149}
150
151/// material id → colours, by following each material's styled representations to
152/// the orphan styled items they reference.
153pub fn build_material_style_index(
154    material_def_reprs: &FxHashMap<u32, Vec<u32>>,
155    orphan_styled_items: &FxHashMap<u32, [f32; 4]>,
156    decoder: &mut EntityDecoder,
157) -> FxHashMap<u32, Vec<[f32; 4]>> {
158    let mut material_styles: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
159    for (&material_id, styled_repr_ids) in material_def_reprs {
160        for &styled_repr_id in styled_repr_ids {
161            let Ok(styled_repr) = decoder.decode_by_id(styled_repr_id) else {
162                continue;
163            };
164            // IfcStyledRepresentation : IfcRepresentation — Items is attr 3.
165            for styled_item_id in extract_refs_from_list(&styled_repr, 3) {
166                if let Some(&color) = orphan_styled_items.get(&styled_item_id) {
167                    material_styles.entry(material_id).or_default().push(color);
168                }
169            }
170        }
171    }
172    material_styles
173}
174
175/// Resolve a material `SELECT` to the individual `IfcMaterial` ids it contains.
176pub fn resolve_material_ids(material_select_id: u32, decoder: &mut EntityDecoder) -> Vec<u32> {
177    resolve_material_ids_inner(material_select_id, decoder, 0)
178}
179
180fn resolve_material_ids_inner(
181    material_select_id: u32,
182    decoder: &mut EntityDecoder,
183    depth: u8,
184) -> Vec<u32> {
185    if depth >= MAX_MATERIAL_RESOLVE_DEPTH {
186        return vec![];
187    }
188    let Ok(entity) = decoder.decode_by_id(material_select_id) else {
189        return vec![];
190    };
191    match entity.ifc_type {
192        IfcType::IfcMaterial => vec![material_select_id],
193        // IfcMaterialList.Materials (attr 0)
194        IfcType::IfcMaterialList => extract_refs_from_list(&entity, 0),
195        // IfcMaterialLayerSetUsage.ForLayerSet (attr 0) → IfcMaterialLayerSet
196        IfcType::IfcMaterialLayerSetUsage => entity
197            .get_ref(0)
198            .map(|id| resolve_material_ids_inner(id, decoder, depth + 1))
199            .unwrap_or_default(),
200        // IfcMaterialLayerSet.MaterialLayers (attr 0) → IfcMaterialLayer.Material (attr 0)
201        IfcType::IfcMaterialLayerSet => extract_nested_material_ids(&entity, 0, 0, decoder),
202        // IfcMaterialConstituentSet.MaterialConstituents (attr 2) → .Material (attr 2)
203        IfcType::IfcMaterialConstituentSet => extract_nested_material_ids(&entity, 2, 2, decoder),
204        // IfcMaterialProfileSet.MaterialProfiles (attr 2) → IfcMaterialProfile.Material (attr 2)
205        IfcType::IfcMaterialProfileSet => extract_nested_material_ids(&entity, 2, 2, decoder),
206        IfcType::IfcMaterialProfileSetUsage | IfcType::IfcMaterialProfileSetUsageTapering => entity
207            .get_ref(0)
208            .map(|id| resolve_material_ids_inner(id, decoder, depth + 1))
209            .unwrap_or_default(),
210        _ => vec![],
211    }
212}
213
214/// Read a list of container refs at `container_list_attr_idx`, then the
215/// `IfcMaterial` ref at `material_attr_idx` on each container.
216fn extract_nested_material_ids(
217    entity: &DecodedEntity,
218    container_list_attr_idx: usize,
219    material_attr_idx: usize,
220    decoder: &mut EntityDecoder,
221) -> Vec<u32> {
222    let mut materials = Vec::new();
223    for container_id in extract_refs_from_list(entity, container_list_attr_idx) {
224        if let Ok(container) = decoder.decode_by_id(container_id) {
225            if let Some(mat_id) = container.get_ref(material_attr_idx) {
226                materials.push(mat_id);
227            }
228        }
229    }
230    materials
231}
232
233fn extract_refs_from_list(entity: &DecodedEntity, index: usize) -> Vec<u32> {
234    entity
235        .get(index)
236        .and_then(|attr| attr.as_list())
237        .map(|list| list.iter().filter_map(|v| v.as_entity_ref()).collect())
238        .unwrap_or_default()
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    const OPAQUE: [f32; 4] = [0.6, 0.6, 0.6, 1.0];
246    const GLASS: [f32; 4] = [0.5, 0.7, 0.9, 0.4];
247    const ELEMENT: [f32; 4] = [0.1, 0.1, 0.1, 1.0];
248
249    #[test]
250    fn submesh_direct_style_wins_without_touching_counter() {
251        let mut idx = 0usize;
252        let direct = [0.9, 0.2, 0.2, 1.0];
253        let colors = [OPAQUE, GLASS];
254        assert_eq!(
255            resolve_submesh_color(Some(direct), Some(&colors), &mut idx, ELEMENT),
256            direct
257        );
258        assert_eq!(idx, 0, "the alternation counter must not advance when a direct style wins");
259    }
260
261    #[test]
262    fn submesh_material_alternates_transparent_opaque() {
263        let colors = [OPAQUE, GLASS];
264        let mut idx = 0usize;
265        // even index → prefer transparent (glass)
266        assert_eq!(resolve_submesh_color(None, Some(&colors), &mut idx, ELEMENT), GLASS);
267        // odd index → prefer opaque (frame)
268        assert_eq!(resolve_submesh_color(None, Some(&colors), &mut idx, ELEMENT), OPAQUE);
269        assert_eq!(idx, 2, "counter advances once per material-resolved sub-mesh");
270    }
271
272    #[test]
273    fn submesh_falls_back_to_element_color() {
274        let mut idx = 0usize;
275        assert_eq!(resolve_submesh_color(None, None, &mut idx, ELEMENT), ELEMENT);
276        assert_eq!(idx, 0, "no material list → counter untouched");
277        // Empty material list also falls through to the element colour.
278        let empty: [[f32; 4]; 0] = [];
279        assert_eq!(resolve_submesh_color(None, Some(&empty), &mut idx, ELEMENT), ELEMENT);
280    }
281}