Merge pull request #2205 from andeecollard/fix/trace-junction-returning-pieces

Image Trace: retain pieces returning to a junction
This commit is contained in:
BFlat
2026-10-16 15:53:29 +07:00
committed by GitHub
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() {
+16
View File
@@ -6,6 +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).
## Pieces returning to a junction
An outline piece can return to its starting junction while being fitted as an open chain. At loose
Corners/Paths settings, a nonzero positioned boundary could collapse to one cubic and lose a small
corner-touching mark. Fixed-end pieces with identical endpoints and fewer than two fitted segments
now retain their positioned boundary when its area is finite and nonzero. Fitting the shared piece
this way also gives an abutting fill exactly the reversed boundary, including within a compound path.
Six source-generated regressions check 1- and 2-pixel regions through four rotations, both windings
and large translations; fixed junctions; zero positioned area; free ends; Noise controls; and shared
edges through four rotations and black/white inversion. Noise 2 still removes the 1-pixel mark.
The white-hole Noise check is a control, not a reproduced returning-hole case. Defaults are unchanged;
these tests establish survival and edge consistency, not a new artistic-quality or parity score.
This is separate from the closed-contour recovery in #2012 and straightening guard in #2025.
## 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.
@@ -36,6 +51,7 @@ anti-aliasing):
| kind | inputs |
|:--|:--|
| 2026-10-15 | Fixed-end pieces returning to their junction retain their positioned nonzero boundary when fitted to fewer than two cubics; source-generated tests cover survival, winding, shared-edge equality, translations and Noise controls. Defaults and parity scores unchanged. |
| curved shapes | a circle; two ellipses; a rounded badge with a round hole; a letter D with its counter |
| polygons | a triangle, a turned square and a hexagon; a five-pointed star; a letter A with its counter |
| thin lines | five lines 0.5, 1, 2, 3 and 5 units wide (of a 256-unit square), a few degrees off the pixel grid |
+2 -1
View File
@@ -64,7 +64,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| 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 (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 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 |
| 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 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; 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; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
@@ -84,6 +84,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. |
| 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). |
| 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged |
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
+2 -1
View File
@@ -11,6 +11,7 @@ the work list made from it. Deep areas have their own checklists: [UI and intera
| Number | Value | Kind |
|---|---|---|
| 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. |
| **Feature breadth** (does each Illustrator menu item, tool, effect and format exist?) | **~88%** | measured in part: menu tree 353 of 375 items wired (94.1%), tools 89 of 92 (97%), vector effects 44 of 50 (88%), Photoshop-style effects 30 of 57 (53%), 3D 4 of 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate); blended by the area weights below |
| **Feature depth** (each area scored by behaviour, weighted) | **~75%** | estimated, area table below |
| **Ready for real work, mainstream illustrator** | **~65%** (60–70%) | estimated, see [Ready for real work](#ready-for-real-work) |
@@ -79,7 +80,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated |
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them |
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them |
| Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` |