diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 1e3849bf4..8572810c9 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -982,6 +982,21 @@ impl Layers<'_> { // (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)?; + // Every trace straightens the pieces of an outline that lie close to a line (Snap + // Curves To Lines straightens more of them), so the outline of a single pixel or + // of a region one pixel thick can have all its pieces on one chord, and the loop + // then has no area. Filled, such a loop paints nothing, and it is left out. + // Without Fills its 1 px stroke draws it, so it is kept. (`fit::fit_closed` puts + // back the positioned outline of a loop that is one closed piece when its fit has + // no area.) The area is summed about the loop's first anchor. + if area_stroke.is_none() { + use kurbo::ParamCurveArea; + let origin = sp.anchors.first()?.p.to_vec2(); + let area: f64 = (0..sp.segment_count()).map(|i| (kurbo::Affine::translate(-origin) * sp.segment(i)).signed_area()).sum(); + if area.abs() < 1e-6 { + return None; + } + } hard.set(hard.get() || is_hard); Some((sp, is_hard)) }; diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index ba8c20856..1717069da 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1128,6 +1128,111 @@ fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { ); } +#[test] +fn snap_curves_to_lines_leaves_out_a_sliver_it_flattens() { + // A light rectangle on a transparent background, with a blue and a black block on it and a + // one-pixel row of a blue-gray along its lower edge: the row is traced in the blue. + let img = Raster::from_fn(200, 120, |x, y| { + if (60..140).contains(&x) && (30..60).contains(&y) { + [40, 120, 240, 255] + } else if (60..140).contains(&x) && (70..90).contains(&y) { + BLACK + } else if (20..180).contains(&x) && (20..100).contains(&y) { + [240, 240, 220, 255] + } else if y == 100 && (30..170).contains(&x) { + [120, 130, 160, 255] + } else { + [0, 0, 0, 0] + } + }); + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 3, + paths: 90.0, + corners: 60.0, + noise: 25, + method: Method::Abutting, + ..TraceParams::default() + }; + let on_the_row = |p: &&TracedPath| p.path.bounds().is_some_and(|b| b.y0 > 99.0 && b.y1 < 102.0); + // Left alone, the row is a path of its own. + let res = trace(&img, ¶ms); + let row: Vec<_> = res.paths.iter().filter(on_the_row).collect(); + assert_eq!(row.len(), 1, "{:?}", res.paths.iter().map(|p| p.path.bounds()).collect::>()); + assert!(net_area(&row[0].path) > 100.0, "{:?}", row[0].path.bounds()); + // Snapped, both sides of the row straighten onto one line: no path is left there, and every + // path keeps an area. + let res = trace(&img, &TraceParams { snap_curves_to_lines: true, ..params.clone() }); + assert_eq!(res.paths.iter().filter(on_the_row).count(), 0, "{:?}", res.paths.iter().map(|p| p.path.bounds()).collect::>()); + for p in &res.paths { + assert!(p.path.subpaths.iter().all(|s| s.area().abs() > 1e-6), "{:?} {:?}", p.color, p.path.bounds()); + } + // Without Fills, the areas are outlined with a 1 px stroke, which draws the flattened row: it + // stays. (Lines no wider than 0.5 px are traced as center lines, so the row is an area.) + let res = trace(&img, &TraceParams { snap_curves_to_lines: true, fills: false, strokes: true, stroke_width: 0.5, ..params }); + let row: Vec<_> = res.paths.iter().filter(on_the_row).collect(); + assert_eq!(row.len(), 1, "{:?}", res.paths.iter().map(|p| p.path.bounds()).collect::>()); + assert_eq!(row[0].stroke, Some(1.0)); + assert!(row[0].path.bounds().is_some_and(|b| b.width() > 100.0), "{:?}", row[0].path.bounds()); +} + +#[test] +fn a_traced_pixel_that_straightens_to_no_area_is_left_out_of_fills_and_kept_as_an_outline() { + // Overlapping anti-aliased ellipses, 4 x 4 samples a pixel. Where the green, the blue and the + // light meet, a single pixel of their blend at (50, 42) is traced as a color of its own, and + // the two pieces of its outline straighten onto one line in every trace, with or without Snap + // Curves To Lines. + let palette: [[f64; 3]; 6] = + [[240.0, 240.0, 220.0], [40.0, 120.0, 240.0], [0.0; 3], [200.0, 40.0, 40.0], [120.0, 130.0, 160.0], [30.0, 160.0, 60.0]]; + let ellipses = [ + (66.37, 47.61, 32.7, 26.4, 1), + (-1.63, -3.39, 46.2, 30.6, 0), + (43.37, 30.61, 12.0, 17.3, 4), + (32.37, 73.61, 43.5, 6.8, 3), + (34.37, 54.61, 42.6, 13.8, 1), + (27.37, 37.61, 23.7, 22.2, 5), + ]; + let img = Raster::from_fn(100, 90, |x, y| { + let mut sum = [0.0f64; 3]; + for i in 0..16 { + let (px, py) = (f64::from(x) + (f64::from(i % 4) + 0.5) / 4.0, f64::from(y) + (f64::from(i / 4) + 0.5) / 4.0); + let k = ellipses.iter().rev().find(|&&(cx, cy, rx, ry, _)| ((px - cx) / rx).powi(2) + ((py - cy) / ry).powi(2) <= 1.0).map_or(0, |e| e.4); + for (s, c) in sum.iter_mut().zip(palette[k]) { + *s += c / 16.0; + } + } + [sum[0].round() as u8, sum[1].round() as u8, sum[2].round() as u8, 255] + }); + let params = TraceParams { + mode: Mode::Color, + palette: Palette::Limited, + colors: 6, + paths: 90.0, + corners: 60.0, + noise: 1, + method: Method::Abutting, + stroke_width: 1.0, + ..TraceParams::default() + }; + let at_the_pixel = |p: &&TracedPath| p.path.bounds().is_some_and(|b| b.x0 >= 50.0 && b.x1 <= 51.0 && b.y0 >= 42.0 && b.y1 <= 43.0); + for snap in [false, true] { + // Filled, the pixel's loop would paint nothing: no path is left there, and every path + // keeps an area. + let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, ..params.clone() }); + assert_eq!(res.paths.iter().filter(at_the_pixel).count(), 0, "snap {snap}"); + for p in &res.paths { + assert!(p.path.subpaths.iter().all(|s| s.area().abs() > 1e-6), "snap {snap}: {:?} {:?}", p.color, p.path.bounds()); + } + // Without Fills, its outline is stroked, which draws it: it stays. + let res = trace(&img, &TraceParams { snap_curves_to_lines: snap, fills: false, strokes: true, ..params.clone() }); + let pixel: Vec<_> = res.paths.iter().filter(at_the_pixel).collect(); + assert_eq!(pixel.len(), 1, "snap {snap}: {:?}", res.paths.iter().map(|p| p.path.bounds()).collect::>()); + assert_eq!(pixel[0].stroke, Some(1.0), "snap {snap}"); + assert!(pixel[0].path.subpaths.iter().all(|s| s.area().abs() < 1e-6), "snap {snap}: {:?}", pixel[0].path.bounds()); + } +} + #[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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7cf7f7cb1..676ee972e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -782,6 +782,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. +- **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. - **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)). - **Image Trace, large images:** images over 1500 px on a side trace with a warning in the result and the status bar; the anchor limit is 8,000,000 (2,000,000 on the web), measured at about 90 bytes an anchor (see [quality notes](image-trace-quality.md#large-images)). - **Image Trace small contours:** closed fits too small to form an editable subpath retain their positioned outline, including holes. Source-generated island, hole/winding and stepped-region regressions cover Corners and Noise; defaults, parity scores and estimates are unchanged.