Skip to main content

valo_dl/
paint.rs

1use std::sync::Arc;
2
3use valo_geometry::{Color, Matrix, Point, Rect, Stroke};
4
5/// `BlendMode` controls how source pixels combine with destination pixels.
6///
7/// [`BlendMode::SrcOver`] is the default. Advanced modes that read destination
8/// pixels may require an additional render-pass break and snapshot.
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub enum BlendMode {
12    Clear,
13    Src,
14    Dst,
15    #[default]
16    SrcOver,
17    DstOver,
18    SrcIn,
19    DstIn,
20    SrcOut,
21    DstOut,
22    SrcAtop,
23    DstAtop,
24    Xor,
25    Plus,
26    Modulate,
27    Screen,
28    // Destination-reading advanced modes require a target snapshot.
29    Overlay,
30    Darken,
31    Lighten,
32    ColorDodge,
33    ColorBurn,
34    HardLight,
35    SoftLight,
36    Difference,
37    Exclusion,
38    Multiply,
39    Hue,
40    Saturation,
41    Color,
42    Luminosity,
43}
44
45#[cfg(test)]
46mod tests {
47    use super::{ColorFilter, ImageFilter, MaskBlur, Paint, PaintStyle};
48    use valo_geometry::Stroke;
49
50    #[test]
51    fn hairline_padding_stays_large_enough_when_minified() {
52        let paint = Paint {
53            style: PaintStyle::Stroke(Stroke::new(0.0)),
54            ..Paint::default()
55        };
56        let scale = 0.1;
57        let device_padding = paint.stroke_padding_at_scale(scale) * scale;
58        assert!(device_padding >= 0.5);
59    }
60
61    #[test]
62    fn composed_image_filters_accumulate_blur_coverage() {
63        let filter = ImageFilter::compose(
64            ImageFilter::blur(3.0, 4.0),
65            ImageFilter::compose(
66                ImageFilter::color(ColorFilter::Matrix([0.0; 20])),
67                ImageFilter::blur(2.0, 1.0),
68            ),
69        );
70        assert_eq!(filter.padding(), [15.0, 15.0]);
71    }
72
73    #[test]
74    fn drop_shadow_padding_covers_the_offset_on_both_sides() {
75        let filter = ImageFilter::drop_shadow(
76            valo_geometry::Point::new(4.0, -6.0),
77            2.0,
78            1.0,
79            valo_geometry::Color::BLACK,
80        );
81        assert_eq!(filter.padding(), [10.0, 9.0]);
82    }
83
84    // A rotation reaches further than any axis length reports: `max_scale`
85    // is 1 for a pure rotation, so scalar padding would clip this shadow.
86    #[test]
87    fn device_padding_bounds_a_rotated_effect() {
88        use valo_geometry::Matrix;
89        let paint = Paint {
90            image_filter: Some(ImageFilter::drop_shadow(
91                valo_geometry::Point::new(10.0, 10.0),
92                0.0,
93                0.0,
94                valo_geometry::Color::BLACK,
95            )),
96            ..Paint::default()
97        };
98        assert_eq!(paint.effect_padding(), 10.0);
99
100        let quarter_turn = Matrix::rotation(std::f32::consts::FRAC_PI_4);
101        let padding = paint.device_effect_padding(&quarter_turn);
102        assert!(
103            (padding - 14.142136).abs() < 1e-3,
104            "a 45° rotation maps the (10, 10) padding box to 14.14, got {padding}"
105        );
106        assert!(
107            padding > paint.effect_padding() * quarter_turn.max_scale(),
108            "the scalar bound is exactly what this has to beat"
109        );
110    }
111
112    #[test]
113    fn device_padding_matches_the_scalar_bound_under_a_plain_scale() {
114        use valo_geometry::Matrix;
115        let paint = Paint {
116            mask_blur: Some(MaskBlur::new(2.0)),
117            ..Paint::default()
118        };
119        let scale = Matrix::scale(3.0, 3.0);
120        assert_eq!(paint.effect_padding(), 6.0);
121        assert!((paint.device_effect_padding(&scale) - 18.0).abs() < 1e-4);
122    }
123
124    #[test]
125    fn an_invisible_drop_shadow_is_a_nop() {
126        let filter = ImageFilter::drop_shadow(
127            valo_geometry::Point::new(4.0, 4.0),
128            2.0,
129            2.0,
130            valo_geometry::Color::TRANSPARENT,
131        );
132        assert!(filter.is_nop());
133        assert!(!filter.modifies_transparent_black());
134    }
135}
136
137impl BlendMode {
138    /// `is_destructive` reports whether transparent source pixels can change
139    /// destination pixels outside the source ink.
140    pub fn is_destructive(self) -> bool {
141        matches!(
142            self,
143            BlendMode::Clear
144                | BlendMode::Src
145                | BlendMode::SrcIn
146                | BlendMode::DstIn
147                | BlendMode::SrcOut
148                | BlendMode::DstOut
149                | BlendMode::DstAtop
150                | BlendMode::Xor
151                | BlendMode::Modulate
152        )
153    }
154
155    /// `is_pipeline_blendable` reports whether fixed-function blending is sufficient.
156    pub fn is_pipeline_blendable(self) -> bool {
157        !matches!(
158            self,
159            BlendMode::Overlay
160                | BlendMode::Darken
161                | BlendMode::Lighten
162                | BlendMode::ColorDodge
163                | BlendMode::ColorBurn
164                | BlendMode::HardLight
165                | BlendMode::SoftLight
166                | BlendMode::Difference
167                | BlendMode::Exclusion
168                | BlendMode::Multiply
169                | BlendMode::Hue
170                | BlendMode::Saturation
171                | BlendMode::Color
172                | BlendMode::Luminosity
173        )
174    }
175}
176
177/// `BlurStyle` controls where blurred coverage appears relative to a shape.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180pub enum BlurStyle {
181    /// `Normal` blurs coverage inside and outside the shape.
182    #[default]
183    Normal,
184    /// `Solid` keeps a sharp interior and blurs outside.
185    Solid,
186    /// `Inner` blurs inside and leaves the exterior empty.
187    Inner,
188    /// `Outer` blurs outside and leaves the interior empty.
189    Outer,
190}
191
192/// `MaskBlur` applies a Gaussian blur to a draw's coverage mask.
193///
194/// Sigma is measured in local units and follows the draw's transform.
195#[derive(Clone, Copy, Debug, PartialEq)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize))]
197pub struct MaskBlur {
198    /// `sigma` is the nonnegative Gaussian standard deviation in local units.
199    pub sigma: f32,
200    /// `style` controls which side of the original coverage remains visible.
201    pub style: BlurStyle,
202}
203
204impl MaskBlur {
205    /// `new` creates a normal mask blur.
206    pub fn new(sigma: f32) -> Self {
207        Self::styled(sigma, BlurStyle::Normal)
208    }
209
210    /// `solid` creates a blur with a sharp interior.
211    pub fn solid(sigma: f32) -> Self {
212        Self::styled(sigma, BlurStyle::Solid)
213    }
214
215    /// `inner` creates a blur visible only inside the shape.
216    pub fn inner(sigma: f32) -> Self {
217        Self::styled(sigma, BlurStyle::Inner)
218    }
219
220    /// `outer` creates a blur visible only outside the shape.
221    pub fn outer(sigma: f32) -> Self {
222        Self::styled(sigma, BlurStyle::Outer)
223    }
224
225    /// `styled` clamps sigma to keep effect bounds from shrinking.
226    fn styled(sigma: f32, style: BlurStyle) -> Self {
227        Self {
228            sigma: sigma.max(0.0),
229            style,
230        }
231    }
232}
233
234/// `ColorFilter` transforms the pixels produced by a draw or layer.
235///
236/// Color filters run before mask blur, so the blur spreads the filtered result.
237#[derive(Clone, Copy, Debug, PartialEq)]
238#[cfg_attr(feature = "serde", derive(serde::Serialize))]
239pub enum ColorFilter {
240    /// `Matrix` is a row-major 4×5 transform over straight color in 0..1.
241    ///
242    /// Each output
243    /// channel is `row · [r, g, b, a, 1]`, clamped. Skia's `SkColorMatrix`
244    /// convention.
245    ///
246    /// Flutter's `ColorFilter.matrix` hands the translation column in
247    /// unnormalized 0..255 space instead, so a Flutter matrix needs entries
248    /// 4, 9, 14 and 19 divided by 255 before it arrives here. Getting that
249    /// wrong still produces a plausible-looking image, which is why it is
250    /// called out rather than absorbed.
251    Matrix([f32; 20]),
252    /// `Blend` composites a constant source color over each produced pixel.
253    Blend(Color, BlendMode),
254}
255
256impl ColorFilter {
257    /// `folded_into` applies this filter to one solid color on the CPU.
258    pub fn folded_into(&self, color: Color) -> Option<Color> {
259        Some(crate::color_filter::apply(*self, color))
260    }
261
262    /// `modifies_transparent_black` reports whether this filter can create
263    /// visible output from a transparent input pixel.
264    pub fn modifies_transparent_black(&self) -> bool {
265        self.folded_into(Color::TRANSPARENT)
266            .is_some_and(|color| color.a > 0.0)
267    }
268}
269
270/// `ImageFilter` transforms a rasterized draw or layer.
271///
272/// In a composition, the inner filter runs first and feeds the outer filter.
273#[derive(Clone, Debug, PartialEq)]
274#[cfg_attr(feature = "serde", derive(serde::Serialize))]
275pub enum ImageFilter {
276    /// `Blur` applies a Gaussian blur in local x and y units.
277    Blur {
278        /// `sigma_x` is the horizontal standard deviation.
279        sigma_x: f32,
280        /// `sigma_y` is the vertical standard deviation.
281        sigma_y: f32,
282    },
283    /// `Color` applies a color filter after rasterization.
284    Color(ColorFilter),
285    /// `DropShadow` composites the input over a blurred, colored copy of its alpha.
286    DropShadow {
287        /// `offset` moves the shadow in local coordinates.
288        offset: Point,
289        /// `sigma_x` is the horizontal standard deviation.
290        sigma_x: f32,
291        /// `sigma_y` is the vertical standard deviation.
292        sigma_y: f32,
293        /// `color` colors the shadow.
294        color: Color,
295    },
296    /// `Compose` applies `inner` and then `outer`.
297    Compose {
298        /// `outer` receives the filtered result of `inner`.
299        outer: Arc<ImageFilter>,
300        /// `inner` receives the original input.
301        inner: Arc<ImageFilter>,
302    },
303}
304
305impl ImageFilter {
306    /// `blur` creates a Gaussian image filter with nonnegative sigmas.
307    pub fn blur(sigma_x: f32, sigma_y: f32) -> Self {
308        Self::Blur {
309            sigma_x: sigma_x.max(0.0),
310            sigma_y: sigma_y.max(0.0),
311        }
312    }
313
314    /// `color` creates an image filter from a color filter.
315    pub fn color(filter: ColorFilter) -> Self {
316        Self::Color(filter)
317    }
318
319    /// `compose` applies `inner` first and `outer` second.
320    pub fn compose(outer: ImageFilter, inner: ImageFilter) -> Self {
321        Self::Compose {
322            outer: Arc::new(outer),
323            inner: Arc::new(inner),
324        }
325    }
326
327    /// `drop_shadow` creates a shadow that retains the original input.
328    pub fn drop_shadow(offset: Point, sigma_x: f32, sigma_y: f32, color: Color) -> Self {
329        Self::DropShadow {
330            offset,
331            sigma_x: sigma_x.max(0.0),
332            sigma_y: sigma_y.max(0.0),
333            color,
334        }
335    }
336
337    /// `is_nop` reports whether the filter leaves every input pixel unchanged.
338    pub fn is_nop(&self) -> bool {
339        match self {
340            Self::Blur { sigma_x, sigma_y } => *sigma_x <= 0.0 && *sigma_y <= 0.0,
341            Self::Color(_) => false,
342            // An invisible shadow leaves the input exactly as it found it.
343            Self::DropShadow { color, .. } => color.a <= 0.0,
344            Self::Compose { outer, inner } => outer.is_nop() && inner.is_nop(),
345        }
346    }
347
348    /// `padding` returns conservative local x and y expansion for this filter.
349    pub fn padding(&self) -> [f32; 2] {
350        match self {
351            Self::Blur { sigma_x, sigma_y } => [(sigma_x * 3.0).ceil(), (sigma_y * 3.0).ceil()],
352            Self::Color(_) => [0.0; 2],
353            // Padding is symmetric, so a one-sided offset has to be paid on
354            // both sides — the shadow is free to land on either.
355            Self::DropShadow {
356                offset,
357                sigma_x,
358                sigma_y,
359                ..
360            } => [
361                (sigma_x * 3.0).ceil() + offset.x.abs(),
362                (sigma_y * 3.0).ceil() + offset.y.abs(),
363            ],
364            Self::Compose { outer, inner } => {
365                let outer = outer.padding();
366                let inner = inner.padding();
367                [outer[0] + inner[0], outer[1] + inner[1]]
368            }
369        }
370    }
371
372    /// `modifies_transparent_black` reports whether this filter can create
373    /// visible output from a transparent input pixel.
374    pub fn modifies_transparent_black(&self) -> bool {
375        match self {
376            Self::Blur { .. } => false,
377            Self::Color(filter) => filter.modifies_transparent_black(),
378            // The shadow is the input's own alpha recoloured, so transparent
379            // input stays transparent however opaque the shadow colour is.
380            Self::DropShadow { .. } => false,
381            Self::Compose { outer, inner } => {
382                outer.modifies_transparent_black() || inner.modifies_transparent_black()
383            }
384        }
385    }
386}
387
388/// `PaintStyle` selects filled geometry or a stroked outline.
389#[derive(Clone, Debug, Default, PartialEq)]
390#[cfg_attr(feature = "serde", derive(serde::Serialize))]
391pub enum PaintStyle {
392    /// `Fill` covers the geometry's interior.
393    #[default]
394    Fill,
395    /// `Stroke` draws the geometry's outline with the supplied stroke parameters.
396    Stroke(Stroke),
397}
398
399/// `Paint` describes how a drawing operation produces and composites pixels.
400///
401/// The default is an opaque black fill using [`BlendMode::SrcOver`].
402#[derive(Clone, Debug, PartialEq)]
403#[cfg_attr(feature = "serde", derive(serde::Serialize))]
404pub struct Paint {
405    /// `color` supplies solid-draw color and the alpha for shader or image draws.
406    ///
407    /// Shader and image draws ignore its RGB channels.
408    pub color: Color,
409    /// `blend_mode` controls compositing with destination pixels.
410    pub blend_mode: BlendMode,
411    /// `shader` replaces the solid color with a per-pixel source.
412    pub shader: Option<crate::Shader>,
413    /// `mask_blur` softens the draw's coverage.
414    pub mask_blur: Option<MaskBlur>,
415    /// `color_filter` transforms produced colors before mask blur.
416    pub color_filter: Option<ColorFilter>,
417    /// `image_filter` transforms the rasterized draw or layer.
418    pub image_filter: Option<ImageFilter>,
419    /// `style` selects fill or stroke rendering.
420    pub style: PaintStyle,
421}
422
423impl Default for Paint {
424    fn default() -> Self {
425        Self {
426            color: Color::BLACK,
427            blend_mode: BlendMode::SrcOver,
428            shader: None,
429            mask_blur: None,
430            color_filter: None,
431            image_filter: None,
432            style: PaintStyle::Fill,
433        }
434    }
435}
436
437impl Paint {
438    /// `from_color` creates a solid-color fill paint.
439    pub fn from_color(color: Color) -> Self {
440        Self {
441            color,
442            ..Default::default()
443        }
444    }
445
446    /// `from_shader` creates a fill paint using a per-pixel shader.
447    pub fn from_shader(shader: crate::Shader) -> Self {
448        Self {
449            color: Color::WHITE,
450            shader: Some(shader),
451            ..Default::default()
452        }
453    }
454
455    /// `is_nop` reports whether this paint can produce no visible change.
456    pub fn is_nop(&self) -> bool {
457        let filter_keeps_transparent = self
458            .color_filter
459            .is_none_or(|filter| !filter.modifies_transparent_black())
460            && self
461                .image_filter
462                .as_ref()
463                .is_none_or(|filter| !filter.modifies_transparent_black());
464        let invisible = self.color.a <= 0.0
465            && self.blend_mode == BlendMode::SrcOver
466            && filter_keeps_transparent;
467        // Width ZERO is a hairline, not an empty stroke — Skia and Impeller
468        // both draw it one device pixel wide, and the renderer's hairline
469        // floor is what realises that. Only a negative width draws nothing.
470        let empty_stroke = matches!(&self.style, PaintStyle::Stroke(s) if s.width < 0.0);
471        invisible || empty_stroke
472    }
473
474    /// `is_opacity_only` reports whether this paint is only a SrcOver alpha.
475    pub fn is_opacity_only(&self) -> bool {
476        self.blend_mode == BlendMode::SrcOver
477            && self.shader.is_none()
478            && self.mask_blur.is_none()
479            && self.color_filter.is_none()
480            && self.effective_image_filter().is_none()
481    }
482
483    /// `effective_image_filter` returns the image filter when it changes pixels.
484    pub fn effective_image_filter(&self) -> Option<&ImageFilter> {
485        self.image_filter.as_ref().filter(|f| !f.is_nop())
486    }
487
488    /// `mask_padding` returns conservative local padding for the mask blur.
489    pub fn mask_padding(&self) -> f32 {
490        self.mask_blur.map_or(0.0, |blur| (blur.sigma * 3.0).ceil())
491    }
492
493    /// `effect_padding_axes` returns local x and y padding for raster effects.
494    pub fn effect_padding_axes(&self) -> [f32; 2] {
495        let image = self
496            .image_filter
497            .as_ref()
498            .map_or([0.0; 2], ImageFilter::padding);
499        let mask = self.mask_padding();
500        [image[0] + mask, image[1] + mask]
501    }
502
503    /// `effect_padding` returns the largest local-axis padding for raster effects.
504    pub fn effect_padding(&self) -> f32 {
505        let axes = self.effect_padding_axes();
506        axes[0].max(axes[1])
507    }
508
509    /// `device_effect_padding` returns effect padding in device pixels.
510    ///
511    /// It maps both local padding axes through `transform`, preserving a
512    /// conservative bound under rotation and shear.
513    pub fn device_effect_padding(&self, transform: &Matrix) -> f32 {
514        let [x, y] = self.effect_padding_axes();
515        if x <= 0.0 && y <= 0.0 {
516            return 0.0;
517        }
518        // The half-extent of an axis-aligned box under a linear map is the
519        // component-wise absolute matrix applied to the half-extent.
520        let [a, b, c, d, ..] = transform.to_affine();
521        let device_x = (x * a).abs() + (y * c).abs();
522        let device_y = (x * b).abs() + (y * d).abs();
523        device_x.max(device_y)
524    }
525
526    /// `effect_bounds` returns local bounds required by this paint's effects.
527    ///
528    /// Filters that create visible pixels from transparency return unbounded
529    /// coverage for the caller to intersect with its active clip.
530    pub fn effect_bounds(&self, bounds: Rect) -> Rect {
531        let floods = self
532            .color_filter
533            .is_some_and(|filter| filter.modifies_transparent_black())
534            || self
535                .image_filter
536                .as_ref()
537                .is_some_and(|filter| filter.modifies_transparent_black());
538        if floods {
539            Rect::EVERYTHING
540        } else {
541            bounds.expand(self.effect_padding())
542        }
543    }
544
545    /// `stroke_padding` returns conservative stroke expansion at unit scale.
546    pub fn stroke_padding(&self) -> f32 {
547        self.stroke_padding_at_scale(1.0)
548    }
549
550    /// `stroke_padding_at_scale` returns stroke expansion at a device scale.
551    ///
552    /// Hairlines and minified strokes retain at least one device pixel.
553    pub fn stroke_padding_at_scale(&self, scale: f32) -> f32 {
554        match &self.style {
555            PaintStyle::Fill => 0.0,
556            PaintStyle::Stroke(s) => {
557                let spike = match s.join {
558                    valo_geometry::Join::Miter => s.miter_limit.max(1.5),
559                    _ => 1.5,
560                };
561                let effective_width = s.width.max(1.0 / scale.max(1e-3));
562                effective_width * 0.5 * spike
563            }
564        }
565    }
566}