diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index dc53e9b8..04ac59f7 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -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 { + 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::() + }; + let sums: Vec = [(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 = (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); +} diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index e8317b01..51707201 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -137,6 +137,24 @@ pub fn dabs(stroke: &Stroke) -> Vec { 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). diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 290e7283..386c5c0a 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -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 { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c388a073..19424f11 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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;