use super::*;
use crate::{chamfer_edge, fillet_edge, make_box_brep, make_cylinder_brep};
fn unit_cube() -> BrepSolid {
make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 1.0, 1.0).unwrap()
}
fn edge_id_near(solid: &BrepSolid, target: Vec3) -> u64 {
solid
.edges
.iter()
.min_by(|a, b| {
let ma = a
.curve
.evaluate((a.t0 + a.t1) * 0.5)
.unwrap()
.sub(target)
.length();
let mb = b
.curve
.evaluate((b.t0 + b.t1) * 0.5)
.unwrap()
.sub(target)
.length();
ma.partial_cmp(&mb).unwrap()
})
.unwrap()
.id
}
#[test]
fn deletes_chamfer_and_recovers_the_sharp_cube() {
let cube = unit_cube();
let volume = solid_signed_volume(&cube).unwrap().abs();
let edge = edge_id_near(&cube, Vec3::new(1.0, 0.5, 1.0));
let chamfered = chamfer_edge(&cube, edge, 0.2, Some("chamfer")).unwrap();
assert!(chamfered.validate().is_empty());
let chamfer_face = resolve_face_by_point(&chamfered, Vec3::new(0.9, 0.5, 0.9)).unwrap();
let face_count_before = chamfered.shells[0].faces.len();
let healed = delete_face_and_heal(&chamfered, chamfer_face).unwrap();
assert!(
healed.validate().is_empty(),
"healed solid must validate: {:?}",
healed.validate()
);
assert_eq!(healed.shells[0].faces.len(), face_count_before - 1);
assert!(!healed.shells[0].faces.iter().any(|f| f.id == chamfer_face));
let healed_volume = solid_signed_volume(&healed).unwrap().abs();
assert!(
(healed_volume - volume).abs() < 1e-6,
"expected full-cube volume {volume}, got {healed_volume}"
);
assert!(healed.edges.iter().any(|edge| {
let mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5).unwrap();
(mid.x - 1.0).abs() < 1e-6 && (mid.z - 1.0).abs() < 1e-6
}));
}
#[test]
fn deletes_fillet_and_recovers_the_sharp_cube() {
let cube = unit_cube();
let volume = solid_signed_volume(&cube).unwrap().abs();
let edge = edge_id_near(&cube, Vec3::new(1.0, 0.5, 1.0));
let filleted = fillet_edge(&cube, edge, 0.2, Some("fillet")).unwrap();
assert!(filleted.validate().is_empty());
let offset = 0.2 / 2.0_f64.sqrt();
let probe = Vec3::new(0.8 + offset, 0.5, 0.8 + offset);
let fillet_face = resolve_face_by_point(&filleted, probe).unwrap();
let face_count_before = filleted.shells[0].faces.len();
let healed = delete_face_and_heal(&filleted, fillet_face).unwrap();
assert!(
healed.validate().is_empty(),
"healed solid must validate: {:?}",
healed.validate()
);
assert_eq!(healed.shells[0].faces.len(), face_count_before - 1);
let healed_volume = solid_signed_volume(&healed).unwrap().abs();
assert!(
(healed_volume - volume).abs() < 1e-6,
"expected full-cube volume {volume}, got {healed_volume}"
);
assert!(healed.edges.iter().any(|edge| {
let mid = edge.curve.evaluate((edge.t0 + edge.t1) * 0.5).unwrap();
(mid.x - 1.0).abs() < 1e-6 && (mid.z - 1.0).abs() < 1e-6
}));
}
#[test]
fn refuses_to_delete_a_face_whose_neighbours_cannot_reintersect() {
let cube = unit_cube();
let top = resolve_face_by_point(&cube, Vec3::new(0.5, 0.5, 1.0)).unwrap();
let result = delete_face_and_heal(&cube, top);
assert!(result.is_err(), "expected a refusal, got a solid");
let message = result.unwrap_err();
assert!(
message.contains("re-intersect") || message.contains("parallel"),
"unexpected error: {message}"
);
assert!(cube.validate().is_empty());
}
#[test]
fn moving_a_box_face_outward_grows_volume_like_an_extrude() {
let block = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 2.0, 3.0).unwrap();
let volume = solid_signed_volume(&block).unwrap();
let plus_x = resolve_face_by_point(&block, Vec3::new(1.0, 1.0, 1.5)).unwrap();
let moved = move_faces(&block, &[plus_x], Vec3::new(0.5, 0.0, 0.0)).unwrap();
assert!(
moved.validate().is_empty(),
"moved solid must validate: {:?}",
moved.validate()
);
let moved_volume = solid_signed_volume(&moved).unwrap();
assert!(
(moved_volume - (volume + 0.5 * 2.0 * 3.0)).abs() < 1e-9,
"expected {} + 3, got {moved_volume}",
volume
);
assert_eq!(moved.vertices.len(), block.vertices.len());
assert_eq!(moved.edges.len(), block.edges.len());
assert_eq!(moved.shells[0].faces.len(), block.shells[0].faces.len());
assert!(block.validate().is_empty());
assert!((solid_signed_volume(&block).unwrap() - volume).abs() < 1e-12);
}
#[test]
fn moving_a_box_face_inward_shrinks_volume_exactly() {
let block = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 1.0, 2.0, 3.0).unwrap();
let volume = solid_signed_volume(&block).unwrap();
let plus_x = resolve_face_by_point(&block, Vec3::new(1.0, 1.0, 1.5)).unwrap();
let moved = move_faces(&block, &[plus_x], Vec3::new(-0.25, 0.0, 0.0)).unwrap();
assert!(moved.validate().is_empty());
let moved_volume = solid_signed_volume(&moved).unwrap();
assert!(
(moved_volume - (volume - 0.25 * 2.0 * 3.0)).abs() < 1e-9,
"expected {} - 1.5, got {moved_volume}",
volume
);
}
#[test]
fn moving_a_two_face_group_diagonally_yields_the_analytic_prism() {
let cube = unit_cube();
let plus_x = resolve_face_by_point(&cube, Vec3::new(1.0, 0.5, 0.5)).unwrap();
let plus_y = resolve_face_by_point(&cube, Vec3::new(0.5, 1.0, 0.5)).unwrap();
let moved = move_faces(&cube, &[plus_x, plus_y], Vec3::new(0.3, 0.4, 0.0)).unwrap();
assert!(
moved.validate().is_empty(),
"moved solid must validate: {:?}",
moved.validate()
);
let moved_volume = solid_signed_volume(&moved).unwrap();
assert!(
(moved_volume - 1.82).abs() < 1e-9,
"expected the analytic prism volume 1.82, got {moved_volume}"
);
assert!(moved
.vertices
.iter()
.any(|vertex| { vertex.point.sub(Vec3::new(1.3, 1.4, 1.0)).length() < 1e-9 }));
assert_eq!(moved.vertices.len(), 8);
assert_eq!(moved.edges.len(), 12);
assert_eq!(moved.shells[0].faces.len(), 6);
}
#[test]
fn moving_a_cylinder_cap_extends_the_wall() {
let cylinder =
make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 1.0, 2.0).unwrap();
let v0 = solid_signed_volume(&cylinder).unwrap();
let cap = resolve_face_by_point(&cylinder, Vec3::new(0.0, 0.0, 2.0)).unwrap();
let moved = move_faces(&cylinder, &[cap], Vec3::new(0.0, 0.0, 0.5))
.expect("cap push along the axis extends the wall");
assert!(
moved.validate().is_empty(),
"moved solid must validate: {:?}",
moved.validate()
);
let delta = solid_signed_volume(&moved).unwrap() - v0;
let expected = std::f64::consts::PI * 0.5; assert!(
(delta - expected).abs() < 1e-3,
"cap push volume delta {delta}, expected {expected}"
);
assert_eq!(moved.shells[0].faces.len(), cylinder.shells[0].faces.len());
assert!(cylinder.validate().is_empty());
}
#[test]
fn unknown_ids_and_collapsing_translations_are_refused_without_panic() {
let cube = unit_cube();
let step = Vec3::new(0.1, 0.0, 0.0);
let unknown = move_faces(&cube, &[424_242], step).unwrap_err();
assert!(unknown.contains("no face with id"), "got: {unknown}");
let empty = move_faces(&cube, &[], step).unwrap_err();
assert!(empty.contains("no faces selected"), "got: {empty}");
let plus_x = resolve_face_by_point(&cube, Vec3::new(1.0, 0.5, 0.5)).unwrap();
let collapse = move_faces(&cube, &[plus_x], Vec3::new(-1.0, 0.0, 0.0)).unwrap_err();
assert!(collapse.contains("collapses"), "got: {collapse}");
let invert = move_faces(&cube, &[plus_x], Vec3::new(-1.5, 0.0, 0.0)).unwrap_err();
assert!(invert.contains("inverts"), "got: {invert}");
assert!(cube.validate().is_empty());
assert!((solid_signed_volume(&cube).unwrap() - 1.0).abs() < 1e-12);
}
fn subtract_solid(body: BrepSolid, cutter: BrepSolid) -> Result<BrepSolid, String> {
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
crate::boolean_operation(&body, &cutter, crate::BooleanOperation::Subtract, &options)
}
fn plate_20x20x4() -> BrepSolid {
make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 4.0).unwrap()
}
fn max_loops(solid: &BrepSolid) -> usize {
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.map(|face| face.loops.len())
.max()
.unwrap_or(0)
}
#[test]
fn move_faces_pushes_planar_multiloop_top() {
let rect_cutter = make_box_brep(Vec3::new(7.0, 7.0, -1.0), 6.0, 6.0, 6.0).unwrap();
let holed = subtract_solid(plate_20x20x4(), rect_cutter).expect("rect through-hole");
assert_eq!(max_loops(&holed), 2, "top/bottom carry the pocket as a 2nd loop");
let top = resolve_face_by_point(&holed, Vec3::new(2.0, 2.0, 4.0)).expect("top face");
let pushed = move_faces(&holed, &[top], Vec3::new(0.0, 0.0, 3.0)).expect("planar multi-loop");
assert!(
(solid_signed_volume(&pushed).unwrap() - 2548.0).abs() < 1e-6,
"volume {}",
solid_signed_volume(&pushed).unwrap()
);
assert_eq!(max_loops(&pushed), 2, "the pocket loop survives the push");
assert!(pushed.validate().is_empty());
}
#[test]
fn move_faces_pushes_drilled_hole_cap_up_and_down() {
let hole_area = std::f64::consts::PI * 9.0;
let base_area = 400.0 - hole_area;
for distance in [3.0_f64, -1.5] {
let cyl_cutter =
make_cylinder_brep(Vec3::new(10.0, 10.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 6.0)
.unwrap();
let drilled = subtract_solid(plate_20x20x4(), cyl_cutter).expect("drilled hole");
let v_before = solid_signed_volume(&drilled).unwrap();
let top = resolve_face_by_point(&drilled, Vec3::new(2.0, 2.0, 4.0)).expect("top face");
let pushed = move_faces(&drilled, &[top], Vec3::new(0.0, 0.0, distance))
.unwrap_or_else(|e| panic!("push cap by {distance}: {e}"));
assert!(pushed.validate().is_empty(), "validate after push {distance}");
let delta = solid_signed_volume(&pushed).unwrap() - v_before;
assert!(
(delta - base_area * distance).abs() < 1e-3,
"push {distance}: volume delta {delta}, expected {}",
base_area * distance
);
assert!(max_loops(&pushed) >= 2, "the hole loop survives");
}
}
#[test]
fn move_faces_pushes_frustum_top_cap() {
let big_r = 3.0_f64; let top_r0 = 1.5_f64; let h0 = 4.0_f64;
let frustum_vol = |r_top: f64, h: f64| {
std::f64::consts::PI * h / 3.0 * (big_r * big_r + big_r * r_top + r_top * r_top)
};
for d in [1.0_f64, -1.5] {
let frustum =
crate::make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), big_r, top_r0, h0)
.unwrap();
let cap = resolve_face_by_point(&frustum, Vec3::new(0.0, 0.0, h0)).expect("top cap");
let pushed = move_faces(&frustum, &[cap], Vec3::new(0.0, 0.0, d))
.unwrap_or_else(|e| panic!("push frustum cap by {d}: {e}"));
assert!(
pushed.validate().is_empty(),
"validate after push {d}: {:?}",
pushed.validate()
);
let r_top_new = big_r + (top_r0 - big_r) * (h0 + d) / h0;
let expected = frustum_vol(r_top_new, h0 + d);
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!(
(got - expected).abs() < 1e-3,
"push {d}: volume {got}, expected {expected}"
);
}
}
#[test]
fn move_faces_pushes_frustum_bottom_cap() {
let big_r = 3.0_f64; let top_r = 1.5_f64; let h0 = 4.0_f64;
let frustum =
crate::make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), big_r, top_r, h0).unwrap();
let bottom = resolve_face_by_point(&frustum, Vec3::new(0.0, 0.0, 0.0)).expect("bottom cap");
let d = 1.0_f64; let pushed = move_faces(&frustum, &[bottom], Vec3::new(0.0, 0.0, -d))
.unwrap_or_else(|e| panic!("push frustum bottom cap: {e}"));
assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
let r_bottom_new = big_r + (top_r - big_r) * (-d) / h0;
let new_h = h0 + d;
let expected = std::f64::consts::PI * new_h / 3.0
* (r_bottom_new * r_bottom_new + r_bottom_new * top_r + top_r * top_r);
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!(
(got - expected).abs() < 1e-3,
"bottom-cap push volume {got}, expected {expected}"
);
}
#[test]
fn move_faces_frustum_apex_push_refuses() {
let frustum =
crate::make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 1.5, 4.0).unwrap();
let cap = resolve_face_by_point(&frustum, Vec3::new(0.0, 0.0, 4.0)).expect("top cap");
let err = move_faces(&frustum, &[cap], Vec3::new(0.0, 0.0, 4.5))
.expect_err("a cap push past the apex must refuse");
assert!(err.contains("apex"), "unexpected refusal: {err}");
}
#[test]
fn move_faces_pushes_conical_hole_cap() {
let cone = crate::make_cone_brep(
Vec3::new(10.0, 10.0, -1.0),
Vec3::new(0.0, 0.0, 1.0),
2.0,
4.0,
6.0,
)
.expect("frustum cutter");
let drilled = subtract_solid(plate_20x20x4(), cone).expect("conical hole");
let v0 = solid_signed_volume(&drilled).unwrap();
let top = resolve_face_by_point(&drilled, Vec3::new(2.0, 2.0, 4.0)).expect("top face");
let pushed = move_faces(&drilled, &[top], Vec3::new(0.0, 0.0, 3.0))
.expect("countersink cap push now heals");
assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
assert!(
solid_signed_volume(&pushed).unwrap() > v0,
"pushing the top out grows the plate volume"
);
assert!(max_loops(&pushed) >= 2, "the conical hole loop survives");
}
#[test]
fn transform_curve_scales_a_rational_circle_exactly() {
let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 2.0).unwrap();
let rim = cyl
.edges
.iter()
.find(|e| e.curve.degree == 2)
.expect("a rational-quadratic circle rim")
.clone();
let scale2 = AffineTransform::new([
2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
])
.unwrap();
let scaled = transform_curve(&rim.curve, scale2).unwrap();
for step in 0..=16 {
let t = rim.t0 + (rim.t1 - rim.t0) * (step as f64 / 16.0);
let p = scaled.evaluate(t).unwrap();
let r = (p.x * p.x + p.y * p.y).sqrt();
assert!((r - 10.0).abs() < 1e-9, "scaled radius {r}, expected 10");
}
}
#[test]
fn move_faces_translates_curved_and_holed_carriers() {
let cyl = make_cylinder_brep(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0)
.unwrap();
let v_cyl = solid_signed_volume(&cyl).unwrap();
let side = resolve_face_by_point(&cyl, Vec3::new(5.0, 0.0, 5.0)).expect("side");
let moved = move_faces(&cyl, &[side], Vec3::new(1.0, 0.0, 0.0)).expect("translate cylinder");
assert!(
(solid_signed_volume(&moved).unwrap() - v_cyl).abs() < 1e-6,
"translating a cylinder side preserves volume"
);
let cyl_cutter =
make_cylinder_brep(Vec3::new(10.0, 10.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 6.0)
.unwrap();
let drilled = subtract_solid(plate_20x20x4(), cyl_cutter).expect("drilled hole");
let v_drilled = solid_signed_volume(&drilled).unwrap();
let wall = resolve_face_by_point(&drilled, Vec3::new(13.0, 10.0, 2.0)).expect("hole wall");
let relocated = move_faces(&drilled, &[wall], Vec3::new(2.0, 0.0, 0.0)).expect("move hole");
assert!(
(solid_signed_volume(&relocated).unwrap() - v_drilled).abs() < 1e-6,
"relocating a drilled hole preserves volume"
);
assert!(relocated.validate().is_empty());
}
fn rot_y(angle: f64) -> AffineTransform {
let (s, c) = angle.sin_cos();
AffineTransform::new([
c, 0.0, s, 0.0, 0.0, 1.0, 0.0, 0.0, -s, 0.0, c, 0.0, 0.0, 0.0, 0.0, 1.0,
])
.unwrap()
}
fn translate_af(t: Vec3) -> AffineTransform {
AffineTransform::new([
1.0, 0.0, 0.0, t.x, 0.0, 1.0, 0.0, t.y, 0.0, 0.0, 1.0, t.z, 0.0, 0.0, 0.0, 1.0,
])
.unwrap()
}
fn compose(a: AffineTransform, b: AffineTransform) -> AffineTransform {
let m = a.elements;
let n = b.elements;
let mut r = [0.0f64; 16];
for i in 0..4 {
for j in 0..4 {
for k in 0..4 {
r[i * 4 + j] += m[i * 4 + k] * n[k * 4 + j];
}
}
}
AffineTransform::new(r).unwrap()
}
fn flatted_oblique_capped(
make_wall: impl Fn() -> BrepSolid,
flat: f64,
alpha: f64,
pivot_z: f64,
) -> (BrepSolid, u64) {
let slab_px = make_box_brep(Vec3::new(flat, -10.0, -1.0), 10.0, 20.0, 40.0).unwrap();
let slab_nx = make_box_brep(Vec3::new(-flat - 10.0, -10.0, -1.0), 10.0, 20.0, 40.0).unwrap();
let flatted = subtract_solid(subtract_solid(make_wall(), slab_px).unwrap(), slab_nx).unwrap();
let raw = make_box_brep(Vec3::new(-15.0, -15.0, 0.0), 30.0, 30.0, 25.0).unwrap();
let xf = compose(translate_af(Vec3::new(0.0, 0.0, pivot_z)), rot_y(alpha));
let cutter = crate::transform_brep(&raw, xf, false).unwrap();
let solid = subtract_solid(flatted, cutter).unwrap();
let cap = resolve_face_by_point(&solid, Vec3::new(0.0, 0.0, pivot_z)).expect("oblique cap");
(solid, cap)
}
fn oblique_capped(make_wall: impl Fn() -> BrepSolid, alpha: f64, pivot_z: f64) -> (BrepSolid, u64) {
let raw = make_box_brep(Vec3::new(-15.0, -15.0, 0.0), 30.0, 30.0, 25.0).unwrap();
let xf = compose(translate_af(Vec3::new(0.0, 0.0, pivot_z)), rot_y(alpha));
let cutter = crate::transform_brep(&raw, xf, false).unwrap();
let solid = subtract_solid(make_wall(), cutter).unwrap();
let cap = resolve_face_by_point(&solid, Vec3::new(0.0, 0.0, pivot_z)).expect("oblique cap");
(solid, cap)
}
fn flatted_frustum_volume(
r0: f64,
r1: f64,
height: f64,
flat: f64,
alpha: f64,
pivot: f64,
d: f64,
) -> f64 {
let slope = (r1 - r0) / height; assert!(slope < 0.0, "oracle assumes a shrinking frustum");
let antiderivative = |u: f64, a: f64| -> f64 {
let root = (u * u - a * a).max(0.0).sqrt();
0.5 * u * root - 0.5 * a * a * (u + root).ln()
};
const N: usize = 4000;
let mut total = 0.0;
for step in 0..=N {
let x = -flat + 2.0 * flat * (step as f64 / N as f64);
let z_cap =
(pivot + d / alpha.cos() - x * alpha.tan()).clamp(0.0, height);
let a = x.abs();
let u_low = (r0 + slope * z_cap).max(a); let u_high = r0.max(a); let inner =
(2.0 / slope.abs()) * (antiderivative(u_high, a) - antiderivative(u_low, a));
let weight = if step == 0 || step == N {
1.0
} else if step % 2 == 1 {
4.0
} else {
2.0
};
total += weight * inner;
}
total * (2.0 * flat / N as f64) / 3.0
}
fn ruled_neighbour_count(solid: &BrepSolid, face_id: u64) -> usize {
let mut foe: std::collections::HashMap<u64, Vec<u64>> = Default::default();
for f in &solid.shells[0].faces {
for lp in &f.loops {
for ce in &lp.coedges {
foe.entry(ce.edge_id).or_default().push(f.id);
}
}
}
let face = solid.shells[0].faces.iter().find(|f| f.id == face_id).unwrap();
let mut ruled: std::collections::HashSet<u64> = Default::default();
for lp in &face.loops {
for ce in &lp.coedges {
for &o in foe.get(&ce.edge_id).into_iter().flatten() {
if o == face_id {
continue;
}
let nf = solid.shells[0].faces.iter().find(|f| f.id == o).unwrap();
if matches!(
nf.surface.analytic(),
Some(AnalyticSurface::RuledRevolution { .. })
| Some(AnalyticSurface::Revolution { .. })
) {
ruled.insert(o);
}
}
}
}
ruled.len()
}
#[test]
fn pushing_an_oblique_multi_rim_cap_on_a_flatted_cylinder() {
let alpha = 30.0_f64.to_radians();
let (r, flat) = (3.0_f64, 2.0_f64);
let (solid, cap) = flatted_oblique_capped(
|| make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), r, 10.0).unwrap(),
flat,
alpha,
8.0,
);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
assert!(
ruled_neighbour_count(&solid, cap) >= 2,
"cap must border multiple ruled bands"
);
let conic_rims = solid.shells[0]
.faces
.iter()
.find(|f| f.id == cap)
.unwrap()
.loops
.iter()
.flat_map(|lp| &lp.coedges)
.filter(|ce| {
solid
.edges
.iter()
.find(|e| e.id == ce.edge_id)
.map(|e| e.curve.degree == 2)
.unwrap_or(false)
})
.count();
assert!(conic_rims >= 2, "cap must have multiple conic rims, got {conic_rims}");
let seg = r * r * (flat / r).acos() - flat * (r * r - flat * flat).sqrt();
let a_xsec = std::f64::consts::PI * r * r - 2.0 * seg;
let n = Vec3::new(alpha.sin(), 0.0, alpha.cos());
let v0 = solid_signed_volume(&solid).unwrap();
for d in [0.6_f64, -0.6] {
let pushed = move_faces(&solid, &[cap], n.scale(d))
.unwrap_or_else(|e| panic!("oblique multi-rim push d={d}: {e}"));
assert!(
pushed.validate().is_empty(),
"pushed solid must validate (d={d}): {:?}",
pushed.validate()
);
let h = d / alpha.cos();
let expected = v0 + a_xsec * h;
let got = solid_signed_volume(&pushed).unwrap();
assert!(
(got - expected).abs() < 1e-2,
"d={d}: volume {got}, expected {expected} (prism slab A·h)"
);
assert_eq!(pushed.vertices.len(), solid.vertices.len());
assert_eq!(pushed.edges.len(), solid.edges.len());
assert_eq!(pushed.shells[0].faces.len(), solid.shells[0].faces.len());
assert!(v0 * got > 0.0, "signed volume must keep its sign (d={d})");
}
assert!(solid.validate().is_empty());
assert!((solid_signed_volume(&solid).unwrap() - v0).abs() < 1e-12);
}
#[test]
fn pushing_an_oblique_multi_rim_cap_on_a_cone_frustum() {
let alpha = 22.0_f64.to_radians();
let (flat, r0, r1, height, pivot) = (2.0_f64, 4.0_f64, 2.0_f64, 10.0_f64, 8.0_f64);
let (solid, cap) = flatted_oblique_capped(
|| crate::make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), r0, r1, height).unwrap(),
flat,
alpha,
pivot,
);
assert!(solid.validate().is_empty(), "cone fixture: {:?}", solid.validate());
assert!(
ruled_neighbour_count(&solid, cap) >= 2,
"cone cap must border multiple ruled bands"
);
let cap_face = solid.shells[0].faces.iter().find(|f| f.id == cap).unwrap();
let conic_rims = cap_face
.loops
.iter()
.flat_map(|lp| &lp.coedges)
.filter(|ce| {
solid
.edges
.iter()
.find(|e| e.id == ce.edge_id)
.map(|e| e.curve.degree == 2)
.unwrap_or(false)
})
.count();
assert!(conic_rims >= 2, "cone cap must have multiple conic rims, got {conic_rims}");
let curved_fixed = solid
.edges
.iter()
.filter(|e| e.curve.degree >= 2 && e.curve.evaluate(e.t0).unwrap().z.abs() < 1e-9)
.count();
assert!(curved_fixed >= 2, "the flats must meet the cone on CURVED (hyperbolic) fixed edges");
let v0 = solid_signed_volume(&solid).unwrap();
let oracle0 = flatted_frustum_volume(r0, r1, height, flat, alpha, pivot, 0.0);
assert!(
(v0.abs() - oracle0).abs() < 1e-3,
"fixture volume {} vs oracle {oracle0}",
v0.abs()
);
let n = Vec3::new(alpha.sin(), 0.0, alpha.cos());
let mut volumes = Vec::new();
for d in [0.5_f64, -0.5] {
let pushed = move_faces(&solid, &[cap], n.scale(d))
.unwrap_or_else(|e| panic!("oblique multi-rim cone push d={d}: {e}"));
assert!(
pushed.validate().is_empty(),
"pushed cone solid must validate (d={d}): {:?}",
pushed.validate()
);
let got = solid_signed_volume(&pushed).unwrap();
let expected = flatted_frustum_volume(r0, r1, height, flat, alpha, pivot, d);
assert!(
(got.abs() - expected).abs() < 2e-3,
"d={d}: volume {}, expected {expected} (quadrature oracle)",
got.abs()
);
assert_eq!(pushed.vertices.len(), solid.vertices.len());
assert_eq!(pushed.edges.len(), solid.edges.len());
assert_eq!(pushed.shells[0].faces.len(), solid.shells[0].faces.len());
assert!(v0 * got > 0.0, "signed volume must keep its sign (d={d})");
let cap_c = n.dot(Vec3::new(0.0, 0.0, pivot)) + d;
for edge in &pushed.edges {
if edge.curve.degree < 2 {
continue;
}
for step in 0..=8 {
let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 8.0);
let p = edge.curve.evaluate(t).unwrap();
let radial = (p.x * p.x + p.y * p.y).sqrt();
let off = (radial - (r0 + (r1 - r0) * p.z / height)).abs();
assert!(off < 1e-9, "d={d}: curved edge {} is {off:.3e} off the cone", edge.id);
}
}
let pushed_cap = pushed.shells[0].faces.iter().find(|f| f.id == cap).unwrap();
for coedge in pushed_cap.loops.iter().flat_map(|lp| &lp.coedges) {
let edge = pushed.edges.iter().find(|e| e.id == coedge.edge_id).unwrap();
for step in 0..=8 {
let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 8.0);
let off = (n.dot(edge.curve.evaluate(t).unwrap()) - cap_c).abs();
assert!(off < 1e-9, "d={d}: cap edge {} is {off:.3e} off the pushed plane", edge.id);
}
}
volumes.push(got.abs());
}
assert!(
volumes[0] > v0.abs() && v0.abs() > volumes[1],
"pushing out must grow and pushing in must shrink: {volumes:?} around {}",
v0.abs()
);
let out = move_faces(&solid, &[cap], n.scale(0.5)).unwrap();
let back = move_faces(&out, &[cap], n.scale(-0.5)).expect("counter-push heals");
assert!(back.validate().is_empty(), "round trip validates: {:?}", back.validate());
assert!(
(solid_signed_volume(&back).unwrap().abs() - v0.abs()).abs() < 1e-2,
"round trip returns to the original volume"
);
assert!(solid.validate().is_empty());
assert!((solid_signed_volume(&solid).unwrap() - v0).abs() < 1e-12);
}
#[test]
fn oblique_multi_rim_cone_cap_tearing_push_refuses() {
let alpha = 22.0_f64.to_radians();
let (solid, cap) = flatted_oblique_capped(
|| crate::make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 2.0, 10.0).unwrap(),
2.0,
alpha,
8.0,
);
assert!(solid.validate().is_empty(), "cone fixture: {:?}", solid.validate());
let n = Vec3::new(alpha.sin(), 0.0, alpha.cos());
let err = move_faces(&solid, &[cap], n.scale(-9.0))
.expect_err("a cone cap pushed through the base must refuse");
assert!(
err.contains("collapse")
|| err.contains("invert")
|| err.contains("tear")
|| err.contains("refusing")
|| err.contains("validation"),
"unexpected refusal: {err}"
);
assert!(solid.validate().is_empty());
}
#[test]
fn pushing_an_oblique_single_rim_cap_on_a_cylinder() {
let alpha = 25.0_f64.to_radians();
let (r, pivot) = (3.0_f64, 7.0_f64);
let (solid, cap) = oblique_capped(
|| make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), r, 10.0).unwrap(),
alpha,
pivot,
);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
assert_eq!(
ruled_neighbour_count(&solid, cap),
1,
"the single-rim cap must border exactly one ruled wall"
);
let closed_conic_rims = solid.shells[0]
.faces
.iter()
.find(|f| f.id == cap)
.unwrap()
.loops
.iter()
.flat_map(|lp| &lp.coedges)
.filter(|ce| {
solid
.edges
.iter()
.find(|e| e.id == ce.edge_id)
.map(|e| e.curve.degree == 2 && e.start_vertex_id == e.end_vertex_id)
.unwrap_or(false)
})
.count();
assert_eq!(closed_conic_rims, 1, "the cap's rim must be a single CLOSED conic");
let n = Vec3::new(alpha.sin(), 0.0, alpha.cos());
let v0 = solid_signed_volume(&solid).unwrap();
let expected0 = std::f64::consts::PI * r * r * pivot;
assert!(
(v0.abs() - expected0).abs() < 1e-3,
"fixture volume {} vs pi*r^2*z_c {expected0}",
v0.abs()
);
for d in [0.6_f64, -0.6] {
let pushed = move_faces(&solid, &[cap], n.scale(d))
.unwrap_or_else(|e| panic!("oblique single-rim push d={d}: {e}"));
assert!(
pushed.validate().is_empty(),
"pushed solid must validate (d={d}): {:?}",
pushed.validate()
);
let expected = std::f64::consts::PI * r * r * (pivot + d / alpha.cos());
let got = solid_signed_volume(&pushed).unwrap();
assert!(
(got.abs() - expected).abs() < 1e-3,
"d={d}: volume {}, expected {expected}",
got.abs()
);
assert_eq!(pushed.vertices.len(), solid.vertices.len());
assert_eq!(pushed.edges.len(), solid.edges.len());
assert_eq!(pushed.shells[0].faces.len(), solid.shells[0].faces.len());
assert!(v0 * got > 0.0, "signed volume must keep its sign (d={d})");
}
assert!(solid.validate().is_empty());
assert!((solid_signed_volume(&solid).unwrap() - v0).abs() < 1e-12);
}
#[test]
fn oblique_multi_rim_cap_tearing_push_refuses() {
let alpha = 30.0_f64.to_radians();
let (solid, cap) = flatted_oblique_capped(
|| make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap(),
2.0,
alpha,
8.0,
);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let n = Vec3::new(alpha.sin(), 0.0, alpha.cos());
let err = move_faces(&solid, &[cap], n.scale(-9.0))
.expect_err("a cap pushed through the base must refuse");
assert!(
err.contains("collapse")
|| err.contains("invert")
|| err.contains("tear")
|| err.contains("refusing")
|| err.contains("validation"),
"unexpected refusal: {err}"
);
assert!(solid.validate().is_empty());
}
fn z_rim_edge(solid: &BrepSolid, h: f64) -> u64 {
solid
.edges
.iter()
.find(|edge| {
edge.start_vertex_id == edge.end_vertex_id
&& edge
.curve
.evaluate(edge.t0)
.map(|point| (point.z - h).abs() < 1e-9)
.unwrap_or(false)
})
.expect("cylinder has a closed rim edge at that height")
.id
}
#[test]
fn pushing_a_cap_extends_a_fillet_band_neighbour() {
let block = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 4.0, 3.0, 4.0).unwrap();
let edge = edge_id_near(&block, Vec3::new(4.0, 1.5, 4.0));
let filleted = fillet_edge(&block, edge, 1.0, Some("F")).unwrap();
assert!(
filleted.validate().is_empty(),
"fillet fixture: {:?}",
filleted.validate()
);
assert!(
filleted.shells[0].faces.iter().any(|face| matches!(
face.surface.analytic(),
Some(AnalyticSurface::Revolution { .. })
)),
"fillet band must be a partial-sweep Revolution"
);
let length = 3.0_f64;
let far_cap = resolve_face_by_point(&filleted, Vec3::new(2.0, length, 2.0)).unwrap();
let v0 = solid_signed_volume(&filleted).unwrap();
for d in [0.75_f64, -0.75] {
let pushed = move_faces(&filleted, &[far_cap], Vec3::new(0.0, d, 0.0))
.unwrap_or_else(|e| panic!("push fillet cap by {d}: {e}"));
assert!(
pushed.validate().is_empty(),
"pushed solid must validate (d={d}): {:?}",
pushed.validate()
);
let v = solid_signed_volume(&pushed).unwrap();
assert!(
(v - v0 * (length + d) / length).abs() < 1e-3,
"volume {v}, expected {} (d={d})",
v0 * (length + d) / length
);
assert_eq!(pushed.vertices.len(), filleted.vertices.len());
assert_eq!(pushed.edges.len(), filleted.edges.len());
assert_eq!(pushed.shells[0].faces.len(), filleted.shells[0].faces.len());
assert!(v0 * v > 0.0, "signed volume must keep its sign (d={d})");
}
assert!(filleted.validate().is_empty());
}
#[test]
fn pushing_a_cap_against_a_toroidal_fillet_refuses() {
let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 6.0).unwrap();
let rim = z_rim_edge(&cyl, 6.0);
let filleted = fillet_edge(&cyl, rim, 0.5, Some("T")).unwrap();
assert!(
filleted.validate().is_empty(),
"toroidal fillet fixture: {:?}",
filleted.validate()
);
let top_cap = resolve_face_by_point(&filleted, Vec3::new(0.0, 0.0, 6.0)).unwrap();
let result = move_faces(&filleted, &[top_cap], Vec3::new(0.0, 0.0, 0.5));
assert!(
result.is_err(),
"a toroidal (curved-generatrix) neighbour must refuse, got a solid"
);
assert!(filleted.validate().is_empty());
}
fn upper_half_ball(r: f64) -> BrepSolid {
let sphere =
crate::make_sphere_brep(Vec3::new(0.0, 0.0, 0.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
let box_up = make_box_brep(Vec3::new(-2.0 * r, -2.0 * r, 0.0), 4.0 * r, 4.0 * r, 2.0 * r).unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
crate::boolean_operation(&sphere, &box_up, crate::BooleanOperation::Intersect, &options).unwrap()
}
fn sphere_plane_rim_radius(solid: &BrepSolid, axis_point: Vec3, axis: Vec3) -> f64 {
use crate::AnalyticSurface;
let mut faces_of_edge: std::collections::HashMap<u64, Vec<u64>> = Default::default();
let mut kind: std::collections::HashMap<u64, &'static str> = Default::default();
for shell in &solid.shells {
for face in &shell.faces {
let k = match face.surface.analytic() {
Some(AnalyticSurface::Sphere { .. }) => "sphere",
Some(AnalyticSurface::Plane { .. }) => "plane",
_ => "other",
};
kind.insert(face.id, k);
for lp in &face.loops {
for ce in &lp.coedges {
faces_of_edge.entry(ce.edge_id).or_default().push(face.id);
}
}
}
}
for edge in &solid.edges {
if edge.degenerate {
continue;
}
let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
let has_sphere = incident.iter().any(|f| kind.get(f) == Some(&"sphere"));
let has_plane = incident.iter().any(|f| kind.get(f) == Some(&"plane"));
if has_sphere && has_plane {
let point = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).unwrap();
let delta = point.sub(axis_point);
return delta.sub(axis.scale(delta.dot(axis))).length();
}
}
panic!("no sphere×plane rim edge found");
}
#[test]
fn push_planar_face_reintersects_sphere_neighbour() {
let r = 5.0f64;
for d in [1.0_f64, -1.0] {
let half = upper_half_ball(r);
assert!(half.validate().is_empty(), "fixture: {:?}", half.validate());
let (nv, ne, nf) = (
half.vertices.len(),
half.edges.len(),
half.shells[0].faces.len(),
);
let v0 = solid_signed_volume(&half).unwrap();
let disk = resolve_face_by_point(&half, Vec3::new(0.0, 0.0, 0.0)).expect("flat disk face");
let pushed = move_faces(&half, &[disk], Vec3::new(0.0, 0.0, d))
.unwrap_or_else(|e| panic!("push disk by {d}: {e}"));
assert!(
pushed.validate().is_empty(),
"validate after push {d}: {:?}",
pushed.validate()
);
let expected = std::f64::consts::PI * (r - d) * (r - d) * (2.0 * r + d) / 3.0;
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!(
(got - expected).abs() < 1e-2,
"push {d}: cap volume {got}, expected {expected}"
);
let want_rim = (r * r - d * d).sqrt();
let got_rim =
sphere_plane_rim_radius(&pushed, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
assert!(
(got_rim - want_rim).abs() < 1e-6,
"push {d}: rim radius {got_rim}, expected {want_rim}"
);
assert_eq!(pushed.vertices.len(), nv, "vertex count (d={d})");
assert_eq!(pushed.edges.len(), ne, "edge count (d={d})");
assert_eq!(pushed.shells[0].faces.len(), nf, "face count (d={d})");
assert!(v0 * solid_signed_volume(&pushed).unwrap() > 0.0, "sign (d={d})");
assert!(half.validate().is_empty());
}
}
#[test]
fn push_planar_sphere_vanishing_cap_refuses() {
let r = 5.0f64;
let half = upper_half_ball(r);
let disk = resolve_face_by_point(&half, Vec3::new(0.0, 0.0, 0.0)).expect("flat disk face");
let err = move_faces(&half, &[disk], Vec3::new(0.0, 0.0, r)).expect_err("vanished cap");
assert!(
err.contains("vanish") || err.contains("tangent") || err.contains("refus"),
"unexpected refusal: {err}"
);
assert!(half.validate().is_empty());
}
#[test]
fn push_planar_sphere_oblique_neighbour_refuses() {
let r = 5.0f64;
let sphere =
crate::make_sphere_brep(Vec3::new(0.0, 0.0, 0.0), r, Vec3::new(1.0, 0.0, 0.0)).unwrap();
let box_up = make_box_brep(Vec3::new(-2.0 * r, -2.0 * r, 0.0), 4.0 * r, 4.0 * r, 2.0 * r).unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let half =
crate::boolean_operation(&sphere, &box_up, crate::BooleanOperation::Intersect, &options)
.unwrap();
assert!(half.validate().is_empty(), "fixture: {:?}", half.validate());
let disk = resolve_face_by_point(&half, Vec3::new(0.0, 0.0, 0.0)).expect("flat disk face");
let result = move_faces(&half, &[disk], Vec3::new(0.0, 0.0, 0.5));
assert!(
result.is_err(),
"an oblique plane × sphere rim must refuse, got a solid"
);
assert!(half.validate().is_empty());
}
#[test]
fn push_planar_against_torus_neighbour_refuses() {
let torus =
crate::make_torus_brep(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 6.0, 2.0).unwrap();
let box_up = make_box_brep(Vec3::new(-20.0, -20.0, 0.0), 40.0, 40.0, 20.0).unwrap();
let options = crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
};
let half_torus =
crate::boolean_operation(&torus, &box_up, crate::BooleanOperation::Intersect, &options)
.expect("torus ∩ box fixture must build — this test's only subject");
let issues = half_torus.validate();
assert!(issues.is_empty(), "half-torus fixture is invalid: {issues:?}");
use crate::AnalyticSurface;
let flat = half_torus
.shells
.iter()
.flat_map(|s| &s.faces)
.find(|f| matches!(f.surface.analytic(), Some(AnalyticSurface::Plane { .. })))
.map(|f| f.id)
.expect("the half torus must expose a planar cut face to push");
let result = move_faces(&half_torus, &[flat], Vec3::new(0.0, 0.0, 0.5));
assert!(
result.is_err(),
"a torus neighbour of a planar push must refuse, got a solid"
);
assert!(half_torus.validate().is_empty());
}
fn box_with_unrecognised_planar_side() -> (BrepSolid, u64, u64) {
let mut solid = make_box_brep(Vec3::new(-5.0, -5.0, 0.0), 10.0, 10.0, 6.0).unwrap();
let top = plane_face_with_normal(&solid, Vec3::new(0.0, 0.0, 1.0), 6.0);
let side = plane_face_with_normal(&solid, Vec3::new(1.0, 0.0, 0.0), 5.0);
for shell in &mut solid.shells {
for face in &mut shell.faces {
if face.id != side {
continue;
}
let surface = &face.surface;
assert_eq!(surface.degree_u, 1, "box faces are bilinear");
assert_eq!(surface.control_points.len(), 2);
let (u0, u1) = (surface.knots_u[0], *surface.knots_u.last().unwrap());
let row0 = surface.control_points[0].clone();
let row1 = surface.control_points[1].clone();
let middle: Vec<crate::Vec4> = row0
.iter()
.zip(row1.iter())
.map(|(a, b)| crate::Vec4 {
x: 0.5 * (a.x + b.x),
y: 0.5 * (a.y + b.y),
z: 0.5 * (a.z + b.z),
w: 0.5 * (a.w + b.w),
})
.collect();
let elevated = NurbsSurface::new(
2,
surface.degree_v,
vec![u0, u0, u0, u1, u1, u1],
surface.knots_v.clone(),
vec![row0, middle, row1],
)
.expect("degree-elevated planar patch");
for (u, v) in [(0.0, 0.0), (0.3, 0.7), (1.0, 1.0), (0.5, 0.5)] {
let [a0, a1] = surface.domain_u().unwrap();
let [b0, b1] = surface.domain_v().unwrap();
let (pu, pv) = (a0 + (a1 - a0) * u, b0 + (b1 - b0) * v);
let before = surface.evaluate(pu, pv).unwrap();
let after = elevated.evaluate(pu, pv).unwrap();
assert!(
after.sub(before).length() < 1e-12,
"degree elevation must preserve the parameterisation"
);
}
assert!(
elevated.analytic().is_none(),
"the elevated patch must NOT recognise as a plane — that is the point"
);
face.surface = elevated;
}
}
assert!(
solid.validate().is_empty(),
"the elevated box is still a valid solid: {:?}",
solid.validate()
);
(solid, top, side)
}
fn plane_face_with_normal(solid: &BrepSolid, normal: Vec3, offset: f64) -> u64 {
use crate::AnalyticSurface;
solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| match face.surface.analytic() {
Some(AnalyticSurface::Plane {
origin,
u_dir,
v_dir,
..
}) => {
let n = match u_dir.cross(*v_dir).normalized() {
Ok(n) => n,
Err(_) => return false,
};
n.cross(normal).length() < 1e-9 && (origin.dot(normal) - offset).abs() < 1e-7
}
_ => false,
})
.map(|face| face.id)
.unwrap_or_else(|| panic!("no planar face with normal {normal:?} at {offset}"))
}
fn merge_options() -> crate::BooleanOptions {
crate::BooleanOptions {
merge_coplanar_faces: true,
..crate::BooleanOptions::default()
}
}
fn boolean_of(a: &BrepSolid, b: &BrepSolid, op: crate::BooleanOperation) -> BrepSolid {
crate::boolean_operation(a, b, op, &merge_options()).expect("fixture boolean")
}
fn rot_x_af(angle: f64) -> AffineTransform {
let (s, c) = angle.sin_cos();
AffineTransform::new([
1.0, 0.0, 0.0, 0.0, 0.0, c, -s, 0.0, 0.0, s, c, 0.0, 0.0, 0.0, 0.0, 1.0,
])
.unwrap()
}
fn square_pyramid(half: f64) -> BrepSolid {
let big = 10.0 * half;
let quarter = std::f64::consts::FRAC_PI_4;
let mut solid = make_box_brep(
Vec3::new(-half, -half, 0.0),
2.0 * half,
2.0 * half,
2.0 * half,
)
.unwrap();
let px = make_box_brep(Vec3::new(0.0, -big, -big), 2.0 * big, 2.0 * big, 2.0 * big).unwrap();
let nx = make_box_brep(
Vec3::new(-2.0 * big, -big, -big),
2.0 * big,
2.0 * big,
2.0 * big,
)
.unwrap();
let py = make_box_brep(Vec3::new(-big, 0.0, -big), 2.0 * big, 2.0 * big, 2.0 * big).unwrap();
let ny = make_box_brep(
Vec3::new(-big, -2.0 * big, -big),
2.0 * big,
2.0 * big,
2.0 * big,
)
.unwrap();
for (raw, rot, shift) in [
(px, rot_y(-quarter), Vec3::new(half, 0.0, 0.0)),
(nx, rot_y(quarter), Vec3::new(-half, 0.0, 0.0)),
(py, rot_x_af(quarter), Vec3::new(0.0, half, 0.0)),
(ny, rot_x_af(-quarter), Vec3::new(0.0, -half, 0.0)),
] {
let cutter =
crate::transform_brep(&raw, compose(translate_af(shift), rot), false).unwrap();
solid = boolean_of(&solid, &cutter, crate::BooleanOperation::Subtract);
}
solid
}
fn pocketed_cylinder(r: f64, h: f64) -> (BrepSolid, u64) {
let solid = boolean_of(
&make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), r, h).unwrap(),
&make_box_brep(
Vec3::new(0.6 * r, -2.0 * r, 0.3 * h),
r,
4.0 * r,
0.4 * h,
)
.unwrap(),
crate::BooleanOperation::Subtract,
);
let floor = plane_face_with_normal(&solid, Vec3::new(1.0, 0.0, 0.0), 0.6 * r);
(solid, floor)
}
fn sphere_zone(r: f64, low: f64, high: f64) -> (BrepSolid, u64) {
let solid = boolean_of(
&crate::make_sphere_brep(Vec3::default(), r, Vec3::new(0.0, 0.0, 1.0)).unwrap(),
&make_box_brep(
Vec3::new(-2.0 * r, -2.0 * r, low),
4.0 * r,
4.0 * r,
high - low,
)
.unwrap(),
crate::BooleanOperation::Intersect,
);
let top = plane_face_with_normal(&solid, Vec3::new(0.0, 0.0, 1.0), high);
(solid, top)
}
#[test]
fn push_plane_across_a_torus_neighbour_names_the_face_and_the_carrier() {
let plate = make_box_brep(Vec3::new(-10.0, -10.0, 0.0), 20.0, 20.0, 6.0).unwrap();
let groove = crate::make_torus_brep(
Vec3::new(0.0, 0.0, 6.0),
Vec3::new(0.0, 0.0, 1.0),
5.0,
1.5,
)
.unwrap();
let solid = boolean_of(&plate, &groove, crate::BooleanOperation::Subtract);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let top = plane_face_with_normal(&solid, Vec3::new(0.0, 0.0, 1.0), 6.0);
for translation in [Vec3::new(0.0, 0.0, 0.5), Vec3::new(0.4, 0.0, 0.0)] {
let error = move_faces(&solid, &[top], translation)
.expect_err("a torus fixed neighbour must refuse");
assert!(
error.contains("the fixed neighbour across boundary edge"),
"the refusal must come from the edge-CLASSIFICATION carrier gate, \
not the face loop's SM3 arm: {error}"
);
assert!(
error.contains("is a torus"),
"the refusal must name the carrier kind: {error}"
);
assert!(
!error.contains("in this slice"),
"slice-scoped wording must not leak out of a shared helper: {error}"
);
}
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn push_plane_across_an_unrecognised_planar_neighbour_refuses_at_the_sm3_arm() {
let (solid, top, _side) = box_with_unrecognised_planar_side();
let error = move_faces(&solid, &[top], Vec3::new(0.0, 0.0, 0.5))
.expect_err("an analytically-unrecognised boundary neighbour must refuse");
assert!(
error.contains("borders a non-planar, non-axis-parallel"),
"expected the SM3-territory boundary-edge gate: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn an_unrecognised_planar_face_can_itself_be_pushed() {
let (solid, _top, side) = box_with_unrecognised_planar_side();
let before = solid_signed_volume(&solid).unwrap().abs();
let pushed = move_faces(&solid, &[side], Vec3::new(0.5, 0.0, 0.0))
.expect("an unrecognised PLANAR moved face pushes");
assert!(
pushed.validate().is_empty(),
"pushed solid is invalid: {:?}",
pushed.validate()
);
let after = solid_signed_volume(&pushed).unwrap().abs();
assert!((before - 600.0).abs() < 1e-9, "fixture volume {before}");
assert!((after - 630.0).abs() < 1e-9, "pushed volume {after}");
}
#[test]
fn pushing_a_cylinder_wall_along_its_axis_names_the_moved_carrier() {
use crate::AnalyticSurface;
let solid = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
let wall = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| {
matches!(
face.surface.analytic(),
Some(AnalyticSurface::RuledRevolution { .. })
)
})
.map(|face| face.id)
.expect("a cylinder wall");
let error = move_faces(&solid, &[wall], Vec3::new(0.0, 0.0, 0.5))
.expect_err("a curved moved carrier at a re-solved corner must refuse");
assert!(
error.contains("a MOVED carrier meeting a fixed neighbour at vertex"),
"expected the generic corner solve's moved-carrier gate: {error}"
);
assert!(error.contains("is a cylinder"), "name the carrier: {error}");
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_a_pocket_floor_parallel_to_the_cylinder_axis_refuses() {
let (solid, floor) = pocketed_cylinder(4.0, 12.0);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
for d in [0.3_f64, -0.3] {
let error = move_faces(&solid, &[floor], Vec3::new(d, 0.0, 0.0))
.expect_err("an axis-parallel cap plane has no rim map");
assert!(
error.contains("the cap plane is parallel to the cylinder axis"),
"expected the rim-map cylinder branch's degenerate gate: {error}"
);
}
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_a_pocket_floor_off_its_ruled_neighbour_refuses() {
let (solid, floor) = pocketed_cylinder(4.0, 12.0);
let error = move_faces(&solid, &[floor], Vec3::new(3.0, 0.0, 0.0))
.expect_err("a corner driven off the cylinder must refuse");
assert!(
error.contains("no longer meets the fixed ruled neighbour"),
"expected the carrier-level corner solve's refusal: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn rebuilding_a_curved_section_on_a_cylinder_carrier_refuses() {
let solid = boolean_of(
&make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap(),
&make_box_brep(Vec3::new(1.5, -6.0, -1.0), 8.0, 12.0, 12.0).unwrap(),
crate::BooleanOperation::Subtract,
);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let flat = plane_face_with_normal(&solid, Vec3::new(1.0, 0.0, 0.0), 1.5);
let error = move_faces(&solid, &[flat], Vec3::new(0.4, 0.0, 0.0))
.expect_err("an elliptical section on a cylinder is deferred");
assert!(
error.contains("curved section on a CYLINDER carrier"),
"expected the cylinder branch of the conic rebuild: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_one_face_of_a_pyramid_apex_tears_and_refuses() {
let solid = square_pyramid(6.0);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
assert_eq!(
solid.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
5,
"a square pyramid: four slopes and a base"
);
use crate::AnalyticSurface;
let slope = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| match face.surface.analytic() {
Some(AnalyticSurface::Plane { u_dir, v_dir, .. }) => {
let n = u_dir.cross(*v_dir).normalized().unwrap_or_default();
(n.x.abs() - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6
&& (n.z.abs() - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-6
}
_ => false,
})
.map(|face| face.id)
.expect("a sloping pyramid face");
let error = move_faces(&solid, &[slope], Vec3::new(0.3, 0.0, 0.3))
.expect_err("breaking a four-plane corner must refuse");
assert!(
error.contains("tears away from its neighbours"),
"expected the over-constrained corner residual gate: {error}"
);
let base = plane_face_with_normal(&solid, Vec3::new(0.0, 0.0, 1.0), 0.0);
let pushed = move_faces(&solid, &[base], Vec3::new(0.0, 0.0, 2.0))
.expect("pushing the base of a pyramid is a plain frustum trim");
assert!(pushed.validate().is_empty(), "{:?}", pushed.validate());
let expected = 4.0 * 4.0f64.powi(3) / 3.0;
let got = solid_signed_volume(&pushed).unwrap().abs();
assert!(
(got - expected).abs() < 1e-9,
"pyramid frustum volume {got} vs {expected}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_a_group_against_a_sphere_neighbour_refuses() {
let (solid, top) = sphere_zone(5.0, -2.0, 2.0);
let bottom = plane_face_with_normal(&solid, Vec3::new(0.0, 0.0, 1.0), -2.0);
let error = move_faces(&solid, &[top, bottom], Vec3::new(0.0, 0.0, 0.4))
.expect_err("a moved group against a sphere is deferred");
assert!(
error.contains("a moved GROUP against a sphere neighbour"),
"expected the sphere lane's single-face restriction: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_a_plate_with_a_spherical_dimple_refuses_on_the_mixed_neighbour() {
let plate = make_box_brep(Vec3::new(-10.0, -10.0, 0.0), 20.0, 20.0, 6.0).unwrap();
let ball = crate::make_sphere_brep(
Vec3::new(0.0, 0.0, 6.0),
4.0,
Vec3::new(0.0, 0.0, 1.0),
)
.unwrap();
let solid = boolean_of(&plate, &ball, crate::BooleanOperation::Subtract);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
let top = plane_face_with_normal(&solid, Vec3::new(0.0, 0.0, 1.0), 6.0);
let error = move_faces(&solid, &[top], Vec3::new(0.0, 0.0, 0.3))
.expect_err("mixed sphere + planar neighbours are deferred");
assert!(
error.contains("borders a non-sphere fixed"),
"expected the sphere lane's neighbour-class gate: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_an_obliquely_capped_ball_refuses_as_an_oblique_rim() {
let sphere =
crate::make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
let raw = make_box_brep(Vec3::new(-15.0, -15.0, -30.0), 30.0, 30.0, 30.0).unwrap();
let tilt = compose(
translate_af(Vec3::new(0.0, 0.0, 2.0)),
rot_y(45.0_f64.to_radians()),
);
let cutter = crate::transform_brep(&raw, tilt, false).unwrap();
let solid = boolean_of(&sphere, &cutter, crate::BooleanOperation::Intersect);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
use crate::AnalyticSurface;
let disk = solid
.shells
.iter()
.flat_map(|shell| &shell.faces)
.find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. })))
.map(|face| face.id)
.expect("the tilted cap");
let error = move_faces(&solid, &[disk], Vec3::new(0.0, 0.0, 0.4))
.expect_err("an oblique plane × sphere rim is deferred");
assert!(
error.contains("an OBLIQUE plane × sphere rim"),
"expected the axis-perpendicular-only gate, not the multi-edge one: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}
#[test]
fn pushing_a_sphere_zone_disk_past_its_other_rim_refuses() {
let (solid, top) = sphere_zone(5.0, -4.0, 2.0);
assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
for d in [-0.5_f64, -3.99] {
let pushed = move_faces(&solid, &[top], Vec3::new(0.0, 0.0, d))
.unwrap_or_else(|e| panic!("zone disk push {d}: {e}"));
assert!(pushed.validate().is_empty(), "{:?}", pushed.validate());
}
let error = move_faces(&solid, &[top], Vec3::new(0.0, 0.0, -6.0))
.expect_err("a disk pushed past the other rim must refuse");
assert!(
error.contains("trim collapses or inverts under"),
"expected the seam-meridian trim guard: {error}"
);
assert!(solid.validate().is_empty(), "the input is never mutated");
}