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. Both scan loops
32/// fill this; neither decodes these entities mid-scan.
33#[derive(Debug, Default)]
34pub struct PrepassSpans {
35    /// `IFCSTYLEDITEM` — geometry-attached AND orphan (material appearance);
36    /// the resolver classifies them (the classifying decode is the cost of
37    /// telling the two apart).
38    pub styled_items: Vec<Span>,
39    /// `IFCINDEXEDCOLOURMAP` (#663/#858 — CATIA/3DEXPERIENCE per-triangle
40    /// palettes, IFC4's second colouring mechanism).
41    pub indexed_colour_maps: Vec<Span>,
42    /// `IFCMATERIALDEFINITIONREPRESENTATION` (#407 material chain).
43    pub material_def_reprs: Vec<Span>,
44    /// `IFCRELASSOCIATESMATERIAL` (#407 material chain).
45    pub rel_associates_material: Vec<Span>,
46    /// `IFCRELVOIDSELEMENT` — host → opening.
47    pub void_rels: Vec<Span>,
48    /// `IFCRELFILLSELEMENT` — opening → filling (window/door); drives the
49    /// native opening filter. Cheap to collect everywhere.
50    pub fills_rels: Vec<Span>,
51    /// `IFCRELAGGREGATES` — parent → children, for aggregate void
52    /// propagation (#845, IfcWallElementedCase etc.).
53    pub aggregate_rels: Vec<Span>,
54}
55
56/// Resolution switches. Both pipelines use the same resolver with different
57/// collection needs.
58#[derive(Debug, Clone, Copy)]
59pub struct ResolveOptions {
60    /// Collect the FULL per-triangle palette maps (#858). The native pipeline
61    /// consumes them in-process; the browser prepass leaves this off (each
62    /// process worker rebuilds its own copy — shipping full palettes over the
63    /// JS boundary would dwarf the styles arrays).
64    pub collect_indexed_colour_full: bool,
65    /// Defer geometry-attached styled items: classify and resolve ORPHAN
66    /// styled items now (#913 §2c — the material chain needs them up front),
67    /// but return attached spans unresolved on
68    /// [`ResolvedPrepass::deferred_attached_styled_spans`] for a later
69    /// [`resolve_styled_item_spans`] replay. Native `fast_first_batch` mode.
70    pub defer_attached_styles: bool,
71}
72
73impl Default for ResolveOptions {
74    fn default() -> Self {
75        Self {
76            collect_indexed_colour_full: true,
77            defer_attached_styles: false,
78        }
79    }
80}
81
82/// Everything the post-scan resolution produces.
83#[derive(Debug, Default)]
84pub struct ResolvedPrepass {
85    /// Geometry item id → resolved style (styled items first in file order,
86    /// then IfcIndexedColourMap dominant colours fill the gaps — styled items
87    /// win, #913 precedence).
88    pub geometry_style_index: FxHashMap<u32, GeometryStyleInfo>,
89    /// Geometry item id → dominant palette colour (#858).
90    pub indexed_colour_index: FxHashMap<u32, [f32; 4]>,
91    /// Geometry item id → full per-triangle palette (#858); empty unless
92    /// [`ResolveOptions::collect_indexed_colour_full`].
93    pub indexed_colour_full: FxHashMap<u32, FullIndexedColourMap>,
94    /// Orphan `IfcStyledItem` colours (material appearances, #407).
95    pub orphan_styled_items: FxHashMap<u32, [f32; 4]>,
96    /// Material id → styled representation ids (#407).
97    pub material_def_reprs: FxHashMap<u32, Vec<u32>>,
98    /// Element id → material(-select) id (#407).
99    pub element_to_material: FxHashMap<u32, u32>,
100    /// Element id → material colour list (#407/#913 §2.3 transparent/opaque
101    /// alternation for window/door parts). The canonical join.
102    pub element_material_colors: FxHashMap<u32, Vec<[f32; 4]>>,
103    /// Host element id → opening ids, AFTER aggregate propagation (#845).
104    pub void_index: FxHashMap<u32, Vec<u32>>,
105    /// Opening id → filling element id (native opening filter input).
106    pub filling_by_opening: FxHashMap<u32, u32>,
107    /// Geometry-attached styled-item spans NOT resolved because
108    /// [`ResolveOptions::defer_attached_styles`] was set; replay them with
109    /// [`resolve_styled_item_spans`].
110    pub deferred_attached_styled_spans: Vec<(usize, usize)>,
111}
112
113/// THE canonical post-scan resolution. Spans are processed in file order so
114/// first-wins precedence matches what the historic inline scans produced.
115pub fn resolve_prepass(
116    spans: &PrepassSpans,
117    decoder: &mut EntityDecoder,
118    opts: ResolveOptions,
119) -> ResolvedPrepass {
120    let mut out = ResolvedPrepass::default();
121
122    // ── Styled items: orphan (material appearance) vs geometry-attached ──
123    for &(id, start, end) in &spans.styled_items {
124        let Ok(styled_item) = decoder.decode_at_with_id(id, start, end) else {
125            if opts.defer_attached_styles {
126                // Undecodable now — let the replay try again later, matching
127                // the historic defer behaviour.
128                out.deferred_attached_styled_spans.push((start, end));
129            }
130            continue;
131        };
132        if styled_item.get_ref(0).is_none() {
133            // Orphan styled item (null Item) = a material appearance (#407).
134            // Always resolved up front — even in defer mode — or
135            // material-only-styled elements render default-gray (#913 §2c).
136            if let Some(info) = extract_style_info_from_styled_item(&styled_item, decoder) {
137                out.orphan_styled_items.insert(id, info.color);
138            }
139        } else if opts.defer_attached_styles {
140            out.deferred_attached_styled_spans.push((start, end));
141        } else {
142            collect_geometry_style_info(&mut out.geometry_style_index, &styled_item, decoder);
143        }
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    // ── Material chain join (#407): element id → colour list ──
228    out.element_material_colors = crate::style::build_element_material_colors(
229        &out.material_def_reprs,
230        &out.orphan_styled_items,
231        &out.element_to_material,
232        decoder,
233    );
234
235    out
236}
237
238/// Resolve geometry-attached styled-item spans into a style index — the
239/// defer-mode replay (`fast_first_batch`), and the building block
240/// [`resolve_prepass`] uses internally.
241pub fn resolve_styled_item_spans(
242    spans: &[(usize, usize)],
243    decoder: &mut EntityDecoder,
244) -> FxHashMap<u32, GeometryStyleInfo> {
245    let mut styles: FxHashMap<u32, GeometryStyleInfo> = FxHashMap::default();
246    for &(start, end) in spans {
247        if let Ok(styled_item) = decoder.decode_at(start, end) {
248            if styled_item.get_ref(0).is_some() {
249                collect_geometry_style_info(&mut styles, &styled_item, decoder);
250            }
251        }
252    }
253    styles
254}
255
256/// Fold `IfcIndexedColourMap` dominant colours into the style index, keyed by
257/// target geometry id. `or_insert` preserves IFCSTYLEDITEM precedence: a
258/// geometry that already has a direct style keeps it; the indexed colour only
259/// fills the gaps (#913).
260pub fn merge_indexed_colours(
261    geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
262    indexed_colours: &FxHashMap<u32, [f32; 4]>,
263) {
264    for (&geometry_id, &color) in indexed_colours {
265        geometry_styles
266            .entry(geometry_id)
267            .or_insert_with(|| GeometryStyleInfo::from_color(color));
268    }
269}
270
271/// The file's unit scales, resolved exactly once per parse.
272#[derive(Debug, Clone, Copy)]
273pub struct UnitScales {
274    /// Length unit → metres (1.0 for metre files, 0.001 for millimetre files).
275    pub length_unit_scale: f64,
276    /// Plane-angle unit → radians (1.0 for RADIAN files, π/180 for DEGREE).
277    pub plane_angle_to_radians: f64,
278    /// The `IFCPROJECT` express id the scales were resolved from, when found.
279    pub project_id: Option<u32>,
280}
281
282impl Default for UnitScales {
283    fn default() -> Self {
284        Self {
285            length_unit_scale: 1.0,
286            plane_angle_to_radians: 1.0,
287            project_id: None,
288        }
289    }
290}
291
292/// Resolve BOTH unit scales once, against the decoder's (possibly partial)
293/// entity index, with the documented fallback ladder:
294///
295/// 1. `project_id` hint (recorded by the scan) — O(1) decode via the index.
296/// 2. No hint? Find `IFCPROJECT` by SIMD substring search (it is a singleton
297///    and many exporters — IfcOpenShell, Revit — emit it near the END of the
298///    file, after the scan's early-meta point).
299/// 3. Resolution chain incomplete on a PARTIAL index (streaming early-meta:
300///    the IFCSIUNIT chain may sit past the scan point)? Re-resolve against a
301///    freshly built FULL index rather than silently defaulting — a millimetre
302///    model resolved as metres renders 1000× oversized.
303///
304/// This is the only sanctioned place that hunts for `IFCPROJECT`; per-element
305/// decoders are seeded from the result (`EntityDecoder::seed_unit_scales`) so
306/// the historic O(file)-scan-per-decoder stall class stays dead.
307pub fn resolve_unit_scales(
308    content: &[u8],
309    project_id_hint: Option<u32>,
310    decoder: &mut EntityDecoder,
311) -> UnitScales {
312    let project_id = project_id_hint.or_else(|| find_ifcproject_id(content));
313    let Some(pid) = project_id else {
314        return UnitScales::default();
315    };
316
317    // Fast path: resolve on the caller's decoder/index.
318    let length = ifc_lite_core::try_extract_length_unit_scale(decoder, pid);
319    let angle = ifc_lite_core::extract_plane_angle_to_radians(decoder, pid).ok();
320
321    if let (Some(length_unit_scale), Some(plane_angle_to_radians)) = (length, angle) {
322        return UnitScales {
323            length_unit_scale,
324            plane_angle_to_radians,
325            project_id,
326        };
327    }
328
329    // Chain incomplete (partial index) — resolve against a full index.
330    let full_index = ifc_lite_core::build_entity_index(content);
331    let mut full_decoder = EntityDecoder::with_index(content, full_index);
332    UnitScales {
333        length_unit_scale: length.or_else(|| {
334            ifc_lite_core::extract_length_unit_scale(&mut full_decoder, pid).ok()
335        })
336        .unwrap_or(1.0),
337        plane_angle_to_radians: angle
338            .or_else(|| {
339                ifc_lite_core::extract_plane_angle_to_radians(&mut full_decoder, pid).ok()
340            })
341            .unwrap_or(1.0),
342        project_id,
343    }
344}
345
346/// Find the singleton `IFCPROJECT`'s express id by SIMD substring search —
347/// no full entity scan. Returns `None` when the file has no project.
348pub fn find_ifcproject_id(content: &[u8]) -> Option<u32> {
349    let mut from = 0usize;
350    while let Some(rel) = memchr::memmem::find(&content[from..], b"=IFCPROJECT(") {
351        let eq = from + rel;
352        // Backtrack over the express id digits to the '#'.
353        let mut i = eq;
354        while i > 0 && content[i - 1].is_ascii_digit() {
355            i -= 1;
356        }
357        if i > 0 && content[i - 1] == b'#' && i < eq {
358            let mut id: u32 = 0;
359            for &b in &content[i..eq] {
360                id = id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
361            }
362            return Some(id);
363        }
364        // `=IFCPROJECT(` without a leading `#<digits>` (e.g. inside a string)
365        // — keep searching.
366        from = eq + 1;
367    }
368    None
369}
370
371/// Flat wire encodings of the resolved styles for the browser's
372/// `styleIds`/`styleColors` arrays: the rich style index flattened to
373/// `(ids, rgba8)`, with IfcIndexedColourMap dominants, flat material colours,
374/// and per-element first material colours filling the gaps — the exact
375/// layered precedence the browser prepasses have always shipped.
376pub fn flat_styles_rgba8(resolved: &ResolvedPrepass, decoder: &mut EntityDecoder) -> (Vec<u32>, Vec<u8>) {
377    let mut merged: FxHashMap<u32, [f32; 4]> = resolved
378        .geometry_style_index
379        .iter()
380        .map(|(&id, info)| (id, info.color))
381        .collect();
382    for (&geometry_id, &color) in &resolved.indexed_colour_index {
383        merged.entry(geometry_id).or_insert(color);
384    }
385    // Flat material_id → colour, then element id → first material colour, so
386    // `processGeometryBatch`'s per-element fallback picks them up.
387    let material_styles = crate::style::build_material_style_index(
388        &resolved.material_def_reprs,
389        &resolved.orphan_styled_items,
390        decoder,
391    );
392    for (&mat_id, &color) in crate::style::flatten_material_color_index(&material_styles).iter() {
393        merged.entry(mat_id).or_insert(color);
394    }
395    for (&element_id, colors) in &resolved.element_material_colors {
396        if let Some(&color) = colors.first() {
397            merged.entry(element_id).or_insert(color);
398        }
399    }
400
401    let mut ids: Vec<u32> = Vec::with_capacity(merged.len());
402    let mut rgba: Vec<u8> = Vec::with_capacity(merged.len() * 4);
403    for (&id, &color) in &merged {
404        ids.push(id);
405        rgba.extend_from_slice(&crate::style::Rgba::from_array(color).to_rgba8());
406    }
407    (ids, rgba)
408}
409
410/// Flat wire encoding of the void index: `(keys, counts, values)` in the
411/// shape `processGeometryBatch` accepts.
412pub fn flat_voids(void_index: &FxHashMap<u32, Vec<u32>>) -> (Vec<u32>, Vec<u32>, Vec<u32>) {
413    let mut keys: Vec<u32> = Vec::with_capacity(void_index.len());
414    let mut counts: Vec<u32> = Vec::with_capacity(void_index.len());
415    let mut values: Vec<u32> = Vec::new();
416    for (&host_id, openings) in void_index {
417        keys.push(host_id);
418        counts.push(openings.len() as u32);
419        values.extend(openings.iter().copied());
420    }
421    (keys, counts, values)
422}
423
424/// Flat wire encoding of the element material colour lists (#407/#913 §2.3):
425/// `(element_ids, counts, rgba8)` — `counts[i]` colours belong to
426/// `element_ids[i]`, in order, 4 bytes each.
427pub fn flat_material_colors(
428    element_material_colors: &FxHashMap<u32, Vec<[f32; 4]>>,
429) -> (Vec<u32>, Vec<u32>, Vec<u8>) {
430    let mut ids: Vec<u32> = Vec::with_capacity(element_material_colors.len());
431    let mut counts: Vec<u32> = Vec::with_capacity(element_material_colors.len());
432    let mut rgba: Vec<u8> = Vec::new();
433    for (&element_id, colors) in element_material_colors {
434        if colors.is_empty() {
435            continue;
436        }
437        ids.push(element_id);
438        counts.push(colors.len() as u32);
439        for &c in colors {
440            rgba.extend_from_slice(&crate::style::Rgba::from_array(c).to_rgba8());
441        }
442    }
443    (ids, counts, rgba)
444}
445
446/// Decode the flat material-colour wire arrays back into the canonical map —
447/// the inverse of [`flat_material_colors`], used by `processGeometryBatch`.
448pub fn material_colors_from_flat(
449    element_ids: &[u32],
450    counts: &[u32],
451    rgba: &[u8],
452) -> FxHashMap<u32, Vec<[f32; 4]>> {
453    let mut out: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
454    let mut offset = 0usize;
455    for (i, &element_id) in element_ids.iter().enumerate() {
456        let Some(&count) = counts.get(i) else { break };
457        let count = count as usize;
458        let mut colors: Vec<[f32; 4]> = Vec::with_capacity(count);
459        for c in 0..count {
460            let base = (offset + c) * 4;
461            if base + 3 >= rgba.len() {
462                break;
463            }
464            colors.push(
465                crate::style::Rgba::from_rgba8([
466                    rgba[base],
467                    rgba[base + 1],
468                    rgba[base + 2],
469                    rgba[base + 3],
470                ])
471                .to_array(),
472            );
473        }
474        offset += count;
475        if !colors.is_empty() {
476            out.insert(element_id, colors);
477        }
478    }
479    out
480}
481
482// ── Styled-item resolution chain (moved from processor.rs — shared) ──
483
484/// Resolve a geometry-attached `IfcStyledItem` into the style index with
485/// first-wins precedence per geometry id (file order = authored intent).
486pub(crate) fn collect_geometry_style_info(
487    geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
488    styled_item: &DecodedEntity,
489    decoder: &mut EntityDecoder,
490) {
491    let Some(geometry_id) = styled_item.get_ref(0) else {
492        return;
493    };
494    if geometry_styles.contains_key(&geometry_id) {
495        return;
496    }
497    if let Some(style_info) = extract_style_info_from_styled_item(styled_item, decoder) {
498        geometry_styles.insert(geometry_id, style_info);
499    }
500}
501
502/// Extract colour + name from an `IfcStyledItem` by traversing its style
503/// references (directly or through `IfcPresentationStyleAssignment`).
504pub(crate) fn extract_style_info_from_styled_item(
505    styled_item: &DecodedEntity,
506    decoder: &mut EntityDecoder,
507) -> Option<GeometryStyleInfo> {
508    let style_refs = refs_from_list(styled_item, 1)?;
509
510    for style_id in style_refs {
511        if let Ok(style) = decoder.decode_by_id(style_id) {
512            // IfcPresentationStyleAssignment has nested style refs at attr 0.
513            if let Some(inner_refs) = refs_from_list(&style, 0) {
514                for inner_id in inner_refs {
515                    if let Some(info) = extract_surface_style_info(inner_id, decoder) {
516                        return Some(info);
517                    }
518                }
519            }
520
521            // Or the style ref points directly to IfcSurfaceStyle.
522            if let Some(info) = extract_surface_style_info(style_id, decoder) {
523                return Some(info);
524            }
525        }
526    }
527
528    None
529}
530
531/// Extract colour + style name from an `IfcSurfaceStyle`. Colour resolution is
532/// the canonical [`crate::style::extract_surface_style_colors`], shared with
533/// the browser pre-pass so the server and viewer can't disagree on
534/// `SurfaceColour` vs `DiffuseColour` precedence (#997).
535fn extract_surface_style_info(
536    style_id: u32,
537    decoder: &mut EntityDecoder,
538) -> Option<GeometryStyleInfo> {
539    let style = decoder.decode_by_id(style_id).ok()?;
540    let material_name = normalize_style_name(style.get_string(0));
541    let (color, shading_color) = crate::style::extract_surface_style_colors(style_id, decoder)?;
542    Some(GeometryStyleInfo {
543        color,
544        shading_color,
545        material_name,
546    })
547}
548
549fn normalize_style_name(raw: Option<&str>) -> Option<String> {
550    let name = raw?.trim();
551    if name.is_empty() || name == "$" {
552        return None;
553    }
554    if name.eq_ignore_ascii_case("<unnamed>") || name.eq_ignore_ascii_case("unnamed") {
555        return None;
556    }
557    Some(name.to_string())
558}
559
560/// Extract entity references from a list attribute.
561fn refs_from_list(entity: &DecodedEntity, index: usize) -> Option<Vec<u32>> {
562    let list = entity.get_list(index)?;
563    let refs: Vec<u32> = list.iter().filter_map(|v| v.as_entity_ref()).collect();
564    if refs.is_empty() {
565        None
566    } else {
567        Some(refs)
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn find_ifcproject_id_late_in_file() {
577        let ifc = b"ISO-10303-21;\nDATA;\n#1=IFCWALL('x',$,$,$,$,$,$,$,$);\n#999123=IFCPROJECT('g',$,'P',$,$,$,$,$,$);\nENDSEC;\n";
578        assert_eq!(find_ifcproject_id(ifc), Some(999123));
579    }
580
581    #[test]
582    fn find_ifcproject_id_absent() {
583        let ifc = b"ISO-10303-21;\nDATA;\n#1=IFCWALL('x',$,$,$,$,$,$,$,$);\nENDSEC;\n";
584        assert_eq!(find_ifcproject_id(ifc), None);
585    }
586
587    #[test]
588    fn find_ifcproject_id_skips_string_decoys() {
589        let ifc = b"DATA;\n#5=IFCWALL('decoy =IFCPROJECT( in a name',$);\n#7=IFCPROJECT('g',$);\n";
590        assert_eq!(find_ifcproject_id(ifc), Some(7));
591    }
592
593    #[test]
594    fn material_colors_flat_round_trip() {
595        let mut map: FxHashMap<u32, Vec<[f32; 4]>> = FxHashMap::default();
596        map.insert(10, vec![[0.5, 0.5, 0.5, 1.0], [0.7, 0.9, 0.5, 0.2]]);
597        map.insert(42, vec![[1.0, 0.0, 0.0, 1.0]]);
598
599        let (ids, counts, rgba) = flat_material_colors(&map);
600        let back = material_colors_from_flat(&ids, &counts, &rgba);
601
602        assert_eq!(back.len(), 2);
603        assert_eq!(back[&42].len(), 1);
604        assert_eq!(back[&10].len(), 2);
605        // RGBA8 quantization: equal within 1/255.
606        for (orig, round) in map[&10].iter().zip(back[&10].iter()) {
607            for (a, b) in orig.iter().zip(round.iter()) {
608                assert!((a - b).abs() <= 1.0 / 255.0 + 1e-6);
609            }
610        }
611    }
612
613    #[test]
614    fn resolve_unit_scales_resolves_degrees_and_millimetres() {
615        const IFC: &[u8] = br#"ISO-10303-21;
616HEADER;
617FILE_DESCRIPTION((''),'2;1');
618FILE_NAME('u.ifc','2026-06-12T00:00:00',(''),(''),'','','');
619FILE_SCHEMA(('IFC4'));
620ENDSEC;
621DATA;
622#1=IFCWALL('w',$,$,$,$,$,$,$,$);
623#10=IFCPROJECT('g',$,'P',$,$,$,$,$,#11);
624#11=IFCUNITASSIGNMENT((#12,#13));
625#12=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
626#13=IFCCONVERSIONBASEDUNIT(#14,.PLANEANGLEUNIT.,'DEGREE',#15);
627#14=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0);
628#15=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16);
629#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
630ENDSEC;
631END-ISO-10303-21;
632"#;
633        // No hint: found by substring search; resolved on a fresh decoder.
634        let mut decoder = EntityDecoder::new(IFC);
635        let scales = resolve_unit_scales(IFC, None, &mut decoder);
636        assert_eq!(scales.project_id, Some(10));
637        assert!((scales.length_unit_scale - 0.001).abs() < 1e-12);
638        assert!((scales.plane_angle_to_radians - 0.017_453_292_519_943_295).abs() < 1e-12);
639    }
640}