From 84bd59de362ea64dbf9479e162c19e5554e9783f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 15:22:07 +0700 Subject: [PATCH] Image Trace: retain pieces returning to a junction Squashed from #2205 onto main. --- crates/trace/src/fit.rs | 12 ++ crates/trace/src/fit/junction_return_tests.rs | 143 ++++++++++++++++++ crates/trace/tests/shared_boundaries.rs | 1 - docs/image-trace-quality.md | 16 ++ docs/roadmap.md | 3 +- docs/target-app-parity.md | 3 +- 6 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 crates/trace/src/fit/junction_return_tests.rs diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs index 3a7521c26..dc5c3a27b 100644 --- a/crates/trace/src/fit.rs +++ b/crates/trace/src/fit.rs @@ -199,6 +199,15 @@ pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec 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 { (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}; diff --git a/crates/trace/src/fit/junction_return_tests.rs b/crates/trace/src/fit/junction_return_tests.rs new file mode 100644 index 000000000..fea2ea7dc --- /dev/null +++ b/crates/trace/src/fit/junction_return_tests.rs @@ -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 { + 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::(); + 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::>() + }; + 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:?}"); + } + } + } +} diff --git a/crates/trace/tests/shared_boundaries.rs b/crates/trace/tests/shared_boundaries.rs index 89e69768f..926ecd067 100644 --- a/crates/trace/tests/shared_boundaries.rs +++ b/crates/trace/tests/shared_boundaries.rs @@ -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() { diff --git a/docs/image-trace-quality.md b/docs/image-trace-quality.md index ea29c60fa..2ba660184 100644 --- a/docs/image-trace-quality.md +++ b/docs/image-trace-quality.md @@ -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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index fa1912e5a..2536eaa97 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5d8be640a..5194a5d54 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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` |