From b253356b4d2c812bc8a1ab8bc892094dda967e57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 09:24:13 +0700 Subject: [PATCH] Knife: a straight cut's ends snap to the anchors and paths Smart Guides snap to Squashed from #5044 onto main. --- crates/tools/src/draw2/gesture.rs | 48 ++++++++++-- crates/tools/src/draw2/gesture/tests.rs | 97 +++++++++++++++++++++++++ crates/tools/src/guides/snap.rs | 8 ++ crates/tools/src/guides/targets.rs | 55 +++++++++----- crates/tools/src/guides/tests.rs | 33 +++++++++ docs/target-app-parity.md | 1 + 6 files changed, 216 insertions(+), 26 deletions(-) diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index a3c3cd93e..cdddc451c 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -114,8 +114,11 @@ pub struct GestureTool { pub edit_within: f64, /// "Close paths when ends are within N pixels". pub close_within: f64, - /// Smart Guides for the Pencil's ends. + /// Smart Guides for the Pencil's ends and the press of a straight Knife cut. snap: DrawSnap, + /// Where the pointer went down, before any snap: a straight Knife cut released there cuts + /// nothing, even when its press snapped elsewhere. + pressed: Point, /// Where the Blob Brush's or the Eraser's tip outline shows: the pointer over the canvas, /// unless Cmd/Ctrl lends a selection tool or Alt the Smooth tool. tip_at: Option, @@ -194,6 +197,7 @@ impl GestureTool { edit_within: 12.0, close_within: 15.0, snap: DrawSnap::default(), + pressed: Point::ZERO, tip_at: None, } } @@ -203,6 +207,31 @@ impl GestureTool { self.id == "pencil" } + /// Does a straight Knife cut snap to anchors and paths? Only with Smart Guides on, which Snap to + /// Pixel and Snap to Grid turn off (`PointSnap::new`). Its ends snap as the Pencil's straight + /// line's do: the press, and the release unless Shift keeps it on a 45° step. + fn knife_snaps(&self, cx: &ToolContext) -> bool { + self.id == "knife" && self.straight && cx.smart_guides && !cx.snap_to_pixel && !cx.snap_to_grid + } + + /// Does the end of a straight line snap: the Pencil's Alt line's, or a straight Knife cut's + /// without Shift? + fn line_end_snaps(&self, cx: &ToolContext) -> bool { + self.straight && self.unconstrained && (self.snaps() || self.knife_snaps(cx)) + } + + /// Where a stroke or straight line released at `p` ends: where it snaps when its end snaps. + fn release_end(&mut self, cx: &ToolContext, p: Point) -> Point { + if (self.snaps() && !self.smoothing && !self.straight) || self.line_end_snaps(cx) { self.snap.drag(cx, p, None) } else { p } + } + + /// Is a straight line with its end at `end` back at its press point (within a pixel), so that + /// there is no line? A Knife cut also is when the pointer, at `pointer`, is back where it went + /// down, though its press snapped elsewhere. + fn back_at_press(&self, cx: &ToolContext, pointer: Point, end: Point) -> bool { + self.points.first().is_some_and(|s| s.distance(end) < cx.tol(1.0)) || (self.id == "knife" && self.pressed.distance(pointer) < cx.tol(1.0)) + } + fn draws_path(&self) -> bool { matches!(self.id, "pencil" | "paintbrush") } @@ -532,10 +561,14 @@ impl Tool for GestureTool { end } else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() { self.snap.press(cx, p, &[], None) + } else if self.knife_snaps(cx) { + // A straight Knife cut, its overlay and Shift's 45° steps start from the snapped point. + self.snap.press_on_paths(cx, p) } else { p }; self.clear(); + self.pressed = p; self.push(start, ev.pressure); vec![] } @@ -554,19 +587,20 @@ impl Tool for GestureTool { // Where the stroke would end: shown, taken on release. end = self.snap.drag(cx, p, None); } - let p = if self.straight && self.unconstrained && self.snaps() { - // Alt's line ends where a stroke would: shown, taken on release. + let p = if self.line_end_snaps(cx) { + // Alt's line ends where a stroke would, and a Knife cut's on the anchors and + // paths its press snaps to: shown, taken on release. self.snap.drag(cx, p, None) } else { p }; if self.straight { - if !self.unconstrained { + if !self.line_end_snaps(cx) { // The press's Smart Guides would stay on screen: the line's end doesn't snap. self.snap.clear(); } // The line runs from the press point to the pointer, wherever the drag went. - if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) { + if self.back_at_press(cx, ev.pos, p) { // Back at the press point there is no line: the one shown goes. self.points.truncate(1); return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }; @@ -618,12 +652,12 @@ impl Tool for GestureTool { // Alt only on release cannot turn a freehand Knife drag into a straight cut. self.knife_shift(ev.mods); let p = self.constrained(cx, p, ev.mods); - let end = if self.snaps() && !self.smoothing && (!self.straight || self.unconstrained) { self.snap.drag(cx, p, None) } else { p }; + let end = self.release_end(cx, p); self.snap.clear(); let mut cancel = false; if self.straight { self.points.truncate(1); - if self.points.first().is_some_and(|s| s.distance(end) >= cx.tol(1.0)) { + if !self.back_at_press(cx, ev.pos, end) { self.push(end, ev.pressure); } else { cancel = self.began; diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 29fe7614f..d9bd9a365 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -1351,6 +1351,103 @@ fn knife_shift_alt_cuts_at_45_degree_steps() { assert_eq!(knife_points(acts.last().unwrap()), vec![(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)]); } +/// With Smart Guides on, a straight Knife cut starts at the anchor or path its press snaps to, +/// and its release snaps the same way, as the Pencil's straight line's ends do. Centers and lines +/// up do not move the press. The overlay shows the same line. +#[test] +fn knife_alt_cut_snaps_where_smart_guides_snap() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("knife"); + // Near the rectangle's corner anchor: the cut starts on it, and the release near the opposite + // corner ends on that one. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 201.0, 198.0).with_mods(alt)); + assert_eq!(overlay_points(&t, &cx), vec![(100.0, 100.0), (200.0, 200.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 201.0, 198.0).with_mods(alt)); + assert_eq!(knife_points(&a), vec![(100.0, 100.0), (200.0, 200.0)]); + assert!(t.overlays(&cx).is_empty()); + // Near the top side, away from its anchors: the cut starts on the path. + let acts = drag_held(&mut t, &cx, &[(130.0, 101.5), (260.0, 240.0)], alt); + assert_eq!(knife_points(acts.last().unwrap()), vec![(130.0, 100.0), (260.0, 240.0)]); + // Near the rectangle's center, or in line with its top side, the press stays where it is. + for press in [(151.0, 149.0), (50.0, 101.5)] { + let acts = drag_held(&mut t, &cx, &[press, (260.0, 240.0)], alt); + assert_eq!(knife_points(acts.last().unwrap()), vec![press, (260.0, 240.0)]); + } + // An Alt-click on the snapped press cuts nothing, and neither does a drag back to where the + // pointer went down. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.0, 99.0).with_mods(alt)), vec![]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 99.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 201.0, 198.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.2, 99.0).with_mods(alt)); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "the line goes"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 101.2, 99.0).with_mods(alt)), vec![]); + // A freehand cut keeps its press. + let acts = drag(&mut t, &cx, &[(101.0, 99.0), (150.0, 120.0), (201.0, 198.0)], Mods::default()); + assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (150.0, 120.0), (201.0, 198.0)]); +} + +/// With Smart Guides off, a straight Knife cut starts where it is pressed, with Snap to Point on +/// or off. +#[test] +fn knife_alt_cut_without_smart_guides_keeps_its_press() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("knife"); + for snap_to_point in [false, true] { + cx.snap_to_point = snap_to_point; + let acts = drag_held(&mut t, &cx, &[(101.0, 99.0), (201.0, 198.0)], alt); + assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (201.0, 198.0)], "Snap to Point {snap_to_point}"); + } +} + +/// With Smart Guides on, a straight Knife cut does not start on art in a hidden layer, whose own +/// visibility is still on. +#[test] +fn knife_alt_cut_does_not_snap_to_hidden_art() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + d.node_mut(l).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("knife"); + let acts = drag_held(&mut t, &cx, &[(101.0, 99.0), (201.0, 198.0)], alt); + assert_eq!(knife_points(acts.last().unwrap()), vec![(101.0, 99.0), (201.0, 198.0)]); +} + +/// With Smart Guides on, Shift and Alt keep the Knife's cut at 45° steps from the snapped press, +/// and an anchor near the release does not pull the cut off that step. +#[test] +fn knife_shift_alt_cut_steps_from_the_snapped_press() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let mut t = GestureTool::new("knife"); + // Pressed by the bottom-left corner, released by the top-right one. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 101.0, 199.0).with_mods(shift_alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 104.0).with_mods(shift_alt)); + let shown = overlay_points(&t, &cx); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 203.0, 104.0).with_mods(shift_alt)); + let cut = knife_points(&a); + assert_eq!(shown, cut); + assert_eq!(cut[0], (100.0, 200.0)); + let step = 103f64.hypot(96.0) / 2f64.sqrt(); + assert!((cut[1].0 - (100.0 + step)).abs() < 1e-9 && (cut[1].1 - (200.0 - step)).abs() < 1e-9, "{cut:?}"); +} + /// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within /// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area /// it covers, and a round one the drag as wide as the tip. diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 4766455f8..0b117b95b 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -348,6 +348,14 @@ impl DrawSnap { self.snap(cx, p, from) } + /// Snap the point pressed at `p` onto the art's anchors and paths alone, within the Smart + /// Guides snapping tolerance (the Knife's straight cut). The targets serve this press only. + pub fn press_on_paths(&mut self, cx: &ToolContext, p: Point) -> Point { + let targets = Targets::for_drawing(cx.doc, &[], visible(cx), None).styled(cx).anchors_and_paths(); + self.targets = Some((None, PointSnap { targets: Some((targets, cx.snapping_tolerance)) })); + self.snap(cx, p, None) + } + /// Gather `targets` other than the art for the drag a press starts (a point being dragged /// leaves itself out); the next hover gathers its own again. pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) { diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index edddd1a48..36947c931 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -81,11 +81,11 @@ fn within(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] { sorted.get(lo..hi).unwrap_or_default() } -/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those: -/// one pass, however many are skipped. A Shaper Group's original shapes, its first child, are -/// not shown and are skipped too. +/// Visit `nodes` and everything inside them, but the nodes in `skip`, the hidden nodes and +/// everything inside those: one pass, however many are skipped. A Shaper Group's original +/// shapes, its first child, are not shown and are skipped too. fn walk_but<'a>(nodes: &'a [Arc], skip: &HashSet, f: &mut impl FnMut(&'a Node)) { - for n in nodes.iter().filter(|n| !skip.contains(&n.id)) { + for n in nodes.iter().filter(|n| n.visible && !skip.contains(&n.id)) { f(n); if let Some(ch) = n.children() { walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), skip, f); @@ -500,6 +500,19 @@ impl Targets { self } + /// Only these targets' anchors and path segments pull, and where segments cross: no centers, + /// bounds, lines up, extensions or glyph guides. + pub(super) fn anchors_and_paths(mut self) -> Self { + self.points.retain(|(_, k)| *k == Kind::Anchor); + self.xs.clear(); + self.ys.clear(); + self.rulers.clear(); + self.boxes.clear(); + self.extensions.clear(); + self.angled.clear(); + self + } + /// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and /// unlocked, as the selection tools pick them) pull the pointer into line where they run. pub(super) fn for_snap_to_point(self, cx: &ToolContext) -> Self { @@ -550,23 +563,27 @@ impl Targets { } /// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds - /// reach into `near`. + /// reach into `near`. Nothing inside a hidden group or layer counts. pub(super) fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self { let mut t = Targets::default(); - for n in roots.iter().filter_map(|id| doc.node(*id)) { - crate::draw2::walk_shown(n, &mut |c| { - if c.is_container() || !c.visible { - return; - } - let Some(b) = c.geometric_bounds() else { return }; - if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 { - return; - } - if let NodeKind::Path { path, .. } = &c.kind { - t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor))); - } - t.points.push((b.center(), Kind::Center)); - }); + let mut push = |c: &Node| { + if c.is_container() { + return; + } + let Some(b) = c.geometric_bounds() else { return }; + if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 { + return; + } + if let NodeKind::Path { path, .. } = &c.kind { + t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor))); + } + t.points.push((b.center(), Kind::Center)); + }; + for n in roots.iter().filter_map(|id| doc.node(*id)).filter(|n| n.visible) { + push(n); + if let Some(ch) = n.children() { + walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), &HashSet::new(), &mut push); + } } t.index() } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 7ecdc2f1c..e82618fb3 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -260,6 +260,39 @@ fn shaper_sources_are_no_targets() { assert!(gives_corner(&Targets::points_near(&d, &[b], near))); } +/// Art inside a hidden group or layer is no target, even while its own visibility is on: not +/// for drawing, the Knife's straight cut, a move or a transform tool's reference point on the +/// shown group around it. +#[test] +fn art_in_hidden_groups_and_layers_is_no_target() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)), Appearance::default_art()); + let mut inner = Node::group(d.alloc_id(), vec![Arc::new(square)]); + inner.visible = false; + let outer = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + let g = outer.id; + d.insert(Some(l), 1, outer).unwrap(); + let corner = Point::new(300.0, 300.0); + let gives = |t: &Targets, at: Point| { + t.points.iter().any(|(p, _)| *p == at) + || t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == at) + || t.segments.iter().any(|(_, s)| s.start() == at || s.end() == at) + }; + assert!(!gives(&Targets::collect(&d, &[], None), corner)); + assert!(!gives(&Targets::for_drawing(&d, &[], None, None), corner)); + assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), corner)); + assert!(!gives(&Targets::points_near(&d, &[g], Rect::new(296.0, 296.0, 304.0, 304.0)), corner)); + // The shown rectangle on the same layer still is one, until the layer is hidden. + let shown = Point::new(100.0, 100.0); + assert!(gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown)); + d.node_mut(l).unwrap().visible = false; + assert!(!gives(&Targets::collect(&d, &[], None), shown)); + assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown)); +} + /// #2120: the dragged end of a straight segment at any angle lands on the segment's own /// extension (Line Extension), past its other end or short of it, the guide drawn from that /// end; with two anchors moving there is none to keep to. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8bfe80641..c5588ef6d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -705,6 +705,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). 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. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing).