refactor(three-d): move lib.rs tests to tests.rs

This commit is contained in:
aiongg
2026-10-26 18:26:53 +00:00
parent a983cf2d5f
commit e528a5f8d0
2 changed files with 186 additions and 187 deletions
+1 -187
View File
@@ -601,190 +601,4 @@ mod tests_caps;
mod tests_smooth;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
for height in [0.0f64, 15.0, 45.0, 80.0] {
for behind in [false, true] {
let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() };
let direction = o.light_direction();
assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12);
assert_eq!(direction[2] < 0.0, behind && height > 0.0);
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) {
let (s, c) = angle.to_radians().sin_cos();
for i in 0..3 {
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
}
}
let [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1]));
assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}");
}
}
}
// Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°.
let o = Revolve { light_rotation: 135.0, ..Default::default() };
assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]);
let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0));
assert!((r - 90.0).abs() < 1e-12 && h == 0.0);
let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
assert!(r.is_finite() && h.is_finite());
}
/// An effect from before Rotation and Height (azimuth and elevation) gets the same light:
/// the same direction, so the same shading.
#[test]
fn an_earlier_azimuth_and_elevation_give_the_same_light() {
let old = |azimuth: f64, elevation: f64| {
let (sl, cl) = elevation.to_radians().sin_cos();
let (sa, ca) = azimuth.to_radians().sin_cos();
[cl * sa, -sl, cl * ca]
};
for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] {
for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] {
let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation);
assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height));
let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction();
let want = old(azimuth, elevation);
for i in 0..3 {
assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}");
}
}
}
// The default light is the earlier default's.
let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0);
assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}");
}
/// Softness wraps the light further round the object; without it, the earlier shading. Ambient
/// Light runs to 200 %, never past the art's own colour.
#[test]
fn softness_wraps_the_light_and_ambient_reaches_200_percent() {
let o = Revolve::default().sanitized();
let l = V3(0.0, 0.0, 1.0);
let n = |deg: f64| {
let (s, c) = deg.to_radians().sin_cos();
V3(s, 0.0, c)
};
for deg in [0.0, 30.0, 60.0, 89.0, 120.0] {
let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0);
assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12);
}
let soft = Revolve { light_softness: 1.0, ..o };
assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°");
assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away");
assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light");
let bright = Revolve { ambient: 2.0, ..o };
assert_eq!(brightness(&bright, n(180.0), l), 1.0);
assert_eq!(
Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(),
Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o }
);
}
#[test]
fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() {
let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() };
let seed = V3(0.3, -0.5, 0.7);
let before = o.rotate(seed);
let planes = o.rotation_planes();
for (axis, [u, v]) in planes.iter().enumerate() {
let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2]));
assert!((normal.dot(normal) - 1.0).abs() < 1e-12);
let mut changed = o;
match axis {
0 => changed.rotation_x += 0.0001,
1 => changed.rotation_y += 0.0001,
_ => changed.rotation_z += 0.0001,
}
let actual = changed.rotate(seed).sub(before);
let expected = normal.cross(before);
let step = 0.0001f64.to_radians();
for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] {
assert!((a - b).abs() < 1e-5);
}
}
assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0));
}
#[test]
fn guides_match_projection_and_dragged_light_matches_shading_direction() {
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
let [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction();
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0));
assert!(rotation.is_finite() && height == 0.0);
}
fn cylinder() -> (PathData, Rect) {
let mut bp = BezPath::new();
bp.move_to((40.0, 10.0));
bp.line_to((40.0, 90.0));
(PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0))
}
#[test]
fn cylinder_has_expected_silhouette_depth_order_and_shading() {
let (p, b) = cylinder();
let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect();
assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6);
assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6);
assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth));
assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4));
}
#[test]
fn edge_offset_sweep_and_rotation_change_the_surface() {
let (p, b) = cylinder();
let base = revolve(&p, b, Revolve::default()).unwrap();
for o in [
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
Revolve { angle: 120.0, ..Default::default() },
Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() },
] {
assert_ne!(base, revolve(&p, b, o).unwrap());
}
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
}
/// The angle that stands for an earlier perspective strength puts the eye where that strength
/// did (4 × the half size / strength), so files from before the angle look as they did.
#[test]
fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() {
for strength in [0.05, 0.3, 0.6, 1.0] {
for size in [1.0, 37.5, 900.0] {
let eye = eye_distance(perspective_from_strength(strength), size).unwrap();
assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}");
}
}
assert_eq!(perspective_from_strength(0.0), 0.0);
assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0));
}
/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside
/// the sphere the object's box fits in.
#[test]
fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() {
assert_eq!(eye_distance(0.0, 50.0), None);
assert_eq!(eye_distance(-20.0, 50.0), None);
assert_eq!(eye_distance(f64::NAN, 50.0), None);
assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0));
let widest = eye_distance(160.0, 50.0).unwrap();
assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}");
// Wider is closer.
let eyes: Vec<f64> = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect();
assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}");
let (p, b) = cylinder();
let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap();
assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite()));
assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap());
}
#[test]
fn malformed_and_degenerate_inputs_are_bounded() {
let (p, b) = cylinder();
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
assert!(f.len() <= 258);
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
}
}
mod tests;
+185
View File
@@ -0,0 +1,185 @@
use super::*;
#[test]
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
for height in [0.0f64, 15.0, 45.0, 80.0] {
for behind in [false, true] {
let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() };
let direction = o.light_direction();
assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12);
assert_eq!(direction[2] < 0.0, behind && height > 0.0);
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) {
let (s, c) = angle.to_radians().sin_cos();
for i in 0..3 {
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
}
}
let [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1]));
assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}");
}
}
}
// Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°.
let o = Revolve { light_rotation: 135.0, ..Default::default() };
assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]);
let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0));
assert!((r - 90.0).abs() < 1e-12 && h == 0.0);
let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
assert!(r.is_finite() && h.is_finite());
}
/// An effect from before Rotation and Height (azimuth and elevation) gets the same light:
/// the same direction, so the same shading.
#[test]
fn an_earlier_azimuth_and_elevation_give_the_same_light() {
let old = |azimuth: f64, elevation: f64| {
let (sl, cl) = elevation.to_radians().sin_cos();
let (sa, ca) = azimuth.to_radians().sin_cos();
[cl * sa, -sl, cl * ca]
};
for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] {
for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] {
let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation);
assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height));
let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction();
let want = old(azimuth, elevation);
for i in 0..3 {
assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}");
}
}
}
// The default light is the earlier default's.
let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0);
assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}");
}
/// Softness wraps the light further round the object; without it, the earlier shading. Ambient
/// Light runs to 200 %, never past the art's own colour.
#[test]
fn softness_wraps_the_light_and_ambient_reaches_200_percent() {
let o = Revolve::default().sanitized();
let l = V3(0.0, 0.0, 1.0);
let n = |deg: f64| {
let (s, c) = deg.to_radians().sin_cos();
V3(s, 0.0, c)
};
for deg in [0.0, 30.0, 60.0, 89.0, 120.0] {
let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0);
assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12);
}
let soft = Revolve { light_softness: 1.0, ..o };
assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°");
assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away");
assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light");
let bright = Revolve { ambient: 2.0, ..o };
assert_eq!(brightness(&bright, n(180.0), l), 1.0);
assert_eq!(
Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(),
Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o }
);
}
#[test]
fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() {
let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() };
let seed = V3(0.3, -0.5, 0.7);
let before = o.rotate(seed);
let planes = o.rotation_planes();
for (axis, [u, v]) in planes.iter().enumerate() {
let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2]));
assert!((normal.dot(normal) - 1.0).abs() < 1e-12);
let mut changed = o;
match axis {
0 => changed.rotation_x += 0.0001,
1 => changed.rotation_y += 0.0001,
_ => changed.rotation_z += 0.0001,
}
let actual = changed.rotate(seed).sub(before);
let expected = normal.cross(before);
let step = 0.0001f64.to_radians();
for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] {
assert!((a - b).abs() < 1e-5);
}
}
assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0));
}
#[test]
fn guides_match_projection_and_dragged_light_matches_shading_direction() {
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
let [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction();
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0));
assert!(rotation.is_finite() && height == 0.0);
}
fn cylinder() -> (PathData, Rect) {
let mut bp = BezPath::new();
bp.move_to((40.0, 10.0));
bp.line_to((40.0, 90.0));
(PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0))
}
#[test]
fn cylinder_has_expected_silhouette_depth_order_and_shading() {
let (p, b) = cylinder();
let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect();
assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6);
assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6);
assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth));
assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4));
}
#[test]
fn edge_offset_sweep_and_rotation_change_the_surface() {
let (p, b) = cylinder();
let base = revolve(&p, b, Revolve::default()).unwrap();
for o in [
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
Revolve { angle: 120.0, ..Default::default() },
Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() },
] {
assert_ne!(base, revolve(&p, b, o).unwrap());
}
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
}
/// The angle that stands for an earlier perspective strength puts the eye where that strength
/// did (4 × the half size / strength), so files from before the angle look as they did.
#[test]
fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() {
for strength in [0.05, 0.3, 0.6, 1.0] {
for size in [1.0, 37.5, 900.0] {
let eye = eye_distance(perspective_from_strength(strength), size).unwrap();
assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}");
}
}
assert_eq!(perspective_from_strength(0.0), 0.0);
assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0));
}
/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside
/// the sphere the object's box fits in.
#[test]
fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() {
assert_eq!(eye_distance(0.0, 50.0), None);
assert_eq!(eye_distance(-20.0, 50.0), None);
assert_eq!(eye_distance(f64::NAN, 50.0), None);
assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0));
let widest = eye_distance(160.0, 50.0).unwrap();
assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}");
// Wider is closer.
let eyes: Vec<f64> = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect();
assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}");
let (p, b) = cylinder();
let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap();
assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite()));
assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap());
}
#[test]
fn malformed_and_degenerate_inputs_are_bounded() {
let (p, b) = cylinder();
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
assert!(f.len() <= 258);
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
}