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