vello_common: Improve filter bounds for drop shadows (#1768)

Currently, the filter bounds of a drop shadow are called additively by
combinining the expansion of the blur radius and potentially the shift.
If the blur radius is 20, we always expand by 20 in each direction,
regardless of the position of the drop shadow.

However, there is an easy optimization to apply here: In case the offset
is for example (20, 20) and the blur radius is only 10, there is
actually no need to expand the top/left side at all, because the
expansion of the blur radius is completely absorbed by the shift. The
following videos demonstrate the behavior before vs. after. As you can
see, in the before case we always unconditionally expand to the top-left
by the blur radius, regardless of where the drops shadow is positioned.

Before:


https://github.com/user-attachments/assets/de7787ba-cf69-4ec0-84f0-0d552ef62fbd

After:


https://github.com/user-attachments/assets/46989f54-36ba-4472-bf55-67b8a4d79d8d
diff --git a/sparse_strips/vello_common/src/filter_effects.rs b/sparse_strips/vello_common/src/filter_effects.rs
index 696c1ea..c6064c9 100644
--- a/sparse_strips/vello_common/src/filter_effects.rs
+++ b/sparse_strips/vello_common/src/filter_effects.rs
@@ -578,8 +578,7 @@
     pub fn filter_expansion(&self) -> Rect {
         match self {
             Self::GaussianBlur { std_deviation, .. } => {
-                // Gaussian blur expands uniformly by 3*sigma (covers 99.7% of distribution)
-                let radius = (*std_deviation * 3.0) as f64;
+                let radius = blur_radius(*std_deviation);
                 Rect::new(-radius, -radius, radius, radius)
             }
             Self::Offset { dx, dy } => {
@@ -600,16 +599,15 @@
                 dy,
                 ..
             } => {
-                // The expansion rect encompasses both the blur and the offset.
-                let blur_radius = (*std_deviation * 3.0) as f64;
-                let dx = *dx as f64;
-                let dy = *dy as f64;
+                let blur_radius = blur_radius(*std_deviation);
+                let dx = f64::from(*dx);
+                let dy = f64::from(*dy);
 
                 Rect::new(
-                    -(blur_radius + (-dx).max(0.0)),
-                    -(blur_radius + (-dy).max(0.0)),
-                    blur_radius + dx.max(0.0),
-                    blur_radius + dy.max(0.0),
+                    (dx - blur_radius).min(0.0),
+                    (dy - blur_radius).min(0.0),
+                    (dx + blur_radius).max(0.0),
+                    (dy + blur_radius).max(0.0),
                 )
             }
             // Most other filters don't expand bounds
@@ -620,19 +618,47 @@
     /// The source expansion of the primitive, see [`Filter::source_expansion`].
     pub fn source_expansion(&self) -> Rect {
         match self {
-            Self::Offset { dx, dy }
-            | Self::DropShadow { dx, dy, .. }
-            | Self::DropShadowOnly { dx, dy, .. } => {
+            Self::Offset { dx, dy } => {
                 self.filter_expansion() - Vec2::new(f64::from(*dx), f64::from(*dy))
             }
+            Self::DropShadow {
+                std_deviation,
+                dx,
+                dy,
+                ..
+            }
+            | Self::DropShadowOnly {
+                std_deviation,
+                dx,
+                dy,
+                ..
+            } => {
+                let blur_radius = blur_radius(*std_deviation);
+                let dx = -f64::from(*dx);
+                let dy = -f64::from(*dy);
+
+                Rect::new(
+                    (dx - blur_radius).min(0.0),
+                    (dy - blur_radius).min(0.0),
+                    (dx + blur_radius).max(0.0),
+                    (dy + blur_radius).max(0.0),
+                )
+            }
             _ => self.filter_expansion(),
         }
     }
 }
 
+fn blur_radius(std_deviation: f32) -> f64 {
+    // Gaussian blur expands uniformly by 3*sigma (covers 99.7% of distribution)
+    f64::from(std_deviation * 3.0)
+}
+
 #[cfg(test)]
-mod offset_expansion_tests {
+mod expansion_tests {
     use super::FilterPrimitive;
+    use crate::color::palette::css::RED;
+    use crate::filter_effects::EdgeMode;
     use crate::kurbo::Rect;
 
     #[test]
@@ -644,6 +670,20 @@
             "Offset expansion should be asymmetric and include the shift vector"
         );
     }
+
+    #[test]
+    fn drop_shadow_expansion_combines_blur_and_offset_tightly() {
+        let p = FilterPrimitive::DropShadow {
+            dx: 20.0,
+            dy: -10.0,
+            std_deviation: 8.0,
+            color: RED,
+            edge_mode: EdgeMode::None,
+        };
+
+        assert_eq!(p.filter_expansion(), Rect::new(-4.0, -34.0, 44.0, 14.0));
+        assert_eq!(p.source_expansion(), Rect::new(-44.0, -14.0, 4.0, 34.0));
+    }
 }
 
 /// Unique identifier for a filter primitive in the graph.
diff --git a/sparse_strips/vello_sparse_tests/snapshots/filter_expansion_grid.png b/sparse_strips/vello_sparse_tests/snapshots/filter_expansion_grid.png
new file mode 100644
index 0000000..ef3bfef
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/filter_expansion_grid.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:199be0713f0060b0218ef08c08759409a2d07bdfa2dcc4b75cfd28d30479aa33
+size 23239
diff --git a/sparse_strips/vello_sparse_tests/tests/filter.rs b/sparse_strips/vello_sparse_tests/tests/filter.rs
index 57a7733..917e1c8 100644
--- a/sparse_strips/vello_sparse_tests/tests/filter.rs
+++ b/sparse_strips/vello_sparse_tests/tests/filter.rs
@@ -1857,3 +1857,166 @@
     ctx.fill_rect(&viewport);
     ctx.pop_clip_path();
 }
+
+#[vello_test(
+    skip_multithreaded,
+    width = 600,
+    height = 450,
+    cpu_u8_tolerance = 1,
+    hybrid_tolerance = 2
+)]
+fn filter_expansion_grid(ctx: &mut impl Renderer) {
+    #[derive(Clone, Copy)]
+    enum ExpansionFilter {
+        Blur {
+            std_deviation: f32,
+        },
+        DropShadow {
+            dx: f32,
+            dy: f32,
+            std_deviation: f32,
+            shadow_only: bool,
+        },
+    }
+
+    #[derive(Clone, Copy)]
+    struct ExpansionCase {
+        filter: ExpansionFilter,
+        rotation_degrees: f64,
+        scale: (f64, f64),
+    }
+
+    const fn expansion_blur(
+        std_deviation: f32,
+        rotation_degrees: f64,
+        scale: (f64, f64),
+    ) -> ExpansionCase {
+        ExpansionCase {
+            filter: ExpansionFilter::Blur { std_deviation },
+            rotation_degrees,
+            scale,
+        }
+    }
+
+    const fn expansion_shadow(
+        dx: f32,
+        dy: f32,
+        std_deviation: f32,
+        shadow_only: bool,
+        rotation_degrees: f64,
+        scale: (f64, f64),
+    ) -> ExpansionCase {
+        ExpansionCase {
+            filter: ExpansionFilter::DropShadow {
+                dx,
+                dy,
+                std_deviation,
+                shadow_only,
+            },
+            rotation_degrees,
+            scale,
+        }
+    }
+
+    fn draw_expansion_case(ctx: &mut impl Renderer, case: ExpansionCase, center: (f64, f64)) {
+        const RADIUS: f64 = 16.0;
+        const CONTENT_BOUNDS: Rect = Rect::new(-RADIUS, -RADIUS, RADIUS, RADIUS);
+
+        let filter = match case.filter {
+            ExpansionFilter::Blur { std_deviation } => {
+                Filter::from_primitive(FilterPrimitive::GaussianBlur {
+                    std_deviation,
+                    edge_mode: EdgeMode::None,
+                })
+            }
+            ExpansionFilter::DropShadow {
+                dx,
+                dy,
+                std_deviation,
+                shadow_only,
+            } => {
+                let color = AlphaColor::from_rgba8(0, 0, 0, 180);
+                let primitive = if shadow_only {
+                    FilterPrimitive::DropShadowOnly {
+                        dx,
+                        dy,
+                        std_deviation,
+                        color,
+                        edge_mode: EdgeMode::None,
+                    }
+                } else {
+                    FilterPrimitive::DropShadow {
+                        dx,
+                        dy,
+                        std_deviation,
+                        color,
+                        edge_mode: EdgeMode::None,
+                    }
+                };
+                Filter::from_primitive(primitive)
+            }
+        };
+
+        let transform = Affine::translate(center)
+            * Affine::rotate(case.rotation_degrees.to_radians())
+            * Affine::scale_non_uniform(case.scale.0, case.scale.1);
+
+        let circle = Circle::new((0.0, 0.0), RADIUS).to_path(0.1);
+
+        ctx.set_transform(transform);
+        ctx.push_filter_layer(filter.clone());
+        ctx.set_paint(ROYAL_BLUE);
+        ctx.fill_path(&circle);
+        ctx.pop_layer();
+
+        let content_bounds = transform.transform_rect_bbox(CONTENT_BOUNDS);
+        let expand = |expansion: Rect| {
+            Rect::new(
+                content_bounds.x0 + expansion.x0,
+                content_bounds.y0 + expansion.y0,
+                content_bounds.x1 + expansion.x1,
+                content_bounds.y1 + expansion.y1,
+            )
+        };
+        let source_bounds = expand(filter.source_expansion(&transform));
+        let filter_bounds = expand(filter.filter_expansion(&transform));
+
+        ctx.set_transform(Affine::IDENTITY);
+
+        ctx.set_stroke(Stroke::new(0.75));
+        ctx.set_paint(RED);
+        ctx.stroke_path(&source_bounds.to_path(0.1));
+        ctx.set_stroke(Stroke::new(0.75));
+        ctx.set_paint(GREEN);
+        ctx.stroke_path(&filter_bounds.to_path(0.1));
+    }
+
+    const CASES: [ExpansionCase; 12] = [
+        expansion_blur(1.0, 0.0, (1.0, 1.0)),
+        expansion_blur(4.0, 0.0, (1.0, 1.0)),
+        expansion_blur(8.0, 0.0, (1.0, 1.0)),
+        expansion_shadow(0.0, 0.0, 4.0, false, 0.0, (1.0, 1.0)),
+        expansion_shadow(16.0, 0.0, 2.0, false, 0.0, (1.0, 1.0)),
+        expansion_shadow(0.0, 16.0, 4.0, false, 0.0, (1.0, 1.0)),
+        expansion_shadow(-16.0, -12.0, 6.0, false, 0.0, (1.0, 1.0)),
+        expansion_shadow(14.0, -14.0, 3.0, false, 0.0, (1.0, 1.0)),
+        expansion_blur(5.0, 30.0, (1.0, 1.0)),
+        expansion_shadow(16.0, 8.0, 4.0, false, 0.0, (1.35, 0.65)),
+        expansion_shadow(-14.0, 12.0, 5.0, true, -30.0, (1.0, 1.0)),
+        expansion_shadow(12.0, 16.0, 7.0, true, 45.0, (0.75, 1.25)),
+    ];
+
+    const COLUMNS: usize = 4;
+    const CELL_WIDTH: f64 = 150.0;
+    const CELL_HEIGHT: f64 = 150.0;
+
+    for (index, case) in CASES.into_iter().enumerate() {
+        let column = index % COLUMNS;
+        let row = index / COLUMNS;
+        let center = (
+            (column as f64 + 0.5) * CELL_WIDTH,
+            (row as f64 + 0.5) * CELL_HEIGHT,
+        );
+        draw_expansion_case(ctx, case, center);
+    }
+}