1use crate::bool2d::{ensure_ccw, is_valid_contour};
15use crate::mesh::Mesh;
16use crate::profile::VoidInfo;
17use nalgebra::{Matrix4, Point2, Point3, Vector3};
18use rustc_hash::FxHashMap;
19
20const DEFAULT_PLANARITY_EPSILON: f64 = 0.02;
22
23const THROUGH_VOID_TOLERANCE: f64 = 0.01;
25
26#[derive(Debug, Clone)]
28pub enum VoidClassification {
29 Coplanar {
31 profile_hole: Vec<Point2<f64>>,
33 depth_start: f64,
35 depth_end: f64,
37 is_through: bool,
39 },
40 NonPlanar {
42 mesh: Mesh,
44 },
45 NonIntersecting,
47}
48
49pub struct VoidAnalyzer {
51 planarity_epsilon: f64,
53 adaptive_epsilon: bool,
55}
56
57impl VoidAnalyzer {
58 pub fn new() -> Self {
60 Self {
61 planarity_epsilon: DEFAULT_PLANARITY_EPSILON,
62 adaptive_epsilon: true,
63 }
64 }
65
66 pub fn with_epsilon(epsilon: f64) -> Self {
68 Self {
69 planarity_epsilon: epsilon,
70 adaptive_epsilon: false,
71 }
72 }
73
74 pub fn classify_void(
85 &self,
86 void_mesh: &Mesh,
87 profile_transform: &Matrix4<f64>,
88 extrusion_direction: &Vector3<f64>,
89 extrusion_depth: f64,
90 ) -> VoidClassification {
91 if void_mesh.is_empty() {
92 return VoidClassification::NonIntersecting;
93 }
94
95 let profile_normal = transform_direction(profile_transform, &Vector3::new(0.0, 0.0, 1.0));
97
98 let is_coplanar = self.check_coplanarity(void_mesh, &profile_normal, extrusion_direction);
100
101 if !is_coplanar {
102 return VoidClassification::NonPlanar {
103 mesh: void_mesh.clone(),
104 };
105 }
106
107 let inverse_transform = match profile_transform.try_inverse() {
109 Some(inv) => inv,
110 None => {
111 return VoidClassification::NonPlanar {
112 mesh: void_mesh.clone(),
113 }
114 }
115 };
116
117 match self.extract_footprint(void_mesh, &inverse_transform, extrusion_direction) {
118 Some((footprint, depth_start, depth_end)) => {
119 if !is_valid_contour(&footprint) {
121 return VoidClassification::NonPlanar {
122 mesh: void_mesh.clone(),
123 };
124 }
125
126 let is_through = depth_start <= THROUGH_VOID_TOLERANCE
128 && depth_end >= extrusion_depth - THROUGH_VOID_TOLERANCE;
129
130 VoidClassification::Coplanar {
131 profile_hole: footprint,
132 depth_start,
133 depth_end,
134 is_through,
135 }
136 }
137 None => VoidClassification::NonPlanar {
138 mesh: void_mesh.clone(),
139 },
140 }
141 }
142
143 fn check_coplanarity(
145 &self,
146 void_mesh: &Mesh,
147 profile_normal: &Vector3<f64>,
148 extrusion_direction: &Vector3<f64>,
149 ) -> bool {
150 let face_normals = self.extract_face_normals(void_mesh);
152
153 if face_normals.is_empty() {
154 return false;
155 }
156
157 let mut has_parallel_face = false;
162 let mut epsilon = self.planarity_epsilon;
163
164 let epsilons = if self.adaptive_epsilon {
166 vec![0.02, 0.01, 0.005, 0.001]
167 } else {
168 vec![epsilon]
169 };
170
171 for eps in epsilons {
172 epsilon = eps;
173 has_parallel_face = false;
174
175 for normal in &face_normals {
176 let dot_profile = normal.dot(profile_normal).abs();
177 let dot_extrusion = normal.dot(extrusion_direction).abs();
178
179 if dot_profile > 1.0 - epsilon {
181 has_parallel_face = true;
182 break;
183 }
184
185 if dot_extrusion < epsilon {
187 has_parallel_face = true;
188 break;
189 }
190 }
191
192 if has_parallel_face {
193 break;
194 }
195 }
196
197 has_parallel_face
198 }
199
200 fn extract_face_normals(&self, mesh: &Mesh) -> Vec<Vector3<f64>> {
202 let mut normal_groups: FxHashMap<(i32, i32, i32), (Vector3<f64>, usize)> =
203 FxHashMap::default();
204
205 let quantize_normal = |n: &Vector3<f64>| -> (i32, i32, i32) {
206 (
207 (n.x * 100.0).round() as i32,
208 (n.y * 100.0).round() as i32,
209 (n.z * 100.0).round() as i32,
210 )
211 };
212
213 for i in (0..mesh.indices.len()).step_by(3) {
214 let i0 = mesh.indices[i] as usize;
215 let i1 = mesh.indices[i + 1] as usize;
216 let i2 = mesh.indices[i + 2] as usize;
217
218 if i0 * 3 + 2 >= mesh.positions.len()
219 || i1 * 3 + 2 >= mesh.positions.len()
220 || i2 * 3 + 2 >= mesh.positions.len()
221 {
222 continue;
223 }
224
225 let v0 = Point3::new(
226 mesh.positions[i0 * 3] as f64,
227 mesh.positions[i0 * 3 + 1] as f64,
228 mesh.positions[i0 * 3 + 2] as f64,
229 );
230 let v1 = Point3::new(
231 mesh.positions[i1 * 3] as f64,
232 mesh.positions[i1 * 3 + 1] as f64,
233 mesh.positions[i1 * 3 + 2] as f64,
234 );
235 let v2 = Point3::new(
236 mesh.positions[i2 * 3] as f64,
237 mesh.positions[i2 * 3 + 1] as f64,
238 mesh.positions[i2 * 3 + 2] as f64,
239 );
240
241 let edge1 = v1 - v0;
242 let edge2 = v2 - v0;
243 let normal = match edge1.cross(&edge2).try_normalize(1e-10) {
244 Some(n) => n,
245 None => continue,
246 };
247
248 let key = quantize_normal(&normal);
249 normal_groups
250 .entry(key)
251 .and_modify(|(sum, count)| {
252 *sum += normal;
253 *count += 1;
254 })
255 .or_insert((normal, 1));
256 }
257
258 normal_groups
260 .values()
261 .filter_map(|(sum, count)| {
262 if *count > 0 {
263 (sum / *count as f64).try_normalize(1e-10)
264 } else {
265 None
266 }
267 })
268 .collect()
269 }
270
271 fn extract_footprint(
276 &self,
277 void_mesh: &Mesh,
278 inverse_profile_transform: &Matrix4<f64>,
279 _extrusion_direction: &Vector3<f64>,
280 ) -> Option<(Vec<Point2<f64>>, f64, f64)> {
281 if void_mesh.is_empty() {
282 return None;
283 }
284
285 let mut min_z = f64::MAX;
287 let mut max_z = f64::MIN;
288 let mut projected_points: Vec<Point2<f64>> = Vec::new();
289
290 for i in (0..void_mesh.positions.len()).step_by(3) {
291 let world_point = Point3::new(
292 void_mesh.positions[i] as f64,
293 void_mesh.positions[i + 1] as f64,
294 void_mesh.positions[i + 2] as f64,
295 );
296
297 let profile_point = inverse_profile_transform.transform_point(&world_point);
299
300 min_z = min_z.min(profile_point.z);
302 max_z = max_z.max(profile_point.z);
303
304 projected_points.push(Point2::new(profile_point.x, profile_point.y));
306 }
307
308 if projected_points.len() < 3 {
309 return None;
310 }
311
312 let hull = self.compute_convex_hull(&projected_points);
314
315 if hull.len() < 3 {
316 return None;
317 }
318
319 let footprint = ensure_ccw(&hull);
321
322 let depth_start = min_z.max(0.0);
324 let depth_end = max_z;
325
326 Some((footprint, depth_start, depth_end))
327 }
328
329 fn compute_convex_hull(&self, points: &[Point2<f64>]) -> Vec<Point2<f64>> {
331 if points.len() < 3 {
332 return points.to_vec();
333 }
334
335 let mut start_idx = 0;
337 for (i, p) in points.iter().enumerate() {
338 if p.y < points[start_idx].y
339 || (p.y == points[start_idx].y && p.x < points[start_idx].x)
340 {
341 start_idx = i;
342 }
343 }
344
345 let start = points[start_idx];
346
347 let mut sorted: Vec<Point2<f64>> =
349 points.iter().filter(|p| **p != start).cloned().collect();
350
351 sorted.sort_by(|a, b| {
352 let angle_a = (a.y - start.y).atan2(a.x - start.x);
353 let angle_b = (b.y - start.y).atan2(b.x - start.x);
354 angle_a.total_cmp(&angle_b)
355 });
356
357 let mut hull = vec![start];
359
360 for p in sorted {
361 while hull.len() > 1 {
362 let top = hull[hull.len() - 1];
363 let second = hull[hull.len() - 2];
364
365 let cross =
367 (top.x - second.x) * (p.y - second.y) - (top.y - second.y) * (p.x - second.x);
368
369 if cross <= 0.0 {
370 hull.pop();
371 } else {
372 break;
373 }
374 }
375 hull.push(p);
376 }
377
378 hull
379 }
380
381 pub fn compute_depth_range(
383 &self,
384 void_mesh: &Mesh,
385 profile_origin: &Point3<f64>,
386 extrusion_direction: &Vector3<f64>,
387 ) -> (f64, f64) {
388 if void_mesh.is_empty() {
389 return (0.0, 0.0);
390 }
391
392 let mut min_depth = f64::MAX;
393 let mut max_depth = f64::MIN;
394
395 for i in (0..void_mesh.positions.len()).step_by(3) {
396 let point = Point3::new(
397 void_mesh.positions[i] as f64,
398 void_mesh.positions[i + 1] as f64,
399 void_mesh.positions[i + 2] as f64,
400 );
401
402 let relative = point - profile_origin;
404 let depth = relative.dot(extrusion_direction);
405
406 min_depth = min_depth.min(depth);
407 max_depth = max_depth.max(depth);
408 }
409
410 (min_depth.max(0.0), max_depth)
411 }
412}
413
414impl Default for VoidAnalyzer {
415 fn default() -> Self {
416 Self::new()
417 }
418}
419
420fn transform_direction(transform: &Matrix4<f64>, direction: &Vector3<f64>) -> Vector3<f64> {
422 let transformed = transform.transform_vector(direction);
423 transformed.normalize()
424}
425
426pub fn classify_voids_batch(
428 void_meshes: &[Mesh],
429 profile_transform: &Matrix4<f64>,
430 extrusion_direction: &Vector3<f64>,
431 extrusion_depth: f64,
432) -> Vec<VoidClassification> {
433 let analyzer = VoidAnalyzer::new();
434
435 void_meshes
436 .iter()
437 .map(|mesh| {
438 analyzer.classify_void(
439 mesh,
440 profile_transform,
441 extrusion_direction,
442 extrusion_depth,
443 )
444 })
445 .collect()
446}
447
448pub fn extract_coplanar_voids(classifications: &[VoidClassification]) -> Vec<VoidInfo> {
450 classifications
451 .iter()
452 .filter_map(|c| match c {
453 VoidClassification::Coplanar {
454 profile_hole,
455 depth_start,
456 depth_end,
457 is_through,
458 } => Some(VoidInfo {
459 contour: profile_hole.clone(),
460 depth_start: *depth_start,
461 depth_end: *depth_end,
462 is_through: *is_through,
463 }),
464 _ => None,
465 })
466 .collect()
467}
468
469pub fn extract_nonplanar_voids(classifications: Vec<VoidClassification>) -> Vec<Mesh> {
471 classifications
472 .into_iter()
473 .filter_map(|c| match c {
474 VoidClassification::NonPlanar { mesh } => Some(mesh),
475 _ => None,
476 })
477 .collect()
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn create_box_mesh(min: Point3<f64>, max: Point3<f64>) -> Mesh {
485 let mut mesh = Mesh::with_capacity(8, 36);
486
487 let vertices = [
489 Point3::new(min.x, min.y, min.z),
490 Point3::new(max.x, min.y, min.z),
491 Point3::new(max.x, max.y, min.z),
492 Point3::new(min.x, max.y, min.z),
493 Point3::new(min.x, min.y, max.z),
494 Point3::new(max.x, min.y, max.z),
495 Point3::new(max.x, max.y, max.z),
496 Point3::new(min.x, max.y, max.z),
497 ];
498
499 for v in &vertices {
501 mesh.add_vertex(*v, Vector3::new(0.0, 0.0, 1.0));
502 }
503
504 mesh.add_triangle(0, 1, 2);
507 mesh.add_triangle(0, 2, 3);
508 mesh.add_triangle(4, 6, 5);
510 mesh.add_triangle(4, 7, 6);
511 mesh.add_triangle(0, 3, 7);
513 mesh.add_triangle(0, 7, 4);
514 mesh.add_triangle(1, 5, 6);
516 mesh.add_triangle(1, 6, 2);
517 mesh.add_triangle(0, 4, 5);
519 mesh.add_triangle(0, 5, 1);
520 mesh.add_triangle(3, 2, 6);
522 mesh.add_triangle(3, 6, 7);
523
524 mesh
525 }
526
527 #[test]
528 fn test_void_analyzer_coplanar() {
529 let analyzer = VoidAnalyzer::new();
530
531 let void_mesh = create_box_mesh(Point3::new(2.0, 2.0, 0.0), Point3::new(4.0, 4.0, 10.0));
533
534 let profile_transform = Matrix4::identity();
535 let extrusion_direction = Vector3::new(0.0, 0.0, 1.0);
536 let extrusion_depth = 10.0;
537
538 let classification = analyzer.classify_void(
539 &void_mesh,
540 &profile_transform,
541 &extrusion_direction,
542 extrusion_depth,
543 );
544
545 match classification {
546 VoidClassification::Coplanar { is_through, .. } => {
547 assert!(is_through);
548 }
549 _ => panic!("Expected Coplanar classification"),
550 }
551 }
552
553 #[test]
554 fn test_void_analyzer_partial_depth() {
555 let analyzer = VoidAnalyzer::new();
556
557 let void_mesh = create_box_mesh(Point3::new(2.0, 2.0, 2.0), Point3::new(4.0, 4.0, 8.0));
559
560 let profile_transform = Matrix4::identity();
561 let extrusion_direction = Vector3::new(0.0, 0.0, 1.0);
562 let extrusion_depth = 10.0;
563
564 let classification = analyzer.classify_void(
565 &void_mesh,
566 &profile_transform,
567 &extrusion_direction,
568 extrusion_depth,
569 );
570
571 match classification {
572 VoidClassification::Coplanar {
573 depth_start,
574 depth_end,
575 is_through,
576 ..
577 } => {
578 assert!(!is_through);
579 assert!(depth_start >= 1.9 && depth_start <= 2.1);
580 assert!(depth_end >= 7.9 && depth_end <= 8.1);
581 }
582 _ => panic!("Expected Coplanar classification"),
583 }
584 }
585
586 #[test]
587 fn test_extract_coplanar_voids() {
588 let classifications = vec![
589 VoidClassification::Coplanar {
590 profile_hole: vec![
591 Point2::new(0.0, 0.0),
592 Point2::new(1.0, 0.0),
593 Point2::new(1.0, 1.0),
594 Point2::new(0.0, 1.0),
595 ],
596 depth_start: 0.0,
597 depth_end: 10.0,
598 is_through: true,
599 },
600 VoidClassification::NonPlanar { mesh: Mesh::new() },
601 VoidClassification::NonIntersecting,
602 ];
603
604 let coplanar = extract_coplanar_voids(&classifications);
605 assert_eq!(coplanar.len(), 1);
606 assert!(coplanar[0].is_through);
607 }
608
609 #[test]
610 fn test_compute_convex_hull() {
611 let analyzer = VoidAnalyzer::new();
612
613 let points = vec![
614 Point2::new(0.0, 0.0),
615 Point2::new(1.0, 0.0),
616 Point2::new(0.5, 0.5), Point2::new(1.0, 1.0),
618 Point2::new(0.0, 1.0),
619 ];
620
621 let hull = analyzer.compute_convex_hull(&points);
622
623 assert_eq!(hull.len(), 4);
625 }
626}