ifc_lite_core/fast_parse.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//! Fast Direct Parsing Module
6//!
7//! Provides zero-allocation parsing for coordinate lists and index arrays.
8//! This bypasses the Token/AttributeValue pipeline for massive speedups
9//! on tessellation-heavy IFC files.
10//!
11//! Performance: 3-5x faster than standard path for IfcTriangulatedFaceSet
12
13#[path = "fast_parse_comments.rs"]
14mod comments;
15
16#[path = "fast_parse_coordinates.rs"]
17mod coordinates;
18
19pub use coordinates::{parse_coordinates_direct, parse_coordinates_direct_f64};
20
21/// Estimate number of floats in coordinate data
22///
23/// Shared with the split-out `coordinates` module.
24#[inline]
25fn estimate_float_count(bytes: &[u8]) -> usize {
26 // Rough estimate: ~8 bytes per float on average (including delimiters)
27 bytes.len() / 8
28}
29
30/// Estimate number of integers in index data
31#[inline]
32fn estimate_int_count(bytes: &[u8]) -> usize {
33 // Rough estimate: ~4 bytes per integer on average
34 bytes.len() / 4
35}
36
37/// Parse index list directly from raw bytes to `Vec<u32>`
38///
39/// This parses IFC face index data like:
40/// `((1,2,3),(2,1,4),...)`
41///
42/// Automatically converts from 1-based IFC indices to 0-based.
43///
44/// # Performance
45/// - Zero intermediate allocations
46/// - Uses inline integer parsing
47#[inline]
48pub fn parse_indices_direct(bytes: &[u8]) -> Vec<u32> {
49 if comments::may_contain_step_comment(bytes) {
50 return comments::parse_indices(bytes);
51 }
52 let mut result = Vec::with_capacity(estimate_int_count(bytes));
53 let mut pos = 0;
54 let len = bytes.len();
55 while pos < len {
56 while pos < len && !bytes[pos].is_ascii_digit() {
57 pos += 1;
58 }
59 if pos >= len {
60 break;
61 }
62 parse_index_value(bytes, &mut pos, &mut result);
63 }
64 result
65}
66
67/// Parse the digit run at `pos`; shared by the comment-free and aware scans.
68#[inline(always)]
69fn parse_index_value(bytes: &[u8], pos: &mut usize, result: &mut Vec<u32>) {
70 // Use checked arithmetic so a pathologically large index saturates to an
71 // obviously out-of-range value instead of wrapping to a valid-looking one.
72 let mut value: u32 = 0;
73 let mut overflowed = false;
74 while *pos < bytes.len() && bytes[*pos].is_ascii_digit() {
75 if !overflowed {
76 match value
77 .checked_mul(10)
78 .and_then(|v| v.checked_add((bytes[*pos] - b'0') as u32))
79 {
80 Some(v) => value = v,
81 None => overflowed = true,
82 }
83 }
84 *pos += 1;
85 }
86 if overflowed {
87 value = u32::MAX;
88 }
89 result.push(value.saturating_sub(1));
90}
91
92/// Parse a whole point-list record's `CoordList` (attribute 0), found by depth, not by the
93/// last `))`: an IFC4X3 `TagList` after it ends in `))` and its digits became phantom
94/// vertices (core review behind #4577, finding 6). `None` when attribute 0 is not a list
95/// or the list is refused as corrupt (#5266).
96#[inline]
97pub fn extract_coordinate_list_from_entity(bytes: &[u8]) -> Option<Vec<f32>> {
98 coordinates::try_parse_coordinates_direct(coordinate_list_span(bytes)?)
99}
100
101/// [`extract_coordinate_list_from_entity`] at f64 precision. A caller that
102/// rebases national-grid coordinates must subtract its offset from these
103/// values: narrowing first can merge vertices that differ by less than one
104/// f32 ULP (0.25 m at 2,600 km) before the offset is removed (#5698).
105#[inline]
106pub fn extract_coordinate_list_from_entity_f64(bytes: &[u8]) -> Option<Vec<f64>> {
107 coordinates::try_parse_coordinates_direct_f64(coordinate_list_span(bytes)?)
108}
109
110/// The bytes of attribute 0's balanced list, including its outer parentheses.
111fn coordinate_list_span(bytes: &[u8]) -> Option<&[u8]> {
112 let head = crate::parser::argument_list_start(bytes)?;
113 let open = crate::parser::skip_step_trivia(bytes, head)?;
114 if bytes.get(open) != Some(&b'(') {
115 return None;
116 }
117 let (mut i, mut depth) = (open + 1, 1usize);
118 loop {
119 i += memchr::memchr3(b'(', b')', b'/', &bytes[i..])?;
120 match bytes[i] {
121 b'/' if bytes.get(i + 1) == Some(&b'*') => {
122 i = crate::parser::skip_step_comment(bytes, i)?;
123 continue;
124 }
125 b'(' => depth += 1,
126 b')' if depth == 1 => return Some(&bytes[open..=i]),
127 b')' => depth -= 1,
128 _ => {}
129 }
130 i += 1;
131 }
132}
133
134/// Parse face indices from IfcTriangulatedFaceSet entity
135///
136/// Finds the CoordIndex attribute (4th attribute, 0-indexed as 3)
137/// in an entity like:
138/// `#77=IFCTRIANGULATEDFACESET(#78,$,$,((1,2,3),(2,1,4),...),$);`
139#[inline]
140pub fn extract_face_indices_from_entity(bytes: &[u8]) -> Option<Vec<u32>> {
141 // Count commas to find the 4th attribute (CoordIndex)
142 // Format: IFCTRIANGULATEDFACESET(Coordinates,Normals,Closed,CoordIndex,PnIndex)
143 let mut paren_depth = 0;
144 let mut comma_count = 0;
145 let mut attr_start = None;
146 let mut attr_end = None;
147
148 for (i, &b) in bytes.iter().enumerate() {
149 match b {
150 b'(' => {
151 if paren_depth == 1 && comma_count == 3 && attr_start.is_none() {
152 attr_start = Some(i);
153 }
154 paren_depth += 1;
155 }
156 b')' => {
157 paren_depth -= 1;
158 if paren_depth == 1
159 && comma_count == 3
160 && attr_start.is_some()
161 && attr_end.is_none()
162 {
163 attr_end = Some(i + 1);
164 }
165 }
166 b',' if paren_depth == 1 => {
167 if comma_count == 3 && attr_end.is_none() && attr_start.is_some() {
168 attr_end = Some(i);
169 }
170 comma_count += 1;
171 }
172 _ => {}
173 }
174 }
175
176 let start = attr_start?;
177 let end = attr_end?;
178
179 if end <= start {
180 return None;
181 }
182
183 Some(parse_indices_direct(&bytes[start..end]))
184}
185
186/// Fast path checker - determines if entity type benefits from direct parsing
187#[inline]
188pub fn should_use_fast_path(type_name: &str) -> bool {
189 matches!(
190 type_name.to_uppercase().as_str(),
191 "IFCCARTESIANPOINTLIST3D"
192 | "IFCTRIANGULATEDFACESET"
193 | "IFCTRIANGULATEDIRREGULARNETWORK"
194 | "IFCPOLYGONALFACESET"
195 | "IFCINDEXEDPOLYGONALFACE"
196 )
197}
198
199/// Extract entity type name from raw bytes
200///
201/// From `#77=IFCTRIANGULATEDFACESET(...)` extracts `IFCTRIANGULATEDFACESET`
202///
203/// TRIMMED, because STEP permits whitespace around `=` and real exporters use
204/// it: buildingSMART's own `column-straight-rectangle-tessellation.ifc` writes
205/// `#71= IFCCOLUMN(` on all 26 of its entity lines. Until #3179 this returned
206/// `" IFCCOLUMN"` with the leading space for those files, which no lookup
207/// keyed on a type name can match — the function's own doc comment above
208/// promised otherwise. It had no production caller at the time, so nothing
209/// noticed; `legacy_aware_ifc_type_from_record` is the first, and it silently
210/// resolved every entity in such a file to `Unknown` until this was fixed.
211#[inline]
212pub fn extract_entity_type_name(bytes: &[u8]) -> Option<&str> {
213 // Find '=' position
214 let eq_pos = bytes.iter().position(|&b| b == b'=')?;
215 // Find '(' position after '='
216 let paren_pos = bytes[eq_pos..].iter().position(|&b| b == b'(')?;
217 let type_start = eq_pos + 1;
218 let type_end = eq_pos + paren_pos;
219
220 // No `type_end <= type_start` guard: `bytes[eq_pos]` is `=`, never `(`, so
221 // `paren_pos >= 1` and `type_end >= type_start` always. The one reachable
222 // equality is `#1=(`, which yields an empty slice that the `is_empty` below
223 // rejects. `an_unreadable_record_changes_nothing` covers that input.
224 let name = std::str::from_utf8(&bytes[type_start..type_end]).ok()?.trim();
225 (!name.is_empty()).then_some(name)
226}
227
228/// Extract the first entity reference from an entity's first attribute
229///
230/// From `#77=IFCTRIANGULATEDFACESET(#78,...)` extracts `78`
231#[inline]
232pub fn extract_first_entity_ref(bytes: &[u8]) -> Option<u32> {
233 // Find opening paren
234 let paren_pos = bytes.iter().position(|&b| b == b'(')?;
235 let content = &bytes[paren_pos + 1..];
236
237 // Find '#' which marks entity reference
238 let hash_pos = content.iter().position(|&b| b == b'#')?;
239 let id_start = hash_pos + 1;
240
241 // Find the end of the digit run, then parse it through the single
242 // checked accumulator shared with every other reference reader and the
243 // definition scanner (issue #3421) — an id above `u32::MAX` is refused
244 // (`None`) rather than wrapped onto a real low-numbered entity.
245 let mut i = id_start;
246 while i < content.len() && content[i].is_ascii_digit() {
247 i += 1;
248 }
249
250 if i > id_start {
251 crate::express_id::parse_express_id(&content[id_start..i])
252 } else {
253 None
254 }
255}
256
257/// Mesh data for fast path processing (avoiding full Mesh struct dependency)
258#[derive(Debug, Clone)]
259pub struct FastMeshData {
260 pub positions: Vec<f32>,
261 pub indices: Vec<u32>,
262}
263
264/// Process IfcTriangulatedFaceSet directly from raw bytes
265///
266/// This completely bypasses the Token/AttributeValue pipeline for
267/// maximum performance on tessellation geometry.
268///
269/// # Arguments
270/// * `faceset_bytes` - Raw bytes of the IfcTriangulatedFaceSet entity
271/// * `get_entity_bytes` - Function to retrieve raw bytes for a given entity ID
272///
273/// # Returns
274/// FastMeshData with positions and indices, or None if parsing fails
275#[inline]
276pub fn process_triangulated_faceset_direct<F>(
277 faceset_bytes: &[u8],
278 get_entity_bytes: F,
279) -> Option<FastMeshData>
280where
281 F: Fn(u32) -> Option<Vec<u8>>,
282{
283 // Extract coordinate entity reference from first attribute
284 let coord_entity_id = extract_first_entity_ref(faceset_bytes)?;
285
286 // Get raw bytes of coordinate list entity
287 let coord_bytes = get_entity_bytes(coord_entity_id)?;
288
289 let positions = extract_coordinate_list_from_entity(&coord_bytes)?;
290
291 // Extract and parse indices from attribute 3 (CoordIndex)
292 let indices = extract_face_indices_from_entity(faceset_bytes)?;
293
294 Some(FastMeshData { positions, indices })
295}
296
297/// Extract entity IDs from a list attribute without full parsing
298///
299/// From `(#1,#2,#3)` extracts `[1, 2, 3]`
300#[inline]
301pub fn extract_entity_refs_from_list(bytes: &[u8]) -> Vec<u32> {
302 let mut ids = Vec::with_capacity(16);
303 let mut i = 0;
304 let len = bytes.len();
305
306 while i < len {
307 // Find next '#'
308 while i < len && bytes[i] != b'#' {
309 i += 1;
310 }
311 if i >= len {
312 break;
313 }
314 i += 1; // Skip '#'
315
316 // Parse ID through the shared checked accumulator (issue #3421): an
317 // id above `u32::MAX` is refused (`None`, dropped from `ids`) rather
318 // than wrapped onto a real low-numbered entity.
319 let id_start = i;
320 while i < len && bytes[i].is_ascii_digit() {
321 i += 1;
322 }
323 if i > id_start {
324 if let Some(id) = crate::express_id::parse_express_id(&bytes[id_start..i]) {
325 if id > 0 {
326 ids.push(id);
327 }
328 }
329 }
330 }
331
332 ids
333}
334
335#[cfg(test)]
336#[path = "fast_parse_tests.rs"]
337mod tests;