Skip to main content

spatialrust_core/
pointcloud.rs

1use crate::{
2    CpuDevice, DType, Device, PointBuffer, PointBufferSet, PointField, PointSchema, SpatialError,
3    SpatialMetadata, SpatialResult, StandardSchemas,
4};
5
6/// Schema-aware columnar point cloud stored on a device.
7#[derive(Clone, Debug, PartialEq)]
8pub struct PointCloud {
9    schema: PointSchema,
10    buffers: PointBufferSet,
11    len: usize,
12    metadata: SpatialMetadata,
13    device: CpuDevice,
14}
15
16/// Builds point clouds field-by-field.
17#[derive(Clone, Debug, Default)]
18pub struct PointCloudBuilder {
19    schema: PointSchema,
20    buffers: PointBufferSet,
21    metadata: SpatialMetadata,
22}
23
24impl PointCloud {
25    /// Creates an empty point cloud with the given schema.
26    #[must_use]
27    pub fn with_schema(schema: PointSchema) -> Self {
28        Self {
29            schema,
30            buffers: PointBufferSet::new(),
31            len: 0,
32            metadata: SpatialMetadata::default(),
33            device: CpuDevice,
34        }
35    }
36
37    /// Creates an empty `PointXYZ` cloud.
38    #[must_use]
39    pub fn xyz() -> Self {
40        Self::with_schema(StandardSchemas::point_xyz())
41    }
42
43    /// Returns the point cloud schema.
44    #[must_use]
45    pub fn schema(&self) -> &PointSchema {
46        &self.schema
47    }
48
49    /// Returns the number of points.
50    #[must_use]
51    pub const fn len(&self) -> usize {
52        self.len
53    }
54
55    /// Returns whether the cloud contains no points.
56    #[must_use]
57    pub const fn is_empty(&self) -> bool {
58        self.len == 0
59    }
60
61    /// Returns spatial metadata.
62    #[must_use]
63    pub fn metadata(&self) -> &SpatialMetadata {
64        &self.metadata
65    }
66
67    /// Returns the storage device.
68    #[must_use]
69    pub fn device(&self) -> &dyn Device {
70        &self.device
71    }
72
73    /// Returns read-only access to a field buffer by name.
74    pub fn field(&self, name: &str) -> SpatialResult<&PointBuffer> {
75        self.buffers.get(name).ok_or_else(|| SpatialError::MissingField(name.to_owned()))
76    }
77
78    /// Validates schema and buffer consistency.
79    pub fn validate(&self) -> SpatialResult<()> {
80        self.schema.validate_positions()?;
81        for field in self.schema.fields() {
82            let buffer = self.field(&field.name)?;
83            if buffer.len() != self.len {
84                return Err(SpatialError::BufferLengthMismatch {
85                    expected: self.len,
86                    found: buffer.len(),
87                });
88            }
89            if buffer.dtype() != field.dtype && field.dtype != DType::F16 {
90                return Err(SpatialError::SchemaValidation(format!(
91                    "field `{}` dtype mismatch",
92                    field.name
93                )));
94            }
95        }
96        Ok(())
97    }
98
99    pub(crate) fn from_builder(builder: PointCloudBuilder, len: usize) -> SpatialResult<Self> {
100        let cloud = Self {
101            schema: builder.schema,
102            buffers: builder.buffers,
103            len,
104            metadata: builder.metadata,
105            device: CpuDevice,
106        };
107        cloud.validate()?;
108        Ok(cloud)
109    }
110
111    /// Constructs a point cloud from schema, column buffers, and metadata.
112    pub fn try_from_parts(
113        schema: PointSchema,
114        buffers: PointBufferSet,
115        metadata: SpatialMetadata,
116    ) -> SpatialResult<Self> {
117        if schema.is_empty() {
118            return Ok(Self { schema, buffers, len: 0, metadata, device: CpuDevice });
119        }
120
121        let len = schema
122            .fields()
123            .first()
124            .and_then(|field| buffers.get(&field.name))
125            .map(|buffer| buffer.len())
126            .ok_or_else(|| SpatialError::MissingField(schema.fields()[0].name.clone()))?;
127
128        for field in schema.fields() {
129            let buffer = buffers
130                .get(&field.name)
131                .ok_or_else(|| SpatialError::MissingField(field.name.clone()))?;
132            if buffer.len() != len {
133                return Err(SpatialError::BufferLengthMismatch {
134                    expected: len,
135                    found: buffer.len(),
136                });
137            }
138        }
139
140        let cloud = Self { schema, buffers, len, metadata, device: CpuDevice };
141        cloud.validate()?;
142        Ok(cloud)
143    }
144}
145
146impl PointCloudBuilder {
147    /// Creates a builder from a schema.
148    #[must_use]
149    pub fn new(schema: PointSchema) -> Self {
150        Self { schema, ..Self::default() }
151    }
152
153    /// Creates a builder for the standard `PointXYZ` schema.
154    #[must_use]
155    pub fn xyz() -> Self {
156        Self::new(StandardSchemas::point_xyz())
157    }
158
159    /// Sets spatial metadata.
160    #[must_use]
161    pub fn metadata(mut self, metadata: SpatialMetadata) -> Self {
162        self.metadata = metadata;
163        self
164    }
165
166    /// Appends one point by field values in schema order.
167    ///
168    /// Each field value must be a scalar convertible to the field dtype.
169    pub fn push_point<I>(&mut self, values: I) -> SpatialResult<()>
170    where
171        I: IntoIterator<Item = f32>,
172    {
173        let values: Vec<f32> = values.into_iter().collect();
174        if values.len() != self.schema.len() {
175            return Err(SpatialError::InvalidArgument(format!(
176                "expected {} field values, got {}",
177                self.schema.len(),
178                values.len()
179            )));
180        }
181
182        let fields: Vec<PointField> = self.schema.fields().to_vec();
183        for (field, value) in fields.iter().zip(values) {
184            self.push_scalar(field, value)?;
185        }
186        Ok(())
187    }
188
189    /// Builds the final point cloud.
190    pub fn build(self) -> SpatialResult<PointCloud> {
191        let len = self
192            .schema
193            .fields()
194            .first()
195            .and_then(|field| self.buffers.get(&field.name))
196            .map(|buffer| buffer.len())
197            .unwrap_or(0);
198        PointCloud::from_builder(self, len)
199    }
200
201    fn push_scalar(&mut self, field: &PointField, value: f32) -> SpatialResult<()> {
202        let buffer = match self.buffers.get_mut(&field.name) {
203            Some(buffer) => buffer,
204            None => {
205                let buffer = PointBuffer::with_capacity(field.dtype, 0);
206                self.buffers.insert(field.name.clone(), buffer);
207                self.buffers.get_mut(&field.name).expect("buffer inserted")
208            }
209        };
210
211        match field.dtype {
212            DType::F32 | DType::F16 => buffer.as_f32_mut()?.push(value),
213            DType::F64 => match buffer {
214                PointBuffer::F64(values) => values.push(f64::from(value)),
215                _ => return Err(SpatialError::UnsupportedDType(field.dtype)),
216            },
217            DType::U8 => match buffer {
218                PointBuffer::U8(values) => values.push(value.round() as u8),
219                _ => return Err(SpatialError::UnsupportedDType(field.dtype)),
220            },
221            DType::U16 => match buffer {
222                PointBuffer::U16(values) => values.push(value.round() as u16),
223                _ => return Err(SpatialError::UnsupportedDType(field.dtype)),
224            },
225            DType::U32 => match buffer {
226                PointBuffer::U32(values) => values.push(value.round() as u32),
227                _ => return Err(SpatialError::UnsupportedDType(field.dtype)),
228            },
229            DType::I32 => match buffer {
230                PointBuffer::I32(values) => values.push(value.round() as i32),
231                _ => return Err(SpatialError::UnsupportedDType(field.dtype)),
232            },
233        }
234        Ok(())
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::PointCloudBuilder;
241    use crate::{FieldSemantic, StandardSchemas};
242
243    #[test]
244    fn build_xyz_cloud() {
245        let mut builder = PointCloudBuilder::xyz();
246        builder.push_point([0.0, 0.0, 0.0]).unwrap();
247        builder.push_point([1.0, 0.0, 0.0]).unwrap();
248        let cloud = builder.build().unwrap();
249        assert_eq!(cloud.len(), 2);
250        assert!(cloud.validate().is_ok());
251        let x = cloud.field("x").unwrap().as_f32().unwrap();
252        assert_eq!(x, &[0.0, 1.0]);
253    }
254
255    #[test]
256    fn standard_xyzi_has_intensity() {
257        let schema = StandardSchemas::point_xyzi();
258        assert!(schema.find_semantic(FieldSemantic::Intensity).is_some());
259    }
260}