mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
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:
co-authored by
Claude Fable 5.1
Brandon Thomas
parent
a7a0d12a85
commit
a579ba2d3c
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user