use axiolid_construct::profile::profile_rings;
use axiolid_construct::sweep;
use axiolid_core::{Point3, Scalar, Tolerance, Vec3};
use axiolid_measure::volume_properties;
use axiolid_mesh::TriMesh;
use axiolid_profile::{Profile, RectangleProfile};
fn tol_for(chord: Scalar) -> Tolerance {
Tolerance::new(chord, 1e-9).expect("tolerance")
}
fn volume(mesh: &TriMesh, tol: Tolerance) -> Scalar {
volume_properties(mesh, tol)
.expect("a swept solid must be closed and two-manifold")
.signed_volume
}
fn rect(x: Scalar, y: Scalar) -> Profile {
Profile::Rectangle(RectangleProfile {
x,
y,
thickness: None,
outer_radius: None,
inner_radius: None,
})
}
#[test]
fn a_tapered_extrusion_matches_the_prismatoid_formula() {
let chord = 1e-6;
let tol = tol_for(chord);
let a = profile_rings(&rect(2.0, 2.0), chord, tol).expect("start");
let b = profile_rings(&rect(4.0, 4.0), chord, tol).expect("end");
let mesh = sweep::tapered_extrude(&a, &b, Vec3::Z, 3.0).expect("taper");
let want = 3.0 * (4.0 + 4.0 * 9.0 + 16.0) / 6.0;
let got = volume(&mesh, tol);
assert!(
(got - want).abs() / want < 1e-9,
"prismatoid volume {got} vs {want}"
);
}
#[test]
fn a_tapered_revolution_averages_its_two_profiles() {
let chord = 1e-5;
let tol = tol_for(chord);
let mut a = profile_rings(&rect(1.0, 2.0), chord, tol).expect("start");
for p in &mut a.outer {
p.x += 4.0;
}
let b = a.clone();
let mesh = sweep::tapered_revolve(&a, &b, Point3::ZERO, Vec3::Y, core::f64::consts::PI, tol)
.expect("taper");
let want = core::f64::consts::TAU * 4.0 * 2.0 / 2.0;
let got = volume(&mesh, tol);
assert!(
(want - got) / want < 1e-4 && got > 0.0,
"tapered revolution {got} vs {want}"
);
}
#[test]
fn a_swept_disk_on_a_straight_path_is_a_cylinder() {
let chord = 1e-6;
let tol = tol_for(chord);
let path = [Point3::ZERO, Point3::new(0.0, 0.0, 5.0)];
let mesh = sweep::swept_disk(&path, 2.0, None, None, tol).expect("disk");
let want = core::f64::consts::PI * 4.0 * 5.0;
let got = volume(&mesh, tol);
assert!(
(want - got) / want < 1e-4 && got < want,
"swept disk {got} vs {want}"
);
}
fn bent_path(
legs: &[Vec3],
leg_length: Scalar,
bend: Scalar,
step: Scalar,
) -> (Vec<Point3>, Scalar) {
let mut points = vec![Point3::ZERO];
let mut here = Point3::ZERO;
for (i, dir) in legs.iter().enumerate() {
here += *dir * leg_length;
points.push(here);
let Some(next) = legs.get(i + 1) else { break };
let angle = dir.angle_between(*next);
let inward = (*next - *dir * dir.dot(*next)).normalize();
let centre = here + inward * bend;
let count = (angle / step).ceil().max(1.0) as usize;
for k in 1..=count {
let a = angle * k as Scalar / count as Scalar;
here = centre - inward * (bend * a.cos()) + *dir * (bend * a.sin());
points.push(here);
}
}
let length = points.windows(2).map(|w| (w[1] - w[0]).length()).sum();
(points, length)
}
fn disk_volume(radius: Scalar, inner: Option<Scalar>, length: Scalar, chord: Scalar) -> Scalar {
let ngon = |r: Scalar| {
let per = 2.0
* (1.0 - (chord / r).min(1.0))
.clamp(-1.0, 1.0)
.acos()
.max(1e-9);
let n = (core::f64::consts::TAU / per).ceil().clamp(2.0, 4096.0);
0.5 * n * r * r * (core::f64::consts::TAU / n).sin()
};
(ngon(radius) - inner.map_or(0.0, ngon)) * length
}
fn assert_bent_disk(legs: &[Vec3], inner: Option<Scalar>, what: &str) {
let chord = 1e-4;
let tol = tol_for(chord);
let (path, length) = bent_path(legs, 3.0, 1.0, 0.02);
let mesh = sweep::swept_disk(&path, 0.25, inner, None, tol)
.unwrap_or_else(|e| panic!("{what}: a bent disk must sweep: {e}"));
let want = disk_volume(0.25, inner, length, chord);
let got = volume(&mesh, tol);
assert!(
(got - want).abs() / want < 1e-3,
"{what}: swept {got} vs n-gon area x path length {want}"
);
}
#[test]
fn a_swept_disk_turning_onto_its_seed_axis_sweeps() {
assert_bent_disk(&[Vec3::X, Vec3::Y], None, "L, solid");
assert_bent_disk(&[Vec3::X, Vec3::Y], Some(0.1), "L, hollow");
}
#[test]
fn a_u_shaped_swept_disk_sweeps() {
assert_bent_disk(&[Vec3::X, Vec3::Y, -Vec3::X], None, "U, solid");
assert_bent_disk(&[Vec3::X, Vec3::Y, -Vec3::X], Some(0.1), "U, hollow");
}
#[test]
fn a_leg_nearly_along_the_seed_axis_does_not_twist() {
let nearly_y = Vec3::new(1e-9, 1.0, 0.0).normalize();
assert_bent_disk(&[Vec3::X, nearly_y], None, "nearly-parallel leg");
assert_bent_disk(
&[Vec3::Z, Vec3::X, nearly_y],
Some(0.1),
"3D, nearly-parallel leg",
);
}
#[test]
fn a_helical_swept_disk_keeps_its_volume() {
let chord = 1e-4;
let tol = tol_for(chord);
let path: Vec<Point3> = (0..=400)
.map(|k| {
let a = k as Scalar * 0.02;
Point3::new(2.0 * a.cos(), 2.0 * a.sin(), 0.3 * a)
})
.collect();
let length: Scalar = path.windows(2).map(|w| (w[1] - w[0]).length()).sum();
let mesh = sweep::swept_disk(&path, 0.25, None, None, tol).expect("helix sweeps");
let want = disk_volume(0.25, None, length, chord);
let got = volume(&mesh, tol);
assert!(
(got - want).abs() / want < 1e-3,
"helix: swept {got} vs {want}"
);
}
#[test]
fn a_hollow_swept_disk_subtracts_its_bore() {
let chord = 1e-6;
let tol = tol_for(chord);
let path = [Point3::ZERO, Point3::new(0.0, 0.0, 5.0)];
let mesh = sweep::swept_disk(&path, 2.0, Some(1.0), None, tol).expect("pipe");
let want = core::f64::consts::PI * 3.0 * 5.0;
let got = volume(&mesh, tol);
assert!(
(want - got).abs() / want < 1e-3 && got > 0.0,
"hollow swept disk {got} vs {want}"
);
}
#[test]
fn a_fillet_radius_is_refused_not_silently_sharpened() {
let tol = tol_for(1e-6);
let path = [Point3::ZERO, Point3::new(0.0, 0.0, 5.0)];
assert!(sweep::swept_disk(&path, 2.0, None, Some(0.5), tol).is_err());
}
#[test]
fn a_fixed_reference_sweep_on_a_straight_path_is_an_extrusion() {
let chord = 1e-6;
let tol = tol_for(chord);
let rings = profile_rings(&rect(2.0, 3.0), chord, tol).expect("rings");
let path = [Point3::ZERO, Point3::new(0.0, 0.0, 4.0)];
let mesh = sweep::fixed_reference_sweep(&rings, &path, Vec3::X).expect("sweep");
let want = 2.0 * 3.0 * 4.0;
let got = volume(&mesh, tol);
assert!(
(got - want).abs() / want < 1e-9,
"fixed reference sweep {got} vs {want}"
);
}
#[test]
fn a_parallel_reference_is_refused() {
let chord = 1e-6;
let tol = tol_for(chord);
let rings = profile_rings(&rect(2.0, 3.0), chord, tol).expect("rings");
let path = [Point3::ZERO, Point3::new(0.0, 0.0, 4.0)];
assert!(sweep::fixed_reference_sweep(&rings, &path, Vec3::Z).is_err());
}
#[test]
fn a_surface_curve_sweep_takes_its_up_from_the_surface() {
let chord = 1e-6;
let tol = tol_for(chord);
let rings = profile_rings(&rect(2.0, 3.0), chord, tol).expect("rings");
let path = [Point3::ZERO, Point3::new(0.0, 0.0, 4.0)];
let normals = [Vec3::X, Vec3::X];
let mesh = sweep::surface_curve_sweep(&rings, &path, &normals).expect("sweep");
let want = 2.0 * 3.0 * 4.0;
let got = volume(&mesh, tol);
assert!(
(got - want).abs() / want < 1e-9,
"surface curve sweep {got} vs {want}"
);
assert!(sweep::surface_curve_sweep(&rings, &path, &[Vec3::X]).is_err());
}
#[test]
fn a_sectioned_spine_with_equal_sections_is_an_extrusion() {
let chord = 1e-6;
let tol = tol_for(chord);
let rings = profile_rings(&rect(2.0, 3.0), chord, tol).expect("rings");
let place = |z: Scalar| -> Vec<Point3> {
rings
.outer
.iter()
.map(|p| Point3::new(p.x, p.y, z))
.collect()
};
let sections = vec![
(rings.clone(), place(0.0)),
(rings.clone(), place(2.0)),
(rings.clone(), place(4.0)),
];
let mesh = sweep::sectioned_spine(§ions).expect("spine");
let want = 2.0 * 3.0 * 4.0;
let got = volume(&mesh, tol);
assert!(
(got - want).abs() / want < 1e-9,
"sectioned spine {got} vs {want}"
);
assert!(sweep::sectioned_spine(§ions[..1]).is_err());
}