use nalgebra::Vector3;
#[derive(Debug, thiserror::Error)]
pub enum CloudError {
#[error("coordinate arrays differ in length: x={x}, y={y}, z={z}")]
MismatchedCoordinates {
x: usize,
y: usize,
z: usize,
},
#[error("attribute \"{name}\" has length {actual}, expected {expected}")]
MismatchedAttribute {
name: String,
actual: usize,
expected: usize,
},
#[error("attribute \"{0}\" already exists")]
DuplicateAttribute(String),
#[error("voxel size must be positive and finite, got {0}")]
InvalidVoxelSize(f64),
#[error("point {index} has a non-finite coordinate")]
NonFinite {
index: usize,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum AttributeData {
F32(Vec<f32>),
F64(Vec<f64>),
U8(Vec<u8>),
U16(Vec<u16>),
U32(Vec<u32>),
I32(Vec<i32>),
}
impl AttributeData {
pub fn len(&self) -> usize {
match self {
Self::F32(v) => v.len(),
Self::F64(v) => v.len(),
Self::U8(v) => v.len(),
Self::U16(v) => v.len(),
Self::U32(v) => v.len(),
Self::I32(v) => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Attribute {
pub name: String,
pub data: AttributeData,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PointCloud {
origin: Vector3<f64>,
x: Vec<f32>,
y: Vec<f32>,
z: Vec<f32>,
attributes: Vec<Attribute>,
}
impl PointCloud {
pub fn new() -> Self {
Self::default()
}
pub fn with_origin(origin: Vector3<f64>) -> Self {
Self {
origin,
..Self::default()
}
}
pub fn rebase(&mut self, new_origin: Vector3<f64>) {
let shift = self.origin - new_origin;
let (dx, dy, dz) = (shift.x as f32, shift.y as f32, shift.z as f32);
for i in 0..self.len() {
self.x[i] += dx;
self.y[i] += dy;
self.z[i] += dz;
}
self.origin = new_origin;
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
origin: Vector3::zeros(),
x: Vec::with_capacity(capacity),
y: Vec::with_capacity(capacity),
z: Vec::with_capacity(capacity),
attributes: Vec::new(),
}
}
pub fn from_columns(
origin: Vector3<f64>,
x: Vec<f32>,
y: Vec<f32>,
z: Vec<f32>,
) -> Result<Self, CloudError> {
if x.len() != y.len() || y.len() != z.len() {
return Err(CloudError::MismatchedCoordinates {
x: x.len(),
y: y.len(),
z: z.len(),
});
}
Ok(Self {
origin,
x,
y,
z,
attributes: Vec::new(),
})
}
pub fn len(&self) -> usize {
self.x.len()
}
pub fn is_empty(&self) -> bool {
self.x.is_empty()
}
pub fn origin(&self) -> Vector3<f64> {
self.origin
}
pub fn point(&self, index: usize) -> Vector3<f64> {
self.origin + self.local(index)
}
pub fn local(&self, index: usize) -> Vector3<f64> {
Vector3::new(
f64::from(self.x[index]),
f64::from(self.y[index]),
f64::from(self.z[index]),
)
}
pub fn columns(&self) -> (&[f32], &[f32], &[f32]) {
(&self.x, &self.y, &self.z)
}
pub fn iter(&self) -> impl Iterator<Item = Vector3<f64>> + '_ {
(0..self.len()).map(|i| self.point(i))
}
pub fn push(&mut self, point: Vector3<f64>) {
assert!(
self.attributes.is_empty(),
"pushing into a cloud with attributes would break column lengths"
);
let local = point - self.origin;
self.x.push(local.x as f32);
self.y.push(local.y as f32);
self.z.push(local.z as f32);
}
pub fn attributes(&self) -> &[Attribute] {
&self.attributes
}
pub fn attribute(&self, name: &str) -> Option<&Attribute> {
self.attributes.iter().find(|a| a.name == name)
}
pub fn push_attribute(&mut self, attribute: Attribute) -> Result<(), CloudError> {
if attribute.data.len() != self.len() {
return Err(CloudError::MismatchedAttribute {
name: attribute.name,
actual: attribute.data.len(),
expected: self.len(),
});
}
if self.attribute(&attribute.name).is_some() {
return Err(CloudError::DuplicateAttribute(attribute.name));
}
self.attributes.push(attribute);
Ok(())
}
pub fn check_finite(&self) -> Result<(), CloudError> {
for i in 0..self.len() {
if !(self.x[i].is_finite() && self.y[i].is_finite() && self.z[i].is_finite()) {
return Err(CloudError::NonFinite { index: i });
}
}
Ok(())
}
pub fn bounds(&self) -> Option<(Vector3<f64>, Vector3<f64>)> {
if self.is_empty() {
return None;
}
let mut min = self.point(0);
let mut max = min;
for i in 1..self.len() {
let p = self.point(i);
min = min.inf(&p);
max = max.sup(&p);
}
Some((min, max))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn origin_preserves_precision_at_utm_scale() {
let absolute = Vector3::new(499_123.456_7, 5_432_198.765_4, 231.0);
let mut cloud = PointCloud::with_origin(Vector3::new(499_000.0, 5_432_000.0, 0.0));
cloud.push(absolute);
let error = (cloud.point(0) - absolute).norm();
assert!(error < 1e-4, "offset storage: error {error:.3e} m");
let naive = f64::from(absolute.x as f32) - absolute.x;
assert!(
naive.abs() > 1e-2,
"plain f32 must lose centimetres here, lost {naive:.3e}"
);
}
#[test]
fn mismatched_columns_are_rejected() {
let err =
PointCloud::from_columns(Vector3::zeros(), vec![1.0, 2.0], vec![1.0], vec![1.0, 2.0]);
assert!(matches!(err, Err(CloudError::MismatchedCoordinates { .. })));
}
#[test]
fn attribute_length_is_checked() {
let mut cloud =
PointCloud::from_columns(Vector3::zeros(), vec![0.0; 3], vec![0.0; 3], vec![0.0; 3])
.unwrap();
let bad = Attribute {
name: "intensity".into(),
data: AttributeData::U16(vec![1, 2]),
};
assert!(cloud.push_attribute(bad).is_err());
}
}