Soft brushes paint soft strokes and thin lines stay even: a Gaussian-like tip and area coverage for tiny tips

Brush phase 1 (owner-approved option A of the brush audit):

- Soft tips fall off as (1 - t²)⁴ from the hard core to the edge, a Gaussian-like curve that
  tracks exp(-4.6 t²) but is exact on every platform and reaches 0 at the edge with a flat tangent.
  The old smoothstep kept so much of the tip near full strength that overlapping dabs (Flow builds
  up, as in Photoshop) turned a soft stroke's edge to half the tip's width. It now keeps about two
  thirds of it, at the default 10 % spacing, with no ripple along the stroke.
- Computed tips under 3 px radius are rasterised by covered area (a 4×4 grid per pixel, each
  sample anti-aliased over its quarter pixel) instead of at pixel centres: a 1 px dab's total
  coverage no longer swings 1.00–1.17 with its sub-pixel position, so thin lines don't bead.
- The default spacing stays 10 %. Photoshop's documented 25 % beads soft strokes with either
  curve; it waits for Photoshop-authored reference strokes.
This commit is contained in:
Tanish Manku committed 2026-10-09 16:23:28 -07:00
1 parent a680ed22c9
commit d873a1fada
4 files changed
+111 -21

No files matched your search

+49
View File
@@ -1049,3 +1049,52 @@ fn dab_rect_of_a_far_or_non_finite_centre_does_not_overflow() {
let r = crate::retouch::dab_rect(&huge);
assert!(r.x0 <= r.x1 && r.y0 <= r.y1, "{r:?}");
}
/// Alpha down from a horizontal line at `y = 100.5` on an f32 surface, one value per pixel row.
fn cross_section(b: &BrushSettings, pts: &[StrokePoint], x: i32) -> Vec<f32> {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
render_stroke(&mut s, b, pts, None, false, 1.0);
(100..170).map(|y| s.rgba(x, y)[3]).collect()
}
/// Rows from where the profile drops below 90 % to where it drops below 10 %.
fn soft_edge_width(profile: &[f32]) -> usize {
let below = |v: f32| profile.iter().position(|a| *a < v).unwrap_or(profile.len());
below(0.1).saturating_sub(below(0.9))
}
#[test]
fn a_soft_stroke_keeps_most_of_its_tips_softness() {
// The default brush (10 % spacing), 100 px, hardness 0. Overlapping dabs build up (Flow); a
// Gaussian-like tip keeps most of its area faint, so the stroke keeps about two thirds of the
// tip's soft edge (half with the old smoothstep falloff).
let b = BrushSettings { size: 100.0, hardness: 0.0, pressure_size: false, ..Default::default() };
let stroke = cross_section(&b, &line(0.0, 400.0, 100.5), 200);
let dab = cross_section(&b, &[StrokePoint::new(200.5, 100.5, 1.0)], 200);
let (s, d) = (soft_edge_width(&stroke), soft_edge_width(&dab));
assert!(d >= 20 && s * 100 >= d * 65, "stroke soft edge {s} px vs tip {d} px");
}
#[test]
fn a_tiny_dabs_coverage_does_not_depend_on_where_it_lands_in_a_pixel() {
let total = |x: f64, y: f64| {
let b = BrushSettings { size: 1.0, hardness: 1.0, pressure_size: false, ..Default::default() };
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
render_stroke(&mut s, &b, &[StrokePoint::new(x, y, 1.0)], None, false, 1.0);
(0..10).flat_map(|py| (0..10).map(move |px| (px, py))).map(|(px, py)| s.rgba(px, py)[3]).sum::<f32>()
};
let sums: Vec<f32> = [(5.5, 5.5), (5.75, 5.5), (5.0, 5.0), (5.3, 5.8)].iter().map(|&(x, y)| total(x, y)).collect();
let (lo, hi) = sums.iter().fold((f32::MAX, 0.0f32), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
assert!(hi - lo < 0.03 * hi, "total coverage by sub-pixel position: {sums:?}");
}
#[test]
fn the_tip_falls_off_from_the_hard_core_to_zero_at_the_edge() {
use crate::tip_falloff;
assert_eq!(tip_falloff(0.0, 10.0, 0.0), 1.0);
assert_eq!(tip_falloff(10.0, 10.0, 0.0), 0.0);
assert_eq!(tip_falloff(4.0, 10.0, 0.5), 1.0, "inside the hard core");
let v: Vec<f32> = (0..=20).map(|i| tip_falloff(i as f32 * 0.5, 10.0, 0.0)).collect();
assert!(v.windows(2).all(|w| w[1] <= w[0]), "{v:?}");
assert!((tip_falloff(5.0, 10.0, 0.0) - 0.75f32.powi(4)).abs() < 1e-6);
}
+19 -10
View File
@@ -137,6 +137,24 @@ pub fn dabs(stroke: &Stroke) -> Vec<Dab> {
out
}
/// Opacity of a round tip at distance `d` from its centre, without edge anti-aliasing: 1 in the
/// hard core (`hardness` of the radius), then a Gaussian-like fall to 0 at the radius:
/// `(1 - t²)⁴`, which tracks `exp(-4.6 t²)` closely but is exact (no `exp`, the same on every
/// platform), reaches exactly 0 at the edge and leaves it flat. A soft tip keeps most of its area
/// faint, so a stroke of overlapping dabs (Flow builds up) stays about as soft as one dab, as in
/// Photoshop; the old smoothstep kept so much near the centre that strokes came out hard.
#[inline]
pub fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 {
let inner = radius * hardness.clamp(0.0, 1.0);
if d <= inner || radius - inner < 1e-3 {
return 1.0;
}
let t = ((d - inner) / (radius - inner)).clamp(0.0, 1.0);
let s = 1.0 - t * t;
let s2 = s * s;
s2 * s2
}
/// Coverage of a round dab at distance `d` from its centre (anti-aliased edge, hardness falloff).
#[inline]
pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 {
@@ -144,16 +162,7 @@ pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 {
return 0.0;
}
let edge = ((radius + 0.5 - d).clamp(0.0, 1.0)).min(1.0);
let h = hardness.clamp(0.0, 1.0);
let inner = radius * h;
let falloff = if d <= inner || radius - inner < 1e-3 {
1.0
} else {
let t = ((d - inner) / (radius - inner)).clamp(0.0, 1.0);
// smoothstep falloff
1.0 - t * t * (3.0 - 2.0 * t)
};
edge * falloff
edge * tip_falloff(d, radius, hardness)
}
/// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface).
+41 -10
View File
@@ -19,10 +19,14 @@ use crate::dynamics::DabGenerator;
use crate::retouch::{Footprint, alpha_index, over_native};
use crate::rng::hash2;
use crate::tile::{Mips, PatternImage};
use crate::{Dab, StrokePoint, dab_coverage};
use crate::{Dab, StrokePoint, dab_coverage, tip_falloff};
/// Edge of a stroke-buffer tile.
pub const COV_TILE: i32 = 64;
/// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres.
const SMALL_TIP_RADIUS: f32 = 3.0;
/// Sub-pixel sample offsets (a 4×4 grid) for the area coverage of small tips.
const SUBPIXEL: [f32; 4] = [-0.375, -0.125, 0.125, 0.375];
const NOISE_SALT: u64 = 0x006E_6F69_7365;
#[inline]
@@ -192,22 +196,49 @@ impl BrushContext {
}
None => (0, 1.0, 1.0, 1.0),
};
// A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip.
let to_tip = |dx: f32, dy: f32| {
let (mut ux, mut uy) = (dx, -dy);
if let Some((ax, ay, k)) = proj {
let t = (ux * ax + uy * ay) * k;
ux += t * ax;
uy += t * ay;
}
((ux * cs + uy * sn) * fx, (-ux * sn + uy * cs) * fy)
};
// A tip a few pixels across takes each pixel's covered area (a 4×4 grid inside it):
// sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel
// position, and thin lines beaded.
let small = mips.is_none() && !aliased && rm < SMALL_TIP_RADIUS;
let small_reach2 = (rm + 0.75) * (rm + 0.75);
for yy in 0..h {
let y = rect.y0 + yy as i32;
let dy = y as f32 + 0.5 - cy;
for xx in 0..w {
let x = rect.x0 + xx as i32;
let dx = x as f32 + 0.5 - cx;
// To y-up, project, rotate by -angle, flip.
let (mut ux, mut uy) = (dx, -dy);
if let Some((ax, ay, k)) = proj {
let t = (ux * ax + uy * ay) * k;
ux += t * ax;
uy += t * ay;
}
let u = (ux * cs + uy * sn) * fx;
let v = (-ux * sn + uy * cs) * fy;
let (u, v) = to_tip(dx, dy);
let (mut val, rn) = match mips {
None if small => {
let d2 = (u * ro).powi(2) + v * v;
if d2 >= small_reach2 {
continue;
}
let mut sum = 0.0;
for sy in SUBPIXEL {
for sx in SUBPIXEL {
let (su, sv) = to_tip(dx + sx, dy + sy);
let sd = ((su * ro).powi(2) + sv * sv).sqrt();
// Each sample anti-aliases over its own quarter pixel, so the
// covered area changes smoothly with the dab's position.
let inside = ((rm - sd) * 4.0 + 0.5).clamp(0.0, 1.0);
if inside > 0.0 {
sum += inside * tip_falloff(sd.min(rm), rm, hardness);
}
}
}
(sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm)
}
None => {
let d2 = (u * ro).powi(2) + v * v;
if d2 >= reach2 {
+2 -1
View File
@@ -4780,7 +4780,8 @@ mod tests {
}
let (shown, key) = display_doc(&mut app, 0);
assert_ne!(key, 0);
assert!(alpha(&shown, 40, 30) > 0.5 && (0.01..0.5).contains(&alpha(&shown, 40, 38)), "soft stroke while drawing");
// A partial value two thirds of the way out: the soft tip, not a hard flat stand-in.
assert!(alpha(&shown, 40, 30) > 0.5 && (0.01..0.5).contains(&alpha(&shown, 40, 36)), "soft stroke while drawing");
assert_eq!(alpha(&app.session.documents()[0].doc, 40, 30), 0.0, "not committed yet");
// The canvas redraws only what the stroke touched.
let dk = canvas_display(&app, &app.session.documents()[0].doc, None).1;