M8: a click with the Bend tool curves a sharp point, its handles along the line between its neighbours (#1875) (#1877)

* M8: a click with the Bend tool curves a sharp point, its handles along the line between its neighbours (#1875)

A release without movement on a sharp point between two segments runs ops::auto_smooth instead of cancelling; a curved point still sharpens, a drag still sets the handles by hand, an endpoint is left alone. Tests; docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Vector edit hit test: the nearer of a handle's end and a point wins

A short path's handles end within the point's hit tolerance; a click on the point is on the point, one on the handle's end still takes the handle. Test short_handles_leave_their_point_clickable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Vector edit hit test: the nearer of a handle's end and a point is judged on the page

Candidates stay within the tolerance as before; ranking by page distance keeps a non-uniform scale from favouring the wrong one. Test a_handle_and_a_point_are_compared_on_the_page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Vector edit hit test: a handle wins a tie with a point, as before

A handle snapped onto another point stays selectable there. Test a_handle_on_another_point_is_still_the_handles (fails with the strict comparison).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Trevor Brown
2026-11-03 00:13:38 -05:00
committed by GitHub
co-authored by Claude Fable 5.1 Brandon Thomas
parent a7a0d12a85
commit a579ba2d3c
4 changed files with 165 additions and 15 deletions
+28 -12
View File
@@ -403,7 +403,9 @@ fn visible_handles(net: &VectorNetwork, ve: &VectorEdit) -> Vec<(usize, bool)> {
}
/// Hit test in page coordinates with a tolerance in page units. Visible handles win, then
/// vertices, then segments.
/// vertices, then segments; between a handle and a point both within reach, the nearer one on
/// the page, the handle on a tie (the handles of a short segment end a few pixels from their
/// point, and a click on the point is on the point, #1875).
pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit> {
let net = network(doc, ve.id)?;
let w = doc.world_transform(ve.id);
@@ -414,17 +416,31 @@ pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit>
if !(lp.x.is_finite() && lp.y.is_finite()) {
return None;
}
for (si, start) in visible_handles(net, ve) {
let (Some(seg), Some(t)) = (net.segments.get(si), net.handle(si, start)) else { continue };
let Some(v) = net.vertices.get(if start { seg.a } else { seg.b }) else { continue };
if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl {
return Some(VHit::Handle { seg: si, start });
}
}
let nearest_vertex =
net.vertices.iter().enumerate().map(|(i, v)| (i, (v.point() - lp).hypot())).filter(|(_, d)| *d <= tl).min_by(|a, b| a.1.total_cmp(&b.1));
if let Some((index, _)) = nearest_vertex {
return Some(VHit::Vertex { index });
// Candidates are within the tolerance as before; the nearest is judged on the page, where a
// non-uniform scale doesn't distort it.
let on_page = |q: Point| (w * q - p).hypot();
let nearest_handle = visible_handles(net, ve)
.into_iter()
.filter_map(|(si, start)| {
let (seg, t) = (net.segments.get(si)?, net.handle(si, start)?);
let v = net.vertices.get(if start { seg.a } else { seg.b })?;
let end = Point::new(v.x + t.0, v.y + t.1);
((end - lp).hypot() <= tl).then(|| ((si, start), on_page(end)))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
let nearest_vertex = net
.vertices
.iter()
.enumerate()
.filter(|(_, v)| (v.point() - lp).hypot() <= tl)
.map(|(i, v)| (i, on_page(v.point())))
.min_by(|a, b| a.1.total_cmp(&b.1));
match (nearest_handle, nearest_vertex) {
// A tie (a handle's end on another point) goes to the handle, as handles always won before.
(Some(((seg, start), dh)), Some((_, dv))) if dh <= dv => return Some(VHit::Handle { seg, start }),
(Some(((seg, start), _)), None) => return Some(VHit::Handle { seg, start }),
(_, Some((index, _))) => return Some(VHit::Vertex { index }),
(None, None) => {}
}
use figcraft_geom::kurbo::ParamCurveNearest;
let nearest_seg = (0..net.segments.len())
@@ -1072,12 +1072,18 @@ impl Session {
}
}
VGesture::BendVertex { v, moved, smooth, .. } => {
let id = ve.id;
if moved {
self.commit_interaction()?;
} else if smooth {
let id = ve.id;
self.preview(|doc, _| edit(doc, id, |net| ops::make_sharp(net, v)))?;
self.commit_interaction()?;
} else if network(self.doc()?, id).is_some_and(|n| n.incident(v).len() == 2) {
// "Click on a point or path where you want to add a curve" (Edit vector
// layers): a sharp point between two segments gets handles along the line
// between its neighbours (#1875); an endpoint has nothing to curve.
self.preview(|doc, _| edit(doc, id, |net| ops::auto_smooth(net, v)))?;
self.commit_interaction()?;
} else {
self.cancel_interaction()?;
}
@@ -119,6 +119,16 @@ fn bend_tool_smooths_and_sharpens_points() {
let n = net(&s, id);
let v = vertex_at(&n, 100.0, 0.0);
assert!(n.incident(v).iter().all(|(s, st)| n.handle(*s, *st) == Some((0.0, 0.0))), "a click sharpens it");
// Clicked again, the sharp corner curves: handles along the line between its neighbours
// (0,0) and (100,100), a third of the way to each (the box refits around the bulge, so the
// point is found by its index).
click(&mut s, 100.0, 0.0);
let n = net(&s, id);
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
for (seg, st) in n.incident(v) {
let h = n.handle(seg, st).unwrap();
assert!((h.0.abs() - 100.0 / 3.0 / 2f64.sqrt()).abs() < 1e-6 && (h.0 - h.1).abs() < 1e-6, "along the neighbours' line: {h:?}");
}
// ⌘-drag on a segment with the Move tool bends it.
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
let mut down = ev(PointerKind::Down, 0.0, 50.0);
@@ -130,6 +140,123 @@ fn bend_tool_smooths_and_sharpens_points() {
assert!(b.x0 < -25.0, "bent outwards: {b:?}");
}
/// #1875: a line whose middle point was moved off the line, clicked with the Bend tool, curves
/// there ("Click on a point or path where you want to add a curve", Edit vector layers): the
/// handles run along the line between the neighbours, a third of the way to each, in one undo
/// step; an endpoint is left alone; a click on the curved point makes it sharp again.
#[test]
fn a_bend_click_curves_a_sharp_point_between_its_neighbours() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 30], [100, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
let (v, end) = (vertex_at(&net(&s, id), 50.0, 30.0), vertex_at(&net(&s, id), 0.0, 0.0));
let before = s.undo_depth().0;
click(&mut s, 50.0, 30.0);
let n = net(&s, id);
let reach = (50f64.powi(2) + 30f64.powi(2)).sqrt() / 3.0;
for (seg, st) in n.incident(v) {
let h = n.handle(seg, st).unwrap();
assert!((h.0.abs() - reach).abs() < 1e-6 && h.1.abs() < 1e-6, "along the neighbours' line, a third of the way: {h:?}");
}
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
assert_eq!(s.undo_depth().0, before + 1, "one undo step");
// An endpoint has nothing to curve: the click changes nothing and leaves no undo step.
click(&mut s, 0.0, 0.0);
let n = net(&s, id);
assert!(n.incident(end).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
assert_eq!(s.undo_depth().0, before + 1);
// The curved point clicked again is sharp again.
click(&mut s, 50.0, 30.0);
let n = net(&s, id);
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
}
/// Between a handle's end and a point both within reach, the nearer on the page wins, also under a
/// non-uniform scale (10× wide here), where local distances would pick the point.
#[test]
fn a_handle_and_a_point_are_compared_on_the_page() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [5, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let mut n = VectorNetwork::default();
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
n.vertices.push(figcraft_doc::Vertex::new(5.0, 0.0));
let mut seg = figcraft_doc::Segment::line(0, 1);
seg.ta = (0.5, -0.9);
n.segments.push(seg);
node.kind = figcraft_doc::NodeKind::Vector { network: n };
node.transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0);
}
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
let doc = s.doc().unwrap().clone();
// On the page the point is at (0, 0) and its handle's end at (5, -0.9): from (4, 0) the
// handle's end is 1.3 away and the point 4, though locally the point is the nearer.
assert_eq!(hit(&doc, &ve(&s), pt(4.0, 0.0), 6.0), Some(VHit::Handle { seg: 0, start: true }));
assert_eq!(hit(&doc, &ve(&s), pt(0.0, 0.0), 6.0), Some(VHit::Vertex { index: 0 }));
}
/// A handle whose end lies exactly on another point (snapped there) can still be taken there: on a
/// tie the handle wins, as handles always did.
#[test]
fn a_handle_on_another_point_is_still_the_handles() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [10, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let mut n = VectorNetwork::default();
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
n.vertices.push(figcraft_doc::Vertex::new(10.0, 0.0));
let mut seg = figcraft_doc::Segment::line(0, 1);
seg.ta = (10.0, 0.0);
n.segments.push(seg);
node.kind = figcraft_doc::NodeKind::Vector { network: n };
node.transform = figcraft_geom::Affine::IDENTITY;
}
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
let doc = s.doc().unwrap().clone();
// Where the points are on the page now (entering edit mode may refit the layer's box).
let n = net(&s, id);
let w = doc.world_transform(id);
let (p0, p1) = (w * n.vertices[0].point(), w * n.vertices[1].point());
// Point 0's handle ends on point 1: there the handle; on point 0, the point.
assert_eq!(hit(&doc, &ve(&s), p1, 6.0), Some(VHit::Handle { seg: 0, start: true }));
assert_eq!(hit(&doc, &ve(&s), p0, 6.0), Some(VHit::Vertex { index: 0 }));
}
/// A short path's handles end a few pixels from their point: a click on the point is still on the
/// point (the nearer of the two wins), so the Bend tool can sharpen it again, while a click on the
/// handle's end still takes the handle.
#[test]
fn short_handles_leave_their_point_clickable() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [6, 10], [12, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
let v = vertex_at(&net(&s, id), 6.0, 10.0);
click(&mut s, 6.0, 10.0);
let n = net(&s, id);
let curved = |n: &VectorNetwork| n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st).is_some_and(|h| h != (0.0, 0.0)));
assert!(curved(&n));
let reach = (6f64.powi(2) + 10f64.powi(2)).sqrt() / 3.0;
assert!(reach < 6.0, "the handles end within the 6 px hit tolerance: {reach}");
// A click on the handle's end takes the handle (nothing changes); one on the point sharpens it.
let (sg, st) = n.incident(v)[0];
let h = n.handle(sg, st).unwrap();
click(&mut s, 6.0 + h.0, 10.0 + h.1);
assert!(curved(&net(&s, id)), "the handle's end is the handle's");
click(&mut s, 6.0, 10.0);
let n = net(&s, id);
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))), "the point is the point's: sharp again");
}
#[test]
fn paint_bucket_fills_and_unfills_areas() {
let mut s = Session::new();
+3 -2
View File
File diff suppressed because one or more lines are too long