mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 06:01:18 +00:00
Scissors: a cut leaves one of the two new end points selected
After a Scissors cut (`path.split`) the whole path stayed selected: both pieces of an open path, or the opened path of a closed one. A Direct Selection drag from the cut then moved every anchor, so an ellipse cut at an anchor could not be pulled open by dragging its end. - `path.split` selects the first anchor of the piece after the cut and nothing else, as `path.cutAtAnchors` does: anchor 0 of an opened closed subpath, of the new path when an open path becomes two, or of the subpath added to a path of several subpaths. - A Direct Selection press on anchors of a selected path that lie on top of each other takes the selected one. It took the first in path order, which in a path of several subpaths was the unselected end. Docs: docs/mcp.md, docs/target-app-parity.md, the `path.split` params text.
This commit is contained in:
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
|
||||
@@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(EngineError::Other("no such segment".into())),
|
||||
};
|
||||
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
|
||||
// The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors`
|
||||
// does, so a Direct Selection drag pulls the path apart there.
|
||||
let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| {
|
||||
sel.clear();
|
||||
sel.add(id);
|
||||
sel.anchors.entry(id).or_default().insert((si, 0));
|
||||
};
|
||||
if sp.closed {
|
||||
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
sel.set([id]);
|
||||
pick(sel, id, si);
|
||||
return Ok(vec![id]);
|
||||
}
|
||||
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
|
||||
@@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let node = sibling_with(d, id, PathData::single(right))?;
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let nid = d.insert(parent, idx + 1, node)?;
|
||||
sel.set([id, nid]);
|
||||
pick(sel, nid, 0);
|
||||
Ok(vec![id, nid])
|
||||
} else {
|
||||
path.subpaths[si] = left;
|
||||
path.subpaths.insert(si + 1, right);
|
||||
sel.set([id]);
|
||||
pick(sel, id, si + 1);
|
||||
Ok(vec![id])
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -1058,11 +1058,88 @@ fn scissors_splits_open_path_in_two() {
|
||||
assert!(near(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 100.0)));
|
||||
assert!(near(ps[1].1.subpaths[0].anchors[0].p, Point::new(150.0, 100.0)));
|
||||
assert_eq!(ps[1].1.subpaths[0].anchors[1].p, Point::new(300.0, 100.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
|
||||
// One of the two new end points is selected: a Direct Selection drag from the cut pulls the
|
||||
// second piece's end away and leaves the first piece as it was.
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [ps[1].0]);
|
||||
assert_eq!(selected_anchors(&s, ps[1].0), [(0, 0)]);
|
||||
drag(&mut s, "directSelection", &[(150.0, 100.0), (150.0, 115.0), (150.0, 130.0)], Mods::default());
|
||||
assert_eq!(path(&s, id), ps[0].1);
|
||||
let moved = anchor_points(&s, ps[1].0);
|
||||
assert!(near(moved[0], Point::new(150.0, 130.0)) && moved[1] == Point::new(300.0, 100.0), "{moved:?}");
|
||||
// Clicking an end point is refused without panicking.
|
||||
assert!(s.execute("path.split", &json!({"id": id.0, "subpath": 0, "anchor": 0})).is_err());
|
||||
}
|
||||
|
||||
/// An ellipse cut once with the Scissors tool, at an anchor or along a segment, opens with its two
|
||||
/// ends on top of each other and one of them selected. A short Direct Selection drag from there,
|
||||
/// plain or with Shift, moves that end alone with its handles, in one undo step, so the open path
|
||||
/// becomes one turn that spirals out; every other anchor stays where it was.
|
||||
#[test]
|
||||
fn scissors_cut_ellipse_end_drags_alone() {
|
||||
let mods = [Mods::default(), Mods { shift: true, ..Mods::default() }];
|
||||
for at_anchor in [true, false] {
|
||||
for m in mods {
|
||||
let mut s = session();
|
||||
drag(&mut s, "ellipse", &[(100.0, 100.0), (200.0, 200.0), (300.0, 200.0)], Mods::default());
|
||||
let id = paths(&s)[0].0;
|
||||
let ellipse = path(&s, id);
|
||||
let cut = if at_anchor {
|
||||
ellipse.subpaths[0].anchors[0].p
|
||||
} else {
|
||||
use kurbo::ParamCurve;
|
||||
ellipse.subpaths[0].segment(1).eval(0.5)
|
||||
};
|
||||
click(&mut s, "scissors", cut.x, cut.y);
|
||||
let opened = path(&s, id);
|
||||
let sp = &opened.subpaths[0];
|
||||
assert!(!sp.closed && near(sp.anchors[0].p, cut) && near(sp.anchors[sp.anchors.len() - 1].p, cut), "{sp:?}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [id]);
|
||||
assert_eq!(selected_anchors(&s, id), [(0, 0)]);
|
||||
// Shift is pressed once the drag is under way: held at the press it toggles the anchor.
|
||||
let v = view();
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
let undo = undo_steps(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, cut.x, cut.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 4.0, cut.y + 4.0).with_mods(m), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap();
|
||||
assert_eq!(undo_steps(&s), undo + 1);
|
||||
let after = path(&s, id);
|
||||
let (old, new) = (&sp.anchors, &after.subpaths[0].anchors);
|
||||
assert_eq!(new.len(), old.len());
|
||||
assert_eq!(new[1..], old[1..], "only the dragged end moves (at anchor: {at_anchor}, {m:?})");
|
||||
let d = Point::new(cut.x + 8.0, cut.y + 8.0) - cut;
|
||||
assert!(near(new[0].p, old[0].p + d) && near(new[0].h_out, old[0].h_out + d) && near(new[0].h_in, new[0].p), "{:?}", new[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Scissors tool on a path of several subpaths: the cut end stays selected in the subpath it
|
||||
/// opened, and a second cut of that subpath selects the end of the new piece.
|
||||
#[test]
|
||||
fn scissors_cut_in_a_compound_path_selects_the_cut_end() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let square =
|
||||
|x: f64| json!({"closed": true, "anchors": [{"x": x, "y": 100}, {"x": x + 100.0, "y": 100}, {"x": x + 100.0, "y": 200}, {"x": x, "y": 200}]});
|
||||
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [square(100.0), square(300.0)]})).unwrap();
|
||||
click(&mut s, "scissors", 400.0, 100.0);
|
||||
let pd = path(&s, id);
|
||||
assert!(pd.subpaths[0].closed && !pd.subpaths[1].closed && pd.subpaths[1].anchors[0].p == Point::new(400.0, 100.0), "{pd:?}");
|
||||
assert_eq!(selected_anchors(&s, id), [(1, 0)]);
|
||||
// Cut again at the opened subpath's corner (400, 200): its second piece follows it.
|
||||
click(&mut s, "scissors", 400.0, 200.0);
|
||||
let pd = path(&s, id);
|
||||
assert_eq!(pd.subpaths.len(), 3);
|
||||
assert_eq!(pd.subpaths[2].anchors[0].p, Point::new(400.0, 200.0));
|
||||
assert_eq!(selected_anchors(&s, id), [(2, 0)]);
|
||||
drag(&mut s, "directSelection", &[(400.0, 200.0), (410.0, 210.0), (420.0, 220.0)], Mods::default());
|
||||
let after = path(&s, id);
|
||||
assert_eq!(after.subpaths[2].anchors[0].p, Point::new(420.0, 220.0));
|
||||
assert_eq!(after.subpaths[1], pd.subpaths[1]);
|
||||
assert_eq!(after.subpaths[0], pd.subpaths[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knife_cuts_rect_into_two_pieces() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -492,7 +492,7 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us
|
||||
break;
|
||||
}
|
||||
if let Some(pd) = cx.doc.node(id).and_then(editable_path)
|
||||
&& let Some((si, ai, a)) = nearest_anchor(&pd, p, tol)
|
||||
&& let Some((si, ai, a)) = nearest_anchor(&pd, p, tol, cx.selection.partial(id))
|
||||
{
|
||||
return Some((id, si, ai, a));
|
||||
}
|
||||
@@ -524,7 +524,7 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us
|
||||
return None;
|
||||
}
|
||||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||||
nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a))
|
||||
nearest_anchor(&pd, p, tol, None).map(|(si, ai, a)| (id, si, ai, a))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -535,9 +535,14 @@ fn over_path_type_anchor(cx: &ToolContext, p: Point) -> bool {
|
||||
hit_anchor(cx, p, cx.point_tol()).is_some_and(|(id, ..)| cx.doc.node(id).is_some_and(is_path_type))
|
||||
}
|
||||
|
||||
/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is).
|
||||
fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> {
|
||||
pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)))
|
||||
/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). Of anchors
|
||||
/// on top of each other, such as the two ends a cut leaves, one in `picked` comes first.
|
||||
fn nearest_anchor(pd: &PathData, p: Point, tol: f64, picked: Option<&BTreeSet<AnchorRef>>) -> Option<(usize, usize, Point)> {
|
||||
let unpicked = |si: usize, ai: usize| !picked.is_some_and(|set| set.contains(&(si, ai)));
|
||||
pd.anchors()
|
||||
.map(|(si, ai, a)| (si, ai, a.p))
|
||||
.filter(|(.., a)| a.distance(p) <= tol)
|
||||
.min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)).then_with(|| unpicked(x.0, x.1).cmp(&unpicked(y.0, y.1))))
|
||||
}
|
||||
|
||||
/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors).
|
||||
@@ -1145,7 +1150,7 @@ fn hovered_anchor_with_hit(cx: &ToolContext, p: Point, hit: Option<&Hit>) -> Opt
|
||||
let under = hit.map(|h| cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)).unwrap_or(h.leaf));
|
||||
cx.selection.objects.iter().copied().filter(|id| in_scope(cx.doc, cx.isolation, *id)).chain(under).find_map(|id| {
|
||||
let path = cx.doc.node(id).and_then(editable_path)?;
|
||||
nearest_anchor(&path, p, tol).map(|(.., a)| (id, a))
|
||||
nearest_anchor(&path, p, tol, None).map(|(.., a)| (id, a))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -3574,7 +3574,9 @@ Selected End Points) joins the ends again; `path.join {ids: [a, b], ends: ["last
|
||||
(Average & Join, Cmd+Alt+Shift+J) moves two ends to their midpoint and joins them there, the joined anchor a corner or a smooth point, in one
|
||||
undo step: the two direct-selected anchors when they are open path ends, else the nearest ends of the two open paths in `ids` or the
|
||||
selection (both ends of one, closing it); anything else is refused. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
|
||||
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
|
||||
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); `path.split`,
|
||||
at an anchor or at `segment` and `t`, leaves the first anchor of the piece after the cut selected alone, and a Direct
|
||||
Selection press on two anchors on top of each other takes the selected one; the Pen with Alt
|
||||
held over a selected path's handle, anchor or segment works as the Anchor Point tool. `press_key` Escape between two
|
||||
`pointer_gesture` calls of an Anchor Point drag cancels it: the path goes back and no undo step is added. `path.reshapeSegment {id,
|
||||
subpath?, segment, t, dx, dy, perpendicular?}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
|
||||
|
||||
@@ -921,6 +921,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end.
|
||||
Escape while an arc, spiral or grid is dragged out cancels it and puts back the options its keys, Alt and Ctrl/Cmd changed (slope, open or closed, base axis, segments, decay, dividers and skews), so the next drag and the tool's options start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
A Scissors cut (`path.split`) leaves one of the two new end points selected and the rest of the path unselected, as [Cut paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/split-paths.html) and the "Split a path" section of the [CS3 user guide](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) describe: the two end points lie on top of each other, and a Direct Selection drag from them moves the selected one with its handles and no other anchor. A closed path cut once stays one open path.
|
||||
While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again.
|
||||
Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle.
|
||||
With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points.
|
||||
|
||||
Reference in New Issue
Block a user