mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #2025 from andeecollard/fix/trace-snap-collapsed-loops
Image Trace: preserve thin closed outlines after straightening
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user