🐛 Fix groups, masks and booleans drop shadow cases (#11911)

* 🐛 Fix blocky and black drop shadows on masked groups

* 🐛 Fix drop shadow spread on paths, bools and circles
This commit is contained in:
Elena Torró 2026-09-25 11:50:19 +02:00 committed by GitHub
parent fa81a3f648
commit 63baf86152
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 250 additions and 17 deletions

View File

@ -431,6 +431,9 @@ pub(crate) struct RenderState {
pub nested_fills: Vec<Vec<Fill>>, pub nested_fills: Vec<Vec<Fill>>,
pub nested_blurs: Vec<Option<Blur>>, // FIXME: why is this an option? pub nested_blurs: Vec<Option<Blur>>, // FIXME: why is this an option?
pub nested_shadows: Vec<Vec<Shadow>>, pub nested_shadows: Vec<Vec<Shadow>>,
/// Cumulative [`Shape::masked_group_filter_reach`] of the masked groups
/// being walked: their children cast into tiles they don't touch.
masked_group_reach: Vec<f32>,
pub show_grid: Option<Uuid>, pub show_grid: Option<Uuid>,
pub focus_mode: FocusMode, pub focus_mode: FocusMode,
/// Viewer-only whitelist for fixed-scroll layer passes. /// Viewer-only whitelist for fixed-scroll layer passes.
@ -656,6 +659,7 @@ impl RenderState {
nested_fills: vec![], nested_fills: vec![],
nested_blurs: vec![], nested_blurs: vec![],
nested_shadows: vec![], nested_shadows: vec![],
masked_group_reach: vec![],
show_grid: None, show_grid: None,
focus_mode: FocusMode::new(), focus_mode: FocusMode::new(),
include_filter: None, include_filter: None,
@ -2435,6 +2439,7 @@ impl RenderState {
self.nested_fills.clear(); self.nested_fills.clear();
self.nested_blurs.clear(); self.nested_blurs.clear();
self.nested_shadows.clear(); self.nested_shadows.clear();
self.masked_group_reach.clear();
// reorder by distance to the center. // reorder by distance to the center.
self.current_tile = None; self.current_tile = None;
@ -2731,6 +2736,7 @@ impl RenderState {
let saved_nested_fills = std::mem::take(&mut self.nested_fills); let saved_nested_fills = std::mem::take(&mut self.nested_fills);
let saved_nested_blurs = std::mem::take(&mut self.nested_blurs); let saved_nested_blurs = std::mem::take(&mut self.nested_blurs);
let saved_nested_shadows = std::mem::take(&mut self.nested_shadows); let saved_nested_shadows = std::mem::take(&mut self.nested_shadows);
let saved_masked_group_reach = std::mem::take(&mut self.masked_group_reach);
let saved_ignore_nested_blurs = self.ignore_nested_blurs; let saved_ignore_nested_blurs = self.ignore_nested_blurs;
let saved_preview_mode = self.preview_mode; let saved_preview_mode = self.preview_mode;
@ -2802,6 +2808,7 @@ impl RenderState {
self.nested_fills = saved_nested_fills; self.nested_fills = saved_nested_fills;
self.nested_blurs = saved_nested_blurs; self.nested_blurs = saved_nested_blurs;
self.nested_shadows = saved_nested_shadows; self.nested_shadows = saved_nested_shadows;
self.masked_group_reach = saved_masked_group_reach;
self.ignore_nested_blurs = saved_ignore_nested_blurs; self.ignore_nested_blurs = saved_ignore_nested_blurs;
self.preview_mode = saved_preview_mode; self.preview_mode = saved_preview_mode;
@ -2954,6 +2961,10 @@ impl RenderState {
} }
if group.masked { if group.masked {
let reach = self.masked_group_reach.last().copied().unwrap_or(0.0)
+ element.masked_group_filter_reach();
self.masked_group_reach.push(reach);
// A masked group's blur and shadows are applied as a single // A masked group's blur and shadows are applied as a single
// image filter over the whole masked result. // image filter over the whole masked result.
let scale = self.get_scale(); let scale = self.get_scale();
@ -3052,6 +3063,7 @@ impl RenderState {
// the blend mode 'destination-in') the content // the blend mode 'destination-in') the content
// of the group and the mask. // of the group and the mask.
if group.masked { if group.masked {
self.masked_group_reach.pop();
self.pending_nodes.push(NodeRenderState { self.pending_nodes.push(NodeRenderState {
id: element.id, id: element.id,
visited_children: true, visited_children: true,
@ -3530,6 +3542,13 @@ impl RenderState {
// (which defers strokes to render_shape_exit for clipped frames). // (which defers strokes to render_shape_exit for clipped frames).
plain_shape_mut.clip_content = false; plain_shape_mut.clip_content = false;
let spread_outset = if shadow.spread > 0.0 && shape.spreads_through_strokes() {
plain_shape_mut.apply_shadow_spread(shadow.spread);
None
} else {
Some(shadow.spread)
};
let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else { let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else {
return Ok(()); return Ok(());
}; };
@ -3561,7 +3580,7 @@ impl RenderState {
false, false,
Some(shadow.offset), Some(shadow.offset),
None, None,
Some(shadow.spread), spread_outset,
target_surface, target_surface,
false, false,
) )
@ -3605,7 +3624,7 @@ impl RenderState {
false, false,
Some(shadow.offset), // Offset is geometric Some(shadow.offset), // Offset is geometric
None, None,
Some(shadow.spread), spread_outset,
target_surface, target_surface,
false, false,
) )
@ -3669,7 +3688,7 @@ impl RenderState {
false, false,
Some(shadow.offset), // Offset is geometric Some(shadow.offset), // Offset is geometric
None, None,
Some(shadow.spread), spread_outset,
target_surface, target_surface,
false, false,
) )
@ -3944,17 +3963,21 @@ impl RenderState {
); );
let has_effects = transformed_element.has_effects_that_extend_bounds(); let has_effects = transformed_element.has_effects_that_extend_bounds();
let area = match self.masked_group_reach.last() {
Some(&reach) => self.render_area_with_margins.with_outset((reach, reach)),
None => self.render_area_with_margins,
};
let is_visible = export let is_visible = export
|| mask || mask
|| if is_container || has_effects { || if is_container || has_effects {
let element_extrect = let element_extrect =
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale)); extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
element_extrect.intersects(self.render_area_with_margins) element_extrect.intersects(area)
&& !transformed_element.visually_insignificant(scale, tree) && !transformed_element.visually_insignificant(scale, tree)
} else { } else {
let selrect = transformed_element.selrect(); let selrect = transformed_element.selrect();
selrect.intersects(self.render_area_with_margins) selrect.intersects(area)
&& !transformed_element.visually_insignificant(scale, tree) && !transformed_element.visually_insignificant(scale, tree)
}; };

View File

@ -1,5 +1,6 @@
use skia_safe::{self as skia}; use skia_safe::{self as skia};
use std::borrow::Cow;
use std::collections::HashSet; use std::collections::HashSet;
use crate::error::Result; use crate::error::Result;
@ -287,7 +288,13 @@ fn render_leaf_geometry(
) -> Result<()> { ) -> Result<()> {
let spread = builder.silhouette_spread; let spread = builder.silhouette_spread;
// Spread outsets fills only (GPU). Rect/Frame strokes ignore outset. // Spread outsets fills only (GPU). Rect/Frame strokes ignore outset.
let fill_shape = shape_with_selrect_outset(element, spread); // Paths and circles spread through strokes instead (see below).
let fill_outset = if element.spreads_through_strokes() {
0.0
} else {
spread
};
let fill_shape = shape_with_selrect_outset(element, fill_outset);
// Always from the original element (not outset selrect) so the pivot // Always from the original element (not outset selrect) so the pivot
// matches content; offset comes from the silhouette pass. // matches content; offset comes from the silhouette pass.
let draw_matrix = builder.silhouette_draw_matrix(element); let draw_matrix = builder.silhouette_draw_matrix(element);
@ -313,13 +320,18 @@ fn render_leaf_geometry(
)?; )?;
// Stroke geometry stays on the original selrect (GPU Rect/Frame // Stroke geometry stays on the original selrect (GPU Rect/Frame
// drop-shadow outset is a no-op for single strokes). // drop-shadow outset is a no-op for single strokes). Paths and circles
let visible_strokes: Vec<_> = element.visible_strokes().collect(); // spread through widened strokes, as on the GPU.
let mut stroke_shape = Cow::Borrowed(element);
if spread > 0.0 && element.spreads_through_strokes() {
stroke_shape.to_mut().apply_shadow_spread(spread);
}
let visible_strokes: Vec<_> = stroke_shape.visible_strokes().collect();
if !visible_strokes.is_empty() { if !visible_strokes.is_empty() {
emit_strokes( emit_strokes(
builder, builder,
shared, shared,
element, &stroke_shape,
&visible_strokes, &visible_strokes,
scale, scale,
Some(draw_matrix), Some(draw_matrix),

View File

@ -6,7 +6,8 @@ expression: svg
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="130" viewBox="0 0 160 130"><defs><mask id="mask0" maskUnits="userSpaceOnUse" mask-type="alpha"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="130" viewBox="0 0 160 130"><defs><mask id="mask0" maskUnits="userSpaceOnUse" mask-type="alpha">
<rect width="160" height="130"/> <rect width="160" height="130"/>
</mask><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="160" height="130" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g mask="url(#mask0)"><g filter="url(#fx1)"> </mask><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="160" height="130" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g mask="url(#mask0)"><g filter="url(#fx1)">
<ellipse fill="#FFD166" cx="80" cy="70" rx="66" ry="66"/> <ellipse fill="#FFD166" cx="80" cy="70" rx="50" ry="50"/>
<ellipse fill="none" stroke="black" stroke-width="32" stroke-miterlimit="4" cx="80" cy="70" rx="50" ry="50"/>
</g> </g>
<ellipse fill="#FFD166" cx="80" cy="70" rx="50" ry="50"/> <ellipse fill="#FFD166" cx="80" cy="70" rx="50" ry="50"/>
</g></svg> </g></svg>

View File

@ -0,0 +1,11 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="260" height="260" viewBox="0 0 260 260"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="260" height="260" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<path fill="#3D7BFF" transform="translate(80 80)" d="M0 0L100 0L100 100L0 100L0 0Z"/>
<path fill="none" stroke="black" stroke-width="40" stroke-miterlimit="4" transform="translate(80 80)" d="M0 0L100 0L100 100L0 100L0 0Z"/>
</g>
<path fill="#3D7BFF" transform="translate(80 80)" d="M0 0L100 0L100 100L0 100L0 0Z"/>
</svg>

View File

@ -1423,6 +1423,33 @@ fn exports_masked_group_as_alpha_mask() {
insta::assert_snapshot!(svg); insta::assert_snapshot!(svg);
} }
#[test]
fn path_leaf_drop_spread_strokes_the_silhouette() {
// Paths had no spread at all: the selrect outset is a no-op for them.
let mut pool = ShapesPool::new();
let id = uid(1);
add_empty_fill_closed_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 100.0, 100.0));
let shape = pool.get_mut(&id).unwrap();
shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb(
61, 123, 255,
)))]);
shape.add_shadow(Shadow::new(
skia::Color::from_rgb(255, 0, 0),
0.0,
20.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
let svg = render(&pool, id);
assert!(
!svg.contains("feMorphology"),
"leaf drop spread must stay geometric: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test] #[test]
fn masked_leaf_drop_spread_uses_geometric_silhouette() { fn masked_leaf_drop_spread_uses_geometric_silhouette() {
// Case 11 style: masked group + circle content with drop spread 16 blur 0. // Case 11 style: masked group + circle content with drop spread 16 blur 0.
@ -1474,7 +1501,7 @@ fn masked_leaf_drop_spread_uses_geometric_silhouette() {
!svg.contains("feMorphology"), !svg.contains("feMorphology"),
"leaf drop spread must not use feMorphology: {svg}" "leaf drop spread must not use feMorphology: {svg}"
); );
// 100×100 circle + 2×16 spread → 132×132 silhouette ellipse. // 100×100 circle + 32px center stroke → 132×132 silhouette.
let filter_open = svg.find("filter=\"url(#fx").expect("drop filter"); let filter_open = svg.find("filter=\"url(#fx").expect("drop filter");
let filter_close = svg[filter_open..] let filter_close = svg[filter_open..]
.find("</g>") .find("</g>")
@ -1482,9 +1509,8 @@ fn masked_leaf_drop_spread_uses_geometric_silhouette() {
.expect("silhouette group close"); .expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close]; let silhouette = &svg[filter_open..=filter_close];
assert!( assert!(
silhouette.contains("<ellipse") silhouette.contains(r#"rx="50""#) && silhouette.contains(r#"stroke-width="32""#),
&& (silhouette.contains(r#"rx="66""#) || silhouette.contains(r#"rx=\"66\""#)), "drop silhouette must be the rx=50 ellipse stroked by 2×spread: {silhouette}"
"drop silhouette must be a geometrically outset ellipse (rx=66): {silhouette}"
); );
// True-size content ellipse remains under the mask. // True-size content ellipse remains under the mask.
assert!( assert!(

View File

@ -708,6 +708,29 @@ impl Shape {
self.strokes.push(s) self.strokes.push(s)
} }
/// Whether a drop-shadow spread grows this shape through its strokes
/// ([`Self::apply_shadow_spread`]) instead of a geometric fill outset.
pub fn spreads_through_strokes(&self) -> bool {
matches!(
self.shape_type,
Type::Path(_) | Type::Bool(_) | Type::Circle
)
}
/// Offsets the shape outline by `spread` with the shape's own joins:
/// every stroke widens by `spread` on each side and a black `2·spread`
/// center stroke grows the fill.
pub fn apply_shadow_spread(&mut self, spread: f32) {
let is_open = self.is_open();
for stroke in self.strokes.iter_mut() {
stroke.grow_by_spread(spread, is_open);
}
let mut outline =
Stroke::new_center_stroke(2.0 * spread, StrokeStyle::Solid, None, None, None, None);
outline.fill = Fill::Solid(SolidColor(skia::Color::BLACK));
self.add_stroke(outline);
}
pub fn set_last_stroke_widths(&mut self, widths: [f32; 4]) -> Result<(), String> { pub fn set_last_stroke_widths(&mut self, widths: [f32; 4]) -> Result<(), String> {
let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?; let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
stroke.widths = Some(widths); stroke.widths = Some(widths);
@ -974,12 +997,22 @@ impl Shape {
} }
fn apply_shadow_bounds(&self, bounds: Bounds) -> Bounds { fn apply_shadow_bounds(&self, bounds: Bounds) -> Bounds {
// A path spread is a mitered stroke (`apply_shadow_spread`): its tips
// reach up to Skia's miter limit (4) half-widths from the vertex.
const MITER_LIMIT: f32 = 4.0;
let mitered = matches!(self.shape_type, Type::Path(_) | Type::Bool(_));
let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open());
let mut rect = bounds.to_rect(); let mut rect = bounds.to_rect();
for shadow in self.shadows_visible() { for shadow in self.shadows_visible() {
if !shadow.hidden() { if !shadow.hidden() {
if let Some(filter) = shadow.get_drop_shadow_filter() { if let Some(filter) = shadow.get_drop_shadow_filter() {
let shadow_bounds = filter.compute_fast_bounds(rect); let mut source = rect;
rect.join(shadow_bounds); if mitered && shadow.spread > 0.0 {
let tip = (MITER_LIMIT - 1.0) * (max_stroke + shadow.spread);
source.outset((tip, tip));
}
rect.join(filter.compute_fast_bounds(source));
} }
} }
} }
@ -1882,6 +1915,26 @@ impl Shape {
} }
} }
/// How far the masked-group layer filter reaches past the content, in
/// document units: the widest drop shadow plus the layer blur.
pub fn masked_group_filter_reach(&self) -> f32 {
let reach = |filter: Option<skia::ImageFilter>| {
filter.map_or(0.0, |f| {
let r = f.compute_fast_bounds(math::Rect::default());
(-r.left).max(-r.top).max(r.right).max(r.bottom)
})
};
let shadows = self
.drop_shadows_visible()
.map(|shadow| reach(shadow.get_drop_shadow_filter()))
.fold(0.0, f32::max);
let blur = self.masked_group_layer_blur().map_or(0.0, |blur| {
let sigma = radius_to_sigma(blur.value);
reach(skia::image_filters::blur((sigma, sigma), None, None, None))
});
shadows + blur
}
/// Shadows of the given style that the masked-group layer filter must /// Shadows of the given style that the masked-group layer filter must
/// carry, bottom-most first, already converted to device space. /// carry, bottom-most first, already converted to device space.
/// ///
@ -1936,7 +1989,7 @@ impl Shape {
if !skip_shadows { if !skip_shadows {
for shadow in self.masked_group_layer_shadows(scale, ShadowStyle::Drop) { for shadow in self.masked_group_layer_shadows(scale, ShadowStyle::Drop) {
layers.push(shadow.get_drop_shadow_filter()); layers.push(shadow.get_layer_drop_shadow_filter());
} }
} }

View File

@ -104,6 +104,45 @@ impl Shadow {
filter filter
} }
/// Same shadow as [`Self::get_drop_shadow_filter`], for a `merge` input.
/// Skia defers color filters and offsets, and `merge` can draw them
/// unresolved in 8×8 cells (untinted source, or opaque black). A `blend`
/// always renders, so the tint is a SrcIn blend of the color over the
/// offset source.
pub fn get_layer_drop_shadow_filter(&self) -> Option<ImageFilter> {
let color = image_filters::shader(skia::shaders::color(self.color), None);
let offset = image_filters::offset((self.offset.0, self.offset.1), None, None);
let mut filter = image_filters::blend(skia::BlendMode::SrcIn, offset, color, None);
// Spread before blur, as in CSS and SVG. Dilating the blur instead
// works on Skia's downscaled blur output and comes out blocky.
if self.spread > 0. {
// Masked-group shadows are already in device space.
filter = Self::chained_dilate(self.spread, filter);
}
let sigma = radius_to_sigma(self.blur);
if sigma > 0.0 {
filter = image_filters::blur((sigma, sigma), None, filter, None);
}
filter
}
/// Square dilate by a device-space `radius`, split into steps Skia will
/// not clamp. Skia caps a single morphology radius at 256 px; square
/// dilates add up exactly, so chaining keeps the full spread.
fn chained_dilate(radius: f32, input: Option<ImageFilter>) -> Option<ImageFilter> {
const MAX_RADIUS: f32 = 255.0;
let steps = (radius / MAX_RADIUS).ceil().max(1.0) as usize;
let step = radius / steps as f32;
let mut filter = input;
for _ in 0..steps {
filter = image_filters::dilate((step, step), filter, None);
}
filter
}
pub fn get_inner_shadow_paint( pub fn get_inner_shadow_paint(
&self, &self,
antialias: bool, antialias: bool,
@ -239,6 +278,43 @@ mod tests {
); );
} }
#[test]
fn chained_dilate_reaches_the_full_radius() {
let rect = skia::Rect::from_xywh(0.0, 0.0, 10.0, 10.0);
// 600 px is three steps of 200 px.
let filter = Shadow::chained_dilate(600.0, None).expect("dilate");
let bounds = filter.compute_fast_bounds(rect);
assert!((bounds.left + 600.0).abs() < 0.01);
assert!((bounds.right - 610.0).abs() < 0.01);
}
#[test]
fn layer_drop_shadow_filter_matches_drop_shadow_bounds() {
let rect = skia::Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
for (blur, spread, ox, oy) in [
(0.0, 0.0, 4.0, 4.0),
(4.0, 0.0, 4.0, 4.0),
(12.0, 6.0, -3.0, 8.0),
] {
let s = shadow(blur, spread, ox, oy);
let expected = s
.get_drop_shadow_filter()
.expect("drop shadow filter")
.compute_fast_bounds(rect);
let actual = s
.get_layer_drop_shadow_filter()
.expect("layer drop shadow filter")
.compute_fast_bounds(rect);
assert!(
(expected.left - actual.left).abs() < 0.01
&& (expected.top - actual.top).abs() < 0.01
&& (expected.right - actual.right).abs() < 0.01
&& (expected.bottom - actual.bottom).abs() < 0.01,
"bounds differ for blur {blur}: {expected:?} vs {actual:?}"
);
}
}
#[test] #[test]
fn overview_scale_vs_extent() { fn overview_scale_vs_extent() {
// At 0.038 even blur 50 is only ~1.9px — below leaf floor. // At 0.038 even blur 50 is only ~1.9px — below leaf floor.

View File

@ -86,6 +86,20 @@ impl Stroke {
} }
} }
/// Widens the band by `spread` on each side. Inner and Outer bands only
/// grow away from the path; the `2·spread` center stroke added by
/// `Shape::apply_shadow_spread` covers the other side.
pub fn grow_by_spread(&mut self, spread: f32, is_open: bool) {
let growth = match self.render_kind(is_open) {
StrokeKind::Center => 2.0 * spread,
StrokeKind::Inner | StrokeKind::Outer => spread,
};
self.width += growth;
if let Some(widths) = self.widths.as_mut() {
widths.iter_mut().for_each(|w| *w += growth);
}
}
/// The widest side of the stroke: the uniform `width` unless per-side /// The widest side of the stroke: the uniform `width` unless per-side
/// widths are set, in which case the maximum of the four sides. /// widths are set, in which case the maximum of the four sides.
pub fn max_width(&self) -> f32 { pub fn max_width(&self) -> f32 {
@ -636,6 +650,23 @@ mod tests {
stroke stroke
} }
#[test]
fn grow_by_spread_moves_both_band_edges_by_the_spread() {
let mut center = Stroke::new_center_stroke(4.0, StrokeStyle::Solid, None, None, None, None);
center.grow_by_spread(3.0, false);
assert_eq!(center.width, 10.0);
let mut inner = stroke_with_widths(Some([2.0, 0.0, 2.0, 0.0]));
inner.grow_by_spread(3.0, false);
assert_eq!(inner.width, 5.0);
assert_eq!(inner.widths, Some([5.0, 3.0, 5.0, 3.0]));
// Open paths render every stroke centered.
let mut open_inner = stroke_with_widths(None);
open_inner.grow_by_spread(3.0, true);
assert_eq!(open_inner.width, 8.0);
}
#[test] #[test]
fn per_side_profile_needs_two_strokes_to_miter_against() { fn per_side_profile_needs_two_strokes_to_miter_against() {
let one = [stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0]))]; let one = [stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0]))];