mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
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:
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user