use nalgebra::Vector3;
use rayon::prelude::*;
use crate::cloud::{CloudError, PointCloud};
type VoxelKey = [i64; 3];
pub fn voxel_downsample(cloud: &PointCloud, voxel_size: f64) -> Result<PointCloud, CloudError> {
voxel_downsample_observed(cloud, voxel_size, |_, _| {})
}
pub fn voxel_downsample_observed<F>(
cloud: &PointCloud,
voxel_size: f64,
mut progress: F,
) -> Result<PointCloud, CloudError>
where
F: FnMut(usize, usize),
{
const PHASES: usize = 4;
if !(voxel_size.is_finite() && voxel_size > 0.0) {
return Err(CloudError::InvalidVoxelSize(voxel_size));
}
cloud.check_finite()?;
let count = cloud.len();
if count == 0 {
return Ok(PointCloud::with_origin(cloud.origin()));
}
let inverse_size = 1.0 / voxel_size;
let keys: Vec<VoxelKey> = (0..count)
.into_par_iter()
.map(|i| {
let p = cloud.local(i);
[
(p.x * inverse_size).floor() as i64,
(p.y * inverse_size).floor() as i64,
(p.z * inverse_size).floor() as i64,
]
})
.collect();
progress(1, PHASES);
let mut order: Vec<u32> = (0..count as u32).collect();
order.par_sort_unstable_by_key(|&i| (keys[i as usize], i));
progress(2, PHASES);
let mut segments: Vec<(usize, usize)> = Vec::new();
let mut start = 0usize;
for i in 1..count {
if keys[order[i] as usize] != keys[order[start] as usize] {
segments.push((start, i));
start = i;
}
}
segments.push((start, count));
progress(3, PHASES);
let centroids: Vec<Vector3<f64>> = segments
.par_iter()
.map(|&(begin, end)| {
let mut sum = Vector3::zeros();
for &index in &order[begin..end] {
sum += cloud.local(index as usize);
}
sum / (end - begin) as f64
})
.collect();
let mut result = PointCloud::with_capacity(centroids.len());
result.rebase(cloud.origin());
for centroid in centroids {
result.push(cloud.origin() + centroid);
}
progress(4, PHASES);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn grid_cloud(side: usize) -> PointCloud {
let mut cloud = PointCloud::with_capacity(side * side * side);
for i in 0..side {
for j in 0..side {
for k in 0..side {
cloud.push(Vector3::new(i as f64 * 0.1, j as f64 * 0.1, k as f64 * 0.1));
}
}
}
cloud
}
#[test]
fn rejects_bad_voxel_size() {
let cloud = grid_cloud(2);
assert!(voxel_downsample(&cloud, 0.0).is_err());
assert!(voxel_downsample(&cloud, -1.0).is_err());
assert!(voxel_downsample(&cloud, f64::NAN).is_err());
}
#[test]
fn empty_cloud_stays_empty() {
let cloud = PointCloud::new();
assert!(voxel_downsample(&cloud, 1.0).unwrap().is_empty());
}
#[test]
fn single_voxel_gives_centroid() {
let cloud = grid_cloud(4);
let reduced = voxel_downsample(&cloud, 100.0).unwrap();
assert_eq!(reduced.len(), 1);
let expected = Vector3::new(0.15, 0.15, 0.15);
let error = (reduced.point(0) - expected).norm();
assert!(error < 1e-6, "centroid deviation {error:.3e}");
assert!(
error > 1e-10,
"the f32 floor disappeared — check whether storage became f64"
);
}
#[test]
fn output_covers_input() {
let cloud = grid_cloud(6);
let size = 0.25;
let reduced = voxel_downsample(&cloud, size).unwrap();
assert!(reduced.len() < cloud.len());
let radius = size * 3f64.sqrt();
for i in 0..cloud.len() {
let p = cloud.point(i);
let nearest = (0..reduced.len())
.map(|j| (reduced.point(j) - p).norm())
.fold(f64::INFINITY, f64::min);
assert!(nearest <= radius, "point {i}: nearest at {nearest}");
}
}
}