1use 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#[derive(Clone, Copy, Debug, PartialEq)]
21pub enum ChunkMapOperation {
22 Crop {
24 min: [f32; 3],
26 max: [f32; 3],
28 invert: bool,
30 },
31 Transform(Mat4<f32>),
33}
34
35pub 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 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 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 #[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#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct PositionReduction {
190 pub point_count: u64,
192 pub finite_point_count: u64,
194 pub bounds: Option<([f64; 3], [f64; 3])>,
196 pub centroid: Option<[f64; 3]>,
198}
199
200pub 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#[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 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 #[must_use]
366 pub fn leaf_size(&self) -> f32 {
367 f32::from_bits(self.leaf_size_bits)
368 }
369
370 #[must_use]
372 pub const fn run_points(&self) -> usize {
373 self.run_points
374 }
375
376 #[must_use]
378 pub const fn max_runs(&self) -> usize {
379 self.max_runs
380 }
381
382 #[must_use]
384 pub const fn spool(&self) -> &SpoolOptions {
385 &self.spool
386 }
387}
388
389pub 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 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 #[must_use]
536 pub const fn spool_bytes(&self) -> u64 {
537 self._spool.extent_bytes()
538 }
539
540 #[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}