Merge pull request #4525 from dotCooCoo/fix/blob-brush-keeps-shift-axis

Blob Brush: keep the first Shift axis without moving earlier points
This commit is contained in:
BFlat
2026-10-29 15:11:13 +07:00
committed by GitHub
6 changed files with 68 additions and 79 deletions
+20 -38
View File
@@ -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<Point>,
/// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds.
shift_next: Option<Point>,
/// 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<f64>,
/// "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.
+44 -41
View File
@@ -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<Action>]| -> 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
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.
+1
View File
@@ -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.