Skip to main content

ifc_lite_processing/style/
indexed_colour.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//! `IfcIndexedColourMap` resolution (issue #913, Phase 2).
6//!
7//! Ported from the browser pipeline so the backend resolves the same authored
8//! colors. CATIA / 3DEXPERIENCE exports color tessellated geometry through
9//! `IFCINDEXEDCOLOURMAP` + `IFCCOLOURRGBLIST` with no `IFCSTYLEDITEM` chain;
10//! pre-fix the backend ignored them and fell back to the default type color
11//! (issue #663).
12//!
13//! Two levels of fidelity:
14//! - [`FullIndexedColourMap::dominant`] — one colour per face set, used to fill
15//!   the element style index (#663, the common single-colour case).
16//! - [`split_mesh_by_indexed_colour`] — one sub-mesh per palette group, so a
17//!   face set whose `ColourIndex` assigns different colours to different
18//!   triangles renders correctly (issue #858).
19
20use super::Rgba;
21use ifc_lite_core::{DecodedEntity, EntityDecoder};
22use ifc_lite_geometry::Mesh;
23
24/// A fully resolved `IfcIndexedColourMap`: the palette plus a per-triangle
25/// index into it (in `CoordIndex` order, which the triangulated-face-set
26/// processor preserves 1:1).
27#[derive(Debug, Clone)]
28pub struct FullIndexedColourMap {
29    /// The face set this map colours (`MappedTo`).
30    pub geometry_id: u32,
31    /// The colour palette (`IfcColourRgbList.ColourList`).
32    pub colours: Vec<Rgba>,
33    /// Per-triangle 0-based index into `colours`, one entry per triangle.
34    pub triangle_palette: Vec<usize>,
35}
36
37impl FullIndexedColourMap {
38    /// Whether >=2 distinct palette entries are referenced by triangles — the
39    /// split threshold. Zero-alloc with an early exit: true as soon as any triangle
40    /// differs from the first, so a uniform (single-colour) map costs one scan with
41    /// no heap work.
42    pub(crate) fn has_multiple_colours(&self) -> bool {
43        self.triangle_palette
44            .first()
45            .is_some_and(|&first| self.triangle_palette.iter().any(|&c| c != first))
46    }
47
48    /// The most-frequently-referenced colour (single-colour maps return their
49    /// only colour). Used to fill the element style index.
50    pub fn dominant(&self) -> Rgba {
51        let mut counts: rustc_hash::FxHashMap<usize, u32> = rustc_hash::FxHashMap::default();
52        for &p in &self.triangle_palette {
53            *counts.entry(p).or_insert(0) += 1;
54        }
55        let idx = counts
56            .iter()
57            .max_by_key(|(_, c)| *c)
58            .map(|(&i, _)| i)
59            .unwrap_or(0);
60        self.colours.get(idx).copied().unwrap_or(Rgba::new(0.8, 0.8, 0.8, 1.0))
61    }
62}
63
64/// Resolve an `IfcIndexedColourMap` to its palette + per-triangle indices.
65///
66/// Schema (IFC4):
67/// - attr 0: `MappedTo` → `IfcTessellatedFaceSet`
68/// - attr 1: `Opacity` (optional `0..=1`, `1.0` when omitted)
69/// - attr 2: `Colours` → `IfcColourRgbList` (attr 0 = `ColourList`)
70/// - attr 3: `ColourIndex` → 1-based palette index per triangle
71pub fn resolve_indexed_colour_map_full(
72    entity: &DecodedEntity,
73    decoder: &mut EntityDecoder,
74) -> Option<FullIndexedColourMap> {
75    let geometry_id = entity.get_ref(0)?;
76    let opacity = entity
77        .get(1)
78        .and_then(|a| a.as_float())
79        .map(|v| v as f32)
80        .unwrap_or(1.0)
81        .clamp(0.0, 1.0);
82    let colours_id = entity.get_ref(2)?;
83    let index_attr = entity.get(3)?;
84    let index_list = index_attr.as_list()?;
85    if index_list.is_empty() {
86        return None;
87    }
88
89    let colours_entity = decoder.decode_by_id(colours_id).ok()?;
90    let colour_list = colours_entity.get(0)?.as_list()?;
91    let colours: Vec<Rgba> = colour_list
92        .iter()
93        .filter_map(|c| {
94            let rgb = c.as_list()?;
95            let r = rgb.first().and_then(|v| v.as_float())? as f32;
96            let g = rgb.get(1).and_then(|v| v.as_float())? as f32;
97            let b = rgb.get(2).and_then(|v| v.as_float())? as f32;
98            Some(Rgba::new(r, g, b, opacity))
99        })
100        .collect();
101    if colours.is_empty() {
102        return None;
103    }
104
105    let max_idx = colours.len() - 1;
106    let triangle_palette: Vec<usize> = index_list
107        .iter()
108        .map(|v| {
109            let one_based = v.as_int().unwrap_or(1).max(1) as usize;
110            (one_based - 1).min(max_idx)
111        })
112        .collect();
113
114    Some(FullIndexedColourMap {
115        geometry_id,
116        colours,
117        triangle_palette,
118    })
119}
120
121/// Split a flat-shaded mesh into one sub-mesh per palette group.
122///
123/// Returns `None` (caller keeps the single dominant-coloured mesh) unless the
124/// mesh triangle count matches `map.triangle_palette` exactly — a mismatch
125/// means CSG/void cutting changed the topology, so the per-triangle mapping no
126/// longer applies. Triangle `i` of the mesh corresponds to `CoordIndex[i]`
127/// because the triangulated-face-set processor preserves triangle order.
128pub fn split_mesh_by_indexed_colour(
129    mesh: &Mesh,
130    map: &FullIndexedColourMap,
131) -> Option<Vec<(Rgba, Mesh)>> {
132    let tri_count = mesh.indices.len() / 3;
133    if tri_count == 0 || tri_count != map.triangle_palette.len() {
134        return None;
135    }
136    if !map.has_multiple_colours() {
137        return None; // single colour — nothing to split
138    }
139
140    let has_normals = mesh.normals.len() == mesh.positions.len();
141    let rtc_applied = mesh.rtc_applied;
142    let origin = mesh.origin;
143    let local_bounds = mesh.local_bounds;
144    let local_to_world = mesh.local_to_world;
145
146    // One accumulator per palette entry; built lazily so empty groups vanish.
147    #[derive(Default)]
148    struct Group {
149        positions: Vec<f32>,
150        normals: Vec<f32>,
151        indices: Vec<u32>,
152    }
153    let mut groups: Vec<Option<Group>> = (0..map.colours.len()).map(|_| None).collect();
154
155    for (tri, &palette) in map.triangle_palette.iter().enumerate() {
156        // Defensive: drop the *whole* triangle if any of its three vertices is
157        // out of range — skipping a single vertex would emit a malformed
158        // 1- or 2-vertex triangle.
159        let tri_in_range = (0..3).all(|k| {
160            let vi = mesh.indices[tri * 3 + k] as usize;
161            vi * 3 + 2 < mesh.positions.len()
162        });
163        if !tri_in_range {
164            continue;
165        }
166
167        let group = groups[palette].get_or_insert_with(Group::default);
168        for k in 0..3 {
169            let vi = mesh.indices[tri * 3 + k] as usize;
170            let base = vi * 3;
171            let new_index = (group.positions.len() / 3) as u32;
172            group.positions.push(mesh.positions[base]);
173            group.positions.push(mesh.positions[base + 1]);
174            group.positions.push(mesh.positions[base + 2]);
175            if has_normals {
176                group.normals.push(mesh.normals[base]);
177                group.normals.push(mesh.normals[base + 1]);
178                group.normals.push(mesh.normals[base + 2]);
179            }
180            group.indices.push(new_index);
181        }
182    }
183
184    let out: Vec<(Rgba, Mesh)> = groups
185        .into_iter()
186        .enumerate()
187        .filter_map(|(palette, group)| {
188            let group = group?;
189            if group.indices.is_empty() {
190                return None;
191            }
192            let mesh = Mesh {
193                positions: group.positions,
194                normals: group.normals,
195                indices: group.indices,
196                rtc_applied,
197                origin,
198                instance_meta: None,
199                // Every split group shares the parent's placement (same element,
200                // just partitioned by colour), so both carry over unchanged. The
201                // parent's local_bounds is a superset of this group's actual
202                // extent, but that's fine — Scene.getEntityLocalBounds unions
203                // across an entity's pieces, and a union of identical supersets
204                // still yields the correct entity-level box. See issue #1474.
205                local_bounds,
206                local_to_world,
207            };
208            Some((map.colours[palette], mesh))
209        })
210        .collect();
211
212    (out.len() >= 2).then_some(out)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::{split_mesh_by_indexed_colour, FullIndexedColourMap};
218    use crate::style::Rgba;
219    use ifc_lite_geometry::Mesh;
220
221    #[test]
222    fn split_drops_out_of_range_triangle_without_partial_geometry() {
223        // 6 in-range vertices (0..=5); the third triangle references vertex 99,
224        // which is out of range. The split must drop that whole triangle, never
225        // emit a 1- or 2-vertex fragment.
226        let positions: Vec<f32> = (0..6).flat_map(|i| [i as f32, 0.0, 0.0]).collect();
227        let mesh = Mesh {
228            positions,
229            normals: Vec::new(),
230            indices: vec![0, 1, 2, 3, 4, 5, 0, 1, 99],
231            rtc_applied: false,
232            origin: [0.0; 3],
233            instance_meta: None,
234            local_bounds: None,
235            local_to_world: None,
236        };
237        let map = FullIndexedColourMap {
238            geometry_id: 1,
239            colours: vec![Rgba::new(1.0, 0.0, 0.0, 1.0), Rgba::new(0.0, 1.0, 0.0, 1.0)],
240            // tri0 → red, tri1 → green, tri2 (out of range) → red
241            triangle_palette: vec![0, 1, 0],
242        };
243
244        let parts = split_mesh_by_indexed_colour(&mesh, &map)
245            .expect("two valid palette groups survive after dropping the OOB triangle");
246
247        let total_tris: usize = parts
248            .iter()
249            .map(|(_, m)| {
250                assert_eq!(m.indices.len() % 3, 0, "index buffer must be whole triangles");
251                assert_eq!(m.positions.len() % 3, 0, "positions must be whole vertices");
252                m.indices.len() / 3
253            })
254            .sum();
255        assert_eq!(
256            total_tris, 2,
257            "the out-of-range triangle must be dropped, not partially emitted"
258        );
259    }
260
261    /// `dominant()` must pick the colour referenced by the MOST triangles, not
262    /// the fewest. Every fixture elsewhere in this crate either has a single
263    /// palette entry (so any selection rule trivially "wins") or an exactly
264    /// balanced split (6 vs. 6), so a `max_by_key` → `min_by_key` flip
265    /// (picking the least-referenced colour instead) previously passed the
266    /// full suite untouched — a real gap, since `resolve_prepass_with_style_seeds`
267    /// always calls `dominant()` to seed the element style index even when the
268    /// mesh is later split, and falls back to it whole when triangle counts
269    /// don't line up with the palette (CSG/void cutting changed the topology).
270    #[test]
271    fn dominant_picks_the_majority_colour_not_the_minority() {
272        let map = FullIndexedColourMap {
273            geometry_id: 1,
274            colours: vec![Rgba::new(1.0, 0.0, 0.0, 1.0), Rgba::new(0.0, 1.0, 0.0, 1.0)],
275            // Palette entry 0 (red) referenced once, entry 1 (green) referenced
276            // three times — green is unambiguously the majority colour.
277            triangle_palette: vec![0, 1, 1, 1],
278        };
279        assert_eq!(
280            map.dominant(),
281            Rgba::new(0.0, 1.0, 0.0, 1.0),
282            "dominant() must return the majority-referenced colour (green), not the minority (red)"
283        );
284    }
285}