From c1c07efc955ee99ba3bd2f8fd17b93454142772d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 14:36:32 +0700 Subject: [PATCH] Blob Brush: keep the first Shift axis without moving earlier points Squashed from #4525 onto main. --- crates/tools/src/draw2/gesture.rs | 58 ++++++----------- crates/tools/src/draw2/gesture/tests.rs | 85 +++++++++++++------------ docs/gaps.md | 1 + docs/roadmap.md | 1 + docs/target-app-parity.md | 1 + docs/ui-parity.md | 1 + 6 files changed, 68 insertions(+), 79 deletions(-) diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 21c090f21..70217dab1 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -82,13 +82,7 @@ pub struct GestureTool { /// 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. unconstrained: bool, - /// Where the pointer was for each Blob Brush point dragged since Shift went down: the last - /// `shift_raw.len()` of `points`. - shift_raw: Vec, - /// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds. - shift_next: Option, - /// The angle of the line the dragged points were last moved onto: while it holds they are on - /// it, and the next drag event need not move them again. + /// Axis selected during the current uninterrupted Shift interval, in degrees. shift_line: Option, /// "Merge only with selection": a Blob Brush stroke merges only with selected paths. pub merge_only_selection: bool, @@ -149,8 +143,6 @@ impl GestureTool { moved: false, straight: false, unconstrained: false, - shift_raw: vec![], - shift_next: None, shift_line: None, merge_only_selection: false, edit_within: 12.0, @@ -176,12 +168,6 @@ impl GestureTool { /// Add a stroke sample. fn push(&mut self, p: Point, pressure: f32) { - let raw = self.shift_next.take(); - if self.points.is_empty() { - self.shift_raw.clear(); - } else if let Some(raw) = raw { - self.shift_raw.push(raw); - } self.points.push(p); if self.id == "paintbrush" { self.pressures.push(pressure); @@ -200,14 +186,10 @@ impl GestureTool { self.id == "blobBrush" && m.alt && !self.moved } - /// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the - /// horizontal or vertical line through the press point that is nearer in angle to `p` (90° - /// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged - /// since Shift went down move to that line too, each at its pointer position's distance and on - /// that position's side of the press point; a pointer at the press point leaves them on their - /// line. Without Shift, or while the Blob Brush smooths, the stroke takes `p`. + /// 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. fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point { - self.shift_next = None; if self.straight && self.unconstrained { return p; } @@ -217,26 +199,26 @@ impl GestureTool { return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle); } let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift && !self.smoothing) else { - self.shift_raw.clear(); self.shift_line = None; return p; }; - self.shift_next = Some(p); - // The press point is on both lines: the points dragged before stay on theirs. - if (p - start).hypot() < vectorcraft_geom::EPS { - return start; + if self.shift_line.is_none() { + // Raw press-to-pointer displacement in logical screen pixels along the constraint + // axes. Zoom changes the document distance, not the activation tolerance. + let v = p - start; + let v = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(Point::new(v.x, v.y), |axes| axes.inverse() * Point::new(v.x, v.y)); + let x = v.x.abs() * cx.zoom; + let y = v.y.abs() * cx.zoom; + self.shift_line = if x >= 1.0 && y < 0.5 { + Some(cx.constrain_angle) + } else if y >= 1.0 && x < 0.5 { + Some(cx.constrain_angle + 90.0) + } else { + // No unconstrained trail while pending, including an initially diagonal drag. + return self.points.last().copied().unwrap_or(start); + }; } - let d = vectorcraft_geom::constrain_angle_from(p - start, 90.0, cx.constrain_angle); - // `line` is the line's angle, measured counterclockwise on screen as the Constrain Angle is measured. - let line = (-d.y).atan2(d.x).to_degrees(); - let on_line = |q: Point| start + vectorcraft_geom::constrain_angle_from(q - start, 180.0, line); - if self.shift_line != Some(line) { - for (q, raw) in self.points.iter_mut().skip(1).rev().zip(self.shift_raw.iter().rev()) { - *q = on_line(*raw); - } - self.shift_line = Some(line); - } - on_line(p) + start + vectorcraft_geom::constrain_angle_from(p - start, 180.0, self.shift_line.unwrap_or(cx.constrain_angle)) } /// Forget the stroke. diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 25e59c67c..e3e8c4a28 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -301,9 +301,7 @@ fn blob_brush_sends_its_fidelity() { } /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, -/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points -/// dragged with Shift held move to the other line together, and each point follows the Shift -/// state of its own event. +/// with its axis chosen by the first clear axial movement. Crossing it never moves earlier samples. #[test] fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let (d, _) = doc_with_rect(); @@ -311,46 +309,51 @@ fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { let p = paint(); let mut cx = cx(&d, &s, &p); let shift = Mods { shift: true, ..Default::default() }; - let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { - let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(c, "path.blob"); - v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() - }; let mut t = GestureTool::new("blobBrush"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); - assert_eq!(pts.len(), 3); - assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); - // A pointer that crosses to the vertical line takes the points before it along, each on - // its own side of the press point and at its own distance. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); - assert_eq!(pts.len(), 4, "{pts:?}"); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); - assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); - // Without Shift the stroke follows the pointer. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); - assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); - // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, - // and a later press of Shift leaves the points of the earlier one where they are. let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); - t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); - let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); - assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); - assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); - assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); - // The axes turn with the Constrain Angle. - cx.constrain_angle = 30.0; - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); - // A pointer back at the press point leaves the stroke on its line. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); + for zoom in [0.5, 1.0, 4.0] { + cx.zoom = zoom; + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 4.0 / zoom, 4.0 / zoom, shift)); + assert_eq!(t.points, vec![Point::ZERO]); + assert!(t.overlays(&cx).is_empty()); + t.pointer(&cx, &ev(PointerKind::Drag, 1.0 / zoom, 0.5 / zoom, shift)); + assert_eq!(t.points.len(), 1, "small component has a strict bound"); + t.pointer(&cx, &ev(PointerKind::Drag, 1.0 / zoom, 0.0, shift)); + let first = t.points[1]; + t.pointer(&cx, &ev(PointerKind::Drag, 3.0 / zoom, 10.0 / zoom, shift)); + assert_eq!(t.points[1], first); + assert!(t.points.iter().all(|p| p.y.abs() < 1e-9)); + // Release Shift: freehand resumes without relocating the constrained points. + t.pointer(&cx, &ev(PointerKind::Drag, 8.0 / zoom, 12.0 / zoom, Mods::default())); + assert_eq!(t.points.last(), Some(&Point::new(8.0 / zoom, 12.0 / zoom))); + t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 15.0 / zoom, shift)); + assert_eq!(t.points[1], first); + assert!(t.points.last().unwrap().x.abs() < 1e-9); + t.pointer(&cx, &ev(PointerKind::Up, 0.0, 15.0 / zoom, shift)); + } + // Exact quarter turns preserve the strict half-pixel boundary in either direction. + for angle in [-450.0, -180.0, -90.0, 90.0, 180.0, 450.0] { + cx.constrain_angle = angle; + cx.zoom = 1.0; + let axes = vectorcraft_geom::constrain_axes(angle).unwrap(); + for (x, y) in [(1.0, 0.5), (0.5, 1.0), (-1.0, -0.5), (-0.5, -1.0)] { + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, shift)); + let at = axes * Point::new(x, y); + t.pointer(&cx, &ev(PointerKind::Drag, at.x, at.y, shift)); + assert_eq!(t.points, vec![Point::ZERO], "angle {angle}, ({x}, {y}) stays pending"); + assert!(t.shift_line.is_none()); + let at = axes * Point::new(x, 0.0); + t.pointer(&cx, &ev(PointerKind::Drag, at.x, at.y, shift)); + assert_eq!(t.shift_line.is_some(), x.abs() >= 1.0); + t.clear(); + } + } + cx.constrain_angle = 0.0; + // A pending diagonal released without locking makes the ordinary dot at the press. + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0, shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0, shift)); + assert!(matches!(&acts[0], Action::Exec(c, v) if c == "path.blob" && v["points"] == json!([[20.0, 20.0]])), "{acts:?}"); } /// Alt switches the Blob Brush to the Smooth tool: held before the press, or pressed before the diff --git a/docs/gaps.md b/docs/gaps.md index d5b98bd65..90190b5cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -829,6 +829,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | | 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | | 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4e66928f6..9c1548270 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -278,6 +278,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-10 | minor | M9 row from main: initial Revolve | | 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3fde68541..e6e40858c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1428,6 +1428,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | | 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | | 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f86001db8..e05c4c272 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -485,6 +485,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) | | 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) | | 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation | +| 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | > **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged.