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.
This commit is contained in:
dotCooCoo
2026-11-01 01:22:08 +07:00
committed by bflatastic
parent c6644c15cb
commit 5a5a7b9ce8
7 changed files with 71 additions and 15 deletions
+15 -1
View File
@@ -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() {
+12 -4
View File
@@ -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<usize> {
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<usize> {
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;
+2 -2
View File
@@ -236,7 +236,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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
),
+6 -3
View File
@@ -16,8 +16,9 @@ pub(super) fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
};
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<Value> {
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;
}
+26
View File
@@ -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::<Vec<_>>());
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]
+9 -4
View File
@@ -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}))]
);
}
}
+1 -1
View File
@@ -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}))]
);
}