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;