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