From c02a204c70e85a7ec9d0af410f9bbd6d06708a47 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 3 Oct 2026 22:57:42 +0000 Subject: [PATCH 1/2] Make draw_box_shadow take a CSS-style box/offset/spread; add NonUniformRoundedRect and shape-preserving recordings --- crates/anyrender/src/box_shadow.rs | 217 +++++++ crates/anyrender/src/lib.rs | 34 +- crates/anyrender/src/null_backend.rs | 8 +- crates/anyrender/src/recording.rs | 173 +++++- crates/anyrender/src/shapes.rs | 537 ++++++++++++++++++ crates/anyrender_serialize/src/lib.rs | 2 +- crates/anyrender_serialize/tests/serialize.rs | 22 +- crates/anyrender_skia/src/scene.rs | 79 ++- crates/anyrender_vello/src/scene.rs | 30 +- crates/anyrender_vello_cpu/src/scene.rs | 138 ++++- crates/anyrender_vello_hybrid/src/scene.rs | 45 +- .../anyrender_vello_hybrid/src/webgl_scene.rs | 45 +- examples/serialize/src/main.rs | 10 +- 13 files changed, 1251 insertions(+), 89 deletions(-) create mode 100644 crates/anyrender/src/box_shadow.rs create mode 100644 crates/anyrender/src/shapes.rs diff --git a/crates/anyrender/src/box_shadow.rs b/crates/anyrender/src/box_shadow.rs new file mode 100644 index 0000000..c388a47 --- /dev/null +++ b/crates/anyrender/src/box_shadow.rs @@ -0,0 +1,217 @@ +//! Box shadows, as drawn by [`PaintScene::draw_box_shadow`]. + +use crate::{NonUniformRoundedRect, PaintScene}; +use kurbo::{Affine, BezPath, Shape, Vec2}; +use peniko::{BlendMode, Color, Compose, Fill, Mix}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Where a box shadow is painted relative to its box. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum BoxShadowKind { + /// A shadow cast outside of the box (a CSS outer shadow, cast by the border box). + Outset { + /// Only paint the shadow outside of the box, as CSS specifies. + /// + /// If the box will be painted opaquely on top of the shadow, setting this to `false` + /// avoids clipping the shadow, and the faint seam that can appear where the shadow's and + /// the box's anti-aliased edges meet. + clip_to_box: bool, + }, + /// A shadow cast inside of the box (a CSS `inset` shadow, cast by the padding box). It is only + /// painted inside of the box. + Inset, +} + +const TOLERANCE: f64 = 0.1; + +/// The geometry of a box shadow, computed from the arguments of [`PaintScene::draw_box_shadow`]. +/// +/// This is intended for use by backends. +#[derive(Clone, Debug)] +pub struct BoxShadowGeometry { + /// The shape of the shadow before it is blurred: the box spread by the shadow's spread (or + /// for an inset shadow, the box shrunk by the spread, which is the unshadowed "hole"), + /// translated by the shadow's offset. + pub shadow: NonUniformRoundedRect, + /// The area to paint, which contains every pixel that the shadow can affect, but no pixels + /// that the shadow must not be painted over. Uses the non-zero fill rule. + pub area: BezPath, + /// The box that the shadow is cast by. + pub box_shape: NonUniformRoundedRect, + pub std_dev: f64, + pub kind: BoxShadowKind, +} + +impl BoxShadowGeometry { + pub fn new( + box_shape: &NonUniformRoundedRect, + offset: Vec2, + spread: f64, + std_dev: f64, + kind: BoxShadowKind, + ) -> Self { + let std_dev = std_dev.max(0.0); + let mut shadow = match kind { + BoxShadowKind::Outset { .. } => box_shape.spread(spread), + BoxShadowKind::Inset => box_shape.spread(-spread), + }; + shadow.rect = shadow.rect + offset; + let area = match kind { + BoxShadowKind::Outset { clip_to_box } => { + // Beyond 3 standard deviations the blurred shadow is effectively transparent. + let extent = shadow.rect.inflate(3.0 * std_dev, 3.0 * std_dev); + if clip_to_box && extent.overlaps(box_shape.rect) { + // The box, reversed so that it is cut out of the extent. + let mut area = extent.union(box_shape.rect).to_path(TOLERANCE); + area.extend(box_shape.to_path(TOLERANCE).reverse_subpaths()); + area + } else { + extent.to_path(TOLERANCE) + } + } + BoxShadowKind::Inset => box_shape.to_path(TOLERANCE), + }; + Self { + shadow, + area, + box_shape: *box_shape, + std_dev, + kind, + } + } + + pub fn is_inset(&self) -> bool { + self.kind == BoxShadowKind::Inset + } + + /// Draw a blurred inset shadow using compositing layers, for backends that can't paint the + /// inverse of a blurred rounded rect: fill the box with `brush`, then cut out the blurred hole + /// with [`Compose::DestOut`], drawn as an unclipped outset shadow. + pub fn draw_inset_with_layers( + &self, + scene: &mut impl PaintScene, + transform: Affine, + brush: Color, + ) { + scene.push_clip_layer(Fill::NonZero, transform, &self.box_shape); + scene.fill(Fill::NonZero, transform, brush, None, &self.box_shape); + scene.push_layer( + Fill::NonZero, + BlendMode::new(Mix::Normal, Compose::DestOut), + 1.0, + transform, + &self.box_shape, + None, + None, + ); + scene.draw_box_shadow( + transform, + &self.shadow, + Vec2::ZERO, + 0.0, + self.std_dev, + Color::BLACK, + BoxShadowKind::Outset { clip_to_box: false }, + ); + scene.pop_layer(); + scene.pop_layer(); + } + + /// Whether the shadow must be clipped to [`Self::area`]. + pub fn needs_clip(&self) -> bool { + !matches!(self.kind, BoxShadowKind::Outset { clip_to_box: false }) + } + + /// The path to fill (inside a clip of [`Self::area`] if [`Self::needs_clip`]) to draw the + /// shadow when it isn't blurred. Uses the non-zero fill rule. + pub fn unblurred_path(&self) -> BezPath { + if self.is_inset() { + // Everything except the hole. + let mut path = self + .box_shape + .rect + .union(self.shadow.rect) + .to_path(TOLERANCE); + path.extend(self.shadow.to_path(TOLERANCE).reverse_subpaths()); + path + } else { + self.shadow.to_path(TOLERANCE) + } + } + + /// Draw the shadow when it isn't blurred (when `std_dev` is zero) with a fill and a clip, + /// keeping the radii of each corner. + /// + /// Backends that have a cheaper non-isolated clip than [`PaintScene::push_clip_layer`] can + /// use [`Self::unblurred_path`] instead: there is only one fill inside of the clip. + pub fn draw_unblurred(&self, scene: &mut impl PaintScene, transform: Affine, brush: Color) { + let clip = self.needs_clip(); + if clip { + scene.push_clip_layer(Fill::NonZero, transform, &self.area); + } + scene.fill( + Fill::NonZero, + transform, + brush, + None, + &self.unblurred_path(), + ); + if clip { + scene.pop_layer(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NonUniformRoundedRectRadii; + use kurbo::{Point, Rect}; + + fn box_shape() -> NonUniformRoundedRect { + let r = Vec2::new(10.0, 10.0); + NonUniformRoundedRect::new( + Rect::new(0.0, 0.0, 100.0, 50.0), + NonUniformRoundedRectRadii { + top_left: r, + top_right: r, + bottom_right: r, + bottom_left: r, + }, + ) + } + + #[test] + fn outset_area_excludes_box() { + let kind = BoxShadowKind::Outset { clip_to_box: true }; + let geometry = BoxShadowGeometry::new(&box_shape(), Vec2::new(5.0, 5.0), 2.0, 4.0, kind); + assert_eq!(geometry.shadow.rect, Rect::new(3.0, 3.0, 107.0, 57.0)); + assert_eq!(geometry.area.winding(Point::new(50.0, 25.0)), 0); + // Inside the box's bounds, but outside its rounded corner. + assert_ne!(geometry.area.winding(Point::new(1.0, 1.0)), 0); + assert_ne!(geometry.area.winding(Point::new(115.0, 60.0)), 0); + assert_eq!(geometry.area.winding(Point::new(125.0, 60.0)), 0); + + let kind = BoxShadowKind::Outset { clip_to_box: false }; + let geometry = BoxShadowGeometry::new(&box_shape(), Vec2::new(5.0, 5.0), 2.0, 4.0, kind); + assert_ne!(geometry.area.winding(Point::new(50.0, 25.0)), 0); + } + + #[test] + fn inset_hole_is_shrunk_by_spread() { + let geometry = BoxShadowGeometry::new( + &box_shape(), + Vec2::new(5.0, 0.0), + 2.0, + 4.0, + BoxShadowKind::Inset, + ); + assert_eq!(geometry.shadow.rect, Rect::new(7.0, 2.0, 103.0, 48.0)); + assert_eq!(geometry.shadow.radii.top_left, Vec2::new(8.0, 8.0)); + assert_eq!(geometry.area.winding(Point::new(1.0, 1.0)), 0); + assert_ne!(geometry.area.winding(Point::new(50.0, 25.0)), 0); + } +} diff --git a/crates/anyrender/src/lib.rs b/crates/anyrender/src/lib.rs index 0637cbc..5485ddb 100644 --- a/crates/anyrender/src/lib.rs +++ b/crates/anyrender/src/lib.rs @@ -41,7 +41,7 @@ #![allow(clippy::collapsible_if)] -use kurbo::{Affine, Rect, Shape, Stroke}; +use kurbo::{Affine, Rect, Shape, Stroke, Vec2}; use peniko::{BlendMode, Color, Fill, FontData, ImageBrushRef, StyleRef}; use recording::RenderCommand; use std::{any::Any, sync::Arc}; @@ -52,6 +52,10 @@ pub mod wasm_send_sync; pub use wasm_send_sync::*; pub mod types; pub use types::*; +mod shapes; +pub use shapes::*; +mod box_shadow; +pub use box_shadow::*; mod null_backend; pub use null_backend::*; pub mod recording; @@ -248,14 +252,26 @@ pub trait PaintScene: RenderContext { glyphs: impl Iterator + Clone, ); - /// Draw a rounded rectangle blurred with a gaussian filter. + /// Draw a box shadow cast by `box_shape`, as specified by CSS `box-shadow`. + /// + /// The shape of the shadow is `box_shape` grown by `spread` (or for an inset shadow, shrunk by + /// `spread`), with its corner radii adjusted as specified by CSS (see + /// [`NonUniformRoundedRect::spread`]), and translated by `offset`. It is then blurred by a + /// gaussian filter with a standard deviation of `std_dev`. A CSS blur radius `b` corresponds + /// to a `std_dev` of `b / 2`. + /// + /// `kind` determines where the shadow is painted: see [`BoxShadowKind`]. Everything is + /// transformed by `transform`. + #[allow(clippy::too_many_arguments)] fn draw_box_shadow( &mut self, transform: Affine, - rect: Rect, - brush: Color, - radius: f64, + box_shape: &NonUniformRoundedRect, + offset: Vec2, + spread: f64, std_dev: f64, + brush: Color, + kind: BoxShadowKind, ); // --- Provided methods @@ -324,10 +340,12 @@ pub trait PaintScene: RenderContext { ), RenderCommand::BoxShadow(cmd) => self.draw_box_shadow( scene_transform * cmd.transform, - cmd.rect, - cmd.brush, - cmd.radius, + &cmd.box_shape, + cmd.offset, + cmd.spread, cmd.std_dev, + cmd.brush, + cmd.kind, ), } } diff --git a/crates/anyrender/src/null_backend.rs b/crates/anyrender/src/null_backend.rs index e73d619..5252e84 100644 --- a/crates/anyrender/src/null_backend.rs +++ b/crates/anyrender/src/null_backend.rs @@ -157,10 +157,12 @@ impl PaintScene for NullScenePainter { fn draw_box_shadow( &mut self, _transform: kurbo::Affine, - _rect: kurbo::Rect, - _brush: peniko::Color, - _radius: f64, + _box_shape: &crate::NonUniformRoundedRect, + _offset: kurbo::Vec2, + _spread: f64, _std_dev: f64, + _brush: peniko::Color, + _kind: crate::BoxShadowKind, ) { } } diff --git a/crates/anyrender/src/recording.rs b/crates/anyrender/src/recording.rs index c7392b5..6d9663d 100644 --- a/crates/anyrender/src/recording.rs +++ b/crates/anyrender/src/recording.rs @@ -1,7 +1,10 @@ use std::sync::Arc; -use crate::{Filter, Glyph, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; -use kurbo::{Affine, BezPath, Rect, Shape, Stroke}; +use crate::{ + BoxShadowKind, Filter, Glyph, NonUniformRoundedRect, NormalizedCoord, Paint, PaintRef, + PaintScene, RenderContext, +}; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, RoundedRect, Shape, Stroke, Vec2}; use peniko::{BlendMode, Color, Fill, FontData, Style, StyleRef}; #[cfg(feature = "serde")] @@ -60,8 +63,7 @@ pub struct LayerCommand { pub blend: BlendMode, pub alpha: f32, pub transform: Affine, - #[cfg_attr(feature = "serde", serde(with = "svg_path"))] - pub clip: BezPath, // TODO: more shape options + pub clip: RecordedShape, pub filter: Option>, pub backdrop_filter: Option>, } @@ -75,8 +77,7 @@ pub struct ClipCommand { #[cfg_attr(feature = "serde", serde(default))] pub fill: Fill, pub transform: Affine, - #[cfg_attr(feature = "serde", serde(with = "svg_path"))] - pub clip: BezPath, // TODO: more shape options + pub clip: RecordedShape, } /// Strokes a shape using the specified style and brush. @@ -87,8 +88,7 @@ pub struct StrokeCommand { pub transform: Affine, pub brush: Brush, // TODO: review ownership to avoid cloning. Should brushes be a "resource"? pub brush_transform: Option, - #[cfg_attr(feature = "serde", serde(with = "svg_path"))] - pub shape: BezPath, // TODO: more shape options + pub shape: RecordedShape, } /// Fills a shape using the specified style and brush. @@ -99,8 +99,7 @@ pub struct FillCommand { pub transform: Affine, pub brush: Brush, // TODO: review ownership to avoid cloning. Should brushes be a "resource"? pub brush_transform: Option, - #[cfg_attr(feature = "serde", serde(with = "svg_path"))] - pub shape: BezPath, // TODO: more shape options + pub shape: RecordedShape, } /// Draws a run of glyphs @@ -121,15 +120,137 @@ pub struct GlyphRunCommand { pub glyphs: Vec, } -/// Draw a box shadow around a box +/// Draw a box shadow cast by a box #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct BoxShadowCommand { pub transform: Affine, - pub rect: Rect, - pub brush: Color, - pub radius: f64, + pub box_shape: NonUniformRoundedRect, + pub offset: Vec2, + pub spread: f64, pub std_dev: f64, + pub brush: Color, + pub kind: BoxShadowKind, +} + +/// A shape stored in a recorded command. +/// +/// Rects and rounded rects are kept as-is (rather than being converted to paths) so that backends +/// can still use their fast paths for them when the recording is replayed. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(untagged))] +pub enum RecordedShape { + Rect(Rect), + RoundedRect(RoundedRect), + Path(#[cfg_attr(feature = "serde", serde(with = "svg_path"))] BezPath), +} + +impl RecordedShape { + /// Record `shape`, converting it to a path unless it is a rect or rounded rect. + pub fn from_shape(shape: &impl Shape, tolerance: f64) -> Self { + if let Some(rect) = shape.as_rect() { + Self::Rect(rect) + } else if let Some(rounded_rect) = shape.as_rounded_rect() { + Self::RoundedRect(rounded_rect) + } else { + Self::Path(shape.into_path(tolerance)) + } + } +} + +impl From for RecordedShape { + fn from(path: BezPath) -> Self { + Self::Path(path) + } +} + +impl From for RecordedShape { + fn from(rect: Rect) -> Self { + Self::Rect(rect) + } +} + +impl From for RecordedShape { + fn from(rounded_rect: RoundedRect) -> Self { + Self::RoundedRect(rounded_rect) + } +} + +/// Iterator over the path elements of a [`RecordedShape`]. +#[allow(clippy::large_enum_variant, reason = "short-lived iterator")] +pub enum RecordedShapePathIter<'a> { + Rect(::PathElementsIter<'a>), + RoundedRect(::PathElementsIter<'a>), + Path(::PathElementsIter<'a>), +} + +impl Iterator for RecordedShapePathIter<'_> { + type Item = PathEl; + + fn next(&mut self) -> Option { + match self { + Self::Rect(iter) => iter.next(), + Self::RoundedRect(iter) => iter.next(), + Self::Path(iter) => iter.next(), + } + } +} + +macro_rules! delegate { + ($self:ident, $shape:ident => $expr:expr) => { + match $self { + RecordedShape::Rect($shape) => $expr, + RecordedShape::RoundedRect($shape) => $expr, + RecordedShape::Path($shape) => $expr, + } + }; +} + +impl Shape for RecordedShape { + type PathElementsIter<'iter> = RecordedShapePathIter<'iter>; + + fn path_elements(&self, tolerance: f64) -> Self::PathElementsIter<'_> { + match self { + Self::Rect(rect) => RecordedShapePathIter::Rect(rect.path_elements(tolerance)), + Self::RoundedRect(rounded_rect) => { + RecordedShapePathIter::RoundedRect(rounded_rect.path_elements(tolerance)) + } + Self::Path(path) => RecordedShapePathIter::Path(path.path_elements(tolerance)), + } + } + + fn area(&self) -> f64 { + delegate!(self, shape => shape.area()) + } + + fn perimeter(&self, accuracy: f64) -> f64 { + delegate!(self, shape => shape.perimeter(accuracy)) + } + + fn winding(&self, pt: Point) -> i32 { + delegate!(self, shape => shape.winding(pt)) + } + + fn bounding_box(&self) -> Rect { + delegate!(self, shape => shape.bounding_box()) + } + + fn to_path(&self, tolerance: f64) -> BezPath { + delegate!(self, shape => shape.to_path(tolerance)) + } + + fn as_rect(&self) -> Option { + delegate!(self, shape => shape.as_rect()) + } + + fn as_rounded_rect(&self) -> Option { + delegate!(self, shape => shape.as_rounded_rect()) + } + + fn as_path_slice(&self) -> Option<&[PathEl]> { + delegate!(self, shape => shape.as_path_slice()) + } } /// A recording of a Scene or Scene Fragment stored as plain data types that can be stored @@ -191,7 +312,7 @@ impl PaintScene for Scene { backdrop_filter: Option>, ) { let blend = blend.into(); - let clip = clip.into_path(self.tolerance); + let clip = RecordedShape::from_shape(clip, self.tolerance); let layer = LayerCommand { fill, blend, @@ -205,7 +326,7 @@ impl PaintScene for Scene { } fn push_clip_layer(&mut self, fill: Fill, transform: Affine, clip: &impl Shape) { - let clip = clip.into_path(self.tolerance); + let clip = RecordedShape::from_shape(clip, self.tolerance); let layer = ClipCommand { fill, transform, @@ -226,7 +347,7 @@ impl PaintScene for Scene { brush_transform: Option, shape: &impl Shape, ) { - let shape = shape.into_path(self.tolerance); + let shape = RecordedShape::from_shape(shape, self.tolerance); let brush = self.convert_paint(paint_ref.into()); let stroke = StrokeCommand { style: style.clone(), @@ -246,7 +367,7 @@ impl PaintScene for Scene { brush_transform: Option, shape: &impl Shape, ) { - let shape = shape.into_path(self.tolerance); + let shape = RecordedShape::from_shape(shape, self.tolerance); let brush = self.convert_paint(paint.into()); let fill = FillCommand { fill: style, @@ -292,17 +413,21 @@ impl PaintScene for Scene { fn draw_box_shadow( &mut self, transform: Affine, - rect: Rect, - brush: Color, - radius: f64, + box_shape: &NonUniformRoundedRect, + offset: Vec2, + spread: f64, std_dev: f64, + brush: Color, + kind: BoxShadowKind, ) { let box_shadow = BoxShadowCommand { transform, - rect, - brush, - radius, + box_shape: *box_shape, + offset, + spread, std_dev, + brush, + kind, }; self.commands.push(RenderCommand::BoxShadow(box_shadow)); } diff --git a/crates/anyrender/src/shapes.rs b/crates/anyrender/src/shapes.rs new file mode 100644 index 0000000..e6ca5a1 --- /dev/null +++ b/crates/anyrender/src/shapes.rs @@ -0,0 +1,537 @@ +//! Additional shapes that backends can recognise and draw efficiently + +use kurbo::{ + Arc, ArcAppendIter, Ellipse, PathEl, Point, Rect, RoundedRect, RoundedRectRadii, Shape, Vec2, +}; +use std::f64::consts::{FRAC_PI_2, PI}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// The radii of each corner of a [`NonUniformRoundedRect`]. +/// +/// Each corner is an ellipse with independent horizontal (`x`) and vertical (`y`) radii. +/// A corner is sharp if either of its radii is zero (or negative). +#[derive(Clone, Copy, Default, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct NonUniformRoundedRectRadii { + pub top_left: Vec2, + pub top_right: Vec2, + pub bottom_right: Vec2, + pub bottom_left: Vec2, +} + +impl NonUniformRoundedRectRadii { + fn corners(&self) -> [Vec2; 4] { + [ + self.top_left, + self.top_right, + self.bottom_right, + self.bottom_left, + ] + } +} + +fn is_sharp(radii: Vec2) -> bool { + radii.x <= 0.0 || radii.y <= 0.0 +} + +/// A rectangle whose corners are each rounded by an ellipse with their own radii (like a CSS box +/// with `border-radius`). +/// +/// [`Shape::as_rect`] and [`Shape::as_rounded_rect`] return a [`Rect`] or [`RoundedRect`] when the +/// shape can be represented as one, which allows backends to use faster code paths. The radii are +/// used as-is: callers are responsible for scaling down radii that would overlap. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct NonUniformRoundedRect { + pub rect: Rect, + pub radii: NonUniformRoundedRectRadii, +} + +impl NonUniformRoundedRect { + /// Create a new `NonUniformRoundedRect`. The rect is normalized to have non-negative width and + /// height. + pub fn new(rect: Rect, radii: NonUniformRoundedRectRadii) -> Self { + Self { + rect: rect.abs(), + radii, + } + } + + /// Grow the shape by `spread` on every side (or shrink it, if `spread` is negative), adjusting + /// its corner radii as CSS does for the spread of a `box-shadow`. + /// + /// Sharp corners stay sharp. Shrinking subtracts the spread from each radius (flooring at + /// zero). Growing adds the spread to each radius, except that a radius `r` smaller than the + /// spread grows by `spread * (1 + (r / spread - 1)^3)`, so that small radii stay small. The + /// resulting radii are then scaled down with [`Self::scale_overlapping_radii`]. + pub fn spread(&self, spread: f64) -> Self { + let Rect { x0, y0, x1, y1 } = self.rect; + let grow = |min: f64, max: f64| { + if max - min + 2.0 * spread >= 0.0 { + (min - spread, max + spread) + } else { + let mid = (min + max) / 2.0; + (mid, mid) + } + }; + let (x0, x1) = grow(x0, x1); + let (y0, y1) = grow(y0, y1); + let adjust = |r: f64| { + if spread < 0.0 { + (r + spread).max(0.0) + } else if r < spread { + r + spread * (1.0 + (r / spread - 1.0).powi(3)) + } else { + r + spread + } + }; + let [top_left, top_right, bottom_right, bottom_left] = self.radii.corners().map(|r| { + if is_sharp(r) { + Vec2::ZERO + } else { + Vec2::new(adjust(r.x), adjust(r.y)) + } + }); + Self { + rect: Rect::new(x0, y0, x1, y1), + radii: NonUniformRoundedRectRadii { + top_left, + top_right, + bottom_right, + bottom_left, + }, + } + .scale_overlapping_radii() + } + + /// Scale all of the corner radii down by the same factor so that the radii of adjacent corners + /// don't add up to more than the length of the side between them, as CSS does. + pub fn scale_overlapping_radii(&self) -> Self { + let [tl, tr, br, bl] = self + .radii + .corners() + .map(|r| if is_sharp(r) { Vec2::ZERO } else { r }); + let (width, height) = (self.rect.width().abs(), self.rect.height().abs()); + let factor = [ + (width, tl.x + tr.x), + (width, bl.x + br.x), + (height, tl.y + bl.y), + (height, tr.y + br.y), + ] + .into_iter() + .filter(|&(_, sum)| sum > 0.0) + .map(|(len, sum)| len / sum) + .fold(1.0, f64::min); + Self { + rect: self.rect, + radii: NonUniformRoundedRectRadii { + top_left: tl * factor, + top_right: tr * factor, + bottom_right: br * factor, + bottom_left: bl * factor, + }, + } + } + + /// The average of the corners' radii (counting sharp corners as zero), for backends that only + /// support a single radius. + pub fn average_radius(&self) -> f64 { + self.radii + .corners() + .into_iter() + .filter(|r| !is_sharp(*r)) + .map(|r| r.x + r.y) + .sum::() + / 8.0 + } + + /// For each corner, in clockwise order (in a y-down coordinate space): the corner point, the + /// corner's radii, the center of the corner's ellipse, and the angle at which its arc starts. + fn corner_geometry(&self) -> [(Point, Vec2, Point, f64); 4] { + let Rect { x0, y0, x1, y1 } = self.rect; + let r = &self.radii; + [ + ( + Point::new(x0, y0), + r.top_left, + Point::new(x0 + r.top_left.x, y0 + r.top_left.y), + PI, + ), + ( + Point::new(x1, y0), + r.top_right, + Point::new(x1 - r.top_right.x, y0 + r.top_right.y), + -FRAC_PI_2, + ), + ( + Point::new(x1, y1), + r.bottom_right, + Point::new(x1 - r.bottom_right.x, y1 - r.bottom_right.y), + 0.0, + ), + ( + Point::new(x0, y1), + r.bottom_left, + Point::new(x0 + r.bottom_left.x, y1 - r.bottom_left.y), + FRAC_PI_2, + ), + ] + } +} + +impl From for NonUniformRoundedRect { + fn from(rect: Rect) -> Self { + Self::new(rect, NonUniformRoundedRectRadii::default()) + } +} + +impl From for NonUniformRoundedRect { + fn from(rounded_rect: RoundedRect) -> Self { + let radii = rounded_rect.radii(); + let corner = |r: f64| Vec2::new(r, r); + Self::new( + rounded_rect.rect(), + NonUniformRoundedRectRadii { + top_left: corner(radii.top_left), + top_right: corner(radii.top_right), + bottom_right: corner(radii.bottom_right), + bottom_left: corner(radii.bottom_left), + }, + ) + } +} + +/// Path elements of a [`NonUniformRoundedRect`]. +pub struct NonUniformRoundedRectPathIter { + corners: [(Point, Vec2, Point, f64); 4], + tolerance: f64, + /// Index of the next corner to start (4 = close path, 5 = done). + next_corner: usize, + arc: Option, +} + +impl Iterator for NonUniformRoundedRectPathIter { + type Item = PathEl; + + fn next(&mut self) -> Option { + if let Some(arc) = &mut self.arc { + if let Some(el) = arc.next() { + return Some(el); + } + self.arc = None; + } + + let idx = self.next_corner; + self.next_corner = (idx + 1).min(5); + let Some(&(corner, radii, center, start_angle)) = self.corners.get(idx) else { + return (idx == 4).then_some(PathEl::ClosePath); + }; + let start = if is_sharp(radii) { + corner + } else { + let arc = Arc::new(center, radii, start_angle, FRAC_PI_2, 0.0); + self.arc = Some(arc.append_iter(self.tolerance)); + // The (exact) direction of `start_angle` from the center, for each corner. + const START_DIRECTIONS: [(f64, f64); 4] = + [(-1.0, 0.0), (0.0, -1.0), (1.0, 0.0), (0.0, 1.0)]; + let (dx, dy) = START_DIRECTIONS[idx]; + center + Vec2::new(radii.x * dx, radii.y * dy) + }; + Some(if idx == 0 { + PathEl::MoveTo(start) + } else { + PathEl::LineTo(start) + }) + } +} + +impl Shape for NonUniformRoundedRect { + type PathElementsIter<'iter> = NonUniformRoundedRectPathIter; + + fn path_elements(&self, tolerance: f64) -> Self::PathElementsIter<'_> { + NonUniformRoundedRectPathIter { + corners: self.corner_geometry(), + tolerance, + next_corner: 0, + arc: None, + } + } + + fn area(&self) -> f64 { + let corner_area: f64 = self + .radii + .corners() + .into_iter() + .filter(|r| !is_sharp(*r)) + .map(|r| r.x * r.y * (1.0 - PI / 4.0)) + .sum(); + self.rect.area() - corner_area + } + + fn perimeter(&self, accuracy: f64) -> f64 { + // Start with the perimeter of the rect, then replace the straight edges surrounding each + // rounded corner with a quarter of the corner's ellipse. + let corners: f64 = self + .radii + .corners() + .into_iter() + .filter(|r| !is_sharp(*r)) + .map(|r| { + let quarter_ellipse = if r.x == r.y { + FRAC_PI_2 * r.x + } else { + Ellipse::new(Point::ORIGIN, r, 0.0).perimeter(accuracy) / 4.0 + }; + quarter_ellipse - r.x - r.y + }) + .sum(); + self.rect.abs().perimeter(accuracy) + corners + } + + fn winding(&self, pt: Point) -> i32 { + if !self.rect.abs().contains(pt) { + return 0; + } + let outside_corner = + self.corner_geometry() + .into_iter() + .any(|(corner, radii, center, _)| { + if is_sharp(radii) { + return false; + } + let in_corner_box = (pt.x - center.x) * (corner.x - center.x) > 0.0 + && (pt.y - center.y) * (corner.y - center.y) > 0.0; + let d = pt - center; + in_corner_box && (d.x / radii.x).powi(2) + (d.y / radii.y).powi(2) > 1.0 + }); + if outside_corner { 0 } else { 1 } + } + + fn bounding_box(&self) -> Rect { + self.rect.abs() + } + + fn as_rect(&self) -> Option { + self.radii + .corners() + .into_iter() + .all(is_sharp) + .then_some(self.rect) + } + + fn as_rounded_rect(&self) -> Option { + let corners = self.radii.corners(); + // `RoundedRect` clamps radii to half the shortest side, which would change the shape. + let max_radius = self.rect.width().abs().min(self.rect.height().abs()) / 2.0; + if !corners + .iter() + .all(|r| is_sharp(*r) || (r.x == r.y && r.x <= max_radius)) + { + return None; + } + let [tl, tr, br, bl] = corners.map(|r| if is_sharp(r) { 0.0 } else { r.x }); + Some(RoundedRect::from_rect( + self.rect, + RoundedRectRadii::new(tl, tr, br, bl), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn radii( + tl: (f64, f64), + tr: (f64, f64), + br: (f64, f64), + bl: (f64, f64), + ) -> NonUniformRoundedRectRadii { + NonUniformRoundedRectRadii { + top_left: tl.into(), + top_right: tr.into(), + bottom_right: br.into(), + bottom_left: bl.into(), + } + } + + const RECT: Rect = Rect::new(10.0, 20.0, 110.0, 80.0); + + #[test] + fn new_normalizes_rect() { + let flipped = Rect::new(RECT.x1, RECT.y1, RECT.x0, RECT.y0); + let shape = NonUniformRoundedRect::new(flipped, NonUniformRoundedRectRadii::default()); + assert_eq!(shape.rect, RECT); + } + + #[test] + fn large_circular_radii_are_not_a_rounded_rect() { + // RECT is 60 tall, so a 40 radius would be clamped by `RoundedRect`. + let shape = NonUniformRoundedRect::new( + RECT, + radii((40.0, 40.0), (0.0, 0.0), (0.0, 0.0), (20.0, 20.0)), + ); + assert_eq!(shape.as_rounded_rect(), None); + } + + #[test] + fn arc_start_points_are_exact() { + let shape = NonUniformRoundedRect::new( + RECT, + radii((5.0, 3.0), (6.0, 2.0), (10.0, 10.0), (1.0, 8.0)), + ); + let starts: Vec = shape + .path_elements(0.1) + .filter_map(|el| match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => Some(p), + _ => None, + }) + .collect(); + assert_eq!( + starts, + [ + Point::new(RECT.x0, RECT.y0 + 3.0), + Point::new(RECT.x1 - 6.0, RECT.y0), + Point::new(RECT.x1, RECT.y1 - 10.0), + Point::new(RECT.x0 + 1.0, RECT.y1), + ] + ); + } + + #[test] + fn spread_grows_radii_like_css() { + let shape = NonUniformRoundedRect::new( + RECT, + radii((10.0, 10.0), (2.0, 2.0), (0.0, 0.0), (0.0, 6.0)), + ); + let spread = shape.spread(4.0); + assert_eq!(spread.rect, RECT.inflate(4.0, 4.0)); + assert_eq!(spread.radii.top_left, Vec2::new(14.0, 14.0)); + // 2 + 4 * (1 + (2 / 4 - 1)^3) + assert_eq!(spread.radii.top_right, Vec2::new(5.5, 5.5)); + assert_eq!(spread.radii.bottom_right, Vec2::ZERO); + assert_eq!(spread.radii.bottom_left, Vec2::ZERO); + } + + #[test] + fn negative_spread_shrinks_radii() { + let shape = NonUniformRoundedRect::new( + RECT, + radii((10.0, 10.0), (3.0, 3.0), (0.0, 0.0), (0.0, 0.0)), + ); + let spread = shape.spread(-4.0); + assert_eq!(spread.rect, RECT.inflate(-4.0, -4.0)); + assert_eq!(spread.radii.top_left, Vec2::new(6.0, 6.0)); + assert_eq!(spread.radii.top_right, Vec2::ZERO); + + // Shrinking past the size of the rect collapses it to its center. + let collapsed = shape.spread(-40.0); + assert_eq!( + collapsed.rect, + Rect::new(10.0, 50.0, 110.0, 50.0).inflate(-40.0, 0.0) + ); + assert_eq!(collapsed.area(), 0.0); + } + + #[test] + fn overlapping_radii_are_scaled_down() { + // The left side is 60 tall, but its radii add up to 120. + let shape = NonUniformRoundedRect::new( + RECT, + radii((10.0, 80.0), (0.0, 0.0), (0.0, 0.0), (10.0, 40.0)), + ) + .scale_overlapping_radii(); + assert_eq!(shape.radii.top_left, Vec2::new(5.0, 40.0)); + assert_eq!(shape.radii.bottom_left, Vec2::new(5.0, 20.0)); + } + + #[test] + fn path_matches_shape() { + for radii in [ + radii((0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (0.0, 0.0)), + radii((0.0, 0.0), (5.0, 5.0), (0.0, 0.0), (10.0, 4.0)), + radii((5.0, 3.0), (0.0, 0.0), (10.0, 10.0), (0.0, 0.0)), + radii((5.0, 3.0), (6.0, 2.0), (10.0, 10.0), (1.0, 8.0)), + ] { + let shape = NonUniformRoundedRect::new(RECT, radii); + let path = shape.to_path(1e-6); + assert!((path.area() - shape.area()).abs() < 1e-3, "{radii:?}"); + let path_perimeter = shape.to_path(1e-9).perimeter(1e-9); + assert!( + (path_perimeter - shape.perimeter(1e-6)).abs() < 1e-6, + "{radii:?}" + ); + for x in (5..=115).step_by(2) { + for y in (15..=85).step_by(2) { + let pt = Point::new(x as f64 + 0.25, y as f64 + 0.25); + assert_eq!(path.winding(pt), shape.winding(pt), "{radii:?} {pt:?}"); + } + } + let bbox = path.bounding_box(); + for (a, b) in [ + (bbox.x0, RECT.x0), + (bbox.y0, RECT.y0), + (bbox.x1, RECT.x1), + (bbox.y1, RECT.y1), + ] { + assert!((a - b).abs() < 1e-9, "{radii:?} {bbox:?}"); + } + } + } + + #[test] + fn sharp_corners_are_a_rect() { + let shape = + NonUniformRoundedRect::new(RECT, radii((0.0, 0.0), (5.0, 0.0), (0.0, 3.0), (0.0, 0.0))); + assert_eq!(shape.as_rect(), Some(RECT)); + assert_eq!(shape.area(), RECT.area()); + } + + #[test] + fn circular_corners_are_a_rounded_rect() { + let shape = NonUniformRoundedRect::new( + RECT, + radii((5.0, 5.0), (0.0, 0.0), (10.0, 10.0), (2.0, 0.0)), + ); + assert_eq!(shape.as_rect(), None); + assert_eq!( + shape.as_rounded_rect(), + Some(RoundedRect::from_rect( + RECT, + RoundedRectRadii::new(5.0, 0.0, 10.0, 0.0) + )) + ); + } + + #[test] + fn elliptical_corners_are_a_path() { + let shape = NonUniformRoundedRect::new( + RECT, + radii((10.0, 5.0), (8.0, 8.0), (20.0, 10.0), (0.0, 0.0)), + ); + assert_eq!(shape.as_rect(), None); + assert_eq!(shape.as_rounded_rect(), None); + + let path = shape.to_path(0.01); + assert!( + (path.area() - shape.area()).abs() < 0.5, + "{} vs {}", + path.area(), + shape.area() + ); + let bbox = path.bounding_box(); + for (a, b) in [ + (bbox.x0, RECT.x0), + (bbox.y0, RECT.y0), + (bbox.x1, RECT.x1), + (bbox.y1, RECT.y1), + ] { + assert!((a - b).abs() < 1e-9, "{bbox:?}"); + } + assert_eq!(shape.winding(Point::new(60.0, 50.0)), 1); + assert_eq!(shape.winding(Point::new(11.0, 21.0)), 0); + assert_eq!(shape.winding(Point::new(11.0, 79.0)), 1); + } +} diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index 39d3f27..0f55cb3 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -82,7 +82,7 @@ pub struct ResourceManifest { impl ResourceManifest { /// Current archive format version. Bump this when the format changes. - pub const CURRENT_VERSION: u32 = 1; + pub const CURRENT_VERSION: u32 = 2; pub fn new(tolerance: f64) -> Self { Self { diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 26893bf..a37eece 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -7,7 +7,7 @@ use anyrender::{Glyph, Paint, PaintScene}; use anyrender_serialize::{ ArchiveError, ResourceManifest, SceneArchive, SerializableRenderCommand, SerializeConfig, }; -use kurbo::{Affine, Rect, Stroke, Vec2}; +use kurbo::{Affine, Rect, RoundedRect, Stroke, Vec2}; use peniko::{ Blob, Color, Compose, Fill, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, }; @@ -96,10 +96,22 @@ fn test_all_command_types_roundtrip() { // Box shadow scene.draw_box_shadow( Affine::translate((0.0, 100.0)), - Rect::new(0.0, 0.0, 100.0, 50.0), + &RoundedRect::new(0.0, 0.0, 100.0, 50.0, 5.0).into(), + kurbo::Vec2::new(2.0, 3.0), + 4.0, + 3.0, Color::from_rgba8(0, 0, 0, 100), - 5.0, + anyrender::BoxShadowKind::Outset { clip_to_box: true }, + ); + // Inset box shadow + scene.draw_box_shadow( + Affine::translate((120.0, 100.0)), + &RoundedRect::new(0.0, 0.0, 100.0, 50.0, 5.0).into(), + kurbo::Vec2::new(-2.0, 1.0), + 4.0, 3.0, + Color::from_rgba8(0, 0, 0, 100), + anyrender::BoxShadowKind::Inset, ); // Clip layer @@ -345,7 +357,7 @@ fn test_font_deduplication() { #[test] fn test_resource_manifest_version() { - assert_eq!(ResourceManifest::CURRENT_VERSION, 1); + assert_eq!(ResourceManifest::CURRENT_VERSION, 2); } #[test] @@ -369,7 +381,7 @@ fn test_archive_contains_expected_files() { .read_to_string(&mut resources_json) .unwrap(); let manifest: ResourceManifest = serde_json::from_str(&resources_json).unwrap(); - assert_eq!(manifest.version, 1); + assert_eq!(manifest.version, 2); assert!(manifest.images.is_empty()); assert!(manifest.fonts.is_empty()); diff --git a/crates/anyrender_skia/src/scene.rs b/crates/anyrender_skia/src/scene.rs index aea443f..c8f0d75 100644 --- a/crates/anyrender_skia/src/scene.rs +++ b/crates/anyrender_skia/src/scene.rs @@ -12,7 +12,7 @@ use peniko::color::AlphaColor; use skia_safe::{ BlendMode as SkBlendMode, BlurStyle, Canvas, Color, ColorSpace, Font, FontArguments, FontHinting, FontMgr, GlyphId, ImageFilter, MaskFilter, Paint, PaintCap, PaintJoin, PaintStyle, - PathEffect, Point, Point3, RRect, Rect, Shader, Typeface, + PathEffect, Point, Point3, RRect, Shader, Typeface, canvas::{GlyphPositions, SaveLayerRec}, font::Edging, font_arguments::{VariationPosition, variation_position::Coordinate}, @@ -562,37 +562,55 @@ impl PaintScene for SkiaScenePainter<'_> { fn draw_box_shadow( &mut self, transform: kurbo::Affine, - rect: kurbo::Rect, - brush: peniko::Color, - radius: f64, + box_shape: &anyrender::NonUniformRoundedRect, + offset: kurbo::Vec2, + spread: f64, std_dev: f64, + brush: peniko::Color, + kind: anyrender::BoxShadowKind, ) { + let geometry = anyrender::BoxShadowGeometry::new(box_shape, offset, spread, std_dev, kind); + let box_rrect = sk_kurbo::rrect_from_non_uniform(box_shape); + + self.inner.save(); self.set_matrix(transform); + match kind { + anyrender::BoxShadowKind::Outset { clip_to_box: true } => { + self.inner + .clip_rrect(box_rrect, skia_safe::ClipOp::Difference, true); + } + anyrender::BoxShadowKind::Outset { clip_to_box: false } => {} + anyrender::BoxShadowKind::Inset => { + self.inner + .clip_rrect(box_rrect, skia_safe::ClipOp::Intersect, true); + } + } self.reset_paint(); self.set_paint_brush(brush, None); self.cache.paint.set_style(PaintStyle::Fill); - - if std_dev > 0.0 { + if geometry.std_dev > 0.0 { self.cache.paint.set_mask_filter( - MaskFilter::blur(BlurStyle::Normal, std_dev as f32, true).unwrap(), + MaskFilter::blur(BlurStyle::Normal, geometry.std_dev as f32, true).unwrap(), ); } - let rrect = RRect::new_nine_patch( - Rect::new( - rect.x0 as f32, - rect.y0 as f32, - rect.x1 as f32, - rect.y1 as f32, - ), - radius as f32, - radius as f32, - radius as f32, - radius as f32, - ); + let shadow = sk_kurbo::rrect_from_non_uniform(&geometry.shadow); + if geometry.is_inset() { + // Paint the blurred inverse of the hole as a ring whose outer edge is far enough + // outside of the box that it isn't affected by the blur. + let margin = 3.0 * geometry.std_dev + 1.0; + let outer = box_shape + .rect + .union(geometry.shadow.rect) + .inflate(margin, margin); + let outer = RRect::new_rect(sk_kurbo::rect_from(outer)); + self.inner.draw_drrect(outer, shadow, &self.cache.paint); + } else { + self.inner.draw_rrect(shadow, &self.cache.paint); + } - self.inner.draw_rrect(rrect, &self.cache.paint); + self.inner.restore(); } } @@ -1268,6 +1286,27 @@ mod sk_kurbo { ) } + pub(super) fn rrect_from_non_uniform(shape: &anyrender::NonUniformRoundedRect) -> SkRRect { + let radius = |r: kurbo::Vec2| { + if r.x <= 0.0 || r.y <= 0.0 { + SkPoint::new(0.0, 0.0) + } else { + SkPoint::new(r.x as f32, r.y as f32) + } + }; + let radii = &shape.radii; + // Corner order: upper-left, upper-right, lower-right, lower-left + SkRRect::new_rect_radii( + rect_from(shape.rect), + &[ + radius(radii.top_left), + radius(radii.top_right), + radius(radii.bottom_right), + radius(radii.bottom_left), + ], + ) + } + pub(super) fn rrect_from(rrect: RoundedRect) -> SkRRect { let rect = rect_from(rrect.rect()); let radii = rrect.radii(); diff --git a/crates/anyrender_vello/src/scene.rs b/crates/anyrender_vello/src/scene.rs index 2286a79..823ef4d 100644 --- a/crates/anyrender_vello/src/scene.rs +++ b/crates/anyrender_vello/src/scene.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyrender::{Filter, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext, ResourceId}; -use kurbo::{Affine, Rect, Shape, Stroke}; +use kurbo::{Affine, Shape, Stroke}; use peniko::{BlendMode, BrushRef, Color, Fill, FontData, ImageBrush, ImageData, StyleRef}; use rustc_hash::FxHashMap; use vello::Renderer as VelloRenderer; @@ -175,12 +175,30 @@ impl PaintScene for VelloScenePainter<'_, '_> { fn draw_box_shadow( &mut self, transform: Affine, - rect: Rect, - brush: Color, - radius: f64, + box_shape: &anyrender::NonUniformRoundedRect, + offset: kurbo::Vec2, + spread: f64, std_dev: f64, + color: Color, + kind: anyrender::BoxShadowKind, ) { - self.inner - .draw_blurred_rounded_rect(transform, rect, brush, radius, std_dev); + let geometry = anyrender::BoxShadowGeometry::new(box_shape, offset, spread, std_dev, kind); + if geometry.std_dev == 0.0 { + geometry.draw_unblurred(self, transform, color); + return; + } + if geometry.is_inset() { + geometry.draw_inset_with_layers(self, transform, color); + return; + } + // TODO: draw shadows with matching individual radii instead of averaging them + self.inner.draw_blurred_rounded_rect_in( + &geometry.area, + transform, + geometry.shadow.rect, + color, + geometry.shadow.average_radius(), + geometry.std_dev, + ); } } diff --git a/crates/anyrender_vello_cpu/src/scene.rs b/crates/anyrender_vello_cpu/src/scene.rs index dfa8435..426f59a 100644 --- a/crates/anyrender_vello_cpu/src/scene.rs +++ b/crates/anyrender_vello_cpu/src/scene.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyrender::{Filter, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; use glifo::FontEmbolden; -use kurbo::{Affine, Diagonal2, Rect, Shape, Stroke}; +use kurbo::{Affine, Diagonal2, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, ImageBrush, StyleRef}; use vello_cpu::{PaintType, Pixmap}; @@ -255,16 +255,49 @@ impl PaintScene for VelloCpuScenePainter { fn draw_box_shadow( &mut self, transform: Affine, - rect: Rect, - color: Color, - radius: f64, + box_shape: &anyrender::NonUniformRoundedRect, + offset: kurbo::Vec2, + spread: f64, std_dev: f64, + color: Color, + kind: anyrender::BoxShadowKind, ) { + let geometry = anyrender::BoxShadowGeometry::new(box_shape, offset, spread, std_dev, kind); self.render_ctx.set_transform(transform); self.render_ctx.set_paint(PaintType::Solid(color)); self.render_ctx.reset_paint_transform(); - self.render_ctx - .fill_blurred_rounded_rect(&rect, radius as f32, std_dev as f32, false); + self.render_ctx.set_fill_rule(Fill::NonZero); + if geometry.std_dev == 0.0 { + // A single fill, so a non-isolated clip anti-aliases the clip edge only once. + let clip = geometry.needs_clip(); + if clip { + self.render_ctx.push_clip_path(&geometry.area); + } + self.render_ctx.fill_path(&geometry.unblurred_path()); + if clip { + self.render_ctx.pop_clip(); + } + return; + } + if geometry.is_inset() { + geometry.draw_inset_with_layers(self, transform, color); + return; + } + // A single fill, so a non-isolated clip anti-aliases the clip edge only once. + let clip = geometry.needs_clip(); + if clip { + self.render_ctx.push_clip_path(&geometry.area); + } + // TODO: draw shadows with matching individual radii instead of averaging them + self.render_ctx.fill_blurred_rounded_rect( + &geometry.shadow.rect, + geometry.shadow.average_radius() as f32, + geometry.std_dev as f32, + false, + ); + if clip { + self.render_ctx.pop_clip(); + } } } @@ -422,3 +455,96 @@ mod clip_rule_tests { assert_pixels(true, true); } } + +#[cfg(test)] +mod box_shadow_tests { + use anyrender::{BoxShadowKind, NonUniformRoundedRect, PaintScene, render_to_buffer}; + use kurbo::{Affine, Rect, Vec2}; + use peniko::color::palette::css::RED; + + use crate::VelloCpuImageRenderer; + + fn render_unblurred(box_rect: Rect, offset: Vec2, spread: f64, kind: BoxShadowKind) -> Vec { + render_to_buffer::( + |scene| { + scene.draw_box_shadow( + Affine::IDENTITY, + &NonUniformRoundedRect::from(box_rect), + offset, + spread, + 0.0, + RED, + kind, + ); + }, + 100, + 100, + ) + } + + fn alpha(buffer: &[u8], x: usize, y: usize) -> u8 { + buffer[(y * 100 + x) * 4 + 3] + } + + #[test] + fn unblurred_outset_shadow_is_clipped_to_outside_of_box() { + let kind = BoxShadowKind::Outset { clip_to_box: true }; + let buffer = render_unblurred( + Rect::new(20.0, 20.0, 60.0, 60.0), + Vec2::new(10.0, 10.0), + 2.0, + kind, + ); + assert_eq!(alpha(&buffer, 65, 65), 255); + assert_eq!(alpha(&buffer, 40, 40), 0); + assert_eq!(alpha(&buffer, 15, 15), 0); + assert_eq!(alpha(&buffer, 75, 75), 0); + + let kind = BoxShadowKind::Outset { clip_to_box: false }; + let buffer = render_unblurred(Rect::new(20.0, 20.0, 60.0, 60.0), Vec2::ZERO, 0.0, kind); + assert_eq!(alpha(&buffer, 40, 40), 255); + } + + #[test] + fn blurred_shadows_are_clipped_to_box() { + let render = |kind| { + render_to_buffer::( + |scene| { + scene.draw_box_shadow( + Affine::IDENTITY, + &NonUniformRoundedRect::from(Rect::new(20.0, 20.0, 80.0, 80.0)), + Vec2::ZERO, + 10.0, + 2.0, + RED, + kind, + ); + }, + 100, + 100, + ) + }; + let inset = render(BoxShadowKind::Inset); + assert!(alpha(&inset, 22, 50) > 250); + assert_eq!(alpha(&inset, 50, 50), 0); + assert_eq!(alpha(&inset, 15, 50), 0); + + let outset = render(BoxShadowKind::Outset { clip_to_box: true }); + assert!(alpha(&outset, 15, 50) > 250); + assert_eq!(alpha(&outset, 50, 50), 0); + assert_eq!(alpha(&outset, 2, 50), 0); + } + + #[test] + fn unblurred_inset_shadow_surrounds_hole() { + let buffer = render_unblurred( + Rect::new(20.0, 20.0, 80.0, 80.0), + Vec2::ZERO, + 10.0, + BoxShadowKind::Inset, + ); + assert_eq!(alpha(&buffer, 25, 50), 255); + assert_eq!(alpha(&buffer, 50, 50), 0); + assert_eq!(alpha(&buffer, 10, 10), 0); + } +} diff --git a/crates/anyrender_vello_hybrid/src/scene.rs b/crates/anyrender_vello_hybrid/src/scene.rs index 850a038..4cc7da8 100644 --- a/crates/anyrender_vello_hybrid/src/scene.rs +++ b/crates/anyrender_vello_hybrid/src/scene.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyrender::{Filter, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext, ResourceId}; use glifo::FontEmbolden; -use kurbo::{Affine, Diagonal2, Rect, Shape, Stroke}; +use kurbo::{Affine, Diagonal2, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, ImageBrush, ImageData, StyleRef}; use rustc_hash::FxHashMap; use vello_common::{ @@ -365,15 +365,48 @@ impl PaintScene for VelloHybridScenePainter<'_> { fn draw_box_shadow( &mut self, transform: Affine, - rect: Rect, - color: Color, - radius: f64, + box_shape: &anyrender::NonUniformRoundedRect, + offset: kurbo::Vec2, + spread: f64, std_dev: f64, + color: Color, + kind: anyrender::BoxShadowKind, ) { + let geometry = anyrender::BoxShadowGeometry::new(box_shape, offset, spread, std_dev, kind); self.scene.set_transform(transform); self.scene.set_paint(PaintType::Solid(color)); self.scene.reset_paint_transform(); - self.scene - .fill_blurred_rounded_rect(&rect, radius as f32, std_dev as f32, false); + self.scene.set_fill_rule(Fill::NonZero); + if geometry.std_dev == 0.0 { + // A single fill, so a non-isolated clip anti-aliases the clip edge only once. + let clip = geometry.needs_clip(); + if clip { + self.scene.push_clip_path(&geometry.area); + } + self.scene.fill_path(&geometry.unblurred_path()); + if clip { + self.scene.pop_clip(); + } + return; + } + if geometry.is_inset() { + geometry.draw_inset_with_layers(self, transform, color); + return; + } + // A single fill, so a non-isolated clip anti-aliases the clip edge only once. + let clip = geometry.needs_clip(); + if clip { + self.scene.push_clip_path(&geometry.area); + } + // TODO: draw shadows with matching individual radii instead of averaging them + self.scene.fill_blurred_rounded_rect( + &geometry.shadow.rect, + geometry.shadow.average_radius() as f32, + geometry.std_dev as f32, + false, + ); + if clip { + self.scene.pop_clip(); + } } } diff --git a/crates/anyrender_vello_hybrid/src/webgl_scene.rs b/crates/anyrender_vello_hybrid/src/webgl_scene.rs index 4be63ea..b31bb51 100644 --- a/crates/anyrender_vello_hybrid/src/webgl_scene.rs +++ b/crates/anyrender_vello_hybrid/src/webgl_scene.rs @@ -2,7 +2,7 @@ use anyrender::{Filter, Glyph, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; use glifo::FontEmbolden; -use kurbo::{Affine, Diagonal2, Rect, Shape, Stroke}; +use kurbo::{Affine, Diagonal2, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, StyleRef}; use vello_common::paint::PaintType; @@ -267,15 +267,48 @@ impl PaintScene for WebGlScenePainter<'_> { fn draw_box_shadow( &mut self, transform: Affine, - rect: Rect, - color: Color, - radius: f64, + box_shape: &anyrender::NonUniformRoundedRect, + offset: kurbo::Vec2, + spread: f64, std_dev: f64, + color: Color, + kind: anyrender::BoxShadowKind, ) { + let geometry = anyrender::BoxShadowGeometry::new(box_shape, offset, spread, std_dev, kind); self.scene.set_transform(transform); self.scene.set_paint(PaintType::Solid(color)); self.scene.reset_paint_transform(); - self.scene - .fill_blurred_rounded_rect(&rect, radius as f32, std_dev as f32, false); + self.scene.set_fill_rule(Fill::NonZero); + if geometry.std_dev == 0.0 { + // A single fill, so a non-isolated clip anti-aliases the clip edge only once. + let clip = geometry.needs_clip(); + if clip { + self.scene.push_clip_path(&geometry.area); + } + self.scene.fill_path(&geometry.unblurred_path()); + if clip { + self.scene.pop_clip(); + } + return; + } + if geometry.is_inset() { + geometry.draw_inset_with_layers(self, transform, color); + return; + } + // A single fill, so a non-isolated clip anti-aliases the clip edge only once. + let clip = geometry.needs_clip(); + if clip { + self.scene.push_clip_path(&geometry.area); + } + // TODO: draw shadows with matching individual radii instead of averaging them + self.scene.fill_blurred_rounded_rect( + &geometry.shadow.rect, + geometry.shadow.average_radius() as f32, + geometry.std_dev as f32, + false, + ); + if clip { + self.scene.pop_clip(); + } } } diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 171b21a..3c9921b 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -74,11 +74,13 @@ fn create_demo_scene() -> Scene { let rounded_card = RoundedRect::from_rect(card_rect, 12.0); scene.draw_box_shadow( - Affine::translate((4.0, 4.0)), - card_rect, - Color::from_rgba8(0, 0, 0, 60), - 12.0, + Affine::IDENTITY, + &rounded_card.into(), + kurbo::Vec2::new(4.0, 4.0), + 0.0, 8.0, + Color::from_rgba8(0, 0, 0, 60), + anyrender::BoxShadowKind::Outset { clip_to_box: false }, ); // Card background From 5863bc787e2101cfac98028d954cbcc92b32c789 Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Sat, 3 Oct 2026 23:00:13 +0000 Subject: [PATCH 2/2] Use an isolated layer for the layered inset shadow fallback --- crates/anyrender/src/box_shadow.rs | 12 ++++++++- crates/anyrender_vello_cpu/src/scene.rs | 35 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/anyrender/src/box_shadow.rs b/crates/anyrender/src/box_shadow.rs index c388a47..60ee4c4 100644 --- a/crates/anyrender/src/box_shadow.rs +++ b/crates/anyrender/src/box_shadow.rs @@ -96,7 +96,17 @@ impl BoxShadowGeometry { transform: Affine, brush: Color, ) { - scene.push_clip_layer(Fill::NonZero, transform, &self.box_shape); + // An isolated layer (not a clip, which may not be isolated) so that the `DestOut` layer + // only cuts the hole out of the shadow, not out of what is underneath it. + scene.push_layer( + Fill::NonZero, + Mix::Normal, + 1.0, + transform, + &self.box_shape, + None, + None, + ); scene.fill(Fill::NonZero, transform, brush, None, &self.box_shape); scene.push_layer( Fill::NonZero, diff --git a/crates/anyrender_vello_cpu/src/scene.rs b/crates/anyrender_vello_cpu/src/scene.rs index 426f59a..c130889 100644 --- a/crates/anyrender_vello_cpu/src/scene.rs +++ b/crates/anyrender_vello_cpu/src/scene.rs @@ -535,6 +535,41 @@ mod box_shadow_tests { assert_eq!(alpha(&outset, 2, 50), 0); } + #[test] + fn blurred_inset_shadow_keeps_background() { + for box_shape in [ + NonUniformRoundedRect::from(Rect::new(20.0, 20.0, 80.0, 80.0)), + NonUniformRoundedRect::from(kurbo::RoundedRect::new(20.0, 20.0, 80.0, 80.0, 8.0)), + ] { + let buffer = render_to_buffer::( + |scene| { + scene.fill( + peniko::Fill::NonZero, + Affine::IDENTITY, + peniko::color::palette::css::WHITE, + None, + &Rect::new(0.0, 0.0, 100.0, 100.0), + ); + scene.draw_box_shadow( + Affine::IDENTITY, + &box_shape, + Vec2::ZERO, + 5.0, + 2.0, + RED, + BoxShadowKind::Inset, + ); + }, + 100, + 100, + ); + let center = (50 * 100 + 50) * 4; + assert_eq!(&buffer[center..center + 4], &[255, 255, 255, 255]); + let edge = (50 * 100 + 21) * 4; + assert!(buffer[edge + 1] < 8 && buffer[edge + 2] < 8 && buffer[edge + 3] == 255); + } + } + #[test] fn unblurred_inset_shadow_surrounds_hole() { let buffer = render_unblurred(