diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 1471c1b1a..ad6293c0e 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1011,6 +1011,27 @@ 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) in [(alt, (250.0, 250.0), 10000.0), (shift_alt, (160.0, 240.0), 5000.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}"); + } +} + #[test] fn knife_partial_cut_does_nothing() { let mut s = session(); diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 2a6380aa9..fab93aecd 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -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 press, or pressed before the Knife's drag has moved, makes the Knife cut along +//! the straight line from the press point to the pointer, at 45° steps from the Constrain Angle +//! while Shift is held. use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; @@ -89,11 +92,12 @@ 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 or before the drag moved): 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, @@ -201,6 +205,23 @@ impl GestureTool { self.id == "blobBrush" && m.alt && !self.moved } + /// Would Alt held now make the Knife cut along a straight line? It does at the press, and + /// during a drag that has not moved from the press point yet. + fn knife_alt_straight(&self, m: Mods) -> bool { + self.id == "knife" && m.alt && !self.moved + } + + /// Alt starts a straight Knife cut before the drag moves. While it cuts along a straight line, + /// Shift keeps the line to 45° steps; without Shift, the line follows the pointer. + fn knife_shift(&mut self, m: Mods) { + if self.knife_alt_straight(m) { + self.straight = true; + } + if self.id == "knife" && self.straight { + self.unconstrained = !m.shift; + } + } + /// Constrain the Pencil line 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. @@ -449,8 +470,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 +501,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 +570,7 @@ impl Tool for GestureTool { if self.blob_alt_smooths(ev.mods) { self.smoothing = true; } + 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(); diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index c3335d1cf..eefca081e 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -1106,6 +1106,99 @@ 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, or of its drag overlay. +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, or pressed before the drag has moved, a wavy Knife drag shows and +/// cuts along the straight line from the press point to the pointer, whatever Alt does after. +/// Alt pressed once the drag has moved 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, alt)); + 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 while the pointer stands still at the press point. + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 150.0, none)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 150.0, alt)); + 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), (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![]); +} + +/// With Shift and Alt held, the Knife's straight cut keeps to 45° steps from the Constrain Angle +/// and is as long as the line to the pointer. Shift is read on each drag 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:?}"); + // 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:?}"); + // The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°. + cx.constrain_angle = 30.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)) - 30.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()).len(), 3); +} + /// 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/docs/target-app-parity.md b/docs/target-app-parity.md index 0dd042385..8c44cbc20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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. With Alt held at the press, or pressed before the drag has moved, the dashed line runs from the press point to the pointer and the release sends `path.knife` with those two points. With Shift held too, the line keeps to 45° steps from the Constrain Angle. The cut stays straight when Alt is let go, and a line taken back to its press point sends no command. 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).