Skip to main content

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::{parse_entity, report_scan_diagnostics, EntityScanner};
12use crate::schema_gen::{AttributeValue, DecodedEntity};
13use rustc_hash::FxHashMap;
14use std::sync::Arc;
15
16#[path = "decoder/caches.rs"]
17mod caches;
18
19/// Pre-built entity index type
20pub type EntityIndex = FxHashMap<u32, (usize, usize)>;
21
22/// Build an entity index from content.
23///
24/// This intentionally shares `EntityScanner`'s HEADER skipping, quoted-string
25/// semantics, and its #3395 refusal of instance names wider than `u32` (reported
26/// here, not handed back as a quietly short index), so scan and lookup agree.
27#[inline]
28pub fn build_entity_index<T>(content: &T) -> EntityIndex
29where
30    T: AsRef<[u8]> + ?Sized,
31{
32    let content = content.as_ref();
33    let estimated_entities = content.len() / 50;
34    let mut index = FxHashMap::with_capacity_and_hasher(estimated_entities, Default::default());
35    let mut scanner = EntityScanner::new(content);
36    while let Some((id, _type_name, start, end)) = scanner.next_entity() {
37        index.insert(id, (start, end));
38    }
39    report_scan_diagnostics(scanner.skipped_oversized_ids(), scanner.malformed_record_start().is_some());
40    index
41}
42
43/// Entity decoder for lazy parsing from raw IFC bytes.
44///
45/// String attributes are decoded lossily when tokens become `AttributeValue`s;
46/// structural scanning and byte offsets always use the original source bytes.
47pub struct EntityDecoder<'a> {
48    content: &'a [u8],
49    /// Cache of decoded entities (entity_id -> `Arc<DecodedEntity>`)
50    /// Using Arc avoids expensive clones on cache hits
51    cache: FxHashMap<u32, Arc<DecodedEntity>>,
52    /// Entity offsets (entity_id -> (start, end)): legacy `FxHashMap` or compact
53    /// columnar index. `pub(crate)` for `crate::columnar_index`'s constructors.
54    pub(crate) entity_index: Option<EntityIndexStore>,
55    /// Cache of cartesian point coordinates for FacetedBrep optimization
56    /// Only populated when using get_polyloop_coords_cached
57    point_cache: FxHashMap<u32, (f64, f64, f64)>,
58    /// Number of `point_cache` hits served by [`Self::get_polyloop_coords_cached`].
59    /// Pure instrumentation (never affects output); read via
60    /// [`Self::point_cache_stats`] to prove cross-element memoization fires when
61    /// the cache is hoisted across a worker's parts.
62    point_cache_hits: u64,
63    /// Number of `point_cache` misses (a CartesianPoint parsed for the first time)
64    /// served by [`Self::get_polyloop_coords_cached`].
65    point_cache_misses: u64,
66    /// Lazy-cached multiplier converting file plane-angle units to radians.
67    /// Populated on first call to [`Self::plane_angle_to_radians`]. Spec
68    /// default (and Renga-style files) is 1.0 (RADIAN); degree-unit files
69    /// resolve to π/180.
70    plane_angle_to_radians_cache: Option<f64>,
71    /// Lazy-cached multiplier converting file length units to metres.
72    /// Populated on first call to [`Self::length_unit_scale`]. 1.0 for metre
73    /// files, 0.001 for millimetre files, etc. Used to express absolute
74    /// tolerances (e.g. curve-tessellation chord deviation) in file units.
75    length_unit_scale_cache: Option<f64>,
76    /// Per-worker memo of resolved placement world transforms, keyed by the
77    /// IfcObjectPlacement entity id and stored as an opaque column-major
78    /// `[f64; 16]` (core must not depend on nalgebra; the geometry crate owns
79    /// the Matrix4 <-> array round-trip). Populated by the geometry router's
80    /// placement resolver, which recursively composes `parent * local` down the
81    /// IfcLocalPlacement chain. For a well-formed acyclic placement DAG the
82    /// resolved transform is a pure function of the placement id, so reusing it
83    /// across the elements a single worker meshes is byte-identical (speed
84    /// only): storey/building placements shared by thousands of elements are
85    /// composed once per worker instead of once per element. Hoisted across a
86    /// worker's parts exactly like `point_cache`; see
87    /// [`Self::take_placement_transform_cache`].
88    placement_transform_cache: FxHashMap<u32, [f64; 16]>,
89}
90
91impl<'a> EntityDecoder<'a> {
92    /// Create new decoder
93    pub fn new<T>(content: &'a T) -> Self
94    where
95        T: AsRef<[u8]> + ?Sized,
96    {
97        let content = content.as_ref();
98        Self {
99            content,
100            cache: FxHashMap::default(),
101            entity_index: None,
102            point_cache: FxHashMap::default(),
103            point_cache_hits: 0,
104            point_cache_misses: 0,
105            plane_angle_to_radians_cache: None,
106            length_unit_scale_cache: None,
107            placement_transform_cache: FxHashMap::default(),
108        }
109    }
110
111    /// Create decoder with pre-built index (faster for repeated lookups)
112    pub fn with_index<T>(content: &'a T, index: EntityIndex) -> Self
113    where
114        T: AsRef<[u8]> + ?Sized,
115    {
116        let content = content.as_ref();
117        Self {
118            content,
119            cache: FxHashMap::default(),
120            entity_index: Some(EntityIndexStore::Hash(Arc::new(index))),
121            point_cache: FxHashMap::default(),
122            point_cache_hits: 0,
123            point_cache_misses: 0,
124            plane_angle_to_radians_cache: None,
125            length_unit_scale_cache: None,
126            placement_transform_cache: FxHashMap::default(),
127        }
128    }
129
130    /// Create decoder with shared Arc index (for parallel processing)
131    pub fn with_arc_index<T>(content: &'a T, index: Arc<EntityIndex>) -> Self
132    where
133        T: AsRef<[u8]> + ?Sized,
134    {
135        let content = content.as_ref();
136        Self {
137            content,
138            cache: FxHashMap::default(),
139            entity_index: Some(EntityIndexStore::Hash(index)),
140            point_cache: FxHashMap::default(),
141            point_cache_hits: 0,
142            point_cache_misses: 0,
143            plane_angle_to_radians_cache: None,
144            length_unit_scale_cache: None,
145            placement_transform_cache: FxHashMap::default(),
146        }
147    }
148
149    /// Install a pre-built index into this decoder. Used when the caller already
150    /// built the index during another pass (e.g. the processor scan loop) so the
151    /// decoder does not lazily rebuild it via `build_index`. Set it before any
152    /// `decode_by_id`/ref-resolving call; afterwards `build_index` no-ops.
153    pub fn set_entity_index(&mut self, index: Arc<EntityIndex>) {
154        self.entity_index = Some(EntityIndexStore::Hash(index));
155    }
156
157    /// Build entity index for O(1) lookups
158    /// This scans the file once and maps entity IDs to byte offsets
159    fn build_index(&mut self) {
160        if self.entity_index.is_some() {
161            return; // Already built
162        }
163        self.entity_index = Some(EntityIndexStore::Hash(Arc::new(build_entity_index(self.content))));
164    }
165
166    /// Decode entity at byte offset
167    /// Returns cached entity if already decoded
168    ///
169    /// Validates the `(start, end)` span against `self.content.len()` before
170    /// slicing. Out-of-range or inverted spans return `Error::parse` instead
171    /// of panicking — callers (e.g. `decode_and_cache`, `decode_at_with_id`,
172    /// the streaming pre-pass shard mergers) hand us spans derived from
173    /// untrusted/streamed entity-index data, and a malformed span must not
174    /// take down the whole worker.
175    #[inline]
176    pub fn decode_at(&mut self, start: usize, end: usize) -> Result<DecodedEntity> {
177        let content_len = self.content.len();
178        if start > end || end > content_len {
179            return Err(Error::parse(
180                0,
181                format!(
182                    "decode_at: invalid byte span ({}, {}) for content length {}",
183                    start, end, content_len,
184                ),
185            ));
186        }
187        let line = &self.content[start..end];
188        let (id, ifc_type, tokens) = parse_entity(line).map_err(|e| {
189            // Add bounded, lossy debug info without requiring the source to be UTF-8.
190            let cut = line.len().min(100);
191            Error::parse(
192                0,
193                format!(
194                    "Failed to parse entity: {:?}, input: {:?}",
195                    e,
196                    String::from_utf8_lossy(&line[..cut])
197                ),
198            )
199        })?;
200
201        // Check cache first - return clone of inner DecodedEntity
202        if let Some(entity_arc) = self.cache.get(&id) {
203            return Ok(entity_arc.as_ref().clone());
204        }
205
206        // Convert tokens to AttributeValues
207        let attributes = tokens
208            .iter()
209            .map(|token| AttributeValue::from_token(token))
210            .collect();
211
212        let entity = DecodedEntity::new(id, ifc_type, attributes);
213        self.cache.insert(id, Arc::new(entity.clone()));
214        Ok(entity)
215    }
216
217    /// Decode the entity in `[start, end)` **without** touching the cache.
218    ///
219    /// [`decode_at`](Self::decode_at) memoizes every entity it parses, which is the
220    /// right trade-off for geometry sub-tree walks (the same points/profiles are
221    /// revisited many times). For a single linear pass over *every* entity in a
222    /// large model — tens of millions of rows — that cache grows without bound and
223    /// dominates memory. This variant parses and returns the entity but never
224    /// inserts it, so a streaming walk stays O(1) in entity count. The caller owns
225    /// the result; for identical bytes it yields an identical [`DecodedEntity`] to
226    /// `decode_at`, only without the cache side effect.
227    pub fn decode_at_uncached(&self, start: usize, end: usize) -> Result<DecodedEntity> {
228        let content_len = self.content.len();
229        if start > end || end > content_len {
230            return Err(Error::parse(
231                0,
232                format!(
233                    "decode_at_uncached: invalid byte span ({}, {}) for content length {}",
234                    start, end, content_len,
235                ),
236            ));
237        }
238        let line = &self.content[start..end];
239        let (id, ifc_type, tokens) = parse_entity(line).map_err(|e| {
240            let cut = line.len().min(100);
241            Error::parse(
242                0,
243                format!(
244                    "Failed to parse entity: {:?}, input: {:?}",
245                    e,
246                    String::from_utf8_lossy(&line[..cut])
247                ),
248            )
249        })?;
250        let attributes = tokens
251            .iter()
252            .map(|token| AttributeValue::from_token(token))
253            .collect();
254        Ok(DecodedEntity::new(id, ifc_type, attributes))
255    }
256
257    /// Decode entity at byte offset with known ID (faster - checks cache before parsing)
258    /// Use this when the scanner provides the entity ID to avoid re-parsing cached entities
259    #[inline]
260    pub fn decode_at_with_id(
261        &mut self,
262        id: u32,
263        start: usize,
264        end: usize,
265    ) -> Result<DecodedEntity> {
266        // Check cache first - avoid parsing if already decoded
267        if let Some(entity_arc) = self.cache.get(&id) {
268            return Ok(entity_arc.as_ref().clone());
269        }
270
271        // Not in cache, parse and cache
272        self.decode_at(start, end)
273    }
274
275    /// Decode entity by ID - O(1) lookup using entity index
276    #[inline]
277    pub fn decode_by_id(&mut self, entity_id: u32) -> Result<DecodedEntity> {
278        // Check cache first - return clone of inner DecodedEntity
279        if let Some(entity_arc) = self.cache.get(&entity_id) {
280            return Ok(entity_arc.as_ref().clone());
281        }
282
283        // Build index if not already built
284        self.build_index();
285
286        // O(1) lookup in index
287        let (start, end) = self
288            .entity_index
289            .as_ref()
290            .and_then(|idx| idx.lookup(entity_id))
291            .ok_or_else(|| Error::parse(0, format!("Entity #{} not found", entity_id)))?;
292
293        self.decode_at(start, end)
294    }
295
296    /// Multiplier that converts file plane-angle units to radians.
297    ///
298    /// Lazy-resolved on first call by scanning for IFCPROJECT and reading
299    /// its IFCUNITASSIGNMENT. Cached for subsequent calls. Returns `1.0`
300    /// when no plane-angle unit is declared (IFC spec default = RADIAN).
301    ///
302    /// Use this at curve-sampling time wherever an `IfcParameterValue` is
303    /// interpreted as an angle (IfcCircle / IfcEllipse trim parameters).
304    /// Without it, `value.to_radians()` is correct only for DEGREE files
305    /// and silently shrinks arcs on RADIAN files (issue #820).
306    pub fn plane_angle_to_radians(&mut self) -> f64 {
307        if let Some(cached) = self.plane_angle_to_radians_cache {
308            return cached;
309        }
310
311        let mut scanner = crate::parser::EntityScanner::new(self.content);
312        let mut project_id: Option<u32> = None;
313        while let Some((id, type_name, _, _)) = scanner.next_entity() {
314            if type_name == "IFCPROJECT" {
315                project_id = Some(id);
316                break;
317            }
318        }
319
320        let scale = match project_id {
321            Some(pid) => crate::units::extract_plane_angle_to_radians(self, pid).unwrap_or(1.0),
322            None => 1.0,
323        };
324        self.plane_angle_to_radians_cache = Some(scale);
325        scale
326    }
327
328    /// Multiplier that converts file length units to metres (1.0 for metre
329    /// files, 0.001 for millimetre files, …). Lazy-resolved on first call by
330    /// scanning for IFCPROJECT and reading its IFCUNITASSIGNMENT, then cached.
331    /// Returns `1.0` when no length unit is declared.
332    ///
333    /// Use this to express an *absolute* metric tolerance in file units —
334    /// e.g. a curve-tessellation chord-deviation budget that stays constant in
335    /// millimetres whether the file is authored in mm or m.
336    pub fn length_unit_scale(&mut self) -> f64 {
337        if let Some(cached) = self.length_unit_scale_cache {
338            return cached;
339        }
340
341        let mut scanner = crate::parser::EntityScanner::new(self.content);
342        let mut project_id: Option<u32> = None;
343        while let Some((id, type_name, _, _)) = scanner.next_entity() {
344            if type_name == "IFCPROJECT" {
345                project_id = Some(id);
346                break;
347            }
348        }
349
350        let scale = match project_id {
351            Some(pid) => crate::units::extract_length_unit_scale(self, pid).unwrap_or(1.0),
352            None => 1.0,
353        };
354        self.length_unit_scale_cache = Some(scale);
355        scale
356    }
357
358    /// Pre-seed the unit-scale caches so [`Self::length_unit_scale`] and
359    /// [`Self::plane_angle_to_radians`] return immediately without the full-file
360    /// `IFCPROJECT` scan.
361    ///
362    /// Both lazy resolvers walk the whole DATA section to locate the (singleton)
363    /// `IFCPROJECT`. That scan is `O(file size)` and `IFCPROJECT` legally sits
364    /// anywhere — IfcOpenShell emits it near the *end*, so on a large model the
365    /// scan touches tens of MB. The cache is per-decoder, and the parallel
366    /// geometry pipeline builds a fresh decoder per element, so without seeding
367    /// every arc-bearing element re-pays the scan (≈135 ms each on a 75 MB
368    /// file). The orchestrator resolves both scales once on a warm shared
369    /// decoder and seeds each worker decoder here.
370    pub fn seed_unit_scales(&mut self, length_unit_scale: f64, plane_angle_to_radians: f64) {
371        self.length_unit_scale_cache = Some(length_unit_scale);
372        self.plane_angle_to_radians_cache = Some(plane_angle_to_radians);
373    }
374
375    /// Resolve entity reference (follow #ID)
376    /// Returns None for null/derived values
377    #[inline]
378    pub fn resolve_ref(&mut self, attr: &AttributeValue) -> Result<Option<DecodedEntity>> {
379        match attr.as_entity_ref() {
380            Some(id) => Ok(Some(self.decode_by_id(id)?)),
381            None => Ok(None),
382        }
383    }
384
385    /// Resolve list of entity references
386    pub fn resolve_ref_list(&mut self, attr: &AttributeValue) -> Result<Vec<DecodedEntity>> {
387        let list = attr
388            .as_list()
389            .ok_or_else(|| Error::parse(0, "Expected list".to_string()))?;
390
391        let mut entities = Vec::with_capacity(list.len());
392        for item in list {
393            if let Some(id) = item.as_entity_ref() {
394                entities.push(self.decode_by_id(id)?);
395            }
396        }
397        Ok(entities)
398    }
399
400    /// Get cached entity (without decoding)
401    pub fn get_cached(&self, entity_id: u32) -> Option<DecodedEntity> {
402        self.cache.get(&entity_id).map(|arc| arc.as_ref().clone())
403    }
404
405    /// Reserve cache capacity to avoid HashMap resizing during processing.
406    /// For a 487 MB file with 208 K building elements, the cache can grow to
407    /// 300 K+ entries (elements + representation chains + placements).
408    /// Pre-allocating avoids ~6 resize-and-rehash operations that each copy
409    /// all entries, reducing both peak memory spikes and timing variance.
410    pub fn reserve_cache(&mut self, additional: usize) {
411        self.cache.reserve(additional);
412    }
413
414    /// Inject a pre-warmed Arc-shared cache into this decoder's local cache.
415    ///
416    /// Used by the de-normalized parallel path: a serial pre-pass builds a
417    /// shared `Arc<FxHashMap<u32, Arc<DecodedEntity>>>` containing all
418    /// entities reachable from the jobs. Each rayon task then injects
419    /// that shared cache into its own decoder via this method, so the
420    /// per-task hot path hits in-WASM-heap Arc handles instead of
421    /// SAB-imported atomic memory.
422    ///
423    /// Cost: one Arc::clone per cached entry (atomic refcount bump).
424    /// For a typical 100K-entry cache × 9 rayon tasks = 900K atomics
425    /// total, ~90 ms wall (incurred ONCE at task setup; the parallel
426    /// hot path then runs lock-free against the populated cache).
427    pub fn inject_shared_cache(&mut self, shared: &FxHashMap<u32, Arc<DecodedEntity>>) {
428        self.cache.reserve(shared.len());
429        for (&id, entity) in shared.iter() {
430            self.cache.insert(id, Arc::clone(entity));
431        }
432    }
433
434    /// Decode + cache without returning. Used by the pre-warm pass to
435    /// populate a shared cache. Returns the cached Arc so the caller
436    /// can chase references without re-decoding.
437    pub fn decode_and_cache(
438        &mut self,
439        id: u32,
440        start: usize,
441        end: usize,
442    ) -> Result<Arc<DecodedEntity>> {
443        if let Some(arc) = self.cache.get(&id) {
444            return Ok(Arc::clone(arc));
445        }
446        let _ = self.decode_at(start, end)?;
447        Ok(Arc::clone(self.cache.get(&id).ok_or_else(|| {
448            Error::parse(0, "decode_at didn't populate cache".to_string())
449        })?))
450    }
451    /// Get raw bytes for an entity (for direct/fast parsing)
452    /// Returns the full entity line including type and attributes
453    #[inline]
454    pub fn get_raw_bytes(&mut self, entity_id: u32) -> Option<&'a [u8]> {
455        self.build_index();
456        let (start, end) = self.entity_index.as_ref()?.lookup(entity_id)?;
457        Some(&self.content[start..end])
458    }
459
460    /// Fast extraction of first entity ref from raw bytes
461    /// Useful for BREP -> shell ID, Face -> FaceBound, etc.
462    /// Returns the first entity reference ID found in the entity
463    #[inline]
464    pub fn get_first_entity_ref_fast(&mut self, entity_id: u32) -> Option<u32> {
465        let bytes = self.get_raw_bytes(entity_id)?;
466        let len = bytes.len();
467        let mut i = 0;
468
469        // Skip to first '(' after '='
470        while i < len && bytes[i] != b'(' {
471            i += 1;
472        }
473        if i >= len {
474            return None;
475        }
476        i += 1; // Skip first '('
477
478        // Find first '#' which is the entity ref
479        while i < len {
480            // Skip whitespace
481            while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
482                i += 1;
483            }
484
485            if i >= len {
486                return None;
487            }
488
489            if bytes[i] == b'#' {
490                i += 1;
491                let start = i;
492                while i < len && bytes[i].is_ascii_digit() {
493                    i += 1;
494                }
495                if i > start {
496                    // Shared checked accumulator (#3421): refuses rather than wraps.
497                    return crate::express_id::parse_express_id(&bytes[start..i]);
498                }
499            }
500            i += 1;
501        }
502
503        None
504    }
505
506    /// Fast extraction of entity reference IDs from a list attribute in raw bytes
507    /// Useful for getting face list from ClosedShell, bounds from Face, etc.
508    /// Returns list of entity IDs
509    #[inline]
510    pub fn get_entity_ref_list_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
511        let bytes = self.get_raw_bytes(entity_id)?;
512
513        // Pattern: IFCTYPE((#id1,#id2,...)); or IFCTYPE((#id1,#id2,...),other);
514        let mut i = 0;
515        let len = bytes.len();
516
517        // Skip to first '(' after '='
518        while i < len && bytes[i] != b'(' {
519            i += 1;
520        }
521        if i >= len {
522            return None;
523        }
524        i += 1; // Skip first '('
525
526        // Skip to second '(' for the list
527        while i < len && bytes[i] != b'(' {
528            i += 1;
529        }
530        if i >= len {
531            return None;
532        }
533        i += 1; // Skip second '('
534
535        // Parse entity IDs
536        let mut ids = Vec::with_capacity(32);
537
538        while i < len {
539            // Skip whitespace and commas
540            while i < len
541                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
542            {
543                i += 1;
544            }
545
546            if i >= len || bytes[i] == b')' {
547                break;
548            }
549
550            // Expect '#' followed by number
551            if bytes[i] == b'#' {
552                i += 1;
553                let start = i;
554                while i < len && bytes[i].is_ascii_digit() {
555                    i += 1;
556                }
557                if i > start {
558                    // Shared checked accumulator (#3421): an oversized id is dropped, not wrapped.
559                    if let Some(id) = crate::express_id::parse_express_id(&bytes[start..i]) {
560                        ids.push(id);
561                    }
562                }
563            } else {
564                i += 1; // Skip unknown character
565            }
566        }
567
568        if ids.is_empty() {
569            None
570        } else {
571            Some(ids)
572        }
573    }
574
575    /// Fast extraction of PolyLoop point IDs directly from raw bytes
576    /// Bypasses full entity decoding for BREP optimization
577    /// Returns list of entity IDs for CartesianPoints
578    #[inline]
579    pub fn get_polyloop_point_ids_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
580        let bytes = self.get_raw_bytes(entity_id)?;
581
582        // IFCPOLYLOOP((#id1,#id2,#id3,...));
583        let mut i = 0;
584        let len = bytes.len();
585
586        // Skip to first '(' after '='
587        while i < len && bytes[i] != b'(' {
588            i += 1;
589        }
590        if i >= len {
591            return None;
592        }
593        i += 1; // Skip first '('
594
595        // Skip to second '(' for the point list
596        while i < len && bytes[i] != b'(' {
597            i += 1;
598        }
599        if i >= len {
600            return None;
601        }
602        i += 1; // Skip second '('
603
604        // Parse point IDs
605        let mut point_ids = Vec::with_capacity(8); // Most faces have 3-8 vertices
606
607        while i < len {
608            // Skip whitespace and commas
609            while i < len
610                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
611            {
612                i += 1;
613            }
614
615            if i >= len || bytes[i] == b')' {
616                break;
617            }
618
619            // Expect '#' followed by number
620            if bytes[i] == b'#' {
621                i += 1;
622                let start = i;
623                while i < len && bytes[i].is_ascii_digit() {
624                    i += 1;
625                }
626                if i > start {
627                    // Shared checked accumulator (#3421): an oversized id is dropped, not wrapped.
628                    if let Some(id) = crate::express_id::parse_express_id(&bytes[start..i]) {
629                        point_ids.push(id);
630                    }
631                }
632            } else {
633                i += 1; // Skip unknown character
634            }
635        }
636
637        if point_ids.is_empty() {
638            None
639        } else {
640            Some(point_ids)
641        }
642    }
643
644    /// Fast extraction of CartesianPoint coordinates directly from raw bytes
645    /// Bypasses full entity decoding for ~3x speedup on BREP-heavy files
646    /// Returns (x, y, z) as f64 tuple
647    #[inline]
648    pub fn get_cartesian_point_fast(&mut self, entity_id: u32) -> Option<(f64, f64, f64)> {
649        let bytes = self.get_raw_bytes(entity_id)?;
650
651        // Find opening paren for coordinates: IFCCARTESIANPOINT((x,y,z));
652        let mut i = 0;
653        let len = bytes.len();
654
655        // Skip to first '(' after '='
656        while i < len && bytes[i] != b'(' {
657            i += 1;
658        }
659        if i >= len {
660            return None;
661        }
662        i += 1; // Skip first '('
663
664        // Skip to second '(' for the coordinate list
665        while i < len && bytes[i] != b'(' {
666            i += 1;
667        }
668        if i >= len {
669            return None;
670        }
671        i += 1; // Skip second '('
672
673        // Parse x coordinate
674        let x = parse_next_float(&bytes[i..], &mut i)?;
675
676        // Parse y coordinate
677        let y = parse_next_float(&bytes[i..], &mut i)?;
678
679        // Parse z coordinate (optional for 2D points, default to 0)
680        let z = parse_next_float(&bytes[i..], &mut i).unwrap_or(0.0);
681
682        Some((x, y, z))
683    }
684
685    /// Fast extraction of FaceBound info directly from raw bytes
686    /// Returns (loop_id, orientation, is_outer_bound)
687    /// Bypasses full entity decoding for BREP optimization
688    #[inline]
689    pub fn get_face_bound_fast(&mut self, entity_id: u32) -> Option<(u32, bool, bool)> {
690        let bytes = self.get_raw_bytes(entity_id)?;
691        let len = bytes.len();
692
693        // Find '=' to locate start of type name, and '(' for end
694        let mut eq_pos = 0;
695        while eq_pos < len && bytes[eq_pos] != b'=' {
696            eq_pos += 1;
697        }
698        if eq_pos >= len {
699            return None;
700        }
701
702        // Check if this is an outer bound by looking for "OUTER" in the type name
703        // IFCFACEOUTERBOUND vs IFCFACEBOUND
704        // The type name is between '=' and '('
705        let mut is_outer = false;
706        let mut i = eq_pos + 1;
707        // Look for "OUTER" pattern (must check for the full word, not just 'O')
708        while i + 4 < len && bytes[i] != b'(' {
709            if bytes[i] == b'O'
710                && bytes[i + 1] == b'U'
711                && bytes[i + 2] == b'T'
712                && bytes[i + 3] == b'E'
713                && bytes[i + 4] == b'R'
714            {
715                is_outer = true;
716                break;
717            }
718            i += 1;
719        }
720        // Continue to find the '(' if we haven't already
721        while i < len && bytes[i] != b'(' {
722            i += 1;
723        }
724        if i >= len {
725            return None;
726        }
727
728        i += 1; // Skip first '('
729
730        // Skip whitespace
731        while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
732            i += 1;
733        }
734
735        // Expect '#' for loop entity ref
736        if i >= len || bytes[i] != b'#' {
737            return None;
738        }
739        i += 1;
740
741        // Parse loop ID
742        let start = i;
743        while i < len && bytes[i].is_ascii_digit() {
744            i += 1;
745        }
746        if i <= start {
747            return None;
748        }
749        // Shared checked accumulator (#3421): an oversized loop ref fails
750        // this lookup outright, like any other unresolvable reference,
751        // instead of resolving to the wrong loop.
752        let loop_id = crate::express_id::parse_express_id(&bytes[start..i])?;
753
754        // Find orientation after comma - default to true (.T.)
755        // Skip to comma
756        while i < len && bytes[i] != b',' {
757            i += 1;
758        }
759        i += 1; // Skip comma
760
761        // Skip whitespace
762        while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
763            i += 1;
764        }
765
766        // Check for .F. (false) or .T. (true)
767        let orientation = if i + 2 < len && bytes[i] == b'.' && bytes[i + 2] == b'.' {
768            bytes[i + 1] != b'F'
769        } else {
770            true // Default to true
771        };
772
773        Some((loop_id, orientation, is_outer))
774    }
775
776    /// Fast extraction of PolyLoop COORDINATES directly from raw bytes
777    /// This is the ultimate fast path - extracts all coordinates in one go
778    /// Avoids N+1 HashMap lookups by batching point extraction
779    /// Returns Vec of (x, y, z) coordinate tuples
780    #[inline]
781    pub fn get_polyloop_coords_fast(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
782        // Ensure index is built once
783        self.build_index();
784        let index = self.entity_index.as_ref()?;
785        let bytes_full = self.content;
786
787        // Get polyloop raw bytes
788        let (start, end) = index.lookup(entity_id)?;
789        let bytes = &bytes_full[start..end];
790
791        // IFCPOLYLOOP((#id1,#id2,#id3,...));
792        let mut i = 0;
793        let len = bytes.len();
794
795        // Skip to first '(' after '='
796        while i < len && bytes[i] != b'(' {
797            i += 1;
798        }
799        if i >= len {
800            return None;
801        }
802        i += 1; // Skip first '('
803
804        // Skip to second '(' for the point list
805        while i < len && bytes[i] != b'(' {
806            i += 1;
807        }
808        if i >= len {
809            return None;
810        }
811        i += 1; // Skip second '('
812
813        // Parse point IDs and immediately fetch coordinates
814        let mut coords = Vec::with_capacity(8); // Most faces have 3-8 vertices
815
816        while i < len {
817            // Skip whitespace and commas
818            while i < len
819                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
820            {
821                i += 1;
822            }
823
824            if i >= len || bytes[i] == b')' {
825                break;
826            }
827
828            // Expect '#' followed by number
829            if bytes[i] == b'#' {
830                i += 1;
831                let id_start = i;
832                while i < len && bytes[i].is_ascii_digit() {
833                    i += 1;
834                }
835                if i > id_start {
836                    // Shared checked accumulator (#3421): an oversized ref drops this point.
837                    if let Some(point_id) =
838                        crate::express_id::parse_express_id(&bytes[id_start..i])
839                    {
840                        // INLINE: Get cartesian point coordinates directly
841                        // This avoids the overhead of calling get_cartesian_point_fast for each point
842                        if let Some((pt_start, pt_end)) = index.lookup(point_id) {
843                            if let Some(coord) =
844                                parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
845                            {
846                                coords.push(coord);
847                            }
848                        }
849                    }
850                }
851            } else {
852                i += 1; // Skip unknown character
853            }
854        }
855
856        if coords.len() >= 3 {
857            Some(coords)
858        } else {
859            None
860        }
861    }
862
863    /// Fast extraction of PolyLoop COORDINATES with point caching
864    /// Uses a cache to avoid re-parsing the same cartesian points
865    /// For files with many faces sharing points, this can be 2-3x faster
866    #[inline]
867    pub fn get_polyloop_coords_cached(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
868        // Ensure index is built once
869        self.build_index();
870        let index = self.entity_index.as_ref()?;
871        let bytes_full = self.content;
872
873        // Get polyloop raw bytes
874        let (start, end) = index.lookup(entity_id)?;
875        let bytes = &bytes_full[start..end];
876
877        // IFCPOLYLOOP((#id1,#id2,#id3,...));
878        let mut i = 0;
879        let len = bytes.len();
880
881        // Skip to first '(' after '='
882        while i < len && bytes[i] != b'(' {
883            i += 1;
884        }
885        if i >= len {
886            return None;
887        }
888        i += 1; // Skip first '('
889
890        // Skip to second '(' for the point list
891        while i < len && bytes[i] != b'(' {
892            i += 1;
893        }
894        if i >= len {
895            return None;
896        }
897        i += 1; // Skip second '('
898
899        // Parse point IDs and fetch coordinates (with caching)
900        // CRITICAL: Track expected count to ensure all points are resolved
901        let mut coords = Vec::with_capacity(8);
902        let mut expected_count = 0u32;
903
904        while i < len {
905            // Skip whitespace and commas
906            while i < len
907                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
908            {
909                i += 1;
910            }
911
912            if i >= len || bytes[i] == b')' {
913                break;
914            }
915
916            // Expect '#' followed by number
917            if bytes[i] == b'#' {
918                i += 1;
919                let id_start = i;
920                while i < len && bytes[i].is_ascii_digit() {
921                    i += 1;
922                }
923                if i > id_start {
924                    expected_count += 1; // Count every point ID we encounter
925
926                    // Shared checked accumulator (#3421): `expected_count`
927                    // was already bumped, so a refused id here trips the
928                    // `coords.len() == expected_count` check below, same as
929                    // any other missing point.
930                    if let Some(point_id) =
931                        crate::express_id::parse_express_id(&bytes[id_start..i])
932                    {
933                        // Check cache first
934                        if let Some(&coord) = self.point_cache.get(&point_id) {
935                            self.point_cache_hits += 1;
936                            coords.push(coord);
937                        } else {
938                            // Not in cache - parse and cache
939                            if let Some((pt_start, pt_end)) = index.lookup(point_id) {
940                                if let Some(coord) =
941                                    parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
942                                {
943                                    self.point_cache_misses += 1;
944                                    self.point_cache.insert(point_id, coord);
945                                    coords.push(coord);
946                                }
947                            }
948                        }
949                    }
950                }
951            } else {
952                i += 1; // Skip unknown character
953            }
954        }
955
956        // CRITICAL: Return None if ANY point failed to resolve
957        // This matches the old behavior where missing points invalidated the whole polygon
958        if coords.len() >= 3 && coords.len() == expected_count as usize {
959            Some(coords)
960        } else {
961            None
962        }
963    }
964}
965
966/// Parse cartesian point coordinates inline from raw bytes
967/// Used by get_polyloop_coords_fast for maximum performance
968#[inline]
969fn parse_cartesian_point_inline(bytes: &[u8]) -> Option<(f64, f64, f64)> {
970    let len = bytes.len();
971    let mut i = 0;
972
973    // Skip to first '(' after '='
974    while i < len && bytes[i] != b'(' {
975        i += 1;
976    }
977    if i >= len {
978        return None;
979    }
980    i += 1; // Skip first '('
981
982    // Skip to second '(' for the coordinate list
983    while i < len && bytes[i] != b'(' {
984        i += 1;
985    }
986    if i >= len {
987        return None;
988    }
989    i += 1; // Skip second '('
990
991    // Parse x coordinate
992    let x = parse_float_inline(&bytes[i..], &mut i)?;
993
994    // Parse y coordinate
995    let y = parse_float_inline(&bytes[i..], &mut i)?;
996
997    // Parse z coordinate (optional for 2D points, default to 0)
998    let z = parse_float_inline(&bytes[i..], &mut i).unwrap_or(0.0);
999
1000    Some((x, y, z))
1001}
1002
1003/// Parse float inline - simpler version for batch coordinate extraction
1004#[inline]
1005fn parse_float_inline(bytes: &[u8], offset: &mut usize) -> Option<f64> {
1006    let len = bytes.len();
1007    let mut i = 0;
1008
1009    // Skip whitespace and commas
1010    while i < len
1011        && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
1012    {
1013        i += 1;
1014    }
1015
1016    if i >= len || bytes[i] == b')' {
1017        return None;
1018    }
1019
1020    // Parse float using fast_float
1021    match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
1022        Ok((value, consumed)) if consumed > 0 => {
1023            *offset += i + consumed;
1024            Some(value)
1025        }
1026        _ => None,
1027    }
1028}
1029
1030/// Parse next float from bytes, advancing position past it
1031#[inline]
1032fn parse_next_float(bytes: &[u8], offset: &mut usize) -> Option<f64> {
1033    let len = bytes.len();
1034    let mut i = 0;
1035
1036    // Skip whitespace and commas
1037    while i < len
1038        && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
1039    {
1040        i += 1;
1041    }
1042
1043    if i >= len || bytes[i] == b')' {
1044        return None;
1045    }
1046
1047    // Parse float using fast_float
1048    match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
1049        Ok((value, consumed)) if consumed > 0 => {
1050            *offset += i + consumed;
1051            Some(value)
1052        }
1053        _ => None,
1054    }
1055}
1056
1057#[cfg(test)]
1058mod decoder_tests;