M12: retain the interior of collapsed closed trace outlines

This commit is contained in:
andeecollard
2026-10-16 04:07:50 +07:00
committed by bflatastic
parent 477ff16357
commit 4714567240
5 changed files with 128 additions and 7 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}");
}
+8 -4
View File
@@ -1,23 +1,26 @@
# Image Trace quality
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Gradients: grown over noise and soft rims)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (closed outlines retain their interior after straightening)
What Image Trace (`crates/trace`) makes of images whose right answer is known, measured by a harness and
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) |
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long