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