From 5a1f6eef2e32be21fee2240a2218dccc1e83c5b5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:14:17 +0200 Subject: [PATCH] Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662) (#2694) * Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662) The Pencil cursor always outlined the square grid_square footprint, but the aliased dab is a disc from 4 px on. It now draws a circle on the same pixel-grid square when the tip is round, and the square for 1-3 px tips whose dab really fills it. The Eraser options bar gains Photoshop's Mode: Brush / Pencil (replacing the blend-mode dropdown the Eraser never used). Pencil mode commits paint.pencil with erase: true (hard, aliased, full flow), live and on release, and shows the Pencil's cursor. Block mode is left for later. Co-Authored-By: Claude Opus 5.5 * Eraser: translate the Pencil mode label in every catalog The Eraser's Mode dropdown added tl!("Pencil"), which had no entry in the cs, el, ko, zh-hans and zh-hant catalogs, so every_tl_literal_is_translated failed. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 38 +++++++++++++++++++------- crates/ui-egui/src/eraser_ui.rs | 13 +++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels.rs | 37 +++++++++++++++++--------- crates/ui-egui/src/pencil_tests.rs | 41 ++++++++++++++++++++++++++++- crates/ui-egui/src/state.rs | 9 +++++++ 10 files changed, 121 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7fdeb2a4da..06af2a85e1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -362,9 +362,10 @@ fn live_retouch_command(tool: Tool) -> Option<&'static str> { } } -/// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`. -pub(crate) fn stroke_command(tool: Tool) -> &'static str { - if tool == Tool::Pencil { "paint.pencil" } else { "paint.stroke" } +/// The command a live-stroking tool commits: the Pencil's (and the Pencil-mode Eraser's) +/// `paint.pencil`, else `paint.stroke`. +pub(crate) fn stroke_command(app: &PhotocraftApp, tool: Tool) -> &'static str { + if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) { "paint.pencil" } else { "paint.stroke" } } /// Windows' crosshair cursor inverts the pixels under it, so over mid-grey (the pasteboard, many @@ -388,6 +389,16 @@ pub(crate) fn pencil_cursor_rect(xf: &ViewXform, doc: [f64; 2], size: f32, ppp: Rect::from_min_max(pos2(snap(r.min.x), snap(r.min.y)), pos2(snap(r.max.x), snap(r.max.y))) } +/// Whether an aliased (Pencil) dab of `size` pixels is round rather than its whole +/// [`pencil_cursor_rect`] square. The dab fills the pixels whose centres lie within its radius of +/// the grid centre (`paint::grid_center`), so up to 3 px the corner pixels are in and the dab is +/// a square; from 4 px on they fall outside and it is a disc (#2662). +pub(crate) fn pencil_tip_is_round(size: f32) -> bool { + let n = if size.is_finite() { size.round().clamp(1.0, 100_000.0) } else { 1.0 }; + let h = n / 2.0; + (h - 0.5) * std::f32::consts::SQRT_2 > h +} + /// Whether a brush-tip circle draws its centre mark. /// Clone Stamp and Healing Brush stay an empty circle until Option is held, the source-point /// cursor. Other brushes follow the cursor preference, the large-tip mark, or the Background Eraser. @@ -466,7 +477,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) } else { let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points)); - EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).ok()?)) + EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?)) }; let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff; let damage = vec![stroke.bounds()]; @@ -3024,13 +3035,22 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: PaintingCursor::Standard => egui::CursorIcon::Default, PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0), _ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0), - // The Pencil: the square of whole pixels its dab fills, on the pixel grid. - _ if tool == Tool::Pencil => { + // The Pencil (and the Eraser in Pencil mode): the outline of the + // whole pixels its dab fills, on the pixel grid. A round tip is a + // disc, a tiny one the whole square (#2662). + _ if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) => { let ppp = painter.ctx().pixels_per_point(); let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp); let px = 1.0 / ppp; - painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside); - painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside); + let (dark, light) = (Stroke::new(px, Color32::from_black_alpha(160)), Stroke::new(px, Color32::from_white_alpha(230))); + if pencil_tip_is_round(brush.size) { + let r = sq.width() / 2.0; + painter.circle_stroke(sq.center(), r + px / 2.0, dark); + painter.circle_stroke(sq.center(), (r - px / 2.0).max(0.0), light); + } else { + painter.rect_stroke(sq, 0.0, dark, egui::StrokeKind::Outside); + painter.rect_stroke(sq, 0.0, light, egui::StrokeKind::Inside); + } // Too small to see where it is: the hotspot as well. if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 { crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0); @@ -4326,7 +4346,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } // The canvas already shows the stroke: let the commit's damage rect refresh it rather // than recompositing the whole document. - if app.run(stroke_command(d.tool), p).is_ok() + if app.run(stroke_command(app, d.tool), p).is_ok() && let Some(l) = live { // Raw preview key 0 = the document itself (its colour display folded in). diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index fed6ade74a..fa62347e2f 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -53,6 +53,19 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - true } +/// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through +/// `paint.pencil` (`"erase": true`) and shows the Pencil's pixel-grid cursor (#2662). +pub fn pencil_mode(app: &PhotocraftApp, tool: Tool) -> bool { + tool == Tool::Eraser && app.ui.tool_options.eraser_mode == "pencil" +} + +/// The Eraser's Mode dropdown (Brush / Pencil). Returns whether it changed. +pub fn mode_dropdown(ui: &mut egui::Ui, mode: &mut String) -> bool { + opt(ui, tl!("Mode:")); + let modes = [("brush".to_string(), tl!("Brush")), ("pencil".to_string(), tl!("Pencil"))]; + widgets::dropdown(ui, "eraser-mode", mode, &modes, 96.0) +} + fn opt(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!(&text)).color(t.text_dim).size(12.0)); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 022f60c61a..5234ff9710 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1549,6 +1549,7 @@ Blur Tool Nástroj rozostření Bold Tučné Brush Štětec + Pencil Tužka Brush Pose Poloha štětce Brush Tip Shape Tvar hrotu štětce Brush Tool Nástroj štětec diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f1f160fb9b..907fd8f83f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3360,6 +3360,7 @@ cameraRaw Yellow Κίτρινο Apple Watch 45mm Apple Watch 45mm Black Matte Μαύρο ματ Brush Πινέλο + Pencil Μολύβι Color… Χρώμα… Cone (Inverted) Κώνος (αντεστραμμένος) Could not open Δεν ήταν δυνατό το άνοιγμα diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 96cbe43edb..792485fae8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1503,6 +1503,7 @@ Blur Tool 흐림 효과 도구 Bold 굵게 Brush 브러시 + Pencil 연필 Brush Pose 브러시 자세 Brush Tip Shape 브러시 끝 모양 Brush Tool 브러시 도구 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 759b1533b6..f1a0d52a62 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1544,6 +1544,7 @@ Blur Tool 模糊工具 Bold 粗体 Brush 画笔 + Pencil 铅笔 Brush Pose 画笔姿态 Brush Tip Shape 画笔笔尖形状 Brush Tool 画笔工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 41bb2d66f4..b20e71ca30 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1538,6 +1538,7 @@ Blur Tool 模糊工具 Bold 粗體 Brush 筆刷 + Pencil 鉛筆 Brush Pose 筆刷姿勢 Brush Tip Shape 筆尖形狀 Brush Tool 筆刷工具 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d06b087279..7cad11516f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -837,21 +837,29 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); - opt_label(ui, tl!("Mode")); - let mut mode = b.mode; - let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { - b.mode = mode; + // The Eraser has no blend mode: its Mode is Brush or Pencil (#2662). + if app.ui.tool == Tool::Eraser { + crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } else { + opt_label(ui, tl!("Mode")); + let mut mode = b.mode; + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + if widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0) { + b.mode = mode; + } } percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0); if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() { b.pressure_opacity = !b.pressure_opacity; } - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); - let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); - if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() { - b.build_up = !b.build_up; - } + // A Pencil-mode Eraser is always full flow, without build-up (Photoshop). + ui.add_enabled_ui(!crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| { + percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0); + let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects")); + if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() { + b.build_up = !b.build_up; + } + }); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 58.0); smoothing_options(ui, b); @@ -889,9 +897,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { opt_label(ui, tl!("Size")); widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); widgets::vline(ui, 22.0); - percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0); + if app.ui.tool == Tool::Eraser { + crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode); + } + // A Pencil-mode Eraser is always hard and at full flow (Photoshop). + let pencil = crate::eraser_ui::pencil_mode(app, app.ui.tool); + ui.add_enabled_ui(!pencil, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0)); percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0); - percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0); + ui.add_enabled_ui(!pencil, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0)); opt_label(ui, tl!("Smoothing")); smoothing_field(ui, b, 66.0); widgets::vline(ui, 22.0); diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index 53cce12173..fc33581bd0 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -6,7 +6,7 @@ use egui_kittest::Harness; use serde_json::json; use crate::PhotocraftApp; -use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, tool_event}; +use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, pencil_tip_is_round, tool_event}; use crate::state::Tool; fn app() -> PhotocraftApp { @@ -137,6 +137,45 @@ fn square_cursor_sits_on_the_pixel_grid() { let _ = pencil_cursor_rect(&xf, [f64::NAN, f64::INFINITY], f32::NAN, f32::NAN); } +#[test] +fn the_cursor_is_round_exactly_when_the_pencil_dab_is() { + // #2662: one dab per size; the cursor is the square only while the dab fills its corners. + let mut app = app(); + for n in 1..=12u32 { + let (x, y) = (8.0 + f64::from((n - 1) % 6) * 18.0 + 0.3, 15.0 + f64::from((n - 1) / 6) * 30.0 + 0.6); + app.run("paint.pencil", json!({"points": [[x, y]], "size": n})).unwrap(); + let [x0, y0, ..] = photocraft_engine::paint::grid_square(x, y, n as f32); + let corner = rgba(&app, x0 as i32, y0 as i32)[3] == 1.0; + assert_eq!(pencil_tip_is_round(n as f32), !corner, "{n} px"); + } + assert!(pencil_tip_is_round(9.0) && !pencil_tip_is_round(3.0)); + assert!(!pencil_tip_is_round(f32::NAN)); +} + +#[test] +fn the_eraser_in_pencil_mode_erases_aliased_pixels() { + // #2662: an opaque layer, then a soft Eraser in Pencil mode erases with hard edges. + let mut app = app(); + app.run("paint.pencil", json!({"points": [[60.0, 40.0]], "size": 400})).unwrap(); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 15, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap(); + app.ui.tool_options.eraser_mode = "pencil".into(); + drag(&mut app, &[(10.0, 40.0), (60.3, 25.7), (110.0, 40.0)], Modifiers::NONE); + let (id, p) = last(&app); + assert_eq!((id.as_str(), &p["erase"]), ("paint.pencil", &json!(true))); + let s = layer(&app).surface().unwrap().clone(); + let mut erased = 0; + for y in 0..80 { + for x in 0..120 { + let a = s.rgba(x, y)[3]; + assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})"); + erased += usize::from(a == 0.0); + } + } + assert!(erased > 500, "{erased}"); +} + #[test] fn b_cycles_brush_pencil_and_mixer_brush() { let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 38abfb4bd2..5e6021b668 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -569,6 +569,10 @@ pub struct ToolOptions { /// Pencil › Auto Erase: a stroke that starts on the foreground colour paints the background colour. #[serde(default)] pub pencil_auto_erase: bool, + /// Eraser › Mode: `brush` (soft, anti-aliased) or `pencil` (hard, aliased pixels, through + /// `paint.pencil`), as in Photoshop's options bar (#2662). + #[serde(default = "default_eraser_mode")] + pub eraser_mode: String, /// Magnetic Lasso: detection width (px, 1..256), edge contrast (%, 1..100), how often it /// fastens points by itself (0..100), and whether pen pressure narrows the width. pub magnetic_width: f32, @@ -606,6 +610,10 @@ fn default_move_target() -> String { "layer".into() } +fn default_eraser_mode() -> String { + "brush".into() +} + fn default_marquee_style() -> String { "normal".into() } @@ -704,6 +712,7 @@ impl Default for ToolOptions { bg_protect_fg: false, zoom_scrubby: true, pencil_auto_erase: false, + eraser_mode: default_eraser_mode(), magnetic_width: 10.0, magnetic_contrast: 10.0, magnetic_frequency: 57.0,