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
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, quoted-string
25/// semantics, and its #3395 refusal of instance names wider than `u32` (reported
26/// here, not handed back as a quietly short index), so scan and lookup agree.
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 report_scan_diagnostics(scanner.skipped_oversized_ids(), scanner.malformed_record_start().is_some());
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 // Shared checked accumulator (#3421): refuses rather than wraps.
497 return crate::express_id::parse_express_id(&bytes[start..i]);
498 }
499 }
500 i += 1;
501 }
502
503 None
504 }
505
506 /// Fast extraction of entity reference IDs from a list attribute in raw bytes
507 /// Useful for getting face list from ClosedShell, bounds from Face, etc.
508 /// Returns list of entity IDs
509 #[inline]
510 pub fn get_entity_ref_list_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
511 let bytes = self.get_raw_bytes(entity_id)?;
512
513 // Pattern: IFCTYPE((#id1,#id2,...)); or IFCTYPE((#id1,#id2,...),other);
514 let mut i = 0;
515 let len = bytes.len();
516
517 // Skip to first '(' after '='
518 while i < len && bytes[i] != b'(' {
519 i += 1;
520 }
521 if i >= len {
522 return None;
523 }
524 i += 1; // Skip first '('
525
526 // Skip to second '(' for the list
527 while i < len && bytes[i] != b'(' {
528 i += 1;
529 }
530 if i >= len {
531 return None;
532 }
533 i += 1; // Skip second '('
534
535 // Parse entity IDs
536 let mut ids = Vec::with_capacity(32);
537
538 while i < len {
539 // Skip whitespace and commas
540 while i < len
541 && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
542 {
543 i += 1;
544 }
545
546 if i >= len || bytes[i] == b')' {
547 break;
548 }
549
550 // Expect '#' followed by number
551 if bytes[i] == b'#' {
552 i += 1;
553 let start = i;
554 while i < len && bytes[i].is_ascii_digit() {
555 i += 1;
556 }
557 if i > start {
558 // Shared checked accumulator (#3421): an oversized id is dropped, not wrapped.
559 if let Some(id) = crate::express_id::parse_express_id(&bytes[start..i]) {
560 ids.push(id);
561 }
562 }
563 } else {
564 i += 1; // Skip unknown character
565 }
566 }
567
568 if ids.is_empty() {
569 None
570 } else {
571 Some(ids)
572 }
573 }
574
575 /// Fast extraction of PolyLoop point IDs directly from raw bytes
576 /// Bypasses full entity decoding for BREP optimization
577 /// Returns list of entity IDs for CartesianPoints
578 #[inline]
579 pub fn get_polyloop_point_ids_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
580 let bytes = self.get_raw_bytes(entity_id)?;
581
582 // IFCPOLYLOOP((#id1,#id2,#id3,...));
583 let mut i = 0;
584 let len = bytes.len();
585
586 // Skip to first '(' after '='
587 while i < len && bytes[i] != b'(' {
588 i += 1;
589 }
590 if i >= len {
591 return None;
592 }
593 i += 1; // Skip first '('
594
595 // Skip to second '(' for the point list
596 while i < len && bytes[i] != b'(' {
597 i += 1;
598 }
599 if i >= len {
600 return None;
601 }
602 i += 1; // Skip second '('
603
604 // Parse point IDs
605 let mut point_ids = Vec::with_capacity(8); // Most faces have 3-8 vertices
606
607 while i < len {
608 // Skip whitespace and commas
609 while i < len
610 && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
611 {
612 i += 1;
613 }
614
615 if i >= len || bytes[i] == b')' {
616 break;
617 }
618
619 // Expect '#' followed by number
620 if bytes[i] == b'#' {
621 i += 1;
622 let start = i;
623 while i < len && bytes[i].is_ascii_digit() {
624 i += 1;
625 }
626 if i > start {
627 // Shared checked accumulator (#3421): an oversized id is dropped, not wrapped.
628 if let Some(id) = crate::express_id::parse_express_id(&bytes[start..i]) {
629 point_ids.push(id);
630 }
631 }
632 } else {
633 i += 1; // Skip unknown character
634 }
635 }
636
637 if point_ids.is_empty() {
638 None
639 } else {
640 Some(point_ids)
641 }
642 }
643
644 /// Fast extraction of CartesianPoint coordinates directly from raw bytes
645 /// Bypasses full entity decoding for ~3x speedup on BREP-heavy files
646 /// Returns (x, y, z) as f64 tuple
647 #[inline]
648 pub fn get_cartesian_point_fast(&mut self, entity_id: u32) -> Option<(f64, f64, f64)> {
649 let bytes = self.get_raw_bytes(entity_id)?;
650
651 // Find opening paren for coordinates: IFCCARTESIANPOINT((x,y,z));
652 let mut i = 0;
653 let len = bytes.len();
654
655 // Skip to first '(' after '='
656 while i < len && bytes[i] != b'(' {
657 i += 1;
658 }
659 if i >= len {
660 return None;
661 }
662 i += 1; // Skip first '('
663
664 // Skip to second '(' for the coordinate list
665 while i < len && bytes[i] != b'(' {
666 i += 1;
667 }
668 if i >= len {
669 return None;
670 }
671 i += 1; // Skip second '('
672
673 // Parse x coordinate
674 let x = parse_next_float(&bytes[i..], &mut i)?;
675
676 // Parse y coordinate
677 let y = parse_next_float(&bytes[i..], &mut i)?;
678
679 // Parse z coordinate (optional for 2D points, default to 0)
680 let z = parse_next_float(&bytes[i..], &mut i).unwrap_or(0.0);
681
682 Some((x, y, z))
683 }
684
685 /// Fast extraction of FaceBound info directly from raw bytes
686 /// Returns (loop_id, orientation, is_outer_bound)
687 /// Bypasses full entity decoding for BREP optimization
688 #[inline]
689 pub fn get_face_bound_fast(&mut self, entity_id: u32) -> Option<(u32, bool, bool)> {
690 let bytes = self.get_raw_bytes(entity_id)?;
691 let len = bytes.len();
692
693 // Find '=' to locate start of type name, and '(' for end
694 let mut eq_pos = 0;
695 while eq_pos < len && bytes[eq_pos] != b'=' {
696 eq_pos += 1;
697 }
698 if eq_pos >= len {
699 return None;
700 }
701
702 // Check if this is an outer bound by looking for "OUTER" in the type name
703 // IFCFACEOUTERBOUND vs IFCFACEBOUND
704 // The type name is between '=' and '('
705 let mut is_outer = false;
706 let mut i = eq_pos + 1;
707 // Look for "OUTER" pattern (must check for the full word, not just 'O')
708 while i + 4 < len && bytes[i] != b'(' {
709 if bytes[i] == b'O'
710 && bytes[i + 1] == b'U'
711 && bytes[i + 2] == b'T'
712 && bytes[i + 3] == b'E'
713 && bytes[i + 4] == b'R'
714 {
715 is_outer = true;
716 break;
717 }
718 i += 1;
719 }
720 // Continue to find the '(' if we haven't already
721 while i < len && bytes[i] != b'(' {
722 i += 1;
723 }
724 if i >= len {
725 return None;
726 }
727
728 i += 1; // Skip first '('
729
730 // Skip whitespace
731 while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
732 i += 1;
733 }
734
735 // Expect '#' for loop entity ref
736 if i >= len || bytes[i] != b'#' {
737 return None;
738 }
739 i += 1;
740
741 // Parse loop ID
742 let start = i;
743 while i < len && bytes[i].is_ascii_digit() {
744 i += 1;
745 }
746 if i <= start {
747 return None;
748 }
749 // Shared checked accumulator (#3421): an oversized loop ref fails
750 // this lookup outright, like any other unresolvable reference,
751 // instead of resolving to the wrong loop.
752 let loop_id = crate::express_id::parse_express_id(&bytes[start..i])?;
753
754 // Find orientation after comma - default to true (.T.)
755 // Skip to comma
756 while i < len && bytes[i] != b',' {
757 i += 1;
758 }
759 i += 1; // Skip comma
760
761 // Skip whitespace
762 while i < len && (bytes[i] == b' ' || bytes[i] == b'\n' || bytes[i] == b'\r') {
763 i += 1;
764 }
765
766 // Check for .F. (false) or .T. (true)
767 let orientation = if i + 2 < len && bytes[i] == b'.' && bytes[i + 2] == b'.' {
768 bytes[i + 1] != b'F'
769 } else {
770 true // Default to true
771 };
772
773 Some((loop_id, orientation, is_outer))
774 }
775
776 /// Fast extraction of PolyLoop COORDINATES directly from raw bytes
777 /// This is the ultimate fast path - extracts all coordinates in one go
778 /// Avoids N+1 HashMap lookups by batching point extraction
779 /// Returns Vec of (x, y, z) coordinate tuples
780 #[inline]
781 pub fn get_polyloop_coords_fast(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
782 // Ensure index is built once
783 self.build_index();
784 let index = self.entity_index.as_ref()?;
785 let bytes_full = self.content;
786
787 // Get polyloop raw bytes
788 let (start, end) = index.lookup(entity_id)?;
789 let bytes = &bytes_full[start..end];
790
791 // IFCPOLYLOOP((#id1,#id2,#id3,...));
792 let mut i = 0;
793 let len = bytes.len();
794
795 // Skip to first '(' after '='
796 while i < len && bytes[i] != b'(' {
797 i += 1;
798 }
799 if i >= len {
800 return None;
801 }
802 i += 1; // Skip first '('
803
804 // Skip to second '(' for the point list
805 while i < len && bytes[i] != b'(' {
806 i += 1;
807 }
808 if i >= len {
809 return None;
810 }
811 i += 1; // Skip second '('
812
813 // Parse point IDs and immediately fetch coordinates
814 let mut coords = Vec::with_capacity(8); // Most faces have 3-8 vertices
815
816 while i < len {
817 // Skip whitespace and commas
818 while i < len
819 && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
820 {
821 i += 1;
822 }
823
824 if i >= len || bytes[i] == b')' {
825 break;
826 }
827
828 // Expect '#' followed by number
829 if bytes[i] == b'#' {
830 i += 1;
831 let id_start = i;
832 while i < len && bytes[i].is_ascii_digit() {
833 i += 1;
834 }
835 if i > id_start {
836 // Shared checked accumulator (#3421): an oversized ref drops this point.
837 if let Some(point_id) =
838 crate::express_id::parse_express_id(&bytes[id_start..i])
839 {
840 // INLINE: Get cartesian point coordinates directly
841 // This avoids the overhead of calling get_cartesian_point_fast for each point
842 if let Some((pt_start, pt_end)) = index.lookup(point_id) {
843 if let Some(coord) =
844 parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
845 {
846 coords.push(coord);
847 }
848 }
849 }
850 }
851 } else {
852 i += 1; // Skip unknown character
853 }
854 }
855
856 if coords.len() >= 3 {
857 Some(coords)
858 } else {
859 None
860 }
861 }
862
863 /// Fast extraction of PolyLoop COORDINATES with point caching
864 /// Uses a cache to avoid re-parsing the same cartesian points
865 /// For files with many faces sharing points, this can be 2-3x faster
866 #[inline]
867 pub fn get_polyloop_coords_cached(&mut self, entity_id: u32) -> Option<Vec<(f64, f64, f64)>> {
868 // Ensure index is built once
869 self.build_index();
870 let index = self.entity_index.as_ref()?;
871 let bytes_full = self.content;
872
873 // Get polyloop raw bytes
874 let (start, end) = index.lookup(entity_id)?;
875 let bytes = &bytes_full[start..end];
876
877 // IFCPOLYLOOP((#id1,#id2,#id3,...));
878 let mut i = 0;
879 let len = bytes.len();
880
881 // Skip to first '(' after '='
882 while i < len && bytes[i] != b'(' {
883 i += 1;
884 }
885 if i >= len {
886 return None;
887 }
888 i += 1; // Skip first '('
889
890 // Skip to second '(' for the point list
891 while i < len && bytes[i] != b'(' {
892 i += 1;
893 }
894 if i >= len {
895 return None;
896 }
897 i += 1; // Skip second '('
898
899 // Parse point IDs and fetch coordinates (with caching)
900 // CRITICAL: Track expected count to ensure all points are resolved
901 let mut coords = Vec::with_capacity(8);
902 let mut expected_count = 0u32;
903
904 while i < len {
905 // Skip whitespace and commas
906 while i < len
907 && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
908 {
909 i += 1;
910 }
911
912 if i >= len || bytes[i] == b')' {
913 break;
914 }
915
916 // Expect '#' followed by number
917 if bytes[i] == b'#' {
918 i += 1;
919 let id_start = i;
920 while i < len && bytes[i].is_ascii_digit() {
921 i += 1;
922 }
923 if i > id_start {
924 expected_count += 1; // Count every point ID we encounter
925
926 // Shared checked accumulator (#3421): `expected_count`
927 // was already bumped, so a refused id here trips the
928 // `coords.len() == expected_count` check below, same as
929 // any other missing point.
930 if let Some(point_id) =
931 crate::express_id::parse_express_id(&bytes[id_start..i])
932 {
933 // Check cache first
934 if let Some(&coord) = self.point_cache.get(&point_id) {
935 self.point_cache_hits += 1;
936 coords.push(coord);
937 } else {
938 // Not in cache - parse and cache
939 if let Some((pt_start, pt_end)) = index.lookup(point_id) {
940 if let Some(coord) =
941 parse_cartesian_point_inline(&bytes_full[pt_start..pt_end])
942 {
943 self.point_cache_misses += 1;
944 self.point_cache.insert(point_id, coord);
945 coords.push(coord);
946 }
947 }
948 }
949 }
950 }
951 } else {
952 i += 1; // Skip unknown character
953 }
954 }
955
956 // CRITICAL: Return None if ANY point failed to resolve
957 // This matches the old behavior where missing points invalidated the whole polygon
958 if coords.len() >= 3 && coords.len() == expected_count as usize {
959 Some(coords)
960 } else {
961 None
962 }
963 }
964}
965
966/// Parse cartesian point coordinates inline from raw bytes
967/// Used by get_polyloop_coords_fast for maximum performance
968#[inline]
969fn parse_cartesian_point_inline(bytes: &[u8]) -> Option<(f64, f64, f64)> {
970 let len = bytes.len();
971 let mut i = 0;
972
973 // Skip to first '(' after '='
974 while i < len && bytes[i] != b'(' {
975 i += 1;
976 }
977 if i >= len {
978 return None;
979 }
980 i += 1; // Skip first '('
981
982 // Skip to second '(' for the coordinate list
983 while i < len && bytes[i] != b'(' {
984 i += 1;
985 }
986 if i >= len {
987 return None;
988 }
989 i += 1; // Skip second '('
990
991 // Parse x coordinate
992 let x = parse_float_inline(&bytes[i..], &mut i)?;
993
994 // Parse y coordinate
995 let y = parse_float_inline(&bytes[i..], &mut i)?;
996
997 // Parse z coordinate (optional for 2D points, default to 0)
998 let z = parse_float_inline(&bytes[i..], &mut i).unwrap_or(0.0);
999
1000 Some((x, y, z))
1001}
1002
1003/// Parse float inline - simpler version for batch coordinate extraction
1004#[inline]
1005fn parse_float_inline(bytes: &[u8], offset: &mut usize) -> Option<f64> {
1006 let len = bytes.len();
1007 let mut i = 0;
1008
1009 // Skip whitespace and commas
1010 while i < len
1011 && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
1012 {
1013 i += 1;
1014 }
1015
1016 if i >= len || bytes[i] == b')' {
1017 return None;
1018 }
1019
1020 // Parse float using fast_float
1021 match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
1022 Ok((value, consumed)) if consumed > 0 => {
1023 *offset += i + consumed;
1024 Some(value)
1025 }
1026 _ => None,
1027 }
1028}
1029
1030/// Parse next float from bytes, advancing position past it
1031#[inline]
1032fn parse_next_float(bytes: &[u8], offset: &mut usize) -> Option<f64> {
1033 let len = bytes.len();
1034 let mut i = 0;
1035
1036 // Skip whitespace and commas
1037 while i < len
1038 && (bytes[i] == b' ' || bytes[i] == b',' || bytes[i] == b'\n' || bytes[i] == b'\r')
1039 {
1040 i += 1;
1041 }
1042
1043 if i >= len || bytes[i] == b')' {
1044 return None;
1045 }
1046
1047 // Parse float using fast_float
1048 match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
1049 Ok((value, consumed)) if consumed > 0 => {
1050 *offset += i + consumed;
1051 Some(value)
1052 }
1053 _ => None,
1054 }
1055}
1056
1057#[cfg(test)]
1058mod decoder_tests;