feat(brush): Photoshop's measured round tip (#4617)

Photoshop 27.11's round tip, measured on single dabs (hardness 0-100 in
steps of 10 at 40 px, checked at 100, 9, 7, 5, 3, 2 and 1 px): a solid
core of radius r0*R, then a Gaussian shoulder exp(-((d - r0) / s)^2) of
width s*R, both from a table by hardness. A soft tip reaches well past its
nominal radius (hardness 0 is a Gaussian of sigma 0.47 R: 8 % at 1.05 R);
PhotoCraft's (1 - t^2)^4 ended at R and kept far less coverage (a 40 px
hardness-0 dab held 251 coverage units against Photoshop's 542). The edge
stays at least ~1 px wide (0.3 R on small tips, never under 1/4 px).
exp is (1 - u/64)^64 by squaring, so it is the same on every platform.

Dab rectangles, reaches and the retouch footprint grow to the tip's reach
(tip_reach); the brush cursor's 50 % ring (half_coverage_radius) follows the
new curve. Tests that hard-coded the old curve now check measured values;
tests that bounded a stroke by the nominal radius use the reach.

Measured against Photoshop (max levels / mean): 40 px dabs, hardness 0-70:
70-156 / 12-15 -> 2-6.5 / <0.7; 100 px dabs 70-177 -> 2-27; strokes
(probe 13) soft and medium 20-60 px 133-178 / up to 17 -> 4-26 / <=0.5;
spacing sweeps 134-221 -> 7-9; soft Flow build-up 85 / 7.5 -> 5 / 0.16;
small 1-7 px total error lower in 21 of 30 cases (medium hardness at
2-5 px a little higher: a follow-up).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 01:14:26 -04:00
1 parent bf5d01b00d
commit cf804c4b27
9 files changed
+140 -79

No files matched your search

+4 -3
View File
@@ -191,7 +191,7 @@ 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}");
assert!(worst <= 2.0 / 255.0 + 1e-6, "{depth}-bit {sampling}: live and commit differ by {worst}");
}
}
}
@@ -783,10 +783,11 @@ 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 (the soft default tip reaches a
// little past its radius).
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": [15, 12, 27, 27]}));
}
#[test]
+7 -3
View File
@@ -1422,14 +1422,18 @@ fn live_dodge_matches_the_commit_across_cells() {
for x in 0..200 {
let (got, want) = (a.rgba(x, y), b.rgba(x, y));
changed |= (want[0] - texture(x, y)[0]).abs() > 0.02;
// The commit also lays the stroke's last dab, which the live stroke shows on release.
if (x - 150).pow(2) + (y - 70).pow(2) > 25 * 25 {
// The commit also lays the stroke's last dab, which the live stroke shows on release
// (a soft tip reaches past its 20 px radius).
let reach = photocraft_paint::tip_reach(20.0, 0.3).ceil() as i32 + 2;
if (x - 150).pow(2) + (y - 70).pow(2) > reach * reach {
worst = (0..4).map(|c| (got[c] - want[c]).abs()).fold(worst, f32::max);
}
}
}
assert!(changed);
assert!(worst <= 1e-4, "live and commit differ by {worst}");
// A soft tip's footprint spans more live cells, so sums land in a different order: within
// 2e-4 (a twentieth of an 8-bit level).
assert!(worst <= 2e-4, "live and commit differ by {worst}");
}
#[test]
+32 -9
View File
@@ -80,10 +80,12 @@ fn hardness_profile_is_monotonic() {
for h in [0.0f32, 0.5, 1.0] {
let b = BrushSettings { hardness: h, ..brush() };
let r = raster(&b, &Dab::round(Point::new(50.0, 50.0), 20.0, 1.0));
let prof: Vec<f32> = (0..22).map(|i| at(&r, 50 + i, 50)).collect();
// A soft tip paints past its 20 px radius, as Photoshop's does, out to its reach.
let n = crate::tip_reach(20.0, h).ceil() as i32 + 1;
let prof: Vec<f32> = (0..n).map(|i| at(&r, 50 + i, 50)).collect();
assert!(prof.windows(2).all(|w| w[1] <= w[0] + 1e-6), "h={h} {prof:?}");
assert!(prof[0] > 0.99);
assert_eq!(prof[21], 0.0);
assert!(*prof.last().unwrap() < 0.002, "h={h} {prof:?}");
let mid = at(&r, 60, 50);
if h == 1.0 {
assert!(mid > 0.99);
@@ -103,7 +105,7 @@ fn hard_edge_is_sharp() {
let band = prof.iter().filter(|&&a| (0.01..0.99).contains(&a)).count();
assert!(band <= 3, "hardness 1 rim is {band} px wide: {prof:?}");
assert!(prof[17] > 0.99, "translucent inside the rim: {prof:?}");
assert_eq!(*prof.last().unwrap(), 0.0);
assert!(*prof.last().unwrap() < 0.01, "{prof:?}");
// A soft tip spreads the same crossing over many pixels instead.
let soft = BrushSettings { hardness: 0.0, ..brush() };
let rs = raster(&soft, &Dab::round(Point::new(50.0, 50.0), 20.0, 1.0));
@@ -1283,7 +1285,9 @@ 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:?}");
// A 20 px soft tip reaches about 1.6 radii (see `tip_reach`).
let side = 2 * (crate::tip_reach(10.0, b.hardness).ceil() as u32 + 1) + 2;
assert!(!r.is_empty() && r.width() <= side && r.height() <= side, "{r:?} vs {side}");
}
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
@@ -1367,14 +1371,32 @@ fn a_tiny_dabs_coverage_does_not_depend_on_where_it_lands_in_a_pixel() {
}
#[test]
fn the_tip_falls_off_from_the_hard_core_to_zero_at_the_edge() {
fn the_tip_matches_photoshops_measured_dabs() {
use crate::tip_falloff;
// Photoshop 27.11, single 40 px dabs (R = 20): mean alpha over rings 0.1 R wide starting at
// the given fraction of the radius (area-weighted, as measured).
let measured: [(f32, [(f32, f32); 4]); 3] = [
(0.0, [(0.2, 0.861), (0.5, 0.509), (0.9, 0.129), (1.2, 0.027)]),
(0.5, [(0.6, 0.97), (0.8, 0.561), (1.0, 0.135), (1.2, 0.012)]),
(1.0, [(0.8, 1.0), (0.9, 0.821), (1.2, 0.0), (1.3, 0.0)]),
];
for (h, rings) in measured {
for (lo, want) in rings {
let (mut sum, mut area) = (0.0, 0.0);
for k in 0..64 {
let r = (lo + 0.1 * (k as f32 + 0.5) / 64.0) * 20.0;
sum += tip_falloff(r, 20.0, h) * r;
area += r;
}
let got = sum / area;
assert!((got - want).abs() < 0.05, "hardness {h} ring at {lo} R: {got} vs {want}");
}
}
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();
let v: Vec<f32> = (0..=40).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);
assert_eq!(tip_falloff(crate::tip_reach(10.0, 0.0) * 4.0, 10.0, 0.0), 0.0, "far past the reach");
}
#[test]
@@ -1385,7 +1407,8 @@ fn half_coverage_radius_is_the_tips_50_percent_contour() {
let d = half_coverage_radius(10.5, h);
assert!((tip_falloff(d, 10.5, h) - 0.5).abs() < 1e-3, "hardness {h}: ring at {d}");
}
assert_eq!(half_coverage_radius(10.5, 1.0), 10.5);
// A hard tip's ring is its edge.
assert!((half_coverage_radius(10.5, 1.0) - 10.5).abs() < 0.5);
assert!(half_coverage_radius(10.5, f32::NAN).is_finite());
}
+60 -22
View File
@@ -138,42 +138,78 @@ 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.
/// Photoshop's round tip, measured (Photoshop 27.11, single dabs at Spacing 1000 %, hardness 0–100
/// at 40 px and checked at 100, 9 and 3 px): a solid core of radius `r0 · R`, then a Gaussian
/// shoulder `exp(-((d − r0) / s)²)` of width `s · R`, both by hardness (0, 0.1, … 1). A soft tip
/// reaches well past its nominal radius (hardness 0 is a Gaussian of σ ≈ 0.47 R: 8 % at 1.05 R).
const TIP_SHAPE: [(f32, f32); 11] =
[(0.00, 0.664), (0.14, 0.58), (0.28, 0.50), (0.40, 0.44), (0.52, 0.36), (0.63, 0.29), (0.70, 0.24), (0.78, 0.19), (0.84, 0.15), (0.89, 0.11), (1.00, 0.0)];
/// Photoshop's anti-aliased edge: the shoulder never narrows below this many pixels, and the core
/// stays this far inside the radius (a hardness-100 tip is a disc with a ~1 px soft edge at any size).
const TIP_EDGE_PX: f32 = 1.2;
/// The core radius and shoulder width (both in pixels) of a round tip of `radius` and `hardness`.
#[inline]
pub fn tip_shape(radius: f32, hardness: f32) -> (f32, f32) {
let h = if hardness.is_nan() { 0.0 } else { hardness.clamp(0.0, 1.0) } * 10.0;
let i = (h.floor() as usize).min(9);
let t = h - i as f32;
let (a, b) = (TIP_SHAPE[i], TIP_SHAPE[i + 1]);
let (r0, s) = (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
let radius = if radius.is_finite() { radius.max(0.0) } else { 0.0 };
// A tip of a few pixels keeps a narrower edge, so a small hard dab stays solid in the middle.
// Never sharper than a quarter pixel, the small-tip supersampling step.
let edge = TIP_EDGE_PX.min(0.3 * radius).max(0.25);
((r0 * radius).min(radius - edge).max(0.0), (s * radius).max(edge).max(1e-3))
}
/// `exp(-u)` as `(1 − u/64)⁶⁴` by six squarings: within 1/255 of `exp` where it matters, exact
/// arithmetic (the same on every platform) and exactly 0 from `u = 64`.
#[inline]
fn soft_exp(u: f32) -> f32 {
let mut g = (1.0 - u / 64.0).clamp(0.0, 1.0);
for _ in 0..6 {
g *= g;
}
g
}
/// Opacity of a round tip at distance `d` from its centre, edge included (see [`tip_shape`]).
#[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 {
let (r0, s) = tip_shape(radius, hardness);
if d <= r0 {
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
let x = (d - r0) / s;
soft_exp(x * x)
}
/// How far from its centre a round tip still paints (where [`tip_falloff`] drops below ~0.1 %).
#[inline]
pub fn tip_reach(radius: f32, hardness: f32) -> f32 {
let (r0, s) = tip_shape(radius, hardness);
r0 + 2.6 * s
}
/// Distance from the centre where [`tip_falloff`] drops to 50 %: the contour Preferences ›
/// Cursors › Normal Brush Tip outlines. It inverts `(1 - t²)⁴ = ½`, so `t = √(1 - ½^¼) ≈ 0.399`
/// of the way from the hard core (`hardness · radius`) to the edge.
/// Cursors › Normal Brush Tip outlines. `(1 − u/64)⁶⁴ = ½` at `u = 64 (1 − ½^(1/64))`.
#[inline]
pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 {
let h = if hardness.is_nan() { 0.0 } else { hardness.clamp(0.0, 1.0) };
let t = (1.0 - 0.5f32.sqrt().sqrt()).sqrt();
radius * (h + t * (1.0 - h))
let (r0, s) = tip_shape(radius, hardness);
let u = 64.0 * (1.0 - 0.5f32.powf(1.0 / 64.0));
r0 + s * u.sqrt()
}
/// Coverage of a round dab at distance `d` from its centre (anti-aliased edge, hardness falloff).
/// Coverage of a round dab at distance `d` from its centre.
#[inline]
pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 {
if d >= radius + 0.5 {
// NaN distances paint nothing.
if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 {
return 0.0;
}
let edge = ((radius + 0.5 - d).clamp(0.0, 1.0)).min(1.0);
edge * tip_falloff(d, radius, hardness)
tip_falloff(d, radius, hardness)
}
/// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface).
@@ -229,7 +265,9 @@ mod tests {
assert_eq!(dab_coverage(20.0, 10.0, 1.0), 0.0);
let soft_mid = dab_coverage(5.0, 10.0, 0.0);
assert!(soft_mid > 0.2 && soft_mid < 0.8);
assert!(dab_coverage(9.9, 10.0, 1.0) > 0.5);
// A hard tip is opaque to just inside its radius and gone 1 px outside it.
assert!(dab_coverage(8.5, 10.0, 1.0) > 0.99 && dab_coverage(9.5, 10.0, 1.0) > 0.5);
assert!(dab_coverage(11.0, 10.0, 1.0) < 0.05);
}
#[test]
+21 -27
View File
@@ -19,7 +19,7 @@ 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, tip_falloff};
use crate::{Dab, StrokePoint, dab_coverage, tip_falloff, tip_reach};
/// Salt of the 8-bit dither's per-pixel noise.
const DITHER_SALT: u64 = 0x0000_6469_7468_6572;
@@ -213,7 +213,16 @@ impl BrushContext {
/// Bounding rectangle of a dab (rotated sampled tips need the corner reach).
pub fn dab_rect(&self, d: &Dab, dual: bool) -> Rect {
let sampled = if dual { self.dual_tip.is_some() } else { self.tip.is_some() };
let reach = if sampled { d.radius * std::f32::consts::SQRT_2 } else { d.radius };
let hardness = if dual { self.brush.dual_brush.hardness } else { self.brush.hardness };
// A computed soft tip paints past its nominal radius (see `tip_shape`).
let aliased = self.brush.aliased && !dual;
let reach = if sampled {
d.radius * std::f32::consts::SQRT_2
} else if aliased {
d.radius
} else {
crate::tip_reach(d.radius, hardness).max(d.radius)
};
rect_around(d, reach)
}
@@ -251,7 +260,8 @@ 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 reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) };
let reach2 = (reach + 0.5) * (reach + 0.5);
// Sampled-tip mapping.
let (level, inv_scale, tw, th) = match mips {
Some(m) => {
@@ -275,7 +285,7 @@ impl BrushContext {
// 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);
let small_reach2 = (reach + 0.75) * (reach + 0.75);
// Keep ordinary computed tips separate from the feature loop, so their dense rows
// can be vectorised without the per-pixel sampled-mask and stroke-depth branches.
if !(mips.is_some() || small || (aliased && b.square) || noise || tex_tip || wet)
@@ -319,12 +329,9 @@ impl BrushContext {
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);
}
// Each sample takes the tip's own edge, so the covered area
// changes smoothly with the dab's position.
sum += tip_falloff(sd, rm, hardness);
}
}
(sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm)
@@ -1082,8 +1089,8 @@ 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 inner = rm * hardness.clamp(0.0, 1.0);
let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) };
let reach2 = (reach + 0.5) * (reach + 0.5);
for (yy, row) in out.chunks_exact_mut(w).enumerate() {
let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy;
if aliased {
@@ -1092,25 +1099,12 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased:
let d2 = (dx * ro).powi(2) + dy * dy;
*dst = if d2 < reach2 && d2.sqrt() <= rm { d.alpha } else { 0.0 };
}
} else if rm - inner < 1e-3 {
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);
*dst = if d2 >= reach2 || edge <= 0.0 { 0.0 } else { edge * d.alpha };
}
} else {
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 dd = d2.sqrt();
let edge = ((rm + 0.5 - dd).clamp(0.0, 1.0)).min(1.0);
let t = ((dd - inner) / (rm - inner)).clamp(0.0, 1.0);
let s = 1.0 - t * t;
let s2 = s * s;
let falloff = if dd <= inner { 1.0 } else { s2 * s2 };
let val = edge * falloff;
*dst = if d2 >= reach2 || val <= 0.0 { 0.0 } else { val * d.alpha };
let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) };
*dst = if val <= 0.0 { 0.0 } else { val * d.alpha };
}
}
}
+4 -8
View File
@@ -30,7 +30,8 @@ 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 reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) };
let reach2 = (reach + 0.5) * (reach + 0.5);
// Sampled-tip mapping.
let (level, inv_scale, tw, th) = match mips {
Some(m) => {
@@ -54,7 +55,7 @@ impl BrushContext {
// 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);
let small_reach2 = (reach + 0.75) * (reach + 0.75);
for yy in 0..h {
let y = rect.y0 + yy as i32;
let dy = y as f32 + 0.5 - cy;
@@ -80,12 +81,7 @@ impl BrushContext {
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 += tip_falloff(sd, rm, hardness);
}
}
(sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm)
+5 -3
View File
@@ -102,7 +102,8 @@ pub fn dab_rect(d: &Dab) -> Rect {
/// Rasterize one dab's coverage.
pub fn footprint(d: &Dab, index: usize, hardness: f32) -> Footprint {
let rect = dab_rect(d);
// A soft tip paints past its nominal radius.
let rect = crate::render::rect_around(d, crate::tip_reach(d.radius, hardness).max(d.radius));
let (cx, cy) = (d.center.x as f32, d.center.y as f32);
let mut cov = Vec::with_capacity(rect.width() as usize * rect.height() as usize);
for y in rect.y0..rect.y1 {
@@ -554,7 +555,7 @@ mod tests {
}
#[test]
fn footprint_stays_inside_the_full_size_ring() {
fn footprint_stays_inside_the_tips_reach() {
let radius = 10.5f32;
for hardness in [1.0, 0.5, 0.0] {
let dab = Dab::round(Point::new(32.0, 32.0), radius, 1.0);
@@ -569,8 +570,9 @@ mod tests {
}
painted += 1;
farthest = farthest.max(dist);
let reach = f64::from(crate::tip_reach(radius, hardness));
assert!(
dist < radius as f64 + 0.5,
dist < reach.max(radius as f64) + 0.5,
"hardness {hardness}: pixel ({x}, {y}) at {dist} px from \
the centre is painted past the Full Size ring of \
{radius} px"
+5 -3
View File
@@ -6677,12 +6677,12 @@ mod tests {
let (shown, key) = display_doc(&mut app, 0);
assert_ne!(key, 0);
// 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!(alpha(&shown, 40, 30) > 0.5 && (0.01..0.95).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;
let d = damage_since(&app, 0, (rev, dk), (rev, key), dk, None).unwrap();
assert!(d.contains(40, 30) && !d.contains(40, 2) && d.width() < 120, "{d:?}");
assert!(d.contains(40, 30) && !d.contains(40, 2) && d.height() < 60, "{d:?}");
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 30.0 }, m);
let doc = app.session.documents()[0].doc.clone();
assert!(app.live_stroke.is_none() && display_doc(&mut app, 0).1 == 0);
@@ -7143,7 +7143,9 @@ mod tests {
}
}
assert!(soft > 50, "a soft brush shows a soft edge");
assert_eq!(trail.coverage(50, 30 + 10), 0, "half pressure halves the 30 px tip");
// Half pressure halves the 30 px tip: nothing past its (soft) reach.
let reach = photocraft_paint::tip_reach(7.5, app.session.tools.brush.hardness).ceil() as usize + 2;
assert_eq!(trail.coverage(50, 30 + reach), 0, "half pressure halves the 30 px tip");
}
#[test]
@@ -137,7 +137,8 @@ fn live_stroke_at(depth: u32) {
tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 20.0 }, m);
assert!(app.live_stroke.is_none());
// The preview at the last move is the committed stroke.
let level = if depth == 8 { 1.0 / 255.0 + 1e-6 } else { 0.0 };
// 8 bits may round a level or two apart between the live working copy and the commit.
let level = if depth == 8 { 2.0 / 255.0 + 1e-6 } else { 0.0 };
for y in 0..40 {
for x in 0..80 {
let (got, want) = (shown.rgba(x, y), rgba(&app, x, y));