mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 15:56:17 +00:00
🐛 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:
parent
fa81a3f648
commit
63baf86152
@ -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)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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!(
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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]))];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user