use super::*;
use crate::make_line;
fn square(z: f64, scale: f64) -> Vec<NurbsCurve> {
let points = [
Vec3::new(-scale, -scale, z),
Vec3::new(scale, -scale, z),
Vec3::new(scale, scale, z),
Vec3::new(-scale, scale, z),
];
(0..4)
.map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
.collect()
}
#[test]
fn three_section_loft_has_shared_skin_topology() {
let solid =
loft_profile_brep(&[square(0.0, 2.0), square(3.0, 3.0), square(8.0, 1.0)]).unwrap();
assert!(solid.validate().is_empty());
assert_eq!(solid.vertices.len(), 8);
assert_eq!(solid.edges.len(), 12);
assert_eq!(solid.shells[0].faces.len(), 6);
}
#[test]
fn clockwise_sections_keep_side_faces_in_input_order() {
let clockwise = |z: f64, scale: f64| -> Vec<NurbsCurve> {
let points = [
Vec3::new(-scale, -scale, z),
Vec3::new(-scale, scale, z),
Vec3::new(scale, scale, z),
Vec3::new(scale, -scale, z),
];
(0..4)
.map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
.collect()
};
let bottom = clockwise(0.0, 2.0);
let solid = loft_profile_brep(&[bottom.clone(), clockwise(5.0, 2.0)]).unwrap();
assert!(solid.validate().is_empty());
let faces = &solid.shells[0].faces;
for (index, curve) in bottom.iter().enumerate() {
let [start, end] = curve.domain().unwrap();
let midpoint = curve.evaluate((start + end) / 2.0).unwrap();
let projection =
crate::project_point_to_surface(&faces[index].surface, midpoint).unwrap();
assert!(
projection.distance < 1e-9,
"side face {index} does not carry input curve {index} \
(distance {})",
projection.distance
);
}
}
fn square_at(center: Vec3, half: f64) -> Vec<NurbsCurve> {
let points = [
center.add(Vec3::new(-half, -half, 0.0)),
center.add(Vec3::new(half, -half, 0.0)),
center.add(Vec3::new(half, half, 0.0)),
center.add(Vec3::new(-half, half, 0.0)),
];
(0..4)
.map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
.collect()
}
fn solid_aabb(solid: &BrepSolid) -> ([f64; 3], [f64; 3]) {
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
let mut swallow = |point: Vec3| {
min[0] = min[0].min(point.x);
min[1] = min[1].min(point.y);
min[2] = min[2].min(point.z);
max[0] = max[0].max(point.x);
max[1] = max[1].max(point.y);
max[2] = max[2].max(point.z);
};
for vertex in &solid.vertices {
swallow(vertex.point);
}
for edge in &solid.edges {
let [start, end] = edge.curve.domain().unwrap();
for index in 0..=24 {
swallow(
edge.curve
.evaluate(start + (end - start) * index as f64 / 24.0)
.unwrap(),
);
}
}
(min, max)
}
#[test]
fn guided_loft_bends_two_squares_along_an_arc() {
use crate::{make_arc, solid_mass_properties};
let bottom = square_at(Vec3::new(0.0, 0.0, 0.0), 1.0);
let top = square_at(Vec3::new(0.0, 0.0, 8.0), 1.0);
let guide = make_arc(
Vec3::new(0.0, 0.0, 4.0),
Vec3::new(0.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, 0.0),
4.0,
0.0,
std::f64::consts::PI,
)
.unwrap();
let solid =
loft_profile_brep_guided(&[bottom.clone(), top.clone()], &guide, Some("Guided"))
.unwrap();
assert!(
solid.validate().is_empty(),
"guided loft is invalid: {:?}",
solid.validate()
);
let volume = solid_mass_properties(&solid).unwrap().volume;
assert!(volume > 0.0, "guided loft has non-positive volume {volume}");
let (guided_min, guided_max) = solid_aabb(&solid);
let plain = loft_profile_brep(&[bottom, top]).unwrap();
let (_plain_min, plain_max) = solid_aabb(&plain);
assert!(
plain_max[0] < 1.5,
"straight loft should not bulge in +X (max_x {})",
plain_max[0]
);
assert!(
guided_max[0] > 3.0,
"guided loft did not bend along the guide (max_x {} <= 3.0)",
guided_max[0]
);
assert!(
guided_min[2] <= 1e-6 && guided_max[2] >= 8.0 - 1e-6,
"guided loft z-span [{}, {}] does not cover the sections",
guided_min[2],
guided_max[2]
);
}
#[test]
fn guided_loft_straight_guide_matches_plain_loft() {
use crate::solid_mass_properties;
let bottom = square_at(Vec3::new(0.0, 0.0, 0.0), 2.0);
let top = square_at(Vec3::new(0.0, 0.0, 6.0), 1.0);
let guide = make_line(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 6.0)).unwrap();
let guided =
loft_profile_brep_guided(&[bottom.clone(), top.clone()], &guide, None).unwrap();
assert!(
guided.validate().is_empty(),
"straight-guided loft is invalid: {:?}",
guided.validate()
);
let plain = loft_profile_brep(&[bottom, top]).unwrap();
let guided_volume = solid_mass_properties(&guided).unwrap().volume;
let plain_volume = solid_mass_properties(&plain).unwrap().volume;
assert!(guided_volume > 0.0, "guided volume {guided_volume}");
let error = (guided_volume - plain_volume).abs() / plain_volume;
assert!(
error < 0.03,
"straight-guided volume {guided_volume} not within 3% of plain loft {plain_volume} (error {error})"
);
}
fn square_from_corners(corners: [Vec3; 4]) -> Vec<NurbsCurve> {
(0..4)
.map(|index| make_line(corners[index], corners[(index + 1) % 4]).unwrap())
.collect()
}
#[test]
fn frame_guided_loft_is_pappus_exact_on_a_quarter_arc() {
use crate::{make_arc, solid_mass_properties};
let guide = make_arc(
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
10.0,
0.0,
std::f64::consts::FRAC_PI_2,
)
.unwrap();
let start_corners = [
Vec3::new(9.0, -1.0, 0.0),
Vec3::new(11.0, -1.0, 0.0),
Vec3::new(11.0, 1.0, 0.0),
Vec3::new(9.0, 1.0, 0.0),
];
let end_corners = [
Vec3::new(0.0, -1.0, 9.0),
Vec3::new(0.0, -1.0, 11.0),
Vec3::new(0.0, 1.0, 11.0),
Vec3::new(0.0, 1.0, 9.0),
];
let sections = vec![
square_from_corners(start_corners),
square_from_corners(end_corners),
];
let framed = loft_profile_brep_guided_frame(§ions, &guide, None).unwrap();
assert!(
framed.validate().is_empty(),
"frame-guided loft is invalid: {:?}",
framed.validate()
);
let pappus = 4.0 * (std::f64::consts::FRAC_PI_2 * 10.0);
let framed_volume = solid_mass_properties(&framed).unwrap().volume;
let framed_error = (framed_volume - pappus).abs() / pappus;
assert!(
framed_error < 2e-3,
"frame-guided volume {framed_volume} not within 0.2% of Pappus {pappus} (error {framed_error})"
);
for corner in start_corners.iter().chain(end_corners.iter()) {
let hit = framed
.vertices
.iter()
.any(|vertex| vertex.point.sub(*corner).length() < 1e-9);
assert!(hit, "user-section corner {corner:?} is not a solid vertex");
}
let translated = loft_profile_brep_guided(§ions, &guide, None).unwrap();
let translated_volume = solid_mass_properties(&translated).unwrap().volume;
let translated_error = (translated_volume - pappus).abs() / pappus;
assert!(
translated_error > 0.05,
"translation mode unexpectedly matches Pappus ({translated_volume} vs {pappus}); \
the frame mode would be indistinguishable"
);
}
#[test]
fn frame_guided_loft_matches_translation_on_a_straight_guide() {
use crate::solid_mass_properties;
let bottom = square_at(Vec3::new(0.0, 0.0, 0.0), 2.0);
let top = square_at(Vec3::new(0.0, 0.0, 6.0), 1.0);
let guide = make_line(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 6.0)).unwrap();
let framed =
loft_profile_brep_guided_frame(&[bottom.clone(), top.clone()], &guide, None).unwrap();
assert!(
framed.validate().is_empty(),
"frame-guided straight loft is invalid: {:?}",
framed.validate()
);
let translated = loft_profile_brep_guided(&[bottom, top], &guide, None).unwrap();
let framed_volume = solid_mass_properties(&framed).unwrap().volume;
let translated_volume = solid_mass_properties(&translated).unwrap().volume;
let error = (framed_volume - translated_volume).abs() / translated_volume;
assert!(
error < 1e-9,
"frame mode {framed_volume} deviates from translation mode {translated_volume} on a straight guide (error {error})"
);
}