Files
effectcraft/crates/path/src/ops/tests.rs
T

376 lines
14 KiB
Rust

use super::*;
use kurbo::{Circle, Rect, Shape};
fn len(p: &[BezPath]) -> f64 {
p.iter().map(length).sum()
}
fn sq(x: f64, y: f64, s: f64) -> BezPath {
Rect::new(x, y, x + s, y + s).to_path(0.1)
}
fn circle(x: f64, y: f64, r: f64) -> BezPath {
Circle::new((x, y), r).to_path(1e-3)
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn on_curve(p: &BezPath) -> Vec<Point> {
p.elements().iter().filter_map(|e| e.end_point()).collect()
}
// ---------------------------------------------------------------- trim
#[test]
fn trim_half_square() {
let s = sq(0.0, 0.0, 100.0);
let t = trim(std::slice::from_ref(&s), 0.0, 50.0, 0.0);
assert!(close(len(&t), 200.0, 0.5), "{}", len(&t));
let t2 = trim(&[s], 0.0, 50.0, 270.0);
assert!(close(len(&t2), 200.0, 0.5));
}
#[test]
fn trim_empty_keeps_slot() {
let t = trim(&[sq(0.0, 0.0, 10.0)], 30.0, 30.0, 0.0);
assert_eq!(t.len(), 1);
assert!(t[0].elements().is_empty());
}
#[test]
fn trim_across_start_point_is_one_piece() {
// 75 %..125 % (via the offset) crosses the start of the closed path: one continuous subpath.
let t = trim(&[sq(0.0, 0.0, 100.0)], 0.0, 50.0, 270.0);
let moves = t[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count();
assert_eq!(moves, 1, "{:?}", t[0]);
}
#[test]
fn trim_simultaneously_trims_each_path() {
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 50.0)];
let t = trim(&paths, 0.0, 50.0, 0.0);
assert!(close(length(&t[0]), 200.0, 0.5));
assert!(close(length(&t[1]), 100.0, 0.5));
}
#[test]
fn trim_individually_is_sequential() {
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 100.0)];
let t = trim_individually(&paths, 0.0, 50.0, 0.0);
assert!(close(length(&t[0]), 400.0, 0.5), "{}", length(&t[0]));
assert!(length(&t[1]) < 1e-6);
let t = trim_individually(&paths, 25.0, 75.0, 0.0);
assert!(close(length(&t[0]), 200.0, 0.5));
assert!(close(length(&t[1]), 200.0, 0.5));
// Unequal lengths: the kept length is the fraction of the total.
let paths = [sq(0.0, 0.0, 100.0), sq(200.0, 0.0, 25.0)];
let t = trim_individually(&paths, 10.0, 90.0, 45.0);
assert!(close(len(&t), 0.8 * 500.0, 0.5), "{}", len(&t));
}
#[test]
fn reverse_flips_orientation() {
let s = sq(0.0, 0.0, 10.0);
let r = reverse(std::slice::from_ref(&s));
assert!(close(r[0].area(), -s.area(), 1e-9));
assert_eq!(vertex_count(&r[0]), 4);
}
// ---------------------------------------------------------------- round corners
#[test]
fn round_corners_square() {
let r = round_corners(&[sq(0.0, 0.0, 100.0)], 10.0);
assert_eq!(vertex_count(&r[0]), 8);
let expect = 10000.0 - (4.0 - PI) * 100.0;
assert!(close(r[0].area().abs(), expect, 1.0), "{}", r[0].area());
// Matches the rectangle's own roundness.
let rr = crate::rect([100.0, 100.0], [50.0, 50.0], 10.0);
assert!(close(rr.area().abs(), r[0].area().abs(), 1.0));
}
#[test]
fn round_corners_clamps_to_half_segment() {
let r = round_corners(&[sq(0.0, 0.0, 20.0)], 100.0);
assert!(close(r[0].area().abs(), PI * 100.0, 2.0), "{}", r[0].area());
}
#[test]
fn round_corners_keeps_smooth_and_open_ends() {
let c = circle(0.0, 0.0, 50.0);
let r = round_corners(std::slice::from_ref(&c), 10.0);
assert_eq!(vertex_count(&r[0]), vertex_count(&c));
let mut open = BezPath::new();
open.move_to((0.0, 0.0));
open.line_to((100.0, 0.0));
open.line_to((100.0, 100.0));
let r = round_corners(&[open], 20.0);
assert_eq!(vertex_count(&r[0]), 4);
let pts = on_curve(&r[0]);
assert_eq!(pts[0], Point::new(0.0, 0.0));
assert!(pts.last().unwrap().distance(Point::new(100.0, 100.0)) < 1e-9);
}
#[test]
fn round_corners_triangle_arc_is_tangent() {
let mut t = BezPath::new();
t.move_to((0.0, 0.0));
t.line_to((100.0, 0.0));
t.line_to((50.0, 80.0));
t.close_path();
let r = round_corners(std::slice::from_ref(&t), 15.0);
assert_eq!(vertex_count(&r[0]), 6);
assert!(r[0].area().abs() < t.area().abs());
assert!(r[0].area().abs() > t.area().abs() - 3.0 * 15.0 * 15.0);
}
// ---------------------------------------------------------------- offset
#[test]
fn offset_grows_square_with_miter() {
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&o), 120.0 * 120.0, 1.0), "{}", area(&o));
let b = o[0].bounding_box();
assert!(close(b.x0, -10.0, 1e-3) && close(b.x1, 110.0, 1e-3));
}
#[test]
fn offset_round_join_adds_perimeter_times_d() {
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Round, 4.0, 1.0, 0.0);
let expect = 10000.0 + 400.0 * 10.0 + PI * 100.0;
assert!(close(area(&o), expect, 2.0), "{} vs {expect}", area(&o));
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Bevel, 4.0, 1.0, 0.0);
let expect = 10000.0 + 400.0 * 10.0 + 4.0 * 50.0;
assert!(close(area(&o), expect, 2.0), "{} vs {expect}", area(&o));
}
#[test]
fn offset_negative_shrinks() {
let o = offset(&[sq(0.0, 0.0, 100.0)], -10.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&o), 80.0 * 80.0, 1.0), "{}", area(&o));
let gone = offset(&[sq(0.0, 0.0, 100.0)], -60.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(area(&gone) < 1e-6);
}
#[test]
fn offset_circle_is_a_circle() {
let o = offset(&[circle(0.0, 0.0, 50.0)], 10.0, Join::Round, 4.0, 1.0, 0.0);
assert!(close(area(&o), PI * 3600.0, 3600.0 * PI * 1e-3), "{}", area(&o));
let o = offset(&[circle(0.0, 0.0, 50.0)], -20.0, Join::Round, 4.0, 1.0, 0.0);
assert!(close(area(&o), PI * 900.0, 900.0 * PI * 1e-3), "{}", area(&o));
// Curves stay curves (no polygon flattening blow-up).
assert!(vertex_count(&o[0]) < 40, "{}", vertex_count(&o[0]));
}
#[test]
fn offset_is_independent_of_orientation() {
let s = sq(0.0, 0.0, 100.0);
let a = offset(std::slice::from_ref(&s), 5.0, Join::Miter, 4.0, 1.0, 0.0);
let b = offset(&reverse(&[s]), 5.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&a), area(&b), 1e-3));
}
#[test]
fn offset_copies() {
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 3.0, 0.0);
let contours = o[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count();
assert_eq!(contours, 3);
assert!(close(o[0].bounding_box().width(), 160.0, 1e-3));
let o = offset(&[sq(0.0, 0.0, 100.0)], 10.0, Join::Miter, 4.0, 1.0, 1.0);
assert!(close(o[0].bounding_box().width(), 140.0, 1e-3));
}
#[test]
fn offset_open_path_outlines() {
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let o = offset(&[l], 5.0, Join::Miter, 4.0, 1.0, 0.0);
assert!(close(area(&o), 1000.0, 1.0), "{}", area(&o));
}
// ---------------------------------------------------------------- pucker & bloat
#[test]
fn bloat_moves_vertices_inwards_and_bulges() {
let s = sq(-50.0, -50.0, 100.0);
let b = pucker_bloat(std::slice::from_ref(&s), 50.0);
for p in on_curve(&b[0]) {
assert!(close(p.x.abs(), 25.0, 1e-9) && close(p.y.abs(), 25.0, 1e-9), "{p:?}");
}
// Handles pushed outwards: the shape reaches beyond its vertices.
let bb = b[0].bounding_box();
assert!(bb.width() > 50.0 + 1.0, "{bb:?}");
}
#[test]
fn pucker_moves_vertices_outwards() {
let s = sq(-50.0, -50.0, 100.0);
let p = pucker_bloat(std::slice::from_ref(&s), -50.0);
for q in on_curve(&p[0]) {
assert!(close(q.x.abs(), 75.0, 1e-9) && close(q.y.abs(), 75.0, 1e-9));
}
assert!(area(&p) < area(&[s]) * 2.25, "segments pulled in");
assert_eq!(pucker_bloat(&[sq(0.0, 0.0, 1.0)], 0.0)[0], sq(0.0, 0.0, 1.0));
}
// ---------------------------------------------------------------- twist
#[test]
fn twist_rotates_by_distance() {
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let t = twist(&[l], 90.0, [0.0, 0.0]);
let pts = on_curve(&t[0]);
assert!(pts[0].distance(Point::ZERO) < 1e-9, "centre is fixed");
let end = *pts.last().unwrap();
assert!(end.distance(Point::new(0.0, 100.0)) < 1e-9, "{end:?}");
// Every on-curve point keeps its distance from the centre (it is a rotation per radius).
for p in &pts {
let r = p.distance(Point::ZERO);
let ang = p.y.atan2(p.x);
assert!(close(ang, PI / 2.0 * r / 100.0, 1e-9), "{p:?}");
}
assert!(pts.len() > 4, "subdivided");
}
#[test]
fn twist_of_centred_circle_keeps_it_round() {
let c = circle(0.0, 0.0, 50.0);
let t = twist(std::slice::from_ref(&c), 120.0, [0.0, 0.0]);
assert!(close(area(&t), area(&[c]), 1.0));
let id = twist(&[sq(0.0, 0.0, 5.0)], 0.0, [0.0, 0.0]);
assert_eq!(id[0], sq(0.0, 0.0, 5.0));
}
// ---------------------------------------------------------------- zig zag
#[test]
fn zigzag_vertex_counts() {
let z = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 5.0, false);
assert_eq!(vertex_count(&z[0]), 24);
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let z = zigzag(&[l], 5.0, 3.0, false);
assert_eq!(vertex_count(&z[0]), 5);
let pts = on_curve(&z[0]);
for (i, p) in pts.iter().enumerate() {
assert!(close(p.x, 25.0 * i as f64, 1e-6));
assert!(close(p.y.abs(), 5.0, 1e-9));
if i > 0 {
assert!(p.y * pts[i - 1].y < 0.0, "alternates");
}
}
}
#[test]
fn zigzag_smooth_uses_curves() {
let z = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 2.0, true);
assert!(z[0].elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))));
assert_eq!(vertex_count(&z[0]), 12);
let corner = zigzag(&[sq(0.0, 0.0, 100.0)], 5.0, 2.0, false);
assert!(!corner[0].elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))));
}
// ---------------------------------------------------------------- wiggle
#[test]
fn wiggle_is_deterministic_and_time_varying() {
let s = [sq(0.0, 0.0, 100.0)];
let w = WiggleParams::default();
let a = wiggle(&s, &w, 0.5);
assert_eq!(a, wiggle(&s, &w, 0.5));
assert_ne!(a, wiggle(&s, &w, 0.75));
assert_ne!(a, wiggle(&s, &WiggleParams { seed: 3.0, ..w }, 0.5));
// Phase of 360° = one wiggle period at speed 1: same as advancing time by one second.
let w1 = WiggleParams { speed: 1.0, ..w };
assert_eq!(wiggle(&s, &WiggleParams { phase_deg: 360.0, ..w1 }, 0.25), wiggle(&s, &w1, 1.25));
assert_eq!(vertex_count(&a[0]), 4 * 11);
}
#[test]
fn wiggle_displacement_is_bounded() {
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((1000.0, 0.0));
let w = WiggleParams { size: 7.0, detail: 50.0, smooth: false, ..Default::default() };
for t in [0.0, 0.3, 1.7, 9.1] {
let r = wiggle(std::slice::from_ref(&l), &w, t);
let pts = on_curve(&r[0]);
assert_eq!(pts.len(), 52);
assert!(pts.iter().all(|p| p.y.abs() <= 7.0 * 1.0001));
assert!(pts.iter().any(|p| p.y.abs() > 0.1));
}
}
#[test]
fn wiggle_full_correlation_translates() {
let s = [sq(0.0, 0.0, 100.0)];
let w = WiggleParams { correlation: 100.0, smooth: false, ..Default::default() };
let r = wiggle(&s, &w, 0.4);
assert!(close(area(&r), 10000.0, 1e-6));
let zero = wiggle(&s, &WiggleParams { size: 0.0, ..w }, 1.0);
assert_eq!(zero[0], s[0]);
}
// ---------------------------------------------------------------- merge / booleans
fn inclusion_exclusion(a: BezPath, b: BezPath) {
let aa = area(std::slice::from_ref(&a));
let ab = area(std::slice::from_ref(&b));
let pair = [a, b];
let u = area(&[merge(&pair, MergeMode::Add)]);
let i = area(&[merge(&pair, MergeMode::Intersect)]);
let s = area(&[merge(&pair, MergeMode::Subtract)]);
let x = area(&[merge(&pair, MergeMode::Exclude)]);
let tol = 1e-3 * (aa + ab);
assert!(i > 1.0, "overlapping");
assert!(close(u, aa + ab - i, tol), "union {u} vs {aa}+{ab}-{i}");
assert!(close(s, aa - i, tol), "subtract {s}");
assert!(close(x, u - i, tol), "exclude {x}");
}
#[test]
fn merge_rects_inclusion_exclusion() {
inclusion_exclusion(sq(0.0, 0.0, 100.0), sq(50.0, 30.0, 100.0));
let i = merge(&[sq(0.0, 0.0, 100.0), sq(50.0, 30.0, 100.0)], MergeMode::Intersect);
assert!(close(area(&[i]), 50.0 * 70.0, 1e-6));
}
#[test]
fn merge_circles_inclusion_exclusion() {
inclusion_exclusion(circle(0.0, 0.0, 50.0), circle(40.0, 10.0, 35.0));
// Curves are preserved: the union of two circles has few vertices.
let u = merge(&[circle(0.0, 0.0, 50.0), circle(40.0, 10.0, 35.0)], MergeMode::Add);
assert!(vertex_count(&u) < 20, "{}", vertex_count(&u));
}
#[test]
fn merge_modes_edge_cases() {
let a = sq(0.0, 0.0, 10.0);
let b = sq(100.0, 0.0, 10.0);
assert!(area(&[merge(&[a.clone(), b.clone()], MergeMode::Intersect)]) < 1e-9);
assert!(close(area(&[merge(&[a.clone(), b.clone()], MergeMode::Add)]), 200.0, 1e-6));
let m = merge(&[a.clone(), b.clone()], MergeMode::Merge);
assert_eq!(m.elements().len(), a.elements().len() + b.elements().len());
// Subtract with three operands: first minus the rest.
let s = merge(&[sq(0.0, 0.0, 100.0), sq(0.0, 0.0, 50.0), sq(50.0, 50.0, 50.0)], MergeMode::Subtract);
assert!(close(area(&[s]), 5000.0, 1e-6));
assert!(merge(&[], MergeMode::Add).elements().is_empty());
}
#[test]
fn normalize_and_filled_area() {
// Two same-direction overlapping squares in one path: non-zero fills the union.
let mut p = sq(0.0, 0.0, 100.0);
p.extend(sq(50.0, 0.0, 100.0).elements().iter().copied());
assert!(close(crate::boolean::filled_area(&p, FillRule::NonZero), 15000.0, 1e-6));
assert!(close(crate::boolean::filled_area(&p, FillRule::EvenOdd), 10000.0, 1e-6));
let n = crate::boolean::normalize(&p, FillRule::EvenOdd);
assert!(close(n.area().abs(), 10000.0, 1e-6));
}