Skip to main content

brepkit_operations/
feature_recognition.rs

1//! Feature recognition: detect geometric features from B-Rep topology.
2//!
3//! Analyzes face adjacency, surface types, and geometry to identify
4//! manufacturing features like holes, pockets, fillets, and chamfers.
5//! Useful for CAM path planning and simulation simplification.
6
7#![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/// Surface classification for a face.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum SurfaceClass {
45    /// Planar surface.
46    Planar,
47    /// Cylindrical surface.
48    Cylindrical,
49    /// Conical surface.
50    Conical,
51    /// Spherical surface.
52    Spherical,
53    /// Toroidal surface.
54    Toroidal,
55    /// NURBS (free-form) surface.
56    FreeForm,
57}
58
59/// Concavity type of an edge between two faces.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum ConcavityType {
62    /// Convex edge (dihedral angle > pi).
63    Convex,
64    /// Concave edge (dihedral angle < pi).
65    Concave,
66    /// Tangent/smooth edge (dihedral angle approximately pi).
67    Tangent,
68}
69
70/// A node in the face adjacency graph.
71#[derive(Debug, Clone)]
72pub struct FagNode {
73    /// The face ID.
74    pub face: FaceId,
75    /// Surface classification.
76    pub surface_class: SurfaceClass,
77    /// Face area (approximate).
78    pub area: f64,
79}
80
81/// An edge in the face adjacency graph.
82#[derive(Debug, Clone)]
83pub struct FagEdge {
84    /// The shared topology edge ID.
85    pub edge: EdgeId,
86    /// Concavity type.
87    pub concavity: ConcavityType,
88    /// Dihedral angle in radians.
89    pub dihedral_angle: f64,
90}
91
92/// Face adjacency graph with typed nodes and edges.
93pub struct FaceAdjacencyGraph {
94    /// Nodes indexed by face index.
95    pub nodes: HashMap<usize, FagNode>,
96    /// Adjacency: `face_index -> [(neighbor_face_index, edge_info)]`.
97    pub adjacency: HashMap<usize, Vec<(usize, FagEdge)>>,
98}
99
100/// Type of a detected pattern.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub enum PatternType {
103    /// Features arranged in a line.
104    Linear,
105    /// Features arranged in a circle.
106    Circular,
107}
108
109/// A recognized geometric feature.
110#[derive(Debug, Clone)]
111pub enum Feature {
112    /// A through-hole or blind hole.
113    Hole {
114        /// Faces forming the hole.
115        faces: Vec<FaceId>,
116        /// Estimated diameter (if detectable).
117        diameter: Option<f64>,
118    },
119    /// A chamfer (bevel) face between two adjacent faces.
120    Chamfer {
121        /// The chamfer face.
122        face: FaceId,
123        /// The two faces adjacent to the chamfer.
124        adjacent: (FaceId, FaceId),
125        /// Angle between the chamfer and each adjacent face.
126        angle: f64,
127    },
128    /// A small face that may be a fillet approximation.
129    FilletLike {
130        /// The fillet face.
131        face: FaceId,
132        /// Area of the face.
133        area: f64,
134    },
135    /// A pocket (depression bounded by walls and a floor).
136    Pocket {
137        /// The floor face.
138        floor: FaceId,
139        /// The wall faces.
140        walls: Vec<FaceId>,
141    },
142    /// A detected pattern of repeated features.
143    Pattern {
144        /// Indices into the feature list of the pattern members.
145        feature_indices: Vec<usize>,
146        /// Pattern type (linear or circular).
147        pattern_type: PatternType,
148        /// Number of instances.
149        count: usize,
150        /// Spacing between instances (for linear patterns).
151        spacing: Option<f64>,
152    },
153}
154
155/// Recognize features in a solid.
156///
157/// Analyzes the solid's face adjacency and geometry to identify
158/// common manufacturing features.
159///
160/// # Errors
161///
162/// Returns an error if topology lookups fail.
163pub 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
185/// Build a typed face adjacency graph from a set of face IDs.
186fn 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
243/// Classify a `FaceSurface` into a `SurfaceClass`.
244fn 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
255/// Classify dihedral angle into a concavity type.
256fn 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
267/// Compute the dihedral angle between two faces at a shared edge.
268///
269/// The dihedral angle is the angle between the outward normals of the
270/// two faces, measured at the edge midpoint.
271fn 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    // Dihedral angle via dot product, clamped for numerical safety.
290    let dot = n_a.dot(n_b).clamp(-1.0, 1.0);
291    Ok(dot.acos())
292}
293
294/// Get the outward normal of a face at a given point.
295///
296/// For planar faces this is exact. For analytic surfaces the axis or
297/// a geometric normal is used. For free-form surfaces a fallback Z
298/// normal is returned.
299fn 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            // For a sphere the normal varies; use a fallback.
311            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
319/// Detect chamfer faces using the face adjacency graph.
320///
321/// A chamfer is a small planar face whose normal is at an intermediate
322/// angle (neither parallel nor perpendicular) to both neighboring faces.
323fn 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                    // Chamfer face is at an angle (not parallel/perpendicular)
365                    // to both adjacent faces.
366                    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
387/// Get the planar normal for a FAG node, or `None` if non-planar.
388fn 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
404/// Detect fillet-like faces by small area relative to the average.
405fn 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
425/// Detect holes by finding cylindrical faces in the FAG.
426///
427/// A through-hole connects to two or more distinct planar faces;
428/// a blind hole connects to fewer.
429fn 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
469/// Detect pockets using concave-connected components in the FAG.
470///
471/// A pocket is a set of faces connected by concave edges, with at
472/// least one planar floor face and two or more wall faces.
473fn 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(&current) {
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        // Classify component: floor = planar, walls = non-planar or
508        // perpendicular planar faces.
509        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
536/// Detect patterns (linear or circular) among already-recognized features.
537///
538/// Groups holes by similar diameter, then tests whether their centroids
539/// are collinear (linear pattern) or cocircular (circular pattern).
540fn 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    // Group by diameter (within 1% tolerance).
557    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        // For now, any group of 3+ holes with matching diameter is a linear
569        // pattern. True centroid fitting would require face centroid data
570        // which we do not have here, so we report the group as linear.
571        #[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
584/// Group `(index, diameter)` pairs by similar diameter (1% relative tolerance).
585fn 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            // The chamfered solid should have at least one chamfer feature
665            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        // A simple box might have pocket features (faces with 4 perpendicular neighbors)
684        // but shouldn't have an excessive number
685        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        // Each face of a box shares edges with 4 other faces.
728        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}