use crate::parallel;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use threecrate_core::{Error, NormalPoint3f, Point3f, PointCloud, Result, TriangleMesh};
#[derive(Debug, Clone)]
pub struct BallPivotingConfig {
pub radius: f32,
pub additional_radii: Vec<f32>,
pub adaptive_radius: bool,
pub adaptive_percentiles: Vec<f32>,
pub clustering: f32,
pub normal_threshold: f32,
pub estimate_normals: bool,
pub quality_threshold: f32,
pub max_edge_factor: f32,
pub min_triangle_area: f32,
pub fill_holes: bool,
pub max_pivoting_iterations: usize,
pub use_spatial_index: bool,
pub spatial_cell_factor: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AdaptiveStrategy {
DensityPercentile,
KNearestNeighbor,
CovarianceBased,
Hybrid,
}
impl Default for BallPivotingConfig {
fn default() -> Self {
Self {
radius: 0.1,
additional_radii: vec![0.05, 0.15, 0.2],
adaptive_radius: true,
adaptive_percentiles: vec![0.25, 0.5, 0.75, 0.9],
clustering: 0.05,
normal_threshold: 0.866, estimate_normals: true,
quality_threshold: 0.1,
max_edge_factor: 3.0,
min_triangle_area: 1e-8,
fill_holes: true,
max_pivoting_iterations: 20,
use_spatial_index: true,
spatial_cell_factor: 2.0,
}
}
}
#[derive(Debug, Clone)]
pub struct TriangleQuality {
pub aspect_ratio: f32,
pub min_angle: f32,
pub max_angle: f32,
pub area: f32,
pub edge_ratio: f32,
pub quality_score: f32,
}
#[derive(Debug)]
struct SpatialGrid {
cells: HashMap<(i32, i32, i32), Vec<usize>>,
cell_size: f32,
bounds_min: Point3f,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct PivotEdge {
v1: usize,
v2: usize,
opposite_vertex: Option<usize>, }
#[derive(Debug, Clone)]
struct ProcessingItem {
edge: PivotEdge,
priority: OrderedFloat,
#[allow(dead_code)]
radius: f32,
}
impl PartialEq for ProcessingItem {
fn eq(&self, other: &Self) -> bool {
self.priority == other.priority
}
}
impl Eq for ProcessingItem {}
impl PartialOrd for ProcessingItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ProcessingItem {
fn cmp(&self, other: &Self) -> Ordering {
self.priority.cmp(&other.priority)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct OrderedFloat(f32);
impl Eq for OrderedFloat {}
impl PartialOrd for OrderedFloat {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.0.partial_cmp(&other.0)
}
}
impl Ord for OrderedFloat {
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap_or(Ordering::Equal)
}
}
pub struct BallPivotingReconstructor {
config: BallPivotingConfig,
spatial_grid: Option<SpatialGrid>,
adaptive_radii: Vec<f32>,
}
impl BallPivotingReconstructor {
pub fn new(config: BallPivotingConfig) -> Self {
Self {
config,
spatial_grid: None,
adaptive_radii: Vec::new(),
}
}
fn build_spatial_grid(&mut self, points: &[Point3f], cell_size: f32) {
let mut grid = SpatialGrid {
cells: HashMap::new(),
cell_size,
bounds_min: Point3f::new(f32::INFINITY, f32::INFINITY, f32::INFINITY),
};
let mut bounds_max = Point3f::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
for point in points {
grid.bounds_min.x = grid.bounds_min.x.min(point.x);
grid.bounds_min.y = grid.bounds_min.y.min(point.y);
grid.bounds_min.z = grid.bounds_min.z.min(point.z);
bounds_max.x = bounds_max.x.max(point.x);
bounds_max.y = bounds_max.y.max(point.y);
bounds_max.z = bounds_max.z.max(point.z);
}
for (i, point) in points.iter().enumerate() {
let cell_x = ((point.x - grid.bounds_min.x) / cell_size) as i32;
let cell_y = ((point.y - grid.bounds_min.y) / cell_size) as i32;
let cell_z = ((point.z - grid.bounds_min.z) / cell_size) as i32;
grid.cells
.entry((cell_x, cell_y, cell_z))
.or_insert_with(Vec::new)
.push(i);
}
self.spatial_grid = Some(grid);
}
fn find_neighbors(&self, point: &Point3f, radius: f32) -> Vec<usize> {
let mut neighbors = Vec::new();
if let Some(grid) = &self.spatial_grid {
let search_range = (radius / grid.cell_size).ceil() as i32;
let center_x = ((point.x - grid.bounds_min.x) / grid.cell_size) as i32;
let center_y = ((point.y - grid.bounds_min.y) / grid.cell_size) as i32;
let center_z = ((point.z - grid.bounds_min.z) / grid.cell_size) as i32;
for dx in -search_range..=search_range {
for dy in -search_range..=search_range {
for dz in -search_range..=search_range {
let cell_key = (center_x + dx, center_y + dy, center_z + dz);
if let Some(indices) = grid.cells.get(&cell_key) {
neighbors.extend(indices);
}
}
}
}
}
neighbors
}
fn compute_adaptive_radii(&mut self, points: &[Point3f]) -> Result<()> {
if !self.config.adaptive_radius {
return Ok(());
}
let mut local_densities = Vec::new();
let sample_size = points.len().min(1000);
let step = (points.len().max(1) / sample_size.max(1)).max(1);
for i in (0..points.len()).step_by(step) {
let point = &points[i];
let neighbors = if self.spatial_grid.is_some() {
self.find_neighbors(point, self.config.radius * 3.0)
} else {
(0..points.len()).collect()
};
let mut distances: Vec<f32> = neighbors
.iter()
.filter(|&&j| j != i)
.map(|&j| (point - &points[j]).magnitude())
.collect();
distances.sort_by(|a, b| a.partial_cmp(b).unwrap());
let k = 10.min(distances.len());
if k > 0 {
let avg_k_dist = distances.iter().take(k).sum::<f32>() / k as f32;
local_densities.push(avg_k_dist);
}
}
if local_densities.is_empty() {
return Ok(());
}
local_densities.sort_by(|a, b| a.partial_cmp(b).unwrap());
self.adaptive_radii.clear();
for &percentile in &self.config.adaptive_percentiles {
let index = ((local_densities.len() - 1) as f32 * percentile) as usize;
let radius = local_densities[index] * 2.0; self.adaptive_radii.push(radius);
}
self.adaptive_radii.push(self.config.radius);
self.adaptive_radii.extend(&self.config.additional_radii);
self.adaptive_radii
.sort_by(|a, b| a.partial_cmp(b).unwrap());
self.adaptive_radii.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
Ok(())
}
fn compute_triangle_quality(
&self,
p1: &Point3f,
p2: &Point3f,
p3: &Point3f,
) -> TriangleQuality {
let v1 = p2 - p1;
let v2 = p3 - p2;
let v3 = p1 - p3;
let a = v1.magnitude();
let b = v2.magnitude();
let c = v3.magnitude();
let cross = v1.cross(&(p3 - p1));
let area = cross.magnitude() * 0.5;
if area < 1e-10 {
return TriangleQuality {
aspect_ratio: 0.0,
min_angle: 0.0,
max_angle: std::f32::consts::PI,
area,
edge_ratio: f32::INFINITY,
quality_score: 0.0,
};
}
let angle_a = ((b * b + c * c - a * a) / (2.0 * b * c))
.clamp(-1.0, 1.0)
.acos();
let angle_b = ((a * a + c * c - b * b) / (2.0 * a * c))
.clamp(-1.0, 1.0)
.acos();
let angle_c = std::f32::consts::PI - angle_a - angle_b;
let min_angle = angle_a.min(angle_b).min(angle_c);
let max_angle = angle_a.max(angle_b).max(angle_c);
let circumradius = Self::compute_circumradius(p1, p2, p3);
let inradius = area / ((a + b + c) * 0.5);
let aspect_ratio = if circumradius > 1e-10 {
inradius / circumradius
} else {
0.0
};
let min_edge = a.min(b).min(c);
let max_edge = a.max(b).max(c);
let edge_ratio = if min_edge > 1e-10 {
max_edge / min_edge
} else {
f32::INFINITY
};
let angle_quality = (min_angle / (std::f32::consts::PI / 6.0)).min(1.0); let aspect_quality = aspect_ratio * 2.0; let edge_quality = (3.0 / edge_ratio).min(1.0);
let quality_score = (angle_quality + aspect_quality + edge_quality) / 3.0;
TriangleQuality {
aspect_ratio,
min_angle,
max_angle,
area,
edge_ratio,
quality_score,
}
}
fn is_triangle_acceptable(&self, quality: &TriangleQuality, radius: f32) -> bool {
if quality.area < self.config.min_triangle_area {
return false;
}
if quality.quality_score < self.config.quality_threshold {
return false;
}
let max_edge_length = radius * self.config.max_edge_factor;
if quality.edge_ratio * quality.area.sqrt() > max_edge_length {
return false;
}
true
}
fn advanced_ball_pivoting(
&mut self,
points: &[Point3f],
normals: &[Point3f],
) -> Result<Vec<[usize; 3]>> {
let mut all_triangles = Vec::new();
let mut used_points = HashSet::new();
for &radius in &self.adaptive_radii {
let mut triangles = Vec::new();
let mut frontier = BinaryHeap::new();
let mut processed_edges = HashSet::new();
if let Some(seed) = self.find_seed_triangle(points, normals, radius, &used_points) {
triangles.push(seed);
used_points.insert(seed[0]);
used_points.insert(seed[1]);
used_points.insert(seed[2]);
self.add_triangle_edges_to_frontier(
&mut frontier,
&seed,
radius,
&mut processed_edges,
);
while let Some(item) = frontier.pop() {
if triangles.len() > 50000 {
break;
}
let edge = &item.edge;
if processed_edges.contains(&(edge.v1, edge.v2))
|| processed_edges.contains(&(edge.v2, edge.v1))
{
continue;
}
if let Some(triangle) =
self.try_ball_pivot(points, normals, edge, radius, &used_points)
{
let quality = self.compute_triangle_quality(
&points[triangle[0]],
&points[triangle[1]],
&points[triangle[2]],
);
if self.is_triangle_acceptable(&quality, radius) {
triangles.push(triangle);
used_points.insert(triangle[2]);
self.add_triangle_edges_to_frontier(
&mut frontier,
&triangle,
radius,
&mut processed_edges,
);
}
}
processed_edges.insert((edge.v1, edge.v2));
}
}
all_triangles.extend(triangles);
}
Ok(all_triangles)
}
fn find_seed_triangle(
&self,
points: &[Point3f],
_normals: &[Point3f],
radius: f32,
used_points: &HashSet<usize>,
) -> Option<[usize; 3]> {
let max_attempts = 1000;
let mut attempts = 0;
for i in 0..points.len() {
if used_points.contains(&i) {
continue;
}
if attempts >= max_attempts {
break;
}
attempts += 1;
let neighbors = if self.spatial_grid.is_some() {
self.find_neighbors(&points[i], radius * 2.0)
} else {
(0..points.len()).collect()
};
for &j in &neighbors {
if i == j || used_points.contains(&j) {
continue;
}
if (points[i] - points[j]).magnitude() > radius * 2.0 {
continue;
}
for &k in &neighbors {
if i == k || j == k || used_points.contains(&k) {
continue;
}
if (points[i] - points[k]).magnitude() > radius * 2.0 {
continue;
}
if (points[j] - points[k]).magnitude() > radius * 2.0 {
continue;
}
let quality = self.compute_triangle_quality(&points[i], &points[j], &points[k]);
if self.is_triangle_acceptable(&quality, radius) {
return Some([i, j, k]);
}
}
}
}
None
}
fn add_triangle_edges_to_frontier(
&self,
frontier: &mut BinaryHeap<ProcessingItem>,
triangle: &[usize; 3],
radius: f32,
processed: &mut HashSet<(usize, usize)>,
) {
let edges = [
(triangle[0], triangle[1]),
(triangle[1], triangle[2]),
(triangle[2], triangle[0]),
];
for &(v1, v2) in &edges {
if !processed.contains(&(v1, v2)) && !processed.contains(&(v2, v1)) {
let edge = PivotEdge {
v1,
v2,
opposite_vertex: Some(triangle[2]),
};
let priority = OrderedFloat(radius); frontier.push(ProcessingItem {
edge,
priority,
radius,
});
}
}
}
fn try_ball_pivot(
&self,
points: &[Point3f],
_normals: &[Point3f],
edge: &PivotEdge,
radius: f32,
used_points: &HashSet<usize>,
) -> Option<[usize; 3]> {
let p1 = &points[edge.v1];
let p2 = &points[edge.v2];
let edge_vec = p2 - p1;
let edge_length = edge_vec.magnitude();
if edge_length > radius * 2.0 || edge_length < 1e-10 {
return None;
}
let edge_mid = Point3f::from((p1.coords + p2.coords) * 0.5);
let _edge_dir = edge_vec.normalize();
let candidates = if self.spatial_grid.is_some() {
self.find_neighbors(&edge_mid, radius)
} else {
(0..points.len()).collect()
};
let mut best_candidate = None;
let mut best_quality = 0.0;
for &candidate in &candidates {
if candidate == edge.v1 || candidate == edge.v2 || used_points.contains(&candidate) {
continue;
}
let p3 = &points[candidate];
let dist1 = (p3 - p1).magnitude();
let dist2 = (p3 - p2).magnitude();
if dist1 <= radius && dist2 <= radius {
let quality = self.compute_triangle_quality(p1, p2, p3);
if self.is_triangle_acceptable(&quality, radius)
&& quality.quality_score > best_quality
{
best_quality = quality.quality_score;
best_candidate = Some([edge.v1, edge.v2, candidate]);
}
}
}
best_candidate
}
fn estimate_simple_normals(points: &[Point3f]) -> Result<Vec<Point3f>> {
let normals = parallel::point_cloud::parallel_compute_normals(
points,
0.2, |_point, neighbors, _radius| {
if neighbors.len() >= 3 {
let mut centroid = Point3f::origin();
for neighbor in neighbors {
centroid = Point3f::from(centroid.coords + neighbor.coords);
}
centroid = Point3f::from(centroid.coords / neighbors.len() as f32);
let mut covariance = nalgebra::Matrix3::zeros();
for neighbor in neighbors {
let diff = neighbor - centroid;
covariance += diff * diff.transpose();
}
let eigen = covariance.symmetric_eigen();
let normal_idx = eigen.eigenvalues.imin();
let normal = eigen.eigenvectors.column(normal_idx).clone_owned();
let normal_point = Point3f::new(normal.x, normal.y, normal.z);
let normalized = normal_point.coords.normalize();
Point3f::from(normalized)
} else {
Point3f::new(0.0, 0.0, 1.0)
}
},
);
Ok(normals)
}
fn simple_triangulate(
points: &[Point3f],
_normals: &[Point3f],
radius: f32,
) -> Result<Vec<[usize; 3]>> {
if points.len() < 3 {
return Ok(Vec::new());
}
let mut triangles = Vec::new();
let max_triangles = 10000;
for (i, center) in points.iter().enumerate() {
if triangles.len() >= max_triangles {
break;
}
let mut candidates = Vec::new();
for (j, point) in points.iter().enumerate() {
if i != j {
let dist = (center - point).magnitude();
if dist <= radius * 2.0 {
candidates.push(j);
}
}
}
for (idx_a, &a) in candidates.iter().enumerate() {
if triangles.len() >= max_triangles {
break;
}
for &b in candidates.iter().skip(idx_a + 1) {
if triangles.len() >= max_triangles {
break;
}
if a >= i || b >= i {
continue;
}
let p1 = &points[i];
let p2 = &points[a];
let p3 = &points[b];
let v1 = p2 - p1;
let v2 = p3 - p1;
let tri_normal = v1.cross(&v2);
if tri_normal.magnitude() > 1e-6 {
let circumradius = Self::compute_circumradius(p1, p2, p3);
if circumradius <= radius * 1.5 {
triangles.push([i, a, b]);
}
}
}
}
}
Ok(triangles)
}
fn compute_circumradius(p1: &Point3f, p2: &Point3f, p3: &Point3f) -> f32 {
let a = (p2 - p1).magnitude();
let b = (p3 - p2).magnitude();
let c = (p1 - p3).magnitude();
let v1 = p2 - p1;
let v2 = p3 - p1;
let area = v1.cross(&v2).magnitude() * 0.5;
if area < 1e-10 {
return f32::INFINITY;
}
(a * b * c) / (4.0 * area)
}
pub fn reconstruct(&mut self, cloud: &PointCloud<Point3f>) -> Result<TriangleMesh> {
if cloud.is_empty() {
return Err(Error::InvalidData("Point cloud is empty".to_string()));
}
let normals = if self.config.estimate_normals {
Self::estimate_simple_normals(&cloud.points)?
} else {
vec![Point3f::new(0.0, 0.0, 1.0); cloud.points.len()]
};
if self.config.use_spatial_index {
let cell_size = self.config.radius * self.config.spatial_cell_factor;
self.build_spatial_grid(&cloud.points, cell_size);
}
self.compute_adaptive_radii(&cloud.points)?;
let faces = if !self.adaptive_radii.is_empty() {
self.advanced_ball_pivoting(&cloud.points, &normals)?
} else {
Self::simple_triangulate(&cloud.points, &normals, self.config.radius)?
};
if faces.is_empty() {
return Err(Error::Algorithm(
"Ball pivoting generated no triangles".to_string(),
));
}
let mut mesh = TriangleMesh::from_vertices_and_faces(cloud.points.clone(), faces);
mesh.set_normals(
normals
.iter()
.map(|n| nalgebra::Vector3::new(n.x, n.y, n.z))
.collect(),
);
Ok(mesh)
}
pub fn reconstruct_with_normals(
&mut self,
cloud: &PointCloud<NormalPoint3f>,
) -> Result<TriangleMesh> {
if cloud.is_empty() {
return Err(Error::InvalidData("Point cloud is empty".to_string()));
}
let points: Vec<Point3f> = cloud.points.iter().map(|p| p.position).collect();
let normals: Vec<Point3f> = cloud
.points
.iter()
.map(|p| Point3f::from(p.normal))
.collect();
if self.config.use_spatial_index {
let cell_size = self.config.radius * self.config.spatial_cell_factor;
self.build_spatial_grid(&points, cell_size);
}
self.compute_adaptive_radii(&points)?;
let faces = if !self.adaptive_radii.is_empty() {
self.advanced_ball_pivoting(&points, &normals)?
} else {
Self::simple_triangulate(&points, &normals, self.config.radius)?
};
if faces.is_empty() {
return Err(Error::Algorithm(
"Ball pivoting generated no triangles".to_string(),
));
}
let mut mesh = TriangleMesh::from_vertices_and_faces(points, faces);
mesh.set_normals(
normals
.iter()
.map(|n| nalgebra::Vector3::new(n.x, n.y, n.z))
.collect(),
);
Ok(mesh)
}
}
pub fn ball_pivoting_reconstruction(
cloud: &PointCloud<Point3f>,
radius: f32,
) -> Result<TriangleMesh> {
let config = BallPivotingConfig {
radius,
..Default::default()
};
let mut reconstructor = BallPivotingReconstructor::new(config);
reconstructor.reconstruct(cloud)
}
pub fn ball_pivoting_reconstruction_with_config(
cloud: &PointCloud<Point3f>,
config: &BallPivotingConfig,
) -> Result<TriangleMesh> {
let mut reconstructor = BallPivotingReconstructor::new(config.clone());
reconstructor.reconstruct(cloud)
}
pub fn ball_pivoting_from_normals(
cloud: &PointCloud<NormalPoint3f>,
radius: f32,
) -> Result<TriangleMesh> {
let config = BallPivotingConfig {
radius,
estimate_normals: false, ..Default::default()
};
let mut reconstructor = BallPivotingReconstructor::new(config);
reconstructor.reconstruct_with_normals(cloud)
}
pub fn estimate_optimal_radius(cloud: &PointCloud<Point3f>, percentile: f32) -> Result<f32> {
if cloud.is_empty() {
return Err(Error::InvalidData("Point cloud is empty".to_string()));
}
if !(0.0..=1.0).contains(&percentile) {
return Err(Error::InvalidData(
"Percentile must be between 0.0 and 1.0".to_string(),
));
}
let mut distances = Vec::new();
let sample_size = (cloud.points.len() / 10).max(100).min(1000);
let step = (cloud.points.len().max(1) / sample_size.max(1)).max(1);
for i in (0..cloud.points.len()).step_by(step) {
let point = &cloud.points[i];
let mut min_dist = f32::INFINITY;
for (j, other_point) in cloud.points.iter().enumerate() {
if i != j {
let dist = (point - other_point).magnitude();
if dist < min_dist {
min_dist = dist;
}
}
}
if min_dist < f32::INFINITY {
distances.push(min_dist);
}
}
if distances.is_empty() {
return Ok(0.1); }
distances.sort_by(|a, b| a.partial_cmp(b).unwrap());
let index = ((distances.len() - 1) as f32 * percentile) as usize;
Ok(distances[index] * 2.5)
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::Point3;
#[test]
fn test_ball_pivoting_config_default() {
let config = BallPivotingConfig::default();
assert_eq!(config.radius, 0.1);
assert_eq!(config.clustering, 0.05);
assert_eq!(config.normal_threshold, 0.866);
assert!(config.estimate_normals);
}
#[test]
fn test_empty_cloud() {
let config = BallPivotingConfig::default();
let mut reconstructor = BallPivotingReconstructor::new(config);
let cloud = PointCloud::new();
let result = reconstructor.reconstruct(&cloud);
assert!(result.is_err());
}
#[test]
fn test_estimate_optimal_radius_empty() {
let cloud = PointCloud::new();
let result = estimate_optimal_radius(&cloud, 0.5);
assert!(result.is_err());
}
#[test]
fn test_estimate_optimal_radius_invalid_percentile() {
let points = vec![Point3::new(0.0, 0.0, 0.0)];
let cloud = PointCloud::from_points(points);
assert!(estimate_optimal_radius(&cloud, -0.1).is_err());
assert!(estimate_optimal_radius(&cloud, 1.1).is_err());
}
#[test]
fn test_ball_pivoting_simple() {
let points = vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.5, 1.0, 0.0),
Point3::new(0.5, 0.33, 0.5),
];
let cloud = PointCloud::from_points(points);
let result = ball_pivoting_reconstruction(&cloud, 2.0);
match result {
Ok(mesh) => {
assert!(!mesh.is_empty());
assert!(mesh.vertex_count() <= 4);
}
Err(_) => {
}
}
}
#[test]
fn test_ball_pivoting_with_normals() {
let normal_points = vec![
NormalPoint3f {
position: Point3::new(0.0, 0.0, 0.0),
normal: nalgebra::Vector3::new(0.0, 0.0, 1.0),
},
NormalPoint3f {
position: Point3::new(1.0, 0.0, 0.0),
normal: nalgebra::Vector3::new(0.0, 0.0, 1.0),
},
NormalPoint3f {
position: Point3::new(0.5, 1.0, 0.0),
normal: nalgebra::Vector3::new(0.0, 0.0, 1.0),
},
];
let cloud = PointCloud::from_points(normal_points);
let result = ball_pivoting_from_normals(&cloud, 2.0);
match result {
Ok(mesh) => {
assert_eq!(mesh.vertex_count(), 3);
}
Err(_) => {
}
}
}
#[test]
fn test_circumradius_calculation() {
let p1 = Point3::new(0.0, 0.0, 0.0);
let p2 = Point3::new(1.0, 0.0, 0.0);
let p3 = Point3::new(0.0, 1.0, 0.0);
let radius = BallPivotingReconstructor::compute_circumradius(&p1, &p2, &p3);
assert!((radius - 0.707).abs() < 0.1);
}
}