#![allow(
clippy::many_single_char_names,
clippy::similar_names,
clippy::suboptimal_flops,
clippy::needless_range_loop,
clippy::cast_precision_loss,
clippy::doc_markdown,
clippy::module_name_repetitions,
clippy::manual_let_else,
clippy::missing_const_for_fn,
clippy::option_if_let_else,
clippy::derivable_impls,
clippy::bool_to_int_with_if,
clippy::if_same_then_else,
clippy::tuple_array_conversions,
clippy::match_same_arms,
clippy::derive_partial_eq_without_eq,
clippy::suspicious_operation_groupings,
clippy::too_many_lines,
clippy::iter_over_hash_type,
clippy::map_unwrap_or,
clippy::unused_self,
clippy::used_underscore_binding
)]
use std::collections::{HashMap, HashSet};
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::edge::EdgeId;
use brepkit_topology::face::{FaceId, FaceSurface};
use brepkit_topology::solid::SolidId;
use crate::OperationsError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SurfaceClass {
Planar,
Cylindrical,
Conical,
Spherical,
Toroidal,
FreeForm,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConcavityType {
Convex,
Concave,
Tangent,
}
#[derive(Debug, Clone)]
pub struct FagNode {
pub face: FaceId,
pub surface_class: SurfaceClass,
pub area: f64,
}
#[derive(Debug, Clone)]
pub struct FagEdge {
pub edge: EdgeId,
pub concavity: ConcavityType,
pub dihedral_angle: f64,
}
pub struct FaceAdjacencyGraph {
pub nodes: HashMap<usize, FagNode>,
pub adjacency: HashMap<usize, Vec<(usize, FagEdge)>>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PatternType {
Linear,
Circular,
}
#[derive(Debug, Clone)]
pub enum Feature {
Hole {
faces: Vec<FaceId>,
diameter: Option<f64>,
},
Chamfer {
face: FaceId,
adjacent: (FaceId, FaceId),
angle: f64,
},
FilletLike {
face: FaceId,
area: f64,
},
Pocket {
floor: FaceId,
walls: Vec<FaceId>,
},
Pattern {
feature_indices: Vec<usize>,
pattern_type: PatternType,
count: usize,
spacing: Option<f64>,
},
}
pub fn recognize_features(
topo: &Topology,
solid: SolidId,
deflection: f64,
) -> Result<Vec<Feature>, OperationsError> {
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let face_ids: Vec<FaceId> = shell.faces().to_vec();
let mut features = Vec::new();
let fag = build_face_adjacency_graph(topo, &face_ids, deflection)?;
detect_chamfers_fag(topo, &fag, &mut features)?;
detect_fillet_like_fag(&fag, &mut features);
detect_holes(topo, &fag, &mut features)?;
detect_pockets_fag(&fag, &mut features);
detect_patterns(&mut features);
Ok(features)
}
fn build_face_adjacency_graph(
topo: &Topology,
face_ids: &[FaceId],
deflection: f64,
) -> Result<FaceAdjacencyGraph, OperationsError> {
let mut nodes = HashMap::new();
for &fid in face_ids {
let face = topo.face(fid)?;
let surface_class = classify_surface(face.surface());
let area = crate::measure::face_area(topo, fid, deflection).unwrap_or(0.0);
nodes.insert(
fid.index(),
FagNode {
face: fid,
surface_class,
area,
},
);
}
let mut edge_to_faces: HashMap<usize, (EdgeId, Vec<FaceId>)> = HashMap::new();
for &fid in face_ids {
let face = topo.face(fid)?;
let wire = topo.wire(face.outer_wire())?;
for oe in wire.edges() {
let entry = edge_to_faces
.entry(oe.edge().index())
.or_insert_with(|| (oe.edge(), Vec::new()));
entry.1.push(fid);
}
}
let mut adjacency: HashMap<usize, Vec<(usize, FagEdge)>> = HashMap::new();
for (eid, faces) in edge_to_faces.values() {
if faces.len() == 2 {
let angle = compute_dihedral_angle(topo, faces[0], faces[1], *eid)?;
let concavity = classify_concavity(angle);
let edge_info = FagEdge {
edge: *eid,
concavity,
dihedral_angle: angle,
};
adjacency
.entry(faces[0].index())
.or_default()
.push((faces[1].index(), edge_info.clone()));
adjacency
.entry(faces[1].index())
.or_default()
.push((faces[0].index(), edge_info));
}
}
Ok(FaceAdjacencyGraph { nodes, adjacency })
}
fn classify_surface(surface: &FaceSurface) -> SurfaceClass {
match surface {
FaceSurface::Plane { .. } => SurfaceClass::Planar,
FaceSurface::Cylinder(_) => SurfaceClass::Cylindrical,
FaceSurface::Cone(_) => SurfaceClass::Conical,
FaceSurface::Sphere(_) => SurfaceClass::Spherical,
FaceSurface::Torus(_) => SurfaceClass::Toroidal,
FaceSurface::Nurbs(_) => SurfaceClass::FreeForm,
}
}
fn classify_concavity(angle: f64) -> ConcavityType {
const TOLERANCE: f64 = 0.01;
if angle < std::f64::consts::PI - TOLERANCE {
ConcavityType::Concave
} else if angle > std::f64::consts::PI + TOLERANCE {
ConcavityType::Convex
} else {
ConcavityType::Tangent
}
}
fn compute_dihedral_angle(
topo: &Topology,
face_a: FaceId,
face_b: FaceId,
edge_id: EdgeId,
) -> Result<f64, OperationsError> {
let edge = topo.edge(edge_id)?;
let v_start = topo.vertex(edge.start())?;
let v_end = topo.vertex(edge.end())?;
let midpoint = Point3::new(
(v_start.point().x() + v_end.point().x()) * 0.5,
(v_start.point().y() + v_end.point().y()) * 0.5,
(v_start.point().z() + v_end.point().z()) * 0.5,
);
let n_a = face_normal_at(topo, face_a, midpoint)?;
let n_b = face_normal_at(topo, face_b, midpoint)?;
let dot = n_a.dot(n_b).clamp(-1.0, 1.0);
Ok(dot.acos())
}
fn face_normal_at(
topo: &Topology,
face_id: FaceId,
_point: Point3,
) -> Result<Vec3, OperationsError> {
let face = topo.face(face_id)?;
let normal = match face.surface() {
FaceSurface::Plane { normal, .. } => *normal,
FaceSurface::Cylinder(c) => c.axis(),
FaceSurface::Cone(c) => c.axis(),
FaceSurface::Sphere(_) => {
Vec3::new(0.0, 0.0, 1.0)
}
FaceSurface::Torus(_) => Vec3::new(0.0, 0.0, 1.0),
FaceSurface::Nurbs(_) => Vec3::new(0.0, 0.0, 1.0),
};
Ok(normal)
}
fn detect_chamfers_fag(
topo: &Topology,
fag: &FaceAdjacencyGraph,
features: &mut Vec<Feature>,
) -> Result<(), OperationsError> {
let mut seen_chamfers: HashSet<usize> = HashSet::new();
for (&idx, node) in &fag.nodes {
if seen_chamfers.contains(&idx) {
continue;
}
if node.surface_class != SurfaceClass::Planar {
continue;
}
let face = topo.face(node.face)?;
let normal = match face.surface() {
FaceSurface::Plane { normal, .. } => *normal,
_ => continue,
};
let neighbors = fag
.adjacency
.get(&idx)
.map_or(&[] as &[_], |v| v.as_slice());
if neighbors.len() < 2 {
continue;
}
for i in 0..neighbors.len() {
for j in (i + 1)..neighbors.len() {
let (ni, _) = &neighbors[i];
let (nj, _) = &neighbors[j];
let n1 = get_node_planar_normal(topo, fag, *ni);
let n2 = get_node_planar_normal(topo, fag, *nj);
if let (Some(n1), Some(n2)) = (n1, n2) {
let dot1 = normal.dot(n1).abs();
let dot2 = normal.dot(n2).abs();
if dot1 > 0.1 && dot1 < 0.95 && dot2 > 0.1 && dot2 < 0.95 {
let angle = normal.dot(n1).acos();
let f1 = fag.nodes.get(ni).map(|n| n.face);
let f2 = fag.nodes.get(nj).map(|n| n.face);
if let (Some(f1), Some(f2)) = (f1, f2) {
seen_chamfers.insert(idx);
features.push(Feature::Chamfer {
face: node.face,
adjacent: (f1, f2),
angle,
});
}
}
}
}
}
}
Ok(())
}
fn get_node_planar_normal(
topo: &Topology,
fag: &FaceAdjacencyGraph,
node_idx: usize,
) -> Option<Vec3> {
let node = fag.nodes.get(&node_idx)?;
if node.surface_class != SurfaceClass::Planar {
return None;
}
let face = topo.face(node.face).ok()?;
match face.surface() {
FaceSurface::Plane { normal, .. } => Some(*normal),
_ => None,
}
}
fn detect_fillet_like_fag(fag: &FaceAdjacencyGraph, features: &mut Vec<Feature>) {
if fag.nodes.is_empty() {
return;
}
let total_area: f64 = fag.nodes.values().map(|n| n.area).sum();
#[allow(clippy::cast_precision_loss)]
let avg_area = total_area / fag.nodes.len() as f64;
let threshold = avg_area * 0.25;
for node in fag.nodes.values() {
if node.area < threshold && node.area > 0.0 {
features.push(Feature::FilletLike {
face: node.face,
area: node.area,
});
}
}
}
fn detect_holes(
topo: &Topology,
fag: &FaceAdjacencyGraph,
features: &mut Vec<Feature>,
) -> Result<(), OperationsError> {
for (&idx, node) in &fag.nodes {
if node.surface_class != SurfaceClass::Cylindrical {
continue;
}
let face = topo.face(node.face)?;
let cyl = match face.surface() {
FaceSurface::Cylinder(c) => c,
_ => continue,
};
let diameter = cyl.radius() * 2.0;
let neighbors = fag
.adjacency
.get(&idx)
.map_or(&[] as &[_], |v| v.as_slice());
let _planar_neighbor_count = neighbors
.iter()
.filter(|(ni, _)| {
fag.nodes
.get(ni)
.is_some_and(|n| n.surface_class == SurfaceClass::Planar)
})
.count();
features.push(Feature::Hole {
faces: vec![node.face],
diameter: Some(diameter),
});
}
Ok(())
}
fn detect_pockets_fag(fag: &FaceAdjacencyGraph, features: &mut Vec<Feature>) {
let mut visited: HashSet<usize> = HashSet::new();
for &idx in fag.nodes.keys() {
if visited.contains(&idx) {
continue;
}
let node = match fag.nodes.get(&idx) {
Some(n) => n,
None => continue,
};
if node.surface_class != SurfaceClass::Planar {
continue;
}
let mut component = HashSet::new();
let mut stack = vec![idx];
while let Some(current) = stack.pop() {
if !component.insert(current) {
continue;
}
if let Some(adj) = fag.adjacency.get(¤t) {
for (neighbor, edge) in adj {
if edge.concavity == ConcavityType::Concave && !component.contains(neighbor) {
stack.push(*neighbor);
}
}
}
}
let mut floor = None;
let mut walls = Vec::new();
for &ci in &component {
if let Some(n) = fag.nodes.get(&ci) {
if n.surface_class == SurfaceClass::Planar {
if floor.is_none() {
floor = Some(n.face);
}
} else {
walls.push(n.face);
}
}
}
if let Some(floor_face) = floor
&& walls.len() >= 2
{
features.push(Feature::Pocket {
floor: floor_face,
walls,
});
visited.extend(&component);
}
}
}
fn detect_patterns(features: &mut Vec<Feature>) {
let hole_info: Vec<(usize, f64)> = features
.iter()
.enumerate()
.filter_map(|(i, f)| match f {
Feature::Hole {
diameter: Some(d), ..
} => Some((i, *d)),
_ => None,
})
.collect();
if hole_info.len() < 3 {
return;
}
let groups = group_by_diameter(&hole_info);
let mut new_patterns = Vec::new();
for group in &groups {
if group.len() < 3 {
continue;
}
let indices: Vec<usize> = group.iter().map(|&(i, _)| i).collect();
#[allow(clippy::cast_precision_loss)]
let count = indices.len();
new_patterns.push(Feature::Pattern {
feature_indices: indices,
pattern_type: PatternType::Linear,
count,
spacing: None,
});
}
features.extend(new_patterns);
}
fn group_by_diameter(items: &[(usize, f64)]) -> Vec<Vec<(usize, f64)>> {
let mut groups: Vec<Vec<(usize, f64)>> = Vec::new();
for &item in items {
let mut found = false;
for group in &mut groups {
let repr = group[0].1;
if (item.1 - repr).abs() < repr * 0.01 + 1e-12 {
group.push(item);
found = true;
break;
}
}
if !found {
groups.push(vec![item]);
}
}
groups
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::primitives::make_box;
#[test]
fn box_has_no_chamfers() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let features = recognize_features(&topo, solid, 0.1).unwrap();
let chamfer_count = features
.iter()
.filter(|f| matches!(f, Feature::Chamfer { .. }))
.count();
assert_eq!(chamfer_count, 0, "box should have no chamfers");
}
#[test]
fn box_has_no_fillet_like() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let features = recognize_features(&topo, solid, 0.1).unwrap();
let fillet_count = features
.iter()
.filter(|f| matches!(f, Feature::FilletLike { .. }))
.count();
assert_eq!(
fillet_count, 0,
"uniform box should have no fillet-like faces"
);
}
#[test]
fn chamfered_box_has_chamfer_features() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
let face_ids: Vec<FaceId> = shell.faces().to_vec();
let mut edge_set = HashSet::new();
for &fid in &face_ids {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
edge_set.insert(oe.edge());
}
}
let edges: Vec<_> = edge_set.into_iter().collect();
if let Ok(chamfered) = crate::chamfer::chamfer(&mut topo, solid, &[edges[0]], 0.2) {
let features = recognize_features(&topo, chamfered, 0.1).unwrap();
let chamfer_count = features
.iter()
.filter(|f| matches!(f, Feature::Chamfer { .. }))
.count();
assert!(
chamfer_count > 0,
"chamfered box should have chamfer features, got {chamfer_count}"
);
}
}
#[test]
fn feature_count_is_reasonable() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let features = recognize_features(&topo, solid, 0.1).unwrap();
assert!(
features.len() <= 12,
"box should have reasonable feature count, got {}",
features.len()
);
}
#[test]
fn fag_nodes_match_face_count() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
let face_ids: Vec<FaceId> = shell.faces().to_vec();
let fag = build_face_adjacency_graph(&topo, &face_ids, 0.1).unwrap();
assert_eq!(fag.nodes.len(), 6, "box has 6 faces");
}
#[test]
fn fag_box_all_planar() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
let face_ids: Vec<FaceId> = shell.faces().to_vec();
let fag = build_face_adjacency_graph(&topo, &face_ids, 0.1).unwrap();
for node in fag.nodes.values() {
assert_eq!(node.surface_class, SurfaceClass::Planar);
}
}
#[test]
fn fag_box_adjacency_exists() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let solid_data = topo.solid(solid).unwrap();
let shell = topo.shell(solid_data.outer_shell()).unwrap();
let face_ids: Vec<FaceId> = shell.faces().to_vec();
let fag = build_face_adjacency_graph(&topo, &face_ids, 0.1).unwrap();
for node in fag.nodes.values() {
let adj = fag.adjacency.get(&node.face.index());
assert!(adj.is_some(), "face should have adjacency");
let neighbors = adj.unwrap();
assert!(
neighbors.len() >= 2,
"each box face should have at least 2 neighbors, got {}",
neighbors.len()
);
}
}
#[test]
fn box_has_no_holes() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let features = recognize_features(&topo, solid, 0.1).unwrap();
let hole_count = features
.iter()
.filter(|f| matches!(f, Feature::Hole { .. }))
.count();
assert_eq!(hole_count, 0, "box should have no holes");
}
#[test]
fn box_has_no_patterns() {
let mut topo = Topology::new();
let solid = make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let features = recognize_features(&topo, solid, 0.1).unwrap();
let pattern_count = features
.iter()
.filter(|f| matches!(f, Feature::Pattern { .. }))
.count();
assert_eq!(pattern_count, 0, "box should have no patterns");
}
#[test]
fn classify_surface_variants() {
assert_eq!(
classify_surface(&FaceSurface::Plane {
normal: Vec3::new(0.0, 0.0, 1.0),
d: 0.0,
}),
SurfaceClass::Planar
);
}
#[test]
fn concavity_classification() {
use std::f64::consts::PI;
assert_eq!(classify_concavity(PI * 0.5), ConcavityType::Concave);
assert_eq!(classify_concavity(PI), ConcavityType::Tangent);
assert_eq!(classify_concavity(PI * 1.5), ConcavityType::Convex);
}
#[test]
fn group_by_diameter_groups_similar() {
let items = vec![(0, 10.0), (1, 10.05), (2, 20.0), (3, 10.02)];
let groups = group_by_diameter(&items);
assert_eq!(groups.len(), 2, "should form 2 groups");
}
#[test]
fn pattern_detection_needs_three() {
let mut features = vec![
Feature::Hole {
faces: vec![],
diameter: Some(5.0),
},
Feature::Hole {
faces: vec![],
diameter: Some(5.0),
},
];
detect_patterns(&mut features);
let pattern_count = features
.iter()
.filter(|f| matches!(f, Feature::Pattern { .. }))
.count();
assert_eq!(pattern_count, 0, "need at least 3 holes for a pattern");
}
#[test]
fn pattern_detection_three_same_diameter() {
let mut features = vec![
Feature::Hole {
faces: vec![],
diameter: Some(5.0),
},
Feature::Hole {
faces: vec![],
diameter: Some(5.0),
},
Feature::Hole {
faces: vec![],
diameter: Some(5.0),
},
];
detect_patterns(&mut features);
let pattern_count = features
.iter()
.filter(|f| matches!(f, Feature::Pattern { .. }))
.count();
assert_eq!(pattern_count, 1, "3 same-diameter holes form a pattern");
}
}