Merge pull request #2025 from andeecollard/fix/trace-snap-collapsed-loops

Image Trace: preserve thin closed outlines after straightening
This commit is contained in:
BFlat
2026-10-16 04:30:15 +07:00
committed by GitHub
5 changed files with 125 additions and 4 deletions
+19
View File
@@ -1,11 +1,15 @@
//! Loop → Bézier curves, and the pieces of that which centre lines use too.
use kurbo::ParamCurveArea;
use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath};
use crate::contour::Loop;
use crate::logo::{Cubic, Stats, Style, all_within, fit_open, fit_outline, resample_closed, resample_open, sample_cubics};
use crate::subpixel::{self, Crack};
#[cfg(test)]
mod snap_area_tests;
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
let ab = b - a;
let l2 = ab.hypot2();
@@ -149,6 +153,21 @@ pub(crate) fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
if style.upright {
snap_upright(&mut segs, true, false);
}
// Opposite sides of a thin loop can both straighten onto the same chord.
// Distance-to-outline checks allow that fit, but it has no filled interior.
// Keep the positioned boundary when a nondegenerate loop loses all its area.
// Translate before integrating to avoid cancellation far from the origin.
let origin = pts.first().copied().unwrap_or(Point::new(0.0, 0.0));
let fitted_area: f64 = segs
.iter()
.map(|c| {
let [a, b, c, d] = c.map(|p| Point::new(p.x - origin.x, p.y - origin.y));
kurbo::PathSeg::Cubic(kurbo::CubicBez::new(a, b, c, d)).signed_area()
})
.sum();
if area2.abs() > 1e-8 && fitted_area.abs() < 1e-8 {
segs = polygon(&pts, 0.0);
}
Some(if flip { reversed(segs) } else { segs })
}
+96
View File
@@ -0,0 +1,96 @@
use super::*;
use crate::{Raster, preset, trace};
#[test]
fn snap_preserves_the_interior_of_a_thin_zig_at_different_positions() {
for (ox, oy) in [(10, 10), (210, 85)] {
let image = Raster::from_fn(256, 128, |x, y| {
if [(ox, oy), (ox + 1, oy), (ox + 1, oy + 1), (ox + 2, oy + 1)].contains(&(x, y)) { [0, 0, 0, 255] } else { [255; 4] }
});
for corners in [0.0, 25.0, 50.0, 75.0, 100.0] {
for snap in [false, true] {
let mut p = preset("Black and White Logo").unwrap();
p.paths = 50.0;
p.corners = corners;
p.noise = 1;
p.ignore_white = true;
p.snap_curves_to_lines = snap;
let out = trace(&image, &p);
assert_eq!(out.paths.len(), 1);
assert!(out.paths[0].path.subpaths[0].area().abs() > 1.0, "position=({ox},{oy}), corners={corners}, snap={snap}");
p.noise = 5;
assert!(trace(&image, &p).paths.is_empty(), "Noise must still remove the four-pixel mark");
}
}
}
}
fn thin_outline() -> Vec<Point> {
vec![
Point::new(0.0, 0.0),
Point::new(2.00390625, 0.501953125),
Point::new(2.501953125, 1.0),
Point::new(3.00390625, 2.00390625),
Point::new(1.0, 1.501953125),
Point::new(0.501953125, 1.00390625),
]
}
fn hard_style() -> Style {
let mut p = crate::preset("Black and White Logo").unwrap();
p.paths = 50.0;
p.corners = 0.0;
let mut st = p.outline_style();
st.tol *= 2.5;
st.line_tol *= 1.8;
st.circle_tol *= 1.8;
st.sigma = 0.9;
st
}
#[test]
fn a_thin_closed_outline_and_its_hole_keep_opposite_winding() {
let points = thin_outline();
let forward = fit_closed(&points, &hard_style()).unwrap();
let area = subpath(&forward, true).unwrap().area();
assert!(area > 1.0);
for shift in 0..points.len() {
let mut backward = points.clone();
backward.rotate_left(shift);
backward.reverse();
let reverse = fit_closed(&backward, &hard_style()).unwrap();
assert_eq!(reverse, reversed(forward.clone()), "shared closed boundary must be the same curve in reverse");
assert!((subpath(&reverse, true).unwrap().area() + area).abs() < 1e-10);
}
}
#[test]
fn a_thin_white_hole_remains_open_with_snap_enabled() {
let image = Raster::from_fn(32, 32, |x, y| {
if (4..28).contains(&x) && (4..28).contains(&y) && ![(14, 14), (15, 14), (15, 15), (16, 15)].contains(&(x, y)) {
[0, 0, 0, 255]
} else {
[255; 4]
}
});
let mut p = preset("Black and White Logo").unwrap();
p.paths = 50.0;
p.corners = 0.0;
p.noise = 1;
p.ignore_white = true;
let out = trace(&image, &p);
assert_eq!(out.paths.len(), 1);
let subs = &out.paths[0].path.subpaths;
assert_eq!(subs.len(), 2);
assert!(subs[0].area() * subs[1].area() < -1.0, "hole must retain a nonzero opposite interior");
p.noise = 5;
assert_eq!(trace(&image, &p).paths[0].path.subpaths.len(), 1, "Noise must still fill the four-pixel hole");
}
#[test]
fn a_narrow_rectangle_remains_a_filled_region() {
let points = vec![Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 1.0), Point::new(0.0, 1.0)];
let curves = fit_closed(&points, &hard_style()).unwrap();
let area = subpath(&curves, true).unwrap().area().abs();
assert!((15.0..25.0).contains(&area), "a narrow filled region must remain visible: {area}");
}
+7 -3
View File
@@ -6,18 +6,21 @@ What Image Trace (`crates/trace`) makes of images whose right answer is known, m
compared with the numbers before each change. Nothing here is a judgement by eye, and nothing is compared
with any other product except the open tracer named under [Yardstick](#4-yardstick).
## Tiny closed contours
## Thin closed outlines with Snap Curves To Lines
A four-pixel stair-step mark can fit as two straight cubics along the same diagonal, leaving a closed path with zero area. The outline-distance checks accept this because both sides are close to the chord. Closed fitting now checks analytic curve area after straightening and upright levelling; if a nondegenerate source loop has lost its interior, it retains the positioned boundary as an unsimplified polygon. The area integral is translated to a local origin to reduce cancellation. This numerical degeneracy check is not a new Noise or minimum-feature-area setting.
`fit::snap_area_tests` covers the mark at two positions and five Corners settings, a white stair-step hole, reversed/rotated closed boundaries with exactly matching opposite curves, and a narrow filled rectangle. Noise still removes the four-pixel mark or hole when set to 5. It does not change open-piece fitting, global Snap tolerances or defaults, and it does not address every possible zero-area compound outline. The preset quality table below is the previous measurement, not a rerun of that full report.
## Tiny closed contours
A coarse fit can reduce a small closed outline to one cubic, which the editable-path builder cannot
represent as a closed subpath with two anchors. Previously that dropped the component or hole after
it survived Noise. The fitter now retains the positioned contour without further simplification when
that minimum is not met. Corners and Noise defaults are unchanged.
Three source-generated regressions cover a one-pixel island, a one-pixel hole with opposite winding,
and a stepped seven-pixel region across Corners values. They also check that Noise still removes the
island and fills the hole when its threshold requests that. These are survival and winding checks,
not a new quality-harness baseline or a general claim about small-feature fidelity.
## How it is measured
`crates/trace/tests/quality` draws its own inputs from vector art (no image files), traces each with every
@@ -967,6 +970,7 @@ One trace at a time on this machine (all cores); memory is the peak allocated ab
| date | change |
|:--|:--|
| 2026-10-14 | Closed fitting retains the positioned boundary when straightening collapses a nonzero-area loop; four generated regressions; preset scores unchanged |
| 2026-10-14 | Tiny closed fits retain their positioned outlines instead of losing islands/holes; three generated regression tests; quality-harness baseline unchanged |
| 2026-10-14 | Gradients: a gradient that noise or a soft rim broke into pieces is found in its largest piece and grown over the rest (blurred and noisy linear gradients: 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo) |
| 2026-10-14 | Enhanced versions of the presets (Auto Grouping, and Shapes in all but High Fidelity Photo; Flat Logo has none), each measured beside its preset; a hole that one colour fills is the same shape as what fills it, rectangles are sized by where their sides lie, and a group keeps its paths in the order they were traced in (grouped, a letter with a counter was painted with a light seam: Default with Auto Grouping, edge ΔE 10.24 to 9.88) |
+1 -1
View File
@@ -62,7 +62,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Film Grain done (29/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate; SVG Filters, remaining raster effects and 3D materials, lights and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (quick presets reveal adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (quick presets reveal adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; broader pass remains) | 60–90 |
File diff suppressed because one or more lines are too long