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;