ifc_lite_core/
decoder.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//! Entity Decoder - On-demand entity parsing
6//!
7//! Lazily decode IFC entities from byte offsets without loading entire file into memory.
8
9use crate::columnar_index::EntityIndexStore;
10use crate::error::{Error, Result};
11use crate::parser::{
12    is_step_space, parse_entity, parse_step_numeric, report_scan_diagnostics, skip_step_trivia,
13    EntityScanner,
14};
15use crate::schema_gen::{AttributeValue, DecodedEntity};
16use rustc_hash::FxHashMap;
17use std::sync::Arc;
18
19#[path = "decoder/caches.rs"]
20mod caches;
21#[path = "decoder/fast_buffers.rs"]
22mod fast_buffers;
23#[path = "decoder/precision.rs"]
24mod precision;
25#[path = "decoder/schema_layout.rs"]
26mod schema_layout;
27#[path = "decoder/styled_items.rs"]
28mod styled_items;
29pub use schema_layout::GridAxisIndex;
30pub(crate) type StyledItemIndexResult = std::result::Result<FxHashMap<u32, Vec<u32>>, String>;
31
32/// Pre-built entity index type
33pub type EntityIndex = FxHashMap<u32, (usize, usize)>;
34
35/// Build an entity index from content.
36///
37/// This intentionally shares `EntityScanner`'s HEADER skipping, quoted-string
38/// semantics, and its #3395 refusal of instance names wider than `u32` (reported
39/// here, not handed back as a quietly short index), so scan and lookup agree.
40#[inline]
41pub fn build_entity_index<T>(content: &T) -> EntityIndex
42where
43    T: AsRef<[u8]> + ?Sized,
44{
45    let content = content.as_ref();
46    let estimated_entities = content.len() / 50;
47    let mut index = FxHashMap::with_capacity_and_hasher(estimated_entities, Default::default());
48    let mut scanner = EntityScanner::new(content);
49    while let Some((id, _type_name, start, end)) = scanner.next_entity() {
50        index.insert(id, (start, end));
51    }
52    report_scan_diagnostics(scanner.skipped_oversized_ids(), scanner.malformed_record_start().is_some());
53    index
54}
55
56/// Entity decoder for lazy parsing from raw IFC bytes.
57///
58/// String attributes are decoded lossily when tokens become `AttributeValue`s;
59/// structural scanning and byte offsets always use the original source bytes.
60pub struct EntityDecoder<'a> {
61    content: &'a [u8],
62    /// Cache of decoded entities (entity_id -> `Arc<DecodedEntity>`)
63    /// Using Arc avoids expensive clones on cache hits
64    cache: FxHashMap<u32, Arc<DecodedEntity>>,
65    /// Entity offsets (entity_id -> (start, end)): legacy `FxHashMap` or compact
66    /// columnar index. `pub(crate)` for `crate::columnar_index`'s constructors.
67    pub(crate) entity_index: Option<EntityIndexStore>,
68    /// Cache of cartesian point coordinates for FacetedBrep optimization
69    /// Only populated when using get_polyloop_coords_cached
70    point_cache: FxHashMap<u32, (f64, f64, f64)>,
71    /// Number of `point_cache` hits served by [`Self::get_polyloop_coords_cached`].
72    /// Pure instrumentation (never affects output); read via
73    /// [`Self::point_cache_stats`] to prove cross-element memoization fires when
74    /// the cache is hoisted across a worker's parts.
75    point_cache_hits: u64,
76    /// Number of `point_cache` misses (a CartesianPoint parsed for the first time)
77    /// served by [`Self::get_polyloop_coords_cached`].
78    point_cache_misses: u64,
79    /// Lazy-cached multiplier converting file plane-angle units to radians.
80    /// Populated on first call to [`Self::plane_angle_to_radians`]. Spec
81    /// default (and Renga-style files) is 1.0 (RADIAN); degree-unit files
82    /// resolve to π/180.
83    plane_angle_to_radians_cache: Option<f64>,
84    /// Lazy-cached multiplier converting file length units to metres.
85    /// Populated on first call to [`Self::length_unit_scale`]. 1.0 for metre
86    /// files, 0.001 for millimetre files, etc. Used to express absolute
87    /// tolerances (e.g. curve-tessellation chord deviation) in file units.
88    length_unit_scale_cache: Option<f64>,
89    /// Smallest and largest declared geometric-context Precision in file units.
90    /// Errors are cached too, so malformed input cannot trigger repeated scans.
91    geometric_precision_cache: Option<std::result::Result<Option<(f64, f64)>, String>>,
92    /// Per-worker memo of resolved placement world transforms, keyed by the
93    /// IfcObjectPlacement entity id and stored as an opaque column-major
94    /// `[f64; 16]` (core must not depend on nalgebra; the geometry crate owns
95    /// the Matrix4 <-> array round-trip). Populated by the geometry router's
96    /// placement resolver, which recursively composes `parent * local` down the
97    /// IfcLocalPlacement chain. For a well-formed acyclic placement DAG the
98    /// resolved transform is a pure function of the placement id, so reusing it
99    /// across the elements a single worker meshes is byte-identical (speed
100    /// only): storey/building placements shared by thousands of elements are
101    /// composed once per worker instead of once per element. Hoisted across a
102    /// worker's parts exactly like `point_cache`; see
103    /// [`Self::take_placement_transform_cache`].
104    placement_transform_cache: FxHashMap<u32, [f64; 16]>,
105    styled_item_index: std::sync::OnceLock<StyledItemIndexResult>,
106    /// Declared `FILE_SCHEMA`, resolved on first use (see `schema_layout`).
107    schema_version_cache: Option<Option<crate::generated::schema_registry::SchemaVersion>>,
108    grid_axis_index: Arc<GridAxisIndex>,
109}
110
111impl<'a> EntityDecoder<'a> {
112    /// Create new decoder
113    pub fn new<T>(content: &'a T) -> Self
114    where
115        T: AsRef<[u8]> + ?Sized,
116    {
117        let content = content.as_ref();
118        Self {
119            content,
120            cache: FxHashMap::default(),
121            entity_index: None,
122            point_cache: FxHashMap::default(),
123            point_cache_hits: 0,
124            point_cache_misses: 0,
125            plane_angle_to_radians_cache: None,
126            length_unit_scale_cache: None,
127            geometric_precision_cache: None,
128            placement_transform_cache: FxHashMap::default(),
129            styled_item_index: std::sync::OnceLock::new(),
130            schema_version_cache: None,
131            grid_axis_index: Arc::default(),
132        }
133    }
134
135    /// Create decoder with pre-built index (faster for repeated lookups)
136    pub fn with_index<T>(content: &'a T, index: EntityIndex) -> Self
137    where
138        T: AsRef<[u8]> + ?Sized,
139    {
140        let content = content.as_ref();
141        Self {
142            content,
143            cache: FxHashMap::default(),
144            entity_index: Some(EntityIndexStore::Hash(Arc::new(index))),
145            point_cache: FxHashMap::default(),
146            point_cache_hits: 0,
147            point_cache_misses: 0,
148            plane_angle_to_radians_cache: None,
149            length_unit_scale_cache: None,
150            geometric_precision_cache: None,
151            placement_transform_cache: FxHashMap::default(),
152            styled_item_index: std::sync::OnceLock::new(),
153            schema_version_cache: None,
154            grid_axis_index: Arc::default(),
155        }
156    }
157
158    /// Create decoder with shared Arc index (for parallel processing)
159    pub fn with_arc_index<T>(content: &'a T, index: Arc<EntityIndex>) -> Self
160    where
161        T: AsRef<[u8]> + ?Sized,
162    {
163        let content = content.as_ref();
164        Self {
165            content,
166            cache: FxHashMap::default(),
167            entity_index: Some(EntityIndexStore::Hash(index)),
168            point_cache: FxHashMap::default(),
169            point_cache_hits: 0,
170            point_cache_misses: 0,
171            plane_angle_to_radians_cache: None,
172            length_unit_scale_cache: None,
173            geometric_precision_cache: None,
174            placement_transform_cache: FxHashMap::default(),
175            styled_item_index: std::sync::OnceLock::new(),
176            schema_version_cache: None,
177            grid_axis_index: Arc::default(),
178        }
179    }
180
181    /// Install a pre-built index into this decoder. Used when the caller already
182    /// built the index during another pass (e.g. the processor scan loop) so the
183    /// decoder does not lazily rebuild it via `build_index`. Set it before any
184    /// `decode_by_id`/ref-resolving call; afterwards `build_index` no-ops.
185    pub fn set_entity_index(&mut self, index: Arc<EntityIndex>) {
186        self.entity_index = Some(EntityIndexStore::Hash(index));
187    }
188
189    /// Build entity index for O(1) lookups
190    /// This scans the file once and maps entity IDs to byte offsets
191    fn build_index(&mut self) {
192        if self.entity_index.is_some() {
193            return; // Already built
194        }
195        self.entity_index = Some(EntityIndexStore::Hash(Arc::new(build_entity_index(self.content))));
196    }
197
198    /// Decode entity at byte offset
199    /// Returns cached entity if already decoded
200    ///
201    /// Validates the `(start, end)` span against `self.content.len()` before
202    /// slicing. Out-of-range or inverted spans return `Error::parse` instead
203    /// of panicking — callers (e.g. `decode_and_cache`, `decode_at_with_id`,
204    /// the streaming pre-pass shard mergers) hand us spans derived from
205    /// untrusted/streamed entity-index data, and a malformed span must not
206    /// take down the whole worker.
207    #[inline]
208    pub fn decode_at(&mut self, start: usize, end: usize) -> Result<DecodedEntity> {
209        let (id, ifc_type, tokens) = self.parse_at(start, end, "decode_at")?;
210
211        // Check cache first - return clone of inner DecodedEntity
212        if let Some(entity_arc) = self.cache.get(&id) {
213            return Ok(entity_arc.as_ref().clone());
214        }
215
216        // Convert tokens to AttributeValues
217        let attributes = tokens
218            .iter()
219            .map(|token| AttributeValue::from_token(token))
220            .collect();
221
222        let entity = DecodedEntity::new(id, ifc_type, attributes);
223        self.cache.insert(id, Arc::new(entity.clone()));
224        Ok(entity)
225    }
226
227    /// Decode the entity in `[start, end)` **without** touching the cache.
228    ///
229    /// [`decode_at`](Self::decode_at) memoizes every entity it parses, which is the
230    /// right trade-off for geometry sub-tree walks (the same points/profiles are
231    /// revisited many times). For a single linear pass over *every* entity in a
232    /// large model — tens of millions of rows — that cache grows without bound and
233    /// dominates memory. This variant parses and returns the entity but never
234    /// inserts it, so a streaming walk stays O(1) in entity count. The caller owns
235    /// the result; for identical bytes it yields an identical [`DecodedEntity`] to
236    /// `decode_at`, only without the cache side effect.
237    pub fn decode_at_uncached(&self, start: usize, end: usize) -> Result<DecodedEntity> {
238        let (id, ifc_type, tokens) = self.parse_at(start, end, "decode_at_uncached")?;
239        let attributes = tokens
240            .iter()
241            .map(|token| AttributeValue::from_token(token))
242            .collect();
243        Ok(DecodedEntity::new(id, ifc_type, attributes))
244    }
245
246    /// Shared full-record validation and existing error context. The returned
247    /// tokens borrow immutable source bytes, not the decoder's entity cache.
248    #[inline]
249    fn parse_at(
250        &self, start: usize, end: usize, method: &str,
251    ) -> Result<(u32, crate::IfcType, Vec<crate::parser::Token<'a>>)> {
252        let content_len = self.content.len();
253        if start > end || end > content_len {
254            return Err(Error::parse(
255                0,
256                format!(
257                    "{method}: invalid byte span ({}, {}) for content length {}",
258                    start, end, content_len,
259                ),
260            ));
261        }
262        let line = &self.content[start..end];
263        parse_entity(line).map_err(|e| {
264            // Add bounded, lossy debug info without requiring the source to be UTF-8.
265            let cut = line.len().min(100);
266            Error::parse(
267                0,
268                format!(
269                    "Failed to parse entity: {:?}, input: {:?}",
270                    e,
271                    String::from_utf8_lossy(&line[..cut])
272                ),
273            )
274        })
275    }
276
277    /// Decode entity at byte offset with known ID (faster - checks cache before parsing)
278    /// Use this when the scanner provides the entity ID to avoid re-parsing cached entities
279    #[inline]
280    pub fn decode_at_with_id(
281        &mut self,
282        id: u32,
283        start: usize,
284        end: usize,
285    ) -> Result<DecodedEntity> {
286        // Check cache first - avoid parsing if already decoded
287        if let Some(entity_arc) = self.cache.get(&id) {
288            return Ok(entity_arc.as_ref().clone());
289        }
290
291        // Not in cache, parse and cache
292        self.decode_at(start, end)
293    }
294
295    /// Decode entity by ID - O(1) lookup using entity index
296    #[inline]
297    pub fn decode_by_id(&mut self, entity_id: u32) -> Result<DecodedEntity> {
298        // Check cache first - return clone of inner DecodedEntity
299        if let Some(entity_arc) = self.cache.get(&entity_id) {
300            return Ok(entity_arc.as_ref().clone());
301        }
302
303        // Build index if not already built
304        self.build_index();
305
306        // O(1) lookup in index
307        let (start, end) = self
308            .entity_index
309            .as_ref()
310            .and_then(|idx| idx.lookup(entity_id))
311            .ok_or_else(|| Error::parse(0, format!("Entity #{} not found", entity_id)))?;
312
313        self.decode_at(start, end)
314    }
315
316    /// Multiplier that converts file plane-angle units to radians.
317    ///
318    /// Lazy-resolved on first call by scanning for IFCPROJECT and reading
319    /// its IFCUNITASSIGNMENT. Cached for subsequent calls. Returns `1.0`
320    /// when no plane-angle unit is declared (IFC spec default = RADIAN).
321    ///
322    /// Use this at curve-sampling time wherever an `IfcParameterValue` is
323    /// interpreted as an angle (IfcCircle / IfcEllipse trim parameters).
324    /// Without it, `value.to_radians()` is correct only for DEGREE files
325    /// and silently shrinks arcs on RADIAN files (issue #820).
326    pub fn plane_angle_to_radians(&mut self) -> f64 {
327        if let Some(cached) = self.plane_angle_to_radians_cache {
328            return cached;
329        }
330
331        let mut scanner = crate::parser::EntityScanner::new(self.content);
332        let mut project_id: Option<u32> = None;
333        while let Some((id, type_name, _, _)) = scanner.next_entity() {
334            // #4497: the keyword is the raw scanner slice; see `parser::keyword`.
335            if crate::parser::keyword_eq(type_name, "IFCPROJECT") {
336                project_id = Some(id);
337                break;
338            }
339        }
340
341        let scale = match project_id {
342            Some(pid) => crate::units::extract_plane_angle_to_radians(self, pid).unwrap_or(1.0),
343            None => 1.0,
344        };
345        self.plane_angle_to_radians_cache = Some(scale);
346        scale
347    }
348
349    /// Multiplier that converts file length units to metres (1.0 for metre
350    /// files, 0.001 for millimetre files, …). Lazy-resolved on first call by
351    /// scanning for IFCPROJECT and reading its IFCUNITASSIGNMENT, then cached.
352    /// Returns `1.0` when no length unit is declared.
353    ///
354    /// Use this to express an *absolute* metric tolerance in file units —
355    /// e.g. a curve-tessellation chord-deviation budget that stays constant in
356    /// millimetres whether the file is authored in mm or m.
357    pub fn length_unit_scale(&mut self) -> f64 {
358        if let Some(cached) = self.length_unit_scale_cache {
359            return cached;
360        }
361
362        let mut scanner = crate::parser::EntityScanner::new(self.content);
363        let mut project_id: Option<u32> = None;
364        while let Some((id, type_name, _, _)) = scanner.next_entity() {
365            if crate::parser::keyword_eq(type_name, "IFCPROJECT") {
366                project_id = Some(id);
367                break;
368            }
369        }
370
371        let scale = match project_id {
372            Some(pid) => crate::units::extract_length_unit_scale(self, pid).unwrap_or(1.0),
373            None => 1.0,
374        };
375        self.length_unit_scale_cache = Some(scale);
376        scale
377    }
378
379    /// Pre-seed the unit-scale caches so [`Self::length_unit_scale`] and
380    /// [`Self::plane_angle_to_radians`] return immediately without the full-file
381    /// `IFCPROJECT` scan.
382    ///
383    /// Both lazy resolvers walk the whole DATA section to locate the (singleton)
384    /// `IFCPROJECT`. That scan is `O(file size)` and `IFCPROJECT` legally sits
385    /// anywhere — IfcOpenShell emits it near the *end*, so on a large model the
386    /// scan touches tens of MB. The cache is per-decoder, and the parallel
387    /// geometry pipeline builds a fresh decoder per element, so without seeding
388    /// every arc-bearing element re-pays the scan (≈135 ms each on a 75 MB
389    /// file). The orchestrator resolves both scales once on a warm shared
390    /// decoder and seeds each worker decoder here.
391    pub fn seed_unit_scales(&mut self, length_unit_scale: f64, plane_angle_to_radians: f64) {
392        self.length_unit_scale_cache = Some(length_unit_scale);
393        self.plane_angle_to_radians_cache = Some(plane_angle_to_radians);
394    }
395
396    /// Resolve entity reference (follow #ID)
397    /// Returns None for null/derived values
398    #[inline]
399    pub fn resolve_ref(&mut self, attr: &AttributeValue) -> Result<Option<DecodedEntity>> {
400        match attr.as_entity_ref() {
401            Some(id) => Ok(Some(self.decode_by_id(id)?)),
402            None => Ok(None),
403        }
404    }
405
406    /// Resolve list of entity references
407    pub fn resolve_ref_list(&mut self, attr: &AttributeValue) -> Result<Vec<DecodedEntity>> {
408        let list = attr
409            .as_list()
410            .ok_or_else(|| Error::parse(0, "Expected list".to_string()))?;
411
412        let mut entities = Vec::with_capacity(list.len());
413        for item in list {
414            if let Some(id) = item.as_entity_ref() {
415                entities.push(self.decode_by_id(id)?);
416            }
417        }
418        Ok(entities)
419    }
420
421    /// Get cached entity (without decoding)
422    pub fn get_cached(&self, entity_id: u32) -> Option<DecodedEntity> {
423        self.cache.get(&entity_id).map(|arc| arc.as_ref().clone())
424    }
425
426    /// Reserve cache capacity to avoid HashMap resizing during processing.
427    /// For a 487 MB file with 208 K building elements, the cache can grow to
428    /// 300 K+ entries (elements + representation chains + placements).
429    /// Pre-allocating avoids ~6 resize-and-rehash operations that each copy
430    /// all entries, reducing both peak memory spikes and timing variance.
431    pub fn reserve_cache(&mut self, additional: usize) {
432        self.cache.reserve(additional);
433    }
434
435    /// Inject a pre-warmed Arc-shared cache into this decoder's local cache.
436    ///
437    /// Used by the de-normalized parallel path: a serial pre-pass builds a
438    /// shared `Arc<FxHashMap<u32, Arc<DecodedEntity>>>` containing all
439    /// entities reachable from the jobs. Each rayon task then injects
440    /// that shared cache into its own decoder via this method, so the
441    /// per-task hot path hits in-WASM-heap Arc handles instead of
442    /// SAB-imported atomic memory.
443    ///
444    /// Cost: one Arc::clone per cached entry (atomic refcount bump).
445    /// For a typical 100K-entry cache × 9 rayon tasks = 900K atomics
446    /// total, ~90 ms wall (incurred ONCE at task setup; the parallel
447    /// hot path then runs lock-free against the populated cache).
448    pub fn inject_shared_cache(&mut self, shared: &FxHashMap<u32, Arc<DecodedEntity>>) {
449        self.cache.reserve(shared.len());
450        for (&id, entity) in shared.iter() {
451            self.cache.insert(id, Arc::clone(entity));
452        }
453    }
454
455    /// Decode + cache without returning. Used by the pre-warm pass to
456    /// populate a shared cache. Returns the cached Arc so the caller
457    /// can chase references without re-decoding.
458    pub fn decode_and_cache(
459        &mut self,
460        id: u32,
461        start: usize,
462        end: usize,
463    ) -> Result<Arc<DecodedEntity>> {
464        if let Some(arc) = self.cache.get(&id) {
465            return Ok(Arc::clone(arc));
466        }
467        let _ = self.decode_at(start, end)?;
468        Ok(Arc::clone(self.cache.get(&id).ok_or_else(|| {
469            Error::parse(0, "decode_at didn't populate cache".to_string())
470        })?))
471    }
472    /// Get raw bytes for an entity (for direct/fast parsing)
473    /// Returns the full entity line including type and attributes
474    #[inline]
475    pub fn get_raw_bytes(&mut self, entity_id: u32) -> Option<&'a [u8]> {
476        self.build_index();
477        let (start, end) = self.entity_index.as_ref()?.lookup(entity_id)?;
478        // An installed index's spans are caller data never checked against
479        // these bytes (#4697); a span outside them is absent, not a panic.
480        self.content.get(start..end)
481    }
482
483    /// Fast extraction of first entity ref from raw bytes
484    /// Useful for BREP -> shell ID, Face -> FaceBound, etc.
485    /// Returns the first entity reference ID found in the entity
486    #[inline]
487    pub fn get_first_entity_ref_fast(&mut self, entity_id: u32) -> Option<u32> {
488        let bytes = self.get_raw_bytes(entity_id)?;
489        let len = bytes.len();
490        let mut i = 0;
491
492        // Skip to first '(' after '='
493        while i < len && bytes[i] != b'(' {
494            i += 1;
495        }
496        if i >= len {
497            return None;
498        }
499        i += 1; // Skip first '('
500
501        // Find first '#' which is the entity ref
502        while i < len {
503            // Skip whitespace
504            while i < len && is_step_space(bytes[i]) {
505                i += 1;
506            }
507
508            if i >= len {
509                return None;
510            }
511
512            if bytes[i] == b'#' {
513                i += 1;
514                let start = i;
515                while i < len && bytes[i].is_ascii_digit() {
516                    i += 1;
517                }
518                if i > start {
519                    // Shared checked accumulator (#3421): refuses rather than wraps.
520                    return crate::express_id::parse_express_id(&bytes[start..i]);
521                }
522            }
523            i += 1;
524        }
525
526        None
527    }
528
529    /// Fast extraction of PolyLoop point IDs directly from raw bytes
530    /// Bypasses full entity decoding for BREP optimization
531    /// Returns list of entity IDs for CartesianPoints
532    ///
533    /// `None` when ANY `#<digits>` in the list is unrepresentable, not just when
534    /// none of them is. A reference above `u32::MAX` is refused rather than
535    /// wrapped (#3421), and dropping only that vertex would hand the caller a
536    /// polygon one corner SHORTER than the file's: a different face, meshed
537    /// and rendered as if it were the authored one.
538    ///
539    /// This accessor sees ids, not entities, so a reference that fits `u32`
540    /// but names no record in the file is returned as written. Whether that
541    /// id RESOLVES is the caller's half of the same rule, and every caller
542    /// must refuse the whole loop on a miss rather than skip the point
543    /// (`processors::helpers::extract_loop_points_by_id` and
544    /// `processors::surface` both do). The coordinate sibling
545    /// [`Self::get_polyloop_coords_cached_into`] enforces both halves itself
546    /// because it resolves the points too.
547    #[inline]
548    pub fn get_polyloop_point_ids_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
549        let bytes = self.get_raw_bytes(entity_id)?;
550
551        // IFCPOLYLOOP((#id1,#id2,#id3,...));
552        let mut i = 0;
553        let len = bytes.len();
554
555        // Skip to first '(' after '='
556        while i < len && bytes[i] != b'(' {
557            i += 1;
558        }
559        if i >= len {
560            return None;
561        }
562        i += 1; // Skip first '('
563
564        // Skip to second '(' for the point list
565        while i < len && bytes[i] != b'(' {
566            i += 1;
567        }
568        if i >= len {
569            return None;
570        }
571        i += 1; // Skip second '('
572
573        // Parse point IDs
574        let mut point_ids = Vec::with_capacity(8); // Most faces have 3-8 vertices
575
576        while i < len {
577            // Skip whitespace and commas
578            while i < len
579                && (bytes[i] == b',' || is_step_space(bytes[i]))
580            {
581                i += 1;
582            }
583
584            if i >= len || bytes[i] == b')' {
585                break;
586            }
587
588            // Expect '#' followed by number
589            if bytes[i] == b'#' {
590                i += 1;
591                // One digit walk, shared with the scanner (#3395). A ref that
592                // was met but does not resolve refuses the whole loop (#3421):
593                // a shorter polygon is a different face, not a smaller error.
594                let (digits, id) = crate::express_id::parse_express_id_prefix(&bytes[i..]);
595                i += digits;
596                if digits > 0 {
597                    point_ids.push(id?);
598                }
599            } else {
600                i += 1; // Skip unknown character
601            }
602        }
603
604        if point_ids.is_empty() {
605            None
606        } else {
607            Some(point_ids)
608        }
609    }
610
611    /// Fast extraction of CartesianPoint coordinates directly from raw bytes
612    /// Bypasses full entity decoding for ~3x speedup on BREP-heavy files
613    /// Returns (x, y, z) as f64 tuple
614    #[inline]
615    pub fn get_cartesian_point_fast(&mut self, entity_id: u32) -> Option<(f64, f64, f64)> {
616        parse_cartesian_point_inline(self.get_raw_bytes(entity_id)?)
617    }
618
619    /// Fast extraction of FaceBound info directly from raw bytes
620    /// Returns (loop_id, orientation, is_outer_bound)
621    /// Bypasses full entity decoding for BREP optimization
622    #[inline]
623    pub fn get_face_bound_fast(&mut self, entity_id: u32) -> Option<(u32, bool, bool)> {
624        let bytes = self.get_raw_bytes(entity_id)?;
625        let len = bytes.len();
626
627        // Find '=' to locate start of type name, and '(' for end
628        let mut eq_pos = 0;
629        while eq_pos < len && bytes[eq_pos] != b'=' {
630            eq_pos += 1;
631        }
632        if eq_pos >= len {
633            return None;
634        }
635
636        // Check if this is an outer bound by looking for "OUTER" in the type name
637        // IFCFACEOUTERBOUND vs IFCFACEBOUND
638        // The type name is between '=' and '('
639        let mut is_outer = false;
640        let mut i = eq_pos + 1;
641        // Look for "OUTER" pattern (must check for the full word, not just 'O')
642        while i + 4 < len && bytes[i] != b'(' {
643            if bytes[i] == b'O'
644                && bytes[i + 1] == b'U'
645                && bytes[i + 2] == b'T'
646                && bytes[i + 3] == b'E'
647                && bytes[i + 4] == b'R'
648            {
649                is_outer = true;
650                break;
651            }
652            i += 1;
653        }
654        // Continue to find the '(' if we haven't already
655        while i < len && bytes[i] != b'(' {
656            i += 1;
657        }
658        if i >= len {
659            return None;
660        }
661
662        i += 1; // Skip first '('
663
664        // Skip whitespace
665        while i < len && is_step_space(bytes[i]) {
666            i += 1;
667        }
668
669        // Expect '#' for loop entity ref
670        if i >= len || bytes[i] != b'#' {
671            return None;
672        }
673        i += 1;
674
675        // Parse loop ID
676        let start = i;
677        while i < len && bytes[i].is_ascii_digit() {
678            i += 1;
679        }
680        if i <= start {
681            return None;
682        }
683        // Shared checked accumulator (#3421): an oversized loop ref fails
684        // this lookup outright, like any other unresolvable reference,
685        // instead of resolving to the wrong loop.
686        let loop_id = crate::express_id::parse_express_id(&bytes[start..i])?;
687
688        // Find orientation after comma - default to true (.T.)
689        // Skip to comma
690        while i < len && bytes[i] != b',' {
691            i += 1;
692        }
693        i += 1; // Skip comma
694
695        // Skip whitespace
696        while i < len && is_step_space(bytes[i]) {
697            i += 1;
698        }
699
700        // Check for .F. (false) or .T. (true)
701        let orientation = if i + 2 < len && bytes[i] == b'.' && bytes[i + 2] == b'.' {
702            bytes[i + 1] != b'F'
703        } else {
704            true // Default to true
705        };
706
707        Some((loop_id, orientation, is_outer))
708    }
709}
710
711/// Parse cartesian point coordinates inline from raw bytes
712/// Used by the cached polyloop coordinate readers for maximum performance
713#[inline]
714fn parse_cartesian_point_inline(bytes: &[u8]) -> Option<(f64, f64, f64)> {
715    let len = bytes.len();
716    let mut i = 0;
717
718    // Skip to first '(' after '='
719    while i < len && bytes[i] != b'(' {
720        i += 1;
721    }
722    if i >= len {
723        return None;
724    }
725    i += 1; // Skip first '('
726
727    // Skip to second '(' for the coordinate list
728    while i < len && bytes[i] != b'(' {
729        i += 1;
730    }
731    if i >= len {
732        return None;
733    }
734    i += 1; // Skip second '('
735
736    parse_point_coordinates(&bytes[i..])
737}
738
739/// `x,y)` or `x,y,z)`, starting just inside the coordinate list's `(`. Every
740/// value goes through the shared STEP literal grammar and every separator is
741/// checked, so a corrupted literal (`1.52.3`), a non-STEP spelling (`nan`) or a
742/// corrupt last value (`3.x`) refuses the whole point instead of splitting it,
743/// reading it as its prefix, or defaulting it to 0, which is what the full
744/// tokenizer does with the same record (#5266). Comments are trivia here as
745/// everywhere else (`3./* c */)` is a legal z). z defaults to 0 only for a
746/// genuine 2D point, whose list closes right after y.
747#[inline]
748fn parse_point_coordinates(bytes: &[u8]) -> Option<(f64, f64, f64)> {
749    let value_at = |i: usize| -> Option<(f64, usize)> {
750        let start = skip_step_trivia(bytes, i)?;
751        let (value, len) = parse_step_numeric::<f64>(&bytes[start..])?;
752        Some((value, skip_step_trivia(bytes, start + len)?))
753    };
754    let (x, i) = value_at(0)?;
755    if bytes.get(i) != Some(&b',') {
756        return None;
757    }
758    let (y, i) = value_at(i + 1)?;
759    match bytes.get(i)? {
760        b')' => return Some((x, y, 0.0)),
761        b',' => {}
762        _ => return None,
763    }
764    let (z, i) = value_at(i + 1)?;
765    (bytes.get(i) == Some(&b')')).then_some((x, y, z))
766}
767
768#[cfg(test)]
769mod decoder_tests;