mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
a680ed22c9
commit
d873a1fada
4 files changed
+111
-21
No files matched your search
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user