1use nalgebra::Vector3;
4
5#[derive(Debug, thiserror::Error)]
7pub enum CloudError {
8 #[error("coordinate arrays differ in length: x={x}, y={y}, z={z}")]
10 MismatchedCoordinates {
11 x: usize,
13 y: usize,
15 z: usize,
17 },
18 #[error("attribute \"{name}\" has length {actual}, expected {expected}")]
20 MismatchedAttribute {
21 name: String,
23 actual: usize,
25 expected: usize,
27 },
28 #[error("attribute \"{0}\" already exists")]
30 DuplicateAttribute(String),
31 #[error("voxel size must be positive and finite, got {0}")]
33 InvalidVoxelSize(f64),
34 #[error("point {index} has a non-finite coordinate")]
36 NonFinite {
37 index: usize,
39 },
40}
41
42#[derive(Debug, Clone, PartialEq)]
47pub enum AttributeData {
48 F32(Vec<f32>),
50 F64(Vec<f64>),
52 U8(Vec<u8>),
54 U16(Vec<u16>),
56 U32(Vec<u32>),
58 I32(Vec<i32>),
60}
61
62impl AttributeData {
63 pub fn len(&self) -> usize {
65 match self {
66 Self::F32(v) => v.len(),
67 Self::F64(v) => v.len(),
68 Self::U8(v) => v.len(),
69 Self::U16(v) => v.len(),
70 Self::U32(v) => v.len(),
71 Self::I32(v) => v.len(),
72 }
73 }
74
75 pub fn is_empty(&self) -> bool {
77 self.len() == 0
78 }
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct Attribute {
84 pub name: String,
86 pub data: AttributeData,
88}
89
90#[derive(Debug, Clone, Default, PartialEq)]
111pub struct PointCloud {
112 origin: Vector3<f64>,
113 x: Vec<f32>,
114 y: Vec<f32>,
115 z: Vec<f32>,
116 attributes: Vec<Attribute>,
117}
118
119impl PointCloud {
120 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn with_origin(origin: Vector3<f64>) -> Self {
127 Self {
128 origin,
129 ..Self::default()
130 }
131 }
132
133 pub fn rebase(&mut self, new_origin: Vector3<f64>) {
138 let shift = self.origin - new_origin;
139 let (dx, dy, dz) = (shift.x as f32, shift.y as f32, shift.z as f32);
140 for i in 0..self.len() {
141 self.x[i] += dx;
142 self.y[i] += dy;
143 self.z[i] += dz;
144 }
145 self.origin = new_origin;
146 }
147
148 pub fn with_capacity(capacity: usize) -> Self {
150 Self {
151 origin: Vector3::zeros(),
152 x: Vec::with_capacity(capacity),
153 y: Vec::with_capacity(capacity),
154 z: Vec::with_capacity(capacity),
155 attributes: Vec::new(),
156 }
157 }
158
159 pub fn from_columns(
163 origin: Vector3<f64>,
164 x: Vec<f32>,
165 y: Vec<f32>,
166 z: Vec<f32>,
167 ) -> Result<Self, CloudError> {
168 if x.len() != y.len() || y.len() != z.len() {
169 return Err(CloudError::MismatchedCoordinates {
170 x: x.len(),
171 y: y.len(),
172 z: z.len(),
173 });
174 }
175 Ok(Self {
176 origin,
177 x,
178 y,
179 z,
180 attributes: Vec::new(),
181 })
182 }
183
184 pub fn len(&self) -> usize {
186 self.x.len()
187 }
188
189 pub fn is_empty(&self) -> bool {
191 self.x.is_empty()
192 }
193
194 pub fn origin(&self) -> Vector3<f64> {
196 self.origin
197 }
198
199 pub fn point(&self, index: usize) -> Vector3<f64> {
201 self.origin + self.local(index)
202 }
203
204 pub fn local(&self, index: usize) -> Vector3<f64> {
206 Vector3::new(
207 f64::from(self.x[index]),
208 f64::from(self.y[index]),
209 f64::from(self.z[index]),
210 )
211 }
212
213 pub fn columns(&self) -> (&[f32], &[f32], &[f32]) {
215 (&self.x, &self.y, &self.z)
216 }
217
218 pub fn iter(&self) -> impl Iterator<Item = Vector3<f64>> + '_ {
220 (0..self.len()).map(|i| self.point(i))
221 }
222
223 pub fn push(&mut self, point: Vector3<f64>) {
230 assert!(
231 self.attributes.is_empty(),
232 "pushing into a cloud with attributes would break column lengths"
233 );
234 let local = point - self.origin;
235 self.x.push(local.x as f32);
236 self.y.push(local.y as f32);
237 self.z.push(local.z as f32);
238 }
239
240 pub fn attributes(&self) -> &[Attribute] {
242 &self.attributes
243 }
244
245 pub fn attribute(&self, name: &str) -> Option<&Attribute> {
247 self.attributes.iter().find(|a| a.name == name)
248 }
249
250 pub fn push_attribute(&mut self, attribute: Attribute) -> Result<(), CloudError> {
252 if attribute.data.len() != self.len() {
253 return Err(CloudError::MismatchedAttribute {
254 name: attribute.name,
255 actual: attribute.data.len(),
256 expected: self.len(),
257 });
258 }
259 if self.attribute(&attribute.name).is_some() {
260 return Err(CloudError::DuplicateAttribute(attribute.name));
261 }
262 self.attributes.push(attribute);
263 Ok(())
264 }
265
266 pub fn check_finite(&self) -> Result<(), CloudError> {
272 for i in 0..self.len() {
273 if !(self.x[i].is_finite() && self.y[i].is_finite() && self.z[i].is_finite()) {
274 return Err(CloudError::NonFinite { index: i });
275 }
276 }
277 Ok(())
278 }
279
280 pub fn bounds(&self) -> Option<(Vector3<f64>, Vector3<f64>)> {
282 if self.is_empty() {
283 return None;
284 }
285 let mut min = self.point(0);
286 let mut max = min;
287 for i in 1..self.len() {
288 let p = self.point(i);
289 min = min.inf(&p);
290 max = max.sup(&p);
291 }
292 Some((min, max))
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
303 fn origin_preserves_precision_at_utm_scale() {
304 let absolute = Vector3::new(499_123.456_7, 5_432_198.765_4, 231.0);
305 let mut cloud = PointCloud::with_origin(Vector3::new(499_000.0, 5_432_000.0, 0.0));
306 cloud.push(absolute);
307
308 let error = (cloud.point(0) - absolute).norm();
309 assert!(error < 1e-4, "offset storage: error {error:.3e} m");
310
311 let naive = f64::from(absolute.x as f32) - absolute.x;
313 assert!(
314 naive.abs() > 1e-2,
315 "plain f32 must lose centimetres here, lost {naive:.3e}"
316 );
317 }
318
319 #[test]
320 fn mismatched_columns_are_rejected() {
321 let err =
322 PointCloud::from_columns(Vector3::zeros(), vec![1.0, 2.0], vec![1.0], vec![1.0, 2.0]);
323 assert!(matches!(err, Err(CloudError::MismatchedCoordinates { .. })));
324 }
325
326 #[test]
327 fn attribute_length_is_checked() {
328 let mut cloud =
329 PointCloud::from_columns(Vector3::zeros(), vec![0.0; 3], vec![0.0; 3], vec![0.0; 3])
330 .unwrap();
331 let bad = Attribute {
332 name: "intensity".into(),
333 data: AttributeData::U16(vec![1, 2]),
334 };
335 assert!(cloud.push_attribute(bad).is_err());
336 }
337}