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 = 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 = res.palette.iter().map(|c| c[0]).collect(); tones.sort_unstable(); assert_eq!(tones, (32..=39).collect::>()); 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 = 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 { 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 = 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::>()); 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 = 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 = (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 { 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 = 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 = 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::>(); // 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> = 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 = (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::>()); } // 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::>() }; // 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:?}"); } /// Create Strokes: a line cut square at its ends is stroked with square caps, a line with round /// ends with round ones, and either ends where its ink does. #[test] fn stroke_caps_are_square_or_round_as_the_ink_is() { let line = |square: bool| { let img = Raster::from_fn(140, 40, |x, y| { // Anti-aliased by 4 × 4 samples: a bar 8 px thick from x = 20 to 110. let mut inside = 0; for sy in 0..4 { for sx in 0..4 { let (px, py) = (f64::from(x) + (f64::from(sx) + 0.5) / 4.0, f64::from(y) + (f64::from(sy) + 0.5) / 4.0); let d = if square { (py - 20.3).abs().max((px - 65.0).abs() - 41.0) } else { (px.clamp(24.0, 106.0) - px).hypot(py - 20.3) }; inside += u32::from(d <= 4.0); } } let v = (255 - inside * 255 / 16) as u8; [v, v, v, 255] }); let res = trace(&img, &crate::preset("Outline").unwrap()); let [only] = res.paths.as_slice() else { panic!("{:?}", res.paths) }; let (a, b) = (only.path.subpaths[0].anchors[0].p, only.path.subpaths[0].anchors.last().unwrap().p); // Either cap goes half the width past the path's end: the path runs from 24 to 106. assert!((a.x.min(b.x) - 24.0).abs() < 0.6 && (a.x.max(b.x) - 106.0).abs() < 0.6, "{a:?} {b:?}"); assert!((only.stroke.unwrap() - 8.0).abs() < 0.4); only.square_caps }; assert!(line(true)); assert!(!line(false)); }