Skip to main content

spatialrust_pipeline/
streaming.rs

1//! Bounded chunk operations and deterministic external voxel aggregation.
2
3use std::cmp::Ordering;
4use std::collections::BinaryHeap;
5use std::fs::File;
6use std::io::{Read, Seek, SeekFrom, Write};
7
8use spatialrust_core::{
9    DType, FieldSemantic, PointBuffer, PointBufferSet, PointCloud, PointSchema, SpatialMetadata,
10};
11use spatialrust_io::{BoundedSpool, SpoolOptions};
12use spatialrust_math::{Mat4, Vec3};
13use spatialrust_records::{
14    BoundedSpatialRecordSource, CancellationToken, ChunkIdentity, MemoryReservation, MemoryTracker,
15    RecordProvenance, RecordsError, RecordsResult, SchemaDescriptor, SpatialRecord,
16    SpatialRecordChunk, StreamOptions,
17};
18
19/// Per-chunk operation that preserves the input schema.
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum ChunkMapOperation {
22    /// Keep points inside inclusive bounds, or outside them when `invert` is true.
23    Crop {
24        /// Inclusive XYZ minimum.
25        min: [f32; 3],
26        /// Inclusive XYZ maximum.
27        max: [f32; 3],
28        /// Select the complement of the box.
29        invert: bool,
30    },
31    /// Apply an affine transform to positions and its linear part to normals.
32    Transform(Mat4<f32>),
33}
34
35/// Bounded source adapter for chunk-local crop and affine transform operations.
36pub struct ChunkMapSource<S> {
37    source: S,
38    operation: ChunkMapOperation,
39    schema: SchemaDescriptor,
40    options: StreamOptions,
41    tracker: MemoryTracker,
42    cancellation: CancellationToken,
43    max_chunk_bytes: u64,
44    next_sequence: u64,
45    next_point_offset: u64,
46}
47
48impl<S: BoundedSpatialRecordSource> ChunkMapSource<S> {
49    /// Creates a crop adapter and validates ordered finite bounds.
50    pub fn crop(source: S, min: [f32; 3], max: [f32; 3], invert: bool) -> RecordsResult<Self> {
51        if min.into_iter().chain(max).any(|value| !value.is_finite())
52            || (0..3).any(|axis| min[axis] > max[axis])
53        {
54            return Err(RecordsError::InvalidConfiguration(
55                "crop bounds must be finite and ordered".into(),
56            ));
57        }
58        Self::try_new(source, ChunkMapOperation::Crop { min, max, invert })
59    }
60
61    /// Creates an affine transform adapter.
62    pub fn transform(source: S, transform: Mat4<f32>) -> RecordsResult<Self> {
63        Self::try_new(source, ChunkMapOperation::Transform(transform))
64    }
65
66    fn try_new(source: S, operation: ChunkMapOperation) -> RecordsResult<Self> {
67        let schema = source.schema().clone();
68        let options = source.options().clone();
69        let tracker = source.memory_tracker().clone();
70        let cancellation = source.cancellation_token();
71        let max_chunk_bytes = schema_bytes(&schema, options.chunk_points())?;
72        Ok(Self {
73            source,
74            operation,
75            schema,
76            options,
77            tracker,
78            cancellation,
79            max_chunk_bytes,
80            next_sequence: 0,
81            next_point_offset: 0,
82        })
83    }
84
85    /// Consumes the adapter and returns the upstream source.
86    #[must_use]
87    pub fn into_inner(self) -> S {
88        self.source
89    }
90
91    fn map_cloud(&self, input: &PointCloud) -> RecordsResult<PointCloud> {
92        match self.operation {
93            ChunkMapOperation::Crop { min, max, invert } => crop_cloud(input, min, max, invert),
94            ChunkMapOperation::Transform(transform) => transform_cloud(input, transform),
95        }
96    }
97}
98
99impl<S: BoundedSpatialRecordSource> BoundedSpatialRecordSource for ChunkMapSource<S> {
100    fn schema(&self) -> &SchemaDescriptor {
101        &self.schema
102    }
103
104    fn options(&self) -> &StreamOptions {
105        &self.options
106    }
107
108    fn memory_tracker(&self) -> &MemoryTracker {
109        &self.tracker
110    }
111
112    fn cancellation_token(&self) -> CancellationToken {
113        self.cancellation.clone()
114    }
115
116    fn max_chunk_bytes(&self) -> u64 {
117        self.max_chunk_bytes
118    }
119
120    fn next_chunk(&mut self) -> Option<RecordsResult<SpatialRecordChunk>> {
121        loop {
122            if let Err(error) = self.cancellation.check() {
123                return Some(Err(error));
124            }
125            let input = match self.source.next_chunk()? {
126                Ok(chunk) => chunk,
127                Err(error) => return Some(Err(error)),
128            };
129            let input_points = input.record().cloud().len();
130            let reservation =
131                match self.tracker.try_reserve(match schema_bytes(&self.schema, input_points) {
132                    Ok(bytes) => bytes,
133                    Err(error) => return Some(Err(error)),
134                }) {
135                    Ok(reservation) => reservation,
136                    Err(error) => return Some(Err(error)),
137                };
138            let cloud = match self.map_cloud(input.record().cloud()) {
139                Ok(cloud) => cloud,
140                Err(error) => return Some(Err(error)),
141            };
142            let provenance = input.record().provenance().clone();
143            drop(input);
144            if cloud.is_empty() {
145                continue;
146            }
147            let point_count = match usize_u64(cloud.len(), "chunk map point count") {
148                Ok(value) => value,
149                Err(error) => return Some(Err(error)),
150            };
151            let identity = ChunkIdentity {
152                sequence: self.next_sequence,
153                point_offset: self.next_point_offset,
154            };
155            let record = match SpatialRecord::try_new_with_provenance(
156                self.schema.clone(),
157                cloud,
158                provenance,
159            ) {
160                Ok(record) => record,
161                Err(error) => return Some(Err(error)),
162            };
163            let next_sequence = match self.next_sequence.checked_add(1) {
164                Some(value) => value,
165                None => {
166                    return Some(Err(RecordsError::ReceiptOverflow("chunk map sequence".into())));
167                }
168            };
169            let next_point_offset = match self.next_point_offset.checked_add(point_count) {
170                Some(value) => value,
171                None => {
172                    return Some(Err(RecordsError::ReceiptOverflow(
173                        "chunk map point offset".into(),
174                    )));
175                }
176            };
177            let chunk = SpatialRecordChunk::try_from_reserved(identity, record, reservation);
178            if chunk.is_ok() {
179                self.next_sequence = next_sequence;
180                self.next_point_offset = next_point_offset;
181            }
182            return Some(chunk);
183        }
184    }
185}
186
187/// Deterministic global position reduction over a bounded source.
188#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct PositionReduction {
190    /// Number of points observed, including non-finite positions.
191    pub point_count: u64,
192    /// Number of positions whose three components are finite.
193    pub finite_point_count: u64,
194    /// Finite inclusive bounds, or `None` when no finite positions exist.
195    pub bounds: Option<([f64; 3], [f64; 3])>,
196    /// Mean of finite positions, or `None` when no finite positions exist.
197    pub centroid: Option<[f64; 3]>,
198}
199
200/// Consumes a source and computes bounds and centroid without retaining chunks.
201pub fn reduce_positions(
202    source: &mut impl BoundedSpatialRecordSource,
203) -> RecordsResult<PositionReduction> {
204    let mut point_count = 0_u64;
205    let mut finite_count = 0_u64;
206    let mut min = [f64::INFINITY; 3];
207    let mut max = [f64::NEG_INFINITY; 3];
208    let mut sums = [CompensatedSum::default(); 3];
209    while let Some(chunk) = source.next_chunk() {
210        let chunk = chunk?;
211        let cloud = chunk.record().cloud();
212        let (x, y, z) = positions(cloud)?;
213        point_count = point_count
214            .checked_add(usize_u64(cloud.len(), "reduction chunk point count")?)
215            .ok_or_else(|| RecordsError::ReceiptOverflow("reduction point count".into()))?;
216        for index in 0..cloud.len() {
217            let point = [f64::from(x[index]), f64::from(y[index]), f64::from(z[index])];
218            if point.iter().any(|value| !value.is_finite()) {
219                continue;
220            }
221            finite_count = finite_count
222                .checked_add(1)
223                .ok_or_else(|| RecordsError::ReceiptOverflow("finite point count".into()))?;
224            for axis in 0..3 {
225                min[axis] = min[axis].min(point[axis]);
226                max[axis] = max[axis].max(point[axis]);
227                sums[axis].add(point[axis]);
228            }
229        }
230    }
231    Ok(PositionReduction {
232        point_count,
233        finite_point_count: finite_count,
234        bounds: (finite_count > 0).then_some((min, max)),
235        centroid: (finite_count > 0).then(|| {
236            [
237                sums[0].total() / finite_count as f64,
238                sums[1].total() / finite_count as f64,
239                sums[2].total() / finite_count as f64,
240            ]
241        }),
242    })
243}
244
245#[derive(Clone, Copy, Debug, Default)]
246struct CompensatedSum {
247    sum: f64,
248    correction: f64,
249}
250
251impl CompensatedSum {
252    fn add(&mut self, value: f64) {
253        let corrected = value - self.correction;
254        let next = self.sum + corrected;
255        self.correction = (next - self.sum) - corrected;
256        self.sum = next;
257    }
258
259    fn total(self) -> f64 {
260        self.sum
261    }
262}
263
264fn crop_cloud(
265    input: &PointCloud,
266    min: [f32; 3],
267    max: [f32; 3],
268    invert: bool,
269) -> RecordsResult<PointCloud> {
270    let (x, y, z) = positions(input)?;
271    let mut buffers = empty_buffers(input.schema(), input.len());
272    for field in input.schema().fields() {
273        let source = input.field(&field.name)?;
274        let target = buffers
275            .get_mut(&field.name)
276            .ok_or_else(|| RecordsError::InvalidChunk("crop output buffer missing".into()))?;
277        for index in 0..input.len() {
278            let inside = x[index] >= min[0]
279                && x[index] <= max[0]
280                && y[index] >= min[1]
281                && y[index] <= max[1]
282                && z[index] >= min[2]
283                && z[index] <= max[2];
284            if inside ^ invert {
285                push_scalar(target, scalar_at(source, index)?)?;
286            }
287        }
288    }
289    PointCloud::try_from_parts(input.schema().clone(), buffers, input.metadata().clone())
290        .map_err(Into::into)
291}
292
293fn transform_cloud(input: &PointCloud, transform: Mat4<f32>) -> RecordsResult<PointCloud> {
294    let (x, y, z) = positions(input)?;
295    let normals = normal_columns(input)?;
296    let mut buffers = empty_buffers(input.schema(), input.len());
297    for field in input.schema().fields() {
298        let source = input.field(&field.name)?;
299        let target = buffers
300            .get_mut(&field.name)
301            .ok_or_else(|| RecordsError::InvalidChunk("transform output buffer missing".into()))?;
302        for index in 0..input.len() {
303            let value = match field.semantic {
304                FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ => {
305                    let point = transform.transform_point(Vec3::new(x[index], y[index], z[index]));
306                    match field.semantic {
307                        FieldSemantic::PositionX => f64::from(point.x),
308                        FieldSemantic::PositionY => f64::from(point.y),
309                        _ => f64::from(point.z),
310                    }
311                }
312                FieldSemantic::NormalX | FieldSemantic::NormalY | FieldSemantic::NormalZ => {
313                    let (nx, ny, nz) = normals.ok_or_else(|| {
314                        RecordsError::InvalidChunk("incomplete normal columns".into())
315                    })?;
316                    let normal = transform
317                        .transform_vector(Vec3::new(nx[index], ny[index], nz[index]))
318                        .normalize();
319                    match field.semantic {
320                        FieldSemantic::NormalX => f64::from(normal.x),
321                        FieldSemantic::NormalY => f64::from(normal.y),
322                        _ => f64::from(normal.z),
323                    }
324                }
325                _ => scalar_at(source, index)?,
326            };
327            push_scalar(target, value)?;
328        }
329    }
330    PointCloud::try_from_parts(input.schema().clone(), buffers, input.metadata().clone())
331        .map_err(Into::into)
332}
333
334/// Configuration for deterministic, spill-backed global voxel centroids.
335#[derive(Clone, Debug, PartialEq, Eq)]
336pub struct StreamingVoxelConfig {
337    leaf_size_bits: u32,
338    run_points: usize,
339    max_runs: usize,
340    spool: SpoolOptions,
341}
342
343impl StreamingVoxelConfig {
344    /// Creates a configuration with positive leaf size, run capacity, and run limit.
345    pub fn new(
346        leaf_size: f32,
347        run_points: usize,
348        max_runs: usize,
349        spool: SpoolOptions,
350    ) -> RecordsResult<Self> {
351        if !leaf_size.is_finite() || leaf_size <= 0.0 {
352            return Err(RecordsError::InvalidConfiguration(
353                "streaming voxel leaf size must be positive and finite".into(),
354            ));
355        }
356        if run_points == 0 || max_runs == 0 {
357            return Err(RecordsError::InvalidConfiguration(
358                "streaming voxel run_points and max_runs must be positive".into(),
359            ));
360        }
361        Ok(Self { leaf_size_bits: leaf_size.to_bits(), run_points, max_runs, spool })
362    }
363
364    /// Returns the voxel edge length.
365    #[must_use]
366    pub fn leaf_size(&self) -> f32 {
367        f32::from_bits(self.leaf_size_bits)
368    }
369
370    /// Returns the maximum points sorted in one in-memory run.
371    #[must_use]
372    pub const fn run_points(&self) -> usize {
373        self.run_points
374    }
375
376    /// Returns the maximum number of external merge runs.
377    #[must_use]
378    pub const fn max_runs(&self) -> usize {
379        self.max_runs
380    }
381
382    /// Returns the global disk spool contract.
383    #[must_use]
384    pub const fn spool(&self) -> &SpoolOptions {
385        &self.spool
386    }
387}
388
389/// Bounded source of globally aggregated voxel centroids.
390///
391/// Construction consumes the upstream stream into sorted fixed-width runs.
392/// Output is then produced by a deterministic k-way merge ordered by
393/// `(voxel key, source point offset)`.
394pub struct StreamingVoxelSource {
395    schema: SchemaDescriptor,
396    options: StreamOptions,
397    tracker: MemoryTracker,
398    cancellation: CancellationToken,
399    max_chunk_bytes: u64,
400    metadata: SpatialMetadata,
401    provenance: RecordProvenance,
402    _spool: BoundedSpool,
403    _merge_reservation: MemoryReservation,
404    runs: Vec<RunCursor>,
405    heap: BinaryHeap<HeapRecord>,
406    pending: Option<VoxelAccumulator>,
407    next_sequence: u64,
408    next_point_offset: u64,
409    finished: bool,
410}
411
412impl StreamingVoxelSource {
413    /// Builds sorted runs from `source` under shared memory and disk budgets.
414    pub fn try_build<S: BoundedSpatialRecordSource>(
415        mut source: S,
416        config: StreamingVoxelConfig,
417    ) -> RecordsResult<Self> {
418        let schema = source.schema().clone();
419        let options = source.options().clone();
420        let tracker = source.memory_tracker().clone();
421        let cancellation = source.cancellation_token();
422        let max_chunk_bytes = schema_bytes(&schema, options.chunk_points())?;
423        let field_count = schema.point_schema().fields().len();
424        let run_bytes = run_memory_bytes(config.run_points, field_count, config.max_runs)?;
425        let run_reservation = tracker.try_reserve(run_bytes)?;
426        let mut run = RunBuffer::with_capacity(config.run_points, field_count);
427        let mut spool = BoundedSpool::create(config.spool(), "voxel-runs")
428            .map_err(|error| RecordsError::InvalidConfiguration(error.to_string()))?;
429        let mut run_metas = Vec::with_capacity(config.max_runs);
430        let mut metadata = None;
431        let mut provenance = None;
432        let leaf = f64::from(config.leaf_size());
433        let mut expected_sequence = 0_u64;
434        let mut expected_point_offset = 0_u64;
435
436        while let Some(chunk) = source.next_chunk() {
437            cancellation.check()?;
438            let chunk = chunk?;
439            let cloud = chunk.record().cloud();
440            if chunk.identity().sequence != expected_sequence
441                || chunk.identity().point_offset != expected_point_offset
442            {
443                return Err(RecordsError::InvalidChunk(format!(
444                    "voxel input identity discontinuity: expected ({expected_sequence}, \
445                     {expected_point_offset}), found ({}, {})",
446                    chunk.identity().sequence,
447                    chunk.identity().point_offset
448                )));
449            }
450            expected_sequence = expected_sequence
451                .checked_add(1)
452                .ok_or_else(|| RecordsError::ReceiptOverflow("voxel input sequence".into()))?;
453            expected_point_offset = expected_point_offset
454                .checked_add(usize_u64(cloud.len(), "voxel input chunk point count")?)
455                .ok_or_else(|| RecordsError::ReceiptOverflow("voxel input point offset".into()))?;
456            if metadata.is_none() {
457                metadata = Some(cloud.metadata().clone());
458            }
459            let chunk_provenance = chunk.record().provenance().clone().without_sequence();
460            match &provenance {
461                None => provenance = Some(chunk_provenance),
462                Some(expected)
463                    if expected.source_id == chunk_provenance.source_id
464                        && expected.source_uri == chunk_provenance.source_uri
465                        && expected.stream_id == chunk_provenance.stream_id => {}
466                Some(_) => {
467                    return Err(RecordsError::InvalidChunk(
468                        "voxel input provenance changed across chunks".into(),
469                    ));
470                }
471            }
472            let (x, y, z) = positions(cloud)?;
473            for local_index in 0..cloud.len() {
474                if run.len() == config.run_points {
475                    flush_run(&mut spool, &mut run, &mut run_metas, config.max_runs)?;
476                }
477                let point = [
478                    f64::from(x[local_index]),
479                    f64::from(y[local_index]),
480                    f64::from(z[local_index]),
481                ];
482                if point.iter().any(|value| !value.is_finite()) {
483                    continue;
484                }
485                let key = [
486                    voxel_coordinate(point[0], leaf)?,
487                    voxel_coordinate(point[1], leaf)?,
488                    voxel_coordinate(point[2], leaf)?,
489                ];
490                let source_index = chunk
491                    .identity()
492                    .point_offset
493                    .checked_add(usize_u64(local_index, "voxel local point index")?)
494                    .ok_or_else(|| RecordsError::ReceiptOverflow("voxel source offset".into()))?;
495                run.push(key, source_index, cloud, local_index)?;
496            }
497        }
498        if !run.is_empty() {
499            flush_run(&mut spool, &mut run, &mut run_metas, config.max_runs)?;
500        }
501        drop(run_reservation);
502        spool.flush().map_err(|error| RecordsError::InvalidConfiguration(error.to_string()))?;
503
504        let merge_bytes = merge_memory_bytes(run_metas.len(), field_count)?;
505        let merge_reservation = tracker.try_reserve(merge_bytes)?;
506        let mut runs = Vec::with_capacity(run_metas.len());
507        let mut heap = BinaryHeap::new();
508        for (run_index, meta) in run_metas.into_iter().enumerate() {
509            let mut cursor = RunCursor::open(spool.path(), meta, field_count)?;
510            if let Some(record) = cursor.next_record()? {
511                heap.push(HeapRecord { run_index, record });
512            }
513            runs.push(cursor);
514        }
515        Ok(Self {
516            schema,
517            options,
518            tracker,
519            cancellation,
520            max_chunk_bytes,
521            metadata: metadata.unwrap_or_default(),
522            provenance: provenance.unwrap_or_default(),
523            _spool: spool,
524            _merge_reservation: merge_reservation,
525            runs,
526            heap,
527            pending: None,
528            next_sequence: 0,
529            next_point_offset: 0,
530            finished: false,
531        })
532    }
533
534    /// Returns the fixed-width temporary spill extent.
535    #[must_use]
536    pub const fn spool_bytes(&self) -> u64 {
537        self._spool.extent_bytes()
538    }
539
540    /// Returns the number of sorted runs participating in the merge.
541    #[must_use]
542    pub fn run_count(&self) -> usize {
543        self.runs.len()
544    }
545
546    fn pop_record(&mut self) -> RecordsResult<Option<SpillRecord>> {
547        let Some(item) = self.heap.pop() else {
548            return Ok(None);
549        };
550        if let Some(next) = self.runs[item.run_index].next_record()? {
551            self.heap.push(HeapRecord { run_index: item.run_index, record: next });
552        }
553        Ok(Some(item.record))
554    }
555
556    fn next_accumulator(&mut self) -> RecordsResult<Option<VoxelAccumulator>> {
557        loop {
558            let Some(record) = self.pop_record()? else {
559                return Ok(self.pending.take());
560            };
561            match &mut self.pending {
562                Some(pending) if pending.key == record.key => pending.add(&record.values),
563                Some(_) => {
564                    let completed = self.pending.replace(VoxelAccumulator::from_record(record));
565                    return Ok(completed);
566                }
567                None => self.pending = Some(VoxelAccumulator::from_record(record)),
568            }
569        }
570    }
571}
572
573impl BoundedSpatialRecordSource for StreamingVoxelSource {
574    fn schema(&self) -> &SchemaDescriptor {
575        &self.schema
576    }
577
578    fn options(&self) -> &StreamOptions {
579        &self.options
580    }
581
582    fn memory_tracker(&self) -> &MemoryTracker {
583        &self.tracker
584    }
585
586    fn cancellation_token(&self) -> CancellationToken {
587        self.cancellation.clone()
588    }
589
590    fn max_chunk_bytes(&self) -> u64 {
591        self.max_chunk_bytes
592    }
593
594    fn next_chunk(&mut self) -> Option<RecordsResult<SpatialRecordChunk>> {
595        if self.finished {
596            return None;
597        }
598        if let Err(error) = self.cancellation.check() {
599            return Some(Err(error));
600        }
601        let reservation = match self.tracker.try_reserve(self.max_chunk_bytes) {
602            Ok(reservation) => reservation,
603            Err(error) => return Some(Err(error)),
604        };
605        let mut buffers = empty_buffers(self.schema.point_schema(), self.options.chunk_points());
606        let mut output_points = 0;
607        while output_points < self.options.chunk_points() {
608            let accumulator = match self.next_accumulator() {
609                Ok(Some(accumulator)) => accumulator,
610                Ok(None) => {
611                    self.finished = true;
612                    break;
613                }
614                Err(error) => return Some(Err(error)),
615            };
616            for (field_index, field) in self.schema.point_schema().fields().iter().enumerate() {
617                let value = accumulator.sums[field_index].total() / accumulator.count as f64;
618                let Some(buffer) = buffers.get_mut(&field.name) else {
619                    return Some(Err(RecordsError::InvalidChunk(format!(
620                        "voxel output buffer missing for field `{}`",
621                        field.name
622                    ))));
623                };
624                if let Err(error) = push_scalar(buffer, value) {
625                    return Some(Err(error));
626                }
627            }
628            output_points += 1;
629        }
630        if output_points == 0 {
631            return None;
632        }
633        let cloud = match PointCloud::try_from_parts(
634            self.schema.point_schema().clone(),
635            buffers,
636            self.metadata.clone(),
637        ) {
638            Ok(cloud) => cloud,
639            Err(error) => return Some(Err(error.into())),
640        };
641        let identity =
642            ChunkIdentity { sequence: self.next_sequence, point_offset: self.next_point_offset };
643        let record = match SpatialRecord::try_new_with_provenance(
644            self.schema.clone(),
645            cloud,
646            self.provenance.clone(),
647        ) {
648            Ok(record) => record,
649            Err(error) => return Some(Err(error)),
650        };
651        let next_sequence = match self.next_sequence.checked_add(1) {
652            Some(value) => value,
653            None => {
654                return Some(Err(RecordsError::ReceiptOverflow("voxel output sequence".into())));
655            }
656        };
657        let output_points_u64 = match usize_u64(output_points, "voxel output chunk point count") {
658            Ok(value) => value,
659            Err(error) => return Some(Err(error)),
660        };
661        let next_point_offset = match self.next_point_offset.checked_add(output_points_u64) {
662            Some(value) => value,
663            None => {
664                return Some(Err(RecordsError::ReceiptOverflow(
665                    "voxel output point offset".into(),
666                )));
667            }
668        };
669        let chunk = SpatialRecordChunk::try_from_reserved(identity, record, reservation);
670        if chunk.is_ok() {
671            self.next_sequence = next_sequence;
672            self.next_point_offset = next_point_offset;
673        }
674        Some(chunk)
675    }
676}
677
678#[derive(Debug)]
679struct RunBuffer {
680    keys: Vec<[i64; 3]>,
681    source_indices: Vec<u64>,
682    values: Vec<f64>,
683    order: Vec<usize>,
684    field_count: usize,
685}
686
687impl RunBuffer {
688    fn with_capacity(points: usize, field_count: usize) -> Self {
689        Self {
690            keys: Vec::with_capacity(points),
691            source_indices: Vec::with_capacity(points),
692            values: Vec::with_capacity(points.saturating_mul(field_count)),
693            order: Vec::with_capacity(points),
694            field_count,
695        }
696    }
697
698    fn len(&self) -> usize {
699        self.keys.len()
700    }
701
702    fn is_empty(&self) -> bool {
703        self.keys.is_empty()
704    }
705
706    fn push(
707        &mut self,
708        key: [i64; 3],
709        source_index: u64,
710        cloud: &PointCloud,
711        point_index: usize,
712    ) -> RecordsResult<()> {
713        self.keys.push(key);
714        self.source_indices.push(source_index);
715        self.order.push(self.order.len());
716        for field in cloud.schema().fields() {
717            self.values.push(scalar_at(cloud.field(&field.name)?, point_index)?);
718        }
719        Ok(())
720    }
721
722    fn clear(&mut self) {
723        self.keys.clear();
724        self.source_indices.clear();
725        self.values.clear();
726        self.order.clear();
727    }
728}
729
730#[derive(Clone, Copy, Debug)]
731struct RunMeta {
732    data_offset: u64,
733    records: u64,
734}
735
736fn flush_run(
737    spool: &mut BoundedSpool,
738    run: &mut RunBuffer,
739    metas: &mut Vec<RunMeta>,
740    max_runs: usize,
741) -> RecordsResult<()> {
742    if metas.len() == max_runs {
743        return Err(RecordsError::InvalidConfiguration(format!(
744            "streaming voxel run limit {max_runs} exceeded"
745        )));
746    }
747    run.order.sort_unstable_by_key(|index| (run.keys[*index], run.source_indices[*index]));
748    let records = usize_u64(run.len(), "voxel run record count")?;
749    spool.write_all(&records.to_le_bytes()).map_err(spool_error)?;
750    let data_offset = spool.stream_position().map_err(spool_error)?;
751    for &index in &run.order {
752        for coordinate in run.keys[index] {
753            spool.write_all(&coordinate.to_le_bytes()).map_err(spool_error)?;
754        }
755        spool.write_all(&run.source_indices[index].to_le_bytes()).map_err(spool_error)?;
756        let start = index * run.field_count;
757        for value in &run.values[start..start + run.field_count] {
758            spool.write_all(&value.to_le_bytes()).map_err(spool_error)?;
759        }
760    }
761    metas.push(RunMeta { data_offset, records });
762    run.clear();
763    Ok(())
764}
765
766#[derive(Debug)]
767struct RunCursor {
768    file: File,
769    remaining: u64,
770    field_count: usize,
771}
772
773impl RunCursor {
774    fn open(path: &std::path::Path, meta: RunMeta, field_count: usize) -> RecordsResult<Self> {
775        let mut file = File::open(path).map_err(spool_error)?;
776        file.seek(SeekFrom::Start(meta.data_offset)).map_err(spool_error)?;
777        Ok(Self { file, remaining: meta.records, field_count })
778    }
779
780    fn next_record(&mut self) -> RecordsResult<Option<SpillRecord>> {
781        if self.remaining == 0 {
782            return Ok(None);
783        }
784        let mut key = [0_i64; 3];
785        for coordinate in &mut key {
786            *coordinate = read_i64(&mut self.file)?;
787        }
788        let source_index = read_u64(&mut self.file)?;
789        let mut values = Vec::with_capacity(self.field_count);
790        for _ in 0..self.field_count {
791            values.push(read_f64(&mut self.file)?);
792        }
793        self.remaining -= 1;
794        Ok(Some(SpillRecord { key, source_index, values }))
795    }
796}
797
798#[derive(Debug)]
799struct SpillRecord {
800    key: [i64; 3],
801    source_index: u64,
802    values: Vec<f64>,
803}
804
805#[derive(Debug)]
806struct HeapRecord {
807    run_index: usize,
808    record: SpillRecord,
809}
810
811impl PartialEq for HeapRecord {
812    fn eq(&self, other: &Self) -> bool {
813        (self.record.key, self.record.source_index, self.run_index)
814            == (other.record.key, other.record.source_index, other.run_index)
815    }
816}
817
818impl Eq for HeapRecord {}
819
820impl PartialOrd for HeapRecord {
821    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
822        Some(self.cmp(other))
823    }
824}
825
826impl Ord for HeapRecord {
827    fn cmp(&self, other: &Self) -> Ordering {
828        (other.record.key, other.record.source_index, other.run_index).cmp(&(
829            self.record.key,
830            self.record.source_index,
831            self.run_index,
832        ))
833    }
834}
835
836#[derive(Debug)]
837struct VoxelAccumulator {
838    key: [i64; 3],
839    count: u64,
840    sums: Vec<CompensatedSum>,
841}
842
843impl VoxelAccumulator {
844    fn from_record(record: SpillRecord) -> Self {
845        let mut sums = vec![CompensatedSum::default(); record.values.len()];
846        for (sum, value) in sums.iter_mut().zip(record.values) {
847            sum.add(value);
848        }
849        Self { key: record.key, count: 1, sums }
850    }
851
852    fn add(&mut self, values: &[f64]) {
853        self.count += 1;
854        for (sum, value) in self.sums.iter_mut().zip(values) {
855            sum.add(*value);
856        }
857    }
858}
859
860fn positions(cloud: &PointCloud) -> RecordsResult<(&[f32], &[f32], &[f32])> {
861    let x = cloud
862        .schema()
863        .find_semantic(FieldSemantic::PositionX)
864        .ok_or_else(|| RecordsError::MissingField("PositionX".into()))?;
865    let y = cloud
866        .schema()
867        .find_semantic(FieldSemantic::PositionY)
868        .ok_or_else(|| RecordsError::MissingField("PositionY".into()))?;
869    let z = cloud
870        .schema()
871        .find_semantic(FieldSemantic::PositionZ)
872        .ok_or_else(|| RecordsError::MissingField("PositionZ".into()))?;
873    Ok((
874        cloud.field(&x.name)?.as_f32()?,
875        cloud.field(&y.name)?.as_f32()?,
876        cloud.field(&z.name)?.as_f32()?,
877    ))
878}
879
880type NormalColumns<'a> = Option<(&'a [f32], &'a [f32], &'a [f32])>;
881
882fn normal_columns(cloud: &PointCloud) -> RecordsResult<NormalColumns<'_>> {
883    let fields = [
884        cloud.schema().find_semantic(FieldSemantic::NormalX),
885        cloud.schema().find_semantic(FieldSemantic::NormalY),
886        cloud.schema().find_semantic(FieldSemantic::NormalZ),
887    ];
888    if fields.iter().all(Option::is_none) {
889        return Ok(None);
890    }
891    let [Some(nx), Some(ny), Some(nz)] = fields else {
892        return Err(RecordsError::InvalidChunk(
893            "normal fields must be present as a complete XYZ triplet".into(),
894        ));
895    };
896    Ok(Some((
897        cloud.field(&nx.name)?.as_f32()?,
898        cloud.field(&ny.name)?.as_f32()?,
899        cloud.field(&nz.name)?.as_f32()?,
900    )))
901}
902
903fn empty_buffers(schema: &PointSchema, capacity: usize) -> PointBufferSet {
904    let mut buffers = PointBufferSet::new();
905    for field in schema.fields() {
906        buffers.insert(field.name.clone(), PointBuffer::with_capacity(field.dtype, capacity));
907    }
908    buffers
909}
910
911fn scalar_at(buffer: &PointBuffer, index: usize) -> RecordsResult<f64> {
912    Ok(match buffer {
913        PointBuffer::F32(values) => f64::from(values[index]),
914        PointBuffer::F64(values) => values[index],
915        PointBuffer::U8(values) => f64::from(values[index]),
916        PointBuffer::U16(values) => f64::from(values[index]),
917        PointBuffer::U32(values) => f64::from(values[index]),
918        PointBuffer::I32(values) => f64::from(values[index]),
919    })
920}
921
922fn push_scalar(buffer: &mut PointBuffer, value: f64) -> RecordsResult<()> {
923    match buffer {
924        PointBuffer::F32(values) => values.push(value as f32),
925        PointBuffer::F64(values) => values.push(value),
926        PointBuffer::U8(values) => values.push(value.round().clamp(0.0, u8::MAX as f64) as u8),
927        PointBuffer::U16(values) => {
928            values.push(value.round().clamp(0.0, u16::MAX as f64) as u16);
929        }
930        PointBuffer::U32(values) => {
931            values.push(value.round().clamp(0.0, u32::MAX as f64) as u32);
932        }
933        PointBuffer::I32(values) => {
934            values.push(value.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32);
935        }
936    }
937    Ok(())
938}
939
940fn schema_bytes(schema: &SchemaDescriptor, points: usize) -> RecordsResult<u64> {
941    let bytes_per_point = schema.point_schema().fields().iter().try_fold(0_u64, |sum, field| {
942        sum.checked_add(match field.dtype {
943            DType::F32 | DType::F16 | DType::U32 | DType::I32 => 4,
944            DType::F64 => 8,
945            DType::U8 => 1,
946            DType::U16 => 2,
947        })
948        .ok_or_else(|| RecordsError::InvalidConfiguration("schema byte width overflow".into()))
949    })?;
950    bytes_per_point
951        .checked_mul(usize_u64(points, "schema point capacity")?)
952        .ok_or_else(|| RecordsError::InvalidConfiguration("chunk byte size overflow".into()))
953}
954
955fn run_memory_bytes(points: usize, fields: usize, max_runs: usize) -> RecordsResult<u64> {
956    let point_buffers = fields
957        .checked_mul(8)
958        .and_then(|bytes| bytes.checked_add(40))
959        .and_then(|bytes| bytes.checked_mul(points))
960        .ok_or_else(|| RecordsError::InvalidConfiguration("voxel run memory overflow".into()))?;
961    let bytes = max_runs
962        .checked_mul(std::mem::size_of::<RunMeta>())
963        .and_then(|run_meta_bytes| run_meta_bytes.checked_add(point_buffers))
964        .ok_or_else(|| RecordsError::InvalidConfiguration("voxel run memory overflow".into()))?;
965    usize_u64(bytes, "voxel run memory bytes")
966}
967
968fn merge_memory_bytes(runs: usize, fields: usize) -> RecordsResult<u64> {
969    let record_bytes = fields
970        .checked_mul(8)
971        .and_then(|bytes| bytes.checked_add(96))
972        .ok_or_else(|| RecordsError::InvalidConfiguration("voxel merge memory overflow".into()))?;
973    let heap_bytes = runs
974        .checked_add(2)
975        .and_then(|records| records.checked_mul(record_bytes))
976        .and_then(|bytes| bytes.checked_add(fields.saturating_mul(16)))
977        .ok_or_else(|| RecordsError::InvalidConfiguration("voxel merge memory overflow".into()))?;
978    usize_u64(heap_bytes, "voxel merge memory bytes")
979}
980
981fn usize_u64(value: usize, context: &str) -> RecordsResult<u64> {
982    u64::try_from(value)
983        .map_err(|_| RecordsError::InvalidConfiguration(format!("{context} does not fit u64")))
984}
985
986fn voxel_coordinate(value: f64, leaf: f64) -> RecordsResult<i64> {
987    let coordinate = (value / leaf).floor();
988    if coordinate < i64::MIN as f64 || coordinate > i64::MAX as f64 {
989        return Err(RecordsError::InvalidChunk(
990            "voxel coordinate exceeds signed 64-bit range".into(),
991        ));
992    }
993    Ok(coordinate as i64)
994}
995
996fn spool_error(error: std::io::Error) -> RecordsError {
997    RecordsError::InvalidChunk(format!("voxel spool I/O failed: {error}"))
998}
999
1000fn read_i64(reader: &mut impl Read) -> RecordsResult<i64> {
1001    let mut bytes = [0_u8; 8];
1002    reader.read_exact(&mut bytes).map_err(spool_error)?;
1003    Ok(i64::from_le_bytes(bytes))
1004}
1005
1006fn read_u64(reader: &mut impl Read) -> RecordsResult<u64> {
1007    let mut bytes = [0_u8; 8];
1008    reader.read_exact(&mut bytes).map_err(spool_error)?;
1009    Ok(u64::from_le_bytes(bytes))
1010}
1011
1012fn read_f64(reader: &mut impl Read) -> RecordsResult<f64> {
1013    let mut bytes = [0_u8; 8];
1014    reader.read_exact(&mut bytes).map_err(spool_error)?;
1015    Ok(f64::from_le_bytes(bytes))
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::{reduce_positions, ChunkMapSource, StreamingVoxelConfig, StreamingVoxelSource};
1021    use spatialrust_core::{
1022        HasIntensity, HasPositions3, PointCloud, PointCloudBuilder, StandardSchemas,
1023    };
1024    use spatialrust_io::SpoolOptions;
1025    use spatialrust_math::{Mat3, Mat4, Vec3};
1026    use spatialrust_records::{
1027        BoundedSpatialRecordSource, CancellationToken, MemoryBudget, RecyclingMemoryChunkSource,
1028        SchemaDescriptor, SchemaVersion, StreamOptions,
1029    };
1030
1031    fn cloud(points: &[[f32; 3]]) -> PointCloud {
1032        let mut builder = PointCloudBuilder::xyz();
1033        for point in points {
1034            builder.push_point(*point).unwrap();
1035        }
1036        builder.build().unwrap()
1037    }
1038
1039    fn source(cloud: PointCloud, chunk_points: usize) -> RecyclingMemoryChunkSource {
1040        let schema = SchemaDescriptor::try_new(
1041            "stream.xyz",
1042            SchemaVersion::new(1, 0),
1043            cloud.schema().clone(),
1044        )
1045        .unwrap();
1046        let options =
1047            StreamOptions::new(chunk_points, MemoryBudget::new(16 * 1024).unwrap()).unwrap();
1048        RecyclingMemoryChunkSource::try_new(schema, cloud, options, CancellationToken::default())
1049            .unwrap()
1050    }
1051
1052    fn collect_positions(source: &mut impl BoundedSpatialRecordSource) -> Vec<[f32; 3]> {
1053        let mut output = Vec::new();
1054        while let Some(chunk) = source.next_chunk() {
1055            let chunk = chunk.unwrap();
1056            let (x, y, z) = chunk.record().cloud().positions3().unwrap();
1057            output.extend((0..x.len()).map(|index| [x[index], y[index], z[index]]));
1058        }
1059        output
1060    }
1061
1062    #[test]
1063    fn crop_and_transform_recompute_contiguous_chunk_identity() {
1064        let input = cloud(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]]);
1065        let cropped =
1066            ChunkMapSource::crop(source(input, 2), [0.5, -1.0, -1.0], [2.5, 1.0, 1.0], false)
1067                .unwrap();
1068        let transform = Mat4::<f32>::from_rotation_translation(
1069            Mat3::<f32>::identity(),
1070            Vec3::new(10.0, 0.0, 0.0),
1071        );
1072        let mut transformed = ChunkMapSource::transform(cropped, transform).unwrap();
1073        let first = transformed.next_chunk().unwrap().unwrap();
1074        assert_eq!(first.identity().sequence, 0);
1075        assert_eq!(first.identity().point_offset, 0);
1076        assert_eq!(first.record().cloud().positions3().unwrap().0, &[11.0]);
1077        drop(first);
1078        let second = transformed.next_chunk().unwrap().unwrap();
1079        assert_eq!(second.identity().sequence, 1);
1080        assert_eq!(second.identity().point_offset, 1);
1081        assert_eq!(second.record().cloud().positions3().unwrap().0, &[12.0]);
1082        drop(second);
1083        assert!(transformed.next_chunk().is_none());
1084        assert!(transformed.memory_tracker().snapshot().peak_bytes <= 16 * 1024);
1085    }
1086
1087    #[test]
1088    fn global_reduction_ignores_non_finite_positions() {
1089        let input = cloud(&[[0.0, 1.0, 2.0], [2.0, 3.0, 4.0], [f32::NAN, 0.0, 0.0]]);
1090        let mut source = source(input, 1);
1091        let reduction = reduce_positions(&mut source).unwrap();
1092        assert_eq!(reduction.point_count, 3);
1093        assert_eq!(reduction.finite_point_count, 2);
1094        assert_eq!(reduction.bounds, Some(([0.0, 1.0, 2.0], [2.0, 3.0, 4.0])));
1095        assert_eq!(reduction.centroid, Some([1.0, 2.0, 3.0]));
1096    }
1097
1098    #[test]
1099    fn voxel_output_is_identical_across_input_chunk_and_run_sizes() {
1100        let input = cloud(&[
1101            [1.3, 0.0, 0.0],
1102            [0.1, 0.0, 0.0],
1103            [1.1, 0.0, 0.0],
1104            [0.2, 0.0, 0.0],
1105            [2.8, 0.0, 0.0],
1106        ]);
1107        let config_a = StreamingVoxelConfig::new(
1108            1.0,
1109            2,
1110            8,
1111            SpoolOptions::new(std::env::temp_dir(), 16 * 1024).unwrap(),
1112        )
1113        .unwrap();
1114        let config_b = StreamingVoxelConfig::new(
1115            1.0,
1116            3,
1117            8,
1118            SpoolOptions::new(std::env::temp_dir(), 16 * 1024).unwrap(),
1119        )
1120        .unwrap();
1121        let mut output_a =
1122            StreamingVoxelSource::try_build(source(input.clone(), 1), config_a).unwrap();
1123        let mut output_b = StreamingVoxelSource::try_build(source(input, 4), config_b).unwrap();
1124        let points_a = collect_positions(&mut output_a);
1125        let points_b = collect_positions(&mut output_b);
1126        assert!(output_a.spool_bytes() > 0);
1127        assert!(output_a.run_count() > 1);
1128        assert_eq!(points_a, points_b);
1129        assert_eq!(points_a, vec![[0.15, 0.0, 0.0], [1.2, 0.0, 0.0], [2.8, 0.0, 0.0]]);
1130    }
1131
1132    #[test]
1133    fn voxel_centroid_averages_attributes_across_runs() {
1134        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzi());
1135        builder.push_point([0.1, 0.0, 0.0, 10.0]).unwrap();
1136        builder.push_point([0.2, 0.0, 0.0, 20.0]).unwrap();
1137        builder.push_point([1.1, 0.0, 0.0, 50.0]).unwrap();
1138        let input = builder.build().unwrap();
1139        let config = StreamingVoxelConfig::new(
1140            1.0,
1141            1,
1142            4,
1143            SpoolOptions::new(std::env::temp_dir(), 4096).unwrap(),
1144        )
1145        .unwrap();
1146        let mut output = StreamingVoxelSource::try_build(source(input, 2), config).unwrap();
1147        let first = output.next_chunk().unwrap().unwrap();
1148        assert_eq!(first.record().cloud().intensity().unwrap(), &[15.0, 50.0]);
1149    }
1150
1151    #[test]
1152    fn voxel_spool_and_run_limits_fail_closed() {
1153        let input = cloud(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
1154        let tiny_spool = StreamingVoxelConfig::new(
1155            0.1,
1156            2,
1157            2,
1158            SpoolOptions::new(std::env::temp_dir(), 8).unwrap(),
1159        )
1160        .unwrap();
1161        assert!(StreamingVoxelSource::try_build(source(input.clone(), 2), tiny_spool).is_err());
1162
1163        let one_run = StreamingVoxelConfig::new(
1164            0.1,
1165            1,
1166            1,
1167            SpoolOptions::new(std::env::temp_dir(), 4096).unwrap(),
1168        )
1169        .unwrap();
1170        assert!(StreamingVoxelSource::try_build(source(input, 2), one_run).is_err());
1171    }
1172
1173    #[test]
1174    fn cancelled_voxel_build_releases_all_tracked_memory() {
1175        let input = cloud(&[[0.0, 0.0, 0.0]]);
1176        let schema = SchemaDescriptor::try_new(
1177            "stream.xyz",
1178            SchemaVersion::new(1, 0),
1179            input.schema().clone(),
1180        )
1181        .unwrap();
1182        let options = StreamOptions::new(1, MemoryBudget::new(4096).unwrap()).unwrap();
1183        let cancellation = CancellationToken::default();
1184        cancellation.cancel();
1185        let source =
1186            RecyclingMemoryChunkSource::try_new(schema, input, options, cancellation).unwrap();
1187        let tracker = source.memory_tracker().clone();
1188        let config = StreamingVoxelConfig::new(
1189            1.0,
1190            1,
1191            2,
1192            SpoolOptions::new(std::env::temp_dir(), 4096).unwrap(),
1193        )
1194        .unwrap();
1195        assert!(StreamingVoxelSource::try_build(source, config).is_err());
1196        assert_eq!(tracker.snapshot().current_bytes, 0);
1197    }
1198}