diff --git a/crates/trace/tests/shared_boundaries.rs b/crates/trace/tests/shared_boundaries.rs new file mode 100644 index 000000000..89e69768f --- /dev/null +++ b/crates/trace/tests/shared_boundaries.rs @@ -0,0 +1,538 @@ +//! Shared boundaries in Abutting traces: every edge between two neighbouring regions is drawn +//! once, so the two filled paths on either side of it follow one curve (one of them reversed), and +//! together the paths cover the image with no gap and no overlap. +//! +//! That must hold for edges between two junctions, for closed edges with no junction at all (an +//! island inside another region, a hole), and for an edge that leaves a junction and comes back to +//! it (two parts of a region touching only at a pixel corner). No region may vanish on its own: a +//! one-pixel island or hole is kept or dropped together with the hole or island it leaves, and two +//! separate areas of one colour stay two shapes. +//! +//! Every image here is drawn in code from simple geometry (squares, discs, sine waves, stripes), +//! hard-edged (each pixel the colour at its centre) and, where it means something, anti-aliased +//! (each pixel the mean of 8 × 8 samples). The check is geometric and needs no renderer: points +//! along each path's outline must lie on another path's outline (to [`TOL`] px), the paths' areas +//! must add up to the image's, and points on a fine grid must each lie in exactly one path. +//! +//! Behaviour reference: Image Trace's Advanced › Method, Abutting (shapes cut out of each other, +//! meeting edge to edge) as against Overlapping (stacked) +//! (). +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveExtrema, ParamCurveNearest, Point, Rect, Shape}; +use vectorcraft_trace::{Method, Mode, Raster, TraceParams, TraceResult, trace}; + +/// How far (px) a point of one path's outline may lie from its neighbour's outline. +const TOL: f64 = 1e-3; + +/// The drawing colours, by index. Far apart, and no blend of two is near a third. +const COLOURS: [[u8; 3]; 3] = [[255, 255, 255], [200, 40, 50], [30, 90, 200]]; + +/// Black and White's two colours: index 0 is paper, 1 is ink. +const BW: [[u8; 3]; 2] = [[255, 255, 255], [0, 0, 0]]; + +/// A drawing: the colour index at a point of a `w × h` image. +struct Art { + name: &'static str, + w: u32, + h: u32, + at: Box usize>, +} + +impl Art { + fn new(name: &'static str, w: u32, h: u32, at: impl Fn(f64, f64) -> usize + 'static) -> Self { + Self { name, w, h, at: Box::new(at) } + } + /// Each pixel the colour at its centre. + fn labels(&self) -> Vec { + (0..self.h).flat_map(|y| (0..self.w).map(move |x| (x, y))).map(|(x, y)| (self.at)(f64::from(x) + 0.5, f64::from(y) + 0.5)).collect() + } + fn hard(&self, colours: &[[u8; 3]]) -> Raster { + Raster::from_fn(self.w, self.h, |x, y| { + let c = colours[(self.at)(f64::from(x) + 0.5, f64::from(y) + 0.5)]; + [c[0], c[1], c[2], 255] + }) + } + /// Anti-aliased: each pixel the mean of its 8 × 8 samples. + fn soft(&self, colours: &[[u8; 3]]) -> Raster { + Raster::from_fn(self.w, self.h, |x, y| { + let mut sum = [0.0f64; 3]; + for i in 0..64 { + let c = colours[(self.at)(f64::from(x) + (f64::from(i % 8) + 0.5) / 8.0, f64::from(y) + (f64::from(i / 8) + 0.5) / 8.0)]; + for k in 0..3 { + sum[k] += f64::from(c[k]) / 64.0; + } + } + [sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255] + }) + } +} + +/// The 4-connected components of each colour of `labels`. +fn components(labels: &[usize], w: usize, h: usize, colours: usize) -> Vec { + let mut seen = vec![false; labels.len()]; + let mut count = vec![0; colours]; + for start in 0..labels.len() { + if seen[start] { + continue; + } + count[labels[start]] += 1; + seen[start] = true; + let mut stack = vec![start]; + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + let near = [(x > 0).then(|| i - 1), (x + 1 < w).then_some(i + 1), (y > 0).then(|| i - w), (y + 1 < h).then_some(i + w)]; + for j in near.into_iter().flatten() { + if !seen[j] && labels[j] == labels[i] { + seen[j] = true; + stack.push(j); + } + } + } + } + count +} + +/// The size of the smallest 4-connected one-colour component of `labels`. +fn smallest(labels: &[usize], w: usize, h: usize) -> usize { + let mut seen = vec![false; labels.len()]; + let mut least = usize::MAX; + for start in 0..labels.len() { + if seen[start] { + continue; + } + seen[start] = true; + let (mut stack, mut size) = (vec![start], 0); + 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_some(i + 1), (y > 0).then(|| i - w), (y + 1 < h).then_some(i + w)]; + for j in near.into_iter().flatten() { + if !seen[j] && labels[j] == labels[i] { + seen[j] = true; + stack.push(j); + } + } + } + least = least.min(size); + } + least +} + +/// Color mode, Abutting, `n` colours, Noise `noise` (0 and 1 remove nothing). +fn color(n: u32, noise: u32) -> TraceParams { + TraceParams { mode: Mode::Color, colors: n, noise, method: Method::Abutting, ignore_white: false, ..TraceParams::default() } +} + +/// Black and White with white traced too, Noise `noise`. +fn black_and_white(noise: u32) -> TraceParams { + TraceParams { mode: Mode::BlackAndWhite, noise, method: Method::Abutting, ignore_white: false, ..TraceParams::default() } +} + +/// What the check found. +#[derive(Debug)] +struct Seams { + /// Paths of each drawing colour (each path goes to the nearest one). + faces: Vec, + /// The farthest a point of one path's outline lies from every other path's outline (px), and + /// where. + gap: f64, + at: Point, + /// The paths' areas summed, less the image's (px²): below 0 a gap, above 0 an overlap. + area: f64, + /// Points of a 4 × 4 grid per pixel that no path covers, and that more than one path covers. + uncovered: usize, + doubled: usize, +} + +impl Seams { + fn ok(&self) -> bool { + self.gap <= TOL && self.area.abs() <= TOL && self.uncovered == 0 && self.doubled == 0 + } +} + +fn segments(p: &vectorcraft_trace::TracedPath) -> Vec { + p.path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))).collect() +} + +fn check(res: &TraceResult, w: u32, h: u32, colours: &[[u8; 3]]) -> Seams { + let (wf, hf) = (f64::from(w), f64::from(h)); + let mut faces = vec![0; colours.len()]; + for p in &res.paths { + assert!(p.stroke.is_none(), "Abutting fills only"); + let d = |c: &[u8; 3]| (0..3).map(|k| (f64::from(c[k]) - f64::from(p.color[k])).powi(2)).sum::(); + let nearest = (0..colours.len()).min_by(|&a, &b| d(&colours[a]).total_cmp(&d(&colours[b]))).unwrap(); + faces[nearest] += 1; + } + let segs: Vec> = + res.paths.iter().map(|p| segments(p).into_iter().map(|c| (c, ParamCurveExtrema::bounding_box(&c))).collect()).collect(); + // Points along each outline, against every other path's outline (the frame has no neighbour). + let on_frame = |p: Point| p.x.abs() < 1e-9 || p.y.abs() < 1e-9 || (p.x - wf).abs() < 1e-9 || (p.y - hf).abs() < 1e-9; + let (mut gap, mut at) = (0.0f64, Point::ORIGIN); + for (i, own) in segs.iter().enumerate() { + for (c, _) in own { + for k in 0..32 { + let p = c.eval(f64::from(k) / 32.0); + if on_frame(p) { + continue; + } + let mut best = f64::INFINITY; + for other in segs.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, o)| o) { + for (o, b) in other { + let dx = (b.x0 - p.x).max(p.x - b.x1).max(0.0); + let dy = (b.y0 - p.y).max(p.y - b.y1).max(0.0); + if dx * dx + dy * dy >= best * best { + continue; + } + best = best.min(o.nearest(p, 1e-12).distance_sq.sqrt()); + } + } + if best > gap { + (gap, at) = (best, p); + } + } + } + } + let area = res.paths.iter().map(|p| p.path.subpaths.iter().map(|s| s.area()).sum::()).sum::() - wf * hf; + let beziers: Vec = res.paths.iter().map(|p| p.path.to_bezpath()).collect(); + let (mut uncovered, mut doubled) = (0, 0); + for yi in 0..h * 4 { + for xi in 0..w * 4 { + // (Off the half and quarter pixels, where hard edges lie.) + let p = Point::new(0.1093 + f64::from(xi) * 0.25, 0.1371 + f64::from(yi) * 0.25); + match beziers.iter().filter(|b| b.winding(p) != 0).count() { + 0 => uncovered += 1, + 1 => {} + _ => doubled += 1, + } + } + } + Seams { faces, gap, at, area, uncovered, doubled } +} + +/// One trace of `art` checked: a line for the record, and what is wrong (empty when nothing is). +/// `expect`: the paths of each colour, where every region must survive. +fn run(art: &Art, soft: bool, bw: bool, params: &TraceParams, setting: &str, expect: Option<&[usize]>) -> (String, Vec) { + let colours: &[[u8; 3]] = if bw { &BW } else { &COLOURS[..params.colors as usize] }; + let img = if soft { art.soft(colours) } else { art.hard(colours) }; + let res = trace(&img, params); + let s = check(&res, art.w, art.h, colours); + let label = format!("{} {} {} noise {} {setting}", art.name, if soft { "soft" } else { "hard" }, if bw { "B&W" } else { "Color" }, params.noise); + let line = format!( + "{label}: faces {:?}, gap {:.6} px at ({:.3}, {:.3}), area {:+.6} px², uncovered {}, doubled {}", + s.faces, s.gap, s.at.x, s.at.y, s.area, s.uncovered, s.doubled + ); + let mut wrong = vec![]; + if !s.ok() { + wrong.push(line.clone()); + } + if let Some(e) = expect + && s.faces != e + { + wrong.push(format!("{label}: faces {:?}, expected {e:?}", s.faces)); + } + (line, wrong) +} + +/// A fitting setting: its name, and what it changes. +type Setting = (&'static str, fn(&mut TraceParams)); + +const DEFAULT: Setting = ("default", |_| {}); +const LOOSEST: Setting = ("paths 0 corners 0", |p| (p.paths, p.corners) = (0.0, 0.0)); +const TIGHTEST: Setting = ("paths 100 corners 100", |p| (p.paths, p.corners) = (100.0, 100.0)); +const SNAP: Setting = ("snap", |p| p.snap_curves_to_lines = true); +const SHAPES: Setting = ("shapes", |p| p.shapes = true); + +/// The defaults, both ends of Paths and Corners, Snap Curves To Lines, and Shapes. +const ALL: [Setting; 5] = [DEFAULT, LOOSEST, TIGHTEST, SNAP, SHAPES]; + +/// Runs every variant of `art` with each of `settings`: hard-edged always, anti-aliased when +/// `soft`, Color with `n` colours, and Black and White when the art has two. Hard-edged, every +/// region must survive (Noise 1 removes nothing), except that at Paths 0 a region of under four +/// pixels may go, if what it leaves goes with it (the geometric checks see to that). +fn variants(art: &Art, n: usize, soft: bool, settings: &[Setting]) -> Vec { + let labels = art.labels(); + let counts = components(&labels, art.w as usize, art.h as usize, n); + let tiny = smallest(&labels, art.w as usize, art.h as usize) < 4; + let mut wrong = vec![]; + let mut runs = vec![(false, false, color(n as u32, 1))]; + if n == 2 { + runs.push((false, true, black_and_white(1))); + } + if soft { + runs.push((true, false, color(n as u32, 1))); + if n == 2 { + runs.push((true, true, black_and_white(1))); + } + } + for (soft, bw, params) in runs { + for &(name, set) in settings { + let mut params = params.clone(); + set(&mut params); + let expect = (!soft && !(tiny && name == LOOSEST.0)).then_some(counts.as_slice()); + let (line, w) = run(art, soft, bw, ¶ms, name, expect); + println!("{line}"); + wrong.extend(w); + } + } + wrong +} + +fn all_variants(art: &Art, n: usize, soft: bool) -> Vec { + variants(art, n, soft, &ALL) +} + +fn assert_none(wrong: Vec) { + assert!(wrong.is_empty(), "shared boundaries broken:\n{}", wrong.join("\n")); +} + +// --- The drawings --------------------------------------------------------------------------- + +fn in_disc(x: f64, y: f64, cx: f64, cy: f64, r: f64) -> bool { + (x - cx).powi(2) + (y - cy).powi(2) <= r * r +} + +fn square_island() -> Art { + Art::new("square island", 32, 32, |x, y| usize::from((9.0..23.0).contains(&x) && (8.0..22.0).contains(&y))) +} + +fn disc_island() -> Art { + Art::new("disc island", 32, 32, |x, y| usize::from(in_disc(x, y, 16.3, 15.8, 8.6))) +} + +/// A ring with a core of a third colour in its hole. +fn nested() -> Art { + Art::new("nested ring and core", 40, 40, |x, y| { + if in_disc(x, y, 20.2, 19.7, 6.3) { + 2 + } else if in_disc(x, y, 20.2, 19.7, 13.4) { + 1 + } else { + 0 + } + }) +} + +/// A ring whose hole shows the background: one colour, two separate areas of it. +fn ring() -> Art { + Art::new("ring with a hole", 40, 40, |x, y| usize::from(in_disc(x, y, 20.2, 19.7, 13.4) && !in_disc(x, y, 20.2, 19.7, 6.3))) +} + +fn square_ring() -> Art { + Art::new("square ring", 32, 32, |x, y| { + let outer = (6.0..26.0).contains(&x) && (6.0..26.0).contains(&y); + let inner = (12.0..20.0).contains(&x) && (12.0..20.0).contains(&y); + usize::from(outer && !inner) + }) +} + +fn wavy_seam() -> Art { + Art::new("wavy seam", 32, 32, |x, y| usize::from(x > 16.0 + 3.2 * (y * std::f64::consts::TAU / 14.0 + 0.4).sin())) +} + +/// Left, upper right and lower right meet at one point inside the image. +fn three_regions() -> Art { + Art::new("three-region junction", 32, 32, |x, y| { + if x < 15.4 + 0.15 * (y - 16.0) { + 0 + } else if y < 16.3 + 0.2 * (x - 16.0) { + 1 + } else { + 2 + } + }) +} + +/// The 3 × 3 pattern 111 / 101 / 110, each cell `k` pixels square: the middle hole and the +/// corner of the background touch at one pixel corner, so the outline round the middle leaves that +/// corner and comes back to it. +fn returning(k: u32) -> Art { + let name: &'static str = match k { + 1 => "returning loop 1 px", + 2 => "returning loop 2 px", + 3 => "returning loop 3 px", + _ => "returning loop 6 px", + }; + let kf = f64::from(k); + Art::new(name, 3 * k, 3 * k, move |x, y| { + let (cx, cy) = ((x / kf) as usize, (y / kf) as usize); + usize::from(!matches!((cx, cy), (1, 1) | (2, 2))) + }) +} + +/// The same pattern inside a frame of the ink colour: the returning outline is an inner one. +fn returning_framed() -> Art { + Art::new("returning loop framed", 24, 24, |x, y| { + let (cx, cy) = (((x - 3.0) / 6.0).floor() as i32, ((y - 3.0) / 6.0).floor() as i32); + usize::from(!matches!((cx, cy), (1, 1) | (2, 2))) + }) +} + +fn one_pixel_hole() -> Art { + Art::new("one-pixel hole", 24, 24, |x, y| { + usize::from((5.0..19.0).contains(&x) && (5.0..19.0).contains(&y) && !(x as u32 == 11 && y as u32 == 12)) + }) +} + +fn one_pixel_island() -> Art { + Art::new("one-pixel island", 15, 15, |x, y| usize::from(x as u32 == 7 && y as u32 == 7)) +} + +fn disconnected_squares() -> Art { + Art::new("disconnected squares", 24, 20, |x, y| { + usize::from(((3.0..7.0).contains(&x) && (3.0..7.0).contains(&y)) || ((16.0..20.0).contains(&x) && (12.0..16.0).contains(&y))) + }) +} + +fn disconnected_discs() -> Art { + Art::new("disconnected discs", 40, 24, |x, y| usize::from(in_disc(x, y, 10.3, 11.7, 6.2) || in_disc(x, y, 28.6, 12.2, 5.4))) +} + +/// Two squares of one colour that touch only at a corner: two regions, not one. +fn corner_contact() -> Art { + Art::new("corner contact", 24, 24, |x, y| { + usize::from(((4.0..12.0).contains(&x) && (4.0..12.0).contains(&y)) || ((12.0..20.0).contains(&x) && (12.0..20.0).contains(&y))) + }) +} + +/// Slanted hatch strokes two pixels wide, one pixel of paper between them (along each row). +fn hatches() -> Art { + Art::new("parallel hatches", 32, 32, |x, y| { + let inside = (4.0..28.0).contains(&x) && (4.0..28.0).contains(&y); + usize::from(inside && (x - (y / 2.0).floor()).rem_euclid(3.0) < 2.0) + }) +} + +/// Upright strokes two pixels wide with one-pixel paper channels, closed at both ends by a bar: +/// each channel is a hole. +fn closed_channels() -> Art { + Art::new("closed paper channels", 32, 24, |x, y| { + let frame = (4.0..28.0).contains(&x) && (4.0..20.0).contains(&y); + let bar = !(6.0..18.0).contains(&y); + usize::from(frame && (bar || (x - 4.0).rem_euclid(3.0) < 2.0 || x >= 25.0)) + }) +} + +// --- The tests ------------------------------------------------------------------------------ + +#[test] +fn a_closed_island_shares_its_whole_outline_with_the_background() { + let mut wrong = all_variants(&square_island(), 2, false); + wrong.extend(all_variants(&disc_island(), 2, true)); + assert_none(wrong); +} + +#[test] +fn a_ring_shares_its_outline_and_its_hole_with_its_neighbours() { + let mut wrong = all_variants(&nested(), 3, true); + wrong.extend(all_variants(&ring(), 2, true)); + wrong.extend(all_variants(&square_ring(), 2, false)); + assert_none(wrong); +} + +#[test] +fn a_wavy_seam_is_one_curve_for_both_sides() { + assert_none(all_variants(&wavy_seam(), 2, true)); +} + +#[test] +fn three_regions_share_their_junction_and_edges() { + assert_none(all_variants(&three_regions(), 3, true)); +} + +/// The drawings whose outlines leave a junction and come back to it. +fn returning_arts() -> Vec { + vec![returning(1), returning(2), returning(3), returning(6), returning_framed(), corner_contact()] +} + +#[test] +fn an_outline_returning_to_its_junction_is_shared() { + let mut wrong = vec![]; + for art in returning_arts() { + wrong.extend(variants(&art, 2, false, &[DEFAULT, TIGHTEST, SNAP])); + } + assert_none(wrong); +} + +/// At Paths 0 and Corners 0, a piece that leaves a junction and returns to it is fitted with fewer +/// than two curves: the region it is the whole outline of is dropped (a closed subpath needs two), +/// and its neighbour keeps a single curve from the junction back to itself, so a hole the size of +/// the region is left uncovered (1 to 6.7 px wide here, 0.3 to 108 px²). +#[test] +#[ignore = "a piece returning to its own junction collapses at Paths 0 / Corners 0 (fit.rs fit_piece and fit_loop): the region is dropped and its area left uncovered; draft #2205 (retain pieces returning to a junction) fixes it"] +fn an_outline_returning_to_its_junction_survives_the_loosest_fit() { + let mut wrong = vec![]; + for art in returning_arts() { + wrong.extend(variants(&art, 2, false, &[LOOSEST])); + } + assert_none(wrong); +} + +/// With Shapes, a region whose outline is a shape becomes that exact shape, but where its outline +/// runs through a junction (two parts of the other colour touch at a corner) the neighbour keeps +/// the traced curve: in Black and White (where the threshold moves hard edges by 1/256 px) the +/// square and its neighbour part by up to 0.0039 px, and a square on the image's corner reaches +/// 0.002 px past the frame. +#[test] +#[ignore = "Shapes replaces a region touching a junction with an exact shape that its neighbour does not share (lib.rs Layers::layer, the Shapes branch): up to 0.0039 px apart in Black and White"] +fn shapes_keep_the_edges_a_region_shares_at_a_junction() { + let mut wrong = vec![]; + for art in returning_arts() { + wrong.extend(variants(&art, 2, false, &[SHAPES])); + } + assert_none(wrong); +} + +#[test] +fn a_one_pixel_hole_and_island_survive_with_what_they_leave() { + let mut wrong = all_variants(&one_pixel_hole(), 2, false); + wrong.extend(all_variants(&one_pixel_island(), 2, false)); + // At the default Noise both go, each together with the hole it would leave. + // (The hole goes into the square around it; the island into the background, leaving no hole.) + for (art, expect_island) in [(one_pixel_hole(), 1), (one_pixel_island(), 0)] { + for (bw, params) in [(false, color(2, 25)), (true, black_and_white(25))] { + let (line, w) = run(&art, false, bw, ¶ms, "default", Some(&[1, expect_island])); + println!("{line}"); + wrong.extend(w); + } + } + assert_none(wrong); +} + +#[test] +fn separate_areas_of_one_colour_stay_separate() { + let mut wrong = all_variants(&disconnected_squares(), 2, false); + wrong.extend(all_variants(&disconnected_discs(), 2, true)); + // (Squares touching at a corner: see the returning-outline tests.) + assert_none(wrong); +} + +#[test] +fn one_pixel_paper_channels_between_hatches_are_shared() { + let mut wrong = all_variants(&hatches(), 2, true); + wrong.extend(all_variants(&closed_channels(), 2, false)); + assert_none(wrong); +} + +/// The check itself: a path moved by a hundredth of a pixel, and stacked (Overlapping) layers, are +/// found. +#[test] +fn the_check_finds_a_hairline_gap_and_an_overlap() { + let art = square_island(); + let colours = &COLOURS[..2]; + let mut res = trace(&art.hard(colours), &color(2, 1)); + let s = check(&res, art.w, art.h, colours); + assert!(s.ok(), "{s:?}"); + let island = res.paths.iter_mut().find(|p| p.color == colours[1]).unwrap(); + for a in &mut island.path.subpaths[0].anchors { + for p in [&mut a.p, &mut a.h_in, &mut a.h_out] { + p.x += 0.01; + } + } + let s = check(&res, art.w, art.h, colours); + assert!(!s.ok() && (s.gap - 0.01).abs() < 1e-6, "{s:?}"); + let stacked = TraceParams { method: Method::Overlapping, ..color(2, 1) }; + let s = check(&trace(&art.hard(colours), &stacked), art.w, art.h, colours); + assert!(s.doubled > 0 && s.area > 100.0, "{s:?}"); +}