use cadrum::{BSplineEnd, Edge, Error, Solid, Tessellation};
use glam::DVec3;
use std::f64::consts::PI;
fn write_outputs(solids: &[Solid], name: &str) {
std::fs::create_dir_all("out").unwrap();
let mut f = std::fs::File::create(format!("out/{name}.step")).unwrap();
cadrum::Solid::write_step(solids, &mut f).expect("step write");
let mut f = std::fs::File::create(format!("out/{name}.stl")).unwrap();
cadrum::Solid::mesh(solids, cadrum::Tessellation { deflection_linear: 0.1, relative_linear: false, ..Default::default() }).and_then(|m| m.write_stl(&mut f)).expect("stl write");
let mut f = std::fs::File::create(format!("out/{name}.svg")).unwrap();
cadrum::Solid::mesh(solids, cadrum::Tessellation { deflection_linear: 0.5, relative_linear: false, ..Default::default() }).and_then(|m| m.scene(cadrum::SceneOption { view: DVec3::new(1.0, 1.0, 2.0), ..Default::default() }).write_svg(&mut f)).expect("svg write");
}
#[test]
fn test_loft_01_frustum_volume_matches_analytical() {
let r1 = 3.0;
let r2 = 2.0;
let h = 10.0;
let lower = vec![Edge::circle(r1, DVec3::Z).unwrap()];
let upper = vec![Edge::circle(r2, DVec3::Z).unwrap().translate(DVec3::Z * h)];
let frustum = Solid::loft(&[lower, upper], false).expect("frustum loft should succeed");
let expected = PI / 3.0 * h * (r1 * r1 + r1 * r2 + r2 * r2);
let actual = frustum.volume();
let rel_err = (actual - expected).abs() / expected;
assert!(rel_err < 0.01, "frustum volume {:.4} vs analytical {:.4} (relative error {:.4})", actual, expected, rel_err);
write_outputs(std::slice::from_ref(&frustum), "test_loft_01_frustum_volume_matches_analytical");
println!("frustum loft: volume = {:.4} (expected {:.4})", actual, expected);
}
#[test]
fn test_loft_02_single_section_returns_loft_failed() {
let only = vec![Edge::circle(1.0, DVec3::Z).unwrap()];
let result = Solid::loft(&[only], false);
let err = result.err().expect("single section must return Err");
match err {
Error::LoftFailed(msg) => {
assert!(msg.contains("≥2") || msg.contains(">=2") || msg.contains("got 1"), "error message should mention min section count, got: {}", msg);
}
other => panic!("expected Error::LoftFailed, got {:?}", other),
}
}
#[test]
fn test_loft_03_empty_section_returns_loft_failed() {
let s1 = vec![Edge::circle(1.0, DVec3::Z).unwrap()];
let empty: Vec<Edge> = vec![];
let s3 = vec![Edge::circle(1.0, DVec3::Z).unwrap().translate(DVec3::Z * 10.0)];
let result = Solid::loft(&[s1, empty, s3], false);
let err = result.err().expect("empty section must return Err");
match err {
Error::LoftFailed(msg) => {
assert!(msg.contains("empty"), "error message should mention empty section, got: {}", msg);
}
other => panic!("expected Error::LoftFailed, got {:?}", other),
}
}
#[test]
fn test_loft_04_closure_iterator_form() {
let ribs: Vec<Edge> = (0..3).map(|i| Edge::circle(2.0 + i as f64 * 0.5, DVec3::Z).unwrap().translate(DVec3::Z * i as f64 * 5.0)).collect();
let plasma = Solid::loft(ribs.iter().map(|e| [e]), false).expect("closure-form loft should succeed");
assert!(plasma.volume() > 0.0);
write_outputs(std::slice::from_ref(&plasma), "test_loft_04_closure_iterator_form");
}
fn naca_points(c: f64, z: f64, n: usize) -> Vec<DVec3> {
let half_thickness = |x: f64| -> f64 { 5.0 * 0.12 * (0.2969 * x.sqrt() - 0.1260 * x - 0.3516 * x * x + 0.2843 * x.powi(3) - 0.1036 * x.powi(4)) };
let mut pts = Vec::new();
for i in 0..=n {
let x = (1.0 + (PI * i as f64 / n as f64).cos()) / 2.0;
pts.push(DVec3::new(c * x, c * half_thickness(x), z));
}
for i in 1..=n {
let x = (1.0 - (PI * i as f64 / n as f64).cos()) / 2.0;
pts.push(DVec3::new(c * x, -c * half_thickness(x), z));
}
pts
}
fn polygon_area(pts: &[DVec3]) -> f64 {
let n = pts.len();
let mut a = 0.0;
for i in 0..n {
let p = pts[i];
let q = pts[(i + 1) % n];
a += p.x * q.y - q.x * p.y;
}
0.5 * a.abs()
}
fn naca_section(c: f64, z: f64, n: usize) -> Vec<Edge> {
vec![Edge::bspline(&naca_points(c, z, n), BSplineEnd::NotAKnot).expect("NACA bspline section")]
}
fn mesh_volume(solid: &Solid) -> f64 {
let mesh = Solid::mesh([solid], Tessellation { deflection_linear: 1.0e-4, relative_linear: false, ..Default::default() }).expect("mesh");
let mut vol = 0.0;
for t in mesh.indices.chunks_exact(3) {
let (a, b, c) = (mesh.vertices[t[0]], mesh.vertices[t[1]], mesh.vertices[t[2]]);
vol += a.dot(b.cross(c)) / 6.0;
}
vol.abs()
}
#[test]
fn test_loft_05_two_naca_sections_volume_sane() {
let n = 60;
let (c_root, c_tip, span) = (1.0, 0.5, 4.0);
let root = naca_section(c_root, 0.0, n);
let tip = naca_section(c_tip, span, n);
let wing = Solid::loft(&[root, tip], false).expect("two-section loft should succeed");
assert!(wing.volume() > 0.0, "wing must enclose positive volume");
let a_root = polygon_area(&naca_points(c_root, 0.0, n));
let s = c_tip / c_root;
let expected = span * a_root * (1.0 + s + s * s) / 3.0;
let actual = mesh_volume(&wing);
let rel_err = (actual - expected).abs() / expected;
assert!(rel_err < 0.01, "wing volume {:.6} vs tapered-prism estimate {:.6} (relative error {:.4})", actual, expected, rel_err);
assert!(wing.contains(DVec3::new(0.4 * c_root, 0.0, 0.2)));
println!("two-section NACA wing: mesh volume = {:.6} (estimate {:.6}, GProp volume {:.6})", actual, expected, wing.volume());
}
#[test]
fn test_loft_06_sections_interpolated_exactly() {
let n = 40;
let (c_mid, z_mid) = (0.7, 2.0);
let sections = [naca_section(1.0, 0.0, n), naca_section(c_mid, z_mid, n), naca_section(0.5, 4.0, n)];
let wing = Solid::loft(§ions, false).expect("three-section loft should succeed");
let eps = 1.0e-3;
for p in naca_points(c_mid, z_mid, n).iter().filter(|p| p.y.abs() > 8.0e-3) {
let inward = DVec3::new(p.x, p.y - eps * p.y.signum(), p.z);
let outward = DVec3::new(p.x, p.y + eps * p.y.signum(), p.z);
assert!(wing.contains(inward), "point {:.4?} - ε must be inside (surface missed the section point)", p);
assert!(!wing.contains(outward), "point {:.4?} + ε must be outside (surface missed the section point)", p);
}
}
#[test]
fn test_loft_07_ruled_matches_piecewise_estimate() {
let n = 60;
let stations = [(1.0, 0.0), (0.6, 2.0), (0.5, 4.0)];
let sections: Vec<Vec<Edge>> = stations.iter().map(|&(c, z)| naca_section(c, z, n)).collect();
let ruled = Solid::loft(§ions, true).expect("ruled loft should succeed");
let mut expected = 0.0;
for w in stations.windows(2) {
let (a1, a2) = (polygon_area(&naca_points(w[0].0, w[0].1, n)), polygon_area(&naca_points(w[1].0, w[1].1, n)));
expected += (w[1].1 - w[0].1) / 3.0 * (a1 + a2 + (a1 * a2).sqrt());
}
let actual = mesh_volume(&ruled);
let rel_err = (actual - expected).abs() / expected;
assert!(rel_err < 0.01, "ruled volume {:.6} vs piecewise frustum estimate {:.6} (relative error {:.4})", actual, expected, rel_err);
let smooth = Solid::loft(§ions, false).expect("smooth loft should succeed");
let ratio = mesh_volume(&smooth) / actual;
assert!((0.85..1.15).contains(&ratio), "smooth/ruled volume ratio {:.4} out of sanity band", ratio);
println!("ruled = {:.6}, smooth = {:.6}, estimate = {:.6}", actual, mesh_volume(&smooth), expected);
}