Image Trace: retain pieces returning to a junction

Squashed from #2205 onto main.
This commit is contained in:
bflatastic
2026-10-16 15:27:54 +07:00
parent 065e237f7f
commit 84bd59de36
6 changed files with 175 additions and 3 deletions
+12
View File
@@ -199,6 +199,15 @@ pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec<C
if style.upright {
snap_upright(&mut segs, false, fixed_ends);
}
// A fixed-end piece can return to its junction. Fewer than two cubics
// cannot form its own editable closed boundary. Preserve the positioned
// edge on both sides, including when it belongs to a larger compound loop.
if fixed_ends && first == last && segs.len() < 2 {
let area2: f64 = pts.windows(2).map(|p| (p[0] - first).cross(p[1] - first)).sum();
if area2.is_finite() && area2.abs() > 1e-8 {
segs = pts.windows(2).filter(|p| p[0] != p[1]).map(|p| [p[0], p[0], p[1], p[1]]).collect();
}
}
if flip { reversed(segs) } else { segs }
}
@@ -271,6 +280,9 @@ fn polygon(pts: &[Point], tol: f64) -> Vec<Cubic> {
(0..kept.len()).map(|i| [kept[i], kept[i], kept[(i + 1) % kept.len()], kept[(i + 1) % kept.len()]]).collect()
}
#[cfg(test)]
mod junction_return_tests;
#[cfg(test)]
mod component_survival_tests {
use crate::{Raster, TraceParams, preset, trace};
@@ -0,0 +1,143 @@
//! Original geometry regressions for a shared piece returning to one junction.
use super::*;
fn hard_style() -> Style {
let mut s = crate::TraceParams { corners: 0., paths: 0., ..crate::TraceParams::default() }.outline_style();
s.tol *= 2.5;
s.line_tol *= 1.8;
s.circle_tol *= 1.8;
s.sigma = 0.9;
s
}
fn returning_rect(width: f64) -> Vec<Point> {
let d = 1.0 / 512.0;
vec![Point::new(width, 1.), Point::new(-d, 1. + d), Point::new(-d, -d), Point::new(width + d, -d), Point::new(width, 1.)]
}
#[test]
fn returning_piece_keeps_its_junction_and_interior() {
let style = crate::TraceParams { corners: 0., paths: 0., ..crate::TraceParams::default() }.outline_style();
for width in [1, 2] {
for turns in 0..4 {
for offset in [(10, 10), (1_000_000_000, 1_000_000_000)] {
let pts: Vec<_> = [(width, 1), (0, 1), (0, 0), (width, 0)]
.into_iter()
.map(|(mut x, mut y)| {
for _ in 0..turns {
(x, y) = (-y, x);
}
(x + offset.0, y + offset.1)
})
.collect();
let junction = pts[0];
let at = |x, y| ((x, y) == junction).then(|| Point::new(x as f64, y as f64));
let raw = Loop { pts: pts.clone(), area2: 2 * i64::from(width) };
let w = offset.0 as usize + 20;
let h = offset.1 as usize + 20;
let (path, _) = fit_loop(&raw, w, h, &|_| (0., 0), &at, &style).expect("a positive-area returning loop must be editable");
assert_eq!(path.anchors[0].p, Point::new(junction.0 as f64, junction.1 as f64));
let mut local = path.clone();
let shift = Point::new(offset.0 as f64, offset.1 as f64).to_vec2();
for a in &mut local.anchors {
a.p -= shift;
a.h_in -= shift;
a.h_out -= shift;
}
assert!(local.area().abs() > 0.9 * width as f64, "{width}, {turns}, {offset:?}");
let reverse = Loop { pts: pts.into_iter().rev().collect(), area2: -raw.area2 };
let (back, _) = fit_loop(&reverse, w, h, &|_| (0., 0), &at, &style).unwrap();
let mut expected = path;
expected.reverse();
let start = expected.anchors.iter().position(|a| a.p == back.anchors[0].p).unwrap();
expected.anchors.rotate_left(start);
assert_eq!(back, expected);
}
}
}
}
#[test]
fn returning_piece_does_not_promote_a_zero_area_chain() {
let raw = Loop { pts: vec![(2, 2), (1, 2), (1, 1), (2, 1)], area2: 2 };
let junction = |x, y| ((x, y) == (2, 2)).then_some(Point::new(1.5, 1.5));
let style = crate::TraceParams { corners: 0., paths: 0., ..crate::TraceParams::default() }.outline_style();
// All four subpixel corners coincide; the raw pixel has area but its
// positioned boundary does not. The fallback must not invent ink.
assert!(fit_loop(&raw, 4, 4, &|_| (-0.5, 0), &junction, &style).is_none());
}
#[test]
fn returning_piece_leaves_free_end_fitting_unchanged() {
assert_eq!(fit_piece(&returning_rect(1.), &hard_style(), false).len(), 1);
let pts = [Point::new(0., 0.), Point::new(2., 0.), Point::new(4., 0.)];
assert_eq!(fit_piece(&pts, &hard_style(), true), fit_piece(&pts, &hard_style(), false));
}
fn trace_art(hole: bool, noise: u32) -> crate::TraceResult {
let image = crate::Raster::from_fn(12, 12, |x, y| {
let ink = if hole {
(3..9).contains(&x) && (3..9).contains(&y) && !(x == 5 && y == 5)
} else {
((4..9).contains(&x) && (4..9).contains(&y)) || (x == 3 && y == 3)
};
if ink { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
});
crate::trace(
&image,
&crate::TraceParams {
corners: if hole { 100. } else { 0. },
paths: if hole { 100. } else { 0. },
noise,
ignore_white: true,
..crate::TraceParams::default()
},
)
}
#[test]
fn returning_piece_keeps_a_corner_touching_mark_until_noise_removes_it() {
assert_eq!(trace_art(false, 0).paths.len(), 2);
assert_eq!(trace_art(false, 2).paths.len(), 1);
}
#[test]
fn returning_piece_preserves_the_existing_hole_noise_control() {
let holes = |noise| trace_art(true, noise).paths.iter().map(|p| p.path.subpaths.len().saturating_sub(1)).sum::<usize>();
assert_eq!(holes(0), 1);
assert_eq!(holes(2), 0);
}
#[test]
fn returning_piece_has_the_same_boundary_in_its_abutting_neighbour() {
for inverse in [false, true] {
for turns in 0..4 {
let image = crate::Raster::from_fn(12, 12, |mut x, mut y| {
for _ in 0..turns {
(x, y) = (y, 11 - x);
}
let mark = ((4..9).contains(&x) && (4..9).contains(&y)) || (x == 3 && y == 3);
if mark != inverse { [0, 0, 0, 255] } else { [255, 255, 255, 255] }
});
let res =
crate::trace(&image, &crate::TraceParams { corners: 0., paths: 0., noise: 0, ignore_white: false, ..crate::TraceParams::default() });
let ink = if inverse { [255, 255, 255] } else { [0, 0, 0] };
let dot = res.paths.iter().find(|p| p.color == ink && p.pixels == 1).expect("corner mark is retained");
let cubics = |p: &vectorcraft_geom::PathData| {
p.subpaths
.iter()
.flat_map(|s| {
(0..s.segment_count()).map(move |i| {
let c = s.segment(i);
[c.p0, c.p1, c.p2, c.p3]
})
})
.collect::<Vec<_>>()
};
let other: Vec<_> = res.paths.iter().filter(|p| p.color != ink).flat_map(|p| cubics(&p.path)).collect();
for c in cubics(&dot.path) {
assert!(other.contains(&[c[3], c[2], c[1], c[0]]), "inverse {inverse}, turns {turns}: unshared recovered edge: {c:?}");
}
}
}
}
-1
View File
@@ -460,7 +460,6 @@ fn an_outline_returning_to_its_junction_is_shared() {
/// and its neighbour keeps a single curve from the junction back to itself, so a hole the size of
/// the region is left uncovered (1 to 6.7 px wide here, 0.3 to 108 px²).
#[test]
#[ignore = "a piece returning to its own junction collapses at Paths 0 / Corners 0 (fit.rs fit_piece and fit_loop): the region is dropped and its area left uncovered; draft #2205 (retain pieces returning to a junction) fixes it"]
fn an_outline_returning_to_its_junction_survives_the_loosest_fit() {
let mut wrong = vec![];
for art in returning_arts() {