ifc_lite_processing/shard_classes.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//! Per-record prepass classification for the sharded scan (split from
6//! `parallel_scan.rs` — the byte-identical shard/stitch protocol lives there;
7//! this module owns the class codes and the classified scan variant the
8//! browser's sharded pre-pass consumes).
9
10use crate::parallel_scan::{ShardRecords, ShardRefusals};
11use ifc_lite_core::EntityScanner;
12
13/// Per-record prepass class emitted by [`scan_shard_classified`].
14///
15/// Only the codes a downstream consumer needs are defined; everything else is
16/// [`PREPASS_CLASS_NONE`]. Classification happens AT SCAN TIME from the same
17/// `type_name` string the serial pre-pass matches on, so a consumer that
18/// filters records by class reproduces the serial pre-pass's span collection
19/// byte-for-byte (same keyword compare, same file order).
20pub const PREPASS_CLASS_NONE: u8 = 0;
21/// `IFCSTYLEDITEM` — the styled-item spans the pre-pass resolver classifies
22/// into orphan (material appearance) vs geometry-attached styles.
23pub const PREPASS_CLASS_STYLED_ITEM: u8 = 4;
24/// `IFCINDEXEDCOLOURMAP` (#663/#858).
25pub const PREPASS_CLASS_INDEXED_COLOUR_MAP: u8 = 5;
26/// `IFCMATERIALDEFINITIONREPRESENTATION` (#407).
27pub const PREPASS_CLASS_MATERIAL_DEF_REPR: u8 = 6;
28/// `IFCRELASSOCIATESMATERIAL` (#407).
29pub const PREPASS_CLASS_REL_ASSOCIATES_MATERIAL: u8 = 7;
30/// `IFCRELVOIDSELEMENT`.
31pub const PREPASS_CLASS_REL_VOIDS: u8 = 8;
32/// `IFCRELFILLSELEMENT`.
33pub const PREPASS_CLASS_REL_FILLS: u8 = 9;
34/// `IFCRELAGGREGATES`.
35pub const PREPASS_CLASS_REL_AGGREGATES: u8 = 10;
36/// `IFCPROJECT`.
37pub const PREPASS_CLASS_PROJECT: u8 = 2;
38/// `IFCSITE` (also a geometry job — the pre-pass buffers it like one).
39pub const PREPASS_CLASS_SITE: u8 = 3;
40/// `IFCMATERIALLAYERSET` / `IFCMATERIALLAYERSETUSAGE` (arms the layer index).
41pub const PREPASS_CLASS_MATERIAL_LAYER_SET: u8 = 13;
42/// FLAG bit: geometry-bearing entity (`has_geometry_by_name`) — a pre-pass
43/// geometry job. Composes with the named codes' nibble range (2..=13) and
44/// with [`PREPASS_CLASS_FLAG_TYPE_CANDIDATE`].
45pub const PREPASS_CLASS_FLAG_GEOMETRY_JOB: u8 = 0x80;
46/// FLAG bit: `IfcTypeProduct` subtype candidate (name ends TYPE/STYLE) for the
47/// #957 orphan type-geometry pass.
48pub const PREPASS_CLASS_FLAG_TYPE_CANDIDATE: u8 = 0x40;
49/// `IFCMAPPEDITEM` (#957/#1623 repmap plans).
50pub const PREPASS_CLASS_MAPPED_ITEM: u8 = 11;
51/// `IFCRELDEFINESBYTYPE` (#957 instantiated-type ids).
52pub const PREPASS_CLASS_REL_DEFINES_BY_TYPE: u8 = 12;
53/// Mask extracting the named-arm code from a class byte (drops the flag bits).
54pub const PREPASS_CLASS_CODE_MASK: u8 = 0x3F;
55
56/// [`scan_shard`] plus a parallel per-record class column (see the
57/// `PREPASS_CLASS_*` codes). Same records, same handoff; the class byte lets
58/// the browser host extract pre-pass span lists (today: styled items) from the
59/// stitched shard columns WITHOUT waiting for the serial pre-pass scan.
60pub fn scan_shard_classified(
61 content: &[u8],
62 range_start: usize,
63 range_end: usize,
64) -> (ShardRecords, Vec<u8>, Option<usize>) {
65 let (records, classes, handoff, _refusals) =
66 scan_shard_classified_with_refusals(content, range_start, range_end);
67 (records, classes, handoff)
68}
69
70/// [`scan_shard_classified`] plus the byte offset of every record this shard
71/// refused because its instance name does not fit `u32` (#3395).
72///
73/// The browser's SAB-backed pre-scanned load hands the stitched shard columns
74/// straight to the parser worker, which cannot recover the refusals from the
75/// narrowed columns — the ids that were dropped are simply not there. So they
76/// have to ride along, and only a caller that asked for them pays for the
77/// extra binding. [`scan_shard_classified`] stays the 3-tuple it always was
78/// (an added return value would be a breaking change for a published crate)
79/// and delegates here, so there is one loop, not two.
80///
81/// Offsets rather than a count, for the reason spelled out on
82/// [`scan_shard_with_diagnostics`](crate::scan_shard_with_diagnostics): a shard that
83/// starts inside a quoted value refuses text the file never declared, so only
84/// the host's stitch — which knows where this shard's retained region begins
85/// — can tell a real refusal from an artefact of where the shard started.
86/// Handing back a count instead would let a clean file be reported as
87/// incomplete.
88pub fn scan_shard_classified_with_refusals(
89 content: &[u8],
90 range_start: usize,
91 range_end: usize,
92) -> (ShardRecords, Vec<u8>, Option<usize>, ShardRefusals) {
93 let mut scanner = if range_start == 0 {
94 EntityScanner::new(content)
95 } else {
96 EntityScanner::new_at(content, range_start)
97 };
98 let mut records = Vec::new();
99 let mut classes = Vec::new();
100 let mut handoff = None;
101 while let Some((id, type_name, start, entity_end)) = scanner.next_entity() {
102 if start >= range_end {
103 handoff = Some(start);
104 break;
105 }
106 records.push((id, start, entity_end));
107 classes.push(classify_type_name_with_content(
108 type_name,
109 &content[start..entity_end],
110 ));
111 }
112 (
113 records,
114 classes,
115 handoff,
116 scanner.skipped_oversized_id_starts().to_vec(),
117 )
118}
119
120/// [`classify_type_name`] plus the #1910 instance-level exception: a spatial
121/// container `has_geometry_by_name` blocks by name (`IfcBuilding` et al.) is
122/// still classified as a geometry job when THIS instance's `Representation`
123/// attribute (index 6) is exceptionally non-null -- mirrors the identical
124/// exception applied to the serial scan loop
125/// (`rust/wasm-bindings/src/api/gpu_meshes/prepass.rs`) and the streaming
126/// processor (`rust/processing/src/processor/mod.rs`), so all three
127/// discovery paths agree on what counts as geometry. `entity_bytes` is the
128/// full `#id=KEYWORD(...)` span for this record.
129pub fn classify_type_name_with_content(type_name: &str, entity_bytes: &[u8]) -> u8 {
130 let mut class = classify_type_name(type_name);
131 if class & PREPASS_CLASS_FLAG_GEOMETRY_JOB == 0
132 && ifc_lite_core::is_representationless_spatial_container_by_name(type_name)
133 && ifc_lite_core::nth_attribute_is_present(entity_bytes, 6)
134 {
135 class |= PREPASS_CLASS_FLAG_GEOMETRY_JOB;
136 }
137 class
138}
139
140/// Classify a scanned STEP keyword into the prepass class byte: a named-arm
141/// code for the exact keywords the serial pre-pass matches, plus the
142/// geometry-job / type-candidate FLAG bits from the same helpers it calls
143/// (`has_geometry_by_name`, `IfcType::is_subtype_of`). Byte-identical span
144/// collection and job discovery follow from using the identical predicates at
145/// scan time.
146///
147/// Deliberately name-only (cannot see the entity bytes) -- the #1910
148/// instance-level exception lives one layer up, in
149/// [`classify_type_name_with_content`], which is what [`scan_shard_classified`]
150/// actually calls. Kept as a separate function (rather than inlining) so
151/// every other named/flag arm here stays byte-identical to what it was
152/// before #1910 -- the only new code path is the explicit OR-in above.
153pub fn classify_type_name(type_name: &str) -> u8 {
154 use ifc_lite_core::{has_geometry_by_name, type_product_ifc_type};
155 let named = match type_name {
156 "IFCPROJECT" => PREPASS_CLASS_PROJECT,
157 "IFCSITE" => return PREPASS_CLASS_SITE, // site is job + site-record; flags implied
158 "IFCSTYLEDITEM" => PREPASS_CLASS_STYLED_ITEM,
159 "IFCINDEXEDCOLOURMAP" => PREPASS_CLASS_INDEXED_COLOUR_MAP,
160 "IFCMATERIALDEFINITIONREPRESENTATION" => PREPASS_CLASS_MATERIAL_DEF_REPR,
161 "IFCRELASSOCIATESMATERIAL" => PREPASS_CLASS_REL_ASSOCIATES_MATERIAL,
162 "IFCRELVOIDSELEMENT" => PREPASS_CLASS_REL_VOIDS,
163 "IFCRELFILLSELEMENT" => PREPASS_CLASS_REL_FILLS,
164 "IFCRELAGGREGATES" => PREPASS_CLASS_REL_AGGREGATES,
165 "IFCMAPPEDITEM" => PREPASS_CLASS_MAPPED_ITEM,
166 "IFCRELDEFINESBYTYPE" => PREPASS_CLASS_REL_DEFINES_BY_TYPE,
167 "IFCMATERIALLAYERSET" | "IFCMATERIALLAYERSETUSAGE" => PREPASS_CLASS_MATERIAL_LAYER_SET,
168 _ => PREPASS_CLASS_NONE,
169 };
170 if named != PREPASS_CLASS_NONE {
171 // The named keywords are mutually exclusive with the flag predicates in
172 // the serial match (its arms return before the `_` arm runs them).
173 return named;
174 }
175 let mut class = PREPASS_CLASS_NONE;
176 if type_product_ifc_type(type_name).is_some() {
177 class |= PREPASS_CLASS_FLAG_TYPE_CANDIDATE;
178 }
179 if has_geometry_by_name(type_name) {
180 class |= PREPASS_CLASS_FLAG_GEOMETRY_JOB;
181 }
182 class
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 /// The type-candidate check ORs two independent suffix tests
190 /// (`ends_with("TYPE")` and `ends_with("STYLE")`) before confirming the
191 /// resolved `IfcType` is a subtype of `IfcTypeProduct`. Pin that the TYPE
192 /// arm alone is load-bearing: `IFCWALLTYPE` (a real `IfcTypeProduct`
193 /// subtype) must set the flag even though it does NOT end in "STYLE", so a
194 /// mutation that drops the `ends_with("TYPE")` disjunct and keeps only the
195 /// STYLE check would misclassify it and lose #957's orphan type-geometry
196 /// pass for every walltype-shaped keyword.
197 #[test]
198 fn type_suffix_sets_type_candidate_flag() {
199 let class = classify_type_name("IFCWALLTYPE");
200 assert_eq!(
201 class & PREPASS_CLASS_FLAG_TYPE_CANDIDATE,
202 PREPASS_CLASS_FLAG_TYPE_CANDIDATE,
203 "IFCWALLTYPE is an IfcTypeProduct subtype ending in TYPE, not STYLE — \
204 it must set the type-candidate flag via the TYPE arm alone"
205 );
206 }
207
208 /// The STYLE arm is deliberately distinct from the TYPE arm: a keyword
209 /// ending in "STYLE" that is NOT an `IfcTypeProduct` subtype (`IFCSURFACESTYLE`
210 /// is an `IfcPresentationStyle`) must NOT set the flag. Combined with
211 /// `type_suffix_sets_type_candidate_flag` above, this pins that the two
212 /// suffix checks gate genuinely different keyword sets rather than one
213 /// subsuming the other.
214 #[test]
215 fn style_suffix_without_type_product_subtype_does_not_set_flag() {
216 let class = classify_type_name("IFCSURFACESTYLE");
217 assert_eq!(
218 class & PREPASS_CLASS_FLAG_TYPE_CANDIDATE,
219 0,
220 "IFCSURFACESTYLE is not an IfcTypeProduct subtype, so the type-candidate \
221 flag must stay clear even though the name ends in STYLE"
222 );
223 }
224
225 /// Pins every named-arm keyword to its own distinct class code. The two
226 /// tests above cover only the TYPE/STYLE suffix flag; nothing asserted
227 /// the named-arm lookup table itself, so this is a textbook lookup-table
228 /// vacuity: any two of the 12 named arms could be swapped
229 /// (e.g. `IFCRELVOIDSELEMENT` ↔ `IFCRELFILLSELEMENT`) and the full suite
230 /// stayed green. A wrong class here means the sharded pre-pass hands the
231 /// browser host the wrong span list for a record (voids treated as
232 /// fills, materials treated as aggregates, etc.), silently diverging
233 /// from the serial pre-pass it must reproduce byte-for-byte.
234 #[test]
235 fn classify_type_name_pins_every_named_arm_to_a_distinct_code() {
236 let cases = [
237 ("IFCPROJECT", PREPASS_CLASS_PROJECT),
238 ("IFCSITE", PREPASS_CLASS_SITE),
239 ("IFCSTYLEDITEM", PREPASS_CLASS_STYLED_ITEM),
240 ("IFCINDEXEDCOLOURMAP", PREPASS_CLASS_INDEXED_COLOUR_MAP),
241 ("IFCMATERIALDEFINITIONREPRESENTATION", PREPASS_CLASS_MATERIAL_DEF_REPR),
242 ("IFCRELASSOCIATESMATERIAL", PREPASS_CLASS_REL_ASSOCIATES_MATERIAL),
243 ("IFCRELVOIDSELEMENT", PREPASS_CLASS_REL_VOIDS),
244 ("IFCRELFILLSELEMENT", PREPASS_CLASS_REL_FILLS),
245 ("IFCRELAGGREGATES", PREPASS_CLASS_REL_AGGREGATES),
246 ("IFCMAPPEDITEM", PREPASS_CLASS_MAPPED_ITEM),
247 ("IFCRELDEFINESBYTYPE", PREPASS_CLASS_REL_DEFINES_BY_TYPE),
248 ("IFCMATERIALLAYERSET", PREPASS_CLASS_MATERIAL_LAYER_SET),
249 ("IFCMATERIALLAYERSETUSAGE", PREPASS_CLASS_MATERIAL_LAYER_SET),
250 ];
251 for (keyword, expected) in cases {
252 assert_eq!(
253 classify_type_name(keyword),
254 expected,
255 "{keyword} classified as {} not {expected}",
256 classify_type_name(keyword)
257 );
258 }
259
260 // Golden totals: every expected code above (except the two that
261 // intentionally share MATERIAL_LAYER_SET) is pairwise distinct, so a
262 // swap between any two arms is caught by the per-case assertion —
263 // not just an aggregate that a swap could still satisfy.
264 let distinct_codes: std::collections::HashSet<u8> =
265 cases.iter().map(|(_, c)| *c).collect();
266 assert_eq!(distinct_codes.len(), 12, "expected 12 distinct codes across 13 keywords (2 share MATERIAL_LAYER_SET)");
267
268 // An unrecognised keyword with no geometry/type-candidate signal is NONE.
269 assert_eq!(classify_type_name("IFCUNKNOWNTHING"), PREPASS_CLASS_NONE);
270 }
271}