Skip to main content

spatialrust_core/
tensor.rs

1//! Provisional chunked views over schema-aware column storage.
2//!
3//! `SpatialTensor` is the architecture-level name for zero-copy iteration over
4//! fixed-size point chunks (AoSoA-style slices). The API is **provisional** —
5//! see [`docs/API_STABILITY.md`](../../docs/API_STABILITY.md).
6
7use std::ops::Range;
8
9use crate::{PointBuffer, PointCloud, PointSchema, SpatialResult};
10
11/// Default chunk size for [`PointCloud::spatial_tensor_chunks`].
12pub const DEFAULT_SPATIAL_TENSOR_CHUNK_SIZE: usize = 16_384;
13
14/// Borrowed chunked view over a [`PointCloud`].
15#[derive(Clone, Copy, Debug)]
16pub struct SpatialTensor<'a> {
17    cloud: &'a PointCloud,
18    chunk_size: usize,
19}
20
21/// One contiguous index range within a [`SpatialTensor`] chunk iteration.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct SpatialTensorChunk {
24    range: Range<usize>,
25}
26
27impl<'a> SpatialTensor<'a> {
28    /// Creates a chunked view with an explicit chunk size (must be > 0).
29    pub fn new(cloud: &'a PointCloud, chunk_size: usize) -> SpatialResult<Self> {
30        if chunk_size == 0 {
31            return Err(crate::SpatialError::InvalidArgument(
32                "SpatialTensor chunk_size must be positive".into(),
33            ));
34        }
35        Ok(Self { cloud, chunk_size })
36    }
37
38    /// Returns the underlying point cloud.
39    #[must_use]
40    pub const fn cloud(&self) -> &'a PointCloud {
41        self.cloud
42    }
43
44    /// Returns the configured chunk size.
45    #[must_use]
46    pub const fn chunk_size(&self) -> usize {
47        self.chunk_size
48    }
49
50    /// Returns the point schema shared by every chunk.
51    #[must_use]
52    pub fn schema(&self) -> &PointSchema {
53        self.cloud.schema()
54    }
55
56    /// Returns the total number of points in the view.
57    #[must_use]
58    pub fn len(&self) -> usize {
59        self.cloud.len()
60    }
61
62    /// Returns whether the view spans zero points.
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.cloud.is_empty()
66    }
67
68    /// Iterates contiguous index ranges covering the full cloud.
69    pub fn chunks(&self) -> impl Iterator<Item = SpatialTensorChunk> + 'a {
70        let len = self.cloud.len();
71        let chunk_size = self.chunk_size;
72        (0..len).step_by(chunk_size).map(move |start| {
73            let end = (start + chunk_size).min(len);
74            SpatialTensorChunk { range: start..end }
75        })
76    }
77}
78
79impl SpatialTensorChunk {
80    /// Returns the half-open index range `[start, end)` for this chunk.
81    #[must_use]
82    pub fn range(&self) -> Range<usize> {
83        self.range.clone()
84    }
85
86    /// Returns the number of points in this chunk.
87    #[must_use]
88    pub fn len(&self) -> usize {
89        self.range.len()
90    }
91
92    /// Returns whether this chunk is empty.
93    #[must_use]
94    pub fn is_empty(&self) -> bool {
95        self.range.is_empty()
96    }
97
98    /// Returns a slice of an `f32` field column for this chunk.
99    pub fn field_f32<'a>(&self, cloud: &'a PointCloud, name: &str) -> SpatialResult<&'a [f32]> {
100        let values = cloud.field(name)?.as_f32()?;
101        Ok(&values[self.range.clone()])
102    }
103
104    /// Returns the underlying buffer slice for any field dtype in this chunk.
105    pub fn field_buffer<'a>(
106        &self,
107        cloud: &'a PointCloud,
108        name: &str,
109    ) -> SpatialResult<SpatialTensorFieldChunk<'a>> {
110        let buffer = cloud.field(name)?;
111        Ok(SpatialTensorFieldChunk { buffer, range: self.range.clone() })
112    }
113}
114
115/// Borrowed slice of one column buffer within a chunk range.
116#[derive(Clone, Debug)]
117pub struct SpatialTensorFieldChunk<'a> {
118    buffer: &'a PointBuffer,
119    range: Range<usize>,
120}
121
122impl<'a> SpatialTensorFieldChunk<'a> {
123    /// Returns the field slice as `f32` when the column dtype allows it.
124    pub fn as_f32(&self) -> SpatialResult<&[f32]> {
125        Ok(&self.buffer.as_f32()?[self.range.clone()])
126    }
127
128    /// Returns the half-open index range for this field chunk.
129    #[must_use]
130    pub fn range(&self) -> Range<usize> {
131        self.range.clone()
132    }
133}
134
135impl PointCloud {
136    /// Returns a provisional chunked view for AoSoA-style iteration.
137    pub fn spatial_tensor_chunks(&self, chunk_size: usize) -> SpatialResult<SpatialTensor<'_>> {
138        SpatialTensor::new(self, chunk_size)
139    }
140
141    /// Returns a chunked view using [`DEFAULT_SPATIAL_TENSOR_CHUNK_SIZE`].
142    pub fn spatial_tensor(&self) -> SpatialResult<SpatialTensor<'_>> {
143        self.spatial_tensor_chunks(DEFAULT_SPATIAL_TENSOR_CHUNK_SIZE)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::{SpatialTensor, DEFAULT_SPATIAL_TENSOR_CHUNK_SIZE};
150    use crate::PointCloudBuilder;
151
152    #[test]
153    fn chunks_cover_all_points() {
154        let mut builder = PointCloudBuilder::xyz();
155        for index in 0..5 {
156            builder.push_point([index as f32, 0.0, 0.0]).unwrap();
157        }
158        let cloud = builder.build().unwrap();
159        let tensor = SpatialTensor::new(&cloud, 2).unwrap();
160        let chunks: Vec<_> = tensor.chunks().collect();
161        assert_eq!(chunks.len(), 3);
162        assert_eq!(chunks[0].len(), 2);
163        assert_eq!(chunks[2].len(), 1);
164        let covered: usize = chunks.iter().map(super::SpatialTensorChunk::len).sum();
165        assert_eq!(covered, cloud.len());
166    }
167
168    #[test]
169    fn field_f32_slice_matches_column() {
170        let mut builder = PointCloudBuilder::xyz();
171        builder.push_point([1.0, 2.0, 3.0]).unwrap();
172        builder.push_point([4.0, 5.0, 6.0]).unwrap();
173        let cloud = builder.build().unwrap();
174        let chunk = SpatialTensor::new(&cloud, DEFAULT_SPATIAL_TENSOR_CHUNK_SIZE)
175            .unwrap()
176            .chunks()
177            .next()
178            .unwrap();
179        assert_eq!(chunk.field_f32(&cloud, "y").unwrap(), &[2.0, 5.0]);
180    }
181
182    #[test]
183    fn rejects_zero_chunk_size() {
184        let cloud = crate::PointCloud::xyz();
185        assert!(SpatialTensor::new(&cloud, 0).is_err());
186    }
187}