Files
vectorcraft/crates/trace/src/tests.rs
T
bflatasticandClaude Opus 5.5 06242cd3ab Fix #525: Image Trace of a large photo no longer crashes the app
Tracing a 2048 x 2048 photo with High Fidelity Photo made 733,000 paths
and 7.8 million anchors. The selected result's outlines and anchor
squares then made an egui mesh of 1.8 GB, over the GPU's largest buffer:
wgpu panicked while painting and the app quit (or ran out of memory
first, at 6.4 GB).

- Canvas: a selection with more than 100,000 anchors shows its bounds
  instead of every outline and anchor, and the hover highlight skips
  such an object. The Contextual Task Bar and that outline read the
  selection's bounds from a per-revision cache (VectorcraftApp::
  selection_bounds): a selected trace drew a frame in 20 ms, not 170.
- Image Trace: the colour layers are traced in parallel (26 s to 1.9 s
  for that photo; the web traces them in turn). trace_within stops once
  the paths pass an anchor budget; imageTrace.make allows two million
  and otherwise reports the trace as too complex, with what to change.
  Images over 64 megapixels are refused from their header, before they
  are decoded.
- The trace crate's messages are translated (es, it) and scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:18:03 +07:00

351 lines
14 KiB
Rust
Raw Blame History

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