Merge pull request #4726 from dotCooCoo/feat/knife-straight-cuts

Knife: Alt cuts in a straight line and Shift+Alt cuts at 45° steps
This commit is contained in:
BFlat
2026-10-31 00:50:04 +07:00
committed by GitHub
4 changed files with 196 additions and 8 deletions
+30
View File
@@ -1011,6 +1011,36 @@ fn knife_cuts_rect_into_two_pieces() {
assert_eq!(paths(&s).len(), 1);
}
/// An Alt-drag with the Knife cuts along the straight line from the press point to the release,
/// wherever the drag went between, and Shift+Alt along the nearest 45° step, in one undo step.
#[test]
fn knife_alt_drag_cuts_along_a_straight_line() {
let alt = Mods { alt: true, ..Default::default() };
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
// From (150, 50) to (250, 250) the line crosses the rectangle's top at x 175 and its bottom at
// x 225, so the left piece holds 10000 pt²; a vertical cut at x 150 leaves 5000 pt².
for (m, to, left, top, bottom) in [
(alt, (250.0, 250.0), 10000.0, Point::new(175.0, 100.0), Point::new(225.0, 200.0)),
(shift_alt, (160.0, 240.0), 5000.0, Point::new(150.0, 100.0), Point::new(150.0, 200.0)),
] {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
s.select_tool("knife", view()).unwrap();
assert_eq!(gesture(&mut s, &[(150.0, 50.0), (290.0, 150.0), to], m), 1, "{m:?}: one undo step");
let ps = paths(&s);
assert_eq!(ps.len(), 2, "{m:?}");
let small = ps.iter().map(|(_, p)| area(p)).fold(f64::INFINITY, f64::min);
assert!((small - left).abs() < 30.0, "{m:?}: {small}");
// The Knife leaves a narrow slit, so each piece's boundary lies just beside the center line.
for (_, piece) in &ps {
let anchors = &piece.subpaths[0].anchors;
assert!(anchors.iter().any(|a| a.p.distance(top) < 0.1), "{m:?}: missing top cut boundary near {top:?}: {anchors:?}");
assert!(anchors.iter().any(|a| a.p.distance(bottom) < 0.1), "{m:?}: missing bottom cut boundary near {bottom:?}: {anchors:?}");
}
}
}
#[test]
fn knife_partial_cut_does_nothing() {
let mut s = session();
+24 -8
View File
@@ -14,6 +14,9 @@
//! collect the drag polyline (shown as an overlay) and run one command on release. With Shift held, the Blob Brush's points stay on the horizontal or
//! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag
//! has moved, switches the Blob Brush to the Smooth tool: the drag smooths the selected paths.
//! Alt held at the Knife's press makes it cut along the straight line from the press point to the
//! pointer, even if Alt is released during the drag. Shift on each drag or release constrains that
//! line to 45° steps from the canvas axes.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
@@ -89,11 +92,11 @@ pub struct GestureTool {
/// The pointer has moved from the press point, by any distance (a move too short to add a
/// point counts).
moved: bool,
/// Active Pencil drag draws a straight line (Shift or Alt held at the press): the stroke is
/// the press point and the pointer.
/// Active Pencil drag draws a straight line (Shift or Alt held at the press), or active Knife
/// drag cuts along one (Alt held at the press): the stroke is the press point and the pointer.
straight: bool,
/// The straight line is Alt's: the pointer is not constrained to 45° steps, and the line's end
/// snaps to Smart Guides as a stroke's end does.
/// The straight line is not constrained to 45° steps: the Pencil's Alt line, whose end snaps to
/// Smart Guides as a stroke's end does, or the Knife's line while Shift is up.
unconstrained: bool,
/// Axis selected during the current uninterrupted Shift interval, in degrees.
shift_line: Option<f64>,
@@ -201,7 +204,16 @@ impl GestureTool {
self.id == "blobBrush" && m.alt && !self.moved
}
/// Constrain the Pencil line or the current Blob Brush sample. Blob Brush chooses an
/// Shift keeps an active straight Knife cut at 45° steps; without Shift it follows the pointer.
/// Releasing Alt does not end a straight cut.
fn knife_shift(&mut self, m: Mods) {
if self.id == "knife" && self.straight {
self.unconstrained = !m.shift;
}
}
/// Constrain a straight Pencil line from the Constrain Angle, a straight Knife cut from the
/// canvas axes, or the current Blob Brush sample. Blob Brush chooses an
/// axis once per uninterrupted Shift interval; samples already drawn never move.
/// A smoothing drag follows the pointer without a Shift constraint.
fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point {
@@ -211,7 +223,8 @@ impl GestureTool {
if self.straight
&& let Some(&start) = self.points.first()
{
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle);
let origin = if self.id == "knife" { 0.0 } else { cx.constrain_angle };
return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, origin);
}
let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else {
self.shift_line = None;
@@ -449,8 +462,8 @@ impl Tool for GestureTool {
// switches the Blob Brush.
self.smoothing = ev.mods.alt && (self.id == "blobBrush" || (self.id == "pencil" && self.alt_smooth));
// Alt draws a straight line only while it doesn't lend the Smooth tool; with Shift
// too, the line keeps Shift's 45° steps.
self.straight = self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt);
// too, the line keeps Shift's 45° steps. Alt makes the Knife cut along a straight line.
self.straight = (self.id == "pencil" && !self.smoothing && (ev.mods.shift || ev.mods.alt)) || (self.id == "knife" && ev.mods.alt);
self.unconstrained = self.straight && !ev.mods.shift;
let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None };
self.extend = extend.map(|(id, at_start, _)| (id, at_start));
@@ -480,6 +493,7 @@ impl Tool for GestureTool {
if (!self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt) || self.blob_alt_smooths(ev.mods) {
self.smoothing = true;
}
self.knife_shift(ev.mods);
self.moved |= self.points.first().is_some_and(|start| p.distance(*start) > vectorcraft_geom::EPS);
let p = self.constrained(cx, p, ev.mods);
let mut end = p;
@@ -548,6 +562,8 @@ impl Tool for GestureTool {
if self.blob_alt_smooths(ev.mods) {
self.smoothing = true;
}
// 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 };
self.snap.clear();
+141
View File
@@ -1106,6 +1106,147 @@ fn eraser_and_knife_exec_on_release() {
assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3));
}
/// The points of the Knife's release command.
fn knife_points(acts: &[Action]) -> Vec<(f64, f64)> {
let Some(Action::Exec(c, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(c, "path.knife");
v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()
}
fn overlay_points(t: &GestureTool, cx: &ToolContext) -> Vec<(f64, f64)> {
let o = t.overlays(cx);
let Some(Overlay::Path { path, dashed: true, .. }) = o.last() else { panic!("{o:?}") };
path.elements().iter().filter_map(|e| e.end_point()).map(|q| (q.x, q.y)).collect()
}
/// With Alt held at the press, a wavy Knife drag shows and cuts along the straight line from the
/// press point to the pointer, even after Alt is released. Alt first pressed after the press
/// leaves the cut freehand, and a straight cut taken back to the press point sends no command.
#[test]
fn knife_alt_cuts_along_a_straight_line() {
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 none = Mods::default();
let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m);
let mut t = GestureTool::new("knife");
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 150.0, alt));
t.pointer(&cx, &ev(PointerKind::Drag, 100.0, 120.0, alt));
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 190.0, none));
assert_eq!(overlay_points(&t, &cx), vec![(50.0, 150.0), (150.0, 190.0)]);
// Alt let go: the cut stays straight.
let a = t.pointer(&cx, &ev(PointerKind::Up, 250.0, 160.0, none));
assert_eq!(knife_points(&a), vec![(50.0, 150.0), (250.0, 160.0)]);
// Alt pressed on the first Drag event leaves the cut freehand.
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 150.0, none));
t.pointer(&cx, &ev(PointerKind::Drag, 100.0, 120.0, alt));
let a = t.pointer(&cx, &ev(PointerKind::Up, 250.0, 160.0, alt));
assert_eq!(knife_points(&a), vec![(50.0, 150.0), (100.0, 120.0), (250.0, 160.0)]);
// Alt pressed after the drag moved: the cut follows the drag.
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 150.0, none));
t.pointer(&cx, &ev(PointerKind::Drag, 100.0, 120.0, none));
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 190.0, alt));
let a = t.pointer(&cx, &ev(PointerKind::Up, 250.0, 160.0, alt));
assert_eq!(knife_points(&a), vec![(50.0, 150.0), (100.0, 120.0), (150.0, 190.0), (250.0, 160.0)]);
// Back at the press point the line goes, and the release cuts nothing.
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 150.0, alt));
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 190.0, alt));
t.pointer(&cx, &ev(PointerKind::Drag, 50.2, 150.0, alt));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.2, 150.0, alt)), vec![]);
}
/// Alt first held after the Knife press leaves the cut freehand, including when the pointer has
/// not moved before Alt is pressed. The release command contains every collected point.
#[test]
fn knife_alt_after_press_keeps_freehand_cut() {
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");
for stationary_first in [false, true] {
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
if stationary_first {
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 150.0).with_mods(alt));
}
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 120.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 190.0));
assert_eq!(overlay_points(&t, &cx), vec![(50.0, 150.0), (100.0, 120.0), (150.0, 190.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 160.0));
assert_eq!(knife_points(&a), vec![(50.0, 150.0), (100.0, 120.0), (150.0, 190.0), (250.0, 160.0)]);
}
}
/// Alt first held at release leaves a freehand Knife cut on its collected drag points.
#[test]
fn knife_alt_only_at_release_keeps_freehand_cut() {
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");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 120.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 190.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 160.0).with_mods(alt));
assert_eq!(knife_points(&a), vec![(50.0, 150.0), (100.0, 120.0), (150.0, 190.0), (250.0, 160.0)]);
}
/// With Shift and Alt held, the Knife's straight cut keeps to 45° steps from the canvas axes
/// and is as long as the line to the pointer. Shift is read on each Drag and Up event: when Shift
/// is let go, the line follows the pointer again. With Shift alone, the cut follows the drag.
#[test]
fn knife_shift_alt_cuts_at_45_degree_steps() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
let shift_alt = Mods { shift: true, alt: true, ..Default::default() };
let alt = Mods { alt: true, ..Default::default() };
let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees();
let mut t = GestureTool::new("knife");
// A wavy drag heading up and right at about 40°: a cut at 45°, as long as the drag.
let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift_alt);
let cut = knife_points(acts.last().unwrap());
assert_eq!(cut.len(), 2, "{cut:?}");
assert_eq!(cut[0], (0.0, 0.0));
assert!((angle(cut[1], (0.0, 0.0)) - 45.0).abs() < 1e-9, "{cut:?}");
assert!((cut[1].0.hypot(cut[1].1) - 100f64.hypot(84.0)).abs() < 1e-9, "{cut:?}");
// A nearly vertical drag takes the nearest 90° step, with the same overlay and command endpoints.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(shift_alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 15.0, -90.0).with_mods(shift_alt));
let shown = overlay_points(&t, &cx);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 15.0, -90.0).with_mods(shift_alt));
let cut = knife_points(&a);
assert_eq!(shown, cut);
assert!((cut[1].0 - 10.0).abs() < 1e-9, "{cut:?}");
assert!((cut[1].1 - (10.0 - 5f64.hypot(100.0))).abs() < 1e-9, "{cut:?}");
// Shift goes down mid-drag and up again: the overlay steps, then follows the pointer.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, -84.0).with_mods(shift_alt));
let shown = overlay_points(&t, &cx);
assert!((angle(shown[1], (0.0, 0.0)) - 45.0).abs() < 1e-9, "{shown:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, -80.0).with_mods(alt));
assert_eq!(overlay_points(&t, &cx), vec![(0.0, 0.0), (100.0, -80.0)]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, -84.0).with_mods(shift_alt));
assert!((angle(knife_points(&a)[1], (0.0, 0.0)) - 45.0).abs() < 1e-9, "{a:?}");
// A 30° Constrain Angle does not turn the Knife's horizontal or 45° cuts.
cx.constrain_angle = 30.0;
let acts = drag_held(&mut t, &cx, &[(10.0, 10.0), (110.0, 3.0)], shift_alt);
assert_eq!(knife_points(acts.last().unwrap()), vec![(10.0, 10.0), (10.0 + 100f64.hypot(7.0), 10.0)]);
let acts = drag_held(&mut t, &cx, &[(10.0, 10.0), (110.0, -74.0)], shift_alt);
let cut = knife_points(acts.last().unwrap());
assert!((angle(cut[1], (10.0, 10.0)) - 45.0).abs() < 1e-9, "{cut:?}");
// Shift alone: the cut follows the drag.
let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], Mods { shift: true, ..Default::default() });
assert_eq!(knife_points(acts.last().unwrap()), vec![(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)]);
}
/// 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.
+1
View File
@@ -676,6 +676,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. 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.
- **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.
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).