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#[path = "decoder/styled_items.rs"]
21mod styled_items;
22pub(crate) type StyledItemIndexResult = std::result::Result<FxHashMap<u32, Vec<u32>>, String>;
23
24/// Pre-built entity index type
25pub type EntityIndex = FxHashMap<u32, (usize, usize)>;
26
27/// Build an entity index from content.
28///
29/// This intentionally shares `EntityScanner`'s HEADER skipping, quoted-string
30/// semantics, and its #3395 refusal of instance names wider than `u32` (reported
31/// here, not handed back as a quietly short index), so scan and lookup agree.
32#[inline]
33pub fn build_entity_index<T>(content: &T) -> EntityIndex
34where
35    T: AsRef<[u8]> + ?Sized,
36{
37    let content = content.as_ref();
38    let estimated_entities = content.len() / 50;
39    let mut index = FxHashMap::with_capacity_and_hasher(estimated_entities, Default::default());
40    let mut scanner = EntityScanner::new(content);
41    while let Some((id, _type_name, start, end)) = scanner.next_entity() {
42        index.insert(id, (start, end));
43    }
44    report_scan_diagnostics(scanner.skipped_oversized_ids(), scanner.malformed_record_start().is_some());
45    index
46}
47
48/// Entity decoder for lazy parsing from raw IFC bytes.
49///
50/// String attributes are decoded lossily when tokens become `AttributeValue`s;
51/// structural scanning and byte offsets always use the original source bytes.
52pub struct EntityDecoder<'a> {
53    content: &'a [u8],
54    /// Cache of decoded entities (entity_id -> `Arc<DecodedEntity>`)
55    /// Using Arc avoids expensive clones on cache hits
56    cache: FxHashMap<u32, Arc<DecodedEntity>>,
57    /// Entity offsets (entity_id -> (start, end)): legacy `FxHashMap` or compact
58    /// columnar index. `pub(crate)` for `crate::columnar_index`'s constructors.
59    pub(crate) entity_index: Option<EntityIndexStore>,
60    /// Cache of cartesian point coordinates for FacetedBrep optimization
61    /// Only populated when using get_polyloop_coords_cached
62    point_cache: FxHashMap<u32, (f64, f64, f64)>,
63    /// Number of `point_cache` hits served by [`Self::get_polyloop_coords_cached`].
64    /// Pure instrumentation (never affects output); read via
65    /// [`Self::point_cache_stats`] to prove cross-element memoization fires when
66    /// the cache is hoisted across a worker's parts.
67    point_cache_hits: u64,
68    /// Number of `point_cache` misses (a CartesianPoint parsed for the first time)
69    /// served by [`Self::get_polyloop_coords_cached`].
70    point_cache_misses: u64,
71    /// Lazy-cached multiplier converting file plane-angle units to radians.
72    /// Populated on first call to [`Self::plane_angle_to_radians`]. Spec
73    /// default (and Renga-style files) is 1.0 (RADIAN); degree-unit files
74    /// resolve to π/180.
75    plane_angle_to_radians_cache: Option<f64>,
76    /// Lazy-cached multiplier converting file length units to metres.
77    /// Populated on first call to [`Self::length_unit_scale`]. 1.0 for metre
78    /// files, 0.001 for millimetre files, etc. Used to express absolute
79    /// tolerances (e.g. curve-tessellation chord deviation) in file units.
80    length_unit_scale_cache: Option<f64>,
81    /// Per-worker memo of resolved placement world transforms, keyed by the
82    /// IfcObjectPlacement entity id and stored as an opaque column-major
83    /// `[f64; 16]` (core must not depend on nalgebra; the geometry crate owns
84    /// the Matrix4 <-> array round-trip). Populated by the geometry router's
85    /// placement resolver, which recursively composes `parent * local` down the
86    /// IfcLocalPlacement chain. For a well-formed acyclic placement DAG the
87    /// resolved transform is a pure function of the placement id, so reusing it
88    /// across the elements a single worker meshes is byte-identical (speed
89    /// only): storey/building placements shared by thousands of elements are
90    /// composed once per worker instead of once per element. Hoisted across a
91    /// worker's parts exactly like `point_cache`; see
92    /// [`Self::take_placement_transform_cache`].
93    placement_transform_cache: FxHashMap<u32, [f64; 16]>,
94    styled_item_index: std::sync::OnceLock<StyledItemIndexResult>,
95}
96
97impl<'a> EntityDecoder<'a> {
98    /// Create new decoder
99    pub fn new<T>(content: &'a T) -> Self
100    where
101        T: AsRef<[u8]> + ?Sized,
102    {
103        let content = content.as_ref();
104        Self {
105            content,
106            cache: FxHashMap::default(),
107            entity_index: None,
108            point_cache: FxHashMap::default(),
109            point_cache_hits: 0,
110            point_cache_misses: 0,
111            plane_angle_to_radians_cache: None,
112            length_unit_scale_cache: None,
113            placement_transform_cache: FxHashMap::default(),
114            styled_item_index: std::sync::OnceLock::new(),
115        }
116    }
117
118    /// Create decoder with pre-built index (faster for repeated lookups)
119    pub fn with_index<T>(content: &'a T, index: EntityIndex) -> Self
120    where
121        T: AsRef<[u8]> + ?Sized,
122    {
123        let content = content.as_ref();
124        Self {
125            content,
126            cache: FxHashMap::default(),
127            entity_index: Some(EntityIndexStore::Hash(Arc::new(index))),
128            point_cache: FxHashMap::default(),
129            point_cache_hits: 0,
130            point_cache_misses: 0,
131            plane_angle_to_radians_cache: None,
132            length_unit_scale_cache: None,
133            placement_transform_cache: FxHashMap::default(),
134            styled_item_index: std::sync::OnceLock::new(),
135        }
136    }
137
138    /// Create decoder with shared Arc index (for parallel processing)
139    pub fn with_arc_index<T>(content: &'a T, index: Arc<EntityIndex>) -> Self
140    where
141        T: AsRef<[u8]> + ?Sized,
142    {
143        let content = content.as_ref();
144        Self {
145            content,
146            cache: FxHashMap::default(),
147            entity_index: Some(EntityIndexStore::Hash(index)),
148            point_cache: FxHashMap::default(),
149            point_cache_hits: 0,
150            point_cache_misses: 0,
151            plane_angle_to_radians_cache: None,
152            length_unit_scale_cache: None,
153            placement_transform_cache: FxHashMap::default(),
154            styled_item_index: std::sync::OnceLock::new(),
155        }
156    }
157
158    /// Install a pre-built index into this decoder. Used when the caller already
159    /// built the index during another pass (e.g. the processor scan loop) so the
160    /// decoder does not lazily rebuild it via `build_index`. Set it before any
161    /// `decode_by_id`/ref-resolving call; afterwards `build_index` no-ops.
162    pub fn set_entity_index(&mut self, index: Arc<EntityIndex>) {
163        self.entity_index = Some(EntityIndexStore::Hash(index));
164    }
165
166    /// Build entity index for O(1) lookups
167    /// This scans the file once and maps entity IDs to byte offsets
168    fn build_index(&mut self) {
169        if self.entity_index.is_some() {
170            return; // Already built
171        }
172        self.entity_index = Some(EntityIndexStore::Hash(Arc::new(build_entity_index(self.content))));
173    }
174
175    /// Decode entity at byte offset
176    /// Returns cached entity if already decoded
177    ///
178    /// Validates the `(start, end)` span against `self.content.len()` before
179    /// slicing. Out-of-range or inverted spans return `Error::parse` instead
180    /// of panicking — callers (e.g. `decode_and_cache`, `decode_at_with_id`,
181    /// the streaming pre-pass shard mergers) hand us spans derived from
182    /// untrusted/streamed entity-index data, and a malformed span must not
183    /// take down the whole worker.
184    #[inline]
185    pub fn decode_at(&mut self, start: usize, end: usize) -> Result<DecodedEntity> {
186        let (id, ifc_type, tokens) = self.parse_at(start, end, "decode_at")?;
187
188        // Check cache first - return clone of inner DecodedEntity
189        if let Some(entity_arc) = self.cache.get(&id) {
190            return Ok(entity_arc.as_ref().clone());
191        }
192
193        // Convert tokens to AttributeValues
194        let attributes = tokens
195            .iter()
196            .map(|token| AttributeValue::from_token(token))
197            .collect();
198
199        let entity = DecodedEntity::new(id, ifc_type, attributes);
200        self.cache.insert(id, Arc::new(entity.clone()));
201        Ok(entity)
202    }
203
204    /// Decode the entity in `[start, end)` **without** touching the cache.
205    ///
206    /// [`decode_at`](Self::decode_at) memoizes every entity it parses, which is the
207    /// right trade-off for geometry sub-tree walks (the same points/profiles are
208    /// revisited many times). For a single linear pass over *every* entity in a
209    /// large model — tens of millions of rows — that cache grows without bound and
210    /// dominates memory. This variant parses and returns the entity but never
211    /// inserts it, so a streaming walk stays O(1) in entity count. The caller owns
212    /// the result; for identical bytes it yields an identical [`DecodedEntity`] to
213    /// `decode_at`, only without the cache side effect.
214    pub fn decode_at_uncached(&self, start: usize, end: usize) -> Result<DecodedEntity> {
215        let (id, ifc_type, tokens) = self.parse_at(start, end, "decode_at_uncached")?;
216        let attributes = tokens
217            .iter()
218            .map(|token| AttributeValue::from_token(token))
219            .collect();
220        Ok(DecodedEntity::new(id, ifc_type, attributes))
221    }
222
223    /// Shared full-record validation and existing error context. The returned
224    /// tokens borrow immutable source bytes, not the decoder's entity cache.
225    #[inline]
226    fn parse_at(
227        &self, start: usize, end: usize, method: &str,
228    ) -> Result<(u32, crate::IfcType, Vec<crate::parser::Token<'a>>)> {
229        let content_len = self.content.len();
230        if start > end || end > content_len {
231            return Err(Error::parse(
232                0,
233                format!(
234                    "{method}: invalid byte span ({}, {}) for content length {}",
235                    start, end, content_len,
236                ),
237            ));
238        }
239        let line = &self.content[start..end];
240        parse_entity(line).map_err(|e| {
241            // Add bounded, lossy debug info without requiring the source to be UTF-8.
242            let cut = line.len().min(100);
243            Error::parse(
244                0,
245                format!(
246                    "Failed to parse entity: {:?}, input: {:?}",
247                    e,
248                    String::from_utf8_lossy(&line[..cut])
249                ),
250            )
251        })
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::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    /// Get raw bytes for an entity (for direct/fast parsing)
449    /// Returns the full entity line including type and attributes
450    #[inline]
451    pub fn get_raw_bytes(&mut self, entity_id: u32) -> Option<&'a [u8]> {
452        self.build_index();
453        let (start, end) = self.entity_index.as_ref()?.lookup(entity_id)?;
454        Some(&self.content[start..end])
455    }
456
457    /// Fast extraction of first entity ref from raw bytes
458    /// Useful for BREP -> shell ID, Face -> FaceBound, etc.
459    /// Returns the first entity reference ID found in the entity
460    #[inline]
461    pub fn get_first_entity_ref_fast(&mut self, entity_id: u32) -> Option<u32> {
462        let bytes = self.get_raw_bytes(entity_id)?;
463        let len = bytes.len();
464        let mut i = 0;
465
466        // Skip to first '(' after '='
467        while i < len && bytes[i] != b'(' {
468            i += 1;
469        }
470        if i >= len {
471            return None;
472        }
473        i += 1; // Skip first '('
474
475        // Find first '#' which is the entity ref
476        while i < len {
477            // Skip whitespace
478            while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
479                i += 1;
480            }
481
482            if i >= len {
483                return None;
484            }
485
486            if bytes[i] == b'#' {
487                i += 1;
488                let start = i;
489                while i < len && bytes[i].is_ascii_digit() {
490                    i += 1;
491                }
492                if i > start {
493                    // Shared checked accumulator (#3421): refuses rather than wraps.
494                    return crate::express_id::parse_express_id(&bytes[start..i]);
495                }
496            }
497            i += 1;
498        }
499
500        None
501    }
502
503    /// Fast extraction of PolyLoop point IDs directly from raw bytes
504    /// Bypasses full entity decoding for BREP optimization
505    /// Returns list of entity IDs for CartesianPoints
506    #[inline]
507    pub fn get_polyloop_point_ids_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
508        let bytes = self.get_raw_bytes(entity_id)?;
509
510        // IFCPOLYLOOP((#id1,#id2,#id3,...));
511        let mut i = 0;
512        let len = bytes.len();
513
514        // Skip to first '(' after '='
515        while i < len && bytes[i] != b'(' {
516            i += 1;
517        }
518        if i >= len {
519            return None;
520        }
521        i += 1; // Skip first '('
522
523        // Skip to second '(' for the point list
524        while i < len && bytes[i] != b'(' {
525            i += 1;
526        }
527        if i >= len {
528            return None;
529        }
530        i += 1; // Skip second '('
531
532        // Parse point IDs
533        let mut point_ids = Vec::with_capacity(8); // Most faces have 3-8 vertices
534
535        while i < len {
536            // Skip whitespace and commas
537            while i < len
538                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
539            {
540                i += 1;
541            }
542
543            if i >= len || bytes[i] == b')' {
544                break;
545            }
546
547            // Expect '#' followed by number
548            if bytes[i] == b'#' {
549                i += 1;
550                let start = i;
551                while i < len && bytes[i].is_ascii_digit() {
552                    i += 1;
553                }
554                if i > start {
555                    // Shared checked accumulator (#3421): an oversized id is dropped, not wrapped.
556                    if let Some(id) = crate::express_id::parse_express_id(&bytes[start..i]) {
557                        point_ids.push(id);
558                    }
559                }
560            } else {
561                i += 1; // Skip unknown character
562            }
563        }
564
565        if point_ids.is_empty() {
566            None
567        } else {
568            Some(point_ids)
569        }
570    }
571
572    /// Fast extraction of CartesianPoint coordinates directly from raw bytes
573    /// Bypasses full entity decoding for ~3x speedup on BREP-heavy files
574    /// Returns (x, y, z) as f64 tuple
575    #[inline]
576    pub fn get_cartesian_point_fast(&mut self, entity_id: u32) -> Option<(f64, f64, f64)> {
577        let bytes = self.get_raw_bytes(entity_id)?;
578
579        // Find opening paren for coordinates: IFCCARTESIANPOINT((x,y,z));
580        let mut i = 0;
581        let len = bytes.len();
582
583        // Skip to first '(' after '='
584        while i < len && bytes[i] != b'(' {
585            i += 1;
586        }
587        if i >= len {
588            return None;
589        }
590        i += 1; // Skip first '('
591
592        // Skip to second '(' for the coordinate list
593        while i < len && bytes[i] != b'(' {
594            i += 1;
595        }
596        if i >= len {
597            return None;
598        }
599        i += 1; // Skip second '('
600
601        // Parse x coordinate
602        let x = parse_next_float(&bytes[i..], &mut i)?;
603
604        // Parse y coordinate
605        let y = parse_next_float(&bytes[i..], &mut i)?;
606
607        // Parse z coordinate (optional for 2D points, default to 0)
608        let z = parse_next_float(&bytes[i..], &mut i).unwrap_or(0.0);
609
610        Some((x, y, z))
611    }
612
613    /// Fast extraction of FaceBound info directly from raw bytes
614    /// Returns (loop_id, orientation, is_outer_bound)
615    /// Bypasses full entity decoding for BREP optimization
616    #[inline]
617    pub fn get_face_bound_fast(&mut self, entity_id: u32) -> Option<(u32, bool, bool)> {
618        let bytes = self.get_raw_bytes(entity_id)?;
619        let len = bytes.len();
620
621        // Find '=' to locate start of type name, and '(' for end
622        let mut eq_pos = 0;
623        while eq_pos < len && bytes[eq_pos] != b'=' {
624            eq_pos += 1;
625        }
626        if eq_pos >= len {
627            return None;
628        }
629
630        // Check if this is an outer bound by looking for "OUTER" in the type name
631        // IFCFACEOUTERBOUND vs IFCFACEBOUND
632        // The type name is between '=' and '('
633        let mut is_outer = false;
634        let mut i = eq_pos + 1;
635        // Look for "OUTER" pattern (must check for the full word, not just 'O')
636        while i + 4 < len && bytes[i] != b'(' {
637            if bytes[i] == b'O'
638                && bytes[i + 1] == b'U'
639                && bytes[i + 2] == b'T'
640                && bytes[i + 3] == b'E'
641                && bytes[i + 4] == b'R'
642            {
643                is_outer = true;
644                break;
645            }
646            i += 1;
647        }
648        // Continue to find the '(' if we haven't already
649        while i < len && bytes[i] != b'(' {
650            i += 1;
651        }
652        if i >= len {
653            return None;
654        }
655
656        i += 1; // Skip first '('
657
658        // Skip whitespace
659        while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
660            i += 1;
661        }
662
663        // Expect '#' for loop entity ref
664        if i >= len || bytes[i] != b'#' {
665            return None;
666        }
667        i += 1;
668
669        // Parse loop ID
670        let start = i;
671        while i < len && bytes[i].is_ascii_digit() {
672            i += 1;
673        }
674        if i <= start {
675            return None;
676        }
677        // Shared checked accumulator (#3421): an oversized loop ref fails
678        // this lookup outright, like any other unresolvable reference,
679        // instead of resolving to the wrong loop.
680        let loop_id = crate::express_id::parse_express_id(&bytes[start..i])?;
681
682        // Find orientation after comma - default to true (.T.)
683        // Skip to comma
684        while i < len && bytes[i] != b',' {
685            i += 1;
686        }
687        i += 1; // Skip comma
688
689        // Skip whitespace
690        while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
691            i += 1;
692        }
693
694        // Check for .F. (false) or .T. (true)
695        let orientation = if i + 2 < len && bytes[i] == b'.' && bytes[i + 2] == b'.' {
696            bytes[i + 1] != b'F'
697        } else {
698            true // Default to true
699        };
700
701        Some((loop_id, orientation, is_outer))
702    }
703
704    /// Fast extraction of PolyLoop COORDINATES directly from raw bytes
705    /// This is the ultimate fast path - extracts all coordinates in one go
706    /// Avoids N+1 HashMap lookups by batching point extraction
707    /// Returns Vec of (x, y, z) coordinate tuples
708    #[inline]
709    pub fn get_polyloop_coords_fast(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
710        // Ensure index is built once
711        self.build_index();
712        let index = self.entity_index.as_ref()?;
713        let bytes_full = self.content;
714
715        // Get polyloop raw bytes
716        let (start, end) = index.lookup(entity_id)?;
717        let bytes = &bytes_full[start..end];
718
719        // IFCPOLYLOOP((#id1,#id2,#id3,...));
720        let mut i = 0;
721        let len = bytes.len();
722
723        // Skip to first '(' after '='
724        while i < len && bytes[i] != b'(' {
725            i += 1;
726        }
727        if i >= len {
728            return None;
729        }
730        i += 1; // Skip first '('
731
732        // Skip to second '(' for the point list
733        while i < len && bytes[i] != b'(' {
734            i += 1;
735        }
736        if i >= len {
737            return None;
738        }
739        i += 1; // Skip second '('
740
741        // Parse point IDs and immediately fetch coordinates
742        let mut coords = Vec::with_capacity(8); // Most faces have 3-8 vertices
743
744        while i < len {
745            // Skip whitespace and commas
746            while i < len
747                && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
748            {
749                i += 1;
750            }
751
752            if i >= len || bytes[i] == b')' {
753                break;
754            }
755
756            // Expect '#' followed by number
757            if bytes[i] == b'#' {
758                i += 1;
759                let id_start = i;
760                while i < len && bytes[i].is_ascii_digit() {
761                    i += 1;
762                }
763                if i > id_start {
764                    // Shared checked accumulator (#3421): an oversized ref drops this point.
765                    if let Some(point_id) =
766                        crate::express_id::parse_express_id(&bytes[id_start..i])
767                    {
768                        // INLINE: Get cartesian point coordinates directly
769                        // This avoids the overhead of calling get_cartesian_point_fast for each point
770                        if let Some((pt_start, pt_end)) = index.lookup(point_id) {
771                            if let Some(coord) =
772                                parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
773                            {
774                                coords.push(coord);
775                            }
776                        }
777                    }
778                }
779            } else {
780                i += 1; // Skip unknown character
781            }
782        }
783
784        if coords.len() >= 3 {
785            Some(coords)
786        } else {
787            None
788        }
789    }
790
791
792}
793
794/// Parse cartesian point coordinates inline from raw bytes
795/// Used by get_polyloop_coords_fast for maximum performance
796#[inline]
797fn parse_cartesian_point_inline(bytes: &[u8]) -> Option<(f64, f64, f64)> {
798    let len = bytes.len();
799    let mut i = 0;
800
801    // Skip to first '(' after '='
802    while i < len && bytes[i] != b'(' {
803        i += 1;
804    }
805    if i >= len {
806        return None;
807    }
808    i += 1; // Skip first '('
809
810    // Skip to second '(' for the coordinate list
811    while i < len && bytes[i] != b'(' {
812        i += 1;
813    }
814    if i >= len {
815        return None;
816    }
817    i += 1; // Skip second '('
818
819    // Parse x coordinate
820    let x = parse_float_inline(&bytes[i..], &mut i)?;
821
822    // Parse y coordinate
823    let y = parse_float_inline(&bytes[i..], &mut i)?;
824
825    // Parse z coordinate (optional for 2D points, default to 0)
826    let z = parse_float_inline(&bytes[i..], &mut i).unwrap_or(0.0);
827
828    Some((x, y, z))
829}
830
831/// Parse float inline - simpler version for batch coordinate extraction
832#[inline]
833fn parse_float_inline(bytes: &[u8], offset: &mut usize) -> Option<f64> {
834    let len = bytes.len();
835    let mut i = 0;
836
837    // Skip whitespace and commas
838    while i < len
839        && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
840    {
841        i += 1;
842    }
843
844    if i >= len || bytes[i] == b')' {
845        return None;
846    }
847
848    // Parse float using fast_float
849    match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
850        Ok((value, consumed)) if consumed > 0 => {
851            *offset += i + consumed;
852            Some(value)
853        }
854        _ => None,
855    }
856}
857
858/// Parse next float from bytes, advancing position past it
859#[inline]
860fn parse_next_float(bytes: &[u8], offset: &mut usize) -> Option<f64> {
861    let len = bytes.len();
862    let mut i = 0;
863
864    // Skip whitespace and commas
865    while i < len
866        && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
867    {
868        i += 1;
869    }
870
871    if i >= len || bytes[i] == b')' {
872        return None;
873    }
874
875    // Parse float using fast_float
876    match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
877        Ok((value, consumed)) if consumed > 0 => {
878            *offset += i + consumed;
879            Some(value)
880        }
881        _ => None,
882    }
883}
884
885#[cfg(test)]
886mod decoder_tests;