oxedyne/fe2o3/fe2o3_graphics/src/blur.rs
22.4 KiB, 18 runs
created by r1870400018:14368, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Blurring, and the drop shadow built on it. |
| 2 | //! |
| 3 | //! # Three boxes make a Gaussian |
| 4 | //! |
| 5 | //! A true Gaussian blur is a convolution with a kernel that never quite reaches zero, so it has to |
| 6 | //! be truncated before it can be used at all, and even separated into a horizontal and a vertical |
| 7 | //! pass it costs a multiply and an add per sample per tap. A box blur -- the plain mean of a window |
| 8 | //! -- costs nothing like as much, but on its own it looks like what it is: its kernel has corners, |
| 9 | //! and a shadow blurred with one comes out with faint square banding that the eye finds at once. |
| 10 | //! |
| 11 | //! Convolve a box with itself and those corners round off; convolve it a third time and what is |
| 12 | //! left is a piecewise cubic sitting within a few percent of the Gaussian it is approaching. This is |
| 13 | //! the central limit theorem doing the work, and three is where the returns stop: no one can tell |
| 14 | //! three passes from thirty, least of all in a shadow, whose whole job is to go unremarked. The |
| 15 | //! variance of a box of radius `r` is `((2r + 1)^2 - 1) / 12`, so [`BOX_PASSES`] of them stand in |
| 16 | //! for a Gaussian of sigma `sqrt(r * (r + 1))`. See [`sigma_for_radius`]. |
| 17 | //! |
| 18 | //! # The window slides |
| 19 | //! |
| 20 | //! The mean of a window is not summed afresh at every pixel. The window moves one sample on, so one |
| 21 | //! sample enters on the right and one leaves on the left, and the running sum is corrected by two |
| 22 | //! arithmetic operations however wide the window is. A blur therefore costs the same whether its |
| 23 | //! radius is one pixel or fifty, which is what makes a large soft shadow affordable at all. |
| 24 | //! |
| 25 | //! The sum is kept in `f64` where the samples are `f32`. Sliding a window the length of a row is |
| 26 | //! thousands of additions and subtractions of the one accumulator, and each rounding error stays in |
| 27 | //! it: in `f32` they compound into a drift a screen can show, as a gradient across a field that |
| 28 | //! should be flat. In `f64` the same drift sits some ten orders of magnitude under one step of an |
| 29 | //! eight-bit channel, and the cast back to `f32` erases it. |
| 30 | //! |
| 31 | //! # The blur runs on premultiplied alpha |
| 32 | //! |
| 33 | //! [`Rgba`] carries straight alpha, so a clear pixel still carries a colour, and that colour is |
| 34 | //! usually black, because black is what an untouched buffer holds. Average the channels as they |
| 35 | //! stand and the black is averaged in at its full weight, though the pixel it came from is not |
| 36 | //! there to be seen. A red shape blurred against a clear background then comes out fringed in dark, |
| 37 | //! dirty red: the colour ramps down towards black alongside the alpha, instead of staying red and |
| 38 | //! merely fading. It is the classic bug of this whole area, and it is invisible in a test that only |
| 39 | //! looks at alpha. |
| 40 | //! |
| 41 | //! Premultiplied, a clear pixel contributes nothing to any channel, because every channel has |
| 42 | //! already been scaled by the alpha that says it is not there. So the blur premultiplies on the way |
| 43 | //! in and un-premultiplies on the way out -- the same trick, for the same reason, as [`Rgba::over`]. |
| 44 | //! |
| 45 | //! # The edges |
| 46 | //! |
| 47 | //! A window centred near an edge reaches for samples that are not there. Counting them as clear |
| 48 | //! would fade the picture into a border it never had, darkening every edge of an image that ran to |
| 49 | //! its own boundary; counting them as nothing at all and dividing by fewer samples costs a branch in |
| 50 | //! the inner loop and still guesses. The sample at the edge is repeated instead, which is the guess |
| 51 | //! that a field flat at its edge stays flat past it, and so leaves a flat field exactly as it found |
| 52 | //! it. |
| 53 | //! |
| 54 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 55 | //! Anthropic Claude |
| 56 | |
| 57 | use crate::{ |
| 58 | colour::Rgba, |
| 59 | path::{ |
| 60 | Bounds, |
| 61 | Path, |
| 62 | }, |
| 63 | pixmap::{ |
| 64 | Pixmap, |
| 65 | MAX_PIXELS, |
| 66 | }, |
| 67 | transform::Transform, |
| 68 | }; |
| 69 | |
| 70 | use oxedyne_fe2o3_core::prelude::*; |
| 71 | |
| 72 | pub const BOX_PASSES: usize = 3; // along each axis; the module docs say why three |
| 73 | |
| 74 | /// A drop shadow: where a shape's silhouette falls, and how far it is softened. |
| 75 | /// |
| 76 | /// The counterpart of [`crate::stroke::Stroke`], and passed the same way: the pen says what ink a |
| 77 | /// path leaves, and this says what shade it throws. The colour is not held here, for the same |
| 78 | /// reason a pen does not hold one -- it is the painting that has a colour, not the tool. |
| 79 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 80 | pub struct Shadow { |
| 81 | pub dx: f32, // pixels right of the shape; negative throws it left |
| 82 | pub dy: f32, // pixels down from the shape; negative throws it up |
| 83 | pub radius: usize, // blur radius, pixels; zero throws a hard-edged silhouette |
| 84 | } |
| 85 | |
| 86 | impl Shadow { |
| 87 | |
| 88 | pub fn new(dx: f32, dy: f32, radius: usize) -> Self { |
| 89 | Self { dx, dy, radius } |
| 90 | } |
| 91 | |
| 92 | /// How far the blur carries paint past where the silhouette stands, in pixels. |
| 93 | /// |
| 94 | /// Each box pass spreads a sample by the radius and there are [`BOX_PASSES`] of them, so the |
| 95 | /// blurred silhouette reaches this much further on every side than the sharp one, and no |
| 96 | /// further: the kernel has finite support, unlike the Gaussian it stands in for. This is what |
| 97 | /// the scratch buffer must be grown by, and what a caller totalling the damage of a frame needs |
| 98 | /// in order to know which pixels a shadow dirtied. |
| 99 | pub fn reach(&self) -> usize { |
| 100 | self.radius.saturating_mul(BOX_PASSES) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// The standard deviation, in pixels, of the Gaussian that a blur of this radius stands in for. |
| 105 | /// |
| 106 | /// A box of radius `r` averages `2r + 1` samples, whose variance is `((2r + 1)^2 - 1) / 12`; |
| 107 | /// [`BOX_PASSES`] of them convolved give three times that, which is `r * (r + 1)`. |
| 108 | pub fn sigma_for_radius(radius: usize) -> f32 { |
| 109 | let r = radius as f64; |
| 110 | (r * (r + 1.0)).sqrt() as f32 |
| 111 | } |
| 112 | |
| 113 | /// The blur radius, in pixels, that best stands in for a Gaussian of this standard deviation. |
| 114 | /// |
| 115 | /// The inverse of [`sigma_for_radius`], rounded to the nearest whole pixel, since a sliding window |
| 116 | /// has no fractional width. A caller who thinks in sigmas, as anyone coming from a design tool or a |
| 117 | /// stylesheet does, converts here once and passes the radius thereafter. A sigma that is not a |
| 118 | /// positive number gives a radius of zero, which blurs nothing. |
| 119 | pub fn radius_for_sigma(sigma: f32) -> usize { |
| 120 | if !sigma.is_finite() || sigma <= 0.0 { |
| 121 | return 0; |
| 122 | } |
| 123 | let s = sigma as f64; |
| 124 | // The positive root of `r^2 + r - s^2 = 0`. |
| 125 | let r = (-1.0 + (1.0 + 4.0 * s * s).sqrt()) / 2.0; |
| 126 | (r + 0.5) as usize |
| 127 | } |
| 128 | |
| 129 | /// One box pass along a line: every sample becomes the mean of the `2r + 1` samples centred on it. |
| 130 | /// |
| 131 | /// Samples off either end are the end sample repeated. See the module docs for why the window |
| 132 | /// slides, why the accumulator is wider than the samples, and why the edge is clamped rather than |
| 133 | /// counted as clear. |
| 134 | fn box_pass(src: &[f32], dst: &mut [f32], r: usize) { |
| 135 | let n = src.len(); |
| 136 | if n == 0 || dst.len() < n { |
| 137 | return; // A private invariant, defended: the two lines are the same length. |
| 138 | } |
| 139 | if r == 0 { |
| 140 | dst[..n].copy_from_slice(src); |
| 141 | return; |
| 142 | } |
| 143 | let first = src[0] as f64; |
| 144 | let last = src[n - 1] as f64; |
| 145 | // The window opens centred on the first sample, so it hangs off the left by `r` samples, every |
| 146 | // one of them the first repeated, and off the right too where the line is shorter than the |
| 147 | // radius. |
| 148 | let hi = r.min(n - 1); |
| 149 | let mut sum = first * (r as f64); |
| 150 | for s in &src[..=hi] { |
| 151 | sum += *s as f64; |
| 152 | } |
| 153 | sum += last * ((r - hi) as f64); |
| 154 | // Saturating, so that a radius no one could mean cannot overflow its way into a panic. |
| 155 | let inv = 1.0 / (r.saturating_mul(2).saturating_add(1) as f64); |
| 156 | for x in 0..n { |
| 157 | dst[x] = (sum * inv) as f32; |
| 158 | // Slide: one sample enters on the right, one leaves on the left, both clamped to the ends. |
| 159 | let add = src[x.saturating_add(r).saturating_add(1).min(n - 1)] as f64; |
| 160 | let sub = src[x.saturating_sub(r)] as f64; |
| 161 | sum += add - sub; |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /// Blurs one line of a plane, gathering it into a contiguous buffer, running every box pass over |
| 166 | /// it, and scattering it back. |
| 167 | /// |
| 168 | /// The line is `n` samples beginning at `start`, each `stride` apart, which lets the one routine |
| 169 | /// serve a row and a column alike. Gathering is not waste: a box pass cannot be done in place, |
| 170 | /// since it needs the samples it is about to overwrite, so a second buffer is wanted anyway, and |
| 171 | /// bringing a column into one makes the passes walk contiguous memory instead of leaping a row at |
| 172 | /// every sample. |
| 173 | fn blur_line( |
| 174 | p: &mut [f32], |
| 175 | start: usize, |
| 176 | stride: usize, |
| 177 | n: usize, |
| 178 | r: usize, |
| 179 | buf: &mut Vec<f32>, |
| 180 | tmp: &mut Vec<f32>, |
| 181 | ) { |
| 182 | if n == 0 { |
| 183 | return; |
| 184 | } |
| 185 | buf.clear(); |
| 186 | buf.extend((0..n).map(|i| p[start + i * stride])); |
| 187 | tmp.clear(); |
| 188 | tmp.resize(n, 0.0); |
| 189 | // Ping-pong between the two buffers. `in_buf` says which of them holds the latest samples. |
| 190 | let mut in_buf = true; |
| 191 | for _ in 0..BOX_PASSES { |
| 192 | if in_buf { |
| 193 | box_pass(buf, tmp, r); |
| 194 | } else { |
| 195 | box_pass(tmp, buf, r); |
| 196 | } |
| 197 | in_buf = !in_buf; |
| 198 | } |
| 199 | let out: &[f32] = if in_buf { buf } else { tmp }; |
| 200 | for i in 0..n { |
| 201 | p[start + i * stride] = out[i]; |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /// Blurs one plane of samples in place: every box pass along every row, then every box pass down |
| 206 | /// every column. |
| 207 | /// |
| 208 | /// A blur is separable, which is the only reason it is affordable: the two-dimensional kernel is |
| 209 | /// the product of two one-dimensional ones, so a pass along each axis does what a full |
| 210 | /// two-dimensional convolution would, at a cost that grows with the radius rather than its square. |
| 211 | /// |
| 212 | /// The axes are grouped -- three horizontal passes, then three vertical -- rather than interleaved |
| 213 | /// as `HVHVHV`. This is the same blur, not an approximation of it: a convolution along the rows and |
| 214 | /// a convolution down the columns act on independent axes and so commute, and convolution is |
| 215 | /// associative, so the two orders are the same operator. Grouping lets each line be gathered and |
| 216 | /// scattered once rather than once per pass, which is most of the cost of the vertical pass. |
| 217 | fn blur_plane(p: &mut [f32], w: usize, h: usize, r: usize) { |
| 218 | let mut buf = Vec::with_capacity(w.max(h)); |
| 219 | let mut tmp = Vec::with_capacity(w.max(h)); |
| 220 | // Rows: one sample to the next is one sample along. |
| 221 | for y in 0..h { |
| 222 | blur_line(p, y * w, 1, w, r, &mut buf, &mut tmp); |
| 223 | } |
| 224 | // Columns: one sample to the next is a whole row along. |
| 225 | for x in 0..w { |
| 226 | blur_line(p, x, w, h, r, &mut buf, &mut tmp); |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | impl Pixmap { |
| 231 | |
| 232 | /// Blurs the pixmap in place, with a blur of the given radius in pixels. |
| 233 | /// |
| 234 | /// Three box passes along each axis stand in for a Gaussian of sigma `sqrt(r * (r + 1))`: see |
| 235 | /// [`sigma_for_radius`] and the module docs. The cost is the same whatever the radius, since the |
| 236 | /// window slides rather than being summed afresh at every pixel. |
| 237 | /// |
| 238 | /// A radius of zero returns without touching a byte. That is not merely an optimisation. The |
| 239 | /// blur works premultiplied, and premultiplying a clear pixel throws away the colour it was |
| 240 | /// carrying, which is exactly right for a blur -- the colour of what is not there must not bleed |
| 241 | /// into what is -- and exactly wrong for a no-op. |
| 242 | pub fn blur(&mut self, radius: usize) { |
| 243 | if radius == 0 { |
| 244 | return; |
| 245 | } |
| 246 | let (w, h) = (self.width(), self.height()); |
| 247 | let n = w * h; // Already known not to overflow: see [`Pixmap::new`]. |
| 248 | // Four planes of premultiplied channels, planar rather than interleaved because the sliding |
| 249 | // window walks one channel at a time and a stride of one is what it wants. |
| 250 | let mut pl = [ |
| 251 | vec![0.0f32; n], |
| 252 | vec![0.0f32; n], |
| 253 | vec![0.0f32; n], |
| 254 | vec![0.0f32; n], |
| 255 | ]; |
| 256 | for (i, px) in self.data().chunks_exact(4).enumerate() { |
| 257 | let a = (px[3] as f32) / 255.0; |
| 258 | pl[0][i] = (px[0] as f32) / 255.0 * a; |
| 259 | pl[1][i] = (px[1] as f32) / 255.0 * a; |
| 260 | pl[2][i] = (px[2] as f32) / 255.0 * a; |
| 261 | pl[3][i] = a; |
| 262 | } |
| 263 | for p in &mut pl { |
| 264 | blur_plane(p, w, h, radius); |
| 265 | } |
| 266 | for (i, px) in self.data_mut().chunks_exact_mut(4).enumerate() { |
| 267 | let a = pl[3][i].clamp(0.0, 1.0); |
| 268 | if a <= 0.0 { |
| 269 | // Nothing is there, and the colour of nothing is not a colour anyone can name. |
| 270 | px[0] = 0; |
| 271 | px[1] = 0; |
| 272 | px[2] = 0; |
| 273 | px[3] = 0; |
| 274 | continue; |
| 275 | } |
| 276 | for c in 0..3 { |
| 277 | px[c] = ((pl[c][i] / a).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; |
| 278 | } |
| 279 | px[3] = (a * 255.0 + 0.5).clamp(0.0, 255.0) as u8; |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | /// Throws a path's soft drop shadow onto the pixmap. |
| 284 | /// |
| 285 | /// Only the shadow is painted, so a caller draws this first and the shape itself over the top; |
| 286 | /// that way round the shape hides the silhouette standing under it, which is what a shadow |
| 287 | /// looks like, and the caller stays free to paint the shape in a way this could not guess. |
| 288 | /// |
| 289 | /// # How |
| 290 | /// |
| 291 | /// The silhouette is filled into a scratch pixmap, blurred there, and composited back. The |
| 292 | /// scratch is grown by [`Shadow::reach`] on every side, because a blur carries paint that much |
| 293 | /// past the shape it came from: a scratch merely the size of the shape's own bounding box would |
| 294 | /// hand back a shadow cut off square at the edges, which is precisely the shape a shadow must |
| 295 | /// not have. It is trimmed to what the pixmap and the clip could show, grown by the same reach, |
| 296 | /// since silhouette rasterised beyond that can reach no pixel anyone will see. |
| 297 | pub fn shadow_path( |
| 298 | &mut self, |
| 299 | path: &Path, |
| 300 | t: &Transform, |
| 301 | colour: Rgba, |
| 302 | clip: Option<Bounds>, |
| 303 | shadow: &Shadow, |
| 304 | ) |
| 305 | -> Outcome<()> |
| 306 | { |
| 307 | if !shadow.dx.is_finite() || !shadow.dy.is_finite() { |
| 308 | return Err(err!( |
| 309 | "A shadow's offset must be finite, but ({}, {}) was given.", shadow.dx, shadow.dy; |
| 310 | Invalid, Input)); |
| 311 | } |
| 312 | if colour.is_transparent() || path.is_empty() { |
| 313 | return Ok(()); |
| 314 | } |
| 315 | let bb = match path.bounds(t) { |
| 316 | Some(bb) => bb, |
| 317 | None => return Ok(()), |
| 318 | }; |
| 319 | // Where the sharp silhouette stands: the shape, moved by the offset. |
| 320 | let sil = Bounds::new( |
| 321 | bb.x0 + shadow.dx, |
| 322 | bb.y0 + shadow.dy, |
| 323 | bb.x1 + shadow.dx, |
| 324 | bb.y1 + shadow.dy, |
| 325 | ); |
| 326 | let reach = shadow.reach() as f32; |
| 327 | // Everywhere the blur can carry the silhouette to. |
| 328 | let spread = sil.grow(reach); |
| 329 | // Everywhere the caller will let it land. |
| 330 | let mut want = self.bounds(); |
| 331 | if let Some(c) = clip { |
| 332 | want = want.intersect(c); |
| 333 | } |
| 334 | if want.is_empty() || spread.intersect(want).is_empty() { |
| 335 | return Ok(()); |
| 336 | } |
| 337 | // The scratch holds every pixel the shadow lands on, and the reach further out that feeds |
| 338 | // them, and nothing besides. |
| 339 | let win = spread.intersect(want.grow(reach)); |
| 340 | let ix0 = win.x0.floor() as i32; |
| 341 | let iy0 = win.y0.floor() as i32; |
| 342 | // Widened, because a reach no one could mean would overflow the difference of two `i32`. |
| 343 | let sw = (win.x1.ceil() as i32 as i64) - (ix0 as i64); |
| 344 | let sh = (win.y1.ceil() as i32 as i64) - (iy0 as i64); |
| 345 | if sw <= 0 || sh <= 0 { |
| 346 | return Ok(()); |
| 347 | } |
| 348 | if sw > MAX_PIXELS as i64 || sh > MAX_PIXELS as i64 { |
| 349 | return Err(err!( |
| 350 | "A shadow of radius {} reaches {} pixels, and needs a scratch of {} by {} pixels.", |
| 351 | shadow.radius, shadow.reach(), sw, sh; |
| 352 | Invalid, Input, Excessive)); |
| 353 | } |
| 354 | let mut scratch = res!(Pixmap::new(sw as usize, sh as usize)); |
| 355 | // The shape, moved by the offset, in the scratch's own coordinates. The filler does the |
| 356 | // rasterising, the anti-aliasing and the fill rule, exactly as it does for a stroke: a |
| 357 | // shadow adds no rasteriser code either. |
| 358 | let st = t.then(&Transform::translate( |
| 359 | shadow.dx - (ix0 as f32), |
| 360 | shadow.dy - (iy0 as f32), |
| 361 | )); |
| 362 | res!(scratch.fill_path(path, &st, colour, None)); |
| 363 | scratch.blur(shadow.radius); |
| 364 | self.blit(&scratch, ix0, iy0, clip); |
| 365 | Ok(()) |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | #[cfg(test)] |
| 370 | mod tests { |
| 371 | use super::*; |
| 372 | |
| 373 | /// The colour at a pixel a test asserts is in range. |
| 374 | fn px(pm: &Pixmap, x: usize, y: usize) -> Outcome<Rgba> { |
| 375 | match pm.pixel(x, y) { |
| 376 | Some(c) => Ok(c), |
| 377 | None => Err(err!( |
| 378 | "The pixel ({}, {}) lies outside a pixmap of {} by {}.", |
| 379 | x, y, pm.width(), pm.height(); |
| 380 | Invalid, Input, Range)), |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | /// A pixmap in which no two pixels agree, so that a blur which moved anything shows. Some of its |
| 385 | /// pixels are clear but coloured, which a premultiplied round trip cannot preserve. |
| 386 | fn ramp(w: usize, h: usize) -> Outcome<Pixmap> { |
| 387 | let mut pm = res!(Pixmap::new(w, h)); |
| 388 | for y in 0..h { |
| 389 | for x in 0..w { |
| 390 | pm.set_pixel(x, y, Rgba::new( |
| 391 | (x * 7 + 3) as u8, |
| 392 | (y * 11 + 5) as u8, |
| 393 | ((x + y) * 13 + 7) as u8, |
| 394 | ((x * 3 + y * 5) % 256) as u8, |
| 395 | )); |
| 396 | } |
| 397 | } |
| 398 | Ok(pm) |
| 399 | } |
| 400 | |
| 401 | #[test] |
| 402 | fn test_a_radius_of_zero_is_an_identity_00() -> Outcome<()> { |
| 403 | // Not one byte, which is more than "looks the same": the ramp holds clear pixels that still |
| 404 | // carry a colour, and a blur of no radius that went round through the planes anyway would |
| 405 | // premultiply that colour away to black. |
| 406 | let pm = res!(ramp(16, 16)); |
| 407 | let mut out = pm.clone(); |
| 408 | out.blur(0); |
| 409 | assert_eq!(out, pm, "a blur of no radius must not touch a byte"); |
| 410 | Ok(()) |
| 411 | } |
| 412 | |
| 413 | #[test] |
| 414 | fn test_a_blurred_edge_is_monotone_and_keeps_its_alpha_01() -> Outcome<()> { |
| 415 | // A hard step: the left half opaque, the right half not there at all. |
| 416 | let mut pm = res!(Pixmap::new(64, 8)); |
| 417 | for y in 0..8 { |
| 418 | for x in 0..32 { |
| 419 | pm.set_pixel(x, y, Rgba::WHITE); |
| 420 | } |
| 421 | } |
| 422 | let total = |pm: &Pixmap| -> u32 { |
| 423 | pm.data().chunks_exact(4).map(|p| p[3] as u32).sum() |
| 424 | }; |
| 425 | let before = total(&pm); |
| 426 | pm.blur(4); |
| 427 | let after = total(&pm); |
| 428 | // The blur moves alpha about; it does not make or destroy any. The step stands far enough |
| 429 | // from either end that the clamped edges give back exactly what they take, and the kernel is |
| 430 | // symmetric, so what leaves one side of the step arrives at the other. |
| 431 | let drift = ((after as f32) - (before as f32)).abs() / (before as f32); |
| 432 | assert!(drift < 0.01, "alpha ran from {} to {}", before, after); |
| 433 | // Across the step, never more alpha to the right than to the left. |
| 434 | for y in 0..8 { |
| 435 | let mut prev = 255u8; |
| 436 | for x in 0..64 { |
| 437 | let c = res!(px(&pm, x, y)).a; |
| 438 | assert!(c <= prev, "row {} rises at column {}: {} then {}", y, x, prev, c); |
| 439 | prev = c; |
| 440 | } |
| 441 | } |
| 442 | // And it is a ramp, not a step that stayed hard. |
| 443 | let soft = (0..64) |
| 444 | .filter(|x| matches!(pm.pixel(*x, 4), Some(c) if c.a > 8 && c.a < 247)) |
| 445 | .count(); |
| 446 | assert!(soft >= 8, "expected a soft edge, found {} partial columns", soft); |
| 447 | Ok(()) |
| 448 | } |
| 449 | |
| 450 | #[test] |
| 451 | fn test_a_clear_neighbour_does_not_darken_the_colour_02() -> Outcome<()> { |
| 452 | // The classic bug, pinned. The left half is opaque red. The right half is not merely clear |
| 453 | // but clear *and black*, which is what `Rgba::TRANSPARENT` is and what an untouched pixmap |
| 454 | // holds. Blur the straight channels and that black is averaged into the red at its full |
| 455 | // weight, so the edge comes out dark red at half alpha instead of the same red, half there. |
| 456 | // Blurred premultiplied, the colour cannot move at all: only the alpha ramps. |
| 457 | let red = Rgba::new(255, 0, 0, 255); |
| 458 | let mut pm = res!(Pixmap::new(48, 4)); |
| 459 | for y in 0..4 { |
| 460 | for x in 0..24 { |
| 461 | pm.set_pixel(x, y, red); |
| 462 | } |
| 463 | } |
| 464 | pm.blur(3); |
| 465 | let mut ramped = 0; |
| 466 | for y in 0..4 { |
| 467 | for x in 0..48 { |
| 468 | let c = res!(px(&pm, x, y)); |
| 469 | if c.a == 0 { |
| 470 | continue; // Nothing is there, so it has no colour to have kept. |
| 471 | } |
| 472 | assert!(c.r >= 250, "({}, {}) lost its red: {:?}", x, y, c); |
| 473 | assert_eq!((c.g, c.b), (0, 0), "({}, {}) picked up a cast: {:?}", x, y, c); |
| 474 | if c.a > 8 && c.a < 247 { |
| 475 | ramped += 1; |
| 476 | } |
| 477 | } |
| 478 | } |
| 479 | // Without a soft edge there would have been nowhere for a fringe to appear, and nothing above |
| 480 | // would have been tested. |
| 481 | assert!(ramped >= 16, "expected a soft edge to test, found {} partial pixels", ramped); |
| 482 | Ok(()) |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn test_a_shadow_is_soft_and_falls_where_it_is_thrown_03() -> Outcome<()> { |
| 487 | let mut pm = res!(Pixmap::filled(64, 64, Rgba::WHITE)); |
| 488 | let card = res!(Path::round_rect(Bounds::new(16.0, 16.0, 48.0, 48.0), 8.0)); |
| 489 | let sh = Shadow::new(4.0, 4.0, 3); |
| 490 | res!(pm.shadow_path(&card, &Transform::IDENTITY, Rgba::BLACK, None, &sh)); |
| 491 | // Thrown down and to the right, so the card no longer stands in the middle of its own shade. |
| 492 | // Six pixels past its right edge is shadow; six past its left edge, the same distance out, |
| 493 | // is nothing. Both lie outside the silhouette's own bounding box, so a scratch that had not |
| 494 | // been grown by the reach would have cut the shadow off square and left the right one white. |
| 495 | let right = res!(px(&pm, 54, 32)); |
| 496 | let left = res!(px(&pm, 10, 32)); |
| 497 | assert_eq!(left, Rgba::WHITE, "no shade should reach back up and to the left"); |
| 498 | assert!(right.r < 240, "the shadow should fall past the right edge, found {:?}", right); |
| 499 | assert!(right.r > 0, "and fade rather than go black, found {:?}", right); |
| 500 | // The corner is a gradient rather than two flat regions with a step between them. |
| 501 | let soft = (44..58) |
| 502 | .filter_map(|k| pm.pixel(k, k)) |
| 503 | .filter(|c| c.r > 8 && c.r < 247) |
| 504 | .count(); |
| 505 | assert!(soft >= 5, "expected a soft corner, found {} partial pixels", soft); |
| 506 | // The shade deepens inwards along that corner, all the way and never back. |
| 507 | let mut prev = 0u8; |
| 508 | for k in 44..58 { |
| 509 | let c = res!(px(&pm, k, k)).r; |
| 510 | assert!(c >= prev, "the corner darkens outwards at {}: {} then {}", k, prev, c); |
| 511 | prev = c; |
| 512 | } |
| 513 | Ok(()) |
| 514 | } |
| 515 | |
| 516 | #[test] |
| 517 | fn test_a_uniform_field_survives_the_blur_04() -> Outcome<()> { |
| 518 | // Every sample a window can reach is the same one, including the samples it reaches off the |
| 519 | // edge for, because those are the edge repeated. So every mean is that same sample and the |
| 520 | // field must come back exactly as it went in, with no border that has faded into a darkness |
| 521 | // that was never there. The radius is wider than the pixmap, so almost every window is mostly |
| 522 | // made of clamped samples and the clamp is what is being tested. |
| 523 | let c = Rgba::new(10, 200, 30, 128); |
| 524 | let mut pm = res!(Pixmap::filled(12, 9, c)); |
| 525 | pm.blur(5); |
| 526 | for y in 0..9 { |
| 527 | for x in 0..12 { |
| 528 | assert_eq!(res!(px(&pm, x, y)), c, "the pixel ({}, {}) moved", x, y); |
| 529 | } |
| 530 | } |
| 531 | Ok(()) |
| 532 | } |
| 533 | |
| 534 | #[test] |
| 535 | fn test_the_radius_and_the_sigma_agree_05() -> Outcome<()> { |
| 536 | for r in 0..64 { |
| 537 | let s = sigma_for_radius(r); |
| 538 | let back = radius_for_sigma(s); |
| 539 | assert_eq!(back, r, "the radius {} came back as {} through the sigma {}", r, back, s); |
| 540 | } |
| 541 | // A sigma that names no blur blurs nothing, rather than reaching for the root of a negative. |
| 542 | assert_eq!(radius_for_sigma(0.0), 0); |
| 543 | assert_eq!(radius_for_sigma(-1.0), 0); |
| 544 | assert_eq!(radius_for_sigma(f32::NAN), 0); |
| 545 | assert_eq!(sigma_for_radius(0), 0.0); |
| 546 | Ok(()) |
| 547 | } |
| 548 | |
| 549 | #[test] |
| 550 | fn test_a_shadow_refuses_what_it_cannot_throw_06() -> Outcome<()> { |
| 551 | let mut pm = res!(Pixmap::filled(16, 16, Rgba::WHITE)); |
| 552 | let card = res!(Path::rect(Bounds::new(4.0, 4.0, 12.0, 12.0))); |
| 553 | let bad = Shadow::new(f32::NAN, 0.0, 2); |
| 554 | assert!(pm.shadow_path(&card, &Transform::IDENTITY, Rgba::BLACK, None, &bad).is_err()); |
| 555 | // A clear shadow and an empty path both paint nothing, and neither is an error. |
| 556 | let sh = Shadow::new(1.0, 1.0, 2); |
| 557 | let before = pm.clone(); |
| 558 | res!(pm.shadow_path(&card, &Transform::IDENTITY, Rgba::TRANSPARENT, None, &sh)); |
| 559 | res!(pm.shadow_path(&Path::default(), &Transform::IDENTITY, Rgba::BLACK, None, &sh)); |
| 560 | assert_eq!(pm, before); |
| 561 | Ok(()) |
| 562 | } |
| 563 | } |