Fix #4977: Blob Brush and Eraser show their tip's outline at the pointer

This commit is contained in:
bflatastic
2026-11-01 07:16:29 +07:00
parent 433ddb593d
commit 859b3732c1
10 changed files with 139 additions and 15 deletions
+7 -1
View File
@@ -327,6 +327,7 @@ impl Session {
},
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
precise_cursors: self.precise_cursors(),
};
let tool = &mut self.tool;
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
@@ -619,7 +620,12 @@ impl Session {
}
None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)),
};
if self.prefs.use_precise_cursors || self.caps_lock { c.precise() } else { c }
if self.precise_cursors() { c.precise() } else { c }
}
/// General › Use Precise Cursors is on, or Caps Lock is.
fn precise_cursors(&self) -> bool {
self.prefs.use_precise_cursors || self.caps_lock
}
pub fn tool_options(&self) -> Value {
+3
View File
@@ -253,6 +253,9 @@ pub struct ToolContext<'a> {
pub screen: Option<ScreenFrame>,
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
pub plane_widget: Option<distort::perspective::widget::WidgetCorner>,
/// General › Use Precise Cursors, or Caps Lock while it is on: the drawing tools' pointers are
/// crosshairs ([`crate::Cursor::precise`]), and the Blob Brush and the Eraser show no tip outline.
pub precise_cursors: bool,
}
impl ToolContext<'_> {
+4 -4
View File
@@ -125,9 +125,11 @@ pub enum Cursor {
/// the path. Also while a stroke is drawn back near its start, where releasing the mouse button
/// closes the path. A crosshair with a ring.
PaintbrushClose,
/// The Blob Brush tool.
/// The Blob Brush tool: a dot at the brush's centre, the tool drawing the tip's outline round
/// it on the canvas.
BlobBrush,
/// The Eraser tool.
/// The Eraser tool: a dot at the tip's centre, the tool drawing the tip's outline round it on
/// the canvas.
Eraser,
/// The Smooth tool.
Smooth,
@@ -305,8 +307,6 @@ impl Cursor {
| Cursor::MagicWand
| Cursor::Pencil
| Cursor::Paintbrush
| Cursor::BlobBrush
| Cursor::Eraser
| Cursor::Smooth
| Cursor::PathEraser
| Cursor::Join
+4 -2
View File
@@ -583,14 +583,16 @@ look = "The Paintbrush cursor with a ring badge (closing the loop), like PenClos
[[cursor]]
id = "BlobBrush"
group = "Drawing and editing tools"
shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. No brush-size outline shows while hovering."]
shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. The tool draws the tip's outline (its size, angle and roundness, at the zoom) round the pointer on the canvas, before and while painting; with precise cursors (Caps Lock) the crosshair shows instead."]
hotspot = "The brush centre."
look = "Only a small dot at the centre: the tip outline drawn on the canvas is the rest of the pointer."
[[cursor]]
id = "Eraser"
group = "Drawing and editing tools"
shown_when = ["The Eraser tool: a drag erases the area it covers. No size outline shows while hovering."]
shown_when = ["The Eraser tool: a drag erases the area it covers. The tool draws the tip's outline (its size, angle and roundness, at the zoom) round the pointer on the canvas, before and while erasing; with precise cursors (Caps Lock) the crosshair shows instead."]
hotspot = "The eraser's centre."
look = "Only a small dot at the centre, as the Blob Brush's: the tip outline drawn on the canvas is the rest of the pointer."
[[cursor]]
id = "Smooth"
+32 -2
View File
@@ -22,9 +22,9 @@
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Affine, Point};
use super::{FEEDBACK, blob_area, eraser_area, points_json, polyline};
use super::{FEEDBACK, blob_area, blob_tip, eraser_area, eraser_tip, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -116,6 +116,9 @@ pub struct GestureTool {
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
snap: DrawSnap,
/// 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<Point>,
}
/// The paths a stroke can redraw for selected object `id`: the path itself, or a compound path's
@@ -191,6 +194,7 @@ impl GestureTool {
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
tip_at: None,
}
}
@@ -274,6 +278,22 @@ impl GestureTool {
start + vectorcraft_geom::constrain_angle_from(p - start, 180.0, self.shift_line.unwrap_or(cx.constrain_angle))
}
/// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline
/// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on
/// any art. Precise cursors show only the crosshair, and smoothing shows no tip.
fn tip_outline(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] };
let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) };
let r = self.size / 2.0;
// A tolerance relative to the radius: four arcs at any size or zoom.
let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6));
let path = Affine::translate(at.to_vec2()) * tip * circle;
vec![
Overlay::Path { path: path.clone(), color: [0xff, 0xff, 0xff], width: 3.0, dashed: false },
Overlay::Path { path, color: [0, 0, 0], width: 1.0, dashed: false },
]
}
/// Forget the stroke.
fn clear(&mut self) {
self.moved = false;
@@ -476,6 +496,9 @@ impl Tool for GestureTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
if self.varies_size() {
self.tip_at = (!ev.mods.cmd && !self.blob_alt_smooths(ev.mods)).then_some(p);
}
match ev.kind {
PointerKind::Move if self.snaps() => {
self.snap.hover(cx, p, &[], None);
@@ -665,8 +688,12 @@ impl Tool for GestureTool {
_ => vec![],
}
}
fn hover_ended(&mut self, _cx: &ToolContext) {
self.tip_at = None;
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let began = self.began;
self.tip_at = None;
// Drags past the last preview are in the stroke kept, too, and with Live curve fitting
// off the stroke kept is fitted.
let stale = (began && (self.previewed != self.points.len() || !self.live_fit) && self.draws_path() && !self.smoothing)
@@ -685,6 +712,7 @@ impl Tool for GestureTool {
let mut o = self.snap.guides().to_vec();
// A path being drawn shows itself, except with the Pencil's Live preview off.
if !self.active || self.points.len() < 2 || (self.began && self.previews()) {
o.extend(self.tip_outline(cx));
return o;
}
// A Blob Brush drag that smooths shows the Smooth tool's thin line.
@@ -727,6 +755,8 @@ impl Tool for GestureTool {
let length = self.points.windows(2).map(|w| w[0].distance(w[1])).sum();
o.push(Overlay::Measure { p: last + vectorcraft_geom::Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: format!("L: {}", cx.len(length)) });
}
// Over the stroke's footprint, which reaches the tip's edge at the pointer.
o.extend(self.tip_outline(cx));
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
+71 -2
View File
@@ -344,7 +344,8 @@ fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
// Precise cursors: no tip outline, only the stroke's own overlay.
let mut cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) };
let shift = Mods { shift: true, ..Default::default() };
let mut t = GestureTool::new("blobBrush");
let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m);
@@ -1358,7 +1359,8 @@ fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
// Precise cursors: no tip outline, only the stroke's own overlay.
let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) };
let mut t = GestureTool::new("eraser");
t.set_option("size", &json!(20));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
@@ -1438,6 +1440,73 @@ fn the_blob_brush_sends_its_tip_and_shows_the_area_it_covers() {
assert!(vectorcraft_geom::Shape::bounding_box(path).height() < 0.1, "{o:?}");
}
/// #4977: the Blob Brush and the Eraser show their tip at the pointer before a press and while
/// painting: its outline at its size, angle and roundness, a black line on a white halo, following
/// `[` / `]` and Tool Options at once. It goes when the hover ends, with precise cursors (Caps
/// Lock), while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth
/// tool. The other gesture tools show none.
#[test]
fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let bounds = |t: &GestureTool, cx: &ToolContext| match &t.overlays(cx)[..] {
[Overlay::Path { path: halo, color: [0xff, 0xff, 0xff], width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => {
assert_eq!(halo, path);
Some(vectorcraft_geom::Shape::bounding_box(path))
}
[] => None,
o => panic!("{o:?}"),
};
let near = |b: Option<vectorcraft_geom::Rect>, want: (f64, f64, f64, f64)| {
let b = b.unwrap_or_else(|| panic!("no tip"));
assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}");
};
let hover = |t: &mut GestureTool, cx: &ToolContext, m: Mods| t.pointer(cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0).with_mods(m));
for id in ["blobBrush", "eraser"] {
let mut t = GestureTool::new(id);
assert_eq!(bounds(&t, &cx), None, "{id}: nothing before the pointer comes");
hover(&mut t, &cx, Mods::default());
near(bounds(&t, &cx), (95.0, 45.0, 105.0, 55.0));
// `]` grows it at once, without a move; Tool Options too.
t.key(&cx, ToolKey::BracketRight, Mods::default());
near(bounds(&t, &cx), (94.5, 44.5, 105.5, 55.5));
t.set_option("size", &json!(20));
near(bounds(&t, &cx), (90.0, 40.0, 110.0, 60.0));
// Upright and a quarter as wide: 5 pt across, 20 pt tall.
t.set_option("angle", &json!(90));
t.set_option("roundness", &json!(25));
near(bounds(&t, &cx), (97.5, 40.0, 102.5, 60.0));
// It follows the drag, over the stroke's footprint.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0));
let o = t.overlays(&cx);
assert!(matches!(&o[o.len() - 2..], [Overlay::Path { width: 3.0, .. }, Overlay::Path { color: [0, 0, 0], width: 1.0, .. }]), "{id}: {o:?}");
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0));
let precise = ToolContext { precise_cursors: true, ..crate::testutil::cx(&d, &s, &p) };
assert_eq!(bounds(&t, &precise), None, "{id}: precise cursors show the crosshair alone");
t.hover_ended(&cx);
assert_eq!(bounds(&t, &cx), None, "{id}: gone with the hover");
hover(&mut t, &cx, Mods::default());
assert!(t.deactivate(&cx).is_empty());
assert_eq!(bounds(&t, &cx), None, "{id}: gone with the tool");
}
// Alt before a press lends the Blob Brush the Smooth tool, and Cmd/Ctrl either tool a
// selection tool, whose cursors show instead.
let mut t = GestureTool::new("blobBrush");
hover(&mut t, &cx, Mods { alt: true, ..Default::default() });
assert_eq!(bounds(&t, &cx), None);
let mut t = GestureTool::new("eraser");
hover(&mut t, &cx, Mods { cmd: true, ..Default::default() });
assert_eq!(bounds(&t, &cx), None);
for id in ["pencil", "paintbrush", "smooth", "knife", "pathEraser", "join"] {
let mut t = GestureTool::new(id);
hover(&mut t, &cx, Mods::default());
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 3.0)), "{id}");
}
}
#[test]
fn smooth_needs_selection_and_brackets_resize() {
let (d, _) = doc_with_rect();
+8 -1
View File
@@ -507,6 +507,7 @@ pub(crate) mod testutil {
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
screen: None,
plane_widget: Some(Default::default()),
precise_cursors: false,
}
}
}
@@ -553,7 +554,13 @@ mod cursor_tests {
] {
assert_eq!(cursor_at(id, &d, away), c, "{id}");
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
assert_eq!(c.stand_in(), Some(if id == "mesh" { Cursor::PenAdd } else { Cursor::Crosshair }), "{id}");
// The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it.
let stand_in = match id {
"mesh" => Some(Cursor::PenAdd),
"blobBrush" | "eraser" => None,
_ => Some(Cursor::Crosshair),
};
assert_eq!(c.stand_in(), stand_in, "{id}");
}
// Away from a selected path the Pencil shows its new-path cursor, drawn with a star; its
// own cursor, for drawing and for a selected path under the pointer, borrows the crosshair.
+2 -2
View File
@@ -120,13 +120,13 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair,
Cursor::TypeVertical => C::VerticalText,
// The system has no tip outline to go round: its crosshair aims as well.
Cursor::BlobBrush | Cursor::Eraser => C::Crosshair,
// Not drawn yet: the cursor each borrows.
Cursor::Lasso
| Cursor::MagicWand
| Cursor::Pencil
| Cursor::Paintbrush
| Cursor::BlobBrush
| Cursor::Eraser
| Cursor::Smooth
| Cursor::PathEraser
| Cursor::Join
+5
View File
@@ -554,6 +554,11 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
}
Cursor::Crosshair => crosshair(ink, p),
// Only the centre: the tool draws the tip's outline round it, at the zoom.
Cursor::BlobBrush | Cursor::Eraser => {
ink.circle_filled(p, 2.5, HALO);
ink.circle_filled(p, 1.25, INK);
}
// The Pencil's crosshair with the Pen's star where the closing ring goes.
Cursor::PencilNew => {
crosshair(ink, p);
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Layers panel: a blend's spine row, #4820) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Blob Brush and Eraser show their tip's outline at the pointer, #4977) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -87,6 +87,7 @@ tools, never checked side by side) isn't captured row by row.
| Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge |
| Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it |
| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed |
| Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests |
| Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) |
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
@@ -388,6 +389,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Blob Brush and Eraser: the tip's outline (size, angle, roundness, at the zoom) follows the pointer before and while painting, around a small centre dot, updating at once with `[` / `]` and Tool Options; precise cursors (Caps Lock) show only the crosshair (#4977); scores and estimates unchanged (estimated) |
| 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `<Path>` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged |
| 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged |
| 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains |