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, 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 and quoted-string
25/// semantics so scan iteration and decoder lookup cannot disagree on malformed
26/// headers or semicolons embedded inside STEP strings.
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
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                    let mut id = 0u32;
497                    for &b in &bytes[start..i] {
498                        id = id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
499                    }
500                    return Some(id);
501                }
502            }
503            i += 1;
504        }
505
506        None
507    }
508
509    /// Fast extraction of entity reference IDs from a list attribute in raw bytes
510    /// Useful for getting face list from ClosedShell, bounds from Face, etc.
511    /// Returns list of entity IDs
512    #[inline]
513    pub fn get_entity_ref_list_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
514        let bytes = self.get_raw_bytes(entity_id)?;
515
516        // Pattern: IFCTYPE((#id1,#id2,...)); or IFCTYPE((#id1,#id2,...),other);
517        let mut i = 0;
518        let len = bytes.len();
519
520        // Skip to first '(' after '='
521        while i < len && bytes[i] != b'(' {
522            i += 1;
523        }
524        if i >= len {
525            return None;
526        }
527        i += 1; // Skip first '('
528
529        // Skip to second '(' for the list
530        while i < len && bytes[i] != b'(' {
531            i += 1;
532        }
533        if i >= len {
534            return None;
535        }
536        i += 1; // Skip second '('
537
538        // Parse entity IDs
539        let mut ids = Vec::with_capacity(32);
540
541        while i < len {
542            // Skip whitespace and commas
543            while i < len
544                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
545            {
546                i += 1;
547            }
548
549            if i >= len || bytes[i] == b')' {
550                break;
551            }
552
553            // Expect '#' followed by number
554            if bytes[i] == b'#' {
555                i += 1;
556                let start = i;
557                while i < len && bytes[i].is_ascii_digit() {
558                    i += 1;
559                }
560                if i > start {
561                    // Fast integer parsing directly from ASCII digits
562                    let mut id = 0u32;
563                    for &b in &bytes[start..i] {
564                        id = id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
565                    }
566                    ids.push(id);
567                }
568            } else {
569                i += 1; // Skip unknown character
570            }
571        }
572
573        if ids.is_empty() {
574            None
575        } else {
576            Some(ids)
577        }
578    }
579
580    /// Fast extraction of PolyLoop point IDs directly from raw bytes
581    /// Bypasses full entity decoding for BREP optimization
582    /// Returns list of entity IDs for CartesianPoints
583    #[inline]
584    pub fn get_polyloop_point_ids_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
585        let bytes = self.get_raw_bytes(entity_id)?;
586
587        // IFCPOLYLOOP((#id1,#id2,#id3,...));
588        let mut i = 0;
589        let len = bytes.len();
590
591        // Skip to first '(' after '='
592        while i < len && bytes[i] != b'(' {
593            i += 1;
594        }
595        if i >= len {
596            return None;
597        }
598        i += 1; // Skip first '('
599
600        // Skip to second '(' for the point list
601        while i < len && bytes[i] != b'(' {
602            i += 1;
603        }
604        if i >= len {
605            return None;
606        }
607        i += 1; // Skip second '('
608
609        // Parse point IDs
610        let mut point_ids = Vec::with_capacity(8); // Most faces have 3-8 vertices
611
612        while i < len {
613            // Skip whitespace and commas
614            while i < len
615                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
616            {
617                i += 1;
618            }
619
620            if i >= len || bytes[i] == b')' {
621                break;
622            }
623
624            // Expect '#' followed by number
625            if bytes[i] == b'#' {
626                i += 1;
627                let start = i;
628                while i < len && bytes[i].is_ascii_digit() {
629                    i += 1;
630                }
631                if i > start {
632                    // Fast integer parsing directly from ASCII digits
633                    let mut id = 0u32;
634                    for &b in &bytes[start..i] {
635                        id = id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
636                    }
637                    point_ids.push(id);
638                }
639            } else {
640                i += 1; // Skip unknown character
641            }
642        }
643
644        if point_ids.is_empty() {
645            None
646        } else {
647            Some(point_ids)
648        }
649    }
650
651    /// Fast extraction of CartesianPoint coordinates directly from raw bytes
652    /// Bypasses full entity decoding for ~3x speedup on BREP-heavy files
653    /// Returns (x, y, z) as f64 tuple
654    #[inline]
655    pub fn get_cartesian_point_fast(&mut self, entity_id: u32) -> Option<(f64, f64, f64)> {
656        let bytes = self.get_raw_bytes(entity_id)?;
657
658        // Find opening paren for coordinates: IFCCARTESIANPOINT((x,y,z));
659        let mut i = 0;
660        let len = bytes.len();
661
662        // Skip to first '(' after '='
663        while i < len && bytes[i] != b'(' {
664            i += 1;
665        }
666        if i >= len {
667            return None;
668        }
669        i += 1; // Skip first '('
670
671        // Skip to second '(' for the coordinate list
672        while i < len && bytes[i] != b'(' {
673            i += 1;
674        }
675        if i >= len {
676            return None;
677        }
678        i += 1; // Skip second '('
679
680        // Parse x coordinate
681        let x = parse_next_float(&bytes[i..], &mut i)?;
682
683        // Parse y coordinate
684        let y = parse_next_float(&bytes[i..], &mut i)?;
685
686        // Parse z coordinate (optional for 2D points, default to 0)
687        let z = parse_next_float(&bytes[i..], &mut i).unwrap_or(0.0);
688
689        Some((x, y, z))
690    }
691
692    /// Fast extraction of FaceBound info directly from raw bytes
693    /// Returns (loop_id, orientation, is_outer_bound)
694    /// Bypasses full entity decoding for BREP optimization
695    #[inline]
696    pub fn get_face_bound_fast(&mut self, entity_id: u32) -> Option<(u32, bool, bool)> {
697        let bytes = self.get_raw_bytes(entity_id)?;
698        let len = bytes.len();
699
700        // Find '=' to locate start of type name, and '(' for end
701        let mut eq_pos = 0;
702        while eq_pos < len && bytes[eq_pos] != b'=' {
703            eq_pos += 1;
704        }
705        if eq_pos >= len {
706            return None;
707        }
708
709        // Check if this is an outer bound by looking for "OUTER" in the type name
710        // IFCFACEOUTERBOUND vs IFCFACEBOUND
711        // The type name is between '=' and '('
712        let mut is_outer = false;
713        let mut i = eq_pos + 1;
714        // Look for "OUTER" pattern (must check for the full word, not just 'O')
715        while i + 4 < len && bytes[i] != b'(' {
716            if bytes[i] == b'O'
717                && bytes[i + 1] == b'U'
718                && bytes[i + 2] == b'T'
719                && bytes[i + 3] == b'E'
720                && bytes[i + 4] == b'R'
721            {
722                is_outer = true;
723                break;
724            }
725            i += 1;
726        }
727        // Continue to find the '(' if we haven't already
728        while i < len && bytes[i] != b'(' {
729            i += 1;
730        }
731        if i >= len {
732            return None;
733        }
734
735        i += 1; // Skip first '('
736
737        // Skip whitespace
738        while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
739            i += 1;
740        }
741
742        // Expect '#' for loop entity ref
743        if i >= len || bytes[i] != b'#' {
744            return None;
745        }
746        i += 1;
747
748        // Parse loop ID
749        let start = i;
750        while i < len && bytes[i].is_ascii_digit() {
751            i += 1;
752        }
753        if i <= start {
754            return None;
755        }
756        let mut loop_id = 0u32;
757        for &b in &bytes[start..i] {
758            loop_id = loop_id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
759        }
760
761        // Find orientation after comma - default to true (.T.)
762        // Skip to comma
763        while i < len && bytes[i] != b',' {
764            i += 1;
765        }
766        i += 1; // Skip comma
767
768        // Skip whitespace
769        while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
770            i += 1;
771        }
772
773        // Check for .F. (false) or .T. (true)
774        let orientation = if i + 2 < len && bytes[i] == b'.' && bytes[i + 2] == b'.' {
775            bytes[i + 1] != b'F'
776        } else {
777            true // Default to true
778        };
779
780        Some((loop_id, orientation, is_outer))
781    }
782
783    /// Fast extraction of PolyLoop COORDINATES directly from raw bytes
784    /// This is the ultimate fast path - extracts all coordinates in one go
785    /// Avoids N+1 HashMap lookups by batching point extraction
786    /// Returns Vec of (x, y, z) coordinate tuples
787    #[inline]
788    pub fn get_polyloop_coords_fast(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
789        // Ensure index is built once
790        self.build_index();
791        let index = self.entity_index.as_ref()?;
792        let bytes_full = self.content;
793
794        // Get polyloop raw bytes
795        let (start, end) = index.lookup(entity_id)?;
796        let bytes = &bytes_full[start..end];
797
798        // IFCPOLYLOOP((#id1,#id2,#id3,...));
799        let mut i = 0;
800        let len = bytes.len();
801
802        // Skip to first '(' after '='
803        while i < len && bytes[i] != b'(' {
804            i += 1;
805        }
806        if i >= len {
807            return None;
808        }
809        i += 1; // Skip first '('
810
811        // Skip to second '(' for the point list
812        while i < len && bytes[i] != b'(' {
813            i += 1;
814        }
815        if i >= len {
816            return None;
817        }
818        i += 1; // Skip second '('
819
820        // Parse point IDs and immediately fetch coordinates
821        let mut coords = Vec::with_capacity(8); // Most faces have 3-8 vertices
822
823        while i < len {
824            // Skip whitespace and commas
825            while i < len
826                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
827            {
828                i += 1;
829            }
830
831            if i >= len || bytes[i] == b')' {
832                break;
833            }
834
835            // Expect '#' followed by number
836            if bytes[i] == b'#' {
837                i += 1;
838                let id_start = i;
839                while i < len && bytes[i].is_ascii_digit() {
840                    i += 1;
841                }
842                if i > id_start {
843                    // Fast integer parsing directly from ASCII digits
844                    let mut point_id = 0u32;
845                    for &b in &bytes[id_start..i] {
846                        point_id = point_id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
847                    }
848
849                    // INLINE: Get cartesian point coordinates directly
850                    // This avoids the overhead of calling get_cartesian_point_fast for each point
851                    if let Some((pt_start, pt_end)) = index.lookup(point_id) {
852                        if let Some(coord) =
853                            parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
854                        {
855                            coords.push(coord);
856                        }
857                    }
858                }
859            } else {
860                i += 1; // Skip unknown character
861            }
862        }
863
864        if coords.len() >= 3 {
865            Some(coords)
866        } else {
867            None
868        }
869    }
870
871    /// Fast extraction of PolyLoop COORDINATES with point caching
872    /// Uses a cache to avoid re-parsing the same cartesian points
873    /// For files with many faces sharing points, this can be 2-3x faster
874    #[inline]
875    pub fn get_polyloop_coords_cached(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
876        // Ensure index is built once
877        self.build_index();
878        let index = self.entity_index.as_ref()?;
879        let bytes_full = self.content;
880
881        // Get polyloop raw bytes
882        let (start, end) = index.lookup(entity_id)?;
883        let bytes = &bytes_full[start..end];
884
885        // IFCPOLYLOOP((#id1,#id2,#id3,...));
886        let mut i = 0;
887        let len = bytes.len();
888
889        // Skip to first '(' after '='
890        while i < len && bytes[i] != b'(' {
891            i += 1;
892        }
893        if i >= len {
894            return None;
895        }
896        i += 1; // Skip first '('
897
898        // Skip to second '(' for the point list
899        while i < len && bytes[i] != b'(' {
900            i += 1;
901        }
902        if i >= len {
903            return None;
904        }
905        i += 1; // Skip second '('
906
907        // Parse point IDs and fetch coordinates (with caching)
908        // CRITICAL: Track expected count to ensure all points are resolved
909        let mut coords = Vec::with_capacity(8);
910        let mut expected_count = 0u32;
911
912        while i < len {
913            // Skip whitespace and commas
914            while i < len
915                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
916            {
917                i += 1;
918            }
919
920            if i >= len || bytes[i] == b')' {
921                break;
922            }
923
924            // Expect '#' followed by number
925            if bytes[i] == b'#' {
926                i += 1;
927                let id_start = i;
928                while i < len && bytes[i].is_ascii_digit() {
929                    i += 1;
930                }
931                if i > id_start {
932                    expected_count += 1; // Count every point ID we encounter
933
934                    // Fast integer parsing directly from ASCII digits
935                    let mut point_id = 0u32;
936                    for &b in &bytes[id_start..i] {
937                        point_id = point_id.wrapping_mul(10).wrapping_add((b - b'0') as u32);
938                    }
939
940                    // Check cache first
941                    if let Some(&coord) = self.point_cache.get(&point_id) {
942                        self.point_cache_hits += 1;
943                        coords.push(coord);
944                    } else {
945                        // Not in cache - parse and cache
946                        if let Some((pt_start, pt_end)) = index.lookup(point_id) {
947                            if let Some(coord) =
948                                parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
949                            {
950                                self.point_cache_misses += 1;
951                                self.point_cache.insert(point_id, coord);
952                                coords.push(coord);
953                            }
954                        }
955                    }
956                }
957            } else {
958                i += 1; // Skip unknown character
959            }
960        }
961
962        // CRITICAL: Return None if ANY point failed to resolve
963        // This matches the old behavior where missing points invalidated the whole polygon
964        if coords.len() >= 3 && coords.len() == expected_count as usize {
965            Some(coords)
966        } else {
967            None
968        }
969    }
970}
971
972/// Parse cartesian point coordinates inline from raw bytes
973/// Used by get_polyloop_coords_fast for maximum performance
974#[inline]
975fn parse_cartesian_point_inline(bytes: &[u8]) -> Option<(f64, f64, f64)> {
976    let len = bytes.len();
977    let mut i = 0;
978
979    // Skip to first '(' after '='
980    while i < len && bytes[i] != b'(' {
981        i += 1;
982    }
983    if i >= len {
984        return None;
985    }
986    i += 1; // Skip first '('
987
988    // Skip to second '(' for the coordinate list
989    while i < len && bytes[i] != b'(' {
990        i += 1;
991    }
992    if i >= len {
993        return None;
994    }
995    i += 1; // Skip second '('
996
997    // Parse x coordinate
998    let x = parse_float_inline(&bytes[i..], &mut i)?;
999
1000    // Parse y coordinate
1001    let y = parse_float_inline(&bytes[i..], &mut i)?;
1002
1003    // Parse z coordinate (optional for 2D points, default to 0)
1004    let z = parse_float_inline(&bytes[i..], &mut i).unwrap_or(0.0);
1005
1006    Some((x, y, z))
1007}
1008
1009/// Parse float inline - simpler version for batch coordinate extraction
1010#[inline]
1011fn parse_float_inline(bytes: &[u8], offset: &mut usize) -> Option<f64> {
1012    let len = bytes.len();
1013    let mut i = 0;
1014
1015    // Skip whitespace and commas
1016    while i < len
1017        && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
1018    {
1019        i += 1;
1020    }
1021
1022    if i >= len || bytes[i] == b')' {
1023        return None;
1024    }
1025
1026    // Parse float using fast_float
1027    match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
1028        Ok((value, consumed)) if consumed > 0 => {
1029            *offset += i + consumed;
1030            Some(value)
1031        }
1032        _ => None,
1033    }
1034}
1035
1036/// Parse next float from bytes, advancing position past it
1037#[inline]
1038fn parse_next_float(bytes: &[u8], offset: &mut usize) -> Option<f64> {
1039    let len = bytes.len();
1040    let mut i = 0;
1041
1042    // Skip whitespace and commas
1043    while i < len
1044        && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
1045    {
1046        i += 1;
1047    }
1048
1049    if i >= len || bytes[i] == b')' {
1050        return None;
1051    }
1052
1053    // Parse float using fast_float
1054    match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
1055        Ok((value, consumed)) if consumed > 0 => {
1056            *offset += i + consumed;
1057            Some(value)
1058        }
1059        _ => None,
1060    }
1061}
1062
1063#[cfg(test)]
1064mod decoder_tests;