281 lines
11 KiB
Rust
281 lines
11 KiB
Rust
use super::*;
|
|
|
|
const BLACK: [u8; 4] = [0, 0, 0, 255];
|
|
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
|
|
|
fn bw() -> TraceParams {
|
|
TraceParams { ignore_white: true, ..TraceParams::default() }
|
|
}
|
|
|
|
fn disc(size: u32, cx: f64, cy: f64, r: f64) -> Raster {
|
|
Raster::from_fn(size, size, |x, y| {
|
|
let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
|
|
if dx * dx + dy * dy <= r * r { BLACK } else { WHITE }
|
|
})
|
|
}
|
|
|
|
/// Net filled area (outer contours positive, holes negative).
|
|
fn net_area(p: &vectorcraft_geom::PathData) -> f64 {
|
|
p.subpaths.iter().map(|s| s.area()).sum()
|
|
}
|
|
|
|
#[test]
|
|
fn black_disc_is_one_closed_path_with_disc_area() {
|
|
let r = 80.0;
|
|
let res = trace(&disc(200, 100.0, 100.0, r), &bw());
|
|
assert_eq!(res.paths.len(), 1);
|
|
let p = &res.paths[0];
|
|
assert_eq!(p.path.subpaths.len(), 1);
|
|
assert!(p.path.subpaths[0].closed);
|
|
assert_eq!(p.color, [0, 0, 0]);
|
|
let want = std::f64::consts::PI * r * r;
|
|
let got = net_area(&p.path);
|
|
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
|
// Curves, not a pixel staircase.
|
|
assert!(p.path.anchor_count() < 40, "anchors {}", p.path.anchor_count());
|
|
}
|
|
|
|
#[test]
|
|
fn small_disc_area_within_tolerance() {
|
|
let r = 20.0;
|
|
let res = trace(&disc(64, 32.0, 32.0, r), &bw());
|
|
assert_eq!(res.paths.len(), 1);
|
|
let want = std::f64::consts::PI * r * r;
|
|
let got = net_area(&res.paths[0].path);
|
|
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
|
}
|
|
|
|
#[test]
|
|
fn ring_has_outer_and_hole() {
|
|
let img = Raster::from_fn(200, 200, |x, y| {
|
|
let d2 = (x as f64 + 0.5 - 100.0).powi(2) + (y as f64 + 0.5 - 100.0).powi(2);
|
|
if (40.0 * 40.0..=80.0 * 80.0).contains(&d2) { BLACK } else { WHITE }
|
|
});
|
|
let res = trace(&img, &bw());
|
|
assert_eq!(res.paths.len(), 1);
|
|
assert_eq!(res.paths[0].path.subpaths.len(), 2);
|
|
let want = std::f64::consts::PI * (80.0f64.powi(2) - 40.0f64.powi(2));
|
|
let got = net_area(&res.paths[0].path);
|
|
assert!((got - want).abs() / want < 0.03, "area {got} vs {want}");
|
|
// Opposite orientations → fills the same under non-zero.
|
|
let a: Vec<f64> = res.paths[0].path.subpaths.iter().map(|s| s.area()).collect();
|
|
assert!(a[0] * a[1] < 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn stripes_give_one_path_per_stripe() {
|
|
// 5 black stripes, 10 px wide, separated by 10 px of white.
|
|
let img = Raster::from_fn(100, 60, |x, _| if (x / 10) % 2 == 0 { BLACK } else { WHITE });
|
|
let res = trace(&img, &bw());
|
|
assert_eq!(res.paths.len(), 5);
|
|
for p in &res.paths {
|
|
let a = net_area(&p.path);
|
|
assert!((a - 600.0).abs() < 1.0, "stripe area {a}");
|
|
assert_eq!(p.path.anchor_count(), 4, "rectangles keep their 4 corners");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn two_colour_stripes_without_ignore_white_trace_both_colours() {
|
|
let img = Raster::from_fn(100, 60, |x, _| if (x / 10) % 2 == 0 { BLACK } else { WHITE });
|
|
let res = trace(&img, &TraceParams::default());
|
|
assert_eq!(res.paths.len(), 10);
|
|
assert_eq!(res.palette.len(), 2);
|
|
let total: f64 = res.paths.iter().map(|p| net_area(&p.path)).sum();
|
|
assert!((total - 6000.0).abs() < 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn noise_removal_drops_speckles() {
|
|
let img = Raster::from_fn(100, 100, |x, y| {
|
|
let square = (20..60).contains(&x) && (20..60).contains(&y);
|
|
let speck = (x % 13 == 3 && y % 11 == 5) && !(15..65).contains(&x);
|
|
if square || speck { BLACK } else { WHITE }
|
|
});
|
|
let noisy = trace(&img, &TraceParams { noise: 0, ..bw() });
|
|
assert!(noisy.paths.len() > 10, "{} paths", noisy.paths.len());
|
|
let clean = trace(&img, &TraceParams { noise: 10, ..bw() });
|
|
assert_eq!(clean.paths.len(), 1);
|
|
assert!((net_area(&clean.paths[0].path) - 1600.0).abs() < 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn noise_removal_fills_pinholes() {
|
|
let img = Raster::from_fn(60, 60, |x, y| {
|
|
let square = (10..50).contains(&x) && (10..50).contains(&y);
|
|
let hole = x == 30 && y == 30;
|
|
if square && !hole { BLACK } else { WHITE }
|
|
});
|
|
let res = trace(&img, &TraceParams { noise: 4, ..bw() });
|
|
assert_eq!(res.paths.len(), 1);
|
|
assert_eq!(res.paths[0].path.subpaths.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn diagonal_pixels_stay_separate() {
|
|
let img = Raster::from_fn(4, 4, |x, y| if (x, y) == (1, 1) || (x, y) == (2, 2) { BLACK } else { WHITE });
|
|
let res = trace(&img, &TraceParams { noise: 0, ..bw() });
|
|
assert_eq!(res.paths.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn contour_loops_are_consistent() {
|
|
let mask = vec![true, true, true, true, false, true, true, true, true];
|
|
let comps = trace_mask(&mask, 3, 3);
|
|
assert_eq!(comps.len(), 1);
|
|
assert_eq!(comps[0].pixels, 8);
|
|
assert_eq!(comps[0].outer.area2, 18);
|
|
assert_eq!(comps[0].holes.len(), 1);
|
|
assert_eq!(comps[0].holes[0].area2, -2);
|
|
}
|
|
|
|
#[test]
|
|
fn color_mode_palette_size_matches_request() {
|
|
let quad = |x: u32, y: u32| match (x < 50, y < 50) {
|
|
(true, true) => [220, 30, 30, 255],
|
|
(false, true) => [30, 200, 40, 255],
|
|
(true, false) => [30, 40, 210, 255],
|
|
(false, false) => [240, 220, 20, 255],
|
|
};
|
|
let img = Raster::from_fn(100, 100, quad);
|
|
let p4 = trace(&img, &TraceParams { mode: Mode::Color, colors: 4, ..TraceParams::default() });
|
|
assert_eq!(p4.palette.len(), 4);
|
|
assert_eq!(p4.paths.len(), 4);
|
|
let p2 = trace(&img, &TraceParams { mode: Mode::Color, colors: 2, ..TraceParams::default() });
|
|
assert_eq!(p2.palette.len(), 2);
|
|
// Palette colours are close to the originals.
|
|
assert!(p4.palette.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60));
|
|
}
|
|
|
|
#[test]
|
|
fn color_mode_gradient_uses_at_most_n_colors() {
|
|
let img = Raster::from_fn(128, 32, |x, _| [(x * 2) as u8, 255 - (x * 2) as u8, 128, 255]);
|
|
for n in [3, 6, 16] {
|
|
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: n, ..TraceParams::default() });
|
|
assert!(q.palette.len() <= n as usize && q.palette.len() >= 2, "n={n}: {}", q.palette.len());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn grayscale_levels() {
|
|
let img = Raster::from_fn(256, 8, |x, _| [x as u8, x as u8, x as u8, 255]);
|
|
let q = quantize(&img, &TraceParams { mode: Mode::Grayscale, colors: 4, ..TraceParams::default() });
|
|
assert_eq!(q.palette.len(), 4);
|
|
assert!(q.palette.iter().all(|c| c[0] == c[1] && c[1] == c[2]));
|
|
let res = trace(&img, &TraceParams { mode: Mode::Grayscale, colors: 4, noise: 0, ..TraceParams::default() });
|
|
assert_eq!(res.paths.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_layers_stack() {
|
|
// White background with a black square: overlapping → bottom layer covers everything.
|
|
let img = Raster::from_fn(50, 50, |x, y| if (10..30).contains(&x) && (10..30).contains(&y) { BLACK } else { WHITE });
|
|
let ab = trace(&img, &TraceParams::default());
|
|
let ov = trace(&img, &TraceParams { method: Method::Overlapping, ..TraceParams::default() });
|
|
assert_eq!(ab.paths.len(), 2);
|
|
assert_eq!(ov.paths.len(), 2);
|
|
// Abutting: white has a hole; overlapping: white is a full rectangle underneath.
|
|
assert_eq!(ab.paths[0].path.subpaths.len(), 2);
|
|
assert_eq!(ov.paths[0].path.subpaths.len(), 1);
|
|
assert!((net_area(&ov.paths[0].path) - 2500.0).abs() < 1.0);
|
|
assert_eq!(ov.paths[1].color, [0, 0, 0]);
|
|
}
|
|
|
|
#[test]
|
|
fn transparent_pixels_are_not_traced() {
|
|
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [0, 0, 0, 0] } else { BLACK });
|
|
let res = trace(&img, &TraceParams::default());
|
|
assert_eq!(res.paths.len(), 1);
|
|
assert!((net_area(&res.paths[0].path) - 800.0).abs() < 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn threshold_controls_black_and_white() {
|
|
let img = Raster::from_fn(40, 40, |x, _| if x < 20 { [100, 100, 100, 255] } else { WHITE });
|
|
assert_eq!(trace(&img, &TraceParams { threshold: 128, ..bw() }).paths.len(), 1);
|
|
assert_eq!(trace(&img, &TraceParams { threshold: 90, ..bw() }).paths.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn fidelity_changes_anchor_count() {
|
|
let img = Raster::from_fn(200, 200, |x, y| {
|
|
let t = (y as f64 / 12.0).sin() * 20.0 + 100.0;
|
|
if (x as f64) < t { BLACK } else { WHITE }
|
|
});
|
|
let lo = trace(&img, &TraceParams { paths: 0.0, ..bw() }).anchor_count();
|
|
let hi = trace(&img, &TraceParams { paths: 100.0, ..bw() }).anchor_count();
|
|
assert!(hi >= lo, "hi {hi} lo {lo}");
|
|
}
|
|
|
|
#[test]
|
|
fn snap_curves_to_lines_straightens_near_lines() {
|
|
// A slightly jagged, almost straight edge.
|
|
let img = Raster::from_fn(200, 100, |x, y| if y as f64 > 30.0 + (x as f64 * 0.05) + if x % 37 < 2 { 1.0 } else { 0.0 } { BLACK } else { WHITE });
|
|
let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() });
|
|
let sp = &res.paths[0].path.subpaths[0];
|
|
let curves = (0..sp.segment_count()).filter(|&i| !sp.segment_is_line(i)).count();
|
|
assert_eq!(curves, 0, "{sp:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn presets_resolve() {
|
|
assert_eq!(presets().len(), 12);
|
|
for n in PRESET_NAMES {
|
|
assert!(preset(n).is_some(), "{n}");
|
|
}
|
|
assert!(preset("[Default]").is_some());
|
|
assert_eq!(preset("16 colors").unwrap().colors, 16);
|
|
assert_eq!(preset("Shades of Gray").unwrap().mode, Mode::Grayscale);
|
|
assert!(preset("nope").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn params_json_roundtrip_and_partial() {
|
|
let p = preset("6 Colors").unwrap();
|
|
let v = serde_json::to_value(&p).unwrap();
|
|
assert_eq!(v["mode"], "color");
|
|
let back: TraceParams = serde_json::from_value(v).unwrap();
|
|
assert_eq!(back, p);
|
|
let partial: TraceParams = serde_json::from_value(serde_json::json!({"threshold": 90, "ignoreWhite": true})).unwrap();
|
|
assert_eq!(partial.threshold, 90);
|
|
assert!(partial.ignore_white);
|
|
assert_eq!(partial.mode, Mode::BlackAndWhite);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_png() {
|
|
let img = disc(32, 16.0, 16.0, 10.0);
|
|
let bytes = img.encode_png();
|
|
let r = Raster::decode(&bytes).unwrap();
|
|
assert_eq!(r, img);
|
|
assert!(Raster::decode(b"not an image").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn all_presets_trace_a_colour_image() {
|
|
let img = Raster::from_fn(64, 64, |x, y| [(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8, 255]);
|
|
for (name, p) in presets() {
|
|
let r = trace(&img, &p);
|
|
assert!(!r.paths.is_empty() || p.ignore_white, "{name}");
|
|
for tp in &r.paths {
|
|
assert!(tp.path.subpaths.iter().all(|s| s.closed && s.anchors.iter().all(|a| a.p.x.is_finite())));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "timing; run with --release --ignored"]
|
|
fn timing_1000x1000_bw_under_300ms() {
|
|
let img = Raster::from_fn(1000, 1000, |x, y| {
|
|
let (fx, fy) = (x as f64, y as f64);
|
|
let v = (fx / 37.0).sin() * (fy / 23.0).cos() + ((fx + fy) / 91.0).sin() * 0.5;
|
|
if v > 0.2 { BLACK } else { WHITE }
|
|
});
|
|
let t = std::time::Instant::now();
|
|
let res = trace(&img, &TraceParams { ignore_white: true, ..TraceParams::default() });
|
|
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
|
eprintln!("1000x1000 B&W: {} paths, {} anchors in {ms:.1} ms", res.paths.len(), res.anchor_count());
|
|
assert!(!res.paths.is_empty());
|
|
assert!(ms < 300.0, "{ms} ms");
|
|
}
|