From d1a5787b2b57ca84f31eb12bd5cfa3e31ed2ee1e Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 12 Oct 2026 11:58:24 -0700 Subject: [PATCH] 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. --- crates/engine/src/brush_cmds/tests.rs | 7 ++-- crates/paint/src/brush_tests.rs | 57 ++++++++++++++++++++++++++- crates/paint/src/lib.rs | 17 ++++++-- crates/paint/src/render.rs | 12 +++--- crates/paint/src/render_reference.rs | 2 +- crates/paint/src/retouch.rs | 4 +- 6 files changed, 85 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 3a04a470..9416324b 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -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 = 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] diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 664aca9c..64dc31c2 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -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) { + let b = BrushSettings { size: 30.0, hardness: 1.0, pressure_size: false, ..Default::default() }; + let pts: Vec = (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::() + (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::() / err.len() as f64; + let sd = (err.iter().map(|e| (e - mean).powi(2)).sum::() / 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"); + } +} diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index c5d06524..cf73c121 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -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). diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 94ac63a7..055d477e 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -173,7 +173,9 @@ fn prepare_pattern(b: &BrushSettings) -> Option> { /// 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; diff --git a/crates/paint/src/render_reference.rs b/crates/paint/src/render_reference.rs index 3811e7c9..c3326ffc 100644 --- a/crates/paint/src/render_reference.rs +++ b/crates/paint/src/render_reference.rs @@ -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) => { diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 0129c316..b034164c 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -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"