From 5a5a7b9ce8c2d359047d380881b338b2abde5e86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 08:09:04 -0700 Subject: [PATCH] Mesh tool: a click on a mesh's edge from just outside adds the lines there A Mesh tool click within the hit tolerance outside a gradient mesh hit the mesh, but object.mesh.addLine looked for the point inside the outline only and failed with "point is outside the mesh". The same click on a path's edge made a 1x1 mesh without the line. - GradientMesh::locate_within takes a tolerance: a point outside the mesh within it of the outline gives the nearest point of the edge. - object.mesh.addLine and object.mesh.create take an optional `tolerance` (0 by default), and the Mesh tool sends its hit tolerance. --- crates/doc/src/live.rs | 16 +++++++++++++++- crates/doc/src/live/mesh.rs | 16 ++++++++++++---- crates/engine/src/cmd/live/commands.rs | 4 ++-- crates/engine/src/cmd/live/mesh.rs | 9 ++++++--- crates/engine/src/tests/live.rs | 26 ++++++++++++++++++++++++++ crates/tools/src/meshblend.rs | 13 +++++++++---- crates/tools/src/meshedit.rs | 2 +- 7 files changed, 71 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 1b234c212..85949436b 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -237,13 +237,27 @@ mod tests { fn mesh_insert_and_remove_lines() { let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); - let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap(); + let i = m.add_lines_at(Point::new(30.0, 60.0), 0.0).unwrap(); assert_eq!((m.rows, m.cols), (2, 2)); assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p); assert!(m.remove_point_lines(i)); assert_eq!((m.rows, m.cols), (1, 1)); } + /// A point outside the mesh within the tolerance locates the nearest point of its edge. + #[test] + fn mesh_locate_within_reaches_the_edge() { + let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap(); + let p = Point::new(102.0, 30.0); + assert_eq!(m.locate(p), None); + assert_eq!(m.locate_within(p, 1.0), None); + let (r, c, u, v) = m.locate_within(p, 3.0).unwrap(); + assert_eq!((r, c), (0, 1)); + let q = m.eval(r, c, u, v); + assert!(q.distance(Point::new(100.0, 30.0)) < 0.5, "{q:?}"); + assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0))); + } + /// 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 f72dd548c..b4e74e711 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -592,11 +592,18 @@ impl GradientMesh { /// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh). pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> { + self.locate_within(p, 0.0) + } + + /// 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. + pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> { + use vectorcraft_geom::kurbo::ParamCurveNearest; if !self.is_valid() { return None; } let bp = self.outline().to_bezpath(); - if bp.winding(p) == 0 { + if bp.winding(p) == 0 && !(tol > 0.0 && bp.segments().any(|s| s.nearest(p, 1e-9).distance_sq <= tol * tol)) { return None; } const N: usize = 16; @@ -721,9 +728,10 @@ impl GradientMesh { self.cols += 1; } - /// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index. - pub fn add_lines_at(&mut self, p: Point) -> Option { - let (r, c, u, v) = self.locate(p)?; + /// Mesh tool click: add a row and a column through `p` (inside the mesh, or outside within + /// `tol` of its edge, through the nearest point of the edge). Returns the new point index. + pub fn add_lines_at(&mut self, p: Point, tol: f64) -> Option { + let (r, c, u, v) = self.locate_within(p, tol)?; let mut rr = r; let mut cc = c; let row_new = v > 0.02 && v < 0.98; diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index aec4e32d0..5f438c8f5 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -236,7 +236,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); 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?}", + "{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], tolerance?: pt (0), 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, or through the nearest point of the edge when `at` is outside within `tolerance`); 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 ), @@ -272,7 +272,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}", + "{id, x, y, color?, 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}", has_doc, mesh_add_line ), diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 602e2e3e9..bdba2b037 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -16,8 +16,9 @@ pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result { Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, None => MeshAppearance::Flat, }; - check_numbers(p, &["highlight"], C)?; + check_numbers(p, &["highlight", "tolerance"], C)?; let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); + let tol = f64_or(p, "tolerance", 0.0).max(0.0); let at = match p.get("at") { None | Some(Value::Null) => None, Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?), @@ -58,7 +59,7 @@ pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result { }; let Some(mut m) = m else { continue }; if let Some(q) = at { - index = m.add_lines_at(q); + index = m.add_lines_at(q, tol); } let mut mn = Node::new(n.id, NodeKind::Mesh(m)); mn.name = n.name.clone(); @@ -273,9 +274,11 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.addLine"; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); crate::cmd::paint::check_keep_model(p, C)?; + check_numbers(p, &["tolerance"], C)?; + let tol = f64_or(p, "tolerance", 0.0).max(0.0); let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| { - let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?; + let i = m.add_lines_at(q, tol).ok_or_else(|| bad(C, "point is outside the mesh"))?; if let Some(c) = color { m.points[i].color = c; } diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index a3b9f132b..b46e5b0d0 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -927,6 +927,32 @@ fn mesh_add_line_and_delete_point() { assert!(s.execute("object.mesh.deletePoint", &json!({"id": a.0, "index": 0})).is_err(), "corner lines can't go"); } +/// A Mesh tool click that hits a mesh's edge from just outside it adds a mesh line at the +/// nearest point of the edge; `tolerance` gives `object.mesh.addLine` the same reach. +#[test] +fn mesh_add_line_on_the_edge_from_just_outside() { + use crate::tooling::ViewInfo; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("mesh", v).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 99.0, 160.0), v).unwrap(); + } + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (2, 1), "a row through the left edge"); + assert!(m.points.iter().any(|q| q.p.distance(Point::new(100.0, 160.0)) < 0.5), "{:?}", m.points.iter().map(|q| q.p).collect::>()); + let r = s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 140, "y": 202, "tolerance": 3})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (2, 2), "a column through the bottom edge"); + assert!(m.points[r["index"].as_u64().unwrap() as usize].p.distance(Point::new(140.0, 200.0)) < 0.5); + assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 140, "y": 202})).is_err(), "no tolerance: outside"); + assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 140, "y": 210, "tolerance": 3})).is_err(), "past the tolerance"); +} + /// 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/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 68c1e4132..0790407d2 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -232,10 +232,12 @@ impl Tool for MeshTool { (Paint::Solid { color, .. }, false) => Some(crate::params::color_json(color)), _ => None, }; + // A click that hits an edge from just outside adds the lines at the edge. + let tol = cx.hit_options().tol; for id in [leaf, top] { match cx.doc.node(id).map(|n| &n.kind) { Some(NodeKind::Mesh(_)) => { - let mut params = json!({"id": id.0, "x": p.x, "y": p.y}); + let mut params = json!({"id": id.0, "x": p.x, "y": p.y, "tolerance": tol}); if let Some(c) = &color { params["color"] = c.clone(); params["keepModel"] = json!(true); @@ -243,10 +245,10 @@ impl Tool for MeshTool { return vec![Action::Exec("object.mesh.addLine".into(), params)]; } Some(NodeKind::Envelope { .. }) if cx.doc.node(id).and_then(grid_of).is_some() => { - return vec![Action::Exec("object.mesh.addLine".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; + return vec![Action::Exec("object.mesh.addLine".into(), json!({"id": id.0, "x": p.x, "y": p.y, "tolerance": tol}))]; } Some(NodeKind::Path { guide: false, .. }) | Some(NodeKind::Compound { .. }) => { - return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y]}))]; + return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y], "tolerance": tol}))]; } _ => {} } @@ -490,6 +492,9 @@ mod tests { let c = cx(&d, &s, &p); let mut t = create("mesh").unwrap(); let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)); - assert_eq!(acts, vec![Action::Exec("object.mesh.create".into(), json!({"ids": [a.0], "at": [150.0, 160.0]}))]); + assert_eq!( + acts, + vec![Action::Exec("object.mesh.create".into(), json!({"ids": [a.0], "at": [150.0, 160.0], "tolerance": c.hit_options().tol}))] + ); } } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index b3904efe4..e0ef779fb 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -360,7 +360,7 @@ mod tests { // Inside the envelope: a new row and column. assert_eq!( t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)), - vec![Action::Exec("object.mesh.addLine".into(), json!({"id": e.0, "x": 150.0, "y": 160.0}))] + vec![Action::Exec("object.mesh.addLine".into(), json!({"id": e.0, "x": 150.0, "y": 160.0, "tolerance": c.hit_options().tol}))] ); }