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;