diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index d1eb20ed21..b4c0f9fdd8 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1025,6 +1025,37 @@ mod tests { assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0))); } + /// A point on a mesh's outline locates on the edge it lies on, on all four sides alike, with no + /// tolerance; a point just outside does not, and a tolerance reaches only as far as it says. + /// A point that isn't finite locates nowhere. + #[test] + fn mesh_locate_takes_a_point_on_any_side_of_the_outline() { + for (rows, cols) in [(1, 1), (2, 3)] { + let m = + GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0) + .unwrap(); + let sides = [ + ("top", Point::new(30.0, 0.0), Vec2::new(0.0, -0.01)), + ("bottom", Point::new(30.0, 100.0), Vec2::new(0.0, 0.01)), + ("left", Point::new(0.0, 70.0), Vec2::new(-0.01, 0.0)), + ("right", Point::new(100.0, 70.0), Vec2::new(0.01, 0.0)), + ]; + for (side, p, out) in sides { + let (r, c, u, v) = m.locate(p).unwrap_or_else(|| panic!("{rows}×{cols} {side}: {p:?} is not located")); + assert!(r < m.rows as usize && c < m.cols as usize && (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v), "{side}"); + assert!(m.eval(r, c, u, v).distance(p) < 1e-3, "{rows}×{cols} {side}: {:?}", m.eval(r, c, u, v)); + let mut e = m.clone(); + assert!(e.add_lines_at(p, 0.0).is_some(), "{rows}×{cols} {side}: no line at {p:?}"); + assert_eq!(m.locate(p + out), None, "{rows}×{cols} {side}: just outside"); + assert_eq!(m.locate_within(p + out * 100.0, 0.5), None, "{rows}×{cols} {side}: past the tolerance"); + assert!(m.locate_within(p + out * 100.0, 1.5).is_some(), "{rows}×{cols} {side}: within the tolerance"); + } + for p in [Point::new(f64::NAN, 0.0), Point::new(30.0, f64::INFINITY)] { + assert_eq!(m.locate_within(p, f64::INFINITY), None, "{p:?}"); + } + } + } + /// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB. #[test] fn a_cmyk_mesh_mixes_its_inks() { diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index be865e2769..91d814a42d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -530,6 +530,10 @@ pub(super) const MAX_BORDER_LINES: usize = 256; /// The most rounds of new lines a mesh made from a path takes to keep its border on the outline. const MAX_BORDER_ROUNDS: usize = 64; +/// A point this close (in points) to a mesh's outline is on it, for [`GradientMesh::locate_within`] +/// with any tolerance. +const ON_OUTLINE: f64 = 1e-6; + /// An anchor whose directions in and out differ by more than this (radians) is a corner: a border /// edge that can't follow the outline takes a mesh line through its sharpest corner first. const SHARP_TURN: f64 = 0.01; @@ -1436,17 +1440,24 @@ impl GradientMesh { } /// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the - /// patch and local (u, v) of the nearest point of the mesh's edge. + /// patch and local (u, v) of the nearest point of the mesh's edge. A point on the outline + /// (within [`ON_OUTLINE`]) locates on its edge on every side, with any `tol`. pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> { - if !self.is_valid() { + if !self.is_valid() || !p.is_finite() { return None; } - let bp = self.outline().to_bezpath(); - if bp.winding(p) == 0 { + // A point on the outline takes the nearest point of the edge on every side: the nonzero + // winding number counts a point on some edges as outside (a rectangle's bottom and right + // edges) and on the others as inside. + let edge = self.nearest_edge(p); + if let Some((_, r, c, u, v)) = edge.filter(|e| e.0 <= ON_OUTLINE * ON_OUTLINE) { + return Some((r, c, u, v)); + } + if self.outline().to_bezpath().winding(p) == 0 { if tol.is_nan() || tol <= 0.0 { return None; } - return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); + return edge.filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); } const N: usize = 16; let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 8e325c69be..0db3987e29 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -290,7 +290,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge → {index}", + "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge → {index}", has_doc, mesh_add_line ), diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index f2ab135817..4556e6f75f 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -1184,6 +1184,28 @@ fn mesh_add_line_on_the_edge_from_just_outside() { assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 140, "y": 210, "tolerance": 3})).is_err(), "past the tolerance"); } +/// `object.mesh.addLine` and Create Gradient Mesh `at` a point exactly on the outline add their +/// lines there with no tolerance, on the bottom and right edges as on the top and left ones. +#[test] +fn mesh_add_line_exactly_on_the_edge_needs_no_tolerance() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + let r = s.execute("object.mesh.create", &json!({"at": [50, 100]})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (1, 2), "a column through the bottom edge"); + assert!(m.points[r["index"].as_u64().unwrap() as usize].p.distance(Point::new(50.0, 100.0)) < 0.5); + for (x, y) in [(100.0, 30.0), (20.0, 0.0), (0.0, 80.0)] { + let r = s.execute("object.mesh.addLine", &json!({"id": a.0, "x": x, "y": y})).unwrap(); + let p = mesh_of(&s, a).points[r["index"].as_u64().unwrap() as usize].p; + assert!(p.distance(Point::new(x, y)) < 0.5, "({x}, {y}): {p:?}"); + } + assert_eq!((mesh_of(&s, a).rows, mesh_of(&s, a).cols), (3, 3)); + let n = undo_len(&s); + assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 60, "y": 100.01})).is_err(), "just outside"); + assert_eq!(undo_len(&s), n, "nothing changed"); +} + /// A mesh point `index`, and `rows`, `cols` or `columns` for Create Gradient Mesh, that is not a /// whole number is an error that changes nothing; 4.0 names point 4. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 3eadc40f4a..30fe6efc65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9626,7 +9626,8 @@ right/left/down/up; none stored means the smooth mesh through the points, as old `object.envelope.setMeshPoint {id, index, x, y, handle?}` and `object.mesh.movePoint`, `object.mesh.addLine` and `object.mesh.deletePoint` edit them like a gradient mesh's (one undo step each). On the canvas a selected envelope shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool -adds a row and a column where it clicks inside. `press_key` Escape during a drag of a point or handle of a mesh +adds a row and a column where it clicks inside. `object.mesh.addLine`, and `object.mesh.create` with `at`, take a point exactly on +the mesh's outline (within a millionth of a point) on any side without `tolerance`; a point outside needs `tolerance`. `press_key` Escape during a drag of a point or handle of a mesh envelope or a gradient mesh cancels it: the point goes back and no undo step is added. The Control bar shows Edit Envelope / Edit Contents, the warp's style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and Envelope Options. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11dc8cb895..7046b5f42b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1308,6 +1308,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. - **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. +- **A point on a mesh's outline is on the mesh:** a point exactly on the outline of a gradient mesh (within a millionth of a point) locates on the edge it lies on, on all four sides alike and with no tolerance, as it already did on the top and left edges, so `object.mesh.addLine` and Create Gradient Mesh `at` a point on the bottom or right edge add their lines there instead of reporting the point outside, and the Eyedropper samples the mesh's color there; a point just outside still needs `tolerance`. Tests: `mesh_locate_takes_a_point_on_any_side_of_the_outline` in `crates/doc/src/live.rs`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance` in `crates/engine/src/tests/live.rs`. 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`.