mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
G11: Preserve gaps when deleting open-path anchors
Keep surviving drawable runs separate when Delete or Backspace removes an open-path anchor. Preserve appearance, selection and one-step Undo. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Codex
parent
a1b3c4804f
commit
67f9d134ed
@@ -565,8 +565,8 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
// Remove every selected anchor against the original indexes. A closed path opens
|
||||
// across each deleted run and keeps the surviving stretches in their old order.
|
||||
// Remove every selected anchor against the original indexes, keeping surviving
|
||||
// stretches separate rather than joining across deleted anchors.
|
||||
path.subpaths = std::mem::take(&mut path.subpaths)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -593,9 +593,8 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Remove anchors `removed`. An open path stays one path and joins across the gap. A closed path
|
||||
/// opens there: each stretch of anchors that were not deleted becomes its own open path, in the
|
||||
/// original order, including a stretch that wraps past the last anchor.
|
||||
/// Remove anchors and their incident segments, keeping each surviving stretch as an open path.
|
||||
/// Only a closed path can have a surviving stretch that wraps past the last anchor.
|
||||
fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
let n = sp.anchors.len();
|
||||
let gone: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < n).collect();
|
||||
@@ -603,9 +602,6 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
return vec![sp];
|
||||
}
|
||||
let keep = |anchors: Vec<Anchor>| (anchors.len() >= 2).then(|| SubPath::new(anchors, false));
|
||||
if !sp.closed {
|
||||
return keep(sp.anchors.into_iter().enumerate().filter(|(i, _)| !gone.contains(i)).map(|(_, a)| a).collect()).into_iter().collect();
|
||||
}
|
||||
let mut runs: Vec<Vec<Anchor>> = vec![];
|
||||
let mut run = vec![];
|
||||
for (i, anchor) in sp.anchors.into_iter().enumerate() {
|
||||
@@ -617,7 +613,11 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
run.push(anchor);
|
||||
}
|
||||
}
|
||||
// The anchors before the first deletion and after the last are one stretch.
|
||||
if !sp.closed {
|
||||
runs.push(run);
|
||||
return runs.into_iter().filter_map(open_run).collect();
|
||||
}
|
||||
// The anchors before the first deletion and after the last are one closed-path stretch.
|
||||
if gone.contains(&0) {
|
||||
if !run.is_empty() {
|
||||
runs.push(run);
|
||||
|
||||
@@ -1399,3 +1399,174 @@ fn a_filled_pencil_path_keeps_no_stroke() {
|
||||
let id = NodeId(s.execute("path.freehand", &json!({"points": pts})).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(!s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_anchor_delete_splits_runs_without_new_connections() {
|
||||
let cases = [
|
||||
(vec![3], vec![vec![0, 1, 2], vec![4, 5, 6]]),
|
||||
(vec![2, 4], vec![vec![0, 1], vec![5, 6]]),
|
||||
(vec![2, 3], vec![vec![0, 1], vec![4, 5, 6]]),
|
||||
(vec![0], vec![vec![1, 2, 3, 4, 5, 6]]),
|
||||
(vec![6], vec![vec![0, 1, 2, 3, 4, 5]]),
|
||||
(vec![0, 6], vec![vec![1, 2, 3, 4, 5]]),
|
||||
(vec![1, 3, 5], vec![]),
|
||||
(vec![0, 1, 2, 3, 4, 5, 6], vec![]),
|
||||
];
|
||||
for (removed, expected) in cases {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..7).map(|i| json!({"x": 100 + 50 * i, "y": 100})).collect();
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
let original = path(&s, id).subpaths[0].clone();
|
||||
let refs: Vec<_> = removed.iter().map(|i| [0, *i]).collect();
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": refs})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = undo_steps(&s);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
if expected.is_empty() {
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none(), "only isolated points remain: {removed:?}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
} else {
|
||||
let result = path(&s, id);
|
||||
assert_eq!(result.subpaths.len(), expected.len(), "deleted anchors: {removed:?}");
|
||||
for (sp, indices) in result.subpaths.iter().zip(&expected) {
|
||||
assert!(!sp.closed);
|
||||
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), indices.iter().map(|&i| original.anchors[i].p).collect::<Vec<_>>());
|
||||
for (segment, pair) in indices.windows(2).enumerate() {
|
||||
assert_eq!(sp.segment(segment), original.segment(pair[0]));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
}
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty() && s.doc().unwrap().selection.segments.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_anchor_delete_preserves_curves_appearance_and_history() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 600.0, 300.0, 30.0, 40.0);
|
||||
let data = "M100 100C120 60 180 140 200 100C220 60 280 140 300 100C320 60 380 140 400 100C420 60 480 140 500 100 M100 300L200 300L200 400Z";
|
||||
let id = NodeId(s.execute("path.create", &json!({"d": data})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setStroke", &json!({"color": "#123456"})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]]})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let original = path(&s, id);
|
||||
let undo = undo_steps(&s);
|
||||
s.execute("path.deleteAnchors", &json!({})).unwrap();
|
||||
let result = path(&s, id);
|
||||
assert_eq!(result.subpaths.len(), 3, "two surviving runs plus the untouched contour");
|
||||
for (sp, segment) in result.subpaths[..2].iter().zip([0, 3]) {
|
||||
assert!(!sp.closed);
|
||||
assert_eq!(sp.segment_count(), 1);
|
||||
assert_eq!(sp.segment(0), original.subpaths[0].segment(segment));
|
||||
assert!(!sp.anchors.first().unwrap().has_in() && !sp.anchors.last().unwrap().has_out());
|
||||
}
|
||||
assert_eq!(result.subpaths[2], original.subpaths[1]);
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
let mut restored = after.clone();
|
||||
std::sync::Arc::make_mut(&mut restored).node_mut(id).unwrap().kind = before.node(id).unwrap().kind.clone();
|
||||
assert_eq!(restored, before, "only the edited path geometry changes");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty() && s.doc().unwrap().selection.segments.is_empty());
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Clear");
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none(), "the second Delete removes the remainder");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_anchor_delete_does_not_change_explicit_remove_anchors() {
|
||||
for command in ["path.removeAnchors", "path.removeAnchor"] {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..5).map(|i| json!({"x": 100 + 50 * i, "y": 100})).collect();
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let original = path(&s, id);
|
||||
let undo = undo_steps(&s);
|
||||
let params = if command == "path.removeAnchor" { json!({"id": id.0, "subpath": 0, "anchor": 2}) } else { json!({}) };
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
let result = path(&s, id);
|
||||
assert_eq!(result.subpaths.len(), 1, "{command} keeps the path connected");
|
||||
let sp = &result.subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
assert_eq!(sp.segment_count(), 3);
|
||||
assert_eq!(sp.anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [0, 1, 3, 4].map(|i| original.subpaths[0].anchors[i].p));
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
|
||||
use vectorcraft_geom::Affine;
|
||||
for xf in [
|
||||
Affine::IDENTITY,
|
||||
Affine::translate((23.0, -17.0)),
|
||||
Affine::rotate(0.7),
|
||||
Affine::translate((30.0, 40.0)) * Affine::scale_non_uniform(-2.0, 0.5),
|
||||
] {
|
||||
let mut s = session();
|
||||
let whole = rect(&mut s, 600.0, 100.0, 40.0, 30.0);
|
||||
let untouched = rect(&mut s, 600.0, 300.0, 40.0, 30.0);
|
||||
let id = NodeId(
|
||||
s.execute(
|
||||
"path.create",
|
||||
&json!({"d": "M100 100C120 60 180 140 200 100C220 60 280 140 300 100C320 60 380 140 400 100C420 60 480 140 500 100"}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
// Node::transform bakes coordinates. Deletion must not apply the transform a second time.
|
||||
s.edit("Fixture transform", |d, _| {
|
||||
d.node_mut(id).unwrap().transform(xf, false);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let original = path(&s, id);
|
||||
s.execute("select.set", &json!({"ids": [whole.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "add"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = undo_steps(&s);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.node(whole).is_none());
|
||||
assert_eq!(st.doc.node(untouched), before.node(untouched));
|
||||
let result = path(&s, id);
|
||||
assert_eq!(result.subpaths.len(), 2);
|
||||
for (sp, segment) in result.subpaths.iter().zip([0, 3]) {
|
||||
assert_eq!(sp.segment_count(), 1);
|
||||
assert_eq!(sp.segment(0), original.subpaths[0].segment(segment));
|
||||
}
|
||||
assert_eq!(st.selection.objects, vec![id]);
|
||||
assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty());
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
let after = st.doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user