Hard brushes: a 2 px anti-aliased edge, so curves and diagonals aren't stair-stepped (#4088)

A computed tip's edge ramped from 1 to 0 over 1 px at its radius. On a hard brush
that left curved and diagonal stroke edges visibly stepped next to Photoshop's,
whose hard round edge measured about 1.6x as wide in the report's screenshots.

The ramp is now 2 px (EDGE_HALF = 1.0), in one place (`edge_ramp`) for every
computed-tip rasterizer and the reference renderer. A dab is still exactly half
covered at its radius, so its size and the cursor rings don't move; dab rects grow
with the ramp. A hard 30 px diagonal stroke's edge now wobbles 0.032 px around the
true line, down from 0.074. Soft brushes barely change, the Pencil stays aliased,
and tips under 3 px radius keep their sub-pixel coverage.
This commit is contained in:
Tanish Manku 2026-10-12 11:58:24 -07:00
parent d43db3e153
commit d1a5787b2b
6 changed files with 85 additions and 14 deletions

View File

@ -191,7 +191,8 @@ fn live_color_replacement_matches_the_commit() {
}
}
assert!(b.rgba(10, 20)[1] > 0.5 || sampling == "backgroundSwatch", "{depth}-bit {sampling}: recoloured");
assert!(worst <= 2.0 / 255.0, "{depth}-bit {sampling}: live and commit differ by {worst}");
// At most 2 levels (half a level of slack for f32 noise on an exact 2-level step).
assert!(worst < 2.5 / 255.0, "{depth}-bit {sampling}: live and commit differ by {worst}");
}
}
}
@ -744,10 +745,10 @@ fn out_of_range_scatter_is_rejected_and_the_maximum_still_paints() {
let d: Vec<i64> = r["damage"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect();
assert!(d[0].abs() < 1000 && d[1].abs() < 1000 && d[2] > 0 && d[3] > 0, "{r}");
}
// Control: 200 % scatter paints where it did before the cap.
// Control: 200 % scatter paints where it did before the cap (plus the 2 px edge ramp, #4088).
let mut s = new();
s.execute("tools.setBrush", json!({ "brush": scatter(2.0) })).unwrap();
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [17, 14, 23, 23]}));
assert_eq!(s.execute("paint.stroke", stroke).unwrap(), json!({"damage": [16, 13, 25, 25]}));
}
#[test]

View File

@ -1250,7 +1250,8 @@ fn huge_scatter_is_capped_and_renders_at_every_depth() {
let ctx = BrushContext::new(&b);
for d in &capped.0 {
let r = ctx.dab_rect(d, false);
assert!(!r.is_empty() && r.width() <= 24 && r.height() <= 24, "{r:?}");
// 10 px radius, its 1 px edge ramp and the rectangle's margin.
assert!(!r.is_empty() && r.width() <= 25 && r.height() <= 25, "{r:?}");
}
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
@ -1400,3 +1401,57 @@ fn dithering_never_moves_an_exact_colour_or_an_untouched_pixel() {
}
assert_eq!(s.rgba(50, 5), [1.0, 1.0, 1.0, 1.0], "far from the stroke: untouched");
}
/// The top edge of a hard 30 px stroke along `y = 100 + slope · x`, per column from 60 to 240:
/// the paint above the centre line (exact for ideal area anti-aliasing) minus the true half-width,
/// and how many pixels per column are partly painted.
fn hard_diagonal_edge(slope: f64) -> (Vec<f64>, f64) {
let b = BrushSettings { size: 30.0, hardness: 1.0, pressure_size: false, ..Default::default() };
let pts: Vec<StrokePoint> = (0..=300).map(|i| StrokePoint::new(i as f64, 100.0 + i as f64 * slope, 1.0)).collect();
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
render_stroke(&mut s, &b, &pts, None, false, 1.0);
let want = 15.0 * (1.0 + slope * slope).sqrt();
let (mut err, mut partial) = (Vec::new(), 0usize);
for x in 60..240 {
let centre = 100.0 + (f64::from(x) + 0.5) * slope;
let c = centre.floor() as i32;
let above = (c - 40..c).map(|y| f64::from(s.rgba(x, y)[3])).sum::<f64>() + (centre - f64::from(c)) * f64::from(s.rgba(x, c)[3]);
err.push(above - want);
partial += (c - 40..c).filter(|y| (0.02..0.98).contains(&s.rgba(x, *y)[3])).count();
}
(err, partial as f64 / 180.0)
}
#[test]
fn a_hard_strokes_edge_is_smooth_not_stair_stepped() {
// #4088: a 1 px ramp at the tip's edge left a hard brush's diagonal edges visibly stepped
// next to Photoshop's. A 2 px ramp halves the edge's wobble around the true line.
for slope in [0.17, 0.4, 1.0] {
let (err, partial) = hard_diagonal_edge(slope);
let mean = err.iter().sum::<f64>() / err.len() as f64;
let sd = (err.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / err.len() as f64).sqrt();
assert!(sd < 0.05, "slope {slope}: the edge wobbles {sd:.3} px around the line");
assert!(partial > 1.3, "slope {slope}: {partial:.2} partly painted pixels per column");
assert!(mean.abs() < 0.5, "slope {slope}: the stroke is {mean:.3} px too wide per side");
}
}
#[test]
fn a_hard_dab_keeps_its_size_and_a_two_pixel_edge() {
use crate::{EDGE_HALF, dab_coverage};
// Half covered exactly at the radius (the dab's size, and the cursor's outline, don't move).
for r in [3.0, 10.0, 50.0] {
assert_eq!(dab_coverage(r, r, 1.0), 0.5);
assert_eq!(dab_coverage(r - EDGE_HALF, r, 1.0), 1.0);
assert_eq!(dab_coverage(r + EDGE_HALF, r, 1.0), 0.0);
}
assert_eq!(EDGE_HALF, 1.0);
// The dab's rectangle holds the whole ramp at any sub-pixel position.
let b = BrushSettings { size: 20.0, hardness: 1.0, pressure_size: false, ..Default::default() };
for (x, y) in [(50.0, 50.0), (50.49, 50.99), (50.99, 50.01)] {
let s = paint(&b, &[StrokePoint::new(x, y, 1.0)], 100, 100);
let painted = (0..100).flat_map(|py| (0..100).map(move |px| (px, py))).filter(|&(px, py)| s.rgba(px, py)[3] > 0.0);
let far = painted.map(|(px, py)| (f64::from(px) + 0.5 - x).hypot(f64::from(py) + 0.5 - y)).fold(0.0, f64::max);
assert!(far > 10.5 && far < 11.0, "({x}, {y}): paint reaches {far:.2} px");
}
}

View File

@ -166,14 +166,25 @@ pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 {
radius * (h + t * (1.0 - h))
}
/// Half the width, in pixels, of the anti-aliasing ramp at a computed tip's edge: 2 px across.
/// A 1 px ramp left hard strokes visibly stair-stepped on curves and diagonals next to
/// Photoshop's, whose hard round edge is about 1.6× as wide (measured from screenshots, #4088).
pub const EDGE_HALF: f32 = 1.0;
/// The anti-aliased edge of a computed tip of radius `radius` at distance `d` from its centre:
/// 1 inside, falling linearly to 0 across [`EDGE_HALF`] either side of the radius.
#[inline]
pub fn edge_ramp(d: f32, radius: f32) -> f32 {
((radius + EDGE_HALF - d) / (2.0 * EDGE_HALF)).clamp(0.0, 1.0)
}
/// 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 {
if d >= radius + 0.5 {
if d >= radius + EDGE_HALF {
return 0.0;
}
let edge = ((radius + 0.5 - d).clamp(0.0, 1.0)).min(1.0);
edge * tip_falloff(d, radius, hardness)
edge_ramp(d, radius) * tip_falloff(d, radius, hardness)
}
/// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface).

View File

@ -173,7 +173,9 @@ fn prepare_pattern(b: &BrushSettings) -> Option<Arc<PatternImage>> {
/// saturate and the arithmetic saturates, so a far or non-finite centre or reach from a direct
/// caller gives a (possibly empty) rectangle instead of an `i32` overflow (#977).
pub(crate) fn rect_around(d: &Dab, reach: f32) -> Rect {
let rr = (reach.ceil() as i32).saturating_add(1);
// Every pixel whose centre is within `reach + EDGE_HALF` of the dab's centre: the
// anti-aliased edge outside the radius (`ceil(r) + 1` holds centres to `r + 0.5`).
let rr = ((reach + crate::EDGE_HALF - 0.5).ceil() as i32).saturating_add(1);
let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32);
Rect::new(cx.saturating_sub(rr), cy.saturating_sub(rr), cx.saturating_add(rr).saturating_add(1), cy.saturating_add(rr).saturating_add(1))
}
@ -251,7 +253,7 @@ impl BrushContext {
let r = d.radius;
let ro = d.roundness.max(0.5 / r).min(1.0);
let rm = (r * ro).max(0.5);
let reach2 = (rm + 0.5) * (rm + 0.5);
let reach2 = (rm + crate::EDGE_HALF) * (rm + crate::EDGE_HALF);
// Sampled-tip mapping.
let (level, inv_scale, tw, th) = match mips {
Some(m) => {
@ -1073,7 +1075,7 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased:
let ro = d.roundness.max(0.5 / d.radius).min(1.0);
let rm = (d.radius * ro).max(0.5);
let w = rect.width() as usize;
let reach2 = (rm + 0.5) * (rm + 0.5);
let reach2 = (rm + crate::EDGE_HALF) * (rm + crate::EDGE_HALF);
let inner = rm * hardness.clamp(0.0, 1.0);
for (yy, row) in out.chunks_exact_mut(w).enumerate() {
let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy;
@ -1087,7 +1089,7 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased:
for (xx, dst) in row.iter_mut().enumerate() {
let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx;
let d2 = (dx * ro).powi(2) + dy * dy;
let edge = ((rm + 0.5 - d2.sqrt()).clamp(0.0, 1.0)).min(1.0);
let edge = crate::edge_ramp(d2.sqrt(), rm);
*dst = if d2 >= reach2 || edge <= 0.0 { 0.0 } else { edge * d.alpha };
}
} else {
@ -1095,7 +1097,7 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased:
let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx;
let d2 = (dx * ro).powi(2) + dy * dy;
let dd = d2.sqrt();
let edge = ((rm + 0.5 - dd).clamp(0.0, 1.0)).min(1.0);
let edge = crate::edge_ramp(dd, rm);
let t = ((dd - inner) / (rm - inner)).clamp(0.0, 1.0);
let s = 1.0 - t * t;
let s2 = s * s;

View File

@ -30,7 +30,7 @@ impl BrushContext {
let r = d.radius;
let ro = d.roundness.max(0.5 / r).min(1.0);
let rm = (r * ro).max(0.5);
let reach2 = (rm + 0.5) * (rm + 0.5);
let reach2 = (rm + crate::EDGE_HALF) * (rm + crate::EDGE_HALF);
// Sampled-tip mapping.
let (level, inv_scale, tw, th) = match mips {
Some(m) => {

View File

@ -569,8 +569,10 @@ mod tests {
}
painted += 1;
farthest = farthest.max(dist);
// The edge's faint outer half (under 25 %) lies under the ring's own line, which
// is 3 px wide and centred on the radius (`tool_cursor`).
assert!(
dist < radius as f64 + 0.5,
dist < f64::from(radius + crate::EDGE_HALF),
"hardness {hardness}: pixel ({x}, {y}) at {dist} px from \
the centre is painted past the Full Size ring of \
{radius} px"