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