1268 lines
57 KiB
Rust
1268 lines
57 KiB
Rust
use super::*;
|
||
use vectorcraft_geom::Point;
|
||
|
||
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 color_mode_separates_close_dark_tones() {
|
||
// Eight stripes one level apart (32..=39): a fixed 5-bit-per-channel histogram put them all in
|
||
// one bin, so `colors: 8` gave a single colour and a single path.
|
||
let img = Raster::from_fn(128, 32, |x, _| {
|
||
let v = 32 + (x / 16) as u8;
|
||
[v, v, v, 255]
|
||
});
|
||
let params = TraceParams { mode: Mode::Color, colors: 8, noise: 0, method: Method::Abutting, ..TraceParams::default() };
|
||
let res = trace(&img, ¶ms);
|
||
let mut tones: Vec<u8> = res.palette.iter().map(|c| c[0]).collect();
|
||
tones.sort_unstable();
|
||
assert_eq!(tones, (32..=39).collect::<Vec<u8>>());
|
||
assert_eq!(res.paths.len(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn color_mode_full_range_and_transparent_inputs() {
|
||
// Full-range input keeps working.
|
||
let img = Raster::from_fn(256, 4, |x, _| [x as u8, 255 - x as u8, 0, 255]);
|
||
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
assert!((2..=6).contains(&q.palette.len()), "{}", q.palette.len());
|
||
// A fully transparent image has no traced pixel and does not fail.
|
||
let clear = Raster::from_fn(8, 8, |_, _| [10, 20, 30, 0]);
|
||
let q = quantize(&clear, &TraceParams { mode: Mode::Color, colors: 4, ..TraceParams::default() });
|
||
assert!(q.labels.iter().all(|&l| l == TRANSPARENT));
|
||
// Deterministic.
|
||
let a = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
let b = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[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. (Hard pixels place it to half a pixel only, and its
|
||
// ends sit on staircase treads: jags far along it would be more than "slightly" off the line.)
|
||
let img = Raster::from_fn(200, 100, |x, y| {
|
||
if y as f64 > 30.0 + (x as f64 * 0.05) + if (5..7).contains(&(x % 37)) && x < 120 { 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(), 15);
|
||
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_eq!(preset("flat logo").unwrap().mode, Mode::Logo);
|
||
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() {
|
||
// Flat Logo refuses a gradient on purpose (see logo::tests); every other preset traces it.
|
||
if p.mode == Mode::Logo {
|
||
continue;
|
||
}
|
||
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");
|
||
}
|
||
|
||
/// Stripes of `n` colours, each with a few dots of the next one: many layers, many small shapes.
|
||
fn stripes(size: u32, n: u32) -> Raster {
|
||
let colour = |i: u32| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 53 % 256) as u8, 255];
|
||
Raster::from_fn(size, size, |x, y| {
|
||
let band = x * n / size;
|
||
if (x % 9 < 3) && (y % 9 < 3) { colour(band + 1) } else { colour(band) }
|
||
})
|
||
}
|
||
|
||
/// #525: past the anchor budget the trace gives up with advice instead of making millions of
|
||
/// anchors; under it, it is the plain trace.
|
||
#[test]
|
||
fn a_trace_past_its_anchor_budget_is_refused() {
|
||
let img = stripes(120, 8);
|
||
let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() };
|
||
let full = trace(&img, &p);
|
||
let anchors = full.anchor_count();
|
||
assert!(anchors > 100, "{anchors} anchors");
|
||
assert!(matches!(trace_within(&img, &p, anchors / 2), Err(TraceError::TooComplex { max }) if max == anchors / 2));
|
||
assert_eq!(trace_within(&img, &p, anchors).unwrap(), full);
|
||
}
|
||
|
||
/// Layers traced in parallel come out in the same order every time: bottom (largest) first.
|
||
#[test]
|
||
fn parallel_layers_keep_their_order() {
|
||
let img = stripes(90, 12);
|
||
let p = TraceParams { mode: Mode::Color, colors: 24, noise: 1, method: Method::Overlapping, ..TraceParams::default() };
|
||
let a = trace(&img, &p);
|
||
assert!(a.palette.len() > 8, "{} colours", a.palette.len());
|
||
for _ in 0..4 {
|
||
assert_eq!(trace(&img, &p), a);
|
||
}
|
||
// Overlapping: each layer's shapes cover the layers above, so the pixel counts fall upwards.
|
||
let firsts: Vec<usize> = a.palette.iter().filter_map(|c| a.paths.iter().find(|t| t.color == *c).map(|t| t.pixels)).collect();
|
||
assert!(firsts.windows(2).all(|w| w[0] >= w[1]), "{firsts:?}");
|
||
}
|
||
|
||
/// A PNG whose header says `w` × `h` pixels, with no image data.
|
||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||
fn crc(bytes: &[u8]) -> u32 {
|
||
let mut c = !0u32;
|
||
for &b in bytes {
|
||
c ^= u32::from(b);
|
||
for _ in 0..8 {
|
||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||
}
|
||
}
|
||
!c
|
||
}
|
||
let mut ihdr = b"IHDR".to_vec();
|
||
ihdr.extend(w.to_be_bytes());
|
||
ihdr.extend(h.to_be_bytes());
|
||
ihdr.extend([8, 6, 0, 0, 0]);
|
||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||
for chunk in [ihdr, b"IDAT".to_vec(), b"IEND".to_vec()] {
|
||
png.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||
png.extend(&chunk);
|
||
png.extend(crc(&chunk).to_be_bytes());
|
||
}
|
||
png
|
||
}
|
||
|
||
/// An image too large to trace is refused from its header, before it is decoded.
|
||
#[test]
|
||
fn an_image_too_large_to_trace_is_refused_before_decoding() {
|
||
let err = Raster::decode(&png_header(30_000, 30_000)).unwrap_err();
|
||
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}");
|
||
assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding");
|
||
}
|
||
|
||
/// An image over the tracer's size is scaled down to fit rather than refused; only one too large
|
||
/// to decode at all is refused, from its header.
|
||
#[test]
|
||
fn decode_to_fit_scales_an_oversized_image_down() {
|
||
// Within the limit: decoded as it is.
|
||
let img = disc(32, 16.0, 16.0, 10.0);
|
||
assert_eq!(Raster::decode_to_fit(&img.encode_png()).unwrap(), img);
|
||
// 8958 × 8958 (a 300 ppi scan, 80 megapixels) fits in the limit with its aspect kept.
|
||
let (w, h) = crate::fit_size(8958, 8958, MAX_PIXELS);
|
||
assert_eq!(w, h);
|
||
assert!(u64::from(w) * u64::from(h) <= MAX_PIXELS && w > 8000, "{w}");
|
||
let (w, h) = crate::fit_size(40_000, 3000, MAX_PIXELS);
|
||
assert!(u64::from(w) * u64::from(h) <= MAX_PIXELS && (f64::from(w) / f64::from(h) - 40.0 / 3.0).abs() < 0.01, "{w} × {h}");
|
||
assert_eq!(crate::fit_size(100_000, 1, 10), (10, 1));
|
||
assert_eq!(crate::fit_size(640, 480, MAX_PIXELS), (640, 480));
|
||
// Past the decoding limit: refused before decoding.
|
||
let err = Raster::decode_to_fit(&png_header(30_000, 30_000)).unwrap_err();
|
||
assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, max: 192 }), "{err}");
|
||
// Between the two limits the header is accepted and decoding goes on (this one has no pixels).
|
||
assert!(matches!(Raster::decode_to_fit(&png_header(9000, 9000)), Err(TraceError::Decode(_))));
|
||
}
|
||
|
||
/// Four flat colours with anti-aliased (blended) edges between the quadrants.
|
||
fn flat_quads() -> Raster {
|
||
const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]];
|
||
Raster::from_fn(100, 100, |x, y| {
|
||
let at = |x: u32, y: u32| C[usize::from(x >= 50) + 2 * usize::from(y >= 50)];
|
||
let (a, b) = (at(x, y), at(x.saturating_sub(1), y.saturating_sub(1)));
|
||
// Blend the pixels along the quadrant edges, as an anti-aliased picture has them.
|
||
let c: Vec<u8> = a.iter().zip(b).map(|(p, q)| ((u16::from(*p) + u16::from(q)) / 2) as u8).collect();
|
||
[c[0], c[1], c[2], 255]
|
||
})
|
||
}
|
||
|
||
/// A smooth two-dimensional colour gradient (continuous tone).
|
||
fn gradient() -> Raster {
|
||
Raster::from_fn(128, 128, |x, y| [(x * 2) as u8, (y * 2) as u8, (255 - x - y / 2) as u8, 255])
|
||
}
|
||
|
||
fn palette(img: &Raster, palette: Palette, colors: u32, color_detail: f64) -> Vec<[u8; 3]> {
|
||
let p = TraceParams { mode: Mode::Color, palette, colors, color_detail, noise: 4, ..TraceParams::default() };
|
||
trace(img, &p).palette
|
||
}
|
||
|
||
#[test]
|
||
fn palettes_parse_from_their_ids_and_names() {
|
||
for (id, want) in [
|
||
("limited", Palette::Limited),
|
||
("fullTone", Palette::FullTone),
|
||
("Automatic", Palette::Automatic),
|
||
("documentLibrary", Palette::DocumentLibrary),
|
||
("library", Palette::DocumentLibrary),
|
||
] {
|
||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "palette": id })).unwrap();
|
||
assert_eq!(p.palette, want, "{id}");
|
||
}
|
||
assert_eq!(TraceParams::default().palette, Palette::Limited);
|
||
// The library's colours are looked up at every trace, not saved with the settings.
|
||
let v = serde_json::to_value(TraceParams { swatches: vec![[1, 2, 3]], ..TraceParams::default() }).unwrap();
|
||
assert!(v.get("swatches").is_none() && v["library"] == "document" && v["colorDetail"] == 50.0, "{v}");
|
||
}
|
||
|
||
#[test]
|
||
fn full_tone_follows_the_tones_the_image_has() {
|
||
// Flat art keeps its few colours (and the tones of its blended edges); a gradient gets many,
|
||
// more with more detail.
|
||
let flat = palette(&flat_quads(), Palette::FullTone, 6, 50.0);
|
||
assert!((4..=10).contains(&flat.len()), "flat art: {flat:?}");
|
||
let few = palette(&gradient(), Palette::FullTone, 6, 10.0).len();
|
||
let many = palette(&gradient(), Palette::FullTone, 6, 90.0).len();
|
||
assert!(few > 6 && many > few && many <= FULL_TONE_MAX, "{few} then {many} colours");
|
||
}
|
||
|
||
#[test]
|
||
fn automatic_traces_flat_art_with_its_colours_and_photos_in_full_tone() {
|
||
let flat = palette(&flat_quads(), Palette::Automatic, 2, 50.0);
|
||
assert_eq!(flat.len(), 4, "the four flat colours, whatever `colors` says: {flat:?}");
|
||
assert!(flat.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60), "red is one of them: {flat:?}");
|
||
let tone = palette(&gradient(), Palette::Automatic, 2, 50.0);
|
||
assert_eq!(tone.len(), palette(&gradient(), Palette::FullTone, 2, 50.0).len(), "continuous tone: as Full Tone");
|
||
}
|
||
|
||
#[test]
|
||
fn document_library_uses_the_most_used_swatches_exactly() {
|
||
let swatches = vec![[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 0, 0], [255, 255, 255]];
|
||
let p = |colors| TraceParams {
|
||
mode: Mode::Color,
|
||
palette: Palette::DocumentLibrary,
|
||
colors,
|
||
swatches: swatches.clone(),
|
||
noise: 4,
|
||
..TraceParams::default()
|
||
};
|
||
let hard = Raster::from_fn(100, 100, |x, y| 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 res = trace(&hard, &p(6));
|
||
let mut got = res.palette.clone();
|
||
got.sort_unstable();
|
||
assert_eq!(got, [[0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0]], "the nearest swatch of each colour, as it is");
|
||
assert!(res.paths.iter().all(|t| swatches.contains(&t.color)));
|
||
// At most `colors` of them: the ones the most pixels are nearest to.
|
||
let two = trace(&hard, &p(2)).palette;
|
||
assert!(two.len() <= 2 && two.iter().all(|c| swatches.contains(c)), "{two:?}");
|
||
// Without library colours, it traces as Limited.
|
||
let none = TraceParams { swatches: vec![], ..p(4) };
|
||
assert_eq!(trace(&hard, &none).palette.len(), 4);
|
||
}
|
||
|
||
fn strokes(fills: bool) -> TraceParams {
|
||
TraceParams { strokes: true, fills, stroke_width: 6.0, noise: 4, ..bw() }
|
||
}
|
||
|
||
/// Endpoints (first and last anchor) of each open subpath of the stroked paths.
|
||
fn stroked(res: &TraceResult) -> Vec<(&TracedPath, &vectorcraft_geom::SubPath)> {
|
||
res.paths.iter().filter(|p| p.stroke.is_some()).flat_map(|p| p.path.subpaths.iter().map(move |s| (p, s))).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_line_traces_as_one_stroked_centre_line() {
|
||
// A 3 px wide diagonal line from (20, 20) to (180, 100).
|
||
let img = Raster::from_fn(200, 120, |x, y| {
|
||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||
let t = (((px - 20.0) * 160.0 + (py - 20.0) * 80.0) / (160.0f64.powi(2) + 80.0f64.powi(2))).clamp(0.0, 1.0);
|
||
let (cx, cy) = (20.0 + 160.0 * t, 20.0 + 80.0 * t);
|
||
if (px - cx).hypot(py - cy) <= 1.5 { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &strokes(true));
|
||
assert_eq!(res.paths.len(), 1, "{:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
|
||
let p = &res.paths[0];
|
||
let width = p.stroke.expect("stroked");
|
||
assert!((2.0..=4.0).contains(&width), "width {width}");
|
||
assert_eq!(p.path.subpaths.len(), 1);
|
||
let sp = &p.path.subpaths[0];
|
||
assert!(!sp.closed);
|
||
let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
|
||
let (a, b) = if a.x < b.x { (a, b) } else { (b, a) };
|
||
assert!(a.distance(Point::new(20.0, 20.0)) < 3.0 && b.distance(Point::new(180.0, 100.0)) < 3.0, "{a:?} … {b:?}");
|
||
// A straight line: a few anchors, all on it.
|
||
assert!(sp.anchors.len() <= 4, "{} anchors", sp.anchors.len());
|
||
// Without Create Strokes, the same line is a filled outline.
|
||
let filled = trace(&img, &bw());
|
||
assert!(filled.paths.iter().all(|p| p.stroke.is_none()) && filled.paths[0].path.subpaths[0].closed);
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_ring_traces_as_a_closed_centre_line() {
|
||
let img = Raster::from_fn(120, 120, |x, y| {
|
||
let d = (x as f64 + 0.5 - 60.0).hypot(y as f64 + 0.5 - 60.0);
|
||
if (d - 40.0).abs() <= 1.5 { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &strokes(true));
|
||
let lines = stroked(&res);
|
||
assert_eq!(lines.len(), 1, "one ring");
|
||
let sp = lines[0].1;
|
||
assert!(sp.closed);
|
||
let len = PathData::single(sp.clone()).length();
|
||
let want = std::f64::consts::TAU * 40.0;
|
||
assert!((len - want).abs() / want < 0.05, "length {len} vs {want}");
|
||
}
|
||
|
||
#[test]
|
||
fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
|
||
// A disc (r = 20) and, apart from it, a plus sign of 3 px wide bars.
|
||
let img = Raster::from_fn(200, 100, |x, y| {
|
||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||
let disc = (px - 50.0).hypot(py - 50.0) <= 20.0;
|
||
let plus = ((px - 150.0).abs() <= 1.5 && (20.0..=80.0).contains(&py)) || ((py - 50.0).abs() <= 1.5 && (120.0..=180.0).contains(&px));
|
||
if disc || plus { BLACK } else { WHITE }
|
||
});
|
||
let res = trace(&img, &strokes(true));
|
||
let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect();
|
||
assert_eq!(fills.len(), 1, "the disc");
|
||
let a = net_area(&fills[0].path);
|
||
assert!((a - std::f64::consts::PI * 400.0).abs() < 60.0, "disc area {a}");
|
||
let lines = stroked(&res);
|
||
assert!((2..=4).contains(&lines.len()), "{} centre lines", lines.len());
|
||
// The centre lines stop about half a width short of each end of the bars (their round caps
|
||
// cover it).
|
||
let total: f64 = lines.iter().map(|(_, s)| PathData::single((*s).clone()).length()).sum();
|
||
assert!((106.0..=120.0).contains(&total), "both bars' length: {total}");
|
||
let ends: Vec<Point> = lines.iter().flat_map(|(_, s)| [s.anchors[0].p, s.anchors[s.anchors.len() - 1].p]).collect();
|
||
for want in [Point::new(150.0, 20.0), Point::new(150.0, 80.0), Point::new(120.0, 50.0), Point::new(180.0, 50.0)] {
|
||
assert!(ends.iter().any(|e| e.distance(want) < 4.0), "a line ends near {want:?}: {ends:?}");
|
||
}
|
||
// Strokes only: the disc is outlined.
|
||
let res = trace(&img, &strokes(false));
|
||
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
|
||
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
|
||
}
|
||
|
||
#[test]
|
||
fn create_settings_parse_with_defaults() {
|
||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();
|
||
assert!(p.fills && p.strokes && p.stroke_width == 4.0);
|
||
let d = TraceParams::default();
|
||
assert!(d.fills && !d.strokes && d.stroke_width == 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn create_strokes_survives_noise_and_tiny_images() {
|
||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 7;
|
||
seed ^= seed << 17;
|
||
seed
|
||
};
|
||
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
|
||
let noise = Raster::new(120, 120, noise);
|
||
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
|
||
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
|
||
for noise in [0, 1, 4] {
|
||
let p = TraceParams { strokes: true, fills, stroke_width: width, noise, ..TraceParams::default() };
|
||
for t in trace(&img, &p).paths {
|
||
assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 0.25), "{:?}", t.stroke);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The 4-connected same-label components of `labels` (transparent pixels left out): each one's size
|
||
/// and whether it touches a pixel of another opaque label.
|
||
fn components(labels: &[u16], w: usize, h: usize) -> Vec<(usize, bool)> {
|
||
let mut seen = vec![false; w * h];
|
||
let mut out = vec![];
|
||
for start in 0..w * h {
|
||
if seen[start] || labels[start] == TRANSPARENT {
|
||
continue;
|
||
}
|
||
let l = labels[start];
|
||
let (mut size, mut touches, mut stack) = (0, false, vec![start]);
|
||
seen[start] = true;
|
||
while let Some(i) = stack.pop() {
|
||
size += 1;
|
||
let (x, y) = (i % w, i / w);
|
||
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||
for j in near.into_iter().flatten() {
|
||
if labels[j] == l {
|
||
if !seen[j] {
|
||
seen[j] = true;
|
||
stack.push(j);
|
||
}
|
||
} else if labels[j] != TRANSPARENT {
|
||
touches = true;
|
||
}
|
||
}
|
||
}
|
||
out.push((size, touches));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// #819: a speck merged into a small region that is itself merged later was left behind as a new
|
||
/// island of the small region's old label.
|
||
#[test]
|
||
fn denoise_leaves_no_island_behind_a_merged_region() {
|
||
// One row: a 1-pixel speck, a 2-pixel region, then a large one; nothing under 3 pixels may stay.
|
||
let mut labels = vec![0, 1, 1, 2, 2, 2, 2, 2];
|
||
denoise(&mut labels, 8, 1, 3);
|
||
assert_eq!(labels, vec![2; 8]);
|
||
}
|
||
|
||
/// On noise, Remove Noise leaves no region under the minimum that could be merged, and a larger
|
||
/// minimum never leaves more regions (#819: more noise removal gave more paths).
|
||
#[test]
|
||
fn denoise_merges_every_small_region_and_more_noise_never_means_more_regions() {
|
||
let (w, h) = (48, 40);
|
||
let mut seed = 0x2545_f491_u32;
|
||
let mut next = || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
seed
|
||
};
|
||
for colors in [2u16, 3, 5, 8] {
|
||
// Blocky noise: random 1-4 pixel runs, so regions of every size touch each other.
|
||
let mut base = vec![0u16; w * h];
|
||
let mut i = 0;
|
||
while i < w * h {
|
||
let (l, run) = ((next() % u32::from(colors)) as u16, 1 + next() as usize % 4);
|
||
for p in base.iter_mut().skip(i).take(run) {
|
||
*p = l;
|
||
}
|
||
i += run;
|
||
}
|
||
let mut last = usize::MAX;
|
||
for min_area in [1usize, 2, 4, 8, 16, 32, 64, 128] {
|
||
let mut labels = base.clone();
|
||
denoise(&mut labels, w, h, min_area);
|
||
let comps = components(&labels, w, h);
|
||
if min_area > 1 {
|
||
assert!(
|
||
comps.iter().all(|&(size, touches)| size >= min_area || !touches),
|
||
"{colors} colours, min {min_area}: a region under the minimum is left: {comps:?}"
|
||
);
|
||
}
|
||
assert!(comps.len() <= last, "{colors} colours: min {min_area} leaves {} regions, more than the {last} a smaller one left", comps.len());
|
||
last = comps.len();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #819: on noisy scan-like images, raising Noise never gives more paths. Before the fix, these
|
||
/// two went from 70 paths to 126 to 206 as Noise rose from 50 to 200 (and 22 to 25).
|
||
#[test]
|
||
fn more_noise_removal_never_gives_more_paths() {
|
||
for (seed0, colors) in [(15u32, 2usize), (9, 6)] {
|
||
let mut seed = seed0.wrapping_mul(0x9e37_79b9) | 1;
|
||
let mut next = move || {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
seed
|
||
};
|
||
let ink: Vec<[u8; 4]> = (0..colors)
|
||
.map(|_| {
|
||
let v = next();
|
||
[(v & 255) as u8, (v >> 8 & 255) as u8, (v >> 16 & 255) as u8, 255]
|
||
})
|
||
.collect();
|
||
// Diagonal bands of ink with a quarter of the pixels speckled at random.
|
||
let (w, h) = (121, 101);
|
||
let px: Vec<[u8; 4]> = (0..w * h)
|
||
.map(|i| {
|
||
let band = ((i % w) / 7 + (i / w) / 5) % colors;
|
||
if next() % 4 == 0 { ink[next() as usize % colors] } else { ink[band] }
|
||
})
|
||
.collect();
|
||
let img = Raster::from_fn(120, 100, |x, y| px[y as usize * w + x as usize]);
|
||
let mut last = (0, usize::MAX);
|
||
for noise in [1, 4, 8, 12, 25, 50, 100, 200] {
|
||
let n = trace(&img, &TraceParams { noise, ..TraceParams::default() }).paths.len();
|
||
assert!(n <= last.1, "image {seed0}: Noise {noise} gives {n} paths, more than the {} of Noise {}", last.1, last.0);
|
||
last = (noise, n);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A shape drawn anti-aliased: each pixel's ink is the share of its 8 × 8 samples that `inside` holds.
|
||
fn soft(w: u32, h: u32, inside: impl Fn(f64, f64) -> bool) -> Raster {
|
||
Raster::from_fn(w, h, |x, y| {
|
||
let n = (0..64).filter(|i| inside(f64::from(x) + (f64::from(i % 8) + 0.5) / 8.0, f64::from(y) + (f64::from(i / 8) + 0.5) / 8.0)).count();
|
||
let v = (255.0 * (1.0 - n as f64 / 64.0)).round() as u8;
|
||
[v, v, v, 255]
|
||
})
|
||
}
|
||
|
||
/// Points along every curve of `p`.
|
||
fn along(p: &vectorcraft_geom::PathData) -> Vec<Point> {
|
||
use vectorcraft_geom::ParamCurve;
|
||
p.subpaths
|
||
.iter()
|
||
.flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..16).map(move |k| sp.segment(i).eval(f64::from(k) / 16.0))))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn an_anti_aliased_disc_is_traced_to_a_fraction_of_a_pixel() {
|
||
let (c, r) = (Point::new(60.3, 59.6), 40.4);
|
||
let res = trace(&soft(120, 120, |x, y| Point::new(x, y).distance(c) <= r), &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let off = along(&res.paths[0].path).iter().map(|p| (p.distance(c) - r).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.12, "{off} px off the circle");
|
||
// Smooth all the way round, with a handful of anchors.
|
||
assert!(res.paths[0].path.anchor_count() <= 8, "{} anchors", res.paths[0].path.anchor_count());
|
||
assert!(res.paths[0].path.subpaths[0].anchors.iter().all(|a| a.kind == vectorcraft_geom::AnchorKind::Smooth));
|
||
}
|
||
|
||
#[test]
|
||
fn an_anti_aliased_square_has_four_corners_and_straight_sides() {
|
||
// Edges between pixel rows and columns, turned a little so that none follows the grid.
|
||
let (c, half, turn) = (Point::new(50.3, 49.7), 30.2, 0.2f64);
|
||
let inside = |x: f64, y: f64| {
|
||
let (dx, dy) = (x - c.x, y - c.y);
|
||
(dx * turn.cos() + dy * turn.sin()).abs() <= half && (dy * turn.cos() - dx * turn.sin()).abs() <= half
|
||
};
|
||
let res = trace(&soft(100, 100, inside), &bw());
|
||
let sp = &res.paths[0].path.subpaths[0];
|
||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||
for a in &sp.anchors {
|
||
let (dx, dy) = (a.p.x - c.x, a.p.y - c.y);
|
||
let (u, v) = (dx * turn.cos() + dy * turn.sin(), dy * turn.cos() - dx * turn.sin());
|
||
assert!((u.abs() - half).abs() < 0.15 && (v.abs() - half).abs() < 0.15, "corner {:?} is off", a.p);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_thin_anti_aliased_line_keeps_its_place_and_width() {
|
||
// 1.2 px wide, lying across two pixel rows.
|
||
let res = trace(&soft(60, 20, |x, y| (5.0..55.0).contains(&x) && (9.7..10.9).contains(&y)), &bw());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let b = res.paths[0].path.bounds().unwrap();
|
||
assert!((b.y0 - 9.7).abs() < 0.1 && (b.y1 - 10.9).abs() < 0.1, "{b:?}");
|
||
assert!((b.x0 - 5.0).abs() < 0.1 && (b.x1 - 55.0).abs() < 0.1, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn touching_colours_get_the_same_edge() {
|
||
// Red left of a slanted edge, blue right of it: where the paths meet, each lies on the edge.
|
||
let edge = |y: f64| 30.3 + 0.2 * y;
|
||
let img = Raster::from_fn(60, 40, |x, y| {
|
||
let t = (edge(f64::from(y) + 0.5) - f64::from(x)).clamp(0.0, 1.0);
|
||
[(200.0 * t + 30.0 * (1.0 - t)) as u8, (40.0 * t + 90.0 * (1.0 - t)) as u8, (50.0 * t + 200.0 * (1.0 - t)) as u8, 255]
|
||
});
|
||
let res = trace(&img, &TraceParams { mode: Mode::Color, colors: 2, ..TraceParams::default() });
|
||
assert_eq!(res.paths.len(), 2);
|
||
for p in &res.paths {
|
||
let inner: Vec<Point> = along(&p.path).into_iter().filter(|q| q.x > 1.0 && q.x < 59.0 && q.y > 1.0 && q.y < 39.0).collect();
|
||
assert!(!inner.is_empty());
|
||
let off = inner.iter().map(|q| (q.x - edge(q.y)).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.12, "{off} px off the shared edge");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hard_edged_disc_is_still_round() {
|
||
let res = trace(&disc(120, 60.0, 60.0, 40.0), &bw());
|
||
let c = Point::new(60.0, 60.0);
|
||
let off = along(&res.paths[0].path).iter().map(|p| (p.distance(c) - 40.0).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.75, "{off} px off the circle");
|
||
assert!(res.paths[0].path.anchor_count() <= 12, "{} anchors", res.paths[0].path.anchor_count());
|
||
}
|
||
|
||
#[test]
|
||
fn a_higher_threshold_widens_a_blurred_shape_smoothly() {
|
||
// A soft-edged disc: its luminance ramps over six pixels.
|
||
let img = Raster::from_fn(100, 100, |x, y| {
|
||
let d = Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5).distance(Point::new(50.0, 50.0));
|
||
let v = (255.0 * ((d - 27.0) / 6.0).clamp(0.0, 1.0)) as u8;
|
||
[v, v, v, 255]
|
||
});
|
||
let radius = |threshold: u8| {
|
||
let res = trace(&img, &TraceParams { threshold, ..bw() });
|
||
let r: Vec<f64> = along(&res.paths[0].path).iter().map(|p| p.distance(Point::new(50.0, 50.0))).collect();
|
||
(r.iter().copied().fold(f64::MAX, f64::min), r.iter().copied().fold(0.0, f64::max))
|
||
};
|
||
let (low, high) = (radius(64), radius(192));
|
||
// Luminance 64 is 1.5 px into the ramp and 192 4.5 px.
|
||
assert!((low.0 - 28.5).abs() < 0.6 && (low.1 - 28.5).abs() < 0.6, "{low:?}");
|
||
assert!((high.0 - 31.5).abs() < 0.6 && (high.1 - 31.5).abs() < 0.6, "{high:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn no_path_of_a_noisy_picture_leaves_the_picture() {
|
||
// Texture and noise: thousands of small ragged regions, where a fit is most likely to stray.
|
||
let img = Raster::from_fn(160, 120, |x, y| {
|
||
let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77);
|
||
v ^= v >> 15;
|
||
v = v.wrapping_mul(0x2C1B_3C6D);
|
||
v ^= v >> 12;
|
||
let wave = 60.0 * ((f64::from(x) * 0.31).sin() * (f64::from(y) * 0.23).cos());
|
||
let c = |k: u32| (128.0 + wave + f64::from((v >> (8 * k)) & 63) + f64::from(x + 2 * y) * 0.2 * f64::from(k)).clamp(0.0, 255.0) as u8;
|
||
[c(0), c(1), c(2), 255]
|
||
});
|
||
for name in PRESET_NAMES {
|
||
let res = trace(&img, &preset(name).unwrap());
|
||
for p in &res.paths {
|
||
// A fit may bulge past the picture's border by its tolerance, never by more.
|
||
let Some(b) = p.path.bounds() else { continue };
|
||
assert!(b.x0 > -2.0 && b.y0 > -2.0 && b.x1 < 162.0 && b.y1 < 122.0, "{name}: a path reaches {b:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A deterministic value in `-1.0..1.0` for a pixel.
|
||
fn jitter(x: u32, y: u32, salt: u32) -> f64 {
|
||
let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ salt.wrapping_mul(0xC2B2_AE3D);
|
||
v ^= v >> 15;
|
||
v = v.wrapping_mul(0x2C1B_3C6D);
|
||
v ^= v >> 12;
|
||
f64::from(v & 0xFFFF) / 32768.0 - 1.0
|
||
}
|
||
|
||
/// Three flat colours (a navy field, a yellow disc, a green bar) drawn anti-aliased, with `noise`
|
||
/// levels of noise per channel.
|
||
fn three_colour_art(noise: f64) -> Raster {
|
||
const NAVY: [f64; 3] = [20.0, 40.0, 90.0];
|
||
const YELLOW: [f64; 3] = [250.0, 200.0, 40.0];
|
||
const GREEN: [f64; 3] = [40.0, 160.0, 90.0];
|
||
Raster::from_fn(120, 120, |x, y| {
|
||
let mut sum = [0.0; 3];
|
||
for i in 0..16 {
|
||
let (sx, sy) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0);
|
||
let c = if Point::new(sx, sy).distance(Point::new(60.3, 45.6)) <= 30.0 {
|
||
YELLOW
|
||
} else if (20.4..100.7).contains(&sx) && (90.3..108.6).contains(&sy) {
|
||
GREEN
|
||
} else {
|
||
NAVY
|
||
};
|
||
for k in 0..3 {
|
||
sum[k] += c[k] / 16.0;
|
||
}
|
||
}
|
||
let c = |k: usize| (sum[k] + noise * jitter(x, y, k as u32)).round().clamp(0.0, 255.0) as u8;
|
||
[c(0), c(1), c(2), 255]
|
||
})
|
||
}
|
||
|
||
fn sixteen_colors() -> TraceParams {
|
||
preset("16 Colors").unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn more_colours_than_flat_art_has_trace_its_own_colours() {
|
||
for noise in [0.0, 8.0] {
|
||
let res = trace(&three_colour_art(noise), &sixteen_colors());
|
||
assert_eq!(res.palette.len(), 3, "noise {noise}: {:?}", res.palette);
|
||
assert_eq!(res.paths.len(), 3, "noise {noise}");
|
||
for want in [[20, 40, 90], [250, 200, 40], [40, 160, 90]] {
|
||
assert!(res.palette.iter().any(|c| (0..3).all(|k| c[k].abs_diff(want[k]) <= 3)), "noise {noise}: no {want:?} in {:?}", res.palette);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_blend_of_two_colours_is_no_sliver_of_a_third() {
|
||
// Halfway from navy to yellow lies an olive nearer to the green of the bar than to either.
|
||
let res = trace(&three_colour_art(0.0), &TraceParams { colors: 3, noise: 0, ..sixteen_colors() });
|
||
let green: Vec<&TracedPath> = res.paths.iter().filter(|p| p.color[1] > p.color[0] && p.color[1] > p.color[2]).collect();
|
||
assert_eq!(green.len(), 1, "the bar, and nothing around the disc");
|
||
let b = green[0].path.bounds().unwrap();
|
||
assert!(b.y0 > 89.0 && b.y1 < 110.0, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn hairlines_keep_their_colour_in_color_mode() {
|
||
// Black lines one pixel wide on white: every black pixel is on an edge, none is a blend.
|
||
let img = Raster::from_fn(80, 80, |x, y| if x % 10 == 4 || y % 10 == 4 { BLACK } else { WHITE });
|
||
let q = quantize(&img, &TraceParams { mode: Mode::Color, colors: 6, ..TraceParams::default() });
|
||
let black = q.palette.iter().position(|c| *c == [0, 0, 0]).unwrap() as u16;
|
||
assert_eq!(q.palette.len(), 2);
|
||
assert!(q.palette.contains(&[255, 255, 255]));
|
||
assert_eq!(q.labels.iter().filter(|&&l| l == black).count(), 8 * 80 * 2 - 64);
|
||
}
|
||
|
||
#[test]
|
||
fn the_bands_of_a_gradient_stay_apart() {
|
||
let img = Raster::from_fn(256, 64, |x, y| [x as u8, (x / 2 + y) as u8, 200 - (x / 2) as u8, 255]);
|
||
let q = quantize(&img, &sixteen_colors());
|
||
assert_eq!(q.palette.len(), 16);
|
||
// With noise as strong as the bands are apart, the bands still follow the gradient.
|
||
let noisy = Raster::from_fn(256, 64, |x, y| {
|
||
let c = |v: u32, k: u32| (f64::from(v) + 6.0 * jitter(x, y, k)).round().clamp(0.0, 255.0) as u8;
|
||
[c(x, 0), c(x / 2 + y, 1), c(200 - x / 2, 2), 255]
|
||
});
|
||
assert!(quantize(&noisy, &sixteen_colors()).palette.len() >= 12);
|
||
}
|
||
|
||
#[test]
|
||
fn a_noisy_gradient_keeps_its_tones_without_specks() {
|
||
// Tones 0–255 across with noise larger than High Fidelity Photo's 64 steps are apart.
|
||
let img = Raster::from_fn(256, 96, |x, y| {
|
||
let c = |k: u32| (f64::from(x) + 5.0 * jitter(x, y, k)).round().clamp(0.0, 255.0) as u8;
|
||
[c(0), c(1), c(2), 255]
|
||
});
|
||
let params = preset("High Fidelity Photo").unwrap();
|
||
let res = trace(&img, ¶ms);
|
||
// Smoothed first, the noise mixes fewer neighbouring tones into one (`quantize` alone works
|
||
// on the picture as it is), and leaves no specks.
|
||
let unsmoothed = quantize(&img, ¶ms).palette.len();
|
||
assert!(res.palette.len() > unsmoothed && res.palette.len() >= 24, "{} colours, {unsmoothed} unsmoothed", res.palette.len());
|
||
assert!(res.paths.len() <= 2 * res.palette.len(), "{} paths of {} colours", res.paths.len(), res.palette.len());
|
||
}
|
||
|
||
#[test]
|
||
fn stacked_layers_go_by_lightness_and_leave_ignored_white_alone() {
|
||
// Stripes: black, white, mid gray, light gray (the widest).
|
||
let img = Raster::from_fn(100, 20, |x, _| {
|
||
let v = match x {
|
||
0..20 => 0,
|
||
20..40 => 255,
|
||
40..60 => 120,
|
||
_ => 200,
|
||
};
|
||
[v, v, v, 255]
|
||
});
|
||
let params = TraceParams { mode: Mode::Grayscale, colors: 4, noise: 0, method: Method::Overlapping, ..TraceParams::default() };
|
||
let tones = |res: &TraceResult| res.paths.iter().map(|p| p.color[0]).collect::<Vec<u8>>();
|
||
// The largest colour is light, so the stack starts at the light end.
|
||
// (Each layer reaches under those above it: under the black stripe too, a part of its own.)
|
||
assert_eq!(tones(&trace(&img, ¶ms)), [255, 200, 200, 120, 120, 0]);
|
||
// Without white, no layer reaches under where it was.
|
||
let res = trace(&img, &TraceParams { ignore_white: true, ..params });
|
||
assert_eq!(tones(&res), [200, 200, 120, 120, 0]);
|
||
for p in &res.paths {
|
||
let b = p.path.bounds().unwrap();
|
||
assert!(b.x1 <= 20.5 || b.x0 >= 39.5, "a {} layer covers the white stripe: {b:?}", p.color[0]);
|
||
}
|
||
}
|
||
|
||
/// The cubic pieces of a path: (from, handle, handle, to).
|
||
fn pieces_of(p: &vectorcraft_geom::PathData) -> Vec<[Point; 4]> {
|
||
p.subpaths
|
||
.iter()
|
||
.flat_map(|sp| {
|
||
(0..sp.segment_count()).map(move |i| {
|
||
let c = sp.segment(i);
|
||
[c.p0, c.p1, c.p2, c.p3]
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn abutting_regions_share_one_curve_along_their_edge() {
|
||
// A disc of two halves on a field: three colours, two junctions where all three meet.
|
||
let color = |x: f64, y: f64| {
|
||
if Point::new(x, y).distance(Point::new(50.3, 49.6)) > 35.2 {
|
||
[255.0, 255.0, 255.0]
|
||
} else if x < 50.3 + 0.2 * (y - 50.0) {
|
||
[200.0, 40.0, 50.0]
|
||
} else {
|
||
[30.0, 90.0, 200.0]
|
||
}
|
||
};
|
||
let img = Raster::from_fn(100, 100, |x, y| {
|
||
let mut sum = [0.0f64; 3];
|
||
for i in 0..16 {
|
||
let c = color(f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0);
|
||
for k in 0..3 {
|
||
sum[k] += c[k] / 16.0;
|
||
}
|
||
}
|
||
[sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255]
|
||
});
|
||
let res = trace(&img, &TraceParams { mode: Mode::Color, colors: 3, ..TraceParams::default() });
|
||
assert_eq!(res.paths.len(), 3);
|
||
let all: Vec<Vec<[Point; 4]>> = res.paths.iter().map(|p| pieces_of(&p.path)).collect();
|
||
// Every curve that is not on the image's border is another path's curve too, run the other way.
|
||
let inner = |c: &[Point; 4]| c.iter().all(|p| p.x > 0.5 && p.x < 99.5 && p.y > 0.5 && p.y < 99.5);
|
||
let mut shared = 0;
|
||
for (i, path) in all.iter().enumerate() {
|
||
for c in path.iter().filter(|c| inner(c)) {
|
||
let back = [c[3], c[2], c[1], c[0]];
|
||
assert!(all.iter().enumerate().any(|(j, other)| j != i && other.contains(&back)), "path {i} has an edge of its own: {c:?}");
|
||
shared += 1;
|
||
}
|
||
}
|
||
assert!(shared >= 12, "{shared} shared curves");
|
||
}
|
||
|
||
#[test]
|
||
fn layers_traced_a_few_at_a_time_come_out_the_same() {
|
||
let items: Vec<usize> = (0..40).collect();
|
||
for at_a_time in [1, 3, usize::MAX] {
|
||
assert_eq!(parallel_map_at_most(at_a_time, &items, |i| i * i), items.iter().map(|i| i * i).collect::<Vec<_>>());
|
||
}
|
||
// A layer is traced within the box of its pixels: a shape far from the image's origin stays there.
|
||
let img = Raster::from_fn(90, 70, |x, y| if (60..80).contains(&x) && (40..60).contains(&y) { BLACK } else { WHITE });
|
||
let b = trace(&img, &bw()).paths[0].path.bounds().unwrap();
|
||
assert!([(b.x0, 60.0), (b.y0, 40.0), (b.x1, 80.0), (b.y1, 60.0)].iter().all(|(got, want)| (got - want).abs() < 0.01), "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_blurred_thin_line_on_unevenly_lit_paper_is_a_few_anchors() {
|
||
// A line 2 px wide a little off the pixel rows, blurred over three pixels, on paper that is a
|
||
// quarter darker at one end than at the other: nowhere is its ink pure black, or the paper white.
|
||
let img = Raster::from_fn(160, 40, |x, y| {
|
||
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||
let paper = 235.0 - 55.0 * fx / 160.0;
|
||
let centre = 17.3 + 0.06 * fx;
|
||
// The share of ink in the pixel, the line's edges blurred linearly over 1.5 px either way.
|
||
let ink = if (10.0..150.0).contains(&fx) { ((1.0 + 1.5 - (fy - centre).abs()) / 3.0).clamp(0.0, 1.0) } else { 0.0 };
|
||
let v = (paper * (1.0 - ink) + 40.0 * ink).round() as u8;
|
||
[v, v, v, 255]
|
||
});
|
||
let res = trace(&img, &preset("Line Art").unwrap());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let path = &res.paths[0].path;
|
||
// Its two long edges are smooth: a ripple would take an anchor every few pixels.
|
||
assert!(path.anchor_count() <= 10, "{} anchors", path.anchor_count());
|
||
// And it lies along the line's middle, about as wide as its ink.
|
||
let off = along(path).iter().filter(|p| p.x > 14.0 && p.x < 146.0).map(|p| ((p.y - (17.3 + 0.06 * p.x)).abs() - 1.0).abs()).fold(0.0, f64::max);
|
||
assert!(off < 0.5, "edges {off} px from where the ink's width puts them");
|
||
}
|
||
|
||
#[test]
|
||
fn ignore_color_leaves_out_the_colour_picked() {
|
||
// Navy field, yellow disc, green bar.
|
||
let img = three_colour_art(0.0);
|
||
let params = TraceParams { colors: 3, ignore_white: true, ..sixteen_colors() };
|
||
// White by default: nothing here is white.
|
||
assert_eq!(trace(&img, ¶ms).paths.len(), 3);
|
||
// The navy picked a little off: the field goes, the disc and the bar stay.
|
||
let res = trace(&img, &TraceParams { ignore_color: Some("#18263c".replace("3c", "5e")), ..params.clone() });
|
||
assert_eq!(res.paths.len(), 2);
|
||
assert!(res.paths.iter().all(|p| p.color != [20, 40, 90]));
|
||
// Not ticked, the colour is not left out; and a colour that is none is refused.
|
||
assert_eq!(trace(&img, &TraceParams { ignore_white: false, ignore_color: Some("#14285a".into()), ..params.clone() }).paths.len(), 3);
|
||
let bad = trace_within(&img, &TraceParams { ignore_color: Some("navy".into()), ..params.clone() }, usize::MAX);
|
||
assert!(matches!(bad, Err(TraceError::BadPalette(_))));
|
||
// Old documents have no such setting: white.
|
||
let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap();
|
||
assert_eq!(old.ignore_color, None);
|
||
assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null);
|
||
}
|
||
|
||
#[test]
|
||
fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() {
|
||
// 120 × 60, turned 1.5° about its centre.
|
||
let (c, t) = (Point::new(100.3, 60.6), 1.5f64.to_radians());
|
||
let img = soft(200, 120, |x, y| {
|
||
let (dx, dy) = (x - c.x, y - c.y);
|
||
(dx * t.cos() + dy * t.sin()).abs() <= 60.0 && (dy * t.cos() - dx * t.sin()).abs() <= 30.0
|
||
});
|
||
let sides = |snap: bool| {
|
||
let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, ..bw() });
|
||
let sp = res.paths[0].path.subpaths[0].clone();
|
||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||
(0..4).map(|i| sp.segment(i)).map(|s| ((s.p3.x - s.p0.x).abs(), (s.p3.y - s.p0.y).abs())).collect::<Vec<_>>()
|
||
};
|
||
// Snapped, every side is exactly level or upright; left alone, it keeps its 1.5°.
|
||
assert!(sides(true).iter().all(|(dx, dy)| *dx == 0.0 || *dy == 0.0), "{:?}", sides(true));
|
||
assert!(sides(false).iter().all(|(dx, dy)| dx.min(*dy) > 0.5), "{:?}", sides(false));
|
||
// And it is still the rectangle: 120 by 60 about the same centre.
|
||
let b = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() }).paths[0].path.bounds().unwrap();
|
||
assert!((b.width() - 120.0).abs() < 1.0 && (b.height() - 60.0).abs() < 1.0 && (b.center().x - c.x).abs() < 0.3, "{b:?}");
|
||
// A side 5° off is no slip of the scanner: it stays.
|
||
let t = 5f64.to_radians();
|
||
let img = soft(200, 120, |x, y| {
|
||
let (dx, dy) = (x - c.x, y - c.y);
|
||
(dx * t.cos() + dy * t.sin()).abs() <= 60.0 && (dy * t.cos() - dx * t.sin()).abs() <= 30.0
|
||
});
|
||
let sp = trace(&img, &TraceParams { snap_curves_to_lines: true, ..bw() }).paths[0].path.subpaths[0].clone();
|
||
assert!(
|
||
(0..sp.segment_count())
|
||
.all(|i| (sp.segment(i).p3.y - sp.segment(i).p0.y).abs() > 1.0 && (sp.segment(i).p3.x - sp.segment(i).p0.x).abs() > 1.0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_straight_anti_aliased_line_is_two_anchors_in_its_middle_as_wide_as_its_ink() {
|
||
// 2.6 px wide, from (20.3, 30.7) to (170.6, 52.2): a little off the pixel rows.
|
||
let (a, b) = (Point::new(20.3, 30.7), Point::new(170.6, 52.2));
|
||
let along_line = |p: Point| ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
|
||
let img = soft(190, 80, |x, y| {
|
||
let p = Point::new(x, y);
|
||
p.distance(a + (b - a) * along_line(p)) <= 1.3
|
||
});
|
||
for noisy in [false, true] {
|
||
// (With grain of ±6 levels too: the line stays two anchors.)
|
||
let img = Raster::from_fn(190, 80, |x, y| {
|
||
let v = f64::from(img.pixel(x, y)[0]) + if noisy { 6.0 * jitter(x, y, 3) } else { 0.0 };
|
||
let v = v.round().clamp(0.0, 255.0) as u8;
|
||
[v, v, v, 255]
|
||
});
|
||
let res = trace(&img, &preset("Outline").unwrap());
|
||
assert_eq!(res.paths.len(), 1);
|
||
let (path, width) = (&res.paths[0].path, res.paths[0].stroke.unwrap());
|
||
assert_eq!(path.anchor_count(), 2, "noisy {noisy}: {path:?}");
|
||
assert!((width - 2.6).abs() < 0.15, "noisy {noisy}: {width} px wide");
|
||
for end in [path.subpaths[0].anchors[0].p, path.subpaths[0].anchors[1].p] {
|
||
// On the line through the true ends, to a tenth of a pixel.
|
||
let off = (end - a).cross(b - a).abs() / (b - a).hypot();
|
||
assert!(off < 0.1, "noisy {noisy}: an end {off} px off the line's middle");
|
||
// And where the ink ends, less the half width its round cap adds (the line here has round ends).
|
||
assert!(end.distance(a).min(end.distance(b)) < 0.6, "noisy {noisy}: an end at {end:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_stroke_is_as_wide_as_its_ink_not_a_whole_number_of_pixels() {
|
||
// (Lines with a pixel that is all ink somewhere: a thinner one's ink is read against its own
|
||
// darkest pixel, and comes out a little wide.)
|
||
for want in [1.7, 2.4, 3.4] {
|
||
let img = soft(120, 40, |x, y| (10.0..110.0).contains(&x) && (y - 20.3).abs() <= want / 2.0);
|
||
let res = trace(&img, &preset("Outline").unwrap());
|
||
let width = res.paths[0].stroke.unwrap();
|
||
assert!((width - want).abs() < 0.08, "{width} for a line {want} px wide");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_anti_aliased_rectangle_along_the_pixel_grid_is_four_anchors() {
|
||
// Its sides lie between pixel columns and rows: its corner pixels are part of two sides each.
|
||
let img = soft(140, 90, |x, y| (20.4..120.7).contains(&x) && (15.3..70.6).contains(&y));
|
||
let res = trace(&img, &bw());
|
||
let sp = &res.paths[0].path.subpaths[0];
|
||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||
let b = res.paths[0].path.bounds().unwrap();
|
||
// (To the eighth of a pixel the test picture is drawn to.)
|
||
assert!((b.x0 - 20.4).abs() < 0.07 && (b.x1 - 120.7).abs() < 0.07 && (b.y0 - 15.3).abs() < 0.07 && (b.y1 - 70.6).abs() < 0.07, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn shapes_come_out_as_shapes_when_asked_for() {
|
||
// A disc, a square along the pixel grid and a rectangle turned 20°, anti-aliased.
|
||
let turn = 20f64.to_radians();
|
||
let turned = |x: f64, y: f64| {
|
||
let (dx, dy) = (x - 180.4, y - 45.3);
|
||
(dx * turn.cos() + dy * turn.sin()).abs() <= 30.0 && (dy * turn.cos() - dx * turn.sin()).abs() <= 18.0
|
||
};
|
||
let img = soft(220, 90, |x, y| {
|
||
Point::new(x, y).distance(Point::new(40.3, 44.6)) <= 25.0 || ((80.4..130.7).contains(&x) && (20.3..70.6).contains(&y)) || turned(x, y)
|
||
});
|
||
let plain = trace(&img, &bw());
|
||
assert!(plain.paths.iter().all(|p| p.shape.is_none()));
|
||
let res = trace(&img, &TraceParams { shapes: true, ..bw() });
|
||
assert_eq!(res.paths.len(), 3);
|
||
let mut found = (false, false, false);
|
||
for p in &res.paths {
|
||
// The path is the shape's own outline.
|
||
assert_eq!(Some(&p.path), p.shape.map(|s| s.path()).as_ref());
|
||
match p.shape {
|
||
Some(TracedShape::Ellipse { center, rx, ry, .. }) => {
|
||
found.0 = center.distance(Point::new(40.3, 44.6)) < 0.15 && (rx - 25.0).abs() < 0.15 && rx == ry
|
||
}
|
||
Some(TracedShape::Rectangle { center, width, height, radius: 0.0, rotation }) if center.x < 150.0 => {
|
||
found.1 = center.distance(Point::new(105.55, 45.45)) < 0.15
|
||
&& (width - 50.3).abs() < 0.25
|
||
&& (height - 50.3).abs() < 0.25
|
||
&& rotation.abs() < 0.3
|
||
}
|
||
Some(TracedShape::Rectangle { center, width, height, radius: 0.0, rotation }) => {
|
||
found.2 = center.distance(Point::new(180.4, 45.3)) < 0.2
|
||
&& (width - 60.0).abs() < 0.3
|
||
&& (height - 36.0).abs() < 0.3
|
||
&& (rotation - 20.0).abs() < 0.3
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
}
|
||
assert_eq!(found, (true, true, true));
|
||
// A letter is no shape, and a ring is not a disc.
|
||
let ring = soft(100, 100, |x, y| (20.0..40.0).contains(&Point::new(x, y).distance(Point::new(50.0, 50.0))));
|
||
assert!(trace(&ring, &TraceParams { shapes: true, ..bw() }).paths.iter().all(|p| p.shape.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_corner_whose_pixel_is_mostly_outside_is_still_a_corner() {
|
||
// Each corner pixel is under half covered (0.6 × 0.7 and less) and traces as outside.
|
||
let img = soft(140, 90, |x, y| (20.4..120.6).contains(&x) && (15.3..70.7).contains(&y));
|
||
let res = trace(&img, &bw());
|
||
let sp = &res.paths[0].path.subpaths[0];
|
||
assert_eq!(sp.anchors.len(), 4, "{sp:?}");
|
||
assert!((0..4).all(|i| sp.segment_is_line(i)));
|
||
let b = res.paths[0].path.bounds().unwrap();
|
||
assert!((b.x0 - 20.4).abs() < 0.07 && (b.x1 - 120.6).abs() < 0.07 && (b.y0 - 15.3).abs() < 0.07 && (b.y1 - 70.7).abs() < 0.07, "{b:?}");
|
||
}
|
||
|
||
/// A circle without anti-aliasing is still a circle, not an ellipse a little wider than tall.
|
||
#[test]
|
||
fn a_hard_edged_circle_is_a_circle() {
|
||
let img = Raster::from_fn(100, 100, |x, y| {
|
||
let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0);
|
||
if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
|
||
});
|
||
let res = trace(&img, &TraceParams { shapes: true, ignore_white: true, ..Default::default() });
|
||
let [TracedPath { shape: Some(TracedShape::Ellipse { center, rx, ry, .. }), .. }] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
|
||
assert!(rx == ry && (rx - 30.0).abs() < 0.3 && center.distance(Point::new(50.0, 50.0)) < 0.1, "{center:?} {rx} {ry}");
|
||
}
|
||
|
||
/// Levelling moves no end far: a long line two degrees off level stays where the image has it.
|
||
#[test]
|
||
fn a_long_line_two_degrees_askew_is_not_levelled() {
|
||
let slope = 2f64.to_radians().tan();
|
||
let img = Raster::from_fn(240, 40, |x, y| {
|
||
let top = 12.0 + slope * (f64::from(x) - 20.0);
|
||
let inside = (20..220).contains(&x) && (f64::from(y) + 0.5 - top - 4.0).abs() <= 4.0;
|
||
if inside { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
|
||
});
|
||
let res = trace(&img, &TraceParams { ignore_white: true, snap_curves_to_lines: true, ..Default::default() });
|
||
let b = res.paths[0].path.bounds().unwrap();
|
||
// 200 px at 2° rises 7 px: levelled, the bar would be 8 px tall, not 15.
|
||
assert!(b.height() > 13.0, "{b:?}");
|
||
}
|