#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::print_stderr,
deprecated
)]
use std::collections::{HashMap, HashSet};
use brepkit_math::nurbs::surface::NurbsSurface;
use brepkit_math::vec::Point3;
use brepkit_topology::Topology;
use brepkit_topology::edge::EdgeId;
use brepkit_topology::face::{FaceId, FaceSurface};
use brepkit_topology::solid::SolidId;
use brepkit_topology::test_utils::make_unit_cube_manifold;
use brepkit_topology::validation::validate_shell_manifold;
use crate::test_helpers::assert_euler_genus0;
use super::*;
fn solid_edge_ids(topo: &Topology, solid_id: SolidId) -> Vec<EdgeId> {
let solid = topo.solid(solid_id).expect("test solid");
let shell = topo.shell(solid.outer_shell()).expect("test shell");
let mut seen = HashSet::new();
let mut edges = Vec::new();
for &fid in shell.faces() {
let face = topo.face(fid).expect("test face");
let wire = topo.wire(face.outer_wire()).expect("test wire");
for oe in wire.edges() {
if seen.insert(oe.edge().index()) {
edges.push(oe.edge());
}
}
}
edges
}
#[test]
fn fillet_single_edge() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let target = edges[0];
let result = fillet(&mut topo, cube, &[target], 0.1).expect("fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(
sh.faces().len(),
7,
"expected 7 faces after single-edge fillet"
);
}
#[test]
fn fillet_single_edge_euler() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet(&mut topo, cube, &[edges[0]], 0.1).expect("fillet should succeed");
assert_euler_genus0(&topo, result);
}
#[test]
fn fillet_result_is_manifold() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet(&mut topo, cube, &[edges[0]], 0.1).expect("fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
validate_shell_manifold(sh, &topo).expect("fillet result should be manifold");
}
#[test]
fn fillet_zero_radius_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
assert!(fillet(&mut topo, cube, &[edges[0]], 0.0).is_err());
}
#[test]
fn fillet_negative_radius_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
assert!(fillet(&mut topo, cube, &[edges[0]], -0.1).is_err());
}
#[test]
fn fillet_no_edges_error() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
assert!(fillet(&mut topo, cube, &[], 0.1).is_err());
}
#[test]
fn radius_law_constant() {
let law = FilletRadiusLaw::Constant(0.5);
assert!((law.evaluate(0.0) - 0.5).abs() < 1e-10);
assert!((law.evaluate(0.5) - 0.5).abs() < 1e-10);
assert!((law.evaluate(1.0) - 0.5).abs() < 1e-10);
}
#[test]
fn radius_law_linear() {
let law = FilletRadiusLaw::Linear {
start: 0.1,
end: 0.5,
};
assert!((law.evaluate(0.0) - 0.1).abs() < 1e-10);
assert!((law.evaluate(0.5) - 0.3).abs() < 1e-10);
assert!((law.evaluate(1.0) - 0.5).abs() < 1e-10);
}
#[test]
fn radius_law_scurve() {
let law = FilletRadiusLaw::SCurve {
start: 0.1,
end: 0.5,
};
assert!((law.evaluate(0.0) - 0.1).abs() < 1e-10);
assert!((law.evaluate(1.0) - 0.5).abs() < 1e-10);
let mid = law.evaluate(0.5);
assert!(mid > 0.1 && mid < 0.5);
}
#[test]
fn fillet_variable_constant_law() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let laws = vec![(edges[0], FilletRadiusLaw::Constant(0.1))];
let result = fillet_variable(&mut topo, cube, &laws).expect("variable fillet should work");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(sh.faces().len(), 7, "should have 7 faces after fillet");
}
#[test]
fn fillet_variable_linear_law() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let laws = vec![(
edges[0],
FilletRadiusLaw::Linear {
start: 0.05,
end: 0.15,
},
)];
let result = fillet_variable(&mut topo, cube, &laws).expect("variable fillet should work");
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
assert!(vol > 0.5, "filleted cube should have volume, got {vol}");
}
#[test]
fn fillet_variable_removes_material_linear_law() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let laws = vec![(
edges[0],
FilletRadiusLaw::Linear {
start: 0.5,
end: 1.5,
},
)];
let result = fillet_variable(&mut topo, solid, &laws).expect("variable fillet");
let vol = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(vol < 1000.0, "fillet must remove material, got {vol}");
assert!(
vol > 900.0,
"single-edge fillet removes only a sliver, got {vol}"
);
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
validate_shell_manifold(sh, &topo).expect("variable fillet result should be manifold");
assert_euler_genus0(&topo, result);
}
#[test]
fn fillet_has_positive_volume() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet(&mut topo, cube, &[edges[0]], 0.1).expect("fillet should succeed");
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
assert!(
vol > 0.5,
"filleted cube should have significant volume, got {vol}"
);
}
#[test]
fn rolling_ball_fillet_single_edge() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet_rolling_ball(&mut topo, cube, &[edges[0]], 0.1)
.expect("rolling-ball fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(
sh.faces().len(),
7,
"expected 7 faces after single-edge rolling-ball fillet"
);
assert_euler_genus0(&topo, result);
}
#[test]
fn rolling_ball_rejects_closed_rim_so_dispatcher_falls_through() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 10.0, 20.0).expect("cylinder");
let edges = solid_edge_ids(&topo, cyl);
let err = fillet_rolling_ball(&mut topo, cyl, &edges, 0.5)
.expect_err("rolling-ball must reject a closed circular rim");
let reason = match &err {
crate::OperationsError::InvalidInput { reason } => reason,
other => unreachable!("expected InvalidInput from the degeneracy guard, got: {other:?}"),
};
assert!(
reason.contains("degenerate face"),
"expected the degeneracy guard to fire, got: {reason}"
);
}
#[test]
fn rolling_ball_straight_edge_wall_is_exact_cylinder() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet_rolling_ball(&mut topo, cube, &[edges[0]], 0.1)
.expect("rolling-ball fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
let walls: Vec<_> = sh
.faces()
.iter()
.filter(|&&fid| !topo.face(fid).expect("face").surface().is_planar())
.collect();
assert_eq!(walls.len(), 1, "one blend wall expected");
let surface = topo.face(*walls[0]).expect("face").surface();
assert!(
matches!(surface, FaceSurface::Cylinder(cyl) if (cyl.radius() - 0.1).abs() < 1e-9),
"straight-edge blend wall must be an exact r=0.1 Cylinder, got {:?}",
surface.type_tag()
);
}
#[test]
fn rolling_ball_fillet_surface_is_circular_arc() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet_rolling_ball(&mut topo, cube, &[edges[0]], 0.2)
.expect("rolling-ball fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
for &fid in sh.faces() {
let face = topo.face(fid).expect("face");
if let FaceSurface::Nurbs(surface) = face.surface() {
assert_eq!(
surface.degree_u(),
2,
"u (arc) direction should be degree 2"
);
assert_eq!(
surface.degree_v(),
1,
"v (extrusion) direction should be degree 1"
);
let mid_pt = surface.evaluate(0.5, 0.5);
assert!(
mid_pt.x() > -0.5 && mid_pt.x() < 1.5,
"fillet midpoint x should be near cube: {mid_pt:?}"
);
}
}
}
#[test]
fn rolling_ball_fillet_positive_volume() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result =
fillet_rolling_ball(&mut topo, cube, &[edges[0]], 0.1).expect("fillet should succeed");
let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
assert!(
vol > 0.5,
"filleted cube should have significant volume, got {vol}"
);
}
#[test]
fn rolling_ball_fillet_multiple_edges() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result = fillet_rolling_ball(&mut topo, cube, &[edges[0], edges[1]], 0.1)
.expect("multi-edge rolling-ball fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(
sh.faces().len(),
9,
"expected 9 faces after two-edge shared-corner rolling-ball fillet"
);
validate_shell_manifold(sh, &topo).expect("two-edge fillet should be manifold");
}
#[test]
fn rolling_ball_two_edges_shared_corner_watertight() {
let mut topo = Topology::new();
let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let all_edges = solid_edge_ids(&topo, cube);
let mut shared_pairs: Vec<(usize, usize)> = Vec::new();
for i in 0..all_edges.len() {
for j in (i + 1)..all_edges.len() {
let ei = topo.edge(all_edges[i]).unwrap();
let ej = topo.edge(all_edges[j]).unwrap();
let shares = ei.start() == ej.start()
|| ei.start() == ej.end()
|| ei.end() == ej.start()
|| ei.end() == ej.end();
if shares {
shared_pairs.push((i, j));
}
}
}
assert!(
!shared_pairs.is_empty(),
"box should have corner-sharing edge pairs"
);
for &(i, j) in shared_pairs.iter().take(6) {
let mut t = Topology::new();
let box_id = crate::primitives::make_box(&mut t, 10.0, 10.0, 10.0).unwrap();
let edges = solid_edge_ids(&t, box_id);
let result = fillet_rolling_ball(&mut t, box_id, &[edges[i], edges[j]], 1.0)
.expect("fillet of shared-corner edges should succeed");
let s = t.solid(result).expect("result solid");
let sh = t.shell(s.outer_shell()).expect("shell");
assert!(
brepkit_topology::validation::validate_shell_closed(sh, &t).is_ok(),
"shared-corner pair {i},{j} should be watertight"
);
assert!(
validate_shell_manifold(sh, &t).is_ok(),
"shared-corner pair {i},{j} should be manifold"
);
assert_euler_genus0(&t, result);
let vol = crate::measure::solid_volume(&t, result, 0.01).unwrap();
assert!(
(985.0..1000.0).contains(&vol),
"shared-corner pair {i},{j}: volume {vol} outside sane range"
);
}
}
#[test]
fn rolling_ball_two_edges_no_shared_corner_watertight() {
let mut topo = Topology::new();
let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let edges = solid_edge_ids(&topo, cube);
let mut pair = None;
'outer: for i in 0..edges.len() {
for j in (i + 1)..edges.len() {
let ei = topo.edge(edges[i]).unwrap();
let ej = topo.edge(edges[j]).unwrap();
let shares = ei.start() == ej.start()
|| ei.start() == ej.end()
|| ei.end() == ej.start()
|| ei.end() == ej.end();
if !shares {
pair = Some((i, j));
break 'outer;
}
}
}
let (i, j) = pair.expect("box should have a non-corner-sharing edge pair");
let result = fillet_rolling_ball(&mut topo, cube, &[edges[i], edges[j]], 1.0)
.expect("non-shared-corner fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(sh.faces().len(), 8, "non-shared pair should add no patch");
brepkit_topology::validation::validate_shell_closed(sh, &topo)
.expect("non-shared-corner fillet should be watertight");
assert_euler_genus0(&topo, result);
}
#[test]
fn rolling_ball_fillet_error_cases() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
assert!(fillet_rolling_ball(&mut topo, cube, &[edges[0]], 0.0).is_err());
assert!(fillet_rolling_ball(&mut topo, cube, &[edges[0]], -0.1).is_err());
assert!(fillet_rolling_ball(&mut topo, cube, &[], 0.1).is_err());
}
#[test]
fn vertex_blend_all_edges_box() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
assert_eq!(edges.len(), 12, "unit cube should have 12 edges");
let result =
fillet_rolling_ball(&mut topo, cube, &edges, 0.1).expect("all-edges fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(
sh.faces().len(),
26,
"expected 26 faces (6 planar + 12 fillet + 8 blend)"
);
}
#[test]
fn vertex_blend_all_edges_box_volume() {
let mut topo = Topology::new();
let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let edges = solid_edge_ids(&topo, cube);
assert_eq!(edges.len(), 12, "box should have 12 edges");
let result =
fillet_rolling_ball(&mut topo, cube, &edges, 1.0).expect("all-edges fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
validate_shell_manifold(sh, &topo).expect("filleted box should be manifold");
assert_euler_genus0(&topo, result);
assert_eq!(
sh.faces().len(),
26,
"expected 26 faces (6 planar + 12 fillet + 8 blend)"
);
let vol = crate::measure::solid_volume(&topo, result, 0.01).unwrap();
assert!(
vol > 974.0 && vol < 978.0,
"filleted box volume should be ≈975.6 (in 974..978), got {vol}"
);
}
#[test]
fn vertex_blend_tessellates_successfully() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result =
fillet_rolling_ball(&mut topo, cube, &edges, 0.1).expect("all-edges fillet should succeed");
let mesh = crate::tessellate::tessellate_solid(&topo, result, 0.05).unwrap();
assert!(mesh.positions.len() > 20, "should have many vertices");
assert!(mesh.indices.len() > 60, "should have many triangles");
}
#[test]
fn vertex_blend_positive_volume() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result =
fillet_rolling_ball(&mut topo, cube, &edges, 0.1).expect("all-edges fillet should succeed");
let vol = crate::measure::solid_volume(&topo, result, 0.005).unwrap();
assert!(
vol > 0.970 && vol < 0.980,
"filleted unit-cube volume should be ≈0.9756 (in 0.970..0.980), got {vol}"
);
}
#[test]
fn vertex_blend_box_primitive() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 2.0, 3.0, 4.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
assert_eq!(edges.len(), 12);
let result = fillet_rolling_ball(&mut topo, solid, &edges, 0.2)
.expect("box primitive all-edges fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert_eq!(sh.faces().len(), 26);
}
#[test]
fn vertex_blend_three_edges_at_corner() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let all_edges = solid_edge_ids(&topo, cube);
let mut vertex_to_edges: HashMap<usize, Vec<EdgeId>> = HashMap::new();
for &eid in &all_edges {
let e = topo.edge(eid).unwrap();
vertex_to_edges
.entry(e.start().index())
.or_default()
.push(eid);
vertex_to_edges
.entry(e.end().index())
.or_default()
.push(eid);
}
let (&_vi, corner_edges) = vertex_to_edges
.iter()
.find(|(_, edges)| edges.len() >= 3)
.expect("box should have vertices with 3 edges");
let targets: Vec<EdgeId> = corner_edges.iter().take(3).copied().collect();
let result = fillet_rolling_ball(&mut topo, cube, &targets, 0.1)
.expect("3-edge corner fillet should succeed");
let s = topo.solid(result).expect("result solid");
let sh = topo.shell(s.outer_shell()).expect("shell");
assert!(
sh.faces().len() >= 10,
"expected at least 10 faces (6 + 3 + 1 blend), got {}",
sh.faces().len()
);
}
#[test]
fn vertex_blend_is_curved_not_flat() {
let r = 0.1_f64;
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result =
fillet_rolling_ball(&mut topo, cube, &edges, r).expect("all-edges fillet should succeed");
let solid = topo.solid(result).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
let mut blend_face_count = 0;
let mut max_sphere_err = 0.0_f64;
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
if !matches!(face.surface(), FaceSurface::Nurbs(_)) {
continue;
}
let wire = topo.wire(face.outer_wire()).unwrap();
let wire_verts: Vec<Point3> = wire
.edges()
.iter()
.map(|oe| {
let v = topo.vertex(topo.edge(oe.edge()).unwrap().start()).unwrap();
v.point()
})
.collect();
if wire_verts.len() != 3 {
continue;
}
blend_face_count += 1;
let avg = Point3::new(
(wire_verts[0].x() + wire_verts[1].x() + wire_verts[2].x()) / 3.0,
(wire_verts[0].y() + wire_verts[1].y() + wire_verts[2].y()) / 3.0,
(wire_verts[0].z() + wire_verts[1].z() + wire_verts[2].z()) / 3.0,
);
let corner = Point3::new(
if avg.x() > 0.5 { 1.0 } else { 0.0 },
if avg.y() > 0.5 { 1.0 } else { 0.0 },
if avg.z() > 0.5 { 1.0 } else { 0.0 },
);
let sphere_center = Point3::new(
corner.x() + if corner.x() > 0.5 { -r } else { r },
corner.y() + if corner.y() > 0.5 { -r } else { r },
corner.z() + if corner.z() > 0.5 { -r } else { r },
);
let r_blend = (wire_verts[0] - sphere_center).length();
if let FaceSurface::Nurbs(srf) = face.surface() {
for u in [0.25, 0.5, 0.75] {
for v in [0.25, 0.5, 0.75] {
let pt = srf.evaluate(u, v);
let dist = (pt - sphere_center).length();
let err = (dist - r_blend).abs();
max_sphere_err = max_sphere_err.max(err);
}
}
}
}
assert!(
blend_face_count >= 8,
"expected 8 vertex blend faces, found {blend_face_count}"
);
assert!(
max_sphere_err < r * 0.06,
"blend surface deviates from sphere by {max_sphere_err:.6} (limit {:.6})",
r * 0.06,
);
}
#[test]
fn vertex_blend_sphere_center_inside_solid() {
let r = 0.1_f64;
let margin = r;
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let edges = solid_edge_ids(&topo, cube);
let result =
fillet_rolling_ball(&mut topo, cube, &edges, r).expect("all-edges fillet should succeed");
let solid = topo.solid(result).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
if let FaceSurface::Nurbs(srf) = face.surface() {
let wire = topo.wire(face.outer_wire()).unwrap();
if wire.edges().len() != 3 {
continue;
}
for u in [0.25, 0.5, 0.75] {
for v in [0.25, 0.5] {
let pt = srf.evaluate(u, v);
assert!(
pt.x() > -margin
&& pt.x() < 1.0 + margin
&& pt.y() > -margin
&& pt.y() < 1.0 + margin
&& pt.z() > -margin
&& pt.z() < 1.0 + margin,
"blend point ({:.4},{:.4},{:.4}) too far outside unit cube",
pt.x(),
pt.y(),
pt.z(),
);
}
}
}
}
}
#[test]
fn fillet_on_boolean_result() {
let mut topo = Topology::new();
let base = crate::primitives::make_box(&mut topo, 80.0, 60.0, 10.0).unwrap();
let boss = crate::primitives::make_cylinder(&mut topo, 15.0, 30.0).unwrap();
let mat = brepkit_math::mat::Mat4::translation(40.0, 30.0, 10.0);
crate::transform::transform_solid(&mut topo, boss, &mat).unwrap();
let fused =
crate::boolean::boolean(&mut topo, crate::boolean::BooleanOp::Fuse, base, boss).unwrap();
let solid = topo.solid(fused).unwrap();
let shell = topo.shell(solid.outer_shell()).unwrap();
let mut edge_to_face_ids: HashMap<usize, Vec<FaceId>> = HashMap::new();
for &fid in shell.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
edge_to_face_ids
.entry(oe.edge().index())
.or_default()
.push(fid);
}
for &iwid in face.inner_wires() {
let iw = topo.wire(iwid).unwrap();
for oe in iw.edges() {
edge_to_face_ids
.entry(oe.edge().index())
.or_default()
.push(fid);
}
}
}
let bad_count = edge_to_face_ids.values().filter(|f| f.len() != 2).count();
assert!(
bad_count <= 4,
"too many non-manifold edges: {bad_count} (expected <= 4 seam edges)",
);
let is_planar = |fid: FaceId| -> bool {
matches!(topo.face(fid).unwrap().surface(), FaceSurface::Plane { .. })
};
let mut planar_edges = Vec::new();
for (&eidx, face_ids) in &edge_to_face_ids {
if face_ids.len() == 2 && is_planar(face_ids[0]) && is_planar(face_ids[1]) {
let face = topo.face(face_ids[0]).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
if oe.edge().index() == eidx {
planar_edges.push(oe.edge());
break;
}
}
}
}
planar_edges.sort_unstable_by_key(|e| e.index());
planar_edges.dedup_by_key(|e| e.index());
assert!(
!planar_edges.is_empty(),
"should have planar-planar edges to fillet"
);
let result = fillet(&mut topo, fused, &planar_edges, 1.0);
assert!(
result.is_ok(),
"fillet on planar edges of boolean result should succeed: {:?}",
result.err()
);
}
#[test]
fn fillet_radius_too_large_rejected() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result = fillet_rolling_ball(&mut topo, solid, &edges[..1], 3.0);
assert!(result.is_err(), "should reject radius exceeding face size");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("exceeds"),
"error should mention exceeds: {msg}"
);
}
#[test]
fn fillet_radius_exceeds_cylinder_curvature_rejected() {
let mut topo = Topology::new();
let solid = crate::primitives::make_cylinder(&mut topo, 1.0, 4.0).unwrap();
let plane_cyl_edge = {
let s = topo.solid(solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let mut edge_faces: HashMap<usize, Vec<FaceId>> = HashMap::new();
for &fid in sh.faces() {
let wire = topo.wire(topo.face(fid).unwrap().outer_wire()).unwrap();
for oe in wire.edges() {
edge_faces.entry(oe.edge().index()).or_default().push(fid);
}
}
let mut found = None;
'outer: for (&eidx, fids) in &edge_faces {
if fids.len() == 2 {
let s1 = topo.face(fids[0]).unwrap().surface().clone();
let s2 = topo.face(fids[1]).unwrap().surface().clone();
let has_plane = matches!(s1, FaceSurface::Plane { .. })
|| matches!(s2, FaceSurface::Plane { .. });
let has_cyl = matches!(s1, FaceSurface::Cylinder(_))
|| matches!(s2, FaceSurface::Cylinder(_));
if has_plane && has_cyl {
for &fid in sh.faces() {
let wire = topo.wire(topo.face(fid).unwrap().outer_wire()).unwrap();
for oe in wire.edges() {
if oe.edge().index() == eidx {
found = Some(oe.edge());
break 'outer;
}
}
}
}
}
}
found.expect("cylinder must have a plane-cylinder edge")
};
let result = fillet_rolling_ball(&mut topo, solid, &[plane_cyl_edge], 1.0);
assert!(
result.is_err(),
"radius == cylinder radius should be rejected"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("curvature"),
"error should mention curvature: {msg}"
);
let result2 = fillet_rolling_ball(&mut topo, solid, &[plane_cyl_edge], 1.5);
assert!(
result2.is_err(),
"radius > cylinder radius should be rejected"
);
let result3 = fillet_rolling_ball(&mut topo, solid, &[plane_cyl_edge], 0.3);
if let Err(ref e) = result3 {
let msg = format!("{e}");
assert!(
!msg.contains("curvature"),
"small radius should not fail curvature check: {msg}"
);
}
}
#[test]
fn fillet_radius_just_fits() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 4.0, 4.0, 4.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result = fillet_rolling_ball(&mut topo, solid, &edges[..1], 1.0);
assert!(
result.is_ok(),
"small radius should succeed: {:?}",
result.err()
);
}
#[test]
fn fillet_plane_cylinder_edge() {
let mut topo = Topology::new();
let solid = crate::primitives::make_cylinder(&mut topo, 2.0, 4.0).unwrap();
let s = topo.solid(solid).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
let mut plane_cyl_edges: Vec<EdgeId> = Vec::new();
let mut edge_faces: HashMap<usize, Vec<FaceId>> = HashMap::new();
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
edge_faces.entry(oe.edge().index()).or_default().push(fid);
}
}
for (&eidx, fids) in &edge_faces {
if fids.len() == 2 {
let s1 = topo.face(fids[0]).unwrap().surface().clone();
let s2 = topo.face(fids[1]).unwrap().surface().clone();
let has_plane =
matches!(s1, FaceSurface::Plane { .. }) || matches!(s2, FaceSurface::Plane { .. });
let has_cyl =
matches!(s1, FaceSurface::Cylinder(_)) || matches!(s2, FaceSurface::Cylinder(_));
if has_plane && has_cyl {
for &fid in sh.faces() {
let face = topo.face(fid).unwrap();
let wire = topo.wire(face.outer_wire()).unwrap();
for oe in wire.edges() {
if oe.edge().index() == eidx {
plane_cyl_edges.push(oe.edge());
}
}
}
break; }
}
}
assert!(
!plane_cyl_edges.is_empty(),
"cylinder should have plane-cylinder edges"
);
let result = fillet_rolling_ball(&mut topo, solid, &plane_cyl_edges[..1], 0.3);
assert!(
result.is_err(),
"rolling-ball fillet of a closed cylinder-rim edge must be rejected as \
degenerate, not return a silently-broken solid"
);
let msg = format!("{}", result.err().unwrap());
assert!(
msg.contains("degenerate"),
"expected a degenerate-face rejection, got: {msg}"
);
}
#[test]
fn g1_propagate_box_no_expansion() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let seed = &edges[..1];
let result = fillet_rolling_ball_propagate_g1(&mut topo, solid, seed, 0.1);
assert!(
result.is_ok(),
"propagate_g1 on a box edge should succeed: {:?}",
result.err()
);
let result_solid = result.unwrap();
let vol = crate::measure::solid_volume(&topo, result_solid, 0.01).unwrap();
assert!(
vol > 0.0 && vol < 1.0,
"filleted box should have smaller volume than original: {vol}"
);
}
#[test]
fn g1_propagate_collinear_long_box() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 4.0, 1.0, 1.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let long_edge = edges
.iter()
.find(|&&eid| {
let e = topo.edge(eid).unwrap();
let p0 = topo.vertex(e.start()).unwrap().point();
let p1 = topo.vertex(e.end()).unwrap().point();
let len = (p1 - p0).length();
len > 3.5
})
.copied();
let seed_edge = long_edge.expect("could not find a long edge on a 4×1×1 box");
let result = fillet_rolling_ball_propagate_g1(&mut topo, solid, &[seed_edge], 0.1);
assert!(
result.is_ok(),
"propagate_g1 on long-box edge should succeed: {:?}",
result.err()
);
let result_solid = result.unwrap();
let vol = crate::measure::solid_volume(&topo, result_solid, 0.01).unwrap();
assert!(
vol > 0.0 && vol < 4.0,
"filleted long box volume should be positive and less than original: {vol}"
);
}
#[test]
fn adjacent_fillet_overlap_all_edges_rejected() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result = fillet_rolling_ball(&mut topo, solid, &edges, 0.5);
assert!(
result.is_err(),
"all-edge fillet with R=0.5 on unit box should be rejected"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("adjacent fillet strips overlap"),
"error should mention overlap: {msg}"
);
}
#[test]
fn adjacent_fillet_overlap_fits_with_small_radius() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result = fillet_rolling_ball(&mut topo, solid, &edges, 0.4);
assert!(
result.is_ok(),
"all-edge fillet with R=0.4 on unit box should be accepted by Phase 2d: {:?}",
result.err()
);
}
#[test]
fn adjacent_fillet_single_edge_no_phase2d_rejection() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 1.0, 1.0, 1.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result = fillet_rolling_ball(&mut topo, solid, &edges[..1], 0.4);
assert!(
result.is_ok(),
"single-edge fillet with R=0.4 should succeed: {:?}",
result.err()
);
}
#[test]
fn face_surface_normal_at_nurbs_via_projection() {
let srf = NurbsSurface::new(
1,
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![0.0, 0.0, 1.0, 1.0],
vec![
vec![Point3::new(0.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)],
vec![Point3::new(1.0, 0.0, 0.0), Point3::new(1.0, 1.0, 0.0)],
],
vec![vec![1.0, 1.0], vec![1.0, 1.0]],
)
.expect("bilinear XY patch");
let surface = FaceSurface::Nurbs(srf);
let n = face_surface_normal_at(&surface, Point3::new(0.2, 0.8, 0.0));
let n = n.expect("NURBS normal should be Some for a surface point");
assert!(
n.z().abs() > 0.9,
"flat XY patch normal must be along Z, got: {n:?}"
);
assert!(
(n.length() - 1.0).abs() < 0.01,
"NURBS normal must be unit length, got: {}",
n.length()
);
}
#[test]
fn fillet_rolling_ball_second_pass_on_nurbs_solid() {
let mut topo = Topology::new();
let solid = make_unit_cube_manifold(&mut topo);
let edges1 = solid_edge_ids(&topo, solid);
let result1 = fillet_rolling_ball(&mut topo, solid, &[edges1[0]], 0.1)
.expect("first rolling-ball fillet should succeed");
let has_nurbs = {
let s = topo.solid(result1).unwrap();
let sh = topo.shell(s.outer_shell()).unwrap();
sh.faces()
.iter()
.any(|&fid| !topo.face(fid).unwrap().surface().is_planar())
};
assert!(has_nurbs, "first fillet must produce a blend face");
let edges2 = solid_edge_ids(&topo, result1);
let result2 = fillet_rolling_ball(&mut topo, result1, &[edges2[1]], 0.05);
assert!(
result2.is_ok(),
"second fillet on NURBS-containing solid must succeed: {:?}",
result2.err()
);
let vol = crate::measure::solid_volume(&topo, result2.unwrap(), 0.1).unwrap();
assert!(
vol > 0.5,
"doubly-filleted solid must have positive volume, got {vol}"
);
}
#[test]
fn fillet_on_fillet_box() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result1 = fillet_rolling_ball(&mut topo, solid, &edges, 0.1).unwrap();
let vol1 = crate::measure::solid_volume(&topo, result1, 0.01).unwrap();
assert!(vol1 > 0.0, "first fillet should produce positive volume");
let edges2 = solid_edge_ids(&topo, result1);
assert!(
!edges2.is_empty(),
"filleted solid must have edges for second fillet attempt"
);
let result2 = fillet_rolling_ball(&mut topo, result1, &edges2[..1], 0.05);
match result2 {
Ok(solid2) => {
let vol2 = crate::measure::solid_volume(&topo, solid2, 0.01).unwrap();
assert!(vol2 > 0.0, "second fillet should produce positive volume");
}
Err(e) => {
eprintln!("second fillet failed gracefully: {e}");
}
}
}
#[test]
fn adjacent_fillet_overlap_curved_face_detected() {
let mut topo = Topology::new();
let cyl = crate::primitives::make_cylinder(&mut topo, 1.0, 2.0).unwrap();
let edges = solid_edge_ids(&topo, cyl);
let result = fillet_rolling_ball(&mut topo, cyl, &edges, 0.9);
match result {
Ok(solid) => {
let vol = crate::measure::solid_volume(&topo, solid, 0.01).unwrap();
assert!(vol > 0.0);
}
Err(e) => {
let msg = format!("{e}");
assert!(
msg.contains("overlap")
|| msg.contains("curvature")
|| msg.contains("exceeds")
|| msg.contains("degenerate"),
"expected overlap/curvature/degenerate error, got: {msg}"
);
}
}
}
#[test]
fn g1_chain_no_expansion_for_box() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let edges = solid_edge_ids(&topo, solid);
let result = fillet_rolling_ball(&mut topo, solid, &edges[..1], 0.1);
assert!(
result.is_ok(),
"single-edge fillet on box should succeed: {:?}",
result.err()
);
let result_solid = result.unwrap();
let vol = crate::measure::solid_volume(&topo, result_solid, 0.01).unwrap();
assert!(
vol > 7.0 && vol < 8.0,
"filleted box volume should be slightly less than 8.0, got {vol}"
);
}
#[test]
fn g1_chain_integrated_matches_explicit_wrapper() {
let mut topo1 = Topology::new();
let solid1 = crate::primitives::make_box(&mut topo1, 1.0, 1.0, 1.0).unwrap();
let edges1 = solid_edge_ids(&topo1, solid1);
let result1 = fillet_rolling_ball(&mut topo1, solid1, &edges1[..1], 0.1);
assert!(result1.is_ok(), "direct call should succeed");
let vol1 = crate::measure::solid_volume(&topo1, result1.unwrap(), 0.01).unwrap();
let mut topo2 = Topology::new();
let solid2 = crate::primitives::make_box(&mut topo2, 1.0, 1.0, 1.0).unwrap();
let edges2 = solid_edge_ids(&topo2, solid2);
let result2 = fillet_rolling_ball_propagate_g1(&mut topo2, solid2, &edges2[..1], 0.1);
assert!(result2.is_ok(), "wrapper call should succeed");
let vol2 = crate::measure::solid_volume(&topo2, result2.unwrap(), 0.01).unwrap();
assert!(
(vol1 - vol2).abs() < 0.01,
"volumes should match: direct={vol1}, wrapper={vol2}"
);
}
fn dihedral_deg(topo: &Topology, e: EdgeId, fs: &[FaceId]) -> f64 {
let ed = topo.edge(e).unwrap();
let a = topo.vertex(ed.start()).unwrap().point();
let b = topo.vertex(ed.end()).unwrap().point();
let mid = a + (b - a) * 0.5;
let nrm = |fid: FaceId| {
let face = topo.face(fid).unwrap();
let n = match face.surface() {
FaceSurface::Plane { normal, .. } => *normal,
other => {
let (u, v) = other.project_point(mid).unwrap_or((0.0, 0.0));
other.normal(u, v)
}
};
let n = if face.is_reversed() { -n } else { n };
n.normalize().unwrap()
};
nrm(fs[0])
.dot(nrm(fs[1]))
.clamp(-1.0, 1.0)
.acos()
.to_degrees()
}
#[test]
fn fillet_edge_adjacent_to_nurbs_blend_is_watertight() {
use brepkit_topology::validation::validate_shell_closed;
let mut topo = Topology::new();
let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
let edges = solid_edge_ids(&topo, cube);
let first = fillet_rolling_ball(&mut topo, cube, &[edges[0]], 1.0).unwrap();
{
let sh = topo
.shell(topo.solid(first).unwrap().outer_shell())
.unwrap();
validate_shell_closed(sh, &topo).expect("first fillet should be watertight");
}
let vol1 = crate::measure::solid_volume(&topo, first, 0.05).unwrap();
let blend_faces: HashSet<usize> = {
let sh = topo
.shell(topo.solid(first).unwrap().outer_shell())
.unwrap();
sh.faces()
.iter()
.filter(|&&f| !topo.face(f).unwrap().surface().is_planar())
.map(|f| f.index())
.collect()
};
assert!(
!blend_faces.is_empty(),
"first fillet must create a blend face"
);
let mut ef: HashMap<usize, Vec<FaceId>> = HashMap::new();
{
let sh = topo
.shell(topo.solid(first).unwrap().outer_shell())
.unwrap();
for &fid in sh.faces() {
for oe in topo
.wire(topo.face(fid).unwrap().outer_wire())
.unwrap()
.edges()
{
ef.entry(oe.edge().index()).or_default().push(fid);
}
}
}
let target = solid_edge_ids(&topo, first)
.into_iter()
.find(|&e| {
ef.get(&e.index()).is_some_and(|fs| {
fs.len() == 2
&& fs.iter().any(|f| blend_faces.contains(&f.index()))
&& dihedral_deg(&topo, e, fs) > 5.0
})
})
.expect("a non-tangent edge bordering the NURBS blend face");
let result = fillet_rolling_ball(&mut topo, first, &[target], 0.5).unwrap();
let sh = topo
.shell(topo.solid(result).unwrap().outer_shell())
.unwrap();
validate_shell_manifold(sh, &topo).expect("second fillet must be manifold");
validate_shell_closed(sh, &topo)
.expect("second fillet on a NURBS-blend-adjacent edge must be watertight");
let vol2 = crate::measure::solid_volume(&topo, result, 0.05).unwrap();
assert!(
vol2 > vol1 - 1e-6 && vol2 <= 1000.0 + 1e-6,
"filled fillet volume out of range: first={vol1}, second={vol2}"
);
}