ifc_lite_processing/prepass.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//! Canonical prepass resolution — the single post-scan step that turns the
6//! entity spans a scan collected into the style / material / void context the
7//! per-element producer ([`crate::element`]) consumes.
8//!
9//! Both pipelines run this exact code:
10//! - the native orchestrator (`processor.rs`) span-stashes during its scan and
11//! resolves here before the rayon loop;
12//! - the browser prepasses (`buildPrePassOnce` / `buildPrePassStreaming` in
13//! `wasm-bindings`) span-stash during their scans and resolve here before
14//! serialising the flat wire arrays.
15//!
16//! The two scan loops remain per-pipeline (they are mechanical `match`-arms
17//! over type names with pipeline-specific extras: quick-metadata, properties,
18//! incremental job emission), but everything SEMANTIC — styled-item
19//! precedence, IfcIndexedColourMap fallback, the #407 material chain, void
20//! collection and aggregate propagation (#845), and unit-scale resolution —
21//! lives here exactly once. The historic #858/#913-class drift was always in
22//! this resolution layer, not in the span stashing.
23
24use crate::style::{FullIndexedColourMap, GeometryStyleInfo};
25use ifc_lite_core::{express_id::parse_express_id, DecodedEntity, EntityDecoder};
26use rustc_hash::FxHashMap;
27
28/// One stashed entity span: `(express_id, start, end)`.
29pub type Span = (u32, usize, usize);
30
31/// Entity spans a scan collected for post-scan resolution (no mid-scan decode).
32#[derive(Debug, Default, Clone)]
33pub struct PrepassSpans {
34 /// `IFCSTYLEDITEM` — geometry-attached AND orphan (material appearance);
35 /// the resolver classifies them (decoding is the cost of telling them apart).
36 pub styled_items: Vec<Span>,
37 /// `IFCINDEXEDCOLOURMAP` (#663/#858 — CATIA/3DEXPERIENCE per-triangle palettes).
38 pub indexed_colour_maps: Vec<Span>,
39 /// `IFCMATERIALDEFINITIONREPRESENTATION` (#407 material chain).
40 pub material_def_reprs: Vec<Span>,
41 /// `IFCRELASSOCIATESMATERIAL` (#407 material chain).
42 pub rel_associates_material: Vec<Span>,
43 /// `IFCRELVOIDSELEMENT` — host → opening.
44 pub void_rels: Vec<Span>,
45 /// `IFCRELFILLSELEMENT` — opening → filling (window/door); drives the
46 /// native opening filter. Cheap to collect everywhere.
47 pub fills_rels: Vec<Span>,
48 /// `IFCRELAGGREGATES` — parent → children, for aggregate void
49 /// propagation (#845, IfcWallElementedCase etc.).
50 pub aggregate_rels: Vec<Span>,
51 pub defines_by_type: Vec<Span>, // IFCRELDEFINESBYTYPE, for prepass_type_material.
52}
53
54/// Resolution switches (both pipelines share the resolver).
55#[derive(Debug, Clone, Copy)]
56pub struct ResolveOptions {
57 /// Collect the FULL per-triangle palette maps (#858). The native pipeline
58 /// consumes them in-process; the browser prepass leaves this off (each
59 /// process worker rebuilds its own copy — shipping full palettes over the
60 /// JS boundary would dwarf the styles arrays).
61 pub collect_indexed_colour_full: bool,
62 /// Defer geometry-attached styled items: classify and resolve ORPHAN
63 /// styled items now (#913 §2c — the material chain needs them up front),
64 /// but return attached spans unresolved on
65 /// [`ResolvedPrepass::deferred_attached_styled_spans`] for a later
66 /// [`resolve_styled_item_spans`] replay. Native `fast_first_batch` mode.
67 pub defer_attached_styles: bool,
68}
69
70impl Default for ResolveOptions {
71 fn default() -> Self {
72 Self {
73 collect_indexed_colour_full: true,
74 defer_attached_styles: false,
75 }
76 }
77}
78
79/// Everything the post-scan resolution produces.
80#[derive(Debug, Default)]
81pub struct ResolvedPrepass {
82 /// Geometry item id → resolved style (styled items first in file order,
83 /// then IfcIndexedColourMap dominant colours fill the gaps — styled items
84 /// win, #913 precedence).
85 pub geometry_style_index: FxHashMap<u32, GeometryStyleInfo>,
86 /// Geometry item id → dominant palette colour (#858).
87 pub indexed_colour_index: FxHashMap<u32, [f32; 4]>,
88 /// Geometry item id → full per-triangle palette (#858); empty unless
89 /// [`ResolveOptions::collect_indexed_colour_full`].
90 pub indexed_colour_full: FxHashMap<u32, FullIndexedColourMap>,
91 /// Orphan `IfcStyledItem` colours (material appearances, #407).
92 pub orphan_styled_items: FxHashMap<u32, [f32; 4]>,
93 /// Material id → styled representation ids (#407).
94 pub material_def_reprs: FxHashMap<u32, Vec<u32>>,
95 /// Element id → material(-select) id (#407).
96 pub element_to_material: FxHashMap<u32, u32>,
97 /// Element id → material colour list (#407/#913 §2.3 transparent/opaque
98 /// alternation for window/door parts). The canonical join.
99 pub element_material_colors: FxHashMap<u32, Vec<[f32; 4]>>,
100 /// Host element id → opening ids, AFTER aggregate propagation (#845).
101 pub void_index: FxHashMap<u32, Vec<u32>>,
102 /// Opening id → filling element id (native opening filter input).
103 pub filling_by_opening: FxHashMap<u32, u32>,
104 /// Geometry-attached styled spans left unresolved under
105 /// [`ResolveOptions::defer_attached_styles`]; replay via [`resolve_styled_item_spans`].
106 pub deferred_attached_styled_spans: Vec<(usize, usize)>,
107}
108
109pub use crate::prepass_styled::{resolve_styled_items_into, StyleSeeds};
110
111/// THE canonical post-scan resolution (file-order, first-wins precedence).
112pub fn resolve_prepass(
113 spans: &PrepassSpans,
114 decoder: &mut EntityDecoder,
115 opts: ResolveOptions,
116) -> ResolvedPrepass {
117 resolve_prepass_with_style_seeds(spans, decoder, opts, None)
118}
119
120/// [`resolve_prepass`] with PRE-RESOLVED styled maps (sharded pre-pass). Seeds
121/// MUST install before the material/void loops: the material chain consults
122/// `orphan_styled_items` — injecting after loses material-dependent styles.
123pub fn resolve_prepass_with_style_seeds(
124 spans: &PrepassSpans,
125 decoder: &mut EntityDecoder,
126 opts: ResolveOptions,
127 style_seeds: Option<StyleSeeds>,
128) -> ResolvedPrepass {
129 let mut out = ResolvedPrepass::default();
130
131 if let Some((orphan, geom)) = style_seeds {
132 out.orphan_styled_items = orphan;
133 out.geometry_style_index = geom;
134 }
135 // ── Styled items: orphan (material appearance) vs geometry-attached ──
136 resolve_styled_items_into(
137 &spans.styled_items,
138 decoder,
139 opts.defer_attached_styles,
140 &mut out.orphan_styled_items,
141 &mut out.geometry_style_index,
142 &mut out.deferred_attached_styled_spans,
143 );
144
145 // ── IfcIndexedColourMap (#663/#858) ──
146 for &(id, start, end) in &spans.indexed_colour_maps {
147 let Ok(icm) = decoder.decode_at_with_id(id, start, end) else {
148 continue;
149 };
150 let Some(full) = crate::style::resolve_indexed_colour_map_full(&icm, decoder) else {
151 continue;
152 };
153 let geometry_id = full.geometry_id;
154 out.indexed_colour_index
155 .entry(geometry_id)
156 .or_insert(full.dominant().to_array());
157 if opts.collect_indexed_colour_full {
158 out.indexed_colour_full.entry(geometry_id).or_insert(full);
159 }
160 }
161
162 // ── Material chain inputs (#407) ──
163 for &(id, start, end) in &spans.material_def_reprs {
164 if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
165 // RepresentedMaterial (attr 3) → Representations (attr 2).
166 if let Some(material_id) = entity.get_ref(3) {
167 if let Some(reprs) = refs_from_list(&entity, 2) {
168 out.material_def_reprs
169 .entry(material_id)
170 .or_default()
171 .extend(reprs);
172 }
173 }
174 }
175 }
176 for &(id, start, end) in &spans.rel_associates_material {
177 if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
178 // RelatingMaterial (attr 5) ← RelatedObjects (attr 4).
179 if let Some(material_select_id) = entity.get_ref(5) {
180 if let Some(related) = refs_from_list(&entity, 4) {
181 for element_id in related {
182 out.element_to_material.insert(element_id, material_select_id);
183 }
184 }
185 }
186 }
187 }
188
189 crate::prepass_type_material::propagate_type_material(&spans.defines_by_type, decoder, &mut out.element_to_material);
190 // ── Voids + fills + aggregate propagation (#845) ──
191 for &(id, start, end) in &spans.void_rels {
192 if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
193 if let (Some(host), Some(opening)) = (entity.get_ref(4), entity.get_ref(5)) {
194 out.void_index.entry(host).or_default().push(opening);
195 }
196 }
197 }
198 for &(id, start, end) in &spans.fills_rels {
199 if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
200 // attr 4 = RelatingOpeningElement, attr 5 = RelatedBuildingElement.
201 if let (Some(opening_id), Some(filling_id)) = (entity.get_ref(4), entity.get_ref(5)) {
202 out.filling_by_opening.insert(opening_id, filling_id);
203 }
204 }
205 }
206 if !out.void_index.is_empty() && !spans.aggregate_rels.is_empty() {
207 let mut aggregate_children: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
208 for &(id, start, end) in &spans.aggregate_rels {
209 if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
210 let Some(parent_id) = entity.get_ref(4) else {
211 continue;
212 };
213 if let Some(children) = refs_from_list(&entity, 5) {
214 aggregate_children
215 .entry(parent_id)
216 .or_default()
217 .extend(children);
218 }
219 }
220 }
221 ifc_lite_geometry::propagate_voids_via_aggregates(
222 &mut out.void_index,
223 &aggregate_children,
224 );
225 }
226
227 // Canonicalise each host's opening order (#2019). The scan pushes in
228 // statement order, aggregate propagation in hashmap-BFS order; neither
229 // is a property of the model. Sequential void cuts are not associative
230 // (each pass snaps f64->f32), so reordering them moves the world
231 // geometry hash on a re-export that only shuffled statements.
232 for openings in out.void_index.values_mut() {
233 openings.sort_unstable();
234 }
235
236 // ── Material chain join (#407): element id → colour list ──
237 out.element_material_colors = crate::style::build_element_material_colors(
238 &out.material_def_reprs,
239 &out.orphan_styled_items,
240 &out.element_to_material,
241 decoder,
242 );
243
244 out
245}
246
247/// Resolve geometry-attached styled-item spans into a style index — the
248/// defer-mode replay (`fast_first_batch`), and the building block
249/// [`resolve_prepass`] uses internally.
250pub fn resolve_styled_item_spans(
251 spans: &[(usize, usize)],
252 decoder: &mut EntityDecoder,
253) -> FxHashMap<u32, GeometryStyleInfo> {
254 let mut styles: FxHashMap<u32, GeometryStyleInfo> = FxHashMap::default();
255 for &(start, end) in spans {
256 if let Ok(styled_item) = decoder.decode_at(start, end) {
257 if styled_item.get_ref(0).is_some() {
258 collect_geometry_style_info(&mut styles, &styled_item, decoder);
259 }
260 }
261 }
262 styles
263}
264
265/// Fold `IfcIndexedColourMap` dominant colours into the style index, keyed by
266/// target geometry id. `or_insert` preserves IFCSTYLEDITEM precedence: a
267/// geometry that already has a direct style keeps it; the indexed colour only
268/// fills the gaps (#913).
269pub fn merge_indexed_colours(
270 geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
271 indexed_colours: &FxHashMap<u32, [f32; 4]>,
272) {
273 for (&geometry_id, &color) in indexed_colours {
274 geometry_styles
275 .entry(geometry_id)
276 .or_insert_with(|| GeometryStyleInfo::from_color(color));
277 }
278}
279
280/// The file's unit scales, resolved exactly once per parse.
281#[derive(Debug, Clone, Copy, PartialEq)]
282pub struct UnitScales {
283 /// Length unit → metres (1.0 for metre files, 0.001 for millimetre files).
284 pub length_unit_scale: f64,
285 /// Plane-angle unit → radians (1.0 for RADIAN files, π/180 for DEGREE).
286 pub plane_angle_to_radians: f64,
287 /// The `IFCPROJECT` express id the scales were resolved from, when found.
288 pub project_id: Option<u32>,
289}
290
291impl Default for UnitScales {
292 fn default() -> Self {
293 Self {
294 length_unit_scale: 1.0,
295 plane_angle_to_radians: 1.0,
296 project_id: None,
297 }
298 }
299}
300
301/// Resolve BOTH unit scales once, against the decoder's (possibly partial)
302/// entity index, with the documented fallback ladder:
303///
304/// 1. `project_id` hint (recorded by the scan) — O(1) decode via the index.
305/// 2. No hint? Find `IFCPROJECT` by SIMD substring search (it is a singleton
306/// and many exporters — IfcOpenShell, Revit — emit it near the END of the
307/// file, after the scan's early-meta point).
308/// 3. Resolution chain incomplete on a PARTIAL index (streaming early-meta:
309/// the IFCSIUNIT chain may sit past the scan point)? Re-resolve against a
310/// freshly built FULL index rather than silently defaulting — a millimetre
311/// model resolved as metres renders 1000× oversized.
312///
313/// This is the only sanctioned place that hunts for `IFCPROJECT`; per-element
314/// decoders are seeded from the result (`EntityDecoder::seed_unit_scales`) so
315/// the historic O(file)-scan-per-decoder stall class stays dead.
316pub fn resolve_unit_scales(
317 content: &[u8],
318 project_id_hint: Option<u32>,
319 decoder: &mut EntityDecoder,
320) -> UnitScales {
321 let project_id = project_id_hint.or_else(|| find_ifcproject_id(content));
322 let Some(pid) = project_id else {
323 return UnitScales::default();
324 };
325
326 // Fast path: resolve on the caller's decoder/index. BOTH resolvers return
327 // `None` (not a masked default) when their chain is incomplete on a partial
328 // index, so an unresolved either-scale forces the full-index retry below
329 // rather than silently shipping radians/metres (issue #1367).
330 let length = ifc_lite_core::try_extract_length_unit_scale(decoder, pid);
331 let angle = ifc_lite_core::try_extract_plane_angle_to_radians(decoder, pid);
332
333 if let (Some(length_unit_scale), Some(plane_angle_to_radians)) = (length, angle) {
334 return UnitScales {
335 length_unit_scale,
336 plane_angle_to_radians,
337 project_id,
338 };
339 }
340
341 // Chain incomplete (partial index) — resolve against a full index.
342 let full_index = ifc_lite_core::build_entity_index(content);
343 let mut full_decoder = EntityDecoder::with_index(content, full_index);
344 UnitScales {
345 length_unit_scale: length.or_else(|| {
346 ifc_lite_core::extract_length_unit_scale(&mut full_decoder, pid).ok()
347 })
348 .unwrap_or(1.0),
349 plane_angle_to_radians: angle
350 .or_else(|| {
351 ifc_lite_core::extract_plane_angle_to_radians(&mut full_decoder, pid).ok()
352 })
353 .unwrap_or(1.0),
354 project_id,
355 }
356}
357
358/// Find the singleton `IFCPROJECT`'s express id by SIMD substring search —
359/// no full entity scan. Returns `None` when the file has no project.
360///
361/// A refused id (issue #3421: an `IFCPROJECT` express id above `u32::MAX`) is
362/// reported through [`ifc_lite_core::parser::report_oversized_ids`] — the
363/// same sink the definition scanner uses (issue #3395/#3752) — because it is
364/// exactly that class of event: this function backtracks from `IFCPROJECT(`
365/// to the record's OWN id, not to a reference into another entity, so a
366/// refusal here is indistinguishable in kind from a scan refusing to index
367/// the record at all. Left unreported, it would default the file's unit
368/// scales (see [`resolve_unit_scales`]) with the exact silent-1000×-oversized
369/// symptom issue #1367 already fixed for the "not found at all" case.
370pub fn find_ifcproject_id(content: &[u8]) -> Option<u32> {
371 let mut refused = 0usize;
372 let result = find_ifcproject_id_inner(content, &mut refused);
373 ifc_lite_core::parser::report_oversized_ids(refused);
374 result
375}
376
377fn find_ifcproject_id_inner(content: &[u8], refused: &mut usize) -> Option<u32> {
378 let mut from = 0usize;
379 // Search for the keyword+paren only; the `=` and `#<id>` are reconstructed by
380 // backtracking. Exporters vary the whitespace around `=` — Revit/EDM emits
381 // `#1593796= IFCPROJECT(` with a SPACE, so the old `=IFCPROJECT(` literal
382 // never matched and the whole unit chain silently defaulted (length → metres
383 // on a mm model, plane-angle → radians on a degree model, making arched
384 // openings render as full circles — issue #1367). `IFCPROJECT(` cannot
385 // collide with `IFCPROJECTEDCRS(` because the `(` must immediately follow.
386 while let Some(rel) = memchr::memmem::find(&content[from..], b"IFCPROJECT(") {
387 let kw = from + rel;
388 // Backtrack over optional whitespace, then require '='.
389 let mut i = kw;
390 while i > 0 && content[i - 1].is_ascii_whitespace() {
391 i -= 1;
392 }
393 if i > 0 && content[i - 1] == b'=' {
394 i -= 1; // step over '='
395 // Optional whitespace between the express id and '='.
396 while i > 0 && content[i - 1].is_ascii_whitespace() {
397 i -= 1;
398 }
399 // Backtrack over the express id digits to the '#'.
400 let digits_end = i;
401 while i > 0 && content[i - 1].is_ascii_digit() {
402 i -= 1;
403 }
404 if i > 0 && content[i - 1] == b'#' && i < digits_end {
405 // Refuse (not wrap) above u32::MAX (#3421); None here just
406 // keeps searching, same as the "not found" case below.
407 // Counted (issue #3752) so the caller can report it via the
408 // scanner's own oversized-id sink instead of it vanishing.
409 match parse_express_id(&content[i..digits_end]) {
410 Some(id) => return Some(id),
411 None => *refused += 1,
412 }
413 }
414 }
415 // `IFCPROJECT(` not preceded by `#<digits>=` (e.g. inside a string)
416 // — keep searching.
417 from = kw + 1;
418 }
419 None
420}
421
422/// Flat wire encodings of the resolved styles for the browser's
423/// `styleIds`/`styleColors` arrays: the rich style index flattened to
424/// `(ids, rgba8)`, with IfcIndexedColourMap dominants, flat material colours,
425/// and per-element first material colours filling the gaps — the exact
426/// layered precedence the browser prepasses have always shipped.
427pub fn flat_styles_rgba8(resolved: &ResolvedPrepass, decoder: &mut EntityDecoder) -> (Vec<u32>, Vec<u8>) {
428 let mut merged: FxHashMap<u32, [f32; 4]> = resolved
429 .geometry_style_index
430 .iter()
431 .map(|(&id, info)| (id, info.color))
432 .collect();
433 for (&geometry_id, &color) in &resolved.indexed_colour_index {
434 merged.entry(geometry_id).or_insert(color);
435 }
436 // Flat material_id → colour, then element id → first material colour, so
437 // `processGeometryBatch`'s per-element fallback picks them up.
438 let material_styles = crate::style::build_material_style_index(
439 &resolved.material_def_reprs,
440 &resolved.orphan_styled_items,
441 decoder,
442 );
443 for (&mat_id, &color) in crate::style::flatten_material_color_index(&material_styles).iter() {
444 merged.entry(mat_id).or_insert(color);
445 }
446 for (&element_id, colors) in &resolved.element_material_colors {
447 if let Some(&color) = colors.first() {
448 merged.entry(element_id).or_insert(color);
449 }
450 }
451
452 // Emit id-ascending: hashmap iteration order is an implementation detail,
453 // and these arrays are wire output. Consumers rebuild a map (see the sort
454 // rationale on `flat_voids`), so the order is free to pin.
455 let mut entries: Vec<(u32, [f32; 4])> = merged.into_iter().collect();
456 entries.sort_unstable_by_key(|&(id, _)| id);
457 let mut ids: Vec<u32> = Vec::with_capacity(entries.len());
458 let mut rgba: Vec<u8> = Vec::with_capacity(entries.len() * 4);
459 for (id, color) in entries {
460 ids.push(id);
461 rgba.extend_from_slice(&crate::style::Rgba::from_array(color).to_rgba8());
462 }
463 (ids, rgba)
464}
465
466/// Flat wire encoding of the void index: `(keys, counts, values)` in the
467/// shape `processGeometryBatch` accepts.
468///
469/// Emitted sorted by host id (u32 ascending). FxHashMap iteration order is
470/// seed-free and therefore stable today, but it is an implicit
471/// insertion+hash-order artifact; the sort makes the wire byte order an
472/// explicit contract (pinned by the mesh-output determinism manifest,
473/// `docs/architecture/mesh-determinism.md`). Consumers rebuild a map from the
474/// flat arrays (`processGeometryBatch`), so they are order-insensitive.
475pub fn flat_voids(void_index: &FxHashMap<u32, Vec<u32>>) -> (Vec<u32>, Vec<u32>, Vec<u32>) {
476 let mut hosts: Vec<(&u32, &Vec<u32>)> = void_index.iter().collect();
477 hosts.sort_unstable_by_key(|&(&host_id, _)| host_id);
478 let mut keys: Vec<u32> = Vec::with_capacity(hosts.len());
479 let mut counts: Vec<u32> = Vec::with_capacity(hosts.len());
480 let mut values: Vec<u32> = Vec::new();
481 for (&host_id, openings) in hosts {
482 keys.push(host_id);
483 counts.push(openings.len() as u32);
484 values.extend(openings.iter().copied());
485 }
486 (keys, counts, values)
487}
488
489/// Flat wire encoding of the element material colour lists (#407/#913 §2.3):
490/// `(element_ids, counts, rgba8)` — `counts[i]` colours belong to
491/// `element_ids[i]`, in order, 4 bytes each.
492///
493/// Emitted sorted by element id (u32 ascending) - same explicit-order wire
494/// contract as [`flat_voids`]; the per-element colour list order (file order)
495/// is unchanged. The inverse [`material_colors_from_flat`] rebuilds a map, so
496/// consumers are order-insensitive.
497pub fn flat_material_colors(
498 element_material_colors: &FxHashMap<u32, Vec<[f32; 4]>>,
499) -> (Vec<u32>, Vec<u32>, Vec<u8>) {
500 let mut elements: Vec<(&u32, &Vec<[f32; 4]>)> = element_material_colors.iter().collect();
501 elements.sort_unstable_by_key(|&(&element_id, _)| element_id);
502 let mut ids: Vec<u32> = Vec::with_capacity(elements.len());
503 let mut counts: Vec<u32> = Vec::with_capacity(elements.len());
504 let mut rgba: Vec<u8> = Vec::new();
505 for (&element_id, colors) in elements {
506 if colors.is_empty() {
507 continue;
508 }
509 ids.push(element_id);
510 counts.push(colors.len() as u32);
511 for &c in colors {
512 rgba.extend_from_slice(&crate::style::Rgba::from_array(c).to_rgba8());
513 }
514 }
515 (ids, counts, rgba)
516}
517
518/// Decode the flat material-colour wire arrays back into the canonical map —
519/// the inverse of [`flat_material_colors`], used by `processGeometryBatch`.
520pub fn material_colors_from_flat(
521 element_ids: &[u32],
522 counts: &[u32],
523 rgba: &[u8],
524) -> FxHashMap<u32, Vec<[f32; 4]>> {
525 let mut out: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
526 let mut offset = 0usize;
527 for (i, &element_id) in element_ids.iter().enumerate() {
528 let Some(&count) = counts.get(i) else { break };
529 let count = count as usize;
530 let mut colors: Vec<[f32; 4]> = Vec::with_capacity(count);
531 for c in 0..count {
532 let base = (offset + c) * 4;
533 if base + 3 >= rgba.len() {
534 break;
535 }
536 colors.push(
537 crate::style::Rgba::from_rgba8([
538 rgba[base],
539 rgba[base + 1],
540 rgba[base + 2],
541 rgba[base + 3],
542 ])
543 .to_array(),
544 );
545 }
546 offset += count;
547 if !colors.is_empty() {
548 out.insert(element_id, colors);
549 }
550 }
551 out
552}
553
554// ── Styled-item resolution chain (moved from processor.rs — shared) ──
555
556/// Resolve a geometry-attached `IfcStyledItem` into the style index with
557/// first-wins precedence per geometry id (file order = authored intent).
558pub(crate) fn collect_geometry_style_info(
559 geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
560 styled_item: &DecodedEntity,
561 decoder: &mut EntityDecoder,
562) {
563 let Some(geometry_id) = styled_item.get_ref(0) else {
564 return;
565 };
566 if geometry_styles.contains_key(&geometry_id) {
567 return;
568 }
569 if let Some(style_info) = extract_style_info_from_styled_item(styled_item, decoder) {
570 geometry_styles.insert(geometry_id, style_info);
571 }
572}
573
574/// Extract colour + name from an `IfcStyledItem` by traversing its style
575/// references (directly or through `IfcPresentationStyleAssignment`).
576pub(crate) fn extract_style_info_from_styled_item(
577 styled_item: &DecodedEntity,
578 decoder: &mut EntityDecoder,
579) -> Option<GeometryStyleInfo> {
580 let style_refs = refs_from_list(styled_item, 1)?;
581
582 for style_id in style_refs {
583 if let Ok(style) = decoder.decode_by_id(style_id) {
584 // IfcPresentationStyleAssignment has nested style refs at attr 0.
585 if let Some(inner_refs) = refs_from_list(&style, 0) {
586 for inner_id in inner_refs {
587 if let Some(info) = extract_surface_style_info(inner_id, decoder) {
588 return Some(info);
589 }
590 }
591 }
592
593 // Or the style ref points directly to IfcSurfaceStyle.
594 if let Some(info) = extract_surface_style_info(style_id, decoder) {
595 return Some(info);
596 }
597 }
598 }
599
600 None
601}
602
603/// Extract colour + style name from an `IfcSurfaceStyle`. Colour resolution is
604/// the canonical [`crate::style::extract_surface_style_colors`], shared with
605/// the browser pre-pass so the server and viewer can't disagree on
606/// `SurfaceColour` vs `DiffuseColour` precedence (#997).
607fn extract_surface_style_info(
608 style_id: u32,
609 decoder: &mut EntityDecoder,
610) -> Option<GeometryStyleInfo> {
611 let style = decoder.decode_by_id(style_id).ok()?;
612 let material_name = normalize_style_name(style.get_string(0));
613 let (color, shading_color) = crate::style::extract_surface_style_colors(style_id, decoder)?;
614 Some(GeometryStyleInfo {
615 color,
616 shading_color,
617 material_name,
618 })
619}
620
621fn normalize_style_name(raw: Option<&str>) -> Option<String> {
622 let name = raw?.trim();
623 if name.is_empty() || name == "$" {
624 return None;
625 }
626 if name.eq_ignore_ascii_case("<unnamed>") || name.eq_ignore_ascii_case("unnamed") {
627 return None;
628 }
629 Some(name.to_string())
630}
631
632/// Extract entity references from a list attribute.
633pub(crate) fn refs_from_list(entity: &DecodedEntity, index: usize) -> Option<Vec<u32>> {
634 let list = entity.get_list(index)?;
635 let refs: Vec<u32> = list.iter().filter_map(|v| v.as_entity_ref()).collect();
636 if refs.is_empty() {
637 None
638 } else {
639 Some(refs)
640 }
641}
642
643#[cfg(test)]
644#[path = "prepass_tests.rs"]
645mod tests;