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::{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 (the classifying decode is the cost of
36    /// telling the two apart).
37    pub styled_items: Vec<Span>,
38    /// `IFCINDEXEDCOLOURMAP` (#663/#858 — CATIA/3DEXPERIENCE per-triangle
39    /// palettes, IFC4's second colouring mechanism).
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}
54
55/// Resolution switches (both pipelines share the resolver).
56#[derive(Debug, Clone, Copy)]
57pub struct ResolveOptions {
58    /// Collect the FULL per-triangle palette maps (#858). The native pipeline
59    /// consumes them in-process; the browser prepass leaves this off (each
60    /// process worker rebuilds its own copy — shipping full palettes over the
61    /// JS boundary would dwarf the styles arrays).
62    pub collect_indexed_colour_full: bool,
63    /// Defer geometry-attached styled items: classify and resolve ORPHAN
64    /// styled items now (#913 §2c — the material chain needs them up front),
65    /// but return attached spans unresolved on
66    /// [`ResolvedPrepass::deferred_attached_styled_spans`] for a later
67    /// [`resolve_styled_item_spans`] replay. Native `fast_first_batch` mode.
68    pub defer_attached_styles: bool,
69}
70
71impl Default for ResolveOptions {
72    fn default() -> Self {
73        Self {
74            collect_indexed_colour_full: true,
75            defer_attached_styles: false,
76        }
77    }
78}
79
80/// Everything the post-scan resolution produces.
81#[derive(Debug, Default)]
82pub struct ResolvedPrepass {
83    /// Geometry item id → resolved style (styled items first in file order,
84    /// then IfcIndexedColourMap dominant colours fill the gaps — styled items
85    /// win, #913 precedence).
86    pub geometry_style_index: FxHashMap<u32, GeometryStyleInfo>,
87    /// Geometry item id → dominant palette colour (#858).
88    pub indexed_colour_index: FxHashMap<u32, [f32; 4]>,
89    /// Geometry item id → full per-triangle palette (#858); empty unless
90    /// [`ResolveOptions::collect_indexed_colour_full`].
91    pub indexed_colour_full: FxHashMap<u32, FullIndexedColourMap>,
92    /// Orphan `IfcStyledItem` colours (material appearances, #407).
93    pub orphan_styled_items: FxHashMap<u32, [f32; 4]>,
94    /// Material id → styled representation ids (#407).
95    pub material_def_reprs: FxHashMap<u32, Vec<u32>>,
96    /// Element id → material(-select) id (#407).
97    pub element_to_material: FxHashMap<u32, u32>,
98    /// Element id → material colour list (#407/#913 §2.3 transparent/opaque
99    /// alternation for window/door parts). The canonical join.
100    pub element_material_colors: FxHashMap<u32, Vec<[f32; 4]>>,
101    /// Host element id → opening ids, AFTER aggregate propagation (#845).
102    pub void_index: FxHashMap<u32, Vec<u32>>,
103    /// Opening id → filling element id (native opening filter input).
104    pub filling_by_opening: FxHashMap<u32, u32>,
105    /// Geometry-attached styled spans left unresolved under
106    /// [`ResolveOptions::defer_attached_styles`]; replay via [`resolve_styled_item_spans`].
107    pub deferred_attached_styled_spans: Vec<(usize, usize)>,
108}
109
110pub use crate::prepass_styled::{resolve_styled_items_into, StyleSeeds};
111
112/// THE canonical post-scan resolution (file-order, first-wins precedence).
113pub fn resolve_prepass(
114    spans: &PrepassSpans,
115    decoder: &mut EntityDecoder,
116    opts: ResolveOptions,
117) -> ResolvedPrepass {
118    resolve_prepass_with_style_seeds(spans, decoder, opts, None)
119}
120
121/// [`resolve_prepass`] with PRE-RESOLVED styled maps (sharded pre-pass). Seeds
122/// MUST install before the material/void loops: the material chain consults
123/// `orphan_styled_items` — injecting after loses material-dependent styles.
124pub fn resolve_prepass_with_style_seeds(
125    spans: &PrepassSpans,
126    decoder: &mut EntityDecoder,
127    opts: ResolveOptions,
128    style_seeds: Option<StyleSeeds>,
129) -> ResolvedPrepass {
130    let mut out = ResolvedPrepass::default();
131
132    if let Some((orphan, geom)) = style_seeds {
133        out.orphan_styled_items = orphan;
134        out.geometry_style_index = geom;
135    }
136    // ── Styled items: orphan (material appearance) vs geometry-attached ──
137    resolve_styled_items_into(
138        &spans.styled_items,
139        decoder,
140        opts.defer_attached_styles,
141        &mut out.orphan_styled_items,
142        &mut out.geometry_style_index,
143        &mut out.deferred_attached_styled_spans,
144    );
145
146    // ── IfcIndexedColourMap (#663/#858) ──
147    for &(id, start, end) in &spans.indexed_colour_maps {
148        let Ok(icm) = decoder.decode_at_with_id(id, start, end) else {
149            continue;
150        };
151        let Some(full) = crate::style::resolve_indexed_colour_map_full(&icm, decoder) else {
152            continue;
153        };
154        let geometry_id = full.geometry_id;
155        out.indexed_colour_index
156            .entry(geometry_id)
157            .or_insert(full.dominant().to_array());
158        if opts.collect_indexed_colour_full {
159            out.indexed_colour_full.entry(geometry_id).or_insert(full);
160        }
161    }
162
163    // ── Material chain inputs (#407) ──
164    for &(id, start, end) in &spans.material_def_reprs {
165        if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
166            // RepresentedMaterial (attr 3) → Representations (attr 2).
167            if let Some(material_id) = entity.get_ref(3) {
168                if let Some(reprs) = refs_from_list(&entity, 2) {
169                    out.material_def_reprs
170                        .entry(material_id)
171                        .or_default()
172                        .extend(reprs);
173                }
174            }
175        }
176    }
177    for &(id, start, end) in &spans.rel_associates_material {
178        if let Ok(entity) = decoder.decode_at_with_id(id, start, end) {
179            // RelatingMaterial (attr 5) ← RelatedObjects (attr 4).
180            if let Some(material_select_id) = entity.get_ref(5) {
181                if let Some(related) = refs_from_list(&entity, 4) {
182                    for element_id in related {
183                        out.element_to_material.insert(element_id, material_select_id);
184                    }
185                }
186            }
187        }
188    }
189
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.
360pub fn find_ifcproject_id(content: &[u8]) -> Option<u32> {
361    let mut from = 0usize;
362    // Search for the keyword+paren only; the `=` and `#<id>` are reconstructed by
363    // backtracking. Exporters vary the whitespace around `=` — Revit/EDM emits
364    // `#1593796= IFCPROJECT(` with a SPACE, so the old `=IFCPROJECT(` literal
365    // never matched and the whole unit chain silently defaulted (length → metres
366    // on a mm model, plane-angle → radians on a degree model, making arched
367    // openings render as full circles — issue #1367). `IFCPROJECT(` cannot
368    // collide with `IFCPROJECTEDCRS(` because the `(` must immediately follow.
369    while let Some(rel) = memchr::memmem::find(&content[from..], b"IFCPROJECT(") {
370        let kw = from + rel;
371        // Backtrack over optional whitespace, then require '='.
372        let mut i = kw;
373        while i > 0 && content[i - 1].is_ascii_whitespace() {
374            i -= 1;
375        }
376        if i > 0 && content[i - 1] == b'=' {
377            i -= 1; // step over '='
378            // Optional whitespace between the express id and '='.
379            while i > 0 && content[i - 1].is_ascii_whitespace() {
380                i -= 1;
381            }
382            // Backtrack over the express id digits to the '#'.
383            let digits_end = i;
384            while i > 0 && content[i - 1].is_ascii_digit() {
385                i -= 1;
386            }
387            if i > 0 && content[i - 1] == b'#' && i < digits_end {
388                let mut id: u32 = 0;
389                for &b in &content[i..digits_end] {
390                    id = id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
391                }
392                return Some(id);
393            }
394        }
395        // `IFCPROJECT(` not preceded by `#<digits>=` (e.g. inside a string)
396        // — keep searching.
397        from = kw + 1;
398    }
399    None
400}
401
402/// Flat wire encodings of the resolved styles for the browser's
403/// `styleIds`/`styleColors` arrays: the rich style index flattened to
404/// `(ids, rgba8)`, with IfcIndexedColourMap dominants, flat material colours,
405/// and per-element first material colours filling the gaps — the exact
406/// layered precedence the browser prepasses have always shipped.
407pub fn flat_styles_rgba8(resolved: &ResolvedPrepass, decoder: &mut EntityDecoder) -> (Vec<u32>, Vec<u8>) {
408    let mut merged: FxHashMap<u32, [f32; 4]> = resolved
409        .geometry_style_index
410        .iter()
411        .map(|(&id, info)| (id, info.color))
412        .collect();
413    for (&geometry_id, &color) in &resolved.indexed_colour_index {
414        merged.entry(geometry_id).or_insert(color);
415    }
416    // Flat material_id → colour, then element id → first material colour, so
417    // `processGeometryBatch`'s per-element fallback picks them up.
418    let material_styles = crate::style::build_material_style_index(
419        &resolved.material_def_reprs,
420        &resolved.orphan_styled_items,
421        decoder,
422    );
423    for (&mat_id, &color) in crate::style::flatten_material_color_index(&material_styles).iter() {
424        merged.entry(mat_id).or_insert(color);
425    }
426    for (&element_id, colors) in &resolved.element_material_colors {
427        if let Some(&color) = colors.first() {
428            merged.entry(element_id).or_insert(color);
429        }
430    }
431
432    // Emit id-ascending: hashmap iteration order is an implementation detail,
433    // and these arrays are wire output. Consumers rebuild a map (see the sort
434    // rationale on `flat_voids`), so the order is free to pin.
435    let mut entries: Vec<(u32, [f32; 4])> = merged.into_iter().collect();
436    entries.sort_unstable_by_key(|&(id, _)| id);
437    let mut ids: Vec<u32> = Vec::with_capacity(entries.len());
438    let mut rgba: Vec<u8> = Vec::with_capacity(entries.len() * 4);
439    for (id, color) in entries {
440        ids.push(id);
441        rgba.extend_from_slice(&crate::style::Rgba::from_array(color).to_rgba8());
442    }
443    (ids, rgba)
444}
445
446/// Flat wire encoding of the void index: `(keys, counts, values)` in the
447/// shape `processGeometryBatch` accepts.
448///
449/// Emitted sorted by host id (u32 ascending). FxHashMap iteration order is
450/// seed-free and therefore stable today, but it is an implicit
451/// insertion+hash-order artifact; the sort makes the wire byte order an
452/// explicit contract (pinned by the mesh-output determinism manifest,
453/// `docs/architecture/mesh-determinism.md`). Consumers rebuild a map from the
454/// flat arrays (`processGeometryBatch`), so they are order-insensitive.
455pub fn flat_voids(void_index: &FxHashMap<u32, Vec<u32>>) -> (Vec<u32>, Vec<u32>, Vec<u32>) {
456    let mut hosts: Vec<(&u32, &Vec<u32>)> = void_index.iter().collect();
457    hosts.sort_unstable_by_key(|&(&host_id, _)| host_id);
458    let mut keys: Vec<u32> = Vec::with_capacity(hosts.len());
459    let mut counts: Vec<u32> = Vec::with_capacity(hosts.len());
460    let mut values: Vec<u32> = Vec::new();
461    for (&host_id, openings) in hosts {
462        keys.push(host_id);
463        counts.push(openings.len() as u32);
464        values.extend(openings.iter().copied());
465    }
466    (keys, counts, values)
467}
468
469/// Flat wire encoding of the element material colour lists (#407/#913 §2.3):
470/// `(element_ids, counts, rgba8)` — `counts[i]` colours belong to
471/// `element_ids[i]`, in order, 4 bytes each.
472///
473/// Emitted sorted by element id (u32 ascending) - same explicit-order wire
474/// contract as [`flat_voids`]; the per-element colour list order (file order)
475/// is unchanged. The inverse [`material_colors_from_flat`] rebuilds a map, so
476/// consumers are order-insensitive.
477pub fn flat_material_colors(
478    element_material_colors: &FxHashMap<u32, Vec<[f32; 4]>>,
479) -> (Vec<u32>, Vec<u32>, Vec<u8>) {
480    let mut elements: Vec<(&u32, &Vec<[f32; 4]>)> = element_material_colors.iter().collect();
481    elements.sort_unstable_by_key(|&(&element_id, _)| element_id);
482    let mut ids: Vec<u32> = Vec::with_capacity(elements.len());
483    let mut counts: Vec<u32> = Vec::with_capacity(elements.len());
484    let mut rgba: Vec<u8> = Vec::new();
485    for (&element_id, colors) in elements {
486        if colors.is_empty() {
487            continue;
488        }
489        ids.push(element_id);
490        counts.push(colors.len() as u32);
491        for &c in colors {
492            rgba.extend_from_slice(&crate::style::Rgba::from_array(c).to_rgba8());
493        }
494    }
495    (ids, counts, rgba)
496}
497
498/// Decode the flat material-colour wire arrays back into the canonical map —
499/// the inverse of [`flat_material_colors`], used by `processGeometryBatch`.
500pub fn material_colors_from_flat(
501    element_ids: &[u32],
502    counts: &[u32],
503    rgba: &[u8],
504) -> FxHashMap<u32, Vec<[f32; 4]>> {
505    let mut out: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
506    let mut offset = 0usize;
507    for (i, &element_id) in element_ids.iter().enumerate() {
508        let Some(&count) = counts.get(i) else { break };
509        let count = count as usize;
510        let mut colors: Vec<[f32; 4]> = Vec::with_capacity(count);
511        for c in 0..count {
512            let base = (offset + c) * 4;
513            if base + 3 >= rgba.len() {
514                break;
515            }
516            colors.push(
517                crate::style::Rgba::from_rgba8([
518                    rgba[base],
519                    rgba[base + 1],
520                    rgba[base + 2],
521                    rgba[base + 3],
522                ])
523                .to_array(),
524            );
525        }
526        offset += count;
527        if !colors.is_empty() {
528            out.insert(element_id, colors);
529        }
530    }
531    out
532}
533
534// ── Styled-item resolution chain (moved from processor.rs — shared) ──
535
536/// Resolve a geometry-attached `IfcStyledItem` into the style index with
537/// first-wins precedence per geometry id (file order = authored intent).
538pub(crate) fn collect_geometry_style_info(
539    geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
540    styled_item: &DecodedEntity,
541    decoder: &mut EntityDecoder,
542) {
543    let Some(geometry_id) = styled_item.get_ref(0) else {
544        return;
545    };
546    if geometry_styles.contains_key(&geometry_id) {
547        return;
548    }
549    if let Some(style_info) = extract_style_info_from_styled_item(styled_item, decoder) {
550        geometry_styles.insert(geometry_id, style_info);
551    }
552}
553
554/// Extract colour + name from an `IfcStyledItem` by traversing its style
555/// references (directly or through `IfcPresentationStyleAssignment`).
556pub(crate) fn extract_style_info_from_styled_item(
557    styled_item: &DecodedEntity,
558    decoder: &mut EntityDecoder,
559) -> Option<GeometryStyleInfo> {
560    let style_refs = refs_from_list(styled_item, 1)?;
561
562    for style_id in style_refs {
563        if let Ok(style) = decoder.decode_by_id(style_id) {
564            // IfcPresentationStyleAssignment has nested style refs at attr 0.
565            if let Some(inner_refs) = refs_from_list(&style, 0) {
566                for inner_id in inner_refs {
567                    if let Some(info) = extract_surface_style_info(inner_id, decoder) {
568                        return Some(info);
569                    }
570                }
571            }
572
573            // Or the style ref points directly to IfcSurfaceStyle.
574            if let Some(info) = extract_surface_style_info(style_id, decoder) {
575                return Some(info);
576            }
577        }
578    }
579
580    None
581}
582
583/// Extract colour + style name from an `IfcSurfaceStyle`. Colour resolution is
584/// the canonical [`crate::style::extract_surface_style_colors`], shared with
585/// the browser pre-pass so the server and viewer can't disagree on
586/// `SurfaceColour` vs `DiffuseColour` precedence (#997).
587fn extract_surface_style_info(
588    style_id: u32,
589    decoder: &mut EntityDecoder,
590) -> Option<GeometryStyleInfo> {
591    let style = decoder.decode_by_id(style_id).ok()?;
592    let material_name = normalize_style_name(style.get_string(0));
593    let (color, shading_color) = crate::style::extract_surface_style_colors(style_id, decoder)?;
594    Some(GeometryStyleInfo {
595        color,
596        shading_color,
597        material_name,
598    })
599}
600
601fn normalize_style_name(raw: Option<&str>) -> Option<String> {
602    let name = raw?.trim();
603    if name.is_empty() || name == "$" {
604        return None;
605    }
606    if name.eq_ignore_ascii_case("<unnamed>") || name.eq_ignore_ascii_case("unnamed") {
607        return None;
608    }
609    Some(name.to_string())
610}
611
612/// Extract entity references from a list attribute.
613fn refs_from_list(entity: &DecodedEntity, index: usize) -> Option<Vec<u32>> {
614    let list = entity.get_list(index)?;
615    let refs: Vec<u32> = list.iter().filter_map(|v| v.as_entity_ref()).collect();
616    if refs.is_empty() {
617        None
618    } else {
619        Some(refs)
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use ifc_lite_core::{EntityIndex, EntityScanner};
627
628    #[test]
629    fn find_ifcproject_id_late_in_file() {
630        let ifc = b"ISO-10303-21;\nDATA;\n#1=IFCWALL('x',$,$,$,$,$,$,$,$);\n#999123=IFCPROJECT('g',$,'P',$,$,$,$,$,$);\nENDSEC;\n";
631        assert_eq!(find_ifcproject_id(ifc), Some(999123));
632    }
633
634    #[test]
635    fn find_ifcproject_id_absent() {
636        let ifc = b"ISO-10303-21;\nDATA;\n#1=IFCWALL('x',$,$,$,$,$,$,$,$);\nENDSEC;\n";
637        assert_eq!(find_ifcproject_id(ifc), None);
638    }
639
640    #[test]
641    fn find_ifcproject_id_skips_string_decoys() {
642        let ifc = b"DATA;\n#5=IFCWALL('decoy =IFCPROJECT( in a name',$);\n#7=IFCPROJECT('g',$);\n";
643        assert_eq!(find_ifcproject_id(ifc), Some(7));
644    }
645
646    #[test]
647    fn find_ifcproject_id_handles_whitespace_around_equals() {
648        // Revit/EDM exporters write `#id= IFCPROJECT(` with a space after `=`;
649        // the old `=IFCPROJECT(` literal never matched → the whole unit chain
650        // defaulted to metres + radians (issue #1367, arched openings → circles).
651        let space_after = b"DATA;\n#1=IFCWALL('x',$);\n#1593796= IFCPROJECT('g',$,'P',$,$,$,$,$,$);\n";
652        assert_eq!(find_ifcproject_id(space_after), Some(1593796));
653
654        let space_both = b"DATA;\n#42 = IFCPROJECT('g',$);\n";
655        assert_eq!(find_ifcproject_id(space_both), Some(42));
656
657        // IFCPROJECTEDCRS must not be mistaken for IFCPROJECT.
658        let crs_only = b"DATA;\n#9= IFCPROJECTEDCRS('EPSG:32632',$,'WGS84',$,'UTM','32N',$);\n";
659        assert_eq!(find_ifcproject_id(crs_only), None);
660    }
661
662    /// Mimics the Revit/EDM ordering of Architecture.ifc (issue #1367): the
663    /// DEGREE plane-angle unit sits near the file head but its conversion
664    /// `IFCMEASUREWITHUNIT` is at the very tail. With a PARTIAL index that has the
665    /// project + assignment + degree unit but NOT the measure, the plane-angle
666    /// resolver must report "incomplete" so `resolve_unit_scales` retries against
667    /// a full index instead of silently shipping radians.
668    #[test]
669    fn resolve_unit_scales_recovers_degrees_when_measure_past_partial_index() {
670        const IFC: &[u8] = br#"ISO-10303-21;
671HEADER;
672FILE_DESCRIPTION((''),'2;1');
673FILE_NAME('u.ifc','2026-06-26T00:00:00',(''),(''),'','','');
674FILE_SCHEMA(('IFC2X3'));
675ENDSEC;
676DATA;
677#10= IFCPROJECT('g',$,'P',$,$,$,$,$,#11);
678#11= IFCUNITASSIGNMENT((#12,#13));
679#12= IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
680#13= IFCCONVERSIONBASEDUNIT(#14,.PLANEANGLEUNIT.,'DEGREE',#15);
681#14= IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0);
682#16= IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
683#15= IFCMEASUREWITHUNIT(IFCRATIOMEASURE(0.0174532925199433),#16);
684ENDSEC;
685END-ISO-10303-21;
686"#;
687        // Build a PARTIAL index that omits the tail measure (#15) and exponents
688        // (#14), exactly the streaming-gate situation that masked the bug.
689        let mut partial = EntityIndex::default();
690        let mut scanner = EntityScanner::new(&IFC);
691        while let Some((id, _t, start, end)) = scanner.next_entity() {
692            if id == 15 || id == 14 {
693                continue; // forward-referenced past the gate
694            }
695            partial.insert(id, (start, end));
696        }
697        let mut decoder = EntityDecoder::with_index(IFC, partial);
698        let scales = resolve_unit_scales(IFC, Some(10), &mut decoder);
699        assert_eq!(scales.project_id, Some(10));
700        assert!((scales.length_unit_scale - 0.001).abs() < 1e-12);
701        assert!(
702            (scales.plane_angle_to_radians - 0.0174532925199433).abs() < 1e-12,
703            "expected degrees via full-index retry, got {}",
704            scales.plane_angle_to_radians
705        );
706    }
707
708    #[test]
709    fn material_colors_flat_round_trip() {
710        let mut map: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
711        map.insert(10, vec![[0.5, 0.5, 0.5, 1.0], [0.7, 0.9, 0.5, 0.2]]);
712        map.insert(42, vec![[1.0, 0.0, 0.0, 1.0]]);
713
714        let (ids, counts, rgba) = flat_material_colors(&map);
715        let back = material_colors_from_flat(&ids, &counts, &rgba);
716
717        assert_eq!(back.len(), 2);
718        assert_eq!(back[&42].len(), 1);
719        assert_eq!(back[&10].len(), 2);
720        // RGBA8 quantization: equal within 1/255.
721        for (orig, round) in map[&10].iter().zip(back[&10].iter()) {
722            for (a, b) in orig.iter().zip(round.iter()) {
723                assert!((a - b).abs() <= 1.0 / 255.0 + 1e-6);
724            }
725        }
726    }
727
728    /// The flat wire arrays are an EXPLICIT id-ascending contract (pinned by
729    /// the mesh-output determinism manifest), not an FxHashMap iteration-order
730    /// artifact.
731    #[test]
732    fn flat_wire_arrays_are_sorted_by_id() {
733        let mut voids: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
734        voids.insert(300, vec![301, 302]);
735        voids.insert(7, vec![8]);
736        voids.insert(90, vec![91]);
737        let (keys, counts, values) = flat_voids(&voids);
738        assert_eq!(keys, vec![7, 90, 300]);
739        assert_eq!(counts, vec![1, 1, 2]);
740        // Per-host opening lists keep their (file-order) sequence.
741        assert_eq!(values, vec![8, 91, 301, 302]);
742
743        let mut colors: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
744        colors.insert(42, vec![[1.0, 0.0, 0.0, 1.0]]);
745        colors.insert(10, vec![[0.0, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.5]]);
746        let (ids, counts, rgba) = flat_material_colors(&colors);
747        assert_eq!(ids, vec![10, 42]);
748        assert_eq!(counts, vec![2, 1]);
749        assert_eq!(rgba.len(), 12);
750        // First colour on the wire is element #10's first (green), not #42's.
751        assert_eq!(&rgba[0..4], &[0, 255, 0, 255]);
752    }
753
754    #[test]
755    fn resolve_unit_scales_resolves_degrees_and_millimetres() {
756        const IFC: &[u8] = br#"ISO-10303-21;
757HEADER;
758FILE_DESCRIPTION((''),'2;1');
759FILE_NAME('u.ifc','2026-06-12T00:00:00',(''),(''),'','','');
760FILE_SCHEMA(('IFC4'));
761ENDSEC;
762DATA;
763#1=IFCWALL('w',$,$,$,$,$,$,$,$);
764#10=IFCPROJECT('g',$,'P',$,$,$,$,$,#11);
765#11=IFCUNITASSIGNMENT((#12,#13));
766#12=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
767#13=IFCCONVERSIONBASEDUNIT(#14,.PLANEANGLEUNIT.,'DEGREE',#15);
768#14=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0);
769#15=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16);
770#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
771ENDSEC;
772END-ISO-10303-21;
773"#;
774        // No hint: found by substring search; resolved on a fresh decoder.
775        let mut decoder = EntityDecoder::new(IFC);
776        let scales = resolve_unit_scales(IFC, None, &mut decoder);
777        assert_eq!(scales.project_id, Some(10));
778        assert!((scales.length_unit_scale - 0.001).abs() < 1e-12);
779        assert!((scales.plane_angle_to_radians - 0.017_453_292_519_943_295).abs() < 1e-12);
780    }
781}