Merge pull request #12169 from DuBearte/fix/11886-airbrush-rate

Airbrush: about ten dabs a second while held, and a click lays two dabs
This commit is contained in:
David Mann authored and GitHub committed 2026-11-11 00:50:39 +01:00
commit c4b4afc0a9
5 files changed
+67 -7

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
//! #11886: with Airbrush on, a brush held still builds up at about ten dabs a second and a plain
//! click lays two dabs, as in Photoshop. Measured there with the default 13 px soft brush at Flow
//! 10 %, black on white: the dab centre reads 205 after a click and 26 after a 2 s hold, and 229
//! with Airbrush off however long the button is held.
use photocraft_color::PixelFormat;
use photocraft_raster::Surface;
use crate::{BrushSettings, StrokePoint, render_stroke};
fn brush(build_up: bool) -> BrushSettings {
BrushSettings { size: 13.0, hardness: 0.0, flow: 0.1, pressure_size: false, build_up, color: [0.0, 0.0, 0.0, 1.0], ..BrushSettings::default() }
}
/// The 0–255 value at the dab centre over white after holding the button still for `ms`.
fn centre_after_hold(b: &BrushSettings, ms: f64) -> f32 {
let a = StrokePoint { time: 0.0, ..StrokePoint::new(30.0, 30.0, 1.0) };
let z = StrokePoint { time: ms, ..a };
let mut s = Surface::new(PixelFormat::RGBA32F);
render_stroke(&mut s, b, &[a, z], None, false, 1.0);
255.0 * (1.0 - s.rgba(30, 30)[3])
}
#[test]
fn the_default_rate_is_ten_dabs_a_second() {
assert_eq!(BrushSettings::default().build_up_rate, 10.0);
}
#[test]
fn a_click_lays_two_dabs() {
let v = centre_after_hold(&brush(true), 0.0);
assert!((v - 205.0).abs() <= 5.0, "{v}");
}
#[test]
fn a_two_second_hold_builds_up_to_the_reference_value() {
let v = centre_after_hold(&brush(true), 2000.0);
assert!((v - 26.0).abs() <= 8.0, "{v}");
}
#[test]
fn airbrush_off_keeps_one_dab_however_long_it_is_held() {
for ms in [0.0, 500.0, 2000.0] {
let v = centre_after_hold(&brush(false), ms);
assert!((v - 229.0).abs() <= 3.0, "{ms} ms: {v}");
}
}
+2 -1
View File
@@ -656,7 +656,8 @@ impl Default for BrushSettings {
noise: false,
wet_edges: false,
build_up: false,
build_up_rate: 20.0,
// About ten dabs a second while held still, Photoshop's Airbrush rate (#11886).
build_up_rate: 10.0,
smoothing: Smoothing::default(),
protect_texture: false,
locks: SectionLocks::default(),
+5 -4
View File
@@ -609,7 +609,8 @@ fn build_up_accumulates_over_time() {
let (n0, a0) = hold(0.0);
let (n1, a1) = hold(500.0);
let (n2, a2) = hold(2000.0);
assert_eq!((n0, n1, n2), (1, 11, 41));
// A click lays two dabs with the airbrush on (#11886), then one per 50 ms at this rate.
assert_eq!((n0, n1, n2), (2, 12, 42));
assert!(a0 < a1 && a1 < a2, "{a0} {a1} {a2}");
// Without build-up, lingering adds nothing.
let nb = BrushSettings { build_up: false, ..b };
@@ -624,15 +625,15 @@ fn huge_airbrush_time_gap_emits_bounded_catch_up() {
let b = BrushSettings { build_up: true, build_up_rate: 1000.0, ..brush() };
let a = StrokePoint { time: 0.0, ..StrokePoint::new(20.0, 20.0, 1.0) };
let brief_pause = StrokePoint { time: 200.0, ..a };
assert_eq!(dabs_of(&b, &[a, brief_pause]).len(), 201, "a 200 ms pause should retain its airbrush build-up");
assert_eq!(dabs_of(&b, &[a, brief_pause]).len(), 202, "a 200 ms pause should retain its airbrush build-up (after the click's two dabs)");
let z = StrokePoint { time: f64::MAX, ..a };
let d = dabs_of(&b, &[a, z]);
assert!(d.len() <= 2, "a huge pause should resume with one dab, not replay its time backlog: {}", d.len());
assert!(d.len() <= 3, "a huge pause should resume with one dab, not replay its time backlog: {}", d.len());
assert_eq!(d.last().map(|dab| dab.center), Some(Point::new(20.0, 20.0)));
let infinite = StrokePoint { time: f64::INFINITY, ..a };
assert!(dabs_of(&b, &[a, infinite]).len() <= 1);
assert!(dabs_of(&b, &[a, infinite]).len() <= 2, "the click's two dabs, no backlog");
}
#[test]
+11 -2
View File
@@ -341,6 +341,15 @@ impl PathWalker {
self
}
/// The stroke's first point. With the airbrush on it lays two dabs, so a plain click builds
/// up as Photoshop's does (#11886).
fn emit_first(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {
self.emit(point, out);
if self.interval.is_some() {
self.emit(point, out);
}
}
fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {
if self.step >= MAX_STEPS_PER_STROKE {
return;
@@ -369,7 +378,7 @@ impl PathWalker {
}
}
if let Some(f) = self.pending_first.take() {
self.emit(f, out);
self.emit_first(f, out);
}
if len > 1e-9 {
// Speed spacing needs the segment's duration; without timestamps (a straight ⇧-click
@@ -432,7 +441,7 @@ impl PathWalker {
pub fn finish(&mut self, out: &mut impl FnMut(StepInput)) {
if let Some(f) = self.pending_first.take() {
self.emit(f, out);
self.emit_first(f, out);
}
}
}
+2
View File
@@ -406,6 +406,8 @@ mod tests {
}
}
#[cfg(test)]
mod airbrush_rate_tests;
#[cfg(test)]
mod brush_tests;