ifc_lite_processing/style/
indexed_colour.rs1use super::Rgba;
21use ifc_lite_core::{DecodedEntity, EntityDecoder};
22use ifc_lite_geometry::Mesh;
23
24#[derive(Debug, Clone)]
28pub struct FullIndexedColourMap {
29 pub geometry_id: u32,
31 pub colours: Vec<Rgba>,
33 pub triangle_palette: Vec<usize>,
35}
36
37impl FullIndexedColourMap {
38 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 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
64pub 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
121pub 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; }
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 #[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 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 local_bounds,
206 local_to_world,
207 welded_in_object_frame: false,
208 };
209 Some((map.colours[palette], mesh))
210 })
211 .collect();
212
213 (out.len() >= 2).then_some(out)
214}
215
216#[cfg(test)]
217mod tests {
218 use super::{split_mesh_by_indexed_colour, FullIndexedColourMap};
219 use crate::style::Rgba;
220 use ifc_lite_geometry::Mesh;
221
222 #[test]
223 fn split_drops_out_of_range_triangle_without_partial_geometry() {
224 let positions: Vec<f32> = (0..6).flat_map(|i| [i as f32, 0.0, 0.0]).collect();
228 let mesh = Mesh {
229 positions,
230 normals: Vec::new(),
231 indices: vec![0, 1, 2, 3, 4, 5, 0, 1, 99],
232 rtc_applied: false,
233 origin: [0.0; 3],
234 instance_meta: None,
235 local_bounds: None,
236 local_to_world: None,
237 welded_in_object_frame: false,
238 };
239 let map = FullIndexedColourMap {
240 geometry_id: 1,
241 colours: vec![Rgba::new(1.0, 0.0, 0.0, 1.0), Rgba::new(0.0, 1.0, 0.0, 1.0)],
242 triangle_palette: vec![0, 1, 0],
244 };
245
246 let parts = split_mesh_by_indexed_colour(&mesh, &map)
247 .expect("two valid palette groups survive after dropping the OOB triangle");
248
249 let total_tris: usize = parts
250 .iter()
251 .map(|(_, m)| {
252 assert_eq!(m.indices.len() % 3, 0, "index buffer must be whole triangles");
253 assert_eq!(m.positions.len() % 3, 0, "positions must be whole vertices");
254 m.indices.len() / 3
255 })
256 .sum();
257 assert_eq!(
258 total_tris, 2,
259 "the out-of-range triangle must be dropped, not partially emitted"
260 );
261 }
262
263 #[test]
273 fn dominant_picks_the_majority_colour_not_the_minority() {
274 let map = FullIndexedColourMap {
275 geometry_id: 1,
276 colours: vec![Rgba::new(1.0, 0.0, 0.0, 1.0), Rgba::new(0.0, 1.0, 0.0, 1.0)],
277 triangle_palette: vec![0, 1, 1, 1],
280 };
281 assert_eq!(
282 map.dominant(),
283 Rgba::new(0.0, 1.0, 0.0, 1.0),
284 "dominant() must return the majority-referenced colour (green), not the minority (red)"
285 );
286 }
287}