mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
fix(selection): 線分の選択と削除をアンカーから分ける (#1278)
This commit is contained in:
committed by
bflatastic
parent
aa15cb52bd
commit
1ac797685f
@@ -781,6 +781,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
|
||||
sel.remove(id);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
}
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
|
||||
// Direct-selected anchors: delete those anchors instead of whole objects.
|
||||
// Direct-selected anchors or segments: remove those components instead of whole objects.
|
||||
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
|
||||
return super::path::delete_anchors(s, p);
|
||||
}
|
||||
|
||||
@@ -456,21 +456,35 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let sel = st.selection.anchors.clone();
|
||||
let segments = st.selection.segments.clone();
|
||||
s.edit("Clear", |d, selection| {
|
||||
let mut survivors = vec![];
|
||||
for (id, set) in &sel {
|
||||
let remove_node = {
|
||||
let path = path_mut(d, *id)?;
|
||||
// Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete).
|
||||
for &(si, ai) in set.iter().rev() {
|
||||
if let Some(sp) = path.subpaths.get_mut(si)
|
||||
&& ai < sp.anchors.len()
|
||||
{
|
||||
if sp.closed {
|
||||
sp.anchors.rotate_left(ai + 1);
|
||||
sp.anchors.pop();
|
||||
sp.closed = false;
|
||||
} else {
|
||||
sp.anchors.remove(ai);
|
||||
if let Some(refs) = segments.get(id) {
|
||||
// A segment pick cuts its connection, keeping every neighboring cubic.
|
||||
path.subpaths = std::mem::take(&mut path.subpaths)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(si, sp)| {
|
||||
let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect();
|
||||
without_segments(sp, &cuts)
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
// Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete).
|
||||
for &(si, ai) in set.iter().rev() {
|
||||
if let Some(sp) = path.subpaths.get_mut(si)
|
||||
&& ai < sp.anchors.len()
|
||||
{
|
||||
if sp.closed {
|
||||
sp.anchors.rotate_left(ai + 1);
|
||||
sp.anchors.pop();
|
||||
sp.closed = false;
|
||||
} else {
|
||||
sp.anchors.remove(ai);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,14 +493,52 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
};
|
||||
if remove_node {
|
||||
d.remove(*id)?;
|
||||
} else if segments.contains_key(id) {
|
||||
survivors.push(*id);
|
||||
}
|
||||
}
|
||||
selection.clear();
|
||||
// Another Delete removes the remaining path after a segment cut. Anchor Delete keeps
|
||||
// its existing deselection behavior.
|
||||
selection.set(survivors);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Remove connections, splitting open/closed contours into their surviving open runs.
|
||||
fn without_segments(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
|
||||
let cuts: BTreeSet<usize> = removed.iter().copied().filter(|&i| i < sp.segment_count()).collect();
|
||||
let Some(&first) = cuts.first() else { return vec![sp] };
|
||||
let n = sp.anchors.len();
|
||||
let start = if sp.closed { (first + 1) % n } else { 0 };
|
||||
let mut result = vec![];
|
||||
let mut run = vec![];
|
||||
let mut previous = None;
|
||||
for (index, anchor) in sp.anchors.iter().enumerate().cycle().skip(start).take(n) {
|
||||
if previous.is_some_and(|i| cuts.contains(&i)) {
|
||||
result.extend(open_run(std::mem::take(&mut run)));
|
||||
}
|
||||
run.push(*anchor);
|
||||
previous = Some(index);
|
||||
}
|
||||
result.extend(open_run(run));
|
||||
result
|
||||
}
|
||||
|
||||
fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
|
||||
if anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The cut edge's incoming/outgoing controls are no longer part of an open endpoint.
|
||||
if let Some(first) = anchors.first_mut() {
|
||||
first.h_in = first.p;
|
||||
}
|
||||
if let Some(last) = anchors.last_mut() {
|
||||
last.h_out = last.p;
|
||||
}
|
||||
Some(SubPath::new(anchors, false))
|
||||
}
|
||||
|
||||
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
|
||||
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
|
||||
|
||||
@@ -70,6 +70,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
key
|
||||
),
|
||||
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
|
||||
cmd!(
|
||||
"select.segments",
|
||||
"Select Segments",
|
||||
[],
|
||||
None,
|
||||
"{id, segments: [[subpath, segment]…]} select path segments; their endpoints move with them, Delete removes only their connections",
|
||||
has_doc,
|
||||
segments
|
||||
),
|
||||
cmd!(
|
||||
"select.anchorsMany",
|
||||
"Select Anchors",
|
||||
@@ -330,6 +339,27 @@ pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result<Vec<(usize, u
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn segments(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.segments";
|
||||
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
|
||||
let refs: BTreeSet<_> = checked_refs(p.get("segments"), C)?.into_iter().collect();
|
||||
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "object is not a path"))?;
|
||||
let mut endpoints = BTreeSet::new();
|
||||
for &(si, seg) in &refs {
|
||||
let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(C, "segment does not exist"))?;
|
||||
endpoints.extend([(si, seg), (si, (seg + 1) % sp.anchors.len())]);
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.clear();
|
||||
if !refs.is_empty() {
|
||||
sel.add(id);
|
||||
sel.anchors.insert(id, endpoints);
|
||||
sel.segments.insert(id, refs);
|
||||
}
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.anchors";
|
||||
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
|
||||
@@ -347,6 +377,7 @@ fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A path selected as a whole has every anchor selected: toggle and add start from them all.
|
||||
let whole = sel.contains(id) && sel.partial(id).is_none();
|
||||
sel.add(id);
|
||||
sel.segments.remove(&id);
|
||||
let e = sel.anchors.entry(id).or_default();
|
||||
if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) {
|
||||
e.extend(path.anchors().map(|(s, i, _)| (s, i)));
|
||||
@@ -385,6 +416,7 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.clear();
|
||||
}
|
||||
for (id, refs) in items {
|
||||
sel.segments.remove(&id);
|
||||
let all: BTreeSet<(usize, usize)> =
|
||||
d.node(id).and_then(|n| n.path_data()).map(|p| p.anchors().map(|(s, i, _)| (s, i)).collect()).unwrap_or_default();
|
||||
// Anything but a path is selected, toggled or deselected as a whole.
|
||||
|
||||
@@ -688,6 +688,112 @@ fn cut_at_anchors_splits_paths_and_leaves_one_end_selected() {
|
||||
assert_eq!(undo_steps(&s), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_segment_delete_keeps_the_other_rectangle_edges_and_one_undo() {
|
||||
for initially_selected in [false, true] {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0);
|
||||
if !initially_selected {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let original = path(&s, id).subpaths[0].clone();
|
||||
let undo = undo_steps(&s);
|
||||
drag(&mut s, "directSelection", &[(200.0, 100.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().doc, before, "a click never edits the document");
|
||||
assert_eq!(undo_steps(&s), undo);
|
||||
assert_eq!(s.doc().unwrap().selection.segments[&id], [(0, 0)].into());
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
let remainder = path(&s, id).subpaths[0].clone();
|
||||
assert!(!remainder.closed);
|
||||
assert_eq!(remainder.anchors.len(), 4, "the endpoints and neighboring edges survive");
|
||||
for i in 0..3 {
|
||||
assert_eq!(remainder.segment(i), original.segment(i + 1));
|
||||
}
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
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.segments[&id], [(0, 0)].into());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).is_none(), "a second Delete removes the rest");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_delete_preserves_curves_appearance_and_unrelated_contours() {
|
||||
let data = "M100 100C110 70 150 130 170 100C180 75 230 125 250 100C270 140 280 80 310 100C320 120 140 140 100 100Z M400 100L500 100L500 200Z";
|
||||
for closed in [false, true] {
|
||||
for cuts in [vec![0], vec![1], vec![3], vec![0, 2], vec![0, 3], vec![0, 1, 2, 3]] {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("path.create", &json!({"d": data})).unwrap()["id"].as_u64().unwrap());
|
||||
s.edit("Fixture", |d, _| {
|
||||
if let NodeKind::Path { path, .. } = &mut d.node_mut(id).unwrap().kind {
|
||||
path.subpaths[0].closed = closed;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color":"#123456"})).unwrap();
|
||||
let original = path(&s, id);
|
||||
let cuts: Vec<_> = cuts.into_iter().filter(|&i| i < original.subpaths[0].segment_count()).collect();
|
||||
if cuts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let refs: Vec<_> = cuts.iter().map(|i| json!([0, i])).collect();
|
||||
s.execute("select.segments", &json!({"id":id.0,"segments":refs})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let result = path(&s, id);
|
||||
assert_eq!(result.subpaths.last().unwrap(), original.subpaths.last().unwrap(), "the other contour stays exact");
|
||||
let expected: Vec<_> =
|
||||
(0..original.subpaths[0].segment_count()).filter(|i| !cuts.contains(i)).map(|i| original.subpaths[0].segment(i)).collect();
|
||||
let actual: Vec<_> =
|
||||
result.subpaths.iter().take(result.subpaths.len() - 1).flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect();
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
assert!(expected.iter().all(|curve| actual.contains(curve)), "{closed} {cuts:?}");
|
||||
for sp in result.subpaths.iter().take(result.subpaths.len() - 1) {
|
||||
assert!(
|
||||
!sp.anchors.first().unwrap().has_in() && !sp.anchors.last().unwrap().has_out(),
|
||||
"deleted-edge handles do not remain at open endpoints"
|
||||
);
|
||||
}
|
||||
let mut restored = s.doc().unwrap().doc.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 kind changes");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_picks_and_deliberate_anchor_picks_delete_different_components() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 200.0, 150.0);
|
||||
s.execute("select.segments", &json!({"id":id.0,"segments":[[0,0]]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
for refs in [json!([[0, 99]]), json!([[99, 0]]), json!([[-1, 0]]), json!([[0, 0.5]])] {
|
||||
assert!(s.execute("select.segments", &json!({"id":id.0,"segments":refs})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
s.execute("select.anchors", &json!({"id":id.0,"anchors":[[0,0],[0,1]]})).unwrap();
|
||||
assert!(s.doc().unwrap().selection.segments.is_empty());
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).anchor_count(), 2, "deliberate anchor Delete keeps its existing behavior");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.segments", &json!({"id":id.0,"segments":[[0,0]]})).unwrap();
|
||||
drag(&mut s, "directSelection", &[(100.0, 100.0)], Mods::default());
|
||||
assert!(s.doc().unwrap().selection.segments.is_empty());
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 0)].into(), "clicking an endpoint picks the anchor itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_with_alt_moves_one_handle_and_converts_anchors() {
|
||||
// Discord feedback: Alt held with the Pen moves a direction handle on its own (and works as
|
||||
|
||||
Reference in New Issue
Block a user