use crate::bool2d::{ensure_ccw, is_valid_contour};
use crate::mesh::Mesh;
use crate::profile::VoidInfo;
use nalgebra::{Matrix4, Point2, Point3, Vector3};
use rustc_hash::FxHashMap;
const DEFAULT_PLANARITY_EPSILON: f64 = 0.02;
const THROUGH_VOID_TOLERANCE: f64 = 0.01;
#[derive(Debug, Clone)]
pub enum VoidClassification {
Coplanar {
profile_hole: Vec<Point2<f64>>,
depth_start: f64,
depth_end: f64,
is_through: bool,
},
NonPlanar {
mesh: Mesh,
},
NonIntersecting,
}
pub struct VoidAnalyzer {
planarity_epsilon: f64,
adaptive_epsilon: bool,
}
impl VoidAnalyzer {
pub fn new() -> Self {
Self {
planarity_epsilon: DEFAULT_PLANARITY_EPSILON,
adaptive_epsilon: true,
}
}
pub fn with_epsilon(epsilon: f64) -> Self {
Self {
planarity_epsilon: epsilon,
adaptive_epsilon: false,
}
}
pub fn classify_void(
&self,
void_mesh: &Mesh,
profile_transform: &Matrix4<f64>,
extrusion_direction: &Vector3<f64>,
extrusion_depth: f64,
) -> VoidClassification {
if void_mesh.is_empty() {
return VoidClassification::NonIntersecting;
}
let profile_normal = transform_direction(profile_transform, &Vector3::new(0.0, 0.0, 1.0));
let is_coplanar = self.check_coplanarity(void_mesh, &profile_normal, extrusion_direction);
if !is_coplanar {
return VoidClassification::NonPlanar {
mesh: void_mesh.clone(),
};
}
let inverse_transform = match profile_transform.try_inverse() {
Some(inv) => inv,
None => {
return VoidClassification::NonPlanar {
mesh: void_mesh.clone(),
}
}
};
match self.extract_footprint(void_mesh, &inverse_transform, extrusion_direction) {
Some((footprint, depth_start, depth_end)) => {
if !is_valid_contour(&footprint) {
return VoidClassification::NonPlanar {
mesh: void_mesh.clone(),
};
}
let is_through = depth_start <= THROUGH_VOID_TOLERANCE
&& depth_end >= extrusion_depth - THROUGH_VOID_TOLERANCE;
VoidClassification::Coplanar {
profile_hole: footprint,
depth_start,
depth_end,
is_through,
}
}
None => VoidClassification::NonPlanar {
mesh: void_mesh.clone(),
},
}
}
fn check_coplanarity(
&self,
void_mesh: &Mesh,
profile_normal: &Vector3<f64>,
extrusion_direction: &Vector3<f64>,
) -> bool {
let face_normals = self.extract_face_normals(void_mesh);
if face_normals.is_empty() {
return false;
}
let mut has_parallel_face = false;
let mut epsilon = self.planarity_epsilon;
let epsilons = if self.adaptive_epsilon {
vec![0.02, 0.01, 0.005, 0.001]
} else {
vec![epsilon]
};
for eps in epsilons {
epsilon = eps;
has_parallel_face = false;
for normal in &face_normals {
let dot_profile = normal.dot(profile_normal).abs();
let dot_extrusion = normal.dot(extrusion_direction).abs();
if dot_profile > 1.0 - epsilon {
has_parallel_face = true;
break;
}
if dot_extrusion < epsilon {
has_parallel_face = true;
break;
}
}
if has_parallel_face {
break;
}
}
has_parallel_face
}
fn extract_face_normals(&self, mesh: &Mesh) -> Vec<Vector3<f64>> {
let mut normal_groups: FxHashMap<(i32, i32, i32), (Vector3<f64>, usize)> =
FxHashMap::default();
let quantize_normal = |n: &Vector3<f64>| -> (i32, i32, i32) {
(
(n.x * 100.0).round() as i32,
(n.y * 100.0).round() as i32,
(n.z * 100.0).round() as i32,
)
};
for i in (0..mesh.indices.len()).step_by(3) {
let i0 = mesh.indices[i] as usize;
let i1 = mesh.indices[i + 1] as usize;
let i2 = mesh.indices[i + 2] as usize;
if i0 * 3 + 2 >= mesh.positions.len()
|| i1 * 3 + 2 >= mesh.positions.len()
|| i2 * 3 + 2 >= mesh.positions.len()
{
continue;
}
let v0 = Point3::new(
mesh.positions[i0 * 3] as f64,
mesh.positions[i0 * 3 + 1] as f64,
mesh.positions[i0 * 3 + 2] as f64,
);
let v1 = Point3::new(
mesh.positions[i1 * 3] as f64,
mesh.positions[i1 * 3 + 1] as f64,
mesh.positions[i1 * 3 + 2] as f64,
);
let v2 = Point3::new(
mesh.positions[i2 * 3] as f64,
mesh.positions[i2 * 3 + 1] as f64,
mesh.positions[i2 * 3 + 2] as f64,
);
let edge1 = v1 - v0;
let edge2 = v2 - v0;
let normal = match edge1.cross(&edge2).try_normalize(1e-10) {
Some(n) => n,
None => continue,
};
let key = quantize_normal(&normal);
normal_groups
.entry(key)
.and_modify(|(sum, count)| {
*sum += normal;
*count += 1;
})
.or_insert((normal, 1));
}
normal_groups
.values()
.filter_map(|(sum, count)| {
if *count > 0 {
(sum / *count as f64).try_normalize(1e-10)
} else {
None
}
})
.collect()
}
fn extract_footprint(
&self,
void_mesh: &Mesh,
inverse_profile_transform: &Matrix4<f64>,
_extrusion_direction: &Vector3<f64>,
) -> Option<(Vec<Point2<f64>>, f64, f64)> {
if void_mesh.is_empty() {
return None;
}
let mut min_z = f64::MAX;
let mut max_z = f64::MIN;
let mut projected_points: Vec<Point2<f64>> = Vec::new();
for i in (0..void_mesh.positions.len()).step_by(3) {
let world_point = Point3::new(
void_mesh.positions[i] as f64,
void_mesh.positions[i + 1] as f64,
void_mesh.positions[i + 2] as f64,
);
let profile_point = inverse_profile_transform.transform_point(&world_point);
min_z = min_z.min(profile_point.z);
max_z = max_z.max(profile_point.z);
projected_points.push(Point2::new(profile_point.x, profile_point.y));
}
if projected_points.len() < 3 {
return None;
}
let hull = self.compute_convex_hull(&projected_points);
if hull.len() < 3 {
return None;
}
let footprint = ensure_ccw(&hull);
let depth_start = min_z.max(0.0);
let depth_end = max_z;
Some((footprint, depth_start, depth_end))
}
fn compute_convex_hull(&self, points: &[Point2<f64>]) -> Vec<Point2<f64>> {
if points.len() < 3 {
return points.to_vec();
}
let mut start_idx = 0;
for (i, p) in points.iter().enumerate() {
if p.y < points[start_idx].y
|| (p.y == points[start_idx].y && p.x < points[start_idx].x)
{
start_idx = i;
}
}
let start = points[start_idx];
let mut sorted: Vec<Point2<f64>> = points
.iter()
.filter(|p| **p != start)
.cloned()
.collect();
sorted.sort_by(|a, b| {
let angle_a = (a.y - start.y).atan2(a.x - start.x);
let angle_b = (b.y - start.y).atan2(b.x - start.x);
angle_a.total_cmp(&angle_b)
});
let mut hull = vec![start];
for p in sorted {
while hull.len() > 1 {
let top = hull[hull.len() - 1];
let second = hull[hull.len() - 2];
let cross = (top.x - second.x) * (p.y - second.y)
- (top.y - second.y) * (p.x - second.x);
if cross <= 0.0 {
hull.pop();
} else {
break;
}
}
hull.push(p);
}
hull
}
pub fn compute_depth_range(
&self,
void_mesh: &Mesh,
profile_origin: &Point3<f64>,
extrusion_direction: &Vector3<f64>,
) -> (f64, f64) {
if void_mesh.is_empty() {
return (0.0, 0.0);
}
let mut min_depth = f64::MAX;
let mut max_depth = f64::MIN;
for i in (0..void_mesh.positions.len()).step_by(3) {
let point = Point3::new(
void_mesh.positions[i] as f64,
void_mesh.positions[i + 1] as f64,
void_mesh.positions[i + 2] as f64,
);
let relative = point - profile_origin;
let depth = relative.dot(extrusion_direction);
min_depth = min_depth.min(depth);
max_depth = max_depth.max(depth);
}
(min_depth.max(0.0), max_depth)
}
}
impl Default for VoidAnalyzer {
fn default() -> Self {
Self::new()
}
}
fn transform_direction(transform: &Matrix4<f64>, direction: &Vector3<f64>) -> Vector3<f64> {
let transformed = transform.transform_vector(direction);
transformed.normalize()
}
pub fn classify_voids_batch(
void_meshes: &[Mesh],
profile_transform: &Matrix4<f64>,
extrusion_direction: &Vector3<f64>,
extrusion_depth: f64,
) -> Vec<VoidClassification> {
let analyzer = VoidAnalyzer::new();
void_meshes
.iter()
.map(|mesh| {
analyzer.classify_void(mesh, profile_transform, extrusion_direction, extrusion_depth)
})
.collect()
}
pub fn extract_coplanar_voids(classifications: &[VoidClassification]) -> Vec<VoidInfo> {
classifications
.iter()
.filter_map(|c| match c {
VoidClassification::Coplanar {
profile_hole,
depth_start,
depth_end,
is_through,
} => Some(VoidInfo {
contour: profile_hole.clone(),
depth_start: *depth_start,
depth_end: *depth_end,
is_through: *is_through,
}),
_ => None,
})
.collect()
}
pub fn extract_nonplanar_voids(classifications: Vec<VoidClassification>) -> Vec<Mesh> {
classifications
.into_iter()
.filter_map(|c| match c {
VoidClassification::NonPlanar { mesh } => Some(mesh),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn create_box_mesh(min: Point3<f64>, max: Point3<f64>) -> Mesh {
let mut mesh = Mesh::with_capacity(8, 36);
let vertices = [
Point3::new(min.x, min.y, min.z),
Point3::new(max.x, min.y, min.z),
Point3::new(max.x, max.y, min.z),
Point3::new(min.x, max.y, min.z),
Point3::new(min.x, min.y, max.z),
Point3::new(max.x, min.y, max.z),
Point3::new(max.x, max.y, max.z),
Point3::new(min.x, max.y, max.z),
];
for v in &vertices {
mesh.add_vertex(*v, Vector3::new(0.0, 0.0, 1.0));
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
mesh.add_triangle(4, 6, 5);
mesh.add_triangle(4, 7, 6);
mesh.add_triangle(0, 3, 7);
mesh.add_triangle(0, 7, 4);
mesh.add_triangle(1, 5, 6);
mesh.add_triangle(1, 6, 2);
mesh.add_triangle(0, 4, 5);
mesh.add_triangle(0, 5, 1);
mesh.add_triangle(3, 2, 6);
mesh.add_triangle(3, 6, 7);
mesh
}
#[test]
fn test_void_analyzer_coplanar() {
let analyzer = VoidAnalyzer::new();
let void_mesh = create_box_mesh(
Point3::new(2.0, 2.0, 0.0),
Point3::new(4.0, 4.0, 10.0),
);
let profile_transform = Matrix4::identity();
let extrusion_direction = Vector3::new(0.0, 0.0, 1.0);
let extrusion_depth = 10.0;
let classification = analyzer.classify_void(
&void_mesh,
&profile_transform,
&extrusion_direction,
extrusion_depth,
);
match classification {
VoidClassification::Coplanar { is_through, .. } => {
assert!(is_through);
}
_ => panic!("Expected Coplanar classification"),
}
}
#[test]
fn test_void_analyzer_partial_depth() {
let analyzer = VoidAnalyzer::new();
let void_mesh = create_box_mesh(
Point3::new(2.0, 2.0, 2.0),
Point3::new(4.0, 4.0, 8.0),
);
let profile_transform = Matrix4::identity();
let extrusion_direction = Vector3::new(0.0, 0.0, 1.0);
let extrusion_depth = 10.0;
let classification = analyzer.classify_void(
&void_mesh,
&profile_transform,
&extrusion_direction,
extrusion_depth,
);
match classification {
VoidClassification::Coplanar {
depth_start,
depth_end,
is_through,
..
} => {
assert!(!is_through);
assert!(depth_start >= 1.9 && depth_start <= 2.1);
assert!(depth_end >= 7.9 && depth_end <= 8.1);
}
_ => panic!("Expected Coplanar classification"),
}
}
#[test]
fn test_extract_coplanar_voids() {
let classifications = vec![
VoidClassification::Coplanar {
profile_hole: vec![
Point2::new(0.0, 0.0),
Point2::new(1.0, 0.0),
Point2::new(1.0, 1.0),
Point2::new(0.0, 1.0),
],
depth_start: 0.0,
depth_end: 10.0,
is_through: true,
},
VoidClassification::NonPlanar {
mesh: Mesh::new(),
},
VoidClassification::NonIntersecting,
];
let coplanar = extract_coplanar_voids(&classifications);
assert_eq!(coplanar.len(), 1);
assert!(coplanar[0].is_through);
}
#[test]
fn test_compute_convex_hull() {
let analyzer = VoidAnalyzer::new();
let points = vec![
Point2::new(0.0, 0.0),
Point2::new(1.0, 0.0),
Point2::new(0.5, 0.5), Point2::new(1.0, 1.0),
Point2::new(0.0, 1.0),
];
let hull = analyzer.compute_convex_hull(&points);
assert_eq!(hull.len(), 4);
}
}