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::{is_step_space, parse_entity, report_scan_diagnostics, EntityScanner};
12use crate::schema_gen::{AttributeValue, DecodedEntity};
13use rustc_hash::FxHashMap;
14use std::sync::Arc;
15
16#[path = "decoder/caches.rs"]
17mod caches;
18#[path = "decoder/fast_buffers.rs"]
19mod fast_buffers;
20#[path = "decoder/styled_items.rs"]
21mod styled_items;
22pub(crate) type StyledItemIndexResult = std::result::Result<FxHashMap<u32, Vec<u32>>, String>;
23
24/// Pre-built entity index type
25pub type EntityIndex = FxHashMap<u32, (usize, usize)>;
26
27/// Build an entity index from content.
28///
29/// This intentionally shares `EntityScanner`'s HEADER skipping, quoted-string
30/// semantics, and its #3395 refusal of instance names wider than `u32` (reported
31/// here, not handed back as a quietly short index), so scan and lookup agree.
32#[inline]
33pub fn build_entity_index<T>(content: &T) -> EntityIndex
34where
35 T: AsRef<[u8]> + ?Sized,
36{
37 let content = content.as_ref();
38 let estimated_entities = content.len() / 50;
39 let mut index = FxHashMap::with_capacity_and_hasher(estimated_entities, Default::default());
40 let mut scanner = EntityScanner::new(content);
41 while let Some((id, _type_name, start, end)) = scanner.next_entity() {
42 index.insert(id, (start, end));
43 }
44 report_scan_diagnostics(scanner.skipped_oversized_ids(), scanner.malformed_record_start().is_some());
45 index
46}
47
48/// Entity decoder for lazy parsing from raw IFC bytes.
49///
50/// String attributes are decoded lossily when tokens become `AttributeValue`s;
51/// structural scanning and byte offsets always use the original source bytes.
52pub struct EntityDecoder<'a> {
53 content: &'a [u8],
54 /// Cache of decoded entities (entity_id -> `Arc<DecodedEntity>`)
55 /// Using Arc avoids expensive clones on cache hits
56 cache: FxHashMap<u32, Arc<DecodedEntity>>,
57 /// Entity offsets (entity_id -> (start, end)): legacy `FxHashMap` or compact
58 /// columnar index. `pub(crate)` for `crate::columnar_index`'s constructors.
59 pub(crate) entity_index: Option<EntityIndexStore>,
60 /// Cache of cartesian point coordinates for FacetedBrep optimization
61 /// Only populated when using get_polyloop_coords_cached
62 point_cache: FxHashMap<u32, (f64, f64, f64)>,
63 /// Number of `point_cache` hits served by [`Self::get_polyloop_coords_cached`].
64 /// Pure instrumentation (never affects output); read via
65 /// [`Self::point_cache_stats`] to prove cross-element memoization fires when
66 /// the cache is hoisted across a worker's parts.
67 point_cache_hits: u64,
68 /// Number of `point_cache` misses (a CartesianPoint parsed for the first time)
69 /// served by [`Self::get_polyloop_coords_cached`].
70 point_cache_misses: u64,
71 /// Lazy-cached multiplier converting file plane-angle units to radians.
72 /// Populated on first call to [`Self::plane_angle_to_radians`]. Spec
73 /// default (and Renga-style files) is 1.0 (RADIAN); degree-unit files
74 /// resolve to π/180.
75 plane_angle_to_radians_cache: Option<f64>,
76 /// Lazy-cached multiplier converting file length units to metres.
77 /// Populated on first call to [`Self::length_unit_scale`]. 1.0 for metre
78 /// files, 0.001 for millimetre files, etc. Used to express absolute
79 /// tolerances (e.g. curve-tessellation chord deviation) in file units.
80 length_unit_scale_cache: Option<f64>,
81 /// Per-worker memo of resolved placement world transforms, keyed by the
82 /// IfcObjectPlacement entity id and stored as an opaque column-major
83 /// `[f64; 16]` (core must not depend on nalgebra; the geometry crate owns
84 /// the Matrix4 <-> array round-trip). Populated by the geometry router's
85 /// placement resolver, which recursively composes `parent * local` down the
86 /// IfcLocalPlacement chain. For a well-formed acyclic placement DAG the
87 /// resolved transform is a pure function of the placement id, so reusing it
88 /// across the elements a single worker meshes is byte-identical (speed
89 /// only): storey/building placements shared by thousands of elements are
90 /// composed once per worker instead of once per element. Hoisted across a
91 /// worker's parts exactly like `point_cache`; see
92 /// [`Self::take_placement_transform_cache`].
93 placement_transform_cache: FxHashMap<u32, [f64; 16]>,
94 styled_item_index: std::sync::OnceLock<StyledItemIndexResult>,
95}
96
97impl<'a> EntityDecoder<'a> {
98 /// Create new decoder
99 pub fn new<T>(content: &'a T) -> Self
100 where
101 T: AsRef<[u8]> + ?Sized,
102 {
103 let content = content.as_ref();
104 Self {
105 content,
106 cache: FxHashMap::default(),
107 entity_index: None,
108 point_cache: FxHashMap::default(),
109 point_cache_hits: 0,
110 point_cache_misses: 0,
111 plane_angle_to_radians_cache: None,
112 length_unit_scale_cache: None,
113 placement_transform_cache: FxHashMap::default(),
114 styled_item_index: std::sync::OnceLock::new(),
115 }
116 }
117
118 /// Create decoder with pre-built index (faster for repeated lookups)
119 pub fn with_index<T>(content: &'a T, index: EntityIndex) -> Self
120 where
121 T: AsRef<[u8]> + ?Sized,
122 {
123 let content = content.as_ref();
124 Self {
125 content,
126 cache: FxHashMap::default(),
127 entity_index: Some(EntityIndexStore::Hash(Arc::new(index))),
128 point_cache: FxHashMap::default(),
129 point_cache_hits: 0,
130 point_cache_misses: 0,
131 plane_angle_to_radians_cache: None,
132 length_unit_scale_cache: None,
133 placement_transform_cache: FxHashMap::default(),
134 styled_item_index: std::sync::OnceLock::new(),
135 }
136 }
137
138 /// Create decoder with shared Arc index (for parallel processing)
139 pub fn with_arc_index<T>(content: &'a T, index: Arc<EntityIndex>) -> Self
140 where
141 T: AsRef<[u8]> + ?Sized,
142 {
143 let content = content.as_ref();
144 Self {
145 content,
146 cache: FxHashMap::default(),
147 entity_index: Some(EntityIndexStore::Hash(index)),
148 point_cache: FxHashMap::default(),
149 point_cache_hits: 0,
150 point_cache_misses: 0,
151 plane_angle_to_radians_cache: None,
152 length_unit_scale_cache: None,
153 placement_transform_cache: FxHashMap::default(),
154 styled_item_index: std::sync::OnceLock::new(),
155 }
156 }
157
158 /// Install a pre-built index into this decoder. Used when the caller already
159 /// built the index during another pass (e.g. the processor scan loop) so the
160 /// decoder does not lazily rebuild it via `build_index`. Set it before any
161 /// `decode_by_id`/ref-resolving call; afterwards `build_index` no-ops.
162 pub fn set_entity_index(&mut self, index: Arc<EntityIndex>) {
163 self.entity_index = Some(EntityIndexStore::Hash(index));
164 }
165
166 /// Build entity index for O(1) lookups
167 /// This scans the file once and maps entity IDs to byte offsets
168 fn build_index(&mut self) {
169 if self.entity_index.is_some() {
170 return; // Already built
171 }
172 self.entity_index = Some(EntityIndexStore::Hash(Arc::new(build_entity_index(self.content))));
173 }
174
175 /// Decode entity at byte offset
176 /// Returns cached entity if already decoded
177 ///
178 /// Validates the `(start, end)` span against `self.content.len()` before
179 /// slicing. Out-of-range or inverted spans return `Error::parse` instead
180 /// of panicking — callers (e.g. `decode_and_cache`, `decode_at_with_id`,
181 /// the streaming pre-pass shard mergers) hand us spans derived from
182 /// untrusted/streamed entity-index data, and a malformed span must not
183 /// take down the whole worker.
184 #[inline]
185 pub fn decode_at(&mut self, start: usize, end: usize) -> Result<DecodedEntity> {
186 let (id, ifc_type, tokens) = self.parse_at(start, end, "decode_at")?;
187
188 // Check cache first - return clone of inner DecodedEntity
189 if let Some(entity_arc) = self.cache.get(&id) {
190 return Ok(entity_arc.as_ref().clone());
191 }
192
193 // Convert tokens to AttributeValues
194 let attributes = tokens
195 .iter()
196 .map(|token| AttributeValue::from_token(token))
197 .collect();
198
199 let entity = DecodedEntity::new(id, ifc_type, attributes);
200 self.cache.insert(id, Arc::new(entity.clone()));
201 Ok(entity)
202 }
203
204 /// Decode the entity in `[start, end)` **without** touching the cache.
205 ///
206 /// [`decode_at`](Self::decode_at) memoizes every entity it parses, which is the
207 /// right trade-off for geometry sub-tree walks (the same points/profiles are
208 /// revisited many times). For a single linear pass over *every* entity in a
209 /// large model — tens of millions of rows — that cache grows without bound and
210 /// dominates memory. This variant parses and returns the entity but never
211 /// inserts it, so a streaming walk stays O(1) in entity count. The caller owns
212 /// the result; for identical bytes it yields an identical [`DecodedEntity`] to
213 /// `decode_at`, only without the cache side effect.
214 pub fn decode_at_uncached(&self, start: usize, end: usize) -> Result<DecodedEntity> {
215 let (id, ifc_type, tokens) = self.parse_at(start, end, "decode_at_uncached")?;
216 let attributes = tokens
217 .iter()
218 .map(|token| AttributeValue::from_token(token))
219 .collect();
220 Ok(DecodedEntity::new(id, ifc_type, attributes))
221 }
222
223 /// Shared full-record validation and existing error context. The returned
224 /// tokens borrow immutable source bytes, not the decoder's entity cache.
225 #[inline]
226 fn parse_at(
227 &self, start: usize, end: usize, method: &str,
228 ) -> Result<(u32, crate::IfcType, Vec<crate::parser::Token<'a>>)> {
229 let content_len = self.content.len();
230 if start > end || end > content_len {
231 return Err(Error::parse(
232 0,
233 format!(
234 "{method}: invalid byte span ({}, {}) for content length {}",
235 start, end, content_len,
236 ),
237 ));
238 }
239 let line = &self.content[start..end];
240 parse_entity(line).map_err(|e| {
241 // Add bounded, lossy debug info without requiring the source to be UTF-8.
242 let cut = line.len().min(100);
243 Error::parse(
244 0,
245 format!(
246 "Failed to parse entity: {:?}, input: {:?}",
247 e,
248 String::from_utf8_lossy(&line[..cut])
249 ),
250 )
251 })
252 }
253
254 /// Decode entity at byte offset with known ID (faster - checks cache before parsing)
255 /// Use this when the scanner provides the entity ID to avoid re-parsing cached entities
256 #[inline]
257 pub fn decode_at_with_id(
258 &mut self,
259 id: u32,
260 start: usize,
261 end: usize,
262 ) -> Result<DecodedEntity> {
263 // Check cache first - avoid parsing if already decoded
264 if let Some(entity_arc) = self.cache.get(&id) {
265 return Ok(entity_arc.as_ref().clone());
266 }
267
268 // Not in cache, parse and cache
269 self.decode_at(start, end)
270 }
271
272 /// Decode entity by ID - O(1) lookup using entity index
273 #[inline]
274 pub fn decode_by_id(&mut self, entity_id: u32) -> Result<DecodedEntity> {
275 // Check cache first - return clone of inner DecodedEntity
276 if let Some(entity_arc) = self.cache.get(&entity_id) {
277 return Ok(entity_arc.as_ref().clone());
278 }
279
280 // Build index if not already built
281 self.build_index();
282
283 // O(1) lookup in index
284 let (start, end) = self
285 .entity_index
286 .as_ref()
287 .and_then(|idx| idx.lookup(entity_id))
288 .ok_or_else(|| Error::parse(0, format!("Entity #{} not found", entity_id)))?;
289
290 self.decode_at(start, end)
291 }
292
293 /// Multiplier that converts file plane-angle units to radians.
294 ///
295 /// Lazy-resolved on first call by scanning for IFCPROJECT and reading
296 /// its IFCUNITASSIGNMENT. Cached for subsequent calls. Returns `1.0`
297 /// when no plane-angle unit is declared (IFC spec default = RADIAN).
298 ///
299 /// Use this at curve-sampling time wherever an `IfcParameterValue` is
300 /// interpreted as an angle (IfcCircle / IfcEllipse trim parameters).
301 /// Without it, `value.to_radians()` is correct only for DEGREE files
302 /// and silently shrinks arcs on RADIAN files (issue #820).
303 pub fn plane_angle_to_radians(&mut self) -> f64 {
304 if let Some(cached) = self.plane_angle_to_radians_cache {
305 return cached;
306 }
307
308 let mut scanner = crate::parser::EntityScanner::new(self.content);
309 let mut project_id: Option<u32> = None;
310 while let Some((id, type_name, _, _)) = scanner.next_entity() {
311 // #4497: the keyword is the raw scanner slice; see `parser::keyword`.
312 if crate::parser::keyword_eq(type_name, "IFCPROJECT") {
313 project_id = Some(id);
314 break;
315 }
316 }
317
318 let scale = match project_id {
319 Some(pid) => crate::units::extract_plane_angle_to_radians(self, pid).unwrap_or(1.0),
320 None => 1.0,
321 };
322 self.plane_angle_to_radians_cache = Some(scale);
323 scale
324 }
325
326 /// Multiplier that converts file length units to metres (1.0 for metre
327 /// files, 0.001 for millimetre files, …). Lazy-resolved on first call by
328 /// scanning for IFCPROJECT and reading its IFCUNITASSIGNMENT, then cached.
329 /// Returns `1.0` when no length unit is declared.
330 ///
331 /// Use this to express an *absolute* metric tolerance in file units —
332 /// e.g. a curve-tessellation chord-deviation budget that stays constant in
333 /// millimetres whether the file is authored in mm or m.
334 pub fn length_unit_scale(&mut self) -> f64 {
335 if let Some(cached) = self.length_unit_scale_cache {
336 return cached;
337 }
338
339 let mut scanner = crate::parser::EntityScanner::new(self.content);
340 let mut project_id: Option<u32> = None;
341 while let Some((id, type_name, _, _)) = scanner.next_entity() {
342 if crate::parser::keyword_eq(type_name, "IFCPROJECT") {
343 project_id = Some(id);
344 break;
345 }
346 }
347
348 let scale = match project_id {
349 Some(pid) => crate::units::extract_length_unit_scale(self, pid).unwrap_or(1.0),
350 None => 1.0,
351 };
352 self.length_unit_scale_cache = Some(scale);
353 scale
354 }
355
356 /// Pre-seed the unit-scale caches so [`Self::length_unit_scale`] and
357 /// [`Self::plane_angle_to_radians`] return immediately without the full-file
358 /// `IFCPROJECT` scan.
359 ///
360 /// Both lazy resolvers walk the whole DATA section to locate the (singleton)
361 /// `IFCPROJECT`. That scan is `O(file size)` and `IFCPROJECT` legally sits
362 /// anywhere — IfcOpenShell emits it near the *end*, so on a large model the
363 /// scan touches tens of MB. The cache is per-decoder, and the parallel
364 /// geometry pipeline builds a fresh decoder per element, so without seeding
365 /// every arc-bearing element re-pays the scan (≈135 ms each on a 75 MB
366 /// file). The orchestrator resolves both scales once on a warm shared
367 /// decoder and seeds each worker decoder here.
368 pub fn seed_unit_scales(&mut self, length_unit_scale: f64, plane_angle_to_radians: f64) {
369 self.length_unit_scale_cache = Some(length_unit_scale);
370 self.plane_angle_to_radians_cache = Some(plane_angle_to_radians);
371 }
372
373 /// Resolve entity reference (follow #ID)
374 /// Returns None for null/derived values
375 #[inline]
376 pub fn resolve_ref(&mut self, attr: &AttributeValue) -> Result<Option<DecodedEntity>> {
377 match attr.as_entity_ref() {
378 Some(id) => Ok(Some(self.decode_by_id(id)?)),
379 None => Ok(None),
380 }
381 }
382
383 /// Resolve list of entity references
384 pub fn resolve_ref_list(&mut self, attr: &AttributeValue) -> Result<Vec<DecodedEntity>> {
385 let list = attr
386 .as_list()
387 .ok_or_else(|| Error::parse(0, "Expected list".to_string()))?;
388
389 let mut entities = Vec::with_capacity(list.len());
390 for item in list {
391 if let Some(id) = item.as_entity_ref() {
392 entities.push(self.decode_by_id(id)?);
393 }
394 }
395 Ok(entities)
396 }
397
398 /// Get cached entity (without decoding)
399 pub fn get_cached(&self, entity_id: u32) -> Option<DecodedEntity> {
400 self.cache.get(&entity_id).map(|arc| arc.as_ref().clone())
401 }
402
403 /// Reserve cache capacity to avoid HashMap resizing during processing.
404 /// For a 487 MB file with 208 K building elements, the cache can grow to
405 /// 300 K+ entries (elements + representation chains + placements).
406 /// Pre-allocating avoids ~6 resize-and-rehash operations that each copy
407 /// all entries, reducing both peak memory spikes and timing variance.
408 pub fn reserve_cache(&mut self, additional: usize) {
409 self.cache.reserve(additional);
410 }
411
412 /// Inject a pre-warmed Arc-shared cache into this decoder's local cache.
413 ///
414 /// Used by the de-normalized parallel path: a serial pre-pass builds a
415 /// shared `Arc<FxHashMap<u32, Arc<DecodedEntity>>>` containing all
416 /// entities reachable from the jobs. Each rayon task then injects
417 /// that shared cache into its own decoder via this method, so the
418 /// per-task hot path hits in-WASM-heap Arc handles instead of
419 /// SAB-imported atomic memory.
420 ///
421 /// Cost: one Arc::clone per cached entry (atomic refcount bump).
422 /// For a typical 100K-entry cache × 9 rayon tasks = 900K atomics
423 /// total, ~90 ms wall (incurred ONCE at task setup; the parallel
424 /// hot path then runs lock-free against the populated cache).
425 pub fn inject_shared_cache(&mut self, shared: &FxHashMap<u32, Arc<DecodedEntity>>) {
426 self.cache.reserve(shared.len());
427 for (&id, entity) in shared.iter() {
428 self.cache.insert(id, Arc::clone(entity));
429 }
430 }
431
432 /// Decode + cache without returning. Used by the pre-warm pass to
433 /// populate a shared cache. Returns the cached Arc so the caller
434 /// can chase references without re-decoding.
435 pub fn decode_and_cache(
436 &mut self,
437 id: u32,
438 start: usize,
439 end: usize,
440 ) -> Result<Arc<DecodedEntity>> {
441 if let Some(arc) = self.cache.get(&id) {
442 return Ok(Arc::clone(arc));
443 }
444 let _ = self.decode_at(start, end)?;
445 Ok(Arc::clone(self.cache.get(&id).ok_or_else(|| {
446 Error::parse(0, "decode_at didn't populate cache".to_string())
447 })?))
448 }
449 /// Get raw bytes for an entity (for direct/fast parsing)
450 /// Returns the full entity line including type and attributes
451 #[inline]
452 pub fn get_raw_bytes(&mut self, entity_id: u32) -> Option<&'a [u8]> {
453 self.build_index();
454 let (start, end) = self.entity_index.as_ref()?.lookup(entity_id)?;
455 // An installed index's spans are caller data never checked against
456 // these bytes (#4697); a span outside them is absent, not a panic.
457 self.content.get(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 && is_step_space(bytes[i]) {
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 PolyLoop point IDs directly from raw bytes
507 /// Bypasses full entity decoding for BREP optimization
508 /// Returns list of entity IDs for CartesianPoints
509 ///
510 /// `None` when ANY `#<digits>` in the list is unrepresentable, not just when
511 /// none of them is. A reference above `u32::MAX` is refused rather than
512 /// wrapped (#3421), and dropping only that vertex would hand the caller a
513 /// polygon one corner SHORTER than the file's: a different face, meshed
514 /// and rendered as if it were the authored one.
515 ///
516 /// This accessor sees ids, not entities, so a reference that fits `u32`
517 /// but names no record in the file is returned as written. Whether that
518 /// id RESOLVES is the caller's half of the same rule, and every caller
519 /// must refuse the whole loop on a miss rather than skip the point
520 /// (`processors::helpers::extract_loop_points_by_id` and
521 /// `processors::surface` both do). The coordinate sibling
522 /// [`Self::get_polyloop_coords_cached_into`] enforces both halves itself
523 /// because it resolves the points too.
524 #[inline]
525 pub fn get_polyloop_point_ids_fast(&mut self, entity_id: u32) -> Option<Vec<u32>> {
526 let bytes = self.get_raw_bytes(entity_id)?;
527
528 // IFCPOLYLOOP((#id1,#id2,#id3,...));
529 let mut i = 0;
530 let len = bytes.len();
531
532 // Skip to first '(' after '='
533 while i < len && bytes[i] != b'(' {
534 i += 1;
535 }
536 if i >= len {
537 return None;
538 }
539 i += 1; // Skip first '('
540
541 // Skip to second '(' for the point list
542 while i < len && bytes[i] != b'(' {
543 i += 1;
544 }
545 if i >= len {
546 return None;
547 }
548 i += 1; // Skip second '('
549
550 // Parse point IDs
551 let mut point_ids = Vec::with_capacity(8); // Most faces have 3-8 vertices
552
553 while i < len {
554 // Skip whitespace and commas
555 while i < len
556 && (bytes[i] == b',' || is_step_space(bytes[i]))
557 {
558 i += 1;
559 }
560
561 if i >= len || bytes[i] == b')' {
562 break;
563 }
564
565 // Expect '#' followed by number
566 if bytes[i] == b'#' {
567 i += 1;
568 // One digit walk, shared with the scanner (#3395). A ref that
569 // was met but does not resolve refuses the whole loop (#3421):
570 // a shorter polygon is a different face, not a smaller error.
571 let (digits, id) = crate::express_id::parse_express_id_prefix(&bytes[i..]);
572 i += digits;
573 if digits > 0 {
574 point_ids.push(id?);
575 }
576 } else {
577 i += 1; // Skip unknown character
578 }
579 }
580
581 if point_ids.is_empty() {
582 None
583 } else {
584 Some(point_ids)
585 }
586 }
587
588 /// Fast extraction of CartesianPoint coordinates directly from raw bytes
589 /// Bypasses full entity decoding for ~3x speedup on BREP-heavy files
590 /// Returns (x, y, z) as f64 tuple
591 #[inline]
592 pub fn get_cartesian_point_fast(&mut self, entity_id: u32) -> Option<(f64, f64, f64)> {
593 let bytes = self.get_raw_bytes(entity_id)?;
594
595 // Find opening paren for coordinates: IFCCARTESIANPOINT((x,y,z));
596 let mut i = 0;
597 let len = bytes.len();
598
599 // Skip to first '(' after '='
600 while i < len && bytes[i] != b'(' {
601 i += 1;
602 }
603 if i >= len {
604 return None;
605 }
606 i += 1; // Skip first '('
607
608 // Skip to second '(' for the coordinate list
609 while i < len && bytes[i] != b'(' {
610 i += 1;
611 }
612 if i >= len {
613 return None;
614 }
615 i += 1; // Skip second '('
616
617 // Parse x coordinate
618 let x = parse_next_float(&bytes[i..], &mut i)?;
619
620 // Parse y coordinate
621 let y = parse_next_float(&bytes[i..], &mut i)?;
622
623 // Parse z coordinate (optional for 2D points, default to 0)
624 let z = parse_next_float(&bytes[i..], &mut i).unwrap_or(0.0);
625
626 Some((x, y, z))
627 }
628
629 /// Fast extraction of FaceBound info directly from raw bytes
630 /// Returns (loop_id, orientation, is_outer_bound)
631 /// Bypasses full entity decoding for BREP optimization
632 #[inline]
633 pub fn get_face_bound_fast(&mut self, entity_id: u32) -> Option<(u32, bool, bool)> {
634 let bytes = self.get_raw_bytes(entity_id)?;
635 let len = bytes.len();
636
637 // Find '=' to locate start of type name, and '(' for end
638 let mut eq_pos = 0;
639 while eq_pos < len && bytes[eq_pos] != b'=' {
640 eq_pos += 1;
641 }
642 if eq_pos >= len {
643 return None;
644 }
645
646 // Check if this is an outer bound by looking for "OUTER" in the type name
647 // IFCFACEOUTERBOUND vs IFCFACEBOUND
648 // The type name is between '=' and '('
649 let mut is_outer = false;
650 let mut i = eq_pos + 1;
651 // Look for "OUTER" pattern (must check for the full word, not just 'O')
652 while i + 4 < len && bytes[i] != b'(' {
653 if bytes[i] == b'O'
654 && bytes[i + 1] == b'U'
655 && bytes[i + 2] == b'T'
656 && bytes[i + 3] == b'E'
657 && bytes[i + 4] == b'R'
658 {
659 is_outer = true;
660 break;
661 }
662 i += 1;
663 }
664 // Continue to find the '(' if we haven't already
665 while i < len && bytes[i] != b'(' {
666 i += 1;
667 }
668 if i >= len {
669 return None;
670 }
671
672 i += 1; // Skip first '('
673
674 // Skip whitespace
675 while i < len && is_step_space(bytes[i]) {
676 i += 1;
677 }
678
679 // Expect '#' for loop entity ref
680 if i >= len || bytes[i] != b'#' {
681 return None;
682 }
683 i += 1;
684
685 // Parse loop ID
686 let start = i;
687 while i < len && bytes[i].is_ascii_digit() {
688 i += 1;
689 }
690 if i <= start {
691 return None;
692 }
693 // Shared checked accumulator (#3421): an oversized loop ref fails
694 // this lookup outright, like any other unresolvable reference,
695 // instead of resolving to the wrong loop.
696 let loop_id = crate::express_id::parse_express_id(&bytes[start..i])?;
697
698 // Find orientation after comma - default to true (.T.)
699 // Skip to comma
700 while i < len && bytes[i] != b',' {
701 i += 1;
702 }
703 i += 1; // Skip comma
704
705 // Skip whitespace
706 while i < len && is_step_space(bytes[i]) {
707 i += 1;
708 }
709
710 // Check for .F. (false) or .T. (true)
711 let orientation = if i + 2 < len && bytes[i] == b'.' && bytes[i + 2] == b'.' {
712 bytes[i + 1] != b'F'
713 } else {
714 true // Default to true
715 };
716
717 Some((loop_id, orientation, is_outer))
718 }
719}
720
721/// Parse cartesian point coordinates inline from raw bytes
722/// Used by the cached polyloop coordinate readers for maximum performance
723#[inline]
724fn parse_cartesian_point_inline(bytes: &[u8]) -> Option<(f64, f64, f64)> {
725 let len = bytes.len();
726 let mut i = 0;
727
728 // Skip to first '(' after '='
729 while i < len && bytes[i] != b'(' {
730 i += 1;
731 }
732 if i >= len {
733 return None;
734 }
735 i += 1; // Skip first '('
736
737 // Skip to second '(' for the coordinate list
738 while i < len && bytes[i] != b'(' {
739 i += 1;
740 }
741 if i >= len {
742 return None;
743 }
744 i += 1; // Skip second '('
745
746 // Parse x coordinate
747 let x = parse_float_inline(&bytes[i..], &mut i)?;
748
749 // Parse y coordinate
750 let y = parse_float_inline(&bytes[i..], &mut i)?;
751
752 // Parse z coordinate (optional for 2D points, default to 0)
753 let z = parse_float_inline(&bytes[i..], &mut i).unwrap_or(0.0);
754
755 Some((x, y, z))
756}
757
758/// Parse float inline - simpler version for batch coordinate extraction
759#[inline]
760fn parse_float_inline(bytes: &[u8], offset: &mut usize) -> Option<f64> {
761 let len = bytes.len();
762 let mut i = 0;
763
764 // Skip whitespace and commas
765 while i < len
766 && (bytes[i] == b',' || is_step_space(bytes[i]))
767 {
768 i += 1;
769 }
770
771 if i >= len || bytes[i] == b')' {
772 return None;
773 }
774
775 // Parse float using fast_float
776 match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
777 Ok((value, consumed)) if consumed > 0 => {
778 *offset += i + consumed;
779 Some(value)
780 }
781 _ => None,
782 }
783}
784
785/// Parse next float from bytes, advancing position past it
786#[inline]
787fn parse_next_float(bytes: &[u8], offset: &mut usize) -> Option<f64> {
788 let len = bytes.len();
789 let mut i = 0;
790
791 // Skip whitespace and commas
792 while i < len
793 && (bytes[i] == b',' || is_step_space(bytes[i]))
794 {
795 i += 1;
796 }
797
798 if i >= len || bytes[i] == b')' {
799 return None;
800 }
801
802 // Parse float using fast_float
803 match fast_float2::parse_partial::<f64, _>(&bytes[i..]) {
804 Ok((value, consumed)) if consumed > 0 => {
805 *offset += i + consumed;
806 Some(value)
807 }
808 _ => None,
809 }
810}
811
812#[cfg(test)]
813mod decoder_tests;