ifc_lite_core/
streaming.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//! Streaming IFC Parser
6//!
7//! Progressive parsing with event callbacks for real-time processing.
8
9use crate::generated::IfcType;
10use crate::parser::{report_scan_diagnostics, EntityScanner};
11use futures_core::Stream;
12use futures_util::stream;
13use std::pin::Pin;
14
15/// Parse event types emitted during streaming parse
16#[derive(Debug, Clone)]
17pub enum ParseEvent {
18    /// Parsing started
19    Started {
20        /// Total file size in bytes
21        file_size: usize,
22        /// Timestamp when parsing started
23        timestamp: f64,
24    },
25
26    /// Entity discovered during scanning
27    EntityScanned {
28        /// Entity ID
29        id: u32,
30        /// Entity type
31        ifc_type: IfcType,
32        /// Position in file
33        position: usize,
34    },
35
36    /// Geometry processing completed for an entity
37    GeometryReady {
38        /// Entity ID
39        id: u32,
40        /// Vertex count
41        vertex_count: usize,
42        /// Triangle count
43        triangle_count: usize,
44    },
45
46    /// Progress update
47    Progress {
48        /// Current phase (e.g., "Scanning", "Parsing", "Processing geometry")
49        phase: String,
50        /// Progress percentage (0-100)
51        percent: f32,
52        /// Entities processed so far
53        entities_processed: usize,
54        /// Total entities
55        total_entities: usize,
56    },
57
58    /// Parsing completed
59    Completed {
60        /// Total duration in milliseconds
61        duration_ms: f64,
62        /// Total entities parsed
63        entity_count: usize,
64        /// Total triangles generated
65        triangle_count: usize,
66    },
67
68    /// Error occurred
69    Error {
70        /// Error message
71        message: String,
72        /// Position where error occurred
73        position: Option<usize>,
74    },
75}
76
77/// Streaming parser configuration
78#[derive(Debug, Clone)]
79pub struct StreamConfig {
80    /// Yield progress events every N entities
81    pub progress_interval: usize,
82    /// Skip these entity types during scanning
83    pub skip_types: Vec<IfcType>,
84    /// Only process these entity types (if specified)
85    pub only_types: Option<Vec<IfcType>>,
86}
87
88impl Default for StreamConfig {
89    fn default() -> Self {
90        Self {
91            progress_interval: 100,
92            skip_types: vec![
93                IfcType::IfcOwnerHistory,
94                IfcType::IfcPerson,
95                IfcType::IfcOrganization,
96                IfcType::IfcApplication,
97            ],
98            only_types: None,
99        }
100    }
101}
102
103/// Stream IFC file parsing with events
104pub fn parse_stream<T>(
105    content: &T,
106    config: StreamConfig,
107) -> Pin<Box<dyn Stream<Item = ParseEvent> + '_>>
108where
109    T: AsRef<[u8]> + ?Sized,
110{
111    let content = content.as_ref();
112    Box::pin(stream::unfold(
113        ParserState::new(content, config),
114        |mut state| async move { state.next_event().map(|event| (event, state)) },
115    ))
116}
117
118/// Internal parser state for streaming
119struct ParserState<'a> {
120    content: &'a [u8],
121    scanner: EntityScanner<'a>,
122    config: StreamConfig,
123    started: bool,
124    completed: bool,
125    start_time: f64,
126    entities_scanned: usize,
127    total_entities: usize,
128    triangles_generated: usize,
129    /// The `Progress` event owed by the `progress_interval`-th entity. A poll
130    /// returns one event, so the entity's own `EntityScanned` goes first and
131    /// this goes out on the next poll; returning it instead dropped the
132    /// entity from the stream (core review behind #4577, finding 5).
133    pending_progress: Option<ParseEvent>,
134    /// Whether [`Self::report_scan_once`] has already fired. The scan is
135    /// reported at whichever comes first: the end of the walk, or the state
136    /// being dropped under a consumer that stopped early.
137    scan_reported: bool,
138}
139
140impl<'a> ParserState<'a> {
141    fn new(content: &'a [u8], config: StreamConfig) -> Self {
142        Self {
143            content,
144            scanner: EntityScanner::new(content),
145            config,
146            started: false,
147            completed: false,
148            start_time: 0.0,
149            entities_scanned: 0,
150            total_entities: 0,
151            triangles_generated: 0,
152            pending_progress: None,
153            scan_reported: false,
154        }
155    }
156
157    /// Report what the scan refused or stopped on, at most once.
158    ///
159    /// `None` from `next_entity` does not only mean "the file ended": the
160    /// scanner skips a record whose instance name does not fit `u32` (#3395)
161    /// and stops the whole scan at a record with no terminator (#3695).
162    /// Either way this stream is short of what the file declares, so say so
163    /// — the same one-line call every other whole-file walk in this
164    /// workspace makes (`columnar_index.rs`, `decoder.rs`,
165    /// `processor/mod.rs`).
166    ///
167    /// Called from both the `Completed` branch and [`Drop`], because a
168    /// consumer is free to stop polling early (`take`, `break`, a dropped
169    /// future) and a refusal the scanner ALREADY recorded would otherwise
170    /// die with the stream — silence for exactly the reader who saw the
171    /// fewest entities. The flag makes the second call a no-op, so the two
172    /// paths cannot double-report (#3791).
173    fn report_scan_once(&mut self) {
174        if self.scan_reported {
175            return;
176        }
177        self.scan_reported = true;
178        report_scan_diagnostics(
179            self.scanner.skipped_oversized_ids(),
180            self.scanner.malformed_record_start().is_some(),
181        );
182    }
183
184    fn next_event(&mut self) -> Option<ParseEvent> {
185        // Stream has ended - CRITICAL: prevents infinite loop!
186        if self.completed {
187            return None;
188        }
189
190        // Progress owed by the previous poll's entity.
191        if let Some(progress) = self.pending_progress.take() {
192            return Some(progress);
193        }
194
195        // Emit Started event on first call
196        if !self.started {
197            self.started = true;
198            self.start_time = get_timestamp();
199            return Some(ParseEvent::Started {
200                file_size: self.content.len(),
201                timestamp: self.start_time,
202            });
203        }
204
205        // Scan for the next entity, skipping filtered types iteratively. A
206        // `return self.next_event()` per skipped entity recursed once per skip
207        // and could overflow the stack on a long run of skip-listed records.
208        loop {
209            let Some((id, type_name, start, _end)) = self.scanner.next_entity() else {
210                // No more entities - emit Completed event and end stream.
211                // `Completed` reads like a clean finish even when the scan
212                // came back short, so report before ending. See
213                // `report_scan_once` for what "short" covers and why the
214                // `Drop` path shares this call (#3791).
215                self.report_scan_once();
216                self.completed = true;
217                let duration_ms = get_timestamp() - self.start_time;
218                return Some(ParseEvent::Completed {
219                    duration_ms,
220                    entity_count: self.entities_scanned,
221                    triangle_count: self.triangles_generated,
222                });
223            };
224
225            // Parse entity type
226            let ifc_type = IfcType::from_str(type_name);
227
228            // Check if we should skip this type
229            if self.config.skip_types.contains(&ifc_type) {
230                continue; // Skip to next
231            }
232
233            // Check if we should only process specific types
234            if let Some(ref only_types) = self.config.only_types {
235                if !only_types.contains(&ifc_type) {
236                    continue; // Skip to next
237                }
238            }
239
240            self.entities_scanned += 1;
241
242            // Emit EntityScanned event
243            let event = ParseEvent::EntityScanned {
244                id,
245                ifc_type,
246                position: start,
247            };
248
249            // Queued, not returned: this poll's return is the entity.
250            if self
251                .entities_scanned
252                .is_multiple_of(self.config.progress_interval)
253            {
254                // Note: In a real implementation, we'd estimate total_entities
255                // by doing a quick pre-scan or using file size heuristics
256                self.pending_progress = Some(ParseEvent::Progress {
257                    phase: "Scanning entities".to_string(),
258                    percent: 0.0, // Would calculate based on position/file_size
259                    entities_processed: self.entities_scanned,
260                    total_entities: self.total_entities,
261                });
262            }
263
264            return Some(event);
265        }
266    }
267}
268
269/// Report the scan even when the consumer never asked for `Completed`.
270///
271/// `parse_stream` hands back a lazy stream, so "stop reading" is a normal,
272/// supported thing for a caller to do (`take`, a `break`, a cancelled task) —
273/// and it is the caller who then sees the FEWEST entities. Reporting only on
274/// the `Completed` branch would stay silent for exactly that reader while
275/// reporting to the one who read everything (#3791).
276impl Drop for ParserState<'_> {
277    fn drop(&mut self) {
278        self.report_scan_once();
279    }
280}
281
282/// Get current timestamp (mock implementation for native Rust)
283/// In WASM, this would use web_sys::window().performance().now()
284fn get_timestamp() -> f64 {
285    #[cfg(not(target_arch = "wasm32"))]
286    {
287        std::time::SystemTime::now()
288            .duration_since(std::time::UNIX_EPOCH)
289            .unwrap()
290            .as_secs_f64()
291            * 1000.0
292    }
293
294    #[cfg(target_arch = "wasm32")]
295    {
296        // In WASM, would use:
297        // web_sys::window().unwrap().performance().unwrap().now()
298        0.0
299    }
300}
301
302#[cfg(test)]
303#[path = "streaming_tests.rs"]
304mod streaming_tests;