Merge pull request #3946 from dotCooCoo/feat/flat-logo-named-colors-only

Image Trace: Flat Logo can trace only the named colors, without a white of its own
This commit is contained in:
BFlat
2026-11-09 01:13:52 +08:00
committed by GitHub
8 changed files with 204 additions and 17 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), 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), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), 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), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), 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), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), 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, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; 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 while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, 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)
),
+37
View File
@@ -3825,6 +3825,43 @@ fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() {
assert_eq!(r["colors"], 1);
}
#[test]
fn image_trace_flat_logo_can_trace_only_the_named_colors() {
// A light disc in a black ring, a blue square on it, and a white spot.
let art = vectorcraft_trace::Raster::from_fn(120, 120, |x, y| {
let (fx, fy) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let d = (fx - 60.0).hypot(fy - 60.0);
if (fx - 32.0).hypot(fy - 40.0) <= 6.0 {
[255, 255, 255, 255]
} else if (45..75).contains(&x) && (45..75).contains(&y) {
[40, 120, 240, 255]
} else if d <= 46.0 {
[240, 240, 220, 255]
} else if d <= 50.0 {
[0, 0, 0, 255]
} else {
[0, 0, 0, 0]
}
});
let fills = |logo_white: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let params = json!({"params": {"mode": "logo", "logoColors": ["#000000", "#F0F0DC", "#2878F0"], "logoWhite": logo_white}});
let r = s.execute("imageTrace.makeAndExpand", &params).unwrap();
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let mut hex: Vec<String> = g.children().unwrap().iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| c.to_hex()).collect();
hex.sort();
hex.dedup();
(r["colors"].clone(), hex)
};
let (colors, hex) = fills(true);
assert_eq!(colors, 4);
assert!(hex.contains(&"#ffffff".to_string()), "{hex:?}");
let (colors, hex) = fills(false);
assert_eq!(colors, 3);
assert_eq!(hex, ["#000000", "#2878f0", "#f0f0dc"]);
}
#[test]
fn image_trace_object_remembers_its_settings() {
let mut s = session();
+49 -14
View File
@@ -353,22 +353,38 @@ fn plane_at(planes: &[Vec<f32>], class: usize, i: usize) -> f32 {
planes.get(class).and_then(|p| p.get(i)).copied().unwrap_or(0.0)
}
/// The unmixing classes for a palette: each colour, then white, then transparent.
fn classes_for(palette: &[[u8; 3]]) -> Vec<[f64; 4]> {
/// The unmixing classes for a palette: each colour, then white (when `own_white`), then transparent.
fn classes_for(palette: &[[u8; 3]], own_white: bool) -> Vec<[f64; 4]> {
let mut classes: Vec<[f64; 4]> =
palette.iter().map(|c| [f64::from(c[0]) / 255.0, f64::from(c[1]) / 255.0, f64::from(c[2]) / 255.0, 1.0]).collect();
classes.push([1.0, 1.0, 1.0, 1.0]);
if own_white {
classes.push([1.0, 1.0, 1.0, 1.0]);
}
classes.push([0.0, 0.0, 0.0, 0.0]);
classes
}
/// Whether white is traced as a class of its own: always, unless the caller named the colors and
/// turned [`TraceParams::logo_white`] off. With Ignore White it is, so white areas are left out.
fn own_white(params: &TraceParams) -> bool {
params.logo_white || params.logo_colors.is_empty() || params.ignore_white
}
/// Without a white class of its own: the palette color that takes white's coverage, the one
/// nearest white (the first of equally near ones). `None` when a named color is white already
/// (closer than [`SAME_COLOUR`]), so white needs no class to unmix with.
fn white_goes_to(palette: &[[u8; 3]]) -> Option<usize> {
let (i, d) = palette.iter().enumerate().map(|(i, &c)| (i, distance(c, [255; 3]))).min_by(|a, b| a.1.total_cmp(&b.1))?;
(d >= SAME_COLOUR).then_some(i)
}
fn distance(a: [u8; 3], b: [u8; 3]) -> f64 {
(0..3).map(|k| (f64::from(a[k]) - f64::from(b[k])).powi(2)).sum::<f64>().sqrt()
}
type Colours = Vec<[u8; 3]>;
/// The colours to trace: those the caller named (white is always its own class so it is skipped;
/// The colours to trace: those the caller named (white is skipped when it is its own class;
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found,
/// the tones of one colour folded into it ([`fold_tones`]); and with them the colours before folding.
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<(Colours, Colours), TraceError> {
@@ -380,7 +396,7 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul
for s in &params.logo_colors {
// Every entry is checked, but only the first few distinct colours are kept.
let c = parse_hex(s).ok_or_else(|| TraceError::BadPalette(s.clone()))?;
let white = c.iter().all(|&v| v >= WHITE_MIN);
let white = own_white(params) && c.iter().all(|&v| v >= WHITE_MIN);
if palette.len() < MAX_COLOURS && !white && !palette.iter().any(|&q| distance(q, c) < SAME_COLOUR) {
palette.push(c);
}
@@ -405,7 +421,11 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
if fraction < MIN_FLAT {
return Err(TraceError::NotFlat { fraction });
}
let mut classes = classes_for(&palette);
let own_white = own_white(params);
// Without a white of its own, white is still unmixed as a class, so blended edges split where
// they are, and its coverage is then added to the color it goes to.
let merge_white = if own_white { None } else { white_goes_to(&palette) };
let mut classes = classes_for(&palette, own_white || merge_white.is_some());
// A very busy image is averaged down further until the unmixing work is bounded.
for _ in 0..6 {
if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 {
@@ -423,15 +443,28 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len()));
// (A colour nothing explained may be one more tone of a colour that is there.)
palette = fold_tones(&pix, palette);
classes = classes_for(&palette);
classes = classes_for(&palette, own_white);
unmix(&pix, w, h, &classes).0
}
} else {
std::mem::take(&mut planes)
};
let (white, transparent) = (palette.len(), palette.len() + 1);
if let Some(to) = merge_white
&& palette.len() < planes.len()
{
let white_plane = planes.remove(palette.len());
if let Some(target) = planes.get_mut(to) {
for (t, v) in target.iter_mut().zip(&white_plane) {
*t += *v;
}
}
}
// Without a white class, `white` is an index no class has.
let (white, transparent) = if own_white { (palette.len(), palette.len() + 1) } else { (usize::MAX, palette.len()) };
let aliased = soften_if_aliased(&mut planes, w, h);
fill_knockouts(&mut planes, w, h, white, transparent);
if own_white {
fill_knockouts(&mut planes, w, h, white, transparent);
}
let n = w * h;
let dominant: Vec<u8> = (0..n)
.map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(&planes, a, i).total_cmp(&plane_at(&planes, b, i))).unwrap_or(transparent) as u8)
@@ -474,7 +507,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) };
let mut shapes: Vec<Kept> = Vec::new();
let mut anchors_made = 0usize;
for class in 0..=white {
for class in 0..transparent {
if class == white && params.ignore_white {
continue;
}
@@ -488,8 +521,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
if a2.abs() / 2.0 < min_area {
continue;
}
// Holes are cut only under Ignore White; otherwise white and the other colors are traced as their own shapes.
if a2 >= 0.0 && !params.ignore_white {
// Holes are cut only under Ignore White or with no white class; otherwise white and the other colors are
// traced as their own shapes.
if a2 >= 0.0 && !(params.ignore_white || !own_white) {
continue;
}
// More real outlines than we will fit is refused, not silently truncated.
@@ -499,8 +533,9 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
let pts = to_pixels(&lp, u);
if a2 < 0.0 { outers.push((a2.abs() / 2.0, pts)) } else { holes.push(pts) }
}
// Each hole belongs to the smallest outline that contains it, and to no other.
let owner: Vec<Option<usize>> = if params.ignore_white {
// Each hole belongs to the smallest outline that contains it, and to no other. (Holes are
// cut where white does not paint over them: with Ignore White, or with no white class.)
let owner: Vec<Option<usize>> = if params.ignore_white || !own_white {
holes
.iter()
.map(|hole| {
+96
View File
@@ -62,6 +62,9 @@ fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]>
const RED: [u8; 3] = [210, 40, 50];
const BLUE: [u8; 3] = [40, 80, 200];
const LIGHT: [u8; 3] = [240, 240, 220];
const SKY: [u8; 3] = [40, 120, 240];
const WHITE: [u8; 3] = [255, 255, 255];
fn logo_params() -> TraceParams {
TraceParams {
@@ -205,6 +208,99 @@ fn the_named_palette_is_used_exactly_and_a_bad_one_is_an_error() {
assert!(matches!(trace_within(&img, &bad, 1000), Err(TraceError::BadPalette(s)) if s == "red"));
}
/// The color of the topmost path at `p` (paths are listed bottom first).
fn top_color(res: &TraceResult, p: Point) -> Option<[u8; 3]> {
res.paths.iter().rev().find(|t| t.path.to_bezpath().winding(p) != 0).map(|t| t.color)
}
#[test]
fn logo_white_off_traces_only_the_named_colors() {
// A light disc in a black ring, a blue square on it, and two white spots.
let img = render(120, 120, 0.7, |x, y| {
let d = (x - 60.0).hypot(y - 60.0);
if (x - 32.0).hypot(y - 40.0) <= 6.0 || (x - 78.0).hypot(y - 86.0) <= 6.0 {
Some(WHITE)
} else if (45.0..=75.0).contains(&x) && (45.0..=75.0).contains(&y) {
Some(SKY)
} else if d <= 46.0 {
Some(LIGHT)
} else if d <= 50.0 {
Some([0, 0, 0])
} else {
None
}
});
let named = TraceParams { logo_colors: vec!["#000000".into(), "#F0F0DC".into(), "#2878F0".into()], ..logo_params() };
// By default white is a fourth color.
let res = trace(&img, &named);
assert_eq!(res.palette.len(), 4, "{:?}", res.palette);
assert_eq!(top_color(&res, Point::new(32.0, 40.0)), Some(WHITE));
// Without it, the three named colors only: the white spots take the light color.
let res = trace(&img, &TraceParams { logo_white: false, ..named.clone() });
assert_eq!(res.palette.len(), 3, "{:?}", res.palette);
assert!(!res.palette.contains(&WHITE));
for p in [Point::new(32.0, 40.0), Point::new(78.0, 86.0), Point::new(20.0, 60.0)] {
assert_eq!(top_color(&res, p), Some(LIGHT), "at {p:?}");
}
assert_eq!(top_color(&res, Point::new(60.0, 60.0)), Some(SKY));
assert_eq!(top_color(&res, Point::new(60.0, 12.0)), Some([0, 0, 0]));
assert!(!covered(&res, Point::new(5.0, 5.0)));
// Ignore White still leaves the white spots out.
let res = trace(&img, &TraceParams { logo_white: false, ignore_white: true, ..named.clone() });
for p in [Point::new(32.0, 40.0), Point::new(78.0, 86.0)] {
assert!(!covered(&res, p), "at {p:?}");
}
assert_eq!(top_color(&res, Point::new(20.0, 60.0)), Some(LIGHT));
}
#[test]
fn logo_white_off_keeps_a_named_white_and_knockouts_as_holes() {
let disc = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(WHITE));
let only_white = TraceParams { logo_colors: vec!["#ffffff".into()], logo_white: false, ..logo_params() };
let res = trace(&disc, &only_white);
assert_eq!(res.paths.len(), 1);
assert_eq!(res.paths[0].color, WHITE);
// An enclosed transparent area stays a hole: there is no white to paint it with.
let ring = render(64, 64, 0.7, |x, y| (12.0..=25.0).contains(&(x - 32.0).hypot(y - 32.0)).then_some(BLUE));
let res = trace(&ring, &TraceParams { logo_colors: vec!["#2850c8".into()], logo_white: false, ..logo_params() });
assert_eq!(res.paths.len(), 1);
assert_eq!(res.paths[0].path.subpaths.len(), 2, "outer outline and a hole");
assert!(!covered(&res, Point::new(32.0, 32.0)), "the hole must be empty");
assert!(covered(&res, Point::new(32.0, 32.0 + 18.0)), "the ring itself is filled");
}
#[test]
fn logo_white_off_gives_each_white_the_nearest_named_color() {
// Red and blue are equally far from white: the white disc takes the first one named.
let disc = render(64, 64, 0.7, |x, y| ((x - 32.0).hypot(y - 32.0) <= 20.0).then_some(WHITE));
let params = TraceParams { logo_colors: vec!["#ff0000".into(), "#0000ff".into()], logo_white: false, ..logo_params() };
let res = trace(&disc, &params);
assert_eq!(top_color(&res, Point::new(32.0, 32.0)), Some([255, 0, 0]));
assert!(!covered(&res, Point::new(5.0, 5.0)));
// Each white takes the named color nearest it, so two named whites both stay.
const OFF: [u8; 3] = [245, 245, 245];
let two = render(96, 64, 0.7, |x, y| {
if (x - 24.0).hypot(y - 32.0) <= 16.0 {
Some(OFF)
} else if (x - 72.0).hypot(y - 32.0) <= 16.0 {
Some(WHITE)
} else {
None
}
});
let params = TraceParams { logo_colors: vec!["#f5f5f5".into(), "#ffffff".into()], logo_white: false, ..logo_params() };
let res = trace(&two, &params);
assert_eq!(top_color(&res, Point::new(24.0, 32.0)), Some(OFF));
assert_eq!(top_color(&res, Point::new(72.0, 32.0)), Some(WHITE));
// A white square on blue keeps its edges where they are when it turns red.
let square = render(100, 100, 0.7, |x, y| Some(if (30.0..70.0).contains(&x) && (30.0..70.0).contains(&y) { WHITE } else { [0, 0, 255] }));
let res = trace(&square, &TraceParams { logo_colors: vec!["#ff0000".into(), "#0000ff".into()], logo_white: false, ..logo_params() });
for (inside, outside) in [(30.3, 29.7), (69.7, 70.3)] {
assert_eq!(top_color(&res, Point::new(inside, 50.0)), Some([255, 0, 0]), "at x {inside}");
assert_eq!(top_color(&res, Point::new(outside, 50.0)), Some([0, 0, 255]), "at x {outside}");
}
}
#[test]
fn a_large_image_is_traced_at_reduced_size_and_scaled_back() {
let (cx, cy, r) = (1200.0, 600.0, 400.0);
+10 -2
View File
@@ -110,9 +110,16 @@ pub struct TraceParams {
/// Stroke: the widest feature (px) traced as a stroke.
pub stroke_width: f64,
/// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is
/// always traced on its own and cannot be named, colours closer than about 12 (RGB distance)
/// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.)
/// traced on its own and cannot be named unless [`logo_white`](Self::logo_white) is off, colours
/// closer than about 12 (RGB distance) count as one, and at most ten are used. (Not `palette`:
/// that is Color mode's colour source.)
pub logo_colors: Vec<String>,
/// Flat Logo mode with named `logo_colors`: `true` traces white (every channel 245 or more) as a
/// color of its own beside the named ones. `false` traces the named colors only: white areas
/// take the nearest of them, a named white or near-white is used as named, and enclosed
/// transparent areas stay holes. With the colors found automatically, white is always its own,
/// and with [`ignore_white`](Self::ignore_white) white areas are left out whatever this says.
pub logo_white: bool,
}
impl Default for TraceParams {
@@ -141,6 +148,7 @@ impl Default for TraceParams {
strokes: false,
stroke_width: 10.0,
logo_colors: Vec::new(),
logo_white: true,
}
}
}
+9
View File
@@ -32,6 +32,15 @@ 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)).
**Flat Logo colors.** Flat Logo traces white as a color of its own beside the colors named in
`logoColors`, and takes a named color with every channel 245 or more as that white. Each pixel is read
as a blend of the traced colors and goes to the color with the largest share, so a near-white area can
go to a named light color instead of white. With `"logoWhite": false` in the params of `imageTrace.make` or
`imageTrace.makeAndExpand`, it traces the named colors only: white areas take the nearest named color,
a named white or near-white is used as named, and enclosed transparent areas stay holes instead of
being painted white. When Flat Logo finds the colors itself, `logoWhite` has no effect, and with
`ignoreWhite` white areas are left out whatever `logoWhite` says.
**Document Library color groups.** With Palette › Document Library and Library › Document Swatches,
the panel's **Colors** menu traces with every document swatch (**All**) or with the colors of one
color group from the Swatches panel, as the public
+1
View File
@@ -136,6 +136,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| 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) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| 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) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| 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; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; Flat Logo with named colors can leave out white with `logoWhite: false`; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 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; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), 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 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); brushes/symbols in progress | 23–37 |
| 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; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged) | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol and brush libraries done; brushes/symbols in progress | 23–37 |
+1
View File
@@ -1090,6 +1090,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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.
- **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. 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, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; 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). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756).
Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)).
Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`.