Oregami
Repositories/oxedyne/fe2o3

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
57use crate::{
58 colour::Rgba,
59 path::{
60 Bounds,
61 Path,
62 },
63 pixmap::{
64 Pixmap,
65 MAX_PIXELS,
66 },
67 transform::Transform,
68};
69
70use oxedyne_fe2o3_core::prelude::*;
71
72pub 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)]
80pub 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
86impl 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)`.
108pub 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.
119pub 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.
134fn 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.
173fn 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.
217fn 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
230impl 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)]
370mod 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}