Merge pull request #11895 from RickiNano/fix/11887-catch-up-on-end-default

fix(paint): Catch-up on Stroke End starts unchecked (#11887)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-09 20:24:32 +01:00
commit b479d94e21
20 files changed
+212 -28

No files matched your search

@@ -282,7 +282,7 @@ fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() {
#[test]
fn mixer_long_segments_are_interpolated_and_replay_matches_live() {
let mut s = fixture(PixelFormat::RGBA8);
let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000","brush":{"smoothing":{"catchUpOnEnd":true}}});
let live = LiveMixer::begin(&s, &p).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let expected = live.doc.layer(id).unwrap().surface().unwrap().clone();
+5 -1
View File
@@ -985,7 +985,11 @@ fn mixer_brush_uses_the_brush_mixer_settings() {
let mut s = session(200, 30);
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
s.execute(
"tools.setBrush",
json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}, "smoothing": {"catchUpOnEnd": true}}}),
)
.unwrap();
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
// A param still overrides the brush.
+6 -1
View File
@@ -142,6 +142,11 @@ pub fn stroke_brush(base: &BrushSettings, p: &Value) -> BrushSettings {
flow: pct("flow", 100.0, 1.0, 100.0),
// Brush Settings › Brush Tip Shape › Spacing, as the Brush uses it (#2140).
spacing: pct("spacing", base.spacing * 100.0, 1.0, 1000.0),
// The options bar's Smoothing; the UI sends 0 for a ⇧-click line, which isn't smoothed (#11887).
smoothing: match p.get("smoothing") {
Some(_) => photocraft_paint::Smoothing { enabled: true, amount: pct("smoothing", 0.0, 0.0, 100.0), ..base.smoothing.clone() },
None => base.smoothing.clone(),
},
erase: false,
..base.clone()
}
@@ -205,7 +210,7 @@ fn damage_json(r: Rect) -> Value {
/// The brush every retouching command shares, plus the tool's own params.
macro_rules! brush_params {
($extra:literal) => {
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"smoothing":0..100=options bar Smoothing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
};
}
@@ -2151,6 +2151,7 @@ fn spot_healing_keeps_film_grain_through_overlapping_strokes() {
};
for (depth, kind) in [(8u64, "contentAware"), (8, "proximityMatch"), (8, "createTexture"), (16, "createTexture")] {
let mut s = session(400, 300, depth, "rgb");
s.execute("tools.setBrush", json!({"brush": {"smoothing": {"catchUpOnEnd": true}}})).unwrap();
paint_layer(&mut s, film_grain);
let (m0, sd0) = red(&s, 0, 100, 0, 100);
for i in 0..8 {
@@ -2442,6 +2443,7 @@ fn periodic_clone_low_flow_revisits_accumulate_across_tile_aliases() {
let setup = |width| {
let mut s = Session::new();
s.execute("file.new", json!({"width":width,"height":24,"depth":32})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"smoothing": {"catchUpOnEnd": true}}})).unwrap();
for x in (8..width).step_by(32) {
s.execute("paint.pencil", json!({"points":[[x,12]],"size":16,"color":"#ff0000"})).unwrap();
}
@@ -2721,3 +2723,19 @@ fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() {
assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn");
}
}
/// `smoothing` (the options bar's %) overrides the session brush's amount and keeps its other
/// options; without it the stroke takes the session brush's. The UI sends 0 for a ⇧-click line (#11887).
#[test]
fn stroke_brush_takes_the_smoothing_param() {
let mut base = BrushSettings::default();
base.smoothing.amount = 0.6;
base.smoothing.pulled_string = true;
assert_eq!(stroke_brush(&base, &json!({})).smoothing, base.smoothing);
let line = stroke_brush(&base, &json!({"smoothing": 0}));
assert_eq!(line.smoothing.amount, 0.0);
assert!(line.smoothing.pulled_string);
assert_eq!(stroke_brush(&base, &json!({"smoothing": 25})).smoothing.amount, 0.25);
assert_eq!(stroke_brush(&base, &json!({"smoothing": 1e9})).smoothing.amount, 1.0);
assert_eq!(stroke_brush(&base, &json!({"smoothing": -5})).smoothing.amount, 0.0);
}
+1 -1
View File
@@ -538,7 +538,7 @@ pub struct Smoothing {
impl Default for Smoothing {
fn default() -> Self {
Self { enabled: true, amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: true, adjust_for_zoom: true }
Self { enabled: true, amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: false, adjust_for_zoom: true }
}
}
+10 -8
View File
@@ -225,6 +225,7 @@ fn pencil_1px_line_is_the_same_whichever_way_and_however_smoothed() {
let pixels = |a: (f64, f64), e: (f64, f64), amount: f32| {
let mut b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() };
b.smoothing.amount = amount;
b.smoothing.catch_up_on_end = true;
let s = paint(&b, &[StrokePoint::new(a.0, a.1, 1.0), StrokePoint::new(e.0, e.1, 1.0)], 24, 24);
(0..24).flat_map(|y| (0..24).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect::<Vec<_>>()
};
@@ -325,6 +326,7 @@ fn hard_round_presets_have_smooth_large_stroke_outlines() {
let mut b = preset.brush.clone();
b.size = 150.0;
b.smoothing.amount = smoothing;
b.smoothing.catch_up_on_end = true;
let mut s = Surface::new(fmt);
render_stroke(&mut s, &b, &line(100.0, 800.0, 120.0), None, false, 1.0);
let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
@@ -869,7 +871,7 @@ fn smoothing_modes() {
let zig: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 6.0 }, 1.0)).collect();
let dev = |pts: &[StrokePoint]| pts.iter().map(|p| (p.y - 3.0).abs()).sum::<f64>() / pts.len() as f64;
// Exponential smoothing reduces the zig-zag.
let cfg = Smoothing { amount: 0.8, ..Default::default() };
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
let sm = smooth_points(&cfg, 1.0, &zig);
assert!(dev(&sm[5..sm.len() - 1]) < dev(&zig) * 0.5);
// Catch-up on end finishes at the last input point; without it, the stroke ends short.
@@ -908,7 +910,7 @@ fn smoothing_modes() {
fn disabled_smoothing_keeps_its_settings_and_paints_unsmoothed_at_every_depth() {
let pts = vec![StrokePoint::new(10.0, 20.0, 1.0), StrokePoint::new(30.0, 8.0, 0.5), StrokePoint::new(50.0, 20.0, 0.8)];
for pulled_string in [false, true] {
let smoothing = Smoothing { enabled: false, amount: 0.8, pulled_string, ..Default::default() };
let smoothing = Smoothing { enabled: false, amount: 0.8, pulled_string, catch_up_on_end: true, ..Default::default() };
let got = smooth_points(&smoothing, 1.0, &pts);
assert_eq!(got.len(), pts.len());
for (a, b) in got.iter().zip(&pts) {
@@ -940,7 +942,7 @@ fn disabled_smoothing_keeps_its_settings_and_paints_unsmoothed_at_every_depth()
#[test]
fn smoothing_string_runs_from_the_lagging_brush_to_the_pointer() {
// #3479: the canvas draws the line between the brush and the pointer.
let cfg = Smoothing { amount: 0.8, ..Default::default() };
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
let mut s = Smoother::new(&cfg, 1.0);
let mut out = Vec::new();
assert_eq!(s.string(), None, "no stroke yet");
@@ -1005,7 +1007,7 @@ fn a_smoothed_brush_keeps_the_pressure_of_where_it_is_when_the_pen_lifts() {
// a hairline from the trailing brush to the pointer.
let pts: Vec<StrokePoint> =
(0..=20).map(|i| StrokePoint { time: f64::from(i) * 4.0, ..StrokePoint::new(f64::from(i) * 10.0, 0.0, if i < 20 { 1.0 } else { 0.05 }) }).collect();
let out = smooth_points(&Smoothing { amount: 0.8, ..Default::default() }, 1.0, &pts);
let out = smooth_points(&Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() }, 1.0, &pts);
let (last, rest) = out.split_last().unwrap();
assert_eq!((last.x, last.pressure), (200.0, 0.05), "the catch-up still ends at the pen's last sample");
let lifted = rest.last().unwrap();
@@ -1034,7 +1036,7 @@ fn a_pulled_string_brush_takes_the_pose_along_the_string() {
fn a_smoothed_brush_catches_up_with_a_pen_lifted_in_place() {
// #7747: only the pressure changes while the pointer stands still. The brush lags in pose,
// so catch-up must keep running and the stroke end at the pen's last pressure.
let cfg = Smoothing { amount: 0.8, ..Default::default() };
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
let mut s = Smoother::new(&cfg, 1.0);
let mut out = Vec::new();
s.push(StrokePoint::new(50.0, 50.0, 1.0), &mut out);
@@ -1053,7 +1055,7 @@ fn a_smoothed_brush_catches_up_with_a_pen_lifted_in_place() {
#[test]
fn catch_up_on_stroke_end_finishes_the_pose_of_a_still_pen() {
// #7747: tilt, rotation and wheel changed in place; the end catch-up lands on all of them.
let cfg = Smoothing { amount: 0.8, ..Default::default() };
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
let a = StrokePoint { rotation: 350.0, ..StrokePoint::new(50.0, 50.0, 1.0) };
let b = StrokePoint { time: 16.0, tilt_x: 40.0, tilt_y: -20.0, rotation: 10.0, wheel: 0.5, ..StrokePoint::new(50.0, 50.0, 1.0) };
let mut s = Smoother::new(&cfg, 1.0);
@@ -1314,7 +1316,7 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
// Live previews draw the smoothing catch-up tail before the stroke ends: compositing the
// tail preview over the incremental composite gives exactly the finished stroke.
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true);
let b = BrushSettings { size: 9.0, smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..brush() };
let b = BrushSettings { size: 9.0, smoothing: Smoothing { amount: 0.7, catch_up_on_end: true, ..Default::default() }, ..brush() };
let pts: Vec<StrokePoint> = (0..12).map(|i| StrokePoint::new(5.0 + i as f64 * 9.0, 20.0 + (i % 3) as f64 * 7.0, 1.0)).collect();
let pre = Surface::new(fmt);
let mut live = pre.clone();
@@ -1339,7 +1341,7 @@ fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk()
// every pass and merging them with `composite_union_many` gives (the commit takes these pixels).
let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() };
let mut brushes = vec![base.clone()];
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() });
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, catch_up_on_end: true, ..Default::default() }, ..base.clone() });
let mut b = base.clone();
b.dual_brush.enabled = true;
b.dual_brush.size = 6.0;
+3
View File
@@ -408,3 +408,6 @@ mod tests {
#[cfg(test)]
mod brush_tests;
#[cfg(test)]
mod smoothing_catch_up_default_tests;
@@ -0,0 +1,36 @@
//! Catch-up on Stroke End starts unchecked, as in Photoshop (#11887).
use crate::StrokePoint;
use crate::brush::{BrushSettings, Smoothing};
use crate::dynamics::smooth_points;
/// A quick drag from x = 100 to x = 300 in 40 steps.
fn drag() -> Vec<StrokePoint> {
(0..=40).map(|i| StrokePoint::new(100.0 + f64::from(i) * 5.0, 100.0, 1.0)).collect()
}
#[test]
fn catch_up_on_end_defaults_off_and_the_others_stay() {
let s = Smoothing::default();
assert!(!s.catch_up_on_end);
assert!(!s.pulled_string && s.catch_up && s.adjust_for_zoom);
assert!(!BrushSettings::default().smoothing.catch_up_on_end);
let json = serde_json::to_value(&BrushSettings::default().smoothing).unwrap();
assert_eq!(json["catchUpOnEnd"], false);
}
#[test]
fn default_smoothed_stroke_ends_short_of_the_release_point() {
let cfg = Smoothing { amount: 0.5, ..Default::default() };
let out = smooth_points(&cfg, 1.0, &drag());
let end = out.last().unwrap();
assert!(end.x < 299.0, "ends at {}", end.x);
}
#[test]
fn catch_up_on_end_on_reaches_the_release_point() {
let cfg = Smoothing { amount: 0.5, catch_up_on_end: true, ..Default::default() };
let out = smooth_points(&cfg, 1.0, &drag());
let end = out.last().unwrap();
assert_eq!((end.x, end.y), (300.0, 100.0));
}
+3 -1
View File
@@ -100,7 +100,9 @@ pub(crate) use selection::{floating_outline, selection_drag_delta};
pub(crate) use start_screen::start_screen_drop_hint;
pub(crate) use strokes::multiple_color_target;
pub use strokes::paint_target;
pub(crate) use strokes::{ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, spread_ms, strokes_live};
pub(crate) use strokes::{
ClonePreviewCache, CommittedStroke, LiveStroke, cancel_mixer_on_context_change, freehand_tool, spread_ms, strokes_live, unsmoothed_line,
};
use strokes::{HOLD_REPEAT_MS, draw_clone_preview, feed_live_stroke, pointer_button_modifiers, pointer_moves, stroke_wants_time, tap_at_peak_pressure};
#[cfg(test)]
pub(crate) use strokes::{SPOT_HEALING_TRAIL, live_retouch_points};
+4
View File
@@ -16,6 +16,9 @@ pub struct Drag {
pub clear: bool,
/// ⇧ constraint of a painting stroke (stroke_constraint.rs).
pub constrain: Option<crate::stroke_constraint::Axis>,
/// A ⇧-click line from the previous stroke's end. Smoothing doesn't apply to it, so it ends
/// at the click whatever Catch-up on Stroke End is set to, as in Photoshop (#11887).
pub line: bool,
/// Modifiers held now (updated on every pointer event of the gesture).
pub live: egui::Modifiers,
/// Which of the modifiers held at the press have been released since.
@@ -55,6 +58,7 @@ impl Drag {
erase,
clear: false,
constrain: None,
line: false,
live: modifiers,
released: egui::Modifiers::NONE,
reposition: false,
+2 -1
View File
@@ -87,7 +87,8 @@ fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option<bool>) {
if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement {
app.live_stroke = None;
}
let finished = crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers);
let finished =
crate::eraser_ui::finish_stroke(app, d.tool, &d.points, d.line) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers, d.line);
app.session.clear_live_stroke();
if finished {
return;
+18 -2
View File
@@ -293,10 +293,12 @@ pub(super) fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke
}
/// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The
/// stroke smoothing is the session brush's (the options bar's Smoothing %).
/// stroke smoothing is the session brush's (the options bar's Smoothing %), except on a ⇧-click
/// line, which isn't smoothed (#11887).
pub(super) fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec<f64>]) -> serde_json::Value {
let tool = d.tool;
let mut p = json!({ "points": points, "erase": d.erase, "zoom": app.point_zoom(), "target": paint_target(app) });
unsmoothed_line(&mut p, d.line);
if d.clear {
p["mode"] = json!("clear");
}
@@ -310,6 +312,15 @@ pub(super) fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec<f64>])
p
}
/// A ⇧-click line isn't smoothed, so it ends at the click whatever Catch-up on Stroke End is set
/// to (#11887). Applied to the params of every painting tool's live preview and commit alike, so
/// a retouching stroke's commit still takes the pixels its preview drew.
pub(crate) fn unsmoothed_line(p: &mut serde_json::Value, line: bool) {
if line && let Some(o) = p.as_object_mut() {
o.insert("smoothing".into(), json!(0.0));
}
}
/// Tools whose strokes the engine renders while they are drawn (`LiveStroke`).
pub(crate) fn strokes_live(tool: Tool) -> bool {
matches!(
@@ -364,17 +375,20 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
// The params `retouch_ui::finish_stroke` commits.
let mut p = crate::retouch_ui::color_replacement_params(app);
p["points"] = json!(d.points);
unsmoothed_line(&mut p, d.line);
EngineStroke::Dab(Box::new(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?))
} else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) {
// The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed.
p["points"] = json!(d.points);
p["target"] = paint_target(app);
unsmoothed_line(&mut p, d.line);
EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?))
} else if d.tool == Tool::PatternStamp {
// The params `retouch_ui::finish_stroke` commits.
let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points);
p["points"] = json!(d.points);
p["target"] = paint_target(app);
unsmoothed_line(&mut p, d.line);
if app.ui.tool_options.pattern_stamp_impressionist {
EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?))
} else {
@@ -382,13 +396,15 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
}
} else if d.tool == Tool::BackgroundEraser {
// The params `eraser_ui::finish_stroke` commits (layer pixels only).
let p = crate::eraser_ui::background_eraser_params(app, &d.points);
let mut p = crate::eraser_ui::background_eraser_params(app, &d.points);
unsmoothed_line(&mut p, d.line);
EngineStroke::Dab(Box::new(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?))
} else if let Some(cmd) = live_retouch_command(d.tool) {
// The params `retouch_ui::finish_stroke` commits.
let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? };
p["points"] = json!(d.points);
p["target"] = paint_target(app);
unsmoothed_line(&mut p, d.line);
EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?))
} else {
let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points));
+3 -1
View File
@@ -2410,6 +2410,8 @@ fn drag_frames(smoothing: f32, xs: &[f64]) -> (std::sync::Arc<Document>, std::sy
app.run("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.5}})).unwrap();
// What the options bar's Smoothing field writes.
app.session.tools.brush.smoothing.amount = smoothing;
// Catch-up on Stroke End starts off (#11887); these tests draw the catch-up tail.
app.session.tools.brush.smoothing.catch_up_on_end = true;
app.ui.tool = Tool::Brush;
ensure_texture(&mut app, &ctx, 0, None);
let m = egui::Modifiers::NONE;
@@ -2481,7 +2483,7 @@ fn smoothed_stroke_shows_its_catch_up_tail_while_drawing() {
// And the commit is what `paint.stroke` gives with that smoothing.
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 200, "height": 80, "background": "transparent"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.5, "smoothing": {"amount": 0.5}}})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.5, "smoothing": {"amount": 0.5, "catchUpOnEnd": true}}})).unwrap();
let mut pts = vec![json!([10.0, 40.0, 1.0])];
pts.extend(xs.iter().map(|&x| json!([x, 40.0 + (x / 7.0).sin() * 8.0, 1.0])));
// (Round tips without dynamics draw the same for any seed.)
+1 -1
View File
@@ -578,7 +578,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// ⌥ flips Dodge/Burn and Blur/Sharpen for this stroke.
let tool = crate::retouch_ui::alt_flipped(tool, mods.alt);
let mut drag = Drag { clear, ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) };
let mut drag = Drag { clear, line: from.is_some(), ..Drag::new(tool, from.map_or([x, y], |p| [p[0], p[1]]), points, mods, erase) };
drag.capture_vector_owners(app);
drag.capture_selection_at_press(app);
drag.document = super::drag::DragDocument::capture(app);
@@ -656,7 +656,7 @@ fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() {
assert_eq!(h.state().session.tools.mixer.reservoir, None);
// The stroke the canvas commits on release lays down only the red it picks up.
crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE);
crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE, false);
let d = h.state().session.active().unwrap();
let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20);
assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}");
+35
View File
@@ -499,6 +499,41 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() {
}
}
/// A ⇧-click Dodge line isn't smoothed (#11887): its live preview and its commit both send
/// `smoothing: 0`, so the release still takes the pixels the preview drew.
#[test]
fn a_shift_click_dodge_line_commits_its_live_pixels_unsmoothed() {
let mut h = harness();
h.state_mut().run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "color": "#2060c0"})).unwrap();
h.state_mut().ui.tool = Tool::Dodge;
h.run_steps(1);
h.state_mut().run("tools.setBrush", json!({"brush": {"size": 12, "smoothing": {"amount": 0.6, "catchUpOnEnd": false}}})).unwrap();
move_to(&mut h, 40.0, 30.0);
button(&mut h, 40.0, 30.0, true);
move_to(&mut h, 100.0, 50.0);
button(&mut h, 100.0, 50.0, false);
h.run_steps(2);
let shift = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool| {
let pos = screen(h, 160.0, 70.0);
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT });
h.run_steps(1);
};
h.event(egui::Event::ModifiersChanged(Modifiers::SHIFT));
move_to(&mut h, 160.0, 70.0);
shift(&mut h, true);
assert!(h.state().live_stroke.is_some(), "a live line");
h.state_mut().retouch_from_live = false;
shift(&mut h, false);
h.run_steps(2);
let app = h.state();
let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.dodge").cloned().expect("a committed line");
let ends: Vec<[f64; 2]> = p["points"].as_array().unwrap().iter().map(|q| [q[0].as_f64().unwrap(), q[1].as_f64().unwrap()]).collect();
assert_eq!(ends, vec![[100.0, 50.0], [160.0, 70.0]]);
assert_eq!(p["smoothing"], json!(0.0));
assert!(app.retouch_from_live, "the release took the live pixels instead of rendering the line again");
assert_eq!(app.session.tools.brush.smoothing.amount, 0.6);
}
/// Pressure changes Smudge's painted footprint, but not the square of colour it carries.
/// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo.
#[test]
+4 -2
View File
@@ -36,11 +36,13 @@ pub fn click(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool {
}
/// Finishes a Background Eraser stroke. Returns false for other tools.
pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) -> bool {
pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], line: bool) -> bool {
if tool != Tool::BackgroundEraser {
return false;
}
let r = app.run("paint.backgroundEraser", background_eraser_params(app, points));
let mut p = background_eraser_params(app, points);
crate::canvas::unsmoothed_line(&mut p, line);
let r = app.run("paint.backgroundEraser", p);
report(app, r);
true
}
+2 -1
View File
@@ -126,7 +126,7 @@ pub(crate) fn stroke_seed(points: &[[f64; 3]]) -> u64 {
}
/// Finish a stroke with a retouching tool. Returns false if `tool` isn't one.
pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], mods: egui::Modifiers) -> bool {
pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], mods: egui::Modifiers, line: bool) -> bool {
let o = app.ui.tool_options.clone();
let pts = json!(points);
let (cmd, mut p): (&str, Value) = match tool {
@@ -183,6 +183,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
p["points"] = pts;
// The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints.
p["target"] = crate::canvas::paint_target(app);
crate::canvas::unsmoothed_line(&mut p, line);
let result = app.run(cmd, p);
app.retouch_from_live = matches!(&result, Ok(r) if r.get("fromLive").and_then(Value::as_bool) == Some(true));
match result {
+6 -6
View File
@@ -147,7 +147,7 @@ fn clone_stamp_brush_opacity_and_flow_reach_the_painted_pixels() {
app.ui.clone_source = Some([10.0, 10.0]);
app.ui.tool_options.clone_sample = "all".into();
app.run("tools.setBrush", json!({"brush": {"opacity": 0.5, "flow": 0.5}})).unwrap();
assert!(finish_stroke(&mut app, Tool::CloneStamp, &[[50.0, 30.0, 1.0]], egui::Modifiers::NONE));
assert!(finish_stroke(&mut app, Tool::CloneStamp, &[[50.0, 30.0, 1.0]], egui::Modifiers::NONE, false));
assert!(!app.ui.status_error, "{}", app.ui.status);
let pixel = active(&app).surface().unwrap().rgba(50, 30);
assert!(pixel[0] > 0.9 && pixel[3] > 0.0 && pixel[3] < 0.5, "partial-opacity, partial-flow red stamp: {pixel:?}");
@@ -224,7 +224,7 @@ fn clone_without_a_source_names_the_platform_modifier() {
for tool in [Tool::CloneStamp, Tool::Healing] {
let mut app = app();
app.ui.tool = tool;
assert!(finish_stroke(&mut app, tool, &[[10.0, 30.0, 1.0], [50.0, 30.0, 1.0]], egui::Modifiers::NONE));
assert!(finish_stroke(&mut app, tool, &[[10.0, 30.0, 1.0], [50.0, 30.0, 1.0]], egui::Modifiers::NONE, false));
assert!(app.ui.status_error, "{tool:?}");
let want = if cfg!(target_os = "macos") { "Option-click" } else { "Alt-click" };
assert!(app.ui.status.starts_with(want), "{tool:?}: {}", app.ui.status);
@@ -1041,7 +1041,7 @@ fn s_group_is_clone_stamp_then_pattern_stamp() {
#[test]
fn pattern_stamp_stroke_paints_pixels_and_keeps_phase_when_aligned() {
let mut app = app();
assert!(finish_stroke(&mut app, Tool::PatternStamp, &[[20.0, 30.0, 1.0], [50.0, 30.0, 1.0]], egui::Modifiers::NONE));
assert!(finish_stroke(&mut app, Tool::PatternStamp, &[[20.0, 30.0, 1.0], [50.0, 30.0, 1.0]], egui::Modifiers::NONE, false));
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("paint.patternStamp"));
assert!(app.ui.pattern_stamp_phase.is_some());
@@ -1049,7 +1049,7 @@ fn pattern_stamp_stroke_paints_pixels_and_keeps_phase_when_aligned() {
assert!(px[3] > 0.5, "the stamp paints through the brush: {px:?}");
app.ui.tool_options.pattern_stamp_aligned = false;
app.ui.pattern_stamp_phase = None;
assert!(finish_stroke(&mut app, Tool::PatternStamp, &[[70.0, 30.0, 1.0]], egui::Modifiers::NONE));
assert!(finish_stroke(&mut app, Tool::PatternStamp, &[[70.0, 30.0, 1.0]], egui::Modifiers::NONE, false));
assert!(app.ui.pattern_stamp_phase.is_none(), "unaligned strokes do not keep phase");
}
@@ -1057,7 +1057,7 @@ fn pattern_stamp_stroke_paints_pixels_and_keeps_phase_when_aligned() {
fn pattern_stamp_does_not_require_a_clone_source() {
let mut app = app();
assert!(app.ui.clone_source.is_none());
assert!(finish_stroke(&mut app, Tool::PatternStamp, &[[12.0, 30.0, 1.0]], egui::Modifiers::NONE));
assert!(finish_stroke(&mut app, Tool::PatternStamp, &[[12.0, 30.0, 1.0]], egui::Modifiers::NONE, false));
assert!(!app.ui.status_error, "{}", app.ui.status);
assert!(app.ui.clone_source.is_none());
}
@@ -1147,7 +1147,7 @@ fn history_brush_opacity_and_flow_reach_the_painted_pixels() {
app.run("paint.pencil", json!({"points": [[10, 10]], "size": 16, "color": "#00ff00"})).unwrap();
app.ui.tool = Tool::HistoryBrush;
app.run("tools.setBrush", json!({"brush": {"opacity": 0.5, "flow": 0.5}})).unwrap();
assert!(finish_stroke(&mut app, Tool::HistoryBrush, &[[10.0, 10.0, 1.0]], egui::Modifiers::NONE));
assert!(finish_stroke(&mut app, Tool::HistoryBrush, &[[10.0, 10.0, 1.0]], egui::Modifiers::NONE, false));
assert!(!app.ui.status_error, "{}", app.ui.status);
let (id, params) = app.session.journal.last().unwrap();
assert_eq!(id, "paint.historyBrush");
@@ -243,6 +243,59 @@ mod tests {
assert!(alpha(&app, 20, 100) > 0.9 && alpha(&app, 40, 80) > 0.9 && alpha(&app, 90, 30) > 0.9);
}
#[test]
fn shift_click_line_ends_at_the_click_with_catch_up_on_stroke_end_off() {
// Measured in Photoshop (#11887): smoothing doesn't apply to a ⇧-click line, whatever
// Smoothing and Catch-up on Stroke End are set to.
let mut app = app();
app.ui.tool = Tool::Brush;
app.session.tools.brush.smoothing.amount = 0.6;
app.session.tools.brush.smoothing.catch_up_on_end = false;
let none = egui::Modifiers::NONE;
// A plain two-point drag at this smoothing ends short of the release point.
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 60.0, pressure: 1.0 }, none);
tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 60.0, pressure: 1.0 }, none);
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 60.0 }, none);
assert!(alpha(&app, 100, 60) == 0.0, "a smoothed drag lags");
// ⇧-click: the line runs from the drag's release point all the way to the click.
let shift = egui::Modifiers::SHIFT;
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 110.0, pressure: 1.0 }, shift);
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 110.0 }, shift);
assert_eq!(last_stroke_points(&app), vec![[100.0, 60.0], [100.0, 110.0]]);
assert!(alpha(&app, 100, 62) > 0.9 && alpha(&app, 100, 85) > 0.9 && alpha(&app, 100, 110) > 0.9);
// The options bar's Smoothing is unchanged.
assert_eq!(app.session.tools.brush.smoothing.amount, 0.6);
}
#[test]
fn retouch_shift_click_line_ends_at_the_click_with_catch_up_on_stroke_end_off() {
// The retouching tools build their brush in the engine (`retouch_cmds::stroke_brush`, or
// `resolve_brush` for the Background Eraser), so their lines need the override too (#11887).
for tool in [Tool::Burn, Tool::BackgroundEraser] {
let mut app = app();
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
let px = |app: &PhotocraftApp, x, y| app.session.documents()[0].doc.layers[0].surface().unwrap().rgba(x, y);
let gray = px(&app, 100, 60);
app.ui.tool = tool;
// The tool's own brush, which the press would swap in.
crate::paint_mouse::sync_tool_brush(&mut app);
app.session.tools.brush.smoothing.amount = 0.6;
app.session.tools.brush.smoothing.catch_up_on_end = false;
let none = egui::Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 60.0, pressure: 1.0 }, none);
tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 60.0, pressure: 1.0 }, none);
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 60.0 }, none);
assert_eq!(px(&app, 100, 60), gray, "{tool:?}: a smoothed drag lags");
let shift = egui::Modifiers::SHIFT;
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 110.0, pressure: 1.0 }, shift);
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 110.0 }, shift);
for y in [62, 85, 110] {
assert_ne!(px(&app, 100, y), gray, "{tool:?}: the line reaches (100, {y})");
}
assert_eq!(app.session.tools.brush.smoothing.amount, 0.6);
}
}
#[test]
fn shift_click_interpolates_from_previous_strokes_endpoint_pressure() {
let mut app = app();