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}