//! Point cloud types + PLY export + Gaussian splat conversion. #![allow(dead_code)] use serde::{Deserialize, Serialize}; use std::io::Write; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Point3D { pub x: f32, pub y: f32, pub z: f32, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ColorPoint { pub x: f32, pub y: f32, pub z: f32, pub r: u8, pub g: u8, pub b: u8, pub intensity: f32, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PointCloud { pub points: Vec, pub timestamp_ms: i64, pub source: String, } impl PointCloud { pub fn new(source: &str) -> Self { Self { points: Vec::new(), timestamp_ms: chrono::Utc::now().timestamp_millis(), source: source.to_string(), } } #[allow(clippy::too_many_arguments)] pub fn add(&mut self, x: f32, y: f32, z: f32, r: u8, g: u8, b: u8, intensity: f32) { self.points.push(ColorPoint { x, y, z, r, g, b, intensity, }); } pub fn bounds(&self) -> ([f32; 3], [f32; 3]) { if self.points.is_empty() { return ([0.0; 3], [0.0; 3]); } let mut min = [f32::MAX; 3]; let mut max = [f32::MIN; 3]; for p in &self.points { min[0] = min[0].min(p.x); min[1] = min[1].min(p.y); min[2] = min[2].min(p.z); max[0] = max[0].max(p.x); max[1] = max[1].max(p.y); max[2] = max[2].max(p.z); } (min, max) } } /// Write point cloud to PLY format (ASCII). pub fn write_ply(cloud: &PointCloud, path: &str) -> anyhow::Result<()> { let mut f = std::fs::File::create(path)?; writeln!(f, "ply")?; writeln!(f, "format ascii 1.0")?; writeln!(f, "comment Generated by RuView Dense Point Cloud")?; writeln!(f, "comment Source: {}", cloud.source)?; writeln!(f, "comment Timestamp: {}", cloud.timestamp_ms)?; writeln!(f, "element vertex {}", cloud.points.len())?; writeln!(f, "property float x")?; writeln!(f, "property float y")?; writeln!(f, "property float z")?; writeln!(f, "property uchar red")?; writeln!(f, "property uchar green")?; writeln!(f, "property uchar blue")?; writeln!(f, "property float intensity")?; writeln!(f, "end_header")?; for p in &cloud.points { writeln!( f, "{:.4} {:.4} {:.4} {} {} {} {:.4}", p.x, p.y, p.z, p.r, p.g, p.b, p.intensity )?; } Ok(()) } /// Convert point cloud to Gaussian splats for 3D rendering. #[derive(Serialize, Deserialize)] pub struct GaussianSplat { pub center: [f32; 3], pub color: [f32; 3], pub opacity: f32, pub scale: [f32; 3], } pub fn to_gaussian_splats(cloud: &PointCloud) -> Vec { // Cluster points into voxels and create one Gaussian per cluster let voxel_size = 0.08; // smaller voxels = more detail = visible movement let mut cells: std::collections::HashMap<(i32, i32, i32), Vec<&ColorPoint>> = std::collections::HashMap::new(); for p in &cloud.points { let key = ( (p.x / voxel_size).floor() as i32, (p.y / voxel_size).floor() as i32, (p.z / voxel_size).floor() as i32, ); cells.entry(key).or_default().push(p); } cells .values() .map(|pts| { let n = pts.len() as f32; // Pass 1 — single fused accumulation of all six sums (position + // colour). Replaces six separate `.iter().sum()` passes; identical // f32 accumulation order, so the result is bit-for-bit unchanged. let (mut sum_x, mut sum_y, mut sum_z) = (0.0f32, 0.0f32, 0.0f32); let (mut sum_r, mut sum_g, mut sum_b) = (0.0f32, 0.0f32, 0.0f32); for p in pts { sum_x += p.x; sum_y += p.y; sum_z += p.z; sum_r += p.r as f32; sum_g += p.g as f32; sum_b += p.b as f32; } let cx = sum_x / n; let cy = sum_y / n; let cz = sum_z / n; let cr = sum_r / n / 255.0; let cg = sum_g / n / 255.0; let cb = sum_b / n / 255.0; // Pass 2 — spread (mean absolute deviation) needs the centroid, so // it is a second fused pass instead of three separate ones. let (mut dev_x, mut dev_y, mut dev_z) = (0.0f32, 0.0f32, 0.0f32); for p in pts { dev_x += (p.x - cx).abs(); dev_y += (p.y - cy).abs(); dev_z += (p.z - cz).abs(); } let sx = dev_x / n + 0.01; let sy = dev_y / n + 0.01; let sz = dev_z / n + 0.01; GaussianSplat { center: [cx, cy, cz], color: [cr, cg, cb], opacity: (n / 10.0).min(1.0), scale: [sx, sy, sz], } }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn empty_cloud_has_no_splats() { let cloud = PointCloud::new("test"); assert!(to_gaussian_splats(&cloud).is_empty()); } #[test] fn single_voxel_centroid_and_scale_are_correct() { // Two points inside the same 0.08 m voxel: (0.01,0.01,0.01) and // (0.03,0.03,0.03). Centroid = 0.02 each axis; mean-abs-dev = 0.01; // scale = 0.01 + 0.01 = 0.02. Colours: r=0 and r=255 → mean 127.5/255. let mut cloud = PointCloud::new("test"); cloud.add(0.01, 0.01, 0.01, 0, 0, 0, 1.0); cloud.add(0.03, 0.03, 0.03, 255, 255, 255, 1.0); let splats = to_gaussian_splats(&cloud); assert_eq!(splats.len(), 1, "both points fall in one voxel"); let s = &splats[0]; for axis in 0..3 { assert!((s.center[axis] - 0.02).abs() < 1e-5, "center[{axis}]={}", s.center[axis]); assert!((s.scale[axis] - 0.02).abs() < 1e-5, "scale[{axis}]={}", s.scale[axis]); assert!((s.color[axis] - 127.5 / 255.0).abs() < 1e-5, "color[{axis}]"); } // opacity = n/10 = 0.2 assert!((s.opacity - 0.2).abs() < 1e-6); } #[test] fn distinct_voxels_yield_distinct_splats() { // Two points far apart → two separate voxels → two splats. let mut cloud = PointCloud::new("test"); cloud.add(0.0, 0.0, 0.0, 10, 20, 30, 1.0); cloud.add(1.0, 1.0, 1.0, 40, 50, 60, 1.0); assert_eq!(to_gaussian_splats(&cloud).len(), 2); } }