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;