Merge pull request #821 from sanyamsingh001/codex/pen-endpoints

Fix Pen endpoint picking and commit handle previews
This commit is contained in:
BFlat
2026-10-12 07:19:52 +07:00
committed by GitHub
4 changed files with 1277 additions and 47 deletions
+4
View File
@@ -81,3 +81,7 @@ sync_github_name = true
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
+357
View File
@@ -1038,6 +1038,363 @@ fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() {
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3);
}
/// Pen interactions exercised through the real preview/commit and undo machinery.
mod pen_interactions {
use super::*;
use vectorcraft_geom::{AnchorKind, PathData, Point};
use vectorcraft_tools::{Cursor, Mods};
fn gesture(s: &mut Session, pts: &[(f64, f64)], mods: Mods, v: ViewInfo) {
let (first, last) = (pts[0], pts[pts.len() - 1]);
s.pointer(&PointerEvent::new(PointerKind::Down, first.0, first.1).with_mods(mods), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(mods), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, last.0, last.1).with_mods(mods), v).unwrap();
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn curve() -> (Session, NodeId, ViewInfo) {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(100.0, 300.0)], Mods::default(), v);
gesture(&mut s, &[(213.0, 313.0), (233.0, 333.0), (253.0, 353.0)], Mods::default(), v);
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(path(&s, id).anchor_count(), 2);
(s, id, v)
}
fn nearby_endpoints(zoom: f64) -> (Session, NodeId, ViewInfo) {
let mut s = session();
let v = ViewInfo { zoom, smart_guides: false, snap_to_point: false, ..ViewInfo::default() };
let end_x = 100.0 + 6.0 / zoom;
let created = s
.execute(
"path.create",
&json!({"anchors": [
{"x": 100.0, "y": 300.0},
{"x": end_x, "y": 300.0, "in": [end_x, 280.0], "out": [end_x, 320.0]}
]}),
)
.unwrap();
let id = NodeId(created["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("prefs.set", &json!({"key": "selectionTolerance", "value": 8})).unwrap();
s.select_tool("pen", v).unwrap();
(s, id, v)
}
#[test]
fn overlapping_endpoints_convert_the_terminal_anchor_without_closing_and_undo() {
for zoom in [0.5, 1.0, 2.0] {
for grid in [false, true] {
for alt in [false, true] {
let (mut s, id, v) = nearby_endpoints(zoom);
let v = ViewInfo { snap_to_grid: grid, ..v };
let before = path(&s, id);
let last = before.subpaths[0].anchors[1];
gesture(&mut s, &[(last.p.x, last.p.y)], Mods::default(), v);
let steps = s.doc().unwrap().history.undo.len();
let mods = Mods { alt, ..Mods::default() };
assert_eq!(s.cursor(last.p, mods, v), Cursor::PenConvert);
gesture(&mut s, &[(last.p.x, last.p.y)], mods, v);
let converted = path(&s, id);
assert!(!converted.is_closed(), "zoom={zoom}, grid={grid}, alt={alt}");
assert_eq!(converted.anchor_count(), 2);
assert_eq!(converted.subpaths[0].anchors[0], before.subpaths[0].anchors[0]);
let a = converted.subpaths[0].anchors[1];
assert_eq!(a.h_out, a.p);
assert_eq!(a.h_in, if alt { a.p } else { last.h_in });
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, id), converted);
}
}
}
}
#[test]
fn overlapping_endpoints_reverse_from_the_first_anchor_and_undo() {
for zoom in [0.5, 1.0, 2.0] {
for grid in [false, true] {
let (mut s, id, v) = nearby_endpoints(zoom);
let v = ViewInfo { snap_to_grid: grid, ..v };
let before = path(&s, id);
let first = before.subpaths[0].anchors[0].p;
let steps = s.doc().unwrap().history.undo.len();
assert_eq!(s.cursor(first, Mods::default(), v), Cursor::PenContinue);
gesture(&mut s, &[(first.x, first.y)], Mods::default(), v);
let reversed = path(&s, id);
assert_eq!(reversed.subpaths[0].anchors[0].p, before.subpaths[0].anchors[1].p);
assert_eq!(reversed.subpaths[0].anchors[1].p, first);
assert!(!reversed.is_closed());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, id), reversed);
gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v);
let extended = path(&s, id);
assert_eq!(extended.anchor_count(), 3);
assert_eq!(extended.subpaths[0].anchors[..2], reversed.subpaths[0].anchors);
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
}
}
/// #544: snapping and the configured pick radius must not steal a terminal-anchor press.
#[test]
fn terminal_conversion_keeps_the_curve_and_is_undoable() {
let (mut s, id, v) = curve();
let before = path(&s, id);
let steps = s.doc().unwrap().history.undo.len();
s.execute("prefs.set", &json!({"key": "selectionTolerance", "value": 8})).unwrap();
let v = ViewInfo { zoom: 2.0, snap_to_grid: true, ..v };
let pos = Point::new(213.0, 316.0); // Six screen pixels from the anchor.
assert_eq!(s.cursor(pos, Mods::default(), v), Cursor::PenConvert);
gesture(&mut s, &[(pos.x, pos.y)], Mods::default(), v);
let converted = path(&s, id);
let last = converted.subpaths[0].anchors[1];
assert_eq!(last.p, Point::new(213.0, 313.0));
assert_eq!(last.h_in, before.subpaths[0].anchors[1].h_in);
assert_eq!(last.h_out, last.p);
assert_eq!(last.kind, AnchorKind::Corner);
assert_eq!(converted.to_bezpath(), before.to_bezpath(), "the completed curve is untouched");
assert_eq!(converted.anchor_count(), 2);
assert!(!s.in_interaction());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, id), converted);
gesture(&mut s, &[(300.0, 400.0)], Mods::default(), v);
let extended = path(&s, id);
assert_eq!(extended.anchor_count(), 3);
assert_eq!(extended.subpaths[0].anchors[..2], converted.subpaths[0].anchors);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
#[test]
fn terminal_handle_drag_keeps_the_incoming_handle_and_shift_constraint() {
let (mut s, id, v) = curve();
let before = path(&s, id);
let steps = s.doc().unwrap().history.undo.len();
let shift = Mods { shift: true, ..Mods::default() };
gesture(&mut s, &[(213.0, 313.0), (223.0, 323.0), (233.0, 343.0), (243.0, 353.0)], shift, v);
let moved = path(&s, id);
let last = moved.subpaths[0].anchors[1];
assert_eq!(last.h_in, before.subpaths[0].anchors[1].h_in);
assert_eq!(last.h_out, last.p + vectorcraft_geom::constrain_angle(Point::new(243.0, 353.0) - last.p, 45.0));
assert_eq!(moved.to_bezpath(), before.to_bezpath());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "all previews are one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
}
/// #516: the last anchor honors Alt's Anchor Point behavior, and drawing continues afterwards.
#[test]
fn alt_on_the_terminal_anchor_converts_both_handles_and_resumes() {
let (mut s, id, v) = curve();
let alt = Mods { alt: true, ..Mods::default() };
gesture(&mut s, &[(213.0, 313.0), (243.0, 343.0), (243.0, 353.0)], alt, v);
let last = path(&s, id).subpaths[0].anchors[1];
assert_eq!(last.kind, AnchorKind::Smooth);
assert_eq!(last.h_in, Point::new(183.0, 273.0));
assert_eq!(last.h_out, Point::new(243.0, 353.0));
gesture(&mut s, &[(213.0, 313.0)], alt, v);
let last = path(&s, id).subpaths[0].anchors[1];
assert_eq!((last.h_in, last.h_out), (last.p, last.p));
gesture(&mut s, &[(300.0, 400.0)], Mods::default(), v);
assert_eq!(path(&s, id).anchor_count(), 3);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
#[test]
fn modifiers_on_a_subsequent_anchor_keep_the_retracted_terminal_curve() {
let (mut s, id, v) = curve();
let v = ViewInfo { smart_guides: false, snap_to_point: false, ..v };
gesture(&mut s, &[(213.0, 313.0)], Mods::default(), v);
let before = path(&s, id);
let steps = s.doc().unwrap().history.undo.len();
let alt = Mods { alt: true, ..Mods::default() };
for (kind, x, y, mods) in [
(PointerKind::Down, 310.0, 430.0, Mods::default()),
(PointerKind::Drag, 340.0, 450.0, Mods::default()),
(PointerKind::Drag, 370.0, 480.0, alt),
(PointerKind::Drag, 380.0, 500.0, Mods { space: true, ..alt }),
(PointerKind::Drag, 380.0, 490.0, Mods { shift: true, ..alt }),
(PointerKind::Up, 380.0, 490.0, Mods::default()),
] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap();
}
let extended = path(&s, id);
assert_eq!(extended.anchor_count(), 3);
assert_eq!(extended.subpaths[0].anchors[..2], before.subpaths[0].anchors);
let a = extended.subpaths[0].anchors[2];
assert_eq!(a.p, Point::new(320.0, 450.0));
assert_eq!(a.h_in, Point::new(290.0, 430.0), "Alt freezes the incoming handle and Space translates it");
assert_eq!(a.h_out, a.p + vectorcraft_geom::constrain_angle(Point::new(380.0, 490.0) - a.p, 45.0));
assert_eq!(a.kind, AnchorKind::Corner);
assert!(!s.in_interaction());
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, id), extended);
}
#[test]
fn cancelling_a_pen_preview_restores_the_curve_without_a_history_step() {
for kind in ["place", "handle", "close", "alt"] {
let (mut s, id, v) = curve();
let before = path(&s, id);
let steps = s.doc().unwrap().history.undo.len();
let at = match kind {
"place" => (300.0, 400.0),
"close" => (100.0, 300.0),
_ => (213.0, 313.0),
};
let mods = Mods { alt: kind == "alt", ..Mods::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1).with_mods(mods), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, at.0 + 25.0, at.1 + 40.0).with_mods(mods), v).unwrap();
assert_ne!(path(&s, id), before);
s.cancel_interaction().unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, at.0 + 25.0, at.1 + 40.0), v).unwrap();
assert_eq!(path(&s, id), before, "{kind}");
assert!(!s.in_interaction());
assert!(!s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// Closing ends drawing; resuming from the terminal end still edits the same path.
if kind == "close" {
gesture(&mut s, &[(213.0, 313.0)], Mods::default(), v);
}
gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v);
assert_eq!(path(&s, id).anchor_count(), 3);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
}
#[test]
fn ending_the_first_anchor_drag_keeps_one_undoable_path() {
for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("pen", v).unwrap();
let steps = s.doc().unwrap().history.undo.len();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 140.0, 320.0), v).unwrap();
let id = s.doc().unwrap().selection.objects[0];
let preview = path(&s, id);
match key {
Some(k) => {
s.tool_key(k, Mods::default(), v).unwrap();
}
None => {
s.select_tool("selection", v).unwrap();
}
}
assert!(!s.in_interaction());
assert!(!s.tool_busy());
assert_eq!(path(&s, id), preview);
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
if key.is_some() {
s.pointer(&PointerEvent::new(PointerKind::Up, 140.0, 320.0), v).unwrap();
}
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, id), preview);
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(500.0, 500.0)], Mods::default(), v);
assert_eq!(path(&s, id), preview);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
}
/// #516: ending a drag must keep the preview as one undo step, with no transaction left open.
#[test]
fn ending_a_pen_drag_finishes_its_transaction() {
for kind in ["place", "handle", "close", "alt"] {
for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] {
let (mut s, id, v) = curve();
let before = path(&s, id);
let steps = s.doc().unwrap().history.undo.len();
let at = match kind {
"place" => (300.0, 400.0),
"close" => (100.0, 300.0),
_ => (213.0, 313.0),
};
let mods = Mods { alt: kind == "alt", ..Mods::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1).with_mods(mods), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, at.0 + 25.0, at.1 + 40.0).with_mods(mods), v).unwrap();
let preview = path(&s, id);
assert!(s.in_interaction());
match key {
Some(k) => {
s.tool_key(k, Mods::default(), v).unwrap();
}
None => {
s.select_tool("selection", v).unwrap();
}
}
assert!(!s.in_interaction(), "{kind}, {key:?}");
assert!(!s.tool_busy());
assert_eq!(path(&s, id), preview);
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
if key.is_some() {
// A delayed button release after Enter/Escape must not re-open or re-commit.
s.pointer(&PointerEvent::new(PointerKind::Up, at.0 + 25.0, at.1 + 40.0), v).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path(&s, id), preview);
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(500.0, 500.0)], Mods::default(), v);
assert_eq!(path(&s, id), preview, "the ended path stays as it was");
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "a new path starts");
}
}
}
#[test]
fn shift_click_closes_an_oblique_path_before_constraining_a_new_point() {
let (mut s, id, v) = curve();
let shift = Mods { shift: true, ..Mods::default() };
assert_eq!(s.cursor(Point::new(100.0, 300.0), shift, v), Cursor::PenClose);
gesture(&mut s, &[(100.0, 300.0)], shift, v);
assert!(path(&s, id).is_closed());
assert_eq!(path(&s, id).anchor_count(), 2);
}
#[test]
fn an_ended_path_resumes_from_either_end_with_grid_snapping() {
for first in [false, true] {
let (mut s, id, v) = curve();
let before = path(&s, id);
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
let v = ViewInfo { snap_to_grid: true, ..v };
let end = before.subpaths[0].anchors[usize::from(!first)].p;
assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue);
gesture(&mut s, &[(end.x, end.y)], Mods::default(), v);
gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v);
let resumed = path(&s, id);
assert_eq!(resumed.anchor_count(), 3);
assert_eq!(resumed.subpaths[0].anchors[1].p, end);
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1);
}
}
}
#[test]
fn direct_selection_moves_one_anchor() {
let mut s = session();
+419
View File
@@ -0,0 +1,419 @@
//! Original tiny-geometry controls for Pen endpoint/Alt integration with continue-any-path.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use vectorcraft_engine::{Session, ViewInfo};
use vectorcraft_geom::{AnchorKind, Point};
use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.prefs.selection_tolerance = 8.0;
s.prefs.use_precise_cursors = false;
s
}
fn view() -> ViewInfo {
ViewInfo { zoom: 1.0, smart_guides: false, snap_to_grid: false, snap_to_pixel: false, snap_to_point: false, ..Default::default() }
}
fn node(s: &Session, id: NodeId) -> &Node {
s.doc().unwrap().doc.node(id).unwrap()
}
fn path_ids(s: &Session) -> Vec<NodeId> {
let mut ids = vec![];
s.doc().unwrap().doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. }) {
ids.push(n.id);
}
});
ids.sort_unstable();
ids
}
fn points(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
let path = node(s, id).path_data().unwrap();
assert_eq!(path.subpaths.len(), 1);
assert!(!path.subpaths[0].closed);
path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
}
fn line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
let result = s.execute("shape.line", &json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1})).unwrap();
NodeId(result["id"].as_u64().unwrap())
}
fn gesture(s: &mut Session, view: ViewInfo, samples: &[(f64, f64)], mods: Mods) {
let first = samples.first().unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, first.0, first.1).with_mods(mods), view).unwrap();
for &(x, y) in samples.iter().skip(1) {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(mods), view).unwrap();
}
let last = samples.last().unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, last.0, last.1).with_mods(mods), view).unwrap();
}
// B is above A. Its first endpoint coincides with A's interior corner, not either end of A.
fn overlapping_endpoint() -> (Session, NodeId, NodeId) {
let mut s = session();
let result = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 100}, {"x": 200, "y": 100}, {"x": 300, "y": 100}]})).unwrap();
let a = NodeId(result["id"].as_u64().unwrap());
let b = line(&mut s, (200.0, 100.0), (200.0, 200.0));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.select_tool("pen", view()).unwrap();
assert_eq!(s.cursor(Point::new(300.0, 100.0), Mods::default(), view()), Cursor::PenContinue);
gesture(&mut s, view(), &[(300.0, 100.0)], Mods::default());
assert_eq!(path_ids(&s), vec![a, b]);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
(s, a, b)
}
#[test]
fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo_redo() {
let (mut s, a, b) = overlapping_endpoint();
let before_a = node(&s, a).clone();
let before_b = node(&s, b).clone();
let before_undo = s.doc().unwrap().history.undo.len();
let alt = Mods { alt: true, ..Default::default() };
let advertised = s.cursor(Point::new(200.0, 100.0), alt, view());
gesture(&mut s, view(), &[(200.0, 100.0), (200.0, 120.0)], alt);
let mut converted_a = before_a.clone();
let NodeKind::Path { path, .. } = &mut converted_a.kind else { panic!("fixture must be a path") };
let middle = &mut path.subpaths[0].anchors[1];
middle.kind = AnchorKind::Smooth;
middle.h_in = Point::new(200.0, 80.0);
middle.h_out = Point::new(200.0, 120.0);
assert_eq!(path_ids(&s), vec![a, b], "Alt conversion must not join away either object");
assert_eq!(node(&s, a), &converted_a, "only A's interior corner becomes smooth");
assert_eq!(node(&s, b), &before_b, "B's endpoint is under the pointer but B must stay unchanged");
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Convert Anchor Point");
assert_eq!(path_ids(&s), vec![a, b]);
assert_eq!(node(&s, a), &before_a);
assert_eq!(node(&s, b), &before_b);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![a, b]);
assert_eq!(node(&s, a), &converted_a);
assert_eq!(node(&s, b), &before_b);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
// Check the captured cursor last: a mismatch must not hide the actual conversion/history controls.
assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join");
}
#[test]
fn plain_click_at_same_endpoint_joins_paths_with_undo_redo() {
let (mut s, a, b) = overlapping_endpoint();
let before_a = node(&s, a).clone();
let before_b = node(&s, b).clone();
let before_undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.cursor(Point::new(200.0, 100.0), Mods::default(), view()), Cursor::PenJoin);
gesture(&mut s, view(), &[(200.0, 100.0)], Mods::default());
assert_eq!(path_ids(&s), vec![a]);
assert!(s.doc().unwrap().doc.node(b).is_none());
assert_eq!(points(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 100.0), (200.0, 100.0), (200.0, 200.0)]);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
let joined = node(&s, a).clone();
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Join");
assert_eq!(path_ids(&s), vec![a, b]);
assert_eq!(node(&s, a), &before_a);
assert_eq!(node(&s, b), &before_b);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![a]);
assert_eq!(node(&s, a), &joined);
assert!(s.doc().unwrap().doc.node(b).is_none());
}
fn grid_continue(preselected: bool, snap_to_grid: bool) {
let mut s = session();
let mut prefs = s.prefs.clone();
prefs.gridline_every = 100.0;
prefs.grid_subdivisions = 1;
s.apply_prefs(prefs);
assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0);
assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1);
let id = line(&mut s, (13.0, 303.0), (63.0, 303.0));
let original = node(&s, id).clone();
s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap();
let view = ViewInfo { snap_to_grid, ..view() };
s.select_tool("pen", view).unwrap();
assert_eq!(s.cursor(Point::new(63.0, 303.0), Mods::default(), view), Cursor::PenContinue);
let journal_start = s.journal.len();
gesture(&mut s, view, &[(63.0, 303.0)], Mods::default());
assert_eq!(path_ids(&s), vec![id], "clicking the advertised endpoint must continue it, not create a snapped path");
assert_eq!(node(&s, id), &original);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
let before_append = s.doc().unwrap().history.undo.len();
gesture(&mut s, view, &[(200.0, 300.0)], Mods::default());
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(points(&s, id), [(13.0, 303.0), (63.0, 303.0), (200.0, 300.0)]);
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
let extended = node(&s, id).clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id), &original);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(node(&s, id), &extended);
}
#[test]
fn unselected_grid_snapped_endpoint_continues_at_raw_pointer() {
grid_continue(false, true);
}
#[test]
fn preselected_grid_snapped_endpoint_continues_at_raw_pointer() {
grid_continue(true, true);
}
#[test]
fn unselected_endpoint_continues_with_grid_snap_disabled() {
grid_continue(false, false);
}
fn nearest_first_continue(preselected: bool) {
let mut s = session();
let id = line(&mut s, (100.0, 300.0), (106.0, 300.0));
s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap();
s.select_tool("pen", view()).unwrap();
// Both ends are within the explicit 8 px tolerance, but the first is exactly under the pointer.
assert_eq!(s.cursor(Point::new(100.0, 300.0), Mods::default(), view()), Cursor::PenContinue);
gesture(&mut s, view(), &[(100.0, 300.0)], Mods::default());
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
gesture(&mut s, view(), &[(80.0, 300.0)], Mods::default());
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(points(&s, id), [(106.0, 300.0), (100.0, 300.0), (80.0, 300.0)], "continue from the nearer first end");
}
#[test]
fn unselected_short_path_continues_nearest_first_endpoint() {
nearest_first_continue(false);
}
#[test]
fn preselected_short_path_continues_nearest_first_endpoint() {
nearest_first_continue(true);
}
#[test]
fn short_other_path_joins_nearest_endpoint_and_keeps_last_on_tie() {
// Both ends of B are within 8 px. First/last must win by distance; the midpoint keeps Last.
// Snapping is disabled here to isolate endpoint choice; active joins keep upstream's snapped input.
for (x, first, last) in [(100.0, 100.0, 106.0), (106.0, 106.0, 100.0), (103.0, 106.0, 100.0)] {
let mut s = session();
let a = line(&mut s, (20.0, 300.0), (40.0, 300.0));
let b = line(&mut s, (100.0, 300.0), (106.0, 300.0));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.select_tool("pen", view()).unwrap();
gesture(&mut s, view(), &[(40.0, 300.0)], Mods::default());
let before_a = node(&s, a).clone();
let before_b = node(&s, b).clone();
let before_undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.cursor(Point::new(x, 300.0), Mods::default(), view()), Cursor::PenJoin);
gesture(&mut s, view(), &[(x, 300.0)], Mods::default());
assert_eq!(path_ids(&s), vec![a], "x={x}");
assert!(s.doc().unwrap().doc.node(b).is_none());
assert_eq!(points(&s, a), [(20.0, 300.0), (40.0, 300.0), (first, 300.0), (last, 300.0)], "x={x}");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
let joined = node(&s, a).clone();
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Join");
assert_eq!(path_ids(&s), vec![a, b]);
assert_eq!(node(&s, a), &before_a);
assert_eq!(node(&s, b), &before_b);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![a]);
assert_eq!(node(&s, a), &joined);
assert!(s.doc().unwrap().doc.node(b).is_none());
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
}
}
fn grid_join(from: (f64, f64), to: (f64, f64), at: (f64, f64), snap_to_grid: bool, expected_cursor: Cursor) {
let mut s = session();
let mut prefs = s.prefs.clone();
prefs.gridline_every = 100.0;
prefs.grid_subdivisions = 1;
s.apply_prefs(prefs);
assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0);
assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1);
let v = ViewInfo { snap_to_grid, ..view() };
let a = line(&mut s, (20.0, 100.0), (40.0, 100.0));
let b = line(&mut s, from, to);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.select_tool("pen", v).unwrap();
assert_eq!(s.cursor(Point::new(40.0, 100.0), Mods::default(), v), Cursor::PenContinue);
gesture(&mut s, v, &[(40.0, 100.0)], Mods::default());
assert_eq!(path_ids(&s), vec![a, b]);
let before_a = node(&s, a).clone();
let before_b = node(&s, b).clone();
let before_undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), expected_cursor);
gesture(&mut s, v, &[at], Mods::default());
assert_eq!(path_ids(&s), vec![a], "the endpoint press joins B into A");
assert!(s.doc().unwrap().doc.node(b).is_none());
assert_eq!(points(&s, a), [(20.0, 100.0), (40.0, 100.0), from, to], "join B's first end, not its farther last end");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
let joined = node(&s, a).clone();
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Join");
assert_eq!(path_ids(&s), vec![a, b]);
assert_eq!(node(&s, a), &before_a);
assert_eq!(node(&s, b), &before_b);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![a]);
assert_eq!(node(&s, a), &joined);
assert!(s.doc().unwrap().doc.node(b).is_none());
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
}
#[test]
fn off_grid_other_endpoint_joins_without_grid_snapping() {
grid_join((63.0, 303.0), (113.0, 303.0), (63.0, 303.0), false, Cursor::PenJoin);
}
#[test]
fn off_grid_other_endpoint_joins_before_grid_snapping() {
// The raw pointer hits B's first end; (100, 300) from grid snapping hits neither end.
grid_join((63.0, 303.0), (113.0, 303.0), (63.0, 303.0), true, Cursor::PenJoin);
}
#[test]
fn overlapping_other_endpoints_join_nearest_raw_end_before_grid_snapping() {
// Raw picks (94, 303), but snapping to (100, 300) would wrongly favor (100, 303).
grid_join((94.0, 303.0), (100.0, 303.0), (94.0, 303.0), true, Cursor::PenJoin);
}
#[test]
fn another_path_join_keeps_snapped_target_when_raw_pointer_misses() {
// Upstream also joins through snapping when raw hits no endpoint. Keep that fallback.
grid_join((100.0, 300.0), (150.0, 300.0), (63.0, 303.0), true, Cursor::Pen);
}
fn snapped_only_continue(preselected: bool) {
for first in [true, false] {
let mut s = session();
let mut prefs = s.prefs.clone();
prefs.gridline_every = 100.0;
prefs.grid_subdivisions = 1;
s.apply_prefs(prefs);
assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0);
assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1);
let id = line(&mut s, (100.0, 300.0), (200.0, 300.0));
let original = node(&s, id).clone();
s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap();
let v = ViewInfo { snap_to_grid: true, ..view() };
s.select_tool("pen", v).unwrap();
let at = if first { (63.0, 303.0) } else { (237.0, 303.0) };
let end = if first { Point::new(100.0, 300.0) } else { Point::new(200.0, 300.0) };
assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue, "the endpoint is eligible");
assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), Cursor::Pen, "the raw pointer misses both ends");
let journal_start = s.journal.len();
let before_continue = s.doc().unwrap().history.undo.len();
gesture(&mut s, v, &[at], Mods::default());
assert_eq!(path_ids(&s), vec![id], "snapped-only continuation must keep one original path: selected={preselected}, first={first}");
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] };
assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}");
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first));
let continued = node(&s, id).clone();
if !first {
assert_eq!(continued, original, "continuing from the last end does not edit the path");
}
let before_append = s.doc().unwrap().history.undo.len();
let next = if first { (0.0, 300.0) } else { (300.0, 300.0) };
gesture(&mut s, v, &[next], Mods::default());
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
assert_eq!(points(&s, id), [expected[0], expected[1], next]);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
let extended = node(&s, id).clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(node(&s, id), &continued);
assert_eq!(s.doc().unwrap().history.undo.len(), before_append);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![id]);
assert_eq!(node(&s, id), &extended);
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
}
}
#[test]
fn selected_snapped_only_endpoint_continues_when_raw_pointer_misses() {
snapped_only_continue(true);
}
#[test]
fn unselected_snapped_only_endpoint_continues_when_raw_pointer_misses() {
snapped_only_continue(false);
}
#[test]
fn raw_unselected_endpoint_precedes_snapped_selected_endpoint() {
let mut s = session();
let mut prefs = s.prefs.clone();
prefs.gridline_every = 100.0;
prefs.grid_subdivisions = 1;
s.apply_prefs(prefs);
assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0);
assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1);
let selected = line(&mut s, (100.0, 300.0), (150.0, 300.0));
let raw = line(&mut s, (13.0, 303.0), (63.0, 303.0));
let original_selected = node(&s, selected).clone();
let original_raw = node(&s, raw).clone();
s.execute("select.set", &json!({"ids": [selected.0]})).unwrap();
let v = ViewInfo { snap_to_grid: true, ..view() };
s.select_tool("pen", v).unwrap();
assert_eq!(s.cursor(Point::new(100.0, 300.0), Mods::default(), v), Cursor::PenContinue);
assert_eq!(s.cursor(Point::new(63.0, 303.0), Mods::default(), v), Cursor::PenContinue);
let journal_start = s.journal.len();
let before_append = s.doc().unwrap().history.undo.len();
gesture(&mut s, v, &[(63.0, 303.0)], Mods::default());
assert_eq!(s.doc().unwrap().selection.objects, vec![raw], "the raw unselected end wins over the snapped selected end");
assert_eq!(path_ids(&s), vec![selected, raw]);
assert_eq!(node(&s, selected), &original_selected);
assert_eq!(node(&s, raw), &original_raw);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_append);
gesture(&mut s, v, &[(200.0, 400.0)], Mods::default());
assert_eq!(path_ids(&s), vec![selected, raw]);
assert_eq!(s.doc().unwrap().selection.objects, vec![raw]);
assert_eq!(node(&s, selected), &original_selected);
assert_eq!(points(&s, raw), [(13.0, 303.0), (63.0, 303.0), (200.0, 400.0)]);
assert!(!s.journal[journal_start..].iter().any(|(command, _)| command == "path.create"));
assert!(!s.in_interaction() && !s.tool_busy());
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
let extended = node(&s, raw).clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![selected, raw]);
assert_eq!(node(&s, selected), &original_selected);
assert_eq!(node(&s, raw), &original_raw);
assert_eq!(s.doc().unwrap().history.undo.len(), before_append);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(path_ids(&s), vec![selected, raw]);
assert_eq!(node(&s, selected), &original_selected);
assert_eq!(node(&s, raw), &extended);
assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1);
}
+497 -47
View File
@@ -95,13 +95,8 @@ fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option<NodeId>) ->
return None;
}
let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
if p.distance(last) <= tol {
Some((id, false))
} else if p.distance(first) <= tol {
Some((id, true))
} else {
None
}
let end = pick_endpoint(Some(first), last, p, tol, Endpoint::Last)?;
Some((id, end == Endpoint::First))
}
/// The last anchor of `id`'s last subpath: (subpath, anchor).
@@ -112,6 +107,29 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
Some((si, ai))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Endpoint {
First,
Last,
}
/// Pick the closest eligible endpoint at `p`. The caller supplies the historical
/// tie choice: closing prefers the first end, resuming prefers the last. A one-anchor path has
/// no eligible closing end.
fn pick_endpoint(first: Option<Point>, last: Point, p: Point, tol: f64, tie: Endpoint) -> Option<Endpoint> {
let first_distance = first.map(|a| p.distance(a)).filter(|d| *d <= tol);
let last_distance = p.distance(last);
let last_distance = (last_distance <= tol).then_some(last_distance);
match (first_distance, last_distance) {
(Some(f), Some(l)) if f < l => Some(Endpoint::First),
(Some(f), Some(l)) if l < f => Some(Endpoint::Last),
(Some(_), Some(_)) => Some(tie),
(Some(_), None) => Some(Endpoint::First),
(None, Some(_)) => Some(Endpoint::Last),
(None, None) => None,
}
}
/// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's
/// gesture, within its tolerance.
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
@@ -164,26 +182,44 @@ impl Tool for PenTool {
self.last = ev.pos;
if let Some((id, first, last, _)) = active {
let end = last_anchor(cx, id);
let closing_end = end.filter(|(_, ai)| *ai > 0).map(|_| first);
let picked = pick_endpoint(closing_end, last, ev.pos, tol, Endpoint::First);
// Pick at the raw pointer before snapping or constraining a new point.
// A one-anchor path doesn't close on itself: its anchor is the last one too.
if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) {
if picked == Some(Endpoint::First) {
self.drag = Some(Place { at: first, path: Some(id), closing: true });
return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))];
}
if p.distance(last) <= tol
// Alt uses Anchor Point behavior at the terminal anchor too.
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
if picked == Some(Endpoint::Last)
&& let Some((si, ai)) = end
{
self.handle = Some((id, si, ai, last));
return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)];
}
let raw_other = open_end_at(cx, ev.pos, tol, Some(id));
if raw_other.is_none() && p != ev.pos {
let snapped = pick_endpoint(closing_end, last, p, tol, Endpoint::First);
if snapped == Some(Endpoint::First) {
self.drag = Some(Place { at: first, path: Some(id), closing: true });
return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))];
}
if snapped == Some(Endpoint::Last)
&& let Some((si, ai)) = end
{
self.handle = Some((id, si, ai, last));
return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)];
}
}
// An end of another open path: the two become one, and the path is finished.
if let Some((other, at_first)) = open_end_at(cx, p, tol, Some(id)) {
if let Some((other, at_first)) = raw_other.or_else(|| open_end_at(cx, p, tol, Some(id))) {
self.stop();
let end = if at_first { "first" } else { "last" };
return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
self.drag = Some(Place { at: p, path: Some(id), closing: false });
self.in_h = p;
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
@@ -194,25 +230,28 @@ impl Tool for PenTool {
if let Some(acts) = spine_click(cx, p, tol) {
return acts;
}
// Continue a selected open path when clicking on one of its ends.
if let Some((_, first, last, _)) = active_path(cx)
&& (p.distance(last) <= tol || p.distance(first) <= tol)
{
self.drawing = true;
self.path = active_path(cx).map(|a| a.0);
if p.distance(first) <= tol && p.distance(last) > tol {
return vec![Action::Exec("path.reverse".into(), json!({}))];
// A raw endpoint anywhere wins before either snapped continuation route.
for at in std::iter::once(ev.pos).chain((p != ev.pos).then_some(p)) {
// Continue a selected open path when clicking on one of its ends.
if let Some((id, first, last, _)) = active_path(cx)
&& let Some(picked) = pick_endpoint(Some(first), last, at, tol, Endpoint::Last)
{
self.drawing = true;
self.path = Some(id);
if picked == Endpoint::First {
return vec![Action::Exec("path.reverse".into(), json!({}))];
}
return vec![];
}
return vec![];
}
// Continue any other open path from the end clicked, selecting it.
if let Some((id, at_first)) = open_end_at(cx, p, tol, None) {
(self.drawing, self.path) = (true, Some(id));
let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
if at_first {
out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]})));
// Continue any other open path from the end clicked, selecting it.
if let Some((id, at_first)) = open_end_at(cx, at, tol, None) {
(self.drawing, self.path) = (true, Some(id));
let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
if at_first {
out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]})));
}
return out;
}
return out;
}
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
return vec![act];
@@ -288,21 +327,21 @@ impl Tool for PenTool {
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.stop();
self.drag = None;
self.handle = None;
self.snap.clear();
// An Alt gesture under way ends as it stands.
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
ToolKey::Enter | ToolKey::Escape => self.deactivate(cx),
_ => vec![],
}
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.stop();
self.snap.clear();
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
let placing = self.drag.take().is_some();
let handling = self.handle.take().is_some();
// Finish the preview as it stands; clearing state alone leaves its transaction open.
let mut acts = self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default();
if placing || handling {
acts.push(Action::Commit);
}
acts
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(t) = &self.convert {
@@ -329,14 +368,13 @@ impl Tool for PenTool {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0).max(cx.point_tol());
if let Some((id, first, last, _)) = self.active(cx) {
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if p.distance(first) <= tol && !one {
return Cursor::PenClose;
let closing_end = last_anchor(cx, id).filter(|(_, ai)| *ai > 0).map(|_| first);
match pick_endpoint(closing_end, last, p, tol, Endpoint::First) {
Some(Endpoint::First) => return Cursor::PenClose,
Some(Endpoint::Last) => return Cursor::PenConvert,
None => {}
}
if p.distance(last) <= tol {
return Cursor::PenConvert;
}
if open_end_at(cx, p, tol, Some(id)).is_some() {
if !alt_converts(cx, p, m) && open_end_at(cx, p, tol, Some(id)).is_some() {
return Cursor::PenJoin;
}
}
@@ -349,7 +387,7 @@ impl Tool for PenTool {
Some([_]) => return Cursor::PenAdd,
_ => {}
}
if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol)
if active_path(cx).is_some_and(|(_, first, last, _)| pick_endpoint(Some(first), last, p, tol, Endpoint::Last).is_some())
|| open_end_at(cx, p, tol, None).is_some()
{
return Cursor::PenContinue;
@@ -427,6 +465,172 @@ mod tests {
use crate::testutil::*;
use vectorcraft_doc::Selection;
#[test]
fn snapped_last_endpoint_converts_active_path_after_raw_miss() {
let first = Point::new(100.0, 300.0);
let last = Point::new(200.0, 400.0);
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
d.grid.spacing = 100.0;
d.grid.subdivisions = 1;
let paint = paint();
let cx = ToolContext {
zoom: 1.0,
selection_tolerance: 8.0,
smart_guides: false,
snap_to_point: false,
snap_to_pixel: false,
snap_to_grid: true,
..cx(&d, &s, &paint)
};
let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0];
assert!(!sp.closed);
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), vec![first, Point::new(200.0, 300.0), last]);
let mut pen = PenTool::default();
assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty());
assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last)));
assert!(!pen.busy());
let raw = Point::new(237.0, 403.0);
let tol = cx.tol(5.0).max(cx.point_tol());
assert!(raw.distance(first) > tol && raw.distance(last) > tol);
let from = Leave::segment(&cx, last, false);
assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last);
assert!(open_end_at(&cx, raw, tol, Some(id)).is_none());
assert!(open_end_at(&cx, last, tol, Some(id)).is_none());
assert_eq!(
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)),
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 2, last.x, last.y)]
);
assert_eq!(pen.handle, Some((id, 0, 2, last)));
assert!(pen.drag.is_none() && pen.busy());
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]);
assert!(pen.handle.is_none() && !pen.busy());
assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last)));
}
#[test]
fn raw_other_endpoint_wins_before_snapped_active_last_endpoint() {
let first = Point::new(100.0, 300.0);
let last = Point::new(200.0, 400.0);
let raw = Point::new(237.0, 403.0);
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
let other = d.alloc_id();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(337.0, 403.0)], false));
d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap();
d.grid.spacing = 100.0;
d.grid.subdivisions = 1;
let paint = paint();
let cx = ToolContext {
zoom: 1.0,
selection_tolerance: 8.0,
smart_guides: false,
snap_to_point: false,
snap_to_pixel: false,
snap_to_grid: true,
..cx(&d, &s, &paint)
};
let mut pen = PenTool::default();
assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty());
assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last)));
assert!(!pen.busy());
let tol = cx.tol(5.0).max(cx.point_tol());
assert!(raw.distance(first) > tol && raw.distance(last) > tol);
assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true)));
let from = Leave::segment(&cx, last, false);
assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), last);
assert_eq!(
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)),
vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))]
);
assert!(!pen.busy() && pen.active(&cx).is_none());
assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty());
}
#[test]
fn snapped_first_endpoint_closes_active_path_after_raw_miss() {
let first = Point::new(100.0, 300.0);
let last = Point::new(200.0, 400.0);
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
d.grid.spacing = 100.0;
d.grid.subdivisions = 1;
let paint = paint();
let cx = ToolContext {
zoom: 1.0,
selection_tolerance: 8.0,
smart_guides: false,
snap_to_point: false,
snap_to_pixel: false,
snap_to_grid: true,
..cx(&d, &s, &paint)
};
let sp = &d.node(id).unwrap().path_data().unwrap().subpaths[0];
assert!(!sp.closed);
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), vec![first, Point::new(200.0, 300.0), last]);
let mut pen = PenTool::default();
assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty());
assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last)));
assert!(!pen.busy());
let raw = Point::new(63.0, 303.0);
let tol = cx.tol(5.0).max(cx.point_tol());
assert!(raw.distance(first) > tol && raw.distance(last) > tol);
let from = Leave::segment(&cx, last, false);
assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first);
assert!(open_end_at(&cx, raw, tol, Some(id)).is_none());
assert!(open_end_at(&cx, first, tol, Some(id)).is_none());
assert_eq!(
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)),
vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]
);
let place = pen.drag.unwrap();
assert!(place.closing);
assert_eq!((place.path, place.at), (Some(id), first));
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)), vec![Action::Commit]);
assert!(!pen.busy() && pen.active(&cx).is_none());
}
#[test]
fn raw_other_endpoint_wins_before_snapped_active_first_endpoint() {
let first = Point::new(100.0, 300.0);
let last = Point::new(200.0, 400.0);
let raw = Point::new(63.0, 303.0);
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[first, Point::new(200.0, 300.0), last], false));
let other = d.alloc_id();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[raw, Point::new(63.0, 403.0)], false));
d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, path, vectorcraft_doc::Appearance::default_art())).unwrap();
d.grid.spacing = 100.0;
d.grid.subdivisions = 1;
let paint = paint();
let cx = ToolContext {
zoom: 1.0,
selection_tolerance: 8.0,
smart_guides: false,
snap_to_point: false,
snap_to_pixel: false,
snap_to_grid: true,
..cx(&d, &s, &paint)
};
let mut pen = PenTool::default();
assert!(click(&mut pen, &cx, last.x, last.y, Mods::default()).is_empty());
assert_eq!(pen.active(&cx).map(|(id, first, last, _)| (id, first, last)), Some((id, first, last)));
assert!(!pen.busy());
let tol = cx.tol(5.0).max(cx.point_tol());
assert!(raw.distance(first) > tol && raw.distance(last) > tol);
assert_eq!(open_end_at(&cx, raw, tol, Some(id)), Some((other, true)));
let from = Leave::segment(&cx, last, false);
assert_eq!(DrawSnap::default().press(&cx, raw, &[id], Some(&from)), first);
assert_eq!(
pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, raw.x, raw.y)),
vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", "first"]}))]
);
assert!(!pen.busy() && pen.active(&cx).is_none());
assert!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, raw.x, raw.y)).is_empty());
}
#[test]
fn first_click_creates_path() {
let (d, _) = doc_with_rect();
@@ -557,6 +761,252 @@ mod tests {
assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose);
}
/// #544: picking the terminal anchor uses the pointer, even when new points snap elsewhere.
#[test]
fn terminal_anchor_conversion_precedes_grid_snapping() {
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(13.0, 303.0), Point::new(63.0, 303.0)], false));
d.grid.spacing = 100.0;
d.grid.subdivisions = 1;
let p = paint();
let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenConvert);
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 63.0, 303.0)),
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 63.0, 303.0)]
);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 63.0, 303.0)), vec![Action::Commit]);
// The same raw picking applies when resuming the path and when closing it.
let mut t = PenTool::default();
assert_eq!(t.cursor(&cx, Point::new(63.0, 303.0), Mods::default()), Cursor::PenContinue);
assert!(click(&mut t, &cx, 63.0, 303.0, Mods::default()).is_empty());
assert_eq!(t.active(&cx).map(|a| a.0), Some(id));
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 13.0, 303.0)),
vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]
);
}
#[test]
fn smart_guides_cannot_steal_a_terminal_anchor_press() {
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let other = d.alloc_id();
let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(66.0, 300.0, 76.0, 310.0));
d.insert(Some(d.layers[0].id), 2, vectorcraft_doc::Node::path(other, rect, vectorcraft_doc::Appearance::default_art())).unwrap();
let p = paint();
let cx = cx(&d, &s, &p);
let pos = Point::new(63.0, 300.0);
assert_eq!(crate::guides::snap_draw(&cx, pos, &[id]).0, Point::new(66.0, 300.0));
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert);
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)),
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
);
}
/// Nearby endpoints share a pick radius: use the closest one for cursor and press.
#[test]
fn overlapping_endpoint_tolerances_pick_the_nearest_end() {
let paint = paint();
let mut failures = vec![];
for zoom in [0.5, 1.0, 2.0] {
let first = Point::new(100.0, 300.0);
let last = Point::new(100.0 + 6.0 / zoom, 300.0);
let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false));
for grid in [false, true] {
let cx = ToolContext {
zoom,
selection_tolerance: 8.0,
smart_guides: false,
snap_to_point: false,
snap_to_grid: grid,
..cx(&d, &selection, &paint)
};
for shift in [false, true] {
let mods = Mods { shift, ..Mods::default() };
// Include off-anchor clicks in both halves of the overlap and the midpoint.
for offset in [6.0, 0.0, 1.0, 3.0, 5.0] {
let at = Point::new(first.x + offset / zoom, first.y);
let first_end = offset <= 3.0;
let mut pen = PenTool { drawing: true, ..PenTool::default() };
let cursor = if first_end { Cursor::PenClose } else { Cursor::PenConvert };
let actual_cursor = pen.cursor(&cx, at, mods);
let expected = if first_end {
vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]
} else {
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, last.x, last.y)]
};
let actual = pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(mods));
if actual_cursor != cursor || actual != expected {
failures.push((zoom, grid, shift, offset, actual_cursor, actual));
}
assert_eq!(pen.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y)), vec![Action::Commit]);
}
}
}
}
assert!(failures.is_empty(), "cursor and press must pick the closest eligible endpoint: {failures:?}");
}
#[test]
fn overlapping_endpoint_tolerances_continue_from_the_nearest_end() {
let paint = paint();
let mut failures = vec![];
for zoom in [0.5, 1.0, 2.0] {
let first = Point::new(100.0, 300.0);
let last = Point::new(100.0 + 6.0 / zoom, 300.0);
let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[first, last], false));
for grid in [false, true] {
let cx = ToolContext {
zoom,
selection_tolerance: 8.0,
smart_guides: false,
snap_to_point: false,
snap_to_grid: grid,
..cx(&d, &selection, &paint)
};
for offset in [6.0, 0.0, 1.0, 3.0, 5.0] {
let at = Point::new(first.x + offset / zoom, first.y);
let mut pen = PenTool::default();
assert_eq!(pen.cursor(&cx, at, Mods::default()), Cursor::PenContinue);
let expected = if offset < 3.0 { vec![Action::Exec("path.reverse".into(), json!({}))] } else { vec![] };
let actual = click(&mut pen, &cx, at.x, at.y, Mods::default());
if actual != expected {
failures.push((zoom, grid, offset, actual));
}
assert_eq!(pen.active(&cx).map(|a| a.0), Some(id));
}
}
}
assert!(failures.is_empty(), "resuming must reverse only for the closest first endpoint: {failures:?}");
}
#[test]
fn coincident_endpoints_keep_the_prior_tie_choice() {
let at = Point::new(100.0, 300.0);
let (d, id, selection) = drawing(vectorcraft_geom::SubPath::polyline(&[at, at], false));
let paint = paint();
for zoom in [0.5, 1.0, 2.0] {
let cx = ToolContext { zoom, smart_guides: false, snap_to_point: false, ..cx(&d, &selection, &paint) };
let mut drawing = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(drawing.cursor(&cx, at, Mods::default()), Cursor::PenClose);
assert_eq!(
click(&mut drawing, &cx, at.x, at.y, Mods::default()),
vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]
);
let mut resuming = PenTool::default();
assert_eq!(resuming.cursor(&cx, at, Mods::default()), Cursor::PenContinue);
assert!(click(&mut resuming, &cx, at.x, at.y, Mods::default()).is_empty());
}
}
#[test]
fn terminal_anchor_uses_the_selection_tolerance_at_each_zoom() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let p = paint();
for zoom in [0.5, 2.0] {
let cx = ToolContext { zoom, selection_tolerance: 8.0, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) };
let pos = Point::new(60.0, 300.0 + 6.0 / zoom);
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::PenConvert);
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pos.x, pos.y)),
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
);
let narrow = ToolContext { selection_tolerance: 1.0, ..cx };
// The upstream point picker retains a minimum screen radius at low tolerance.
let near = Point::new(60.0, 300.0 + 4.0 / zoom);
let mut narrow_pen = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(narrow_pen.cursor(&narrow, near, Mods::default()), Cursor::PenConvert);
assert_eq!(
click(&mut narrow_pen, &narrow, near.x, near.y, Mods::default()),
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
);
assert_eq!(narrow_pen.cursor(&narrow, Point::new(60.0, 300.0 + 6.0 / zoom), Mods::default()), Cursor::Pen);
}
}
/// Upstream's point radius grows with the drawn anchor; cursor and press keep agreeing.
#[test]
fn enlarged_anchor_radius_is_used_for_endpoints_and_alt_conversion() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let p = paint();
for zoom in [0.5, 1.0, 2.0] {
let cx = ToolContext { zoom, selection_tolerance: 1.0, anchor_size: 7, smart_guides: false, snap_to_point: false, ..cx(&d, &s, &p) };
let at = Point::new(60.0 + 6.0 / zoom, 300.0);
let mut terminal = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(terminal.cursor(&cx, at, Mods::default()), Cursor::PenConvert);
assert_eq!(
click(&mut terminal, &cx, at.x, at.y, Mods::default()),
vec![Action::Begin("Convert Anchor Point".into()), set_out(id, 1, 60.0, 300.0)]
);
let mut continuing = PenTool::default();
assert_eq!(continuing.cursor(&cx, at, Mods::default()), Cursor::PenContinue);
assert!(click(&mut continuing, &cx, at.x, at.y, Mods::default()).is_empty());
assert_eq!(continuing.active(&cx).map(|a| a.0), Some(id));
let mut closing = PenTool { drawing: true, ..PenTool::default() };
let first = Point::new(10.0 - 6.0 / zoom, 300.0);
assert_eq!(closing.cursor(&cx, first, Mods::default()), Cursor::PenClose);
assert_eq!(
click(&mut closing, &cx, first.x, first.y, Mods::default()),
vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]
);
let mut alt_pen = PenTool { drawing: true, ..PenTool::default() };
let alt = Mods { alt: true, ..Mods::default() };
assert!(alt_pen.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(alt)).is_empty());
assert!(alt_pen.busy(), "Alt must reach the enlarged terminal-anchor target");
assert!(alt_pen.convert.is_some());
let away_from_segment = Point::new(35.0, 300.0 + 4.0 / zoom);
assert!(!alt_converts(&cx, away_from_segment, alt), "segment picking retains the smaller selection radius");
}
}
/// #516: Alt on the terminal anchor borrows Anchor Point, just like the other anchors.
#[test]
fn alt_on_the_terminal_anchor_pulls_symmetric_handles() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(alt)).is_empty());
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 330.0).with_mods(alt)),
vec![
Action::Begin("Convert Anchor Point".into()),
Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1, "to": "smooth", "x": 230.0, "y": 330.0}))
]
);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 330.0)), vec![Action::Commit]);
assert!(
matches!(click(&mut t, &cx, 300.0, 400.0, Mods::default()).as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor")
);
}
#[test]
fn ending_during_a_pen_gesture_commits_and_clears_it() {
let (d, _, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
for terminal in [false, true] {
for key in [Some(ToolKey::Escape), Some(ToolKey::Enter), None] {
let mut t = PenTool { drawing: true, ..PenTool::default() };
let x = if terminal { 200.0 } else { 300.0 };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, 300.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, 330.0));
let acts = match key {
Some(k) => t.key(&cx, k, Mods::default()),
None => t.deactivate(&cx),
};
assert_eq!(acts, vec![Action::Commit]);
assert!(!t.busy());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 20.0, 330.0)).is_empty());
assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::Pen);
}
}
}
/// #501: a press on the first anchor closes the path, however far the drag then goes: the
/// closing preview leaves the path closed (so not one the pen extends), and each move still
/// previews the close with symmetric handles (Alt breaks them), released as a close.