mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 18:20:31 -06:00
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
96ec16ed7d
commit
5a1f6eef2e
@@ -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<LiveStroke> {
|
||||
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).
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3360,6 +3360,7 @@ cameraRaw Yellow Κίτρινο
|
||||
Apple Watch 45mm Apple Watch 45mm
|
||||
Black Matte Μαύρο ματ
|
||||
Brush Πινέλο
|
||||
Pencil Μολύβι
|
||||
Color… Χρώμα…
|
||||
Cone (Inverted) Κώνος (αντεστραμμένος)
|
||||
Could not open Δεν ήταν δυνατό το άνοιγμα
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 973 and column 18.
|
@@ -1503,6 +1503,7 @@
|
||||
Blur Tool 흐림 효과 도구
|
||||
Bold 굵게
|
||||
Brush 브러시
|
||||
Pencil 연필
|
||||
Brush Pose 브러시 자세
|
||||
Brush Tip Shape 브러시 끝 모양
|
||||
Brush Tool 브러시 도구
|
||||
|
||||
|
@@ -1544,6 +1544,7 @@
|
||||
Blur Tool 模糊工具
|
||||
Bold 粗体
|
||||
Brush 画笔
|
||||
Pencil 铅笔
|
||||
Brush Pose 画笔姿态
|
||||
Brush Tip Shape 画笔笔尖形状
|
||||
Brush Tool 画笔工具
|
||||
|
||||
|
@@ -1538,6 +1538,7 @@
|
||||
Blur Tool 模糊工具
|
||||
Bold 粗體
|
||||
Brush 筆刷
|
||||
Pencil 鉛筆
|
||||
Brush Pose 筆刷姿勢
|
||||
Brush Tip Shape 筆尖形狀
|
||||
Brush Tool 筆刷工具
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user