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