From e4ad256d7d0c54243d8994fb6c6d22c5e0f369b2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:05:38 +0200 Subject: [PATCH] Gradient mesh: Object > Create Gradient Mesh on an embedded image (#1676) The mesh covers the image's rectangle with its transform kept; each point takes the average colour and opacity of the picture around it. A linked image is refused. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/pixels.rs | 53 +++++++++++++++++++++++ crates/engine/src/cmd/live.rs | 60 +++++++++++++++++++++----- crates/engine/src/tests_live.rs | 63 ++++++++++++++++++++++++++++ crates/engine/tests/command_sweep.rs | 60 ++++++++++++++++++++++++++ docs/gaps.md | 7 +++- docs/target-app-parity.md | 3 ++ 6 files changed, 234 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index f433f0568..306c8360e 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -79,6 +79,59 @@ impl ImageBlob { sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size) } + /// The colours of a `rows` × `cols` mesh laid over the image: `(rows + 1) * (cols + 1)` straight + /// RGBA samples, row-major, one per grid line crossing (row 0 is the top). Each is the + /// alpha-weighted average of the area around the crossing, one grid cell wide and high (a + /// quarter of each of the neighbouring cells), not a single pixel. A crossing over transparent + /// pixels gets their average colour at alpha 0. `None` when `rows` or `cols` is 0 or the + /// image can't be decoded. + pub fn grid_samples(&self, rows: u32, cols: u32) -> Option> { + if rows == 0 || cols == 0 { + return None; + } + let img = self.decoded()?; + let (w, h) = (img.width() as usize, img.height() as usize); + if w == 0 || h == 0 { + return None; + } + // The pixel columns [from, to) around grid line `i` of `n`, at least one pixel wide. + let span = |i: u32, n: u32, len: usize| { + let at = |k: f64| (k / n as f64 * len as f64).round().clamp(0.0, len as f64) as usize; + let (from, to) = (at(i as f64 - 0.5), at(i as f64 + 0.5)); + let from = from.min(len - 1); + (from, to.max(from + 1)) + }; + let mut out = Vec::with_capacity((rows as usize + 1) * (cols as usize + 1)); + for r in 0..=rows { + let (y0, y1) = span(r, rows, h); + for c in 0..=cols { + let (x0, x1) = span(c, cols, w); + let (mut sum, mut plain, mut alpha, mut n) = ([0u64; 3], [0u64; 3], 0u64, 0u64); + for y in y0..y1 { + for x in x0..x1 { + let [pr, pg, pb, pa] = img.get_pixel(x as u32, y as u32).0; + let a = pa as u64; + for (k, v) in [pr, pg, pb].into_iter().enumerate() { + sum[k] += v as u64 * a; + plain[k] += v as u64; + } + alpha += a; + n += 1; + } + } + let n = n.max(1); + out.push(if alpha == 0 { + let m = |k: usize| ((plain[k] + n / 2) / n) as u8; + [m(0), m(1), m(2), 0] + } else { + let m = |k: usize| ((sum[k] + alpha / 2) / alpha) as u8; + [m(0), m(1), m(2), ((alpha + n / 2) / n) as u8] + }); + } + } + Some(out) + } + /// The decoded pixels (the last image decoded is kept for the next sample). fn decoded(&self) -> Option> { thread_local! { diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 9209164be..316af4faa 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -238,7 +238,7 @@ pub fn specs() -> Vec { "Create Gradient Mesh…", ["Object"], None, - "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}", + "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}", has_selection, mesh_create ), @@ -1348,18 +1348,33 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { let mut out = vec![]; let mut index = None; + let mut refused = None; for id in &roots { let Some(n) = d.node(*id).cloned() else { continue }; - let Some(path) = outline_of(&n) else { continue }; - let m = match n.appearance.fill_paint() { - // The points take the colours the gradient paints there. - Paint::Gradient(g) => { - path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| { + let m = if let NodeKind::Image(im) = &n.kind { + // An embedded image: the points take the picture's colours there. + match image_mesh(d, im, rows, cols) { + Ok(mut m) => { m.highlight(app, highlight); - m - }) + Some(m) + } + Err(why) => { + refused.get_or_insert(why); + continue; + } + } + } else { + let Some(path) = outline_of(&n) else { continue }; + match n.appearance.fill_paint() { + // The points take the colours the gradient paints there. + Paint::Gradient(g) => { + path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| { + m.highlight(app, highlight); + m + }) + } + p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), } - p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight), }; let Some(mut m) = m else { continue }; if let Some(q) = at { @@ -1375,7 +1390,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { out.push(*id); } if out.is_empty() { - return Err(bad(C, "select a path or compound path")); + return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image"))); } sel.set(out.iter().copied()); Ok((out, index)) @@ -1387,6 +1402,31 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle +/// (placed with the image's transform), each point taking the average colour and opacity of the +/// area around it. Linked images are refused: the documentation allows embedded ones only. +fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result { + if im.link.is_some() { + return Err("a linked image can't become a gradient mesh: embed it first"); + } + let xf = im.xf.as_coeffs(); + if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) { + return Err("the image has no usable size or position"); + } + let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?; + let (w, h) = (im.width as f64, im.height as f64); + let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h); + if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) { + return Err("the image has no usable size or position"); + } + let cols_n = cols as usize + 1; + let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::>(); + Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| { + let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]); + (Color::rgb8(pr, pg, pb), pa as f32 / 255.0) + })) +} + /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh /// envelope's grid too (its points and handles are written back: editing fills in the handles). fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index 438ef0d94..c151ed3e6 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -732,3 +732,66 @@ fn disabled_without_live_selection() { assert!(s.execute("object.blend.make", &json!({})).is_err(), "needs two objects"); let _ = Paint::solid(Color::BLACK); } + +/// A selected embedded 8×4 image at (100, 50), 2 pt per pixel: red on the left half, blue on the +/// right half (which is transparent when `clear_right`). `link` makes it a linked image. +fn two_colour_image(s: &mut Session, clear_right: bool, linked: bool) -> NodeId { + let right = image::Rgba([0, 0, 255, if clear_right { 0 } else { 255 }]); + let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([255, 0, 0, 255]) } else { right }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((100.0, 50.0)) * vectorcraft_geom::Affine::scale(2.0); + let link = linked.then(|| vectorcraft_doc::LinkInfo { path: "/tmp/pic.png".into(), ..Default::default() }); + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default() }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn mesh_create_on_an_embedded_image_takes_its_colours_and_placement() { + let mut s = session(); + let id = two_colour_image(&mut s, false, false); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let m = mesh_of(&s, id); + assert_eq!((m.rows, m.cols, m.points.len()), (1, 2, 6)); + // Row 0 is the image's top: red at the left edge, the average of both halves in the middle + // (an area, not one pixel), blue at the right edge. + let hex = |i: usize| m.points[i].color.to_hex(); + assert_eq!((hex(0).as_str(), hex(1).as_str(), hex(2).as_str()), ("#ff0000", "#800080", "#0000ff")); + assert_eq!((hex(3).as_str(), hex(5).as_str()), ("#ff0000", "#0000ff")); + assert!(m.points.iter().all(|p| p.opacity == 1.0)); + // The mesh keeps the image's transform: (100, 50) to (116, 58). + let near = |p: Point, x: f64, y: f64| close(p.x, x) && close(p.y, y); + assert!(near(m.points[0].p, 100.0, 50.0) && near(m.points[2].p, 116.0, 50.0) && near(m.points[5].p, 116.0, 58.0)); + assert_eq!(node(&s, id).kind_label(), "Mesh"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, id).kind_label(), "Image"); +} + +#[test] +fn mesh_create_on_an_image_keeps_alpha_and_refuses_linked_and_big_grids() { + let mut s = session(); + let id = two_colour_image(&mut s, true, false); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + let m = mesh_of(&s, id); + assert_eq!(m.points.iter().map(|p| p.opacity).collect::>(), [1.0, 0.0, 1.0, 0.0]); + assert_eq!(m.points[0].color.to_hex(), "#ff0000"); + + let mut s = session(); + two_colour_image(&mut s, false, false); + assert!(s.execute("object.mesh.create", &json!({"rows": 51, "cols": 4})).is_err(), "grid capped"); + + let mut s = session(); + let id = two_colour_image(&mut s, false, true); + let e = s.execute("object.mesh.create", &json!({})).unwrap_err().to_string(); + assert!(e.contains("linked"), "{e}"); + assert_eq!(node(&s, id).kind_label(), "Image", "left as it was"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 121c4120b..a19d5caa8 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -735,3 +735,63 @@ fn text_commands_with_junk_params_on_inline_graphics() { } assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } + +/// Create Gradient Mesh on embedded, linked and broken images (bad pixels, zero size, a NaN +/// transform) with junk rows and columns: never a panic or a broken tree. +#[test] +fn gradient_mesh_from_images_with_junk_params() { + use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeKind}; + use vectorcraft_geom::Affine; + let img = image::RgbaImage::from_fn(5, 3, |x, y| image::Rgba([x as u8 * 50, y as u8 * 90, 7, if x == 2 { 0 } else { 255 }])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let params = [ + json!({}), + json!({"rows": 1, "cols": 1}), + json!({"rows": 50, "cols": 50}), + json!({"rows": 0, "cols": 3}), + json!({"rows": 51, "cols": 3}), + json!({"rows": -1, "cols": 1e308}), + json!({"rows": "x", "columns": null, "appearance": 5}), + json!({"rows": 7, "cols": 9, "appearance": "edge", "highlight": f64::MAX}), + json!({"at": [2.5, 1.0]}), + json!({"at": [1e308, -1e308]}), + json!({"ids": [u64::MAX]}), + ]; + // (stored bytes, width, height, transform, linked) + let variants: [(Vec, u32, u32, Affine, bool); 6] = [ + (png.clone(), 5, 3, Affine::scale(4.0), false), + (png.clone(), 5, 3, Affine::scale(4.0), true), + (png.clone(), 0, 0, Affine::scale(4.0), false), + (png.clone(), 5, 3, Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]), false), + (png[..png.len() / 2].to_vec(), 5, 3, Affine::IDENTITY, false), + (png.clone(), u32::MAX, u32::MAX, Affine::scale(1e300), false), + ]; + let mut failures = vec![]; + for (i, (bytes, w, h, xf, linked)) in variants.iter().enumerate() { + for p in ¶ms { + let mut s = Fixture::Empty.session(); + let placed = s.edit("Place", |d, sel| { + d.images.insert("pic".into(), ImageBlob::png(bytes.clone())); + let id = d.alloc_id(); + let link = linked.then(|| LinkInfo { path: "/nonexistent/pic.png".into(), ..Default::default() }); + let im = ImageObject { key: "pic".into(), width: *w, height: *h, xf: *xf, link, placement: Default::default() }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(()) + }); + if placed.is_err() { + // Insert already refuses a NaN or out-of-range transform. + assert!(i >= 3, "variant {i}: {placed:?}"); + continue; + } + let r = catch_quiet(|| no_panic(s.execute("object.mesh.create", p))); + if r.is_err() { + failures.push(format!("PANIC variant {i} {p}")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("variant {i} {p}: {e}")); + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 6276ba097..6b5507ecb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -227,8 +227,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall where three or more overlap. The group renders, saves and exports through its live expansion. Still missing: the right-click Bring Forward / Send Backward menu where three or more objects overlap; adding objects to the group by dragging them in the Layers panel is untested. -- **Missing (asked for by users, from earlier versions):** Object › Create Gradient - Mesh on an embedded image, which turns it into a mesh of its colours (#1676); radial gradients in Image Trace's +- **Done (from users' reports):** Object › Create Gradient Mesh on an embedded image turns it into a mesh over its + rectangle, with the image's transform kept; each point takes the average colour and opacity of the picture around it + (#1676). A linked image is refused, as the public documentation has it. +- **Missing (asked for by users, from earlier versions):** radial gradients in Image Trace's Gradients option, which traces linear ones only (draft #1849). Inside the estimate below. - **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular snapping and the consolidated snapping options (30.0); artboard locking, artboard colours and managing @@ -355,6 +357,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | 2026-10-14 | minor | G6: image variables (`VariableKind::Image`, `variable.makeImageDynamic`, path values on `dataset.select` keeping bounds); remaining: scripting surface, Batch, graph variables, dataset import/export; estimate 8–14 → 6–12 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. | +| 2026-10-16 | minor | G13: Object › Create Gradient Mesh on an embedded image (#1676); a linked image is refused. No entry closed, estimates unchanged | | 2026-10-15 | minor | G13: Object › Intertwine (#1763): Make, Edit, Release, the Intertwine tool (hover, click, drawn path) and `object.intertwine.arrange` (toggle, front, back). Bring Forward / Send Backward stay missing; adding objects through the Layers panel is untested. No entry closed, estimates unchanged | | 2026-10-14 | minor | MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged. | | 2026-10-14 | minor | Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5d954e895..602b09aa4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -601,6 +601,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, same shift from either side of an edge:** a crack's sub-pixel shift is averaged from its two sides, so abutting regions on one- and two-pixel slivers get the same points and leave nothing uncovered; code-drawn regressions in `crates/trace` (see [quality notes](image-trace-quality.md#same-sub-pixel-shift-from-either-side-of-an-edge)). - **Image Trace, simpler contour walk:** `contour::trace_mask` works out each turn from the pixels during the walk, with no precomputed direction table and no assertion; identical loops to the previous walk on every 4 × 4 mask (see [quality notes](image-trace-quality.md#a-simpler-contour-walk)). - **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. +- **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. *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. +- **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. - **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). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. @@ -742,6 +744,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Create Gradient Mesh on an embedded image (#1676): colours sampled as area averages, transform kept, linked images refused. Scores and hours unchanged | | 2026-10-16 | minor | The Move dialog opens on the offset of the last drag or Move and with the Copy, Transform Objects and Preview it was last confirmed with (Transform Patterns sets the preference it starts from), so Enter, Enter repeats a move or a copy; scores and hours unchanged | | 2026-10-16 | minor | Image Trace: a loop or junction-returning piece whose fit collapsed to one cubic keeps the fewest segments that hold its area and course instead of every positioned point, so a higher Paths no longer gives fewer segments on code-drawn noisy images (six falling runs to none; one fall with another cause remains). Scores and hours unchanged. | | 2026-10-16 | minor | Image Trace: a notch corner is cut only when the straight edges beside it lie in the shared piece, so both regions of the piece get the same points (code-drawn noisy colour images: 93 uncovered sample points to 0). Scores and hours unchanged. |