diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 80b53fd90..1d44fe6e7 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -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 = 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] diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 625e70298..5febba9c5 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -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] diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 371363b02..284b7ae3b 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -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 = (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 = (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 = (0..=20).map(|i| tip_falloff(i as f32 * 0.5, 10.0, 0.0)).collect(); + let v: Vec = (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()); } diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 5d181b28e..4dc07b660 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -138,42 +138,78 @@ 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. +/// 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] diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 34c296fb0..89c0abebd 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -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 }; } } } diff --git a/crates/paint/src/render_reference.rs b/crates/paint/src/render_reference.rs index 3811e7c99..10172e6fe 100644 --- a/crates/paint/src/render_reference.rs +++ b/crates/paint/src/render_reference.rs @@ -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) diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 0129c3163..9ef837c0c 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -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" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b05291688..9cf6da73e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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] diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs index bfa5037df..64f04f3c7 100644 --- a/crates/ui-egui/src/color_replacement_tests.rs +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -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));