Skip to main content

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