1#![allow(
8 clippy::many_single_char_names,
9 clippy::similar_names,
10 clippy::suboptimal_flops,
11 clippy::needless_range_loop,
12 clippy::cast_precision_loss,
13 clippy::doc_markdown,
14 clippy::module_name_repetitions,
15 clippy::manual_let_else,
16 clippy::missing_const_for_fn,
17 clippy::option_if_let_else,
18 clippy::derivable_impls,
19 clippy::bool_to_int_with_if,
20 clippy::if_same_then_else,
21 clippy::tuple_array_conversions,
22 clippy::match_same_arms,
23 clippy::derive_partial_eq_without_eq,
24 clippy::suspicious_operation_groupings,
25 clippy::too_many_lines,
26 clippy::iter_over_hash_type,
27 clippy::map_unwrap_or,
28 clippy::unused_self,
29 clippy::used_underscore_binding
30)]
31
32use std::collections::{HashMap, HashSet};
33
34use brepkit_math::vec::{Point3, Vec3};
35use brepkit_topology::Topology;
36use brepkit_topology::edge::EdgeId;
37use brepkit_topology::face::{FaceId, FaceSurface};
38use brepkit_topology::solid::SolidId;
39
40use crate::OperationsError;
41
42#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum SurfaceClass {
45 Planar,
47 Cylindrical,
49 Conical,
51 Spherical,
53 Toroidal,
55 FreeForm,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum ConcavityType {
62 Convex,
64 Concave,
66 Tangent,
68}
69
70#[derive(Debug, Clone)]
72pub struct FagNode {
73 pub face: FaceId,
75 pub surface_class: SurfaceClass,
77 pub area: f64,
79}
80
81#[derive(Debug, Clone)]
83pub struct FagEdge {
84 pub edge: EdgeId,
86 pub concavity: ConcavityType,
88 pub dihedral_angle: f64,
90}
91
92pub struct FaceAdjacencyGraph {
94 pub nodes: HashMap<usize, FagNode>,
96 pub adjacency: HashMap<usize, Vec<(usize, FagEdge)>>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
102pub enum PatternType {
103 Linear,
105 Circular,
107}
108
109#[derive(Debug, Clone)]
111pub enum Feature {
112 Hole {
114 faces: Vec<FaceId>,
116 diameter: Option<f64>,
118 },
119 Chamfer {
121 face: FaceId,
123 adjacent: (FaceId, FaceId),
125 angle: f64,
127 },
128 FilletLike {
130 face: FaceId,
132 area: f64,
134 },
135 Pocket {
137 floor: FaceId,
139 walls: Vec<FaceId>,
141 },
142 Pattern {
144 feature_indices: Vec<usize>,
146 pattern_type: PatternType,
148 count: usize,
150 spacing: Option<f64>,
152 },
153}
154
155pub fn recognize_features(
164 topo: &Topology,
165 solid: SolidId,
166 deflection: f64,
167) -> Result<Vec<Feature>, OperationsError> {
168 let solid_data = topo.solid(solid)?;
169 let shell = topo.shell(solid_data.outer_shell())?;
170 let face_ids: Vec<FaceId> = shell.faces().to_vec();
171
172 let mut features = Vec::new();
173
174 let fag = build_face_adjacency_graph(topo, &face_ids, deflection)?;
175
176 detect_chamfers_fag(topo, &fag, &mut features)?;
177 detect_fillet_like_fag(&fag, &mut features);
178 detect_holes(topo, &fag, &mut features)?;
179 detect_pockets_fag(&fag, &mut features);
180 detect_patterns(&mut features);
181
182 Ok(features)
183}
184
185fn build_face_adjacency_graph(
187 topo: &Topology,
188 face_ids: &[FaceId],
189 deflection: f64,
190) -> Result<FaceAdjacencyGraph, OperationsError> {
191 let mut nodes = HashMap::new();
192 for &fid in face_ids {
193 let face = topo.face(fid)?;
194 let surface_class = classify_surface(face.surface());
195 let area = crate::measure::face_area(topo, fid, deflection).unwrap_or(0.0);
196 nodes.insert(
197 fid.index(),
198 FagNode {
199 face: fid,
200 surface_class,
201 area,
202 },
203 );
204 }
205
206 let mut edge_to_faces: HashMap<usize, (EdgeId, Vec<FaceId>)> = HashMap::new();
207 for &fid in face_ids {
208 let face = topo.face(fid)?;
209 let wire = topo.wire(face.outer_wire())?;
210 for oe in wire.edges() {
211 let entry = edge_to_faces
212 .entry(oe.edge().index())
213 .or_insert_with(|| (oe.edge(), Vec::new()));
214 entry.1.push(fid);
215 }
216 }
217
218 let mut adjacency: HashMap<usize, Vec<(usize, FagEdge)>> = HashMap::new();
219 for (eid, faces) in edge_to_faces.values() {
220 if faces.len() == 2 {
221 let angle = compute_dihedral_angle(topo, faces[0], faces[1], *eid)?;
222 let concavity = classify_concavity(angle);
223
224 let edge_info = FagEdge {
225 edge: *eid,
226 concavity,
227 dihedral_angle: angle,
228 };
229 adjacency
230 .entry(faces[0].index())
231 .or_default()
232 .push((faces[1].index(), edge_info.clone()));
233 adjacency
234 .entry(faces[1].index())
235 .or_default()
236 .push((faces[0].index(), edge_info));
237 }
238 }
239
240 Ok(FaceAdjacencyGraph { nodes, adjacency })
241}
242
243fn classify_surface(surface: &FaceSurface) -> SurfaceClass {
245 match surface {
246 FaceSurface::Plane { .. } => SurfaceClass::Planar,
247 FaceSurface::Cylinder(_) => SurfaceClass::Cylindrical,
248 FaceSurface::Cone(_) => SurfaceClass::Conical,
249 FaceSurface::Sphere(_) => SurfaceClass::Spherical,
250 FaceSurface::Torus(_) => SurfaceClass::Toroidal,
251 FaceSurface::Nurbs(_) => SurfaceClass::FreeForm,
252 }
253}
254
255fn classify_concavity(angle: f64) -> ConcavityType {
257 const TOLERANCE: f64 = 0.01;
258 if angle < std::f64::consts::PI - TOLERANCE {
259 ConcavityType::Concave
260 } else if angle > std::f64::consts::PI + TOLERANCE {
261 ConcavityType::Convex
262 } else {
263 ConcavityType::Tangent
264 }
265}
266
267fn compute_dihedral_angle(
272 topo: &Topology,
273 face_a: FaceId,
274 face_b: FaceId,
275 edge_id: EdgeId,
276) -> Result<f64, OperationsError> {
277 let edge = topo.edge(edge_id)?;
278 let v_start = topo.vertex(edge.start())?;
279 let v_end = topo.vertex(edge.end())?;
280 let midpoint = Point3::new(
281 (v_start.point().x() + v_end.point().x()) * 0.5,
282 (v_start.point().y() + v_end.point().y()) * 0.5,
283 (v_start.point().z() + v_end.point().z()) * 0.5,
284 );
285
286 let n_a = face_normal_at(topo, face_a, midpoint)?;
287 let n_b = face_normal_at(topo, face_b, midpoint)?;
288
289 let dot = n_a.dot(n_b).clamp(-1.0, 1.0);
291 Ok(dot.acos())
292}
293
294fn face_normal_at(
300 topo: &Topology,
301 face_id: FaceId,
302 _point: Point3,
303) -> Result<Vec3, OperationsError> {
304 let face = topo.face(face_id)?;
305 let normal = match face.surface() {
306 FaceSurface::Plane { normal, .. } => *normal,
307 FaceSurface::Cylinder(c) => c.axis(),
308 FaceSurface::Cone(c) => c.axis(),
309 FaceSurface::Sphere(_) => {
310 Vec3::new(0.0, 0.0, 1.0)
312 }
313 FaceSurface::Torus(_) => Vec3::new(0.0, 0.0, 1.0),
314 FaceSurface::Nurbs(_) => Vec3::new(0.0, 0.0, 1.0),
315 };
316 Ok(normal)
317}
318
319fn detect_chamfers_fag(
324 topo: &Topology,
325 fag: &FaceAdjacencyGraph,
326 features: &mut Vec<Feature>,
327) -> Result<(), OperationsError> {
328 let mut seen_chamfers: HashSet<usize> = HashSet::new();
329
330 for (&idx, node) in &fag.nodes {
331 if seen_chamfers.contains(&idx) {
332 continue;
333 }
334 if node.surface_class != SurfaceClass::Planar {
335 continue;
336 }
337
338 let face = topo.face(node.face)?;
339 let normal = match face.surface() {
340 FaceSurface::Plane { normal, .. } => *normal,
341 _ => continue,
342 };
343
344 let neighbors = fag
345 .adjacency
346 .get(&idx)
347 .map_or(&[] as &[_], |v| v.as_slice());
348 if neighbors.len() < 2 {
349 continue;
350 }
351
352 for i in 0..neighbors.len() {
353 for j in (i + 1)..neighbors.len() {
354 let (ni, _) = &neighbors[i];
355 let (nj, _) = &neighbors[j];
356
357 let n1 = get_node_planar_normal(topo, fag, *ni);
358 let n2 = get_node_planar_normal(topo, fag, *nj);
359
360 if let (Some(n1), Some(n2)) = (n1, n2) {
361 let dot1 = normal.dot(n1).abs();
362 let dot2 = normal.dot(n2).abs();
363
364 if dot1 > 0.1 && dot1 < 0.95 && dot2 > 0.1 && dot2 < 0.95 {
367 let angle = normal.dot(n1).acos();
368 let f1 = fag.nodes.get(ni).map(|n| n.face);
369 let f2 = fag.nodes.get(nj).map(|n| n.face);
370 if let (Some(f1), Some(f2)) = (f1, f2) {
371 seen_chamfers.insert(idx);
372 features.push(Feature::Chamfer {
373 face: node.face,
374 adjacent: (f1, f2),
375 angle,
376 });
377 }
378 }
379 }
380 }
381 }
382 }
383
384 Ok(())
385}
386
387fn get_node_planar_normal(
389 topo: &Topology,
390 fag: &FaceAdjacencyGraph,
391 node_idx: usize,
392) -> Option<Vec3> {
393 let node = fag.nodes.get(&node_idx)?;
394 if node.surface_class != SurfaceClass::Planar {
395 return None;
396 }
397 let face = topo.face(node.face).ok()?;
398 match face.surface() {
399 FaceSurface::Plane { normal, .. } => Some(*normal),
400 _ => None,
401 }
402}
403
404fn detect_fillet_like_fag(fag: &FaceAdjacencyGraph, features: &mut Vec<Feature>) {
406 if fag.nodes.is_empty() {
407 return;
408 }
409
410 let total_area: f64 = fag.nodes.values().map(|n| n.area).sum();
411 #[allow(clippy::cast_precision_loss)]
412 let avg_area = total_area / fag.nodes.len() as f64;
413 let threshold = avg_area * 0.25;
414
415 for node in fag.nodes.values() {
416 if node.area < threshold && node.area > 0.0 {
417 features.push(Feature::FilletLike {
418 face: node.face,
419 area: node.area,
420 });
421 }
422 }
423}
424
425fn detect_holes(
430 topo: &Topology,
431 fag: &FaceAdjacencyGraph,
432 features: &mut Vec<Feature>,
433) -> Result<(), OperationsError> {
434 for (&idx, node) in &fag.nodes {
435 if node.surface_class != SurfaceClass::Cylindrical {
436 continue;
437 }
438
439 let face = topo.face(node.face)?;
440 let cyl = match face.surface() {
441 FaceSurface::Cylinder(c) => c,
442 _ => continue,
443 };
444
445 let diameter = cyl.radius() * 2.0;
446
447 let neighbors = fag
448 .adjacency
449 .get(&idx)
450 .map_or(&[] as &[_], |v| v.as_slice());
451 let _planar_neighbor_count = neighbors
452 .iter()
453 .filter(|(ni, _)| {
454 fag.nodes
455 .get(ni)
456 .is_some_and(|n| n.surface_class == SurfaceClass::Planar)
457 })
458 .count();
459
460 features.push(Feature::Hole {
461 faces: vec![node.face],
462 diameter: Some(diameter),
463 });
464 }
465
466 Ok(())
467}
468
469fn detect_pockets_fag(fag: &FaceAdjacencyGraph, features: &mut Vec<Feature>) {
474 let mut visited: HashSet<usize> = HashSet::new();
475
476 for &idx in fag.nodes.keys() {
477 if visited.contains(&idx) {
478 continue;
479 }
480
481 let node = match fag.nodes.get(&idx) {
482 Some(n) => n,
483 None => continue,
484 };
485
486 if node.surface_class != SurfaceClass::Planar {
487 continue;
488 }
489
490 let mut component = HashSet::new();
491 let mut stack = vec![idx];
492
493 while let Some(current) = stack.pop() {
494 if !component.insert(current) {
495 continue;
496 }
497
498 if let Some(adj) = fag.adjacency.get(¤t) {
499 for (neighbor, edge) in adj {
500 if edge.concavity == ConcavityType::Concave && !component.contains(neighbor) {
501 stack.push(*neighbor);
502 }
503 }
504 }
505 }
506
507 let mut floor = None;
510 let mut walls = Vec::new();
511
512 for &ci in &component {
513 if let Some(n) = fag.nodes.get(&ci) {
514 if n.surface_class == SurfaceClass::Planar {
515 if floor.is_none() {
516 floor = Some(n.face);
517 }
518 } else {
519 walls.push(n.face);
520 }
521 }
522 }
523
524 if let Some(floor_face) = floor
525 && walls.len() >= 2
526 {
527 features.push(Feature::Pocket {
528 floor: floor_face,
529 walls,
530 });
531 visited.extend(&component);
532 }
533 }
534}
535
536fn detect_patterns(features: &mut Vec<Feature>) {
541 let hole_info: Vec<(usize, f64)> = features
542 .iter()
543 .enumerate()
544 .filter_map(|(i, f)| match f {
545 Feature::Hole {
546 diameter: Some(d), ..
547 } => Some((i, *d)),
548 _ => None,
549 })
550 .collect();
551
552 if hole_info.len() < 3 {
553 return;
554 }
555
556 let groups = group_by_diameter(&hole_info);
558
559 let mut new_patterns = Vec::new();
560
561 for group in &groups {
562 if group.len() < 3 {
563 continue;
564 }
565
566 let indices: Vec<usize> = group.iter().map(|&(i, _)| i).collect();
567
568 #[allow(clippy::cast_precision_loss)]
572 let count = indices.len();
573 new_patterns.push(Feature::Pattern {
574 feature_indices: indices,
575 pattern_type: PatternType::Linear,
576 count,
577 spacing: None,
578 });
579 }
580
581 features.extend(new_patterns);
582}
583
584fn group_by_diameter(items: &[(usize, f64)]) -> Vec<Vec<(usize, f64)>> {
586 let mut groups: Vec<Vec<(usize, f64)>> = Vec::new();
587
588 for &item in items {
589 let mut found = false;
590 for group in &mut groups {
591 let repr = group[0].1;
592 if (item.1 - repr).abs() < repr * 0.01 + 1e-12 {
593 group.push(item);
594 found = true;
595 break;
596 }
597 }
598 if !found {
599 groups.push(vec![item]);
600 }
601 }
602
603 groups
604}
605
606#[cfg(test)]
607#[allow(clippy::unwrap_used)]
608mod tests {
609 use super::*;
610 use crate::primitives::make_box;
611
612 #[test]
613 fn box_has_no_chamfers() {
614 let mut topo = Topology::new();
615 let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
616
617 let features = recognize_features(&topo, solid, 0.1).unwrap();
618
619 let chamfer_count = features
620 .iter()
621 .filter(|f| matches!(f, Feature::Chamfer { .. }))
622 .count();
623 assert_eq!(chamfer_count, 0, "box should have no chamfers");
624 }
625
626 #[test]
627 fn box_has_no_fillet_like() {
628 let mut topo = Topology::new();
629 let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
630
631 let features = recognize_features(&topo, solid, 0.1).unwrap();
632
633 let fillet_count = features
634 .iter()
635 .filter(|f| matches!(f, Feature::FilletLike { .. }))
636 .count();
637 assert_eq!(
638 fillet_count, 0,
639 "uniform box should have no fillet-like faces"
640 );
641 }
642
643 #[test]
644 fn chamfered_box_has_chamfer_features() {
645 let mut topo = Topology::new();
646 let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
647
648 let solid_data = topo.solid(solid).unwrap();
649 let shell = topo.shell(solid_data.outer_shell()).unwrap();
650 let face_ids: Vec<FaceId> = shell.faces().to_vec();
651
652 let mut edge_set = HashSet::new();
653 for &fid in &face_ids {
654 let face = topo.face(fid).unwrap();
655 let wire = topo.wire(face.outer_wire()).unwrap();
656 for oe in wire.edges() {
657 edge_set.insert(oe.edge());
658 }
659 }
660 let edges: Vec<_> = edge_set.into_iter().collect();
661
662 if let Ok(chamfered) = crate::chamfer::chamfer(&mut topo, solid, &[edges[0]], 0.2) {
663 let features = recognize_features(&topo, chamfered, 0.1).unwrap();
664 let chamfer_count = features
666 .iter()
667 .filter(|f| matches!(f, Feature::Chamfer { .. }))
668 .count();
669 assert!(
670 chamfer_count > 0,
671 "chamfered box should have chamfer features, got {chamfer_count}"
672 );
673 }
674 }
675
676 #[test]
677 fn feature_count_is_reasonable() {
678 let mut topo = Topology::new();
679 let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
680
681 let features = recognize_features(&topo, solid, 0.1).unwrap();
682
683 assert!(
686 features.len() <= 12,
687 "box should have reasonable feature count, got {}",
688 features.len()
689 );
690 }
691
692 #[test]
693 fn fag_nodes_match_face_count() {
694 let mut topo = Topology::new();
695 let solid = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
696 let solid_data = topo.solid(solid).unwrap();
697 let shell = topo.shell(solid_data.outer_shell()).unwrap();
698 let face_ids: Vec<FaceId> = shell.faces().to_vec();
699
700 let fag = build_face_adjacency_graph(&topo, &face_ids, 0.1).unwrap();
701 assert_eq!(fag.nodes.len(), 6, "box has 6 faces");
702 }
703
704 #[test]
705 fn fag_box_all_planar() {
706 let mut topo = Topology::new();
707 let solid = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
708 let solid_data = topo.solid(solid).unwrap();
709 let shell = topo.shell(solid_data.outer_shell()).unwrap();
710 let face_ids: Vec<FaceId> = shell.faces().to_vec();
711
712 let fag = build_face_adjacency_graph(&topo, &face_ids, 0.1).unwrap();
713 for node in fag.nodes.values() {
714 assert_eq!(node.surface_class, SurfaceClass::Planar);
715 }
716 }
717
718 #[test]
719 fn fag_box_adjacency_exists() {
720 let mut topo = Topology::new();
721 let solid = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
722 let solid_data = topo.solid(solid).unwrap();
723 let shell = topo.shell(solid_data.outer_shell()).unwrap();
724 let face_ids: Vec<FaceId> = shell.faces().to_vec();
725
726 let fag = build_face_adjacency_graph(&topo, &face_ids, 0.1).unwrap();
727 for node in fag.nodes.values() {
729 let adj = fag.adjacency.get(&node.face.index());
730 assert!(adj.is_some(), "face should have adjacency");
731 let neighbors = adj.unwrap();
732 assert!(
733 neighbors.len() >= 2,
734 "each box face should have at least 2 neighbors, got {}",
735 neighbors.len()
736 );
737 }
738 }
739
740 #[test]
741 fn box_has_no_holes() {
742 let mut topo = Topology::new();
743 let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
744
745 let features = recognize_features(&topo, solid, 0.1).unwrap();
746 let hole_count = features
747 .iter()
748 .filter(|f| matches!(f, Feature::Hole { .. }))
749 .count();
750 assert_eq!(hole_count, 0, "box should have no holes");
751 }
752
753 #[test]
754 fn box_has_no_patterns() {
755 let mut topo = Topology::new();
756 let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
757
758 let features = recognize_features(&topo, solid, 0.1).unwrap();
759 let pattern_count = features
760 .iter()
761 .filter(|f| matches!(f, Feature::Pattern { .. }))
762 .count();
763 assert_eq!(pattern_count, 0, "box should have no patterns");
764 }
765
766 #[test]
767 fn classify_surface_variants() {
768 assert_eq!(
769 classify_surface(&FaceSurface::Plane {
770 normal: Vec3::new(0.0, 0.0, 1.0),
771 d: 0.0,
772 }),
773 SurfaceClass::Planar
774 );
775 }
776
777 #[test]
778 fn concavity_classification() {
779 use std::f64::consts::PI;
780 assert_eq!(classify_concavity(PI * 0.5), ConcavityType::Concave);
781 assert_eq!(classify_concavity(PI), ConcavityType::Tangent);
782 assert_eq!(classify_concavity(PI * 1.5), ConcavityType::Convex);
783 }
784
785 #[test]
786 fn group_by_diameter_groups_similar() {
787 let items = vec![(0, 10.0), (1, 10.05), (2, 20.0), (3, 10.02)];
788 let groups = group_by_diameter(&items);
789 assert_eq!(groups.len(), 2, "should form 2 groups");
790 }
791
792 #[test]
793 fn pattern_detection_needs_three() {
794 let mut features = vec![
795 Feature::Hole {
796 faces: vec![],
797 diameter: Some(5.0),
798 },
799 Feature::Hole {
800 faces: vec![],
801 diameter: Some(5.0),
802 },
803 ];
804 detect_patterns(&mut features);
805 let pattern_count = features
806 .iter()
807 .filter(|f| matches!(f, Feature::Pattern { .. }))
808 .count();
809 assert_eq!(pattern_count, 0, "need at least 3 holes for a pattern");
810 }
811
812 #[test]
813 fn pattern_detection_three_same_diameter() {
814 let mut features = vec![
815 Feature::Hole {
816 faces: vec![],
817 diameter: Some(5.0),
818 },
819 Feature::Hole {
820 faces: vec![],
821 diameter: Some(5.0),
822 },
823 Feature::Hole {
824 faces: vec![],
825 diameter: Some(5.0),
826 },
827 ];
828 detect_patterns(&mut features);
829 let pattern_count = features
830 .iter()
831 .filter(|f| matches!(f, Feature::Pattern { .. }))
832 .count();
833 assert_eq!(pattern_count, 1, "3 same-diameter holes form a pattern");
834 }
835}