diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index c9b546f74..fd625f202 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -865,6 +865,35 @@ impl Layers<'_> { first.is_some() } + /// Shapes, Abutting: the shape the outline `sp` (fitted from the loop `l`, `hard` as that + /// loop is) is, where it may be one. The edge is shared by the regions on its two sides, so + /// both must become that same shape or neither: only a closed edge between two regions, a + /// region whose whole outline is the hole of one other (no junction on it, not on the image's + /// border), and only a shape inside the image. The shape is read from the outline run one way + /// from one start, so the region and the hole it fills get the very same one. + fn shared_shape(&self, l: &Loop, sp: &vectorcraft_geom::SubPath, hard: bool) -> Option { + let (w, h) = (i32::try_from(self.w).ok()?, i32::try_from(self.h).ok()?); + if l.pts.iter().any(|&(x, y)| x <= 0 || y <= 0 || x >= w || y >= h || self.junction(x, y).is_some()) { + return None; + } + let mut one_way = sp.clone(); + if one_way.signed_area() < 0.0 { + one_way.reverse(); + } + let first = one_way.anchors.iter().enumerate().min_by(|a, b| a.1.p.x.total_cmp(&b.1.p.x).then(a.1.p.y.total_cmp(&b.1.p.y)))?.0; + one_way.anchors.rotate_left(first); + let shape = shapes::recognise(&one_way, hard)?; + let (wf, hf) = (self.w as f64, self.h as f64); + let inside = shape + .path() + .subpaths + .iter() + .flat_map(|s| &s.anchors) + .flat_map(|a| [a.p, a.h_in, a.h_out]) + .all(|p| (0.0..=wf).contains(&p.x) && (0.0..=hf).contains(&p.y)); + inside.then_some(shape) + } + /// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the /// maximum. fn layer(&self, r: usize, ci: usize) -> Option> { @@ -923,26 +952,32 @@ impl Layers<'_> { for comp in comps { // (`hard`: some of the outline has no anti-aliasing, and is known less exactly.) let hard = std::cell::Cell::new(false); + // (Each loop fitted, and whether its own outline is hard.) let fit = |l: &Loop| { let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?; hard.set(hard.get() || is_hard); - Some(sp) + Some((sp, is_hard)) }; - let Some(outer) = fit(&comp.outer) else { continue }; + let Some((outer, _)) = fit(&comp.outer) else { continue }; let mut subs = vec![outer]; for hole in &comp.holes { if hole.area2.abs() / 2 < min_hole && params.noise > 0 { continue; } - let Some(mut sp) = fit(hole) else { continue }; + let Some((mut sp, own_hard)) = fit(hole) else { continue }; // Shapes: a hole that is a shape is that shape, as what fills it is (the two are // one outline: they stay one). Only where one colour fills it: the parts of - // several are not that shape, and would no longer meet it. - if params.shapes - && area_stroke.is_none() - && self.one_colour_within(hole) - && let Some(exact) = shapes::recognise(&sp, hard.get()).and_then(|shape| shape.path().subpaths.into_iter().next()) - { + // several are not that shape, and would no longer meet it. Abutting, only where the + // region that fills it is that shape too (see `shared_shape`). + let exact = if !params.shapes || area_stroke.is_some() || !self.one_colour_within(hole) { + None + } else if overlapping { + shapes::recognise(&sp, hard.get()) + } else { + // (As hard as this outline is, as the region that fills it reads it.) + self.shared_shape(hole, &sp, own_hard) + }; + if let Some(exact) = exact.and_then(|shape| shape.path().subpaths.into_iter().next()) { // (Which way round each goes: by the area of the polygon through points along it.) let turn = |sp: &vectorcraft_geom::SubPath| { use vectorcraft_geom::ParamCurve; @@ -960,7 +995,9 @@ impl Layers<'_> { } // Shapes: a filled outline without holes that is a shape becomes that shape. let shape = match subs.as_slice() { - [only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()), + [only] if params.shapes && area_stroke.is_none() && overlapping => shapes::recognise(only, hard.get()), + // Abutting: only an island in one other region, whose hole it is (see `shared_shape`). + [only] if params.shapes && area_stroke.is_none() && comp.holes.is_empty() => self.shared_shape(&comp.outer, only, hard.get()), _ => None, }; let path = shape.map_or_else(|| PathData::new(subs), |s| s.path()); diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index b57cfe7ef..d809a3c51 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1410,3 +1410,119 @@ fn grayscale_gradients_are_grey() { } assert!(res.palette.iter().all(|&c| grey(c)), "{:?}", res.palette); } + +/// `w × h` art of `colours`, anti-aliased: each pixel the mean of the 8 × 8 samples `at` colours. +fn soft_colours(w: u32, h: u32, colours: &[[u8; 3]], at: impl Fn(f64, f64) -> usize) -> Raster { + Raster::from_fn(w, h, |x, y| { + let mut sum = [0.0f64; 3]; + for i in 0..64 { + let c = colours[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] + }) +} + +/// Abutting paths: how far (px), at worst, a point along one path's outline lies from every other +/// path's outline (the image's frame aside), and how many points of a 4 × 4 grid per pixel no path +/// or more than one path covers. +fn seams(res: &TraceResult, w: u32, h: u32) -> (f64, usize) { + use kurbo::{ParamCurve, ParamCurveNearest, Shape}; + let (wf, hf) = (f64::from(w), f64::from(h)); + let segs: Vec> = + res.paths.iter().map(|p| p.path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))).collect()).collect(); + let mut gap = 0.0f64; + for (i, own) in segs.iter().enumerate() { + for c in own { + for k in 0..16 { + let p = c.eval(f64::from(k) / 16.0); + if p.x.abs() < 1e-9 || p.y.abs() < 1e-9 || (p.x - wf).abs() < 1e-9 || (p.y - hf).abs() < 1e-9 { + continue; + } + let near = segs.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, o)| o).map(|o| o.nearest(p, 1e-12).distance_sq); + gap = gap.max(near.fold(f64::INFINITY, f64::min).sqrt()); + } + } + } + let beziers: Vec = res.paths.iter().map(|p| p.path.to_bezpath()).collect(); + let mut wrong = 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 = kurbo::Point::new(0.1093 + f64::from(xi) * 0.25, 0.1371 + f64::from(yi) * 0.25); + wrong += usize::from(beziers.iter().filter(|b| b.winding(p) != 0).count() != 1); + } + } + (gap, wrong) +} + +/// Shapes, Abutting: a region that is a shape is that shape on both of its sides, or on neither. +#[test] +fn shapes_keep_abutting_neighbours_watertight() { + let colours = [[255, 255, 255], [200, 40, 50], [30, 90, 200], [40, 160, 70], [240, 200, 40]]; + let disc = |x: f64, y: f64, cx: f64, cy: f64, r: f64| (x - cx).hypot(y - cy) <= r; + let tile = |x: f64, y: f64, cx: f64, cy: f64, half: f64, deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + let (dx, dy) = (x - cx, y - cy); + (dx * c + dy * s).abs() <= half && (dy * c - dx * s).abs() <= half + }; + let arts: Vec<(&str, Raster)> = vec![ + // A disc in a square in the background, and a disc across the square's edge. + ( + "nested", + soft_colours(120, 100, &colours, |x, y| { + if disc(x, y, 50.3, 50.6, 14.2) { + 2 + } else if disc(x, y, 90.4, 30.2, 13.1) { + 3 + } else if (20.4..80.6).contains(&x) && (20.3..80.7).contains(&y) { + 1 + } else { + 0 + } + }), + ), + // A square on the frame, a disc on the frame, and a square away from it. + ( + "on the frame", + soft_colours(100, 80, &colours, |x, y| { + if (0.0..30.6).contains(&x) && (20.3..50.4).contains(&y) { + 1 + } else if disc(x, y, 70.3, 12.0, 18.4) { + 2 + } else if (55.3..85.6).contains(&x) && (40.4..70.7).contains(&y) { + 3 + } else { + 0 + } + }), + ), + // Tilted tiles, some touching, some apart. + ( + "tiles", + soft_colours(120, 120, &colours, |x, y| { + for (k, (cx, cy)) in [(30.3, 30.4), (52.0, 30.4), (90.2, 32.1), (30.6, 88.7), (60.4, 62.3), (92.1, 90.6)].into_iter().enumerate() { + if tile(x, y, cx, cy, 11.2, 15.0 + 7.0 * k as f64) { + return 1 + k % 4; + } + } + 0 + }), + ), + ]; + let mut wrong = vec![]; + for (name, img) in &arts { + let params = TraceParams { mode: Mode::Color, colors: 5, noise: 4, ignore_white: false, shapes: true, ..TraceParams::default() }; + let res = trace(img, ¶ms); + let (gap, bad) = seams(&res, img.width, img.height); + let shapes = res.paths.iter().filter(|p| p.shape.is_some()).count(); + let line = format!("{name}: {} paths, {shapes} shapes, gap {gap:.6} px, {bad} points covered other than once", res.paths.len()); + eprintln!("{line}"); + if gap > 1e-3 || bad > 0 { + wrong.push(line); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +}