Files
vectorcraft/crates/engine/tests/pen_endpoint_picking.rs
T
arhammusheerandClaude Opus 5.5 bbbafdef6b #1244: undo history bounded by bytes, without lookup tables or repeated selections in its steps
History was limited by step count only; a whole-selection move of 50k paths keeps ~40 MB per
step. Steps now carry a size estimate (only unshared subtrees are visited) and the oldest are
dropped beyond a byte budget, keeping the last 8. Documents entering history drop their id
index, selections are shared between steps, the command journal is bounded (held while an
Action records), and document resources are copy-on-write. Saved files are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
2026-10-12 11:02:17 +07:00

420 lines
20 KiB
Rust

//! 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.since(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.since(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.since(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.since(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.since(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);
}