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