Browse / github.com/storytold/vectorcraft / patch
Image Trace: Ignore Color can leave out only the background
Image Trace: Ignore Color can leave out only the background ## What and why Ignore Color leaves out every area of the chosen color. On art with a black field around a panel and black line work on the panel, ignoring black removes the line work along with the field. A new trace parameter, `ignoreBackground` (default `false`, so today's results do not change), limits Ignore Color to the background. With `"ignoreBackground": true` beside `"ignoreWhite": true` in the params of `imageTrace.make` or `imageTrace.makeAndExpand`, only the areas of the ignored color that reach the image's edge are left out, joined through pixels above, below or to either side.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #3952, open)
- Base
f792c63597f289d0bfb914fd06255cd1e4c8852d- Size
- +239 −15 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e97f462b42454d75079fcce1
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/buildcmds.rs crates/engine/src/tests_build.rs crates/trace/src/lib.rs crates/trace/src/tests.rs docs/image-trace-adjustments.md docs/target-app-parity.md
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make", ["Object", "Image Trace"], None,- "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",+ "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ),@@ -2323,6 +2323,42 @@ fn image_trace_make_places_paths_over_the_image() { assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0))); } +#[test]+fn image_trace_ignore_background_keeps_the_color_inside_the_art() {+ // A black field, a light panel on it and a black ring on the panel.+ let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {+ let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0);+ if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] }+ });+ let traced = |background: bool| {+ let mut s = session();+ add_image(&mut s, &art, Affine::IDENTITY);+ let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}});+ let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap();+ let g = node(&s, NodeId(r["id"].as_u64().unwrap()));+ let black: Vec<_> = g+ .children()+ .unwrap()+ .iter()+ .filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0)))+ .map(|n| outline(n).0.bounds().unwrap())+ .collect();+ (r["colors"].clone(), black)+ };+ // The whole color: the ring goes with the field.+ let (colors, black) = traced(false);+ assert_eq!(colors, 1);+ assert!(black.is_empty(), "{black:?}");+ // The background only: the ring stays, and nothing black reaches the image's edge.+ let (colors, black) = traced(true);+ assert_eq!(colors, 2);+ assert_eq!(black.len(), 1, "{black:?}");+ let bb = black[0];+ assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}");+ // The params doc tells agents about the option.+ assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false"));+}+ /// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same /// circle as its traced path; without the option it is a plain path. #[test]@@ -295,6 +295,10 @@ pub struct TraceParams { /// The colour Ignore Color leaves out (`#rrggbb`): the palette colours within about 30 levels /// of it. `None` is white. (Flat Logo mode leaves out white only.) pub ignore_color: Option<String>,+ /// With Ignore Color: leave out only the areas of that color that reach the image's edge (a+ /// background), through neighbors above, below and to either side, and trace the areas of it+ /// that other colors enclose. (Flat Logo mode leaves out every white area.)+ pub ignore_background: bool, /// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs /// to true circles. pub snap_curves_to_lines: bool,@@ -336,6 +340,7 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, ignore_color: None,+ ignore_background: false, snap_curves_to_lines: false, shapes: false, auto_grouping: false,@@ -507,6 +512,71 @@ fn is_ignored(c: [u8; 3], target: Option<[u8; 3]>) -> bool { } } +/// Whether Ignore Color leaves out every area of its color (not only the background).+fn drops_color(params: &TraceParams) -> bool {+ params.ignore_white && !params.ignore_background+}++/// Ignore Color, background only: clears to [`TRANSPARENT`] every area of a label `ignored` leaves+/// out that reaches the image's edge, through neighbors above, below and to either side. It fills+/// one run of a row at a time.+fn clear_background(labels: &mut [u16], w: usize, h: usize, ignored: &[bool]) {+ let left_out = |labels: &[u16], i: usize| labels.get(i).is_some_and(|&l| ignored.get(l as usize).copied().unwrap_or(false));+ let (last_row, last_col) = (h.saturating_sub(1) * w, w.saturating_sub(1));+ let mut seeds: Vec<usize> = (0..w).flat_map(|x| [x, last_row + x]).chain((0..h).flat_map(|y| [y * w, y * w + last_col])).collect();+ while let Some(i) = seeds.pop() {+ if !left_out(labels, i) {+ continue;+ }+ // The run of left-out pixels along the row through `i`.+ let row = i - i % w;+ let (mut a, mut b) = (i, i + 1);+ while a > row && left_out(labels, a - 1) {+ a -= 1;+ }+ while b < row + w && left_out(labels, b) {+ b += 1;+ }+ if let Some(run) = labels.get_mut(a..b) {+ run.fill(TRANSPARENT);+ }+ // A seed at the start of each run of left-out pixels next to it in the rows above and below.+ for next in [row.checked_sub(w), Some(row + w).filter(|&r| r < w * h)].into_iter().flatten() {+ let mut inside = false;+ for x in a - row..b - row {+ let here = left_out(labels, next + x);+ if here && !inside {+ seeds.push(next + x);+ }+ inside = here;+ }+ }+ }+}++/// A box of pixels: `(x0, y0, x1, y1)`, the far sides excluded.+type PixelBox = (usize, usize, usize, usize);++/// Each of `n` palette entries' pixel count in `labels`, and the box its pixels lie in within+/// `traced` (`labels` with Ignore Color's background [`TRANSPARENT`]). A layer is traced within its+/// box, so a color whose background is left out is traced within the box of its remaining areas.+fn label_extents(labels: &[u16], traced: &[u16], w: usize, n: usize) -> (Vec<usize>, Vec<Option<PixelBox>>) {+ let mut counts = vec![0usize; n];+ for &l in labels {+ if let Some(c) = counts.get_mut(l as usize) {+ *c += 1;+ }+ }+ let mut boxes: Vec<Option<PixelBox>> = vec![None; n];+ for (i, &l) in traced.iter().enumerate() {+ if let Some(b) = boxes.get_mut(l as usize) {+ let (x, y) = (i % w, i / w);+ *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1))));+ }+ }+ (counts, boxes)+}+ /// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use /// [`trace_within`] to see why. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {@@ -588,16 +658,23 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result // Noise clears specks, not the regions of a rare colour Automatic kept on purpose. let keep: Vec<bool> = q.palette.iter().map(|c| rare.contains(c)).collect(); quantize::denoise_keeping(&mut q.labels, w, h, params.noise as usize, &keep);- // Pixel count per palette entry, and the box its pixels lie in.- let mut counts = vec![0usize; q.palette.len()];- let mut boxes: Vec<Option<(usize, usize, usize, usize)>> = vec![None; q.palette.len()];- for (i, &l) in q.labels.iter().enumerate() {- if let (Some(c), Some(b)) = (counts.get_mut(l as usize), boxes.get_mut(l as usize)) {- *c += 1;- let (x, y) = (i % w, i / w);- *b = Some(b.map_or((x, y, x + 1, y + 1), |b| (b.0.min(x), b.1.min(y), b.2.max(x + 1), b.3.max(y + 1))));+ // Ignore Color, background only: the labels traced, with the ignored color's areas that reach+ // the edge left out. Its other areas are traced with the rest. Edges are still placed by+ // `q.labels`, as when the whole color is left out.+ let cleared = (params.ignore_white && params.ignore_background).then(|| {+ let ignored: Vec<bool> = q.palette.iter().map(|&c| is_ignored(c, ignore)).collect();+ let mut labels = q.labels.clone();+ clear_background(&mut labels, w, h, &ignored);+ labels+ });+ // Whether any of a palette entry's pixels are left after the background.+ let mut remains = vec![false; q.palette.len()];+ for &l in cleared.iter().flatten() {+ if let Some(r) = remains.get_mut(l as usize) {+ *r = true; } }+ let (counts, boxes) = label_extents(&q.labels, cleared.as_deref().unwrap_or(&q.labels), w, q.palette.len()); // Layers bottom → top: largest area first. let mut order: Vec<usize> = (0..q.palette.len()).filter(|&i| counts[i] > 0).collect(); order.sort_by(|a, b| counts[*b].cmp(&counts[*a]).then(a.cmp(b)));@@ -618,10 +695,11 @@ fn trace_paths(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result for (r, &i) in order.iter().enumerate() { rank[i] = r; }- let layers: Vec<(usize, usize)> =- order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_ignored(q.palette[ci], ignore))).collect();+ let dropped = |ci: usize| params.ignore_white && is_ignored(q.palette[ci], ignore) && !remains.get(ci).copied().unwrap_or(false);+ let layers: Vec<(usize, usize)> = order.iter().copied().enumerate().filter(|&(_, ci)| !dropped(ci)).collect(); let job = Layers { q: &q,+ traced: cleared.as_deref().unwrap_or(&q.labels), rank: &rank, params, img,@@ -687,6 +765,9 @@ fn bw_tones(img: &Raster, q: &Quantized) -> [f64; 2] { /// What tracing one colour layer needs. struct Layers<'a> { q: &'a Quantized,+ /// The label each layer traces a pixel as: `q.labels`, or with Ignore Color for the background+ /// only, those with the background [`TRANSPARENT`]. Edges are placed by `q.labels`.+ traced: &'a [u16], /// Each palette entry's layer, bottom (0) up. rank: &'a [usize], params: &'a TraceParams,@@ -710,9 +791,10 @@ struct Layers<'a> { impl Layers<'_> { /// Ignore White: white is no part of any layer (stacked layers reach under the layers above- /// them, and must not reach under white that is left out).+ /// them, and must not reach under white that is left out). With `ignore_background` the+ /// background is [`TRANSPARENT`] in `traced`, and the color's other areas are traced. fn ignored(&self, label: u16) -> bool {- self.params.ignore_white && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore))+ drops_color(self.params) && self.q.palette.get(label as usize).is_some_and(|c| is_ignored(*c, self.ignore)) } /// The region across a crack, for cutting outlines at their junctions: the outside pixel's /// colour. Stacked layers end on many colours at once and share no edge: one neighbour.@@ -944,7 +1026,7 @@ impl Layers<'_> { let (thin, lines) = if params.strokes { // The ink the lines are read against: the layer's colour; Black and White's own black. let ink = if params.mode == Mode::BlackAndWhite { [self.bw_tones[usize::from(ci == 1)]; 3] } else { color.map(f64::from) };- centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style)+ centerline::lines(self.traced, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, self.img, ink, &self.style) } else { (vec![], vec![]) };@@ -962,7 +1044,7 @@ impl Layers<'_> { let (bw, bh) = (x1 - x0, y1 - y0); let mut mask = Vec::with_capacity(bw * bh); for y in y0..y1 {- let row = self.q.labels.get(y * w + x0..y * w + x1)?;+ let row = self.traced.get(y * w + x0..y * w + x1)?; mask.extend(row.iter().enumerate().map(|(k, &l)| l != TRANSPARENT && !thin.get(y * w + x0 + k).copied().unwrap_or(false) && part(l))); } // Without Fills, areas are outlined with a 1 px stroke.@@ -1081,6 +1163,7 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { let gradients = vec![None; n]; let job = Layers { q: &q,+ traced: &q.labels, rank: &rank, params, img,@@ -1217,6 +1217,103 @@ fn ignore_color_leaves_out_the_colour_picked() { assert_eq!(serde_json::to_value(¶ms).unwrap()["ignoreColor"], serde_json::Value::Null); } +/// A 600 × 400 field of `edge` with a light panel (40..560 × 40..360) and, on the panel, a ring of+/// `line` 8 px wide and 100 px in radius around (300, 200), filled with `inside`.+fn panel_with_ring(edge: [u8; 4], line: [u8; 4], inside: [u8; 4]) -> Raster {+ Raster::from_fn(600, 400, |x, y| {+ let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);+ let r = Point::new(fx, fy).distance(Point::new(300.0, 200.0));+ if !((40.0..560.0).contains(&fx) && (40.0..360.0).contains(&fy)) {+ edge+ } else if (96.0..=104.0).contains(&r) {+ line+ } else if r < 96.0 {+ inside+ } else {+ [240, 240, 220, 255]+ }+ })+}++#[test]+fn ignore_background_leaves_out_only_the_areas_of_the_color_that_reach_the_edge() {+ use kurbo::Shape;+ let covers = |res: &TraceResult, color: [u8; 3], x: f64, y: f64| {+ res.paths.iter().any(|t| t.color == color && t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0)+ };+ let covered = |res: &TraceResult, x: f64, y: f64| res.paths.iter().any(|t| t.path.to_bezpath().winding(kurbo::Point::new(x, y)) != 0);+ // A black field, a light panel and a black ring on it.+ let light = [240, 240, 220, 255];+ let img = panel_with_ring(BLACK, BLACK, light);+ for method in [Method::Abutting, Method::Overlapping] {+ let params = TraceParams {+ mode: Mode::Color,+ palette: Palette::Limited,+ colors: 2,+ method,+ ignore_white: true,+ ignore_color: Some("#000000".into()),+ ..TraceParams::default()+ };+ // The whole color: the ring goes with the field.+ let all = trace(&img, ¶ms);+ assert!(all.paths.iter().all(|p| p.color != [0, 0, 0]), "{method:?}");+ assert!(!covered(&all, 300.0, 100.0) && !covered(&all, 10.0, 10.0), "{method:?}");+ // The background only: the field goes, the ring stays.+ let res = trace(&img, &TraceParams { ignore_background: true, ..params.clone() });+ assert!(covers(&res, [0, 0, 0], 300.0, 100.0) && covers(&res, [0, 0, 0], 300.0, 300.0), "{method:?}");+ assert!(covers(&res, [240, 240, 220], 100.0, 200.0) && covers(&res, [240, 240, 220], 300.0, 200.0), "{method:?}");+ assert!(!covered(&res, 10.0, 10.0) && !covered(&res, 590.0, 390.0) && !covered(&res, 300.0, 20.0), "{method:?}");+ // Without Ignore Color, the setting has no effect.+ let kept = |p: &TraceParams| trace(&img, &TraceParams { ignore_white: false, ..p.clone() });+ assert_eq!(kept(&TraceParams { ignore_background: true, ..params.clone() }), kept(¶ms), "{method:?}");+ }+ // White, the default: a white field goes, the white inside a ring stays.+ let img = panel_with_ring(WHITE, BLACK, WHITE);+ let params = TraceParams { mode: Mode::Color, colors: 3, ignore_white: true, ignore_background: true, ..TraceParams::default() };+ let res = trace(&img, ¶ms);+ assert!(covers(&res, [255, 255, 255], 300.0, 200.0) && !covered(&res, 10.0, 10.0));+ assert!(!covered(&trace(&img, &TraceParams { ignore_background: false, ..params }), 300.0, 200.0));+ // With no area of the color enclosed, the trace is the one that leaves out the whole color,+ // its anti-aliased edges placed the same.+ let img = three_colour_art(0.0);+ for method in [Method::Abutting, Method::Overlapping] {+ let params = TraceParams { colors: 3, method, ignore_white: true, ignore_color: Some("#14285a".into()), ..sixteen_colors() };+ assert_eq!(trace(&img, &TraceParams { ignore_background: true, ..params.clone() }), trace(&img, ¶ms), "{method:?}");+ }+ // Old documents have no such setting: the whole color.+ let old: TraceParams = serde_json::from_str(r#"{"mode":"color","ignoreWhite":true}"#).unwrap();+ assert!(!old.ignore_background);+ let on: TraceParams = serde_json::from_str(r#"{"ignoreWhite":true,"ignoreBackground":true}"#).unwrap();+ assert!(on.ignore_background);+}++#[test]+fn ignore_background_traces_a_color_within_the_box_of_its_remaining_areas() {+ // A 40 × 30 field of color 0 with a ring of color 1 around a 2 × 2 area of color 0.+ let (w, h) = (40, 30);+ let mut labels = vec![0u16; w * h];+ for y in 10..16 {+ for x in 20..26 {+ labels[y * w + x] = 1;+ }+ }+ for y in 12..14 {+ for x in 22..24 {+ labels[y * w + x] = 0;+ }+ }+ let mut cleared = labels.clone();+ clear_background(&mut cleared, w, h, &[true, false]);+ let (counts, boxes) = label_extents(&labels, &cleared, w, 2);+ // The counts stay those of the whole image (they order the layers); the boxes are those of+ // what is traced.+ assert_eq!(counts, vec![w * h - 32, 32]);+ assert_eq!(boxes, vec![Some((22, 12, 24, 14)), Some((20, 10, 26, 16))]);+ // Without Ignore Color, the boxes are those of every pixel.+ assert_eq!(label_extents(&labels, &labels, w, 2).1, vec![Some((0, 0, w, h)), Some((20, 10, 26, 16))]);+}+ #[test] fn snap_curves_to_lines_levels_a_rectangle_a_degree_askew() { // 120 × 60, turned 1.5° about its centre.@@ -32,6 +32,13 @@ white beside a cream), at most six; scattered specks of a colour add none. Adobe its own palettes are chosen; this is VectorCraft's own method (see [image-trace-quality.md](image-trace-quality.md#auto-color-keeps-rare-colours)). +**Ignore Color, background only.** Ignore Color leaves out every area of the chosen color. With+`"ignoreBackground": true` beside `"ignoreWhite": true` in the params of `imageTrace.make` or+`imageTrace.makeAndExpand`, it leaves out only the areas of that color that reach the image's edge,+joined through pixels above, below or to either side, and traces the areas of it that other colors+enclose: black line work inside a panel stays when the black around the panel is left out. Flat Logo+leaves out every white area whatever `ignoreBackground` is.+ **Abutting and Overlapping.** Abutting is the default Method, and the Default, Auto Color, 3 Colors, 6 Colors and 16 Colors presets use it. With Abutting, two neighboring colors share one exact edge. The canvas smooths the edges of each path on its own, so each of the two paths covers only part of the@@ -802,6 +802,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: 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). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.+- **Image Trace, Ignore Color for the background only:** `"ignoreBackground": true` with Ignore Color leaves out only the areas of the ignored color that reach the image's edge and traces the areas of it that other colors enclose, in Black and White, Grayscale and Color; code-drawn tests in `crates/trace` and `crates/engine`. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. The four polynomial distributions are original approximations of the public description, not measured