Skip to main content

threecrate_algorithms/
streaming.rs

1//! Streaming point cloud processing pipeline.
2//!
3//! Enables out-of-core processing of arbitrarily large point clouds by reading
4//! and processing data in bounded-size chunks.  Only one chunk resides in RAM at
5//! a time; the pipeline accumulates lightweight per-chunk state (e.g. a voxel map)
6//! that is orders of magnitude smaller than the full dataset.
7//!
8//! # Architecture
9//!
10//! ```text
11//! Source iterator                Pipeline stage           Output
12//! (file / network / …)  ──►  process_chunk(&[T])  ──►  finalize()
13//!      chunk 0                  accumulate state
14//!      chunk 1                  accumulate state
15//!      …                        …
16//!      chunk N                  accumulate state
17//! ```
18//!
19//! For real-time sources (sensors, network streams), [`RealtimePipeline`] wraps
20//! any [`StreamingPipeline`] with a bounded input queue and a background worker
21//! thread.  When the queue is full the producer is automatically throttled
22//! (backpressure); [`RealtimePipeline::try_send`] provides a non-blocking
23//! variant that drops items instead of blocking.
24//!
25//! # Provided pipelines
26//!
27//! | Type | Description |
28//! |---|---|
29//! | [`StreamingVoxelFilter`] | Downsamples via a voxel grid; O(voxels) memory |
30//! | [`StreamingStatistics`] | Accumulates bounding-box and point count |
31//! | [`StreamingCollector`] | Collects all points (useful for testing) |
32//! | [`RealtimePipeline`] | Drives any pipeline from a background thread with bounded-queue backpressure |
33//!
34//! # Example
35//!
36//! ```rust
37//! use threecrate_algorithms::streaming::{
38//!     StreamingPipeline, StreamingVoxelFilter, StreamingVoxelFilterConfig, run_pipeline,
39//! };
40//! use threecrate_core::Point3f;
41//!
42//! let points: Vec<Result<Point3f, _>> = vec![
43//!     Ok(Point3f::new(0.0, 0.0, 0.0)),
44//!     Ok(Point3f::new(0.05, 0.0, 0.0)),
45//!     Ok(Point3f::new(1.0, 1.0, 1.0)),
46//! ];
47//! let mut filter = StreamingVoxelFilter::new(StreamingVoxelFilterConfig { voxel_size: 0.1 });
48//! let stats = run_pipeline(&mut filter, points.into_iter(), 2).unwrap();
49//! let cloud = filter.finalize().unwrap();
50//! println!("Downsampled to {} points", cloud.len());
51//! ```
52
53use std::collections::HashMap;
54use std::sync::atomic::{AtomicU64, Ordering};
55use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
56use std::sync::Arc;
57use std::thread::{self, JoinHandle};
58use std::time::Duration;
59use threecrate_core::{Error, Point3f, PointCloud, Result};
60
61// ---------------------------------------------------------------------------
62// Core trait
63// ---------------------------------------------------------------------------
64
65/// Trait for chunk-based streaming processors.
66///
67/// Implementations accumulate state across calls to [`process_chunk`] and
68/// produce a final result via [`finalize`].  The chunk size controls peak RAM
69/// usage: smaller chunks use less memory at the cost of more function-call
70/// overhead.
71///
72/// [`process_chunk`]: StreamingPipeline::process_chunk
73/// [`finalize`]: StreamingPipeline::finalize
74pub trait StreamingPipeline<T> {
75    /// The type produced after all chunks have been processed.
76    type Output;
77
78    /// Ingest one chunk of items.  Called repeatedly until the source is
79    /// exhausted.  `chunk` will never be empty.
80    fn process_chunk(&mut self, chunk: &[T]) -> Result<()>;
81
82    /// Consume the pipeline and return the accumulated output.
83    fn finalize(self) -> Result<Self::Output>;
84
85    /// Estimated number of bytes currently held by this pipeline stage.
86    /// Default returns `0`; override to expose real memory usage.
87    fn memory_bytes(&self) -> usize {
88        0
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Pipeline runner
94// ---------------------------------------------------------------------------
95
96/// Statistics reported by [`run_pipeline`].
97#[derive(Debug, Clone, Default)]
98pub struct RunStats {
99    /// Total number of successfully processed items.
100    pub items_processed: usize,
101    /// Number of chunks delivered to the pipeline.
102    pub chunks_processed: usize,
103    /// Number of items skipped due to errors (if `skip_errors` is set).
104    pub errors_skipped: usize,
105}
106
107/// Options for [`run_pipeline`].
108#[derive(Debug, Clone)]
109pub struct RunOptions {
110    /// If `true`, item-level errors from the source iterator are counted and
111    /// skipped rather than causing [`run_pipeline`] to return early.
112    /// Default: `false`.
113    pub skip_errors: bool,
114}
115
116impl Default for RunOptions {
117    fn default() -> Self {
118        Self { skip_errors: false }
119    }
120}
121
122/// Drive `pipeline` by reading from `source` in chunks of `chunk_size` items.
123///
124/// Returns [`RunStats`] on success.  The iterator's item errors are propagated
125/// unless [`RunOptions::skip_errors`] is set.
126///
127/// # Arguments
128/// * `pipeline`   – A mutable reference to a [`StreamingPipeline`].
129/// * `source`     – Any iterator whose items are `Result<T>`.
130/// * `chunk_size` – Number of items to accumulate before calling
131///                  [`StreamingPipeline::process_chunk`].  Must be ≥ 1.
132pub fn run_pipeline<T, P>(
133    pipeline: &mut P,
134    source: impl Iterator<Item = Result<T>>,
135    chunk_size: usize,
136) -> Result<RunStats>
137where
138    P: StreamingPipeline<T>,
139{
140    run_pipeline_with_options(pipeline, source, chunk_size, &RunOptions::default())
141}
142
143/// Like [`run_pipeline`] but accepts explicit options.
144pub fn run_pipeline_with_options<T, P>(
145    pipeline: &mut P,
146    source: impl Iterator<Item = Result<T>>,
147    chunk_size: usize,
148    opts: &RunOptions,
149) -> Result<RunStats>
150where
151    P: StreamingPipeline<T>,
152{
153    if chunk_size == 0 {
154        return Err(Error::InvalidData("chunk_size must be ≥ 1".into()));
155    }
156
157    let mut stats = RunStats::default();
158    let mut chunk: Vec<T> = Vec::with_capacity(chunk_size);
159
160    for item in source {
161        match item {
162            Ok(point) => {
163                chunk.push(point);
164                if chunk.len() == chunk_size {
165                    pipeline.process_chunk(&chunk)?;
166                    stats.items_processed += chunk.len();
167                    stats.chunks_processed += 1;
168                    chunk.clear();
169                }
170            }
171            Err(e) => {
172                if opts.skip_errors {
173                    stats.errors_skipped += 1;
174                } else {
175                    return Err(e);
176                }
177            }
178        }
179    }
180
181    // Flush any remaining items.
182    if !chunk.is_empty() {
183        pipeline.process_chunk(&chunk)?;
184        stats.items_processed += chunk.len();
185        stats.chunks_processed += 1;
186    }
187
188    Ok(stats)
189}
190
191// ---------------------------------------------------------------------------
192// StreamingVoxelFilter
193// ---------------------------------------------------------------------------
194
195/// Configuration for [`StreamingVoxelFilter`].
196#[derive(Debug, Clone)]
197pub struct StreamingVoxelFilterConfig {
198    /// Side length of each cubic voxel (same units as the point coordinates).
199    /// Must be positive.
200    pub voxel_size: f32,
201}
202
203/// Streaming voxel-grid downsampler.
204///
205/// Maintains a [`HashMap`] from voxel coordinates to a representative point.
206/// Peak memory is `O(V)` where `V` is the number of occupied voxels in the
207/// entire dataset — typically far smaller than N points.
208///
209/// Unlike the in-memory [`voxel_grid_filter`](crate::filtering::voxel_grid_filter),
210/// no bounding-box pre-scan is required; voxel keys are derived by dividing
211/// each coordinate by `voxel_size` and rounding toward negative infinity, so
212/// they are consistent across all chunks.
213///
214/// The representative point for each voxel is the **centroid** of all points
215/// assigned to that voxel, giving a smoother result than first-point selection.
216pub struct StreamingVoxelFilter {
217    config: StreamingVoxelFilterConfig,
218    /// Accumulated sum and count for centroid computation.
219    voxels: HashMap<(i32, i32, i32), ([f64; 3], u32)>,
220}
221
222impl StreamingVoxelFilter {
223    /// Create a new streaming voxel filter.
224    pub fn new(config: StreamingVoxelFilterConfig) -> Self {
225        Self {
226            config,
227            voxels: HashMap::new(),
228        }
229    }
230
231    #[inline]
232    fn voxel_key(&self, p: &Point3f) -> (i32, i32, i32) {
233        let inv = 1.0 / self.config.voxel_size;
234        (
235            (p.x * inv).floor() as i32,
236            (p.y * inv).floor() as i32,
237            (p.z * inv).floor() as i32,
238        )
239    }
240
241    /// Number of occupied voxels accumulated so far.
242    pub fn voxel_count(&self) -> usize {
243        self.voxels.len()
244    }
245}
246
247impl StreamingPipeline<Point3f> for StreamingVoxelFilter {
248    type Output = PointCloud<Point3f>;
249
250    fn process_chunk(&mut self, chunk: &[Point3f]) -> Result<()> {
251        if self.config.voxel_size <= 0.0 {
252            return Err(Error::InvalidData("voxel_size must be positive".into()));
253        }
254        for p in chunk {
255            let key = self.voxel_key(p);
256            let entry = self.voxels.entry(key).or_insert(([0.0; 3], 0));
257            entry.0[0] += p.x as f64;
258            entry.0[1] += p.y as f64;
259            entry.0[2] += p.z as f64;
260            entry.1 += 1;
261        }
262        Ok(())
263    }
264
265    fn finalize(self) -> Result<PointCloud<Point3f>> {
266        let points: Vec<Point3f> = self
267            .voxels
268            .values()
269            .map(|(sum, count)| {
270                let n = *count as f64;
271                Point3f::new(
272                    (sum[0] / n) as f32,
273                    (sum[1] / n) as f32,
274                    (sum[2] / n) as f32,
275                )
276            })
277            .collect();
278        Ok(PointCloud::from_points(points))
279    }
280
281    fn memory_bytes(&self) -> usize {
282        // Each entry: key (12 bytes) + value (28 bytes) + HashMap overhead (~50 bytes).
283        self.voxels.len() * 90
284    }
285}
286
287// ---------------------------------------------------------------------------
288// StreamingStatistics
289// ---------------------------------------------------------------------------
290
291/// Accumulated statistics produced by [`StreamingStatistics`].
292#[derive(Debug, Clone)]
293pub struct PointCloudStats {
294    /// Total number of points processed.
295    pub point_count: u64,
296    /// Minimum coordinate (axis-aligned bounding box corner).
297    pub min: Point3f,
298    /// Maximum coordinate (axis-aligned bounding box corner).
299    pub max: Point3f,
300    /// Per-axis mean coordinates.
301    pub mean: Point3f,
302}
303
304/// Streaming statistics collector.
305///
306/// Computes bounding box, point count, and mean position in a single pass
307/// without retaining any individual points.  Peak memory is `O(1)`.
308pub struct StreamingStatistics {
309    count: u64,
310    min: [f32; 3],
311    max: [f32; 3],
312    sum: [f64; 3],
313}
314
315impl StreamingStatistics {
316    /// Create a new statistics collector.
317    pub fn new() -> Self {
318        Self {
319            count: 0,
320            min: [f32::INFINITY; 3],
321            max: [f32::NEG_INFINITY; 3],
322            sum: [0.0; 3],
323        }
324    }
325}
326
327impl Default for StreamingStatistics {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333impl StreamingPipeline<Point3f> for StreamingStatistics {
334    type Output = PointCloudStats;
335
336    fn process_chunk(&mut self, chunk: &[Point3f]) -> Result<()> {
337        for p in chunk {
338            self.count += 1;
339            self.min[0] = self.min[0].min(p.x);
340            self.min[1] = self.min[1].min(p.y);
341            self.min[2] = self.min[2].min(p.z);
342            self.max[0] = self.max[0].max(p.x);
343            self.max[1] = self.max[1].max(p.y);
344            self.max[2] = self.max[2].max(p.z);
345            self.sum[0] += p.x as f64;
346            self.sum[1] += p.y as f64;
347            self.sum[2] += p.z as f64;
348        }
349        Ok(())
350    }
351
352    fn finalize(self) -> Result<PointCloudStats> {
353        if self.count == 0 {
354            return Err(Error::InvalidData("no points were processed".into()));
355        }
356        let n = self.count as f64;
357        Ok(PointCloudStats {
358            point_count: self.count,
359            min: Point3f::new(self.min[0], self.min[1], self.min[2]),
360            max: Point3f::new(self.max[0], self.max[1], self.max[2]),
361            mean: Point3f::new(
362                (self.sum[0] / n) as f32,
363                (self.sum[1] / n) as f32,
364                (self.sum[2] / n) as f32,
365            ),
366        })
367    }
368
369    fn memory_bytes(&self) -> usize {
370        std::mem::size_of::<Self>()
371    }
372}
373
374// ---------------------------------------------------------------------------
375// StreamingCollector
376// ---------------------------------------------------------------------------
377
378/// Streaming pipeline stage that collects all points into a `PointCloud`.
379///
380/// Useful for testing or as a terminal stage when the full cloud must
381/// eventually be materialized (e.g. after prior stages have filtered it down).
382pub struct StreamingCollector {
383    points: Vec<Point3f>,
384}
385
386impl StreamingCollector {
387    /// Create a new collector.
388    pub fn new() -> Self {
389        Self { points: Vec::new() }
390    }
391
392    /// Create a collector with pre-allocated capacity.
393    pub fn with_capacity(cap: usize) -> Self {
394        Self {
395            points: Vec::with_capacity(cap),
396        }
397    }
398}
399
400impl Default for StreamingCollector {
401    fn default() -> Self {
402        Self::new()
403    }
404}
405
406impl StreamingPipeline<Point3f> for StreamingCollector {
407    type Output = PointCloud<Point3f>;
408
409    fn process_chunk(&mut self, chunk: &[Point3f]) -> Result<()> {
410        self.points.extend_from_slice(chunk);
411        Ok(())
412    }
413
414    fn finalize(self) -> Result<PointCloud<Point3f>> {
415        Ok(PointCloud::from_points(self.points))
416    }
417
418    fn memory_bytes(&self) -> usize {
419        self.points.len() * std::mem::size_of::<Point3f>()
420    }
421}
422
423// ---------------------------------------------------------------------------
424// Streaming source helpers
425// ---------------------------------------------------------------------------
426
427/// Wrap a `PointCloud` as a streaming source of `Result<Point3f>`.
428///
429/// Useful for testing pipelines without a file on disk.
430pub fn cloud_as_stream(cloud: &PointCloud<Point3f>) -> impl Iterator<Item = Result<Point3f>> + '_ {
431    cloud.points.iter().copied().map(Ok)
432}
433
434// ---------------------------------------------------------------------------
435// Real-time streaming pipeline with backpressure
436// ---------------------------------------------------------------------------
437
438/// Configuration for [`RealtimePipeline`].
439#[derive(Debug, Clone)]
440pub struct BackpressureConfig {
441    /// Maximum number of items buffered in the pipeline queue before backpressure
442    /// kicks in.  [`RealtimePipeline::send`] blocks; [`RealtimePipeline::try_send`]
443    /// drops the item and increments [`RealtimeMetrics::items_dropped`].
444    pub max_queue_depth: usize,
445    /// Number of items accumulated before calling [`StreamingPipeline::process_chunk`].
446    pub chunk_size: usize,
447    /// How long the worker waits for the next item before flushing a partial chunk.
448    ///
449    /// `Some(d)` bounds end-to-end latency at the cost of occasional small chunk
450    /// calls.  `None` flushes only when a full chunk is available or the input
451    /// is closed, which is more efficient for bulk/batch workloads.
452    pub flush_timeout: Option<Duration>,
453}
454
455impl Default for BackpressureConfig {
456    fn default() -> Self {
457        Self {
458            max_queue_depth: 1024,
459            chunk_size: 256,
460            flush_timeout: Some(Duration::from_millis(10)),
461        }
462    }
463}
464
465/// Real-time metrics snapshot from a [`RealtimePipeline`].
466#[derive(Debug, Clone, Default)]
467pub struct RealtimeMetrics {
468    /// Total items successfully placed in the queue (accepted by `send` or `try_send`).
469    pub items_queued: u64,
470    /// Total items dequeued and processed by the background worker.
471    pub items_processed: u64,
472    /// Items dropped because the queue was full (only incremented by `try_send`).
473    pub items_dropped: u64,
474    /// Estimated current queue depth (`items_queued − items_processed`).
475    pub estimated_queue_depth: u64,
476}
477
478struct SharedMetrics {
479    items_queued: AtomicU64,
480    items_processed: AtomicU64,
481    items_dropped: AtomicU64,
482}
483
484impl SharedMetrics {
485    fn new() -> Arc<Self> {
486        Arc::new(Self {
487            items_queued: AtomicU64::new(0),
488            items_processed: AtomicU64::new(0),
489            items_dropped: AtomicU64::new(0),
490        })
491    }
492
493    fn snapshot(&self) -> RealtimeMetrics {
494        let queued = self.items_queued.load(Ordering::Relaxed);
495        let processed = self.items_processed.load(Ordering::Relaxed);
496        let dropped = self.items_dropped.load(Ordering::Relaxed);
497        RealtimeMetrics {
498            items_queued: queued,
499            items_processed: processed,
500            items_dropped: dropped,
501            estimated_queue_depth: queued.saturating_sub(processed),
502        }
503    }
504}
505
506/// Real-time streaming pipeline with backpressure.
507///
508/// Wraps any [`StreamingPipeline`] with a bounded input queue and drives it
509/// from a background worker thread.  Flow control works as follows:
510///
511/// - **[`send`]** blocks the caller when the queue is full — the producer is
512///   naturally throttled without any explicit rate-limiter code.
513/// - **[`try_send`]** never blocks; it drops the item and increments
514///   [`RealtimeMetrics::items_dropped`] when the queue is full.
515/// - Call **[`finish`]** to close the input, drain remaining items, join the
516///   worker, and retrieve the pipeline's final output.
517/// - Dropping the pipeline without calling `finish` is safe: the channel is
518///   closed and the worker is joined in the `Drop` impl (result discarded).
519///
520/// [`send`]: RealtimePipeline::send
521/// [`try_send`]: RealtimePipeline::try_send
522/// [`finish`]: RealtimePipeline::finish
523///
524/// # Example
525///
526/// ```rust
527/// use threecrate_algorithms::streaming::{
528///     StreamingCollector, BackpressureConfig, RealtimePipeline,
529/// };
530/// use threecrate_core::Point3f;
531///
532/// let config = BackpressureConfig { max_queue_depth: 64, chunk_size: 16, ..Default::default() };
533/// let rt = RealtimePipeline::new(StreamingCollector::new(), config);
534/// for i in 0..50_u32 {
535///     rt.send(Point3f::new(i as f32, 0.0, 0.0)).unwrap();
536/// }
537/// let cloud = rt.finish().unwrap();
538/// assert_eq!(cloud.len(), 50);
539/// ```
540pub struct RealtimePipeline<T: Send + 'static, O: Send + 'static> {
541    sender: Option<SyncSender<T>>,
542    metrics: Arc<SharedMetrics>,
543    join_handle: Option<JoinHandle<Result<O>>>,
544}
545
546impl<T: Send + 'static, O: Send + 'static> RealtimePipeline<T, O> {
547    /// Create a new real-time pipeline backed by `pipeline`.
548    ///
549    /// The worker thread starts immediately and is ready to receive items.
550    pub fn new<P>(pipeline: P, config: BackpressureConfig) -> Self
551    where
552        P: StreamingPipeline<T, Output = O> + Send + 'static,
553    {
554        assert!(config.chunk_size >= 1, "chunk_size must be ≥ 1");
555        assert!(config.max_queue_depth >= 1, "max_queue_depth must be ≥ 1");
556
557        let (sender, receiver) = sync_channel::<T>(config.max_queue_depth);
558        let metrics = SharedMetrics::new();
559        let metrics_worker = Arc::clone(&metrics);
560        let chunk_size = config.chunk_size;
561        let flush_timeout = config.flush_timeout;
562
563        let join_handle = thread::spawn(move || {
564            realtime_worker(
565                receiver,
566                pipeline,
567                chunk_size,
568                flush_timeout,
569                metrics_worker,
570            )
571        });
572
573        Self {
574            sender: Some(sender),
575            metrics,
576            join_handle: Some(join_handle),
577        }
578    }
579
580    /// Send an item, blocking until queue space is available (backpressure).
581    ///
582    /// Returns `Err` if the worker thread has unexpectedly terminated.
583    pub fn send(&self, item: T) -> Result<()> {
584        let sender = self
585            .sender
586            .as_ref()
587            .ok_or_else(|| Error::InvalidData("pipeline already finished".into()))?;
588        sender
589            .send(item)
590            .map_err(|_| Error::InvalidData("pipeline worker has terminated".into()))?;
591        self.metrics.items_queued.fetch_add(1, Ordering::Relaxed);
592        Ok(())
593    }
594
595    /// Try to send without blocking.
596    ///
597    /// Returns `Ok(true)` when the item was queued, `Ok(false)` when the queue
598    /// was full and the item was dropped (counted in
599    /// [`RealtimeMetrics::items_dropped`]).  Returns `Err` if the worker has
600    /// terminated unexpectedly.
601    pub fn try_send(&self, item: T) -> Result<bool> {
602        let sender = self
603            .sender
604            .as_ref()
605            .ok_or_else(|| Error::InvalidData("pipeline already finished".into()))?;
606        match sender.try_send(item) {
607            Ok(()) => {
608                self.metrics.items_queued.fetch_add(1, Ordering::Relaxed);
609                Ok(true)
610            }
611            Err(TrySendError::Full(_)) => {
612                self.metrics.items_dropped.fetch_add(1, Ordering::Relaxed);
613                Ok(false)
614            }
615            Err(TrySendError::Disconnected(_)) => {
616                Err(Error::InvalidData("pipeline worker has terminated".into()))
617            }
618        }
619    }
620
621    /// Snapshot current pipeline metrics.
622    pub fn metrics(&self) -> RealtimeMetrics {
623        self.metrics.snapshot()
624    }
625
626    /// Close the input queue, wait for the worker to drain all buffered items,
627    /// and return the pipeline's final output.
628    pub fn finish(mut self) -> Result<O> {
629        self.sender = None;
630        self.join_handle
631            .take()
632            .expect("pipeline already finished")
633            .join()
634            .map_err(|_| Error::InvalidData("pipeline worker panicked".into()))?
635    }
636}
637
638impl<T: Send + 'static, O: Send + 'static> Drop for RealtimePipeline<T, O> {
639    fn drop(&mut self) {
640        self.sender = None;
641        if let Some(handle) = self.join_handle.take() {
642            let _ = handle.join();
643        }
644    }
645}
646
647fn realtime_worker<T, P>(
648    receiver: std::sync::mpsc::Receiver<T>,
649    mut pipeline: P,
650    chunk_size: usize,
651    flush_timeout: Option<Duration>,
652    metrics: Arc<SharedMetrics>,
653) -> Result<P::Output>
654where
655    P: StreamingPipeline<T>,
656{
657    let mut chunk: Vec<T> = Vec::with_capacity(chunk_size);
658
659    match flush_timeout {
660        None => {
661            for item in receiver {
662                metrics.items_processed.fetch_add(1, Ordering::Relaxed);
663                chunk.push(item);
664                if chunk.len() >= chunk_size {
665                    pipeline.process_chunk(&chunk)?;
666                    chunk.clear();
667                }
668            }
669        }
670        Some(timeout) => {
671            use std::sync::mpsc::RecvTimeoutError;
672            loop {
673                match receiver.recv_timeout(timeout) {
674                    Ok(item) => {
675                        metrics.items_processed.fetch_add(1, Ordering::Relaxed);
676                        chunk.push(item);
677                        if chunk.len() >= chunk_size {
678                            pipeline.process_chunk(&chunk)?;
679                            chunk.clear();
680                        }
681                    }
682                    Err(RecvTimeoutError::Timeout) => {
683                        if !chunk.is_empty() {
684                            pipeline.process_chunk(&chunk)?;
685                            chunk.clear();
686                        }
687                    }
688                    Err(RecvTimeoutError::Disconnected) => break,
689                }
690            }
691        }
692    }
693
694    if !chunk.is_empty() {
695        pipeline.process_chunk(&chunk)?;
696    }
697
698    pipeline.finalize()
699}
700
701// ---------------------------------------------------------------------------
702// Tests
703// ---------------------------------------------------------------------------
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    fn grid_cloud(n: usize) -> PointCloud<Point3f> {
710        let pts: Vec<Point3f> = (0..n)
711            .map(|i| Point3f::new(i as f32 * 0.1, 0.0, 0.0))
712            .collect();
713        PointCloud::from_points(pts)
714    }
715
716    // ---- StreamingCollector -----------------------------------------------
717
718    #[test]
719    fn test_collector_round_trip() {
720        let cloud = grid_cloud(25);
721        let mut collector = StreamingCollector::new();
722        let stats = run_pipeline(&mut collector, cloud_as_stream(&cloud), 8).unwrap();
723        let out = collector.finalize().unwrap();
724
725        assert_eq!(stats.items_processed, 25);
726        assert_eq!(stats.chunks_processed, 4); // 8+8+8+1
727        assert_eq!(out.len(), 25);
728    }
729
730    // ---- StreamingStatistics ----------------------------------------------
731
732    #[test]
733    fn test_statistics_correctness() {
734        // Three points: (0,0,0), (1,0,0), (2,0,0)
735        let cloud = PointCloud::from_points(vec![
736            Point3f::new(0.0, 0.0, 0.0),
737            Point3f::new(1.0, 0.0, 0.0),
738            Point3f::new(2.0, 0.0, 0.0),
739        ]);
740        let mut stats_pipe = StreamingStatistics::new();
741        run_pipeline(&mut stats_pipe, cloud_as_stream(&cloud), 2).unwrap();
742        let s = stats_pipe.finalize().unwrap();
743
744        assert_eq!(s.point_count, 3);
745        assert!((s.min.x - 0.0).abs() < 1e-6);
746        assert!((s.max.x - 2.0).abs() < 1e-6);
747        assert!((s.mean.x - 1.0).abs() < 1e-6);
748    }
749
750    #[test]
751    fn test_statistics_empty_fails() {
752        let mut stats_pipe = StreamingStatistics::new();
753        // No chunks processed — finalize should error.
754        assert!(stats_pipe.finalize().is_err());
755    }
756
757    // ---- StreamingVoxelFilter ---------------------------------------------
758
759    #[test]
760    fn test_voxel_filter_reduces_density() {
761        // 100 points at x=0..9.9 in steps of 0.1 → should collapse to ~10 voxels
762        // with voxel_size=1.0.
763        let cloud = grid_cloud(100);
764        let config = StreamingVoxelFilterConfig { voxel_size: 1.0 };
765        let mut filter = StreamingVoxelFilter::new(config);
766        run_pipeline(&mut filter, cloud_as_stream(&cloud), 32).unwrap();
767        let out = filter.finalize().unwrap();
768
769        assert!(out.len() <= 10, "expected ≤10 voxels, got {}", out.len());
770        assert!(!out.is_empty());
771    }
772
773    #[test]
774    fn test_voxel_filter_centroid() {
775        // Two points in the same voxel → centroid should be their midpoint.
776        let cloud = PointCloud::from_points(vec![
777            Point3f::new(0.1, 0.0, 0.0),
778            Point3f::new(0.3, 0.0, 0.0),
779        ]);
780        let config = StreamingVoxelFilterConfig { voxel_size: 1.0 };
781        let mut filter = StreamingVoxelFilter::new(config);
782        run_pipeline(&mut filter, cloud_as_stream(&cloud), 10).unwrap();
783        let out = filter.finalize().unwrap();
784
785        assert_eq!(out.len(), 1);
786        assert!((out.points[0].x - 0.2).abs() < 1e-5);
787    }
788
789    #[test]
790    fn test_voxel_filter_across_chunk_boundary() {
791        // The two points that belong to the same voxel are split across chunks.
792        // The filter must still merge them.
793        let cloud = PointCloud::from_points(vec![
794            Point3f::new(0.1, 0.0, 0.0),
795            Point3f::new(0.9, 0.0, 0.0),
796        ]);
797        let config = StreamingVoxelFilterConfig { voxel_size: 1.0 };
798        let mut filter = StreamingVoxelFilter::new(config);
799        // chunk_size=1 forces each point into its own chunk.
800        run_pipeline(&mut filter, cloud_as_stream(&cloud), 1).unwrap();
801        let out = filter.finalize().unwrap();
802
803        assert_eq!(
804            out.len(),
805            1,
806            "points in the same voxel across chunks should merge"
807        );
808        assert!((out.points[0].x - 0.5).abs() < 1e-5);
809    }
810
811    #[test]
812    fn test_invalid_voxel_size() {
813        let config = StreamingVoxelFilterConfig { voxel_size: -1.0 };
814        let mut filter = StreamingVoxelFilter::new(config);
815        let cloud = PointCloud::from_points(vec![Point3f::new(0.0, 0.0, 0.0)]);
816        let result = run_pipeline(&mut filter, cloud_as_stream(&cloud), 1);
817        assert!(result.is_err());
818    }
819
820    // ---- run_pipeline options --------------------------------------------
821
822    #[test]
823    fn test_skip_errors() {
824        let source: Vec<Result<Point3f>> = vec![
825            Ok(Point3f::new(0.0, 0.0, 0.0)),
826            Err(Error::InvalidData("bad point".into())),
827            Ok(Point3f::new(1.0, 0.0, 0.0)),
828        ];
829        let mut collector = StreamingCollector::new();
830        let run_stats = run_pipeline_with_options(
831            &mut collector,
832            source.into_iter(),
833            10,
834            &RunOptions { skip_errors: true },
835        )
836        .unwrap();
837        let out = collector.finalize().unwrap();
838        assert_eq!(out.len(), 2);
839        assert_eq!(run_stats.errors_skipped, 1);
840    }
841
842    #[test]
843    fn test_error_propagation() {
844        let source: Vec<Result<Point3f>> = vec![
845            Ok(Point3f::new(0.0, 0.0, 0.0)),
846            Err(Error::InvalidData("bad point".into())),
847        ];
848        let mut collector = StreamingCollector::new();
849        // Default options: errors propagate.
850        assert!(run_pipeline(&mut collector, source.into_iter(), 10).is_err());
851    }
852
853    #[test]
854    fn test_chunk_size_zero_fails() {
855        let mut collector = StreamingCollector::new();
856        let result = run_pipeline(&mut collector, std::iter::empty::<Result<Point3f>>(), 0);
857        assert!(result.is_err());
858    }
859
860    #[test]
861    fn test_memory_bytes() {
862        let mut filter = StreamingVoxelFilter::new(StreamingVoxelFilterConfig { voxel_size: 0.5 });
863        let cloud = grid_cloud(20);
864        run_pipeline(&mut filter, cloud_as_stream(&cloud), 5).unwrap();
865        // At least some voxels should be occupied and memory should be > 0.
866        assert!(filter.memory_bytes() > 0);
867    }
868
869    // ---- RealtimePipeline ---------------------------------------------------
870
871    #[test]
872    fn test_realtime_basic_round_trip() {
873        let config = BackpressureConfig {
874            max_queue_depth: 32,
875            chunk_size: 8,
876            flush_timeout: None,
877        };
878        let rt = RealtimePipeline::new(StreamingCollector::new(), config);
879        for i in 0..50_u32 {
880            rt.send(Point3f::new(i as f32, 0.0, 0.0)).unwrap();
881        }
882        let cloud = rt.finish().unwrap();
883        assert_eq!(cloud.len(), 50);
884    }
885
886    #[test]
887    fn test_realtime_with_flush_timeout() {
888        let config = BackpressureConfig {
889            max_queue_depth: 64,
890            chunk_size: 100,
891            flush_timeout: Some(Duration::from_millis(5)),
892        };
893        let rt = RealtimePipeline::new(StreamingCollector::new(), config);
894        for i in 0..20_u32 {
895            rt.send(Point3f::new(i as f32, 0.0, 0.0)).unwrap();
896        }
897        // finish() waits for worker; flush_timeout ensures partial chunk is processed.
898        let cloud = rt.finish().unwrap();
899        assert_eq!(cloud.len(), 20);
900    }
901
902    #[test]
903    fn test_realtime_voxel_filter() {
904        let filter = StreamingVoxelFilter::new(StreamingVoxelFilterConfig { voxel_size: 1.0 });
905        let config = BackpressureConfig {
906            max_queue_depth: 64,
907            chunk_size: 16,
908            flush_timeout: None,
909        };
910        let rt = RealtimePipeline::new(filter, config);
911        for i in 0..100_u32 {
912            rt.send(Point3f::new(i as f32 * 0.1, 0.0, 0.0)).unwrap();
913        }
914        let cloud = rt.finish().unwrap();
915        assert!(
916            cloud.len() <= 10,
917            "expected ≤10 voxels, got {}",
918            cloud.len()
919        );
920        assert!(!cloud.is_empty());
921    }
922
923    #[test]
924    fn test_realtime_metrics_queued_count() {
925        let config = BackpressureConfig {
926            max_queue_depth: 64,
927            chunk_size: 32,
928            flush_timeout: None,
929        };
930        let rt = RealtimePipeline::new(StreamingCollector::new(), config);
931        for i in 0..20_u32 {
932            rt.send(Point3f::new(i as f32, 0.0, 0.0)).unwrap();
933        }
934        let m = rt.metrics();
935        assert_eq!(m.items_queued, 20);
936        assert_eq!(m.items_dropped, 0);
937        rt.finish().unwrap();
938    }
939
940    #[test]
941    fn test_realtime_try_send_accepts_when_space() {
942        let config = BackpressureConfig {
943            max_queue_depth: 16,
944            chunk_size: 8,
945            flush_timeout: None,
946        };
947        let rt = RealtimePipeline::new(StreamingCollector::new(), config);
948        let accepted = rt.try_send(Point3f::new(1.0, 0.0, 0.0)).unwrap();
949        assert!(accepted, "should accept item when queue has space");
950        let m = rt.metrics();
951        assert_eq!(m.items_queued, 1);
952        assert_eq!(m.items_dropped, 0);
953        let cloud = rt.finish().unwrap();
954        assert_eq!(cloud.len(), 1);
955    }
956
957    #[test]
958    fn test_realtime_try_send_drops_when_full() {
959        use std::sync::{Condvar, Mutex};
960
961        // A pipeline whose first process_chunk blocks until we release a latch,
962        // so we can fill the bounded channel before the worker drains it.
963        struct LatchedCollector {
964            latch: Arc<(Mutex<bool>, Condvar)>,
965            inner: StreamingCollector,
966            blocked: bool,
967        }
968        impl StreamingPipeline<Point3f> for LatchedCollector {
969            type Output = PointCloud<Point3f>;
970            fn process_chunk(&mut self, chunk: &[Point3f]) -> Result<()> {
971                if !self.blocked {
972                    self.blocked = true;
973                    let (lock, cv) = &*self.latch;
974                    let mut released = lock.lock().unwrap();
975                    while !*released {
976                        released = cv.wait(released).unwrap();
977                    }
978                }
979                self.inner.process_chunk(chunk)
980            }
981            fn finalize(self) -> Result<PointCloud<Point3f>> {
982                self.inner.finalize()
983            }
984        }
985
986        let latch = Arc::new((Mutex::new(false), Condvar::new()));
987        let latch_release = Arc::clone(&latch);
988
989        // chunk_size=1: every item triggers process_chunk → first one blocks.
990        let config = BackpressureConfig {
991            max_queue_depth: 1,
992            chunk_size: 1,
993            flush_timeout: None,
994        };
995        let rt = RealtimePipeline::new(
996            LatchedCollector {
997                latch,
998                inner: StreamingCollector::new(),
999                blocked: false,
1000            },
1001            config,
1002        );
1003
1004        // First item: worker picks it up and blocks inside process_chunk.
1005        rt.send(Point3f::new(0.0, 0.0, 0.0)).unwrap();
1006        // Give worker time to dequeue and enter process_chunk.
1007        std::thread::sleep(Duration::from_millis(20));
1008
1009        // Queue depth is 1; worker is blocked → channel is full.
1010        let mut accepted = 0usize;
1011        let mut dropped = 0usize;
1012        for i in 1..=8_u32 {
1013            if rt.try_send(Point3f::new(i as f32, 0.0, 0.0)).unwrap() {
1014                accepted += 1;
1015            } else {
1016                dropped += 1;
1017            }
1018        }
1019        assert!(
1020            dropped > 0,
1021            "expected at least one drop with max_queue_depth=1"
1022        );
1023
1024        // Release the latch so the worker can finish processing.
1025        let (lock, cv) = &*latch_release;
1026        *lock.lock().unwrap() = true;
1027        cv.notify_all();
1028
1029        // Capture drop count before consuming rt.
1030        let total_dropped = rt.metrics().items_dropped;
1031        let cloud = rt.finish().unwrap();
1032        // Total accepted = 1 (from send) + accepted (from try_send).
1033        assert_eq!(cloud.len(), 1 + accepted);
1034        assert_eq!(total_dropped, dropped as u64);
1035    }
1036
1037    #[test]
1038    fn test_realtime_drop_without_finish() {
1039        let config = BackpressureConfig {
1040            max_queue_depth: 16,
1041            chunk_size: 4,
1042            flush_timeout: None,
1043        };
1044        let rt = RealtimePipeline::new(StreamingCollector::new(), config);
1045        for i in 0..10_u32 {
1046            rt.send(Point3f::new(i as f32, 0.0, 0.0)).unwrap();
1047        }
1048        drop(rt); // Must not panic or deadlock.
1049    }
1050
1051    #[test]
1052    fn test_realtime_large_workload() {
1053        let config = BackpressureConfig {
1054            max_queue_depth: 512,
1055            chunk_size: 128,
1056            flush_timeout: None,
1057        };
1058        let rt = RealtimePipeline::new(StreamingCollector::new(), config);
1059        const N: u32 = 10_000;
1060        for i in 0..N {
1061            rt.send(Point3f::new(i as f32, 0.0, 0.0)).unwrap();
1062        }
1063        let cloud = rt.finish().unwrap();
1064        assert_eq!(cloud.len(), N as usize);
1065    }
1066}