1use std::sync::Arc;
2
3use valo_geometry::{Color, Matrix, Point, Rect, Stroke};
4
5#[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 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 #[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 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180pub enum BlurStyle {
181 #[default]
183 Normal,
184 Solid,
186 Inner,
188 Outer,
190}
191
192#[derive(Clone, Copy, Debug, PartialEq)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize))]
197pub struct MaskBlur {
198 pub sigma: f32,
200 pub style: BlurStyle,
202}
203
204impl MaskBlur {
205 pub fn new(sigma: f32) -> Self {
207 Self::styled(sigma, BlurStyle::Normal)
208 }
209
210 pub fn solid(sigma: f32) -> Self {
212 Self::styled(sigma, BlurStyle::Solid)
213 }
214
215 pub fn inner(sigma: f32) -> Self {
217 Self::styled(sigma, BlurStyle::Inner)
218 }
219
220 pub fn outer(sigma: f32) -> Self {
222 Self::styled(sigma, BlurStyle::Outer)
223 }
224
225 fn styled(sigma: f32, style: BlurStyle) -> Self {
227 Self {
228 sigma: sigma.max(0.0),
229 style,
230 }
231 }
232}
233
234#[derive(Clone, Copy, Debug, PartialEq)]
238#[cfg_attr(feature = "serde", derive(serde::Serialize))]
239pub enum ColorFilter {
240 Matrix([f32; 20]),
252 Blend(Color, BlendMode),
254}
255
256impl ColorFilter {
257 pub fn folded_into(&self, color: Color) -> Option<Color> {
259 Some(crate::color_filter::apply(*self, color))
260 }
261
262 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#[derive(Clone, Debug, PartialEq)]
274#[cfg_attr(feature = "serde", derive(serde::Serialize))]
275pub enum ImageFilter {
276 Blur {
278 sigma_x: f32,
280 sigma_y: f32,
282 },
283 Color(ColorFilter),
285 DropShadow {
287 offset: Point,
289 sigma_x: f32,
291 sigma_y: f32,
293 color: Color,
295 },
296 Compose {
298 outer: Arc<ImageFilter>,
300 inner: Arc<ImageFilter>,
302 },
303}
304
305impl ImageFilter {
306 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 pub fn color(filter: ColorFilter) -> Self {
316 Self::Color(filter)
317 }
318
319 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 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 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 Self::DropShadow { color, .. } => color.a <= 0.0,
344 Self::Compose { outer, inner } => outer.is_nop() && inner.is_nop(),
345 }
346 }
347
348 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 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 pub fn modifies_transparent_black(&self) -> bool {
375 match self {
376 Self::Blur { .. } => false,
377 Self::Color(filter) => filter.modifies_transparent_black(),
378 Self::DropShadow { .. } => false,
381 Self::Compose { outer, inner } => {
382 outer.modifies_transparent_black() || inner.modifies_transparent_black()
383 }
384 }
385 }
386}
387
388#[derive(Clone, Debug, Default, PartialEq)]
390#[cfg_attr(feature = "serde", derive(serde::Serialize))]
391pub enum PaintStyle {
392 #[default]
394 Fill,
395 Stroke(Stroke),
397}
398
399#[derive(Clone, Debug, PartialEq)]
403#[cfg_attr(feature = "serde", derive(serde::Serialize))]
404pub struct Paint {
405 pub color: Color,
409 pub blend_mode: BlendMode,
411 pub shader: Option<crate::Shader>,
413 pub mask_blur: Option<MaskBlur>,
415 pub color_filter: Option<ColorFilter>,
417 pub image_filter: Option<ImageFilter>,
419 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 pub fn from_color(color: Color) -> Self {
440 Self {
441 color,
442 ..Default::default()
443 }
444 }
445
446 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 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 let empty_stroke = matches!(&self.style, PaintStyle::Stroke(s) if s.width < 0.0);
471 invisible || empty_stroke
472 }
473
474 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 pub fn effective_image_filter(&self) -> Option<&ImageFilter> {
485 self.image_filter.as_ref().filter(|f| !f.is_nop())
486 }
487
488 pub fn mask_padding(&self) -> f32 {
490 self.mask_blur.map_or(0.0, |blur| (blur.sigma * 3.0).ceil())
491 }
492
493 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 pub fn effect_padding(&self) -> f32 {
505 let axes = self.effect_padding_axes();
506 axes[0].max(axes[1])
507 }
508
509 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 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 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 pub fn stroke_padding(&self) -> f32 {
547 self.stroke_padding_at_scale(1.0)
548 }
549
550 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}