mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
🐛 Fix drop shadows in masks (#11754)
* 🐛 Fix shadows on a masked group in the WASM renderer A masked group renders in two passes: its content, then the mask shape composited with DstIn so everything outside the mask silhouette is erased. Both happen inside one save_layer, and the group's drop shadow was composited into that same layer before the mask pass — so the mask erased it. A drop shadow lives mostly outside the silhouette, so it disappeared entirely. Inner shadows never drew at all: render_fill_inner_shadows needs fill geometry to paint into, and a group has none. Both now ride on an image filter set on the masked-group layer, which Skia evaluates after the mask is composited, so the shadow comes from the real masked pixels rather than the group's own, empty geometry. The effects compose in the order the SVG renderer uses for a group: drop shadows, then the source, then inner shadows, with the layer blur over all of it. That layer is opened on a canvas carrying no transform, so the filter is built in device units. Shadow::scale_to_device does that rather than scale_content, because radius_to_sigma is affine: scaling the radius applies its constant term once at device scale, while a filter built in document space has the term scaled by the canvas matrix. The two would blur differently by 0.5 · (scale - 1) sigma, visible as a masked group's own shadow being narrower than the same shadow on its parent. The masked-group layer blur had the same flaw. Three paths are suppressed for masked groups so nothing is drawn twice: the silhouette composite, the nested_shadows inheritance that would reach text descendants, and the fill inner-shadow pass. Every save and restore around that layer is keyed on the shape alone. Enter and exit run on different walker passes, and a pan or zoom in between changes fast mode, so deriving them from render state could leave the canvas clip stack unbalanced. Refs #11697 AI-assisted-by: claude-opus-5 * 🐛 Fix a container's drop shadow over a masked group in WASM A container builds its drop shadow by drawing each descendant as a black silhouette and blurring the result. The walk descends only through children that can be flattened, and a masked group never can, so it stopped there and asked the group to draw its own geometry. A group has none, so nothing was drawn and the shadow layer stayed blank: no shadow at all for a group, board or frame holding a masked group. This is what the file attached to the issue reproduces. render_drop_black_shadow now draws the masked silhouette for such a group — content children flat black, DstIn the mask, and only then the offset, blur and spread. Masking after the blur would trim the shadow along the wrong edge. The walk recurses, so it narrows the clip the way the main walker does: content a clipping container hides must not widen the shadow. The clip rule now lives in one place, shared with the walker, and a test pins the two against each other. The shadow layer is sized to the silhouette plus the shadow's reach rather than falling back to the clip, so a wide blur is not cut at the tile edge. Spread keeps the renderer's existing behaviour: the silhouette goes through the same get_drop_shadow_filter every other shadow uses, so a masked group gains no ordering of its own. Closes #11697 AI-assisted-by: claude-opus-5
This commit is contained in:
parent
617bf195cf
commit
0581c35452
@ -81,6 +81,16 @@ pub struct NodeRenderState {
|
||||
flattened: bool,
|
||||
}
|
||||
|
||||
/// The parts of a drop-shadow silhouette walk that stay the same at every
|
||||
/// level. Only the clip stack changes as the walk descends.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SilhouettePass<'a> {
|
||||
tree: ShapesPoolRef<'a>,
|
||||
scale: f32,
|
||||
extra_layer_blur: Option<Blur>,
|
||||
target_surface: SurfaceId,
|
||||
}
|
||||
|
||||
/// Get simplified children of a container, flattening nested flattened containers
|
||||
fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec<Uuid> {
|
||||
let mut result = Vec::new();
|
||||
@ -137,10 +147,27 @@ impl NodeRenderState {
|
||||
offset: Option<(f32, f32)>,
|
||||
clip_inset: Option<f32>,
|
||||
) -> Option<ClipStack> {
|
||||
if self.id.is_nil() || !element.clip() {
|
||||
if self.id.is_nil() {
|
||||
return self.clip_bounds.clone();
|
||||
}
|
||||
|
||||
Self::append_child_clip(element, self.clip_bounds.clone(), offset, clip_inset)
|
||||
}
|
||||
|
||||
/// Appends `element`'s own clip to `clip_bounds`, for walks that carry a
|
||||
/// bare [`ClipStack`] instead of a [`NodeRenderState`] (the drop-shadow
|
||||
/// silhouette recursion). Returns the stack untouched when `element` does
|
||||
/// not clip its content.
|
||||
fn append_child_clip(
|
||||
element: &Shape,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
offset: Option<(f32, f32)>,
|
||||
clip_inset: Option<f32>,
|
||||
) -> Option<ClipStack> {
|
||||
if !element.clip() {
|
||||
return clip_bounds;
|
||||
}
|
||||
|
||||
let mut bounds = element.selrect();
|
||||
if let Some(offset) = offset {
|
||||
let x = bounds.x() - offset.0;
|
||||
@ -163,7 +190,7 @@ impl NodeRenderState {
|
||||
bounds.inset((clip_inset, clip_inset));
|
||||
}
|
||||
|
||||
Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform))
|
||||
Self::append_clip(clip_bounds, (bounds, corners, transform))
|
||||
}
|
||||
|
||||
/// Calculates the clip bounds for shadow rendering of a given shape.
|
||||
@ -2819,6 +2846,13 @@ impl RenderState {
|
||||
crate::get_gpu_state().context.flush(None);
|
||||
}
|
||||
|
||||
/// Gating for the masked-group layer filter: its shadows follow the
|
||||
/// container drop-shadow rules, its blur follows fast mode.
|
||||
#[inline]
|
||||
pub(crate) fn masked_group_layer_skips(&self) -> (bool, bool) {
|
||||
(self.should_skip_drop_shadows(), self.options.is_fast_mode())
|
||||
}
|
||||
|
||||
/// Skip all drop/inner shadows in fast mode, or when even a large design-space
|
||||
/// shadow would be subpixel. Otherwise filter per shadow via
|
||||
/// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes).
|
||||
@ -2885,18 +2919,30 @@ impl RenderState {
|
||||
// other already drawn elements.
|
||||
if let Type::Group(group) = element.shape_type {
|
||||
let fills = &element.fills;
|
||||
let shadows = &element.shadows;
|
||||
self.nested_fills.push(fills.to_vec());
|
||||
self.nested_shadows.push(shadows.to_vec());
|
||||
|
||||
// A masked group's own shadows are applied to the masked result by
|
||||
// the layer filter below, so descendants must not inherit them.
|
||||
if group.masked {
|
||||
self.nested_shadows.push(Vec::new());
|
||||
} else {
|
||||
self.nested_shadows.push(element.shadows.to_vec());
|
||||
}
|
||||
|
||||
if group.masked {
|
||||
// A masked group's blur is applied as a single layer blur over
|
||||
// the whole masked result.
|
||||
let mask_group_blur = element.masked_group_layer_blur().is_some();
|
||||
if mask_group_blur {
|
||||
self.surfaces.canvas(target_surface).save();
|
||||
// A masked group's blur and shadows are applied as a single
|
||||
// image filter over the whole masked result.
|
||||
let scale = self.get_scale();
|
||||
let (skip_shadows, skip_blur) = self.masked_group_layer_skips();
|
||||
let filter = element.masked_group_layer_filter(scale, skip_shadows, skip_blur);
|
||||
|
||||
// Unconditional: `render_shape_exit` runs on a later walker pass
|
||||
// and restores this from the shape type alone. Gating it on the
|
||||
// filter would let fast mode or zoom change in between and leave
|
||||
// the save stack (and its clip) unbalanced.
|
||||
self.surfaces.canvas(target_surface).save();
|
||||
if filter.is_some() {
|
||||
if let Some(clips) = clip_bounds {
|
||||
let scale = self.get_scale();
|
||||
let antialias = !self.options.is_fast_mode()
|
||||
&& element
|
||||
.should_use_antialias(scale, self.options.antialias_threshold);
|
||||
@ -2905,16 +2951,8 @@ impl RenderState {
|
||||
}
|
||||
|
||||
let mut paint = skia::Paint::default();
|
||||
if !self.options.is_fast_mode() {
|
||||
if let Some(blur) = element.masked_group_layer_blur() {
|
||||
let scale = self.get_scale();
|
||||
let sigma = radius_to_sigma(blur.value * scale);
|
||||
if let Some(filter) =
|
||||
skia::image_filters::blur((sigma, sigma), None, None, None)
|
||||
{
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
}
|
||||
if let Some(filter) = filter {
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
@ -3072,7 +3110,10 @@ impl RenderState {
|
||||
self.surfaces.canvas(target_surface).restore();
|
||||
}
|
||||
|
||||
if visited_mask && element.masked_group_layer_blur().is_some() {
|
||||
// Pairs with the unconditional `save()` `render_shape_enter` does for a
|
||||
// masked group. Keyed on the shape alone so it cannot disagree with the
|
||||
// enter side, which runs on an earlier walker pass.
|
||||
if visited_mask && element.is_masked_group() {
|
||||
self.surfaces.canvas(target_surface).restore();
|
||||
}
|
||||
|
||||
@ -3176,6 +3217,7 @@ impl RenderState {
|
||||
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
|
||||
self.render_drop_black_shadow(
|
||||
shadow_shape,
|
||||
tree,
|
||||
&shadow_shape.extrect(tree, scale),
|
||||
shadow,
|
||||
nested_clip_bounds,
|
||||
@ -3222,6 +3264,166 @@ impl RenderState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders the masked silhouette of a masked group into the current
|
||||
/// drop-shadow layer.
|
||||
///
|
||||
/// The mask is applied to the flat silhouettes *before* the offset, blur and
|
||||
/// spread: blurring each child first and then cutting the result against an
|
||||
/// unshifted mask would trim the shadow along the wrong edge.
|
||||
fn render_masked_group_black_shadow(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
shape_bounds: &Rect,
|
||||
shadow: &Shadow,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
pass: SilhouettePass,
|
||||
) -> Result<()> {
|
||||
let mut black_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
|
||||
black_shadow.to_mut().color = skia::Color::BLACK;
|
||||
let Some(drop_filter) = black_shadow.get_drop_shadow_filter() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Early cull, mirroring `render_drop_black_shadow`.
|
||||
let bounds = drop_filter.compute_fast_bounds(*shape_bounds);
|
||||
if !bounds.intersects(self.render_area_with_margins)
|
||||
&& pass.target_surface != SurfaceId::Export
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Size the layer to the silhouette plus everything the shadow reaches.
|
||||
// Left unbounded, Skia falls back to the clip — the tile-sized
|
||||
// `DropShadows` surface — and a wide blur gets cut at the tile edge.
|
||||
// The union keeps the source geometry inside the layer as well.
|
||||
let mut layer_bounds = bounds;
|
||||
layer_bounds.join(*shape_bounds);
|
||||
|
||||
let mut shadow_paint = skia::Paint::default();
|
||||
shadow_paint.set_image_filter(drop_filter);
|
||||
let shadow_rec = skia::canvas::SaveLayerRec::default()
|
||||
.bounds(&layer_bounds)
|
||||
.paint(&shadow_paint);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&shadow_rec);
|
||||
|
||||
self.render_masked_group_silhouette(shape, clip_bounds, pass)?;
|
||||
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draws the mask-trimmed silhouette of a masked group, flat black, on the
|
||||
/// drop-shadow surface.
|
||||
///
|
||||
/// The mask is composited here rather than by the caller so the offset,
|
||||
/// blur and spread always land on the trimmed result: blurring the content
|
||||
/// first and then cutting it against an unshifted mask would trim the
|
||||
/// shadow along the wrong edge.
|
||||
fn render_masked_group_silhouette(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
pass: SilhouettePass,
|
||||
) -> Result<()> {
|
||||
let Some(mask_shape) = shape.mask_id().and_then(|id| pass.tree.get(id)) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// `children_ids_iter` already excludes the mask for a masked group.
|
||||
let content_ids: Vec<Uuid> = shape.children_ids_iter(false).copied().collect();
|
||||
if content_ids.is_empty() || mask_shape.hidden {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The masked group's children are clipped by the group itself before
|
||||
// anything below it applies.
|
||||
let children_clip_bounds =
|
||||
NodeRenderState::append_child_clip(shape, clip_bounds, None, None);
|
||||
|
||||
for content_id in content_ids {
|
||||
let Some(content) = pass.tree.get(&content_id) else {
|
||||
continue;
|
||||
};
|
||||
if content.hidden {
|
||||
continue;
|
||||
}
|
||||
self.render_black_silhouette_subtree(content, children_clip_bounds.clone(), pass)?;
|
||||
}
|
||||
|
||||
let mut mask_paint = skia::Paint::default();
|
||||
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
|
||||
let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&mask_rec);
|
||||
|
||||
self.render_black_silhouette_subtree(mask_shape, children_clip_bounds, pass)?;
|
||||
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draws `shape` and its descendants as a flat black silhouette on the
|
||||
/// drop-shadow surface: a shadow with no offset, blur or spread turns
|
||||
/// [`Self::render_drop_black_shadow`] into a plain silhouette pass.
|
||||
fn render_black_silhouette_subtree(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
pass: SilhouettePass,
|
||||
) -> Result<()> {
|
||||
// Already the trimmed silhouette of its own subtree, and the caller's
|
||||
// layer carries the shadow — no second filter layer for a nested one.
|
||||
if shape.is_masked_group() {
|
||||
return self.render_masked_group_silhouette(shape, clip_bounds, pass);
|
||||
}
|
||||
|
||||
let flat = Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
0.0,
|
||||
0.0,
|
||||
(0.0, 0.0),
|
||||
crate::shapes::ShadowStyle::Drop,
|
||||
false,
|
||||
);
|
||||
|
||||
self.render_drop_black_shadow(
|
||||
shape,
|
||||
pass.tree,
|
||||
&shape.extrect(pass.tree, pass.scale),
|
||||
&flat,
|
||||
clip_bounds.clone(),
|
||||
pass.scale,
|
||||
pass.extra_layer_blur,
|
||||
pass.target_surface,
|
||||
)?;
|
||||
|
||||
if !shape.is_recursive() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Descendants of a clipping container are invisible outside it, so they
|
||||
// must not widen the silhouette either.
|
||||
let children_clip_bounds =
|
||||
NodeRenderState::append_child_clip(shape, clip_bounds, None, None);
|
||||
|
||||
for child_id in get_simplified_children(pass.tree, shape) {
|
||||
let Some(child) = pass.tree.get(&child_id) else {
|
||||
continue;
|
||||
};
|
||||
if child.hidden {
|
||||
continue;
|
||||
}
|
||||
self.render_black_silhouette_subtree(child, children_clip_bounds.clone(), pass)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders a drop shadow effect for the given shape.
|
||||
///
|
||||
/// Creates a black shadow by converting the original shadow color to black,
|
||||
@ -3230,6 +3432,7 @@ impl RenderState {
|
||||
fn render_drop_black_shadow(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
shape_bounds: &Rect,
|
||||
shadow: &Shadow,
|
||||
clip_bounds: Option<ClipStack>,
|
||||
@ -3237,6 +3440,24 @@ impl RenderState {
|
||||
extra_layer_blur: Option<Blur>,
|
||||
target_surface: SurfaceId,
|
||||
) -> Result<()> {
|
||||
// A group has no geometry of its own, so a masked group would
|
||||
// contribute nothing here. Draw its masked silhouette instead.
|
||||
if shape.is_masked_group() {
|
||||
let pass = SilhouettePass {
|
||||
tree,
|
||||
scale,
|
||||
extra_layer_blur,
|
||||
target_surface,
|
||||
};
|
||||
return self.render_masked_group_black_shadow(
|
||||
shape,
|
||||
shape_bounds,
|
||||
shadow,
|
||||
clip_bounds,
|
||||
pass,
|
||||
);
|
||||
}
|
||||
|
||||
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
|
||||
transformed_shadow.to_mut().offset = (0.0, 0.0);
|
||||
transformed_shadow.to_mut().color = skia::Color::BLACK;
|
||||
@ -3479,6 +3700,13 @@ impl RenderState {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// A masked group's own shadows ride on its layer filter (see
|
||||
// `Shape::masked_group_layer_filter`). Compositing them here would
|
||||
// paint them inside the layer that the mask pass then erases.
|
||||
if element.is_masked_group() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale));
|
||||
let inherited_layer_blur = match element.shape_type {
|
||||
Type::Frame(_) | Type::Group(_) => element.blur,
|
||||
@ -3511,6 +3739,7 @@ impl RenderState {
|
||||
} else {
|
||||
self.render_drop_black_shadow(
|
||||
element,
|
||||
tree,
|
||||
element_extrect,
|
||||
shadow,
|
||||
clip_bounds.clone(),
|
||||
@ -4603,3 +4832,106 @@ impl RenderState {
|
||||
get_gpu_state().context.free_gpu_resources();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes::{Frame, Group, Rect as RectType};
|
||||
|
||||
fn frame(clip: bool) -> Shape {
|
||||
let mut shape = Shape::new(Uuid::new_v4());
|
||||
shape.set_shape_type(Type::Frame(Frame::default()));
|
||||
shape.set_selrect(10.0, 20.0, 110.0, 120.0);
|
||||
shape.clip_content = clip;
|
||||
shape
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_child_clip_leaves_the_stack_alone_for_a_non_clipping_shape() {
|
||||
let shape = frame(false);
|
||||
assert!(NodeRenderState::append_child_clip(&shape, None, None, None).is_none());
|
||||
|
||||
let existing: ClipStack = vec![(
|
||||
Rect::from_ltrb(0.0, 0.0, 10.0, 10.0),
|
||||
None,
|
||||
Matrix::new_identity(),
|
||||
)];
|
||||
let stack = NodeRenderState::append_child_clip(&shape, Some(existing), None, None)
|
||||
.expect("the incoming stack is returned as-is");
|
||||
assert_eq!(stack.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_child_clip_adds_the_selrect_of_a_clipping_shape() {
|
||||
let shape = frame(true);
|
||||
let stack = NodeRenderState::append_child_clip(&shape, None, None, None)
|
||||
.expect("a clipping shape starts a stack");
|
||||
|
||||
assert_eq!(stack.len(), 1);
|
||||
assert_eq!(stack[0].0, shape.selrect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_child_clip_stacks_nested_clips() {
|
||||
let outer = frame(true);
|
||||
let mut inner = frame(true);
|
||||
inner.set_selrect(30.0, 40.0, 60.0, 70.0);
|
||||
|
||||
let stack = NodeRenderState::append_child_clip(&outer, None, None, None);
|
||||
let stack = NodeRenderState::append_child_clip(&inner, stack, None, None)
|
||||
.expect("both clips are kept");
|
||||
|
||||
assert_eq!(stack.len(), 2);
|
||||
assert_eq!(stack[0].0, outer.selrect());
|
||||
assert_eq!(stack[1].0, inner.selrect());
|
||||
}
|
||||
|
||||
/// The silhouette recursion and the main walker must narrow the clip by the
|
||||
/// same rule, or a drop shadow stops matching the shape that casts it.
|
||||
#[test]
|
||||
fn append_child_clip_agrees_with_get_children_clip_bounds() {
|
||||
let mut masked = Shape::new(Uuid::new_v4());
|
||||
masked.set_shape_type(Type::Group(Group { masked: true }));
|
||||
masked.set_selrect(0.0, 0.0, 50.0, 50.0);
|
||||
|
||||
for shape in [frame(true), frame(false), masked] {
|
||||
for clip_inset in [None, Some(2.0)] {
|
||||
let node = NodeRenderState {
|
||||
id: Uuid::new_v4(),
|
||||
visited_children: false,
|
||||
clip_bounds: None,
|
||||
visited_mask: false,
|
||||
mask: false,
|
||||
flattened: false,
|
||||
};
|
||||
|
||||
let walker = node.get_children_clip_bounds(&shape, None, clip_inset);
|
||||
let silhouette = NodeRenderState::append_child_clip(&shape, None, None, clip_inset);
|
||||
|
||||
assert_eq!(
|
||||
walker.as_ref().map(|s| s.len()),
|
||||
silhouette.as_ref().map(|s| s.len())
|
||||
);
|
||||
assert_eq!(
|
||||
walker.map(|s| s[0].0),
|
||||
silhouette.map(|s| s[0].0),
|
||||
"clip rect disagreement for {:?} inset {clip_inset:?}",
|
||||
shape.shape_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_child_clip_applies_the_inset() {
|
||||
let mut shape = Shape::new(Uuid::new_v4());
|
||||
shape.set_shape_type(Type::Rect(RectType::default()));
|
||||
shape.set_selrect(0.0, 0.0, 100.0, 100.0);
|
||||
shape.clip_content = true;
|
||||
|
||||
let stack = NodeRenderState::append_child_clip(&shape, None, None, Some(2.0))
|
||||
.expect("a clipping shape starts a stack");
|
||||
|
||||
assert_eq!(stack[0].0, Rect::from_ltrb(2.0, 2.0, 98.0, 98.0));
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,7 +362,9 @@ pub fn render_fill_inner_shadows(
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
) {
|
||||
if !shape.has_fills() || render_state.should_skip_drop_shadows() {
|
||||
// A masked group's inner shadows are applied to the masked result by its
|
||||
// layer filter (see `Shape::masked_group_layer_filter`).
|
||||
if !shape.has_fills() || shape.is_masked_group() || render_state.should_skip_drop_shadows() {
|
||||
return;
|
||||
}
|
||||
let scale = render_state.get_scale();
|
||||
|
||||
@ -1867,6 +1867,11 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group whose first child clips the rest of its content.
|
||||
pub fn is_masked_group(&self) -> bool {
|
||||
matches!(self.shape_type, Type::Group(Group { masked: true }))
|
||||
}
|
||||
|
||||
pub fn masked_group_layer_blur(&self) -> Option<Blur> {
|
||||
use crate::shapes::BlurType;
|
||||
match self.shape_type {
|
||||
@ -1877,6 +1882,94 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shadows of the given style that the masked-group layer filter must
|
||||
/// carry, bottom-most first, already converted to device space.
|
||||
///
|
||||
/// Containers use the stricter `is_perceptible_at_scale_for` floor: a
|
||||
/// shadow too small to see is not worth a filter pass.
|
||||
fn masked_group_layer_shadows(
|
||||
&self,
|
||||
scale: f32,
|
||||
style: ShadowStyle,
|
||||
) -> impl Iterator<Item = Shadow> + '_ {
|
||||
self.shadows
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(move |shadow| {
|
||||
shadow.style() == style
|
||||
&& !shadow.hidden()
|
||||
&& shadow.is_perceptible_at_scale_for(scale, true)
|
||||
})
|
||||
.map(move |shadow| {
|
||||
let mut scaled = *shadow;
|
||||
scaled.scale_to_device(scale);
|
||||
scaled
|
||||
})
|
||||
}
|
||||
|
||||
/// Image filter for the `save_layer` that wraps a masked group, i.e. for
|
||||
/// the result *after* the mask has been composited into it.
|
||||
///
|
||||
/// Mirrors the filter chain the SVG renderer builds for a group
|
||||
/// (`app.common.geom.shapes.bounds/shape->filters`): drop shadows, then the
|
||||
/// source graphic, then inner shadows, with the layer blur over all of it.
|
||||
/// A drop shadow on a masked group is therefore derived from the real
|
||||
/// masked pixels instead of from the group's own (empty) geometry.
|
||||
///
|
||||
/// The layer is opened on a canvas that still has an identity matrix, so
|
||||
/// every blur radius, spread and offset is pre-multiplied by `scale`.
|
||||
/// `skip_shadows` mirrors `should_skip_drop_shadows` and `skip_blur`
|
||||
/// mirrors fast mode.
|
||||
pub fn masked_group_layer_filter(
|
||||
&self,
|
||||
scale: f32,
|
||||
skip_shadows: bool,
|
||||
skip_blur: bool,
|
||||
) -> Option<skia::ImageFilter> {
|
||||
if !self.is_masked_group() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// `merge` composites its inputs bottom-to-top and reads `None` as the
|
||||
// source graphic, which is exactly the SVG primitive order.
|
||||
let mut layers: Vec<Option<skia::ImageFilter>> = Vec::new();
|
||||
|
||||
if !skip_shadows {
|
||||
for shadow in self.masked_group_layer_shadows(scale, ShadowStyle::Drop) {
|
||||
layers.push(shadow.get_drop_shadow_filter());
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(None);
|
||||
|
||||
if !skip_shadows {
|
||||
for shadow in self.masked_group_layer_shadows(scale, ShadowStyle::Inner) {
|
||||
layers.push(shadow.get_inner_shadow_filter());
|
||||
}
|
||||
}
|
||||
|
||||
let mut filter = if layers.len() > 1 {
|
||||
skia::image_filters::merge(layers, None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !skip_blur {
|
||||
if let Some(blur) = self.masked_group_layer_blur() {
|
||||
// Scale the sigma, not the radius — see `Shadow::scale_to_device`.
|
||||
let sigma = radius_to_sigma(blur.value) * scale;
|
||||
// Keep the shadows if the blur filter cannot be built.
|
||||
if let Some(blurred) =
|
||||
skia::image_filters::blur((sigma, sigma), None, filter.clone(), None)
|
||||
{
|
||||
filter = Some(blurred);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// Checks if this shape has visual effects that might extend its bounds beyond selrect
|
||||
/// Shapes with these effects require expensive extrect calculation for accurate visibility checks
|
||||
pub fn has_effects_that_extend_bounds(&self) -> bool {
|
||||
@ -2157,6 +2250,229 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn masked_group_with(shadows: Vec<Shadow>, blur: Option<Blur>) -> Shape {
|
||||
let mut shape = any_shape();
|
||||
shape.set_shape_type(Type::Group(Group { masked: true }));
|
||||
shape.set_selrect(0.0, 0.0, 100.0, 100.0);
|
||||
for shadow in shadows {
|
||||
shape.add_shadow(shadow);
|
||||
}
|
||||
shape.set_blur(blur);
|
||||
shape
|
||||
}
|
||||
|
||||
fn drop_shadow(blur: f32, spread: f32, offset: (f32, f32)) -> Shadow {
|
||||
Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
blur,
|
||||
spread,
|
||||
offset,
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn inner_shadow(blur: f32, offset: (f32, f32)) -> Shadow {
|
||||
Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
blur,
|
||||
0.0,
|
||||
offset,
|
||||
ShadowStyle::Inner,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn hidden_drop_shadow(blur: f32, offset: (f32, f32)) -> Shadow {
|
||||
Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
blur,
|
||||
0.0,
|
||||
offset,
|
||||
ShadowStyle::Drop,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn unit_rect() -> math::Rect {
|
||||
math::Rect::from_ltrb(0.0, 0.0, 100.0, 100.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_is_none_without_effects() {
|
||||
let shape = masked_group_with(vec![], None);
|
||||
assert!(shape.masked_group_layer_filter(1.0, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_ignores_unmasked_groups() {
|
||||
let mut shape = masked_group_with(vec![drop_shadow(4.0, 0.0, (10.0, 0.0))], None);
|
||||
shape.set_shape_type(Type::Group(Group { masked: false }));
|
||||
|
||||
assert!(shape.masked_group_layer_filter(1.0, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_ignores_hidden_shadows() {
|
||||
let shape = masked_group_with(vec![hidden_drop_shadow(4.0, (10.0, 0.0))], None);
|
||||
assert!(shape.masked_group_layer_filter(1.0, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_extends_bounds_towards_the_drop_shadow() {
|
||||
let shape = masked_group_with(vec![drop_shadow(4.0, 0.0, (20.0, 10.0))], None);
|
||||
|
||||
let filter = shape
|
||||
.masked_group_layer_filter(1.0, false, false)
|
||||
.expect("drop shadow should produce a filter");
|
||||
let bounds = filter.compute_fast_bounds(unit_rect());
|
||||
|
||||
assert!(bounds.right > unit_rect().right + 20.0);
|
||||
assert!(bounds.bottom > unit_rect().bottom + 10.0);
|
||||
// The source graphic is kept, so the rect never shrinks.
|
||||
assert!(bounds.left <= unit_rect().left);
|
||||
assert!(bounds.top <= unit_rect().top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_includes_inner_shadows() {
|
||||
let shape = masked_group_with(vec![inner_shadow(4.0, (20.0, 10.0))], None);
|
||||
|
||||
assert!(shape.masked_group_layer_filter(1.0, false, false).is_some());
|
||||
|
||||
// Inner shadows are shadows too: `skip_shadows` drops them.
|
||||
assert!(shape.masked_group_layer_filter(1.0, true, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_scales_shadows_to_device_space() {
|
||||
// No blur, so the reach is the offset alone and doubling the scale
|
||||
// must double it exactly.
|
||||
let shape = masked_group_with(vec![drop_shadow(0.0, 0.0, (20.0, 0.0))], None);
|
||||
|
||||
let at_1x = shape
|
||||
.masked_group_layer_filter(1.0, false, false)
|
||||
.expect("filter at 1x")
|
||||
.compute_fast_bounds(unit_rect());
|
||||
let at_2x = shape
|
||||
.masked_group_layer_filter(2.0, false, false)
|
||||
.expect("filter at 2x")
|
||||
.compute_fast_bounds(unit_rect());
|
||||
|
||||
let reach_1x = at_1x.right - unit_rect().right;
|
||||
let reach_2x = at_2x.right - unit_rect().right;
|
||||
|
||||
assert!((reach_1x - 20.0).abs() < 0.001);
|
||||
assert!((reach_2x - 40.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_honours_skip_flags() {
|
||||
let shape = masked_group_with(
|
||||
vec![drop_shadow(4.0, 0.0, (20.0, 0.0))],
|
||||
Some(Blur::new(BlurType::LayerBlur, false, 8.0)),
|
||||
);
|
||||
|
||||
let without_shadows = shape
|
||||
.masked_group_layer_filter(1.0, true, false)
|
||||
.expect("blur still produces a filter");
|
||||
let bounds = without_shadows.compute_fast_bounds(unit_rect());
|
||||
// Blur grows the rect symmetrically; the shadow offset would not.
|
||||
assert!(
|
||||
(bounds.right - unit_rect().right - (bounds.left - unit_rect().left).abs()).abs()
|
||||
< 0.001
|
||||
);
|
||||
|
||||
assert!(shape.masked_group_layer_filter(1.0, false, true).is_some());
|
||||
assert!(shape.masked_group_layer_filter(1.0, true, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_preserves_blur_only_behaviour() {
|
||||
let shape = masked_group_with(vec![], Some(Blur::new(BlurType::LayerBlur, false, 8.0)));
|
||||
|
||||
let filter = shape
|
||||
.masked_group_layer_filter(1.0, false, false)
|
||||
.expect("layer blur should produce a filter");
|
||||
let bounds = filter.compute_fast_bounds(unit_rect());
|
||||
|
||||
let sigma = radius_to_sigma(8.0);
|
||||
let expected = skia::image_filters::blur((sigma, sigma), None, None, None)
|
||||
.expect("reference blur")
|
||||
.compute_fast_bounds(unit_rect());
|
||||
|
||||
assert!((bounds.left - expected.left).abs() < 0.001);
|
||||
assert!((bounds.right - expected.right).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_layer_filter_drops_imperceptible_shadows() {
|
||||
// A container shadow needs a 4 device px footprint to be worth drawing.
|
||||
let shape = masked_group_with(vec![drop_shadow(8.0, 0.0, (0.0, 0.0))], None);
|
||||
|
||||
assert!(shape.masked_group_layer_filter(1.0, false, false).is_some());
|
||||
assert!(shape.masked_group_layer_filter(0.1, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_splits_its_mask_from_its_content() {
|
||||
// The masked-group shadow silhouette relies on this split: the mask is
|
||||
// the first child, and iterating the children yields only the content.
|
||||
let mut group = any_shape();
|
||||
group.set_shape_type(Type::Group(Group { masked: true }));
|
||||
|
||||
let mask_id = Uuid::new_v4();
|
||||
let content_a = Uuid::new_v4();
|
||||
let content_b = Uuid::new_v4();
|
||||
group.children = vec![mask_id, content_a, content_b];
|
||||
|
||||
assert_eq!(group.mask_id(), Some(&mask_id));
|
||||
|
||||
let content: Vec<Uuid> = group.children_ids_iter(false).copied().collect();
|
||||
assert_eq!(content.len(), 2);
|
||||
assert!(content.contains(&content_a));
|
||||
assert!(content.contains(&content_b));
|
||||
assert!(!content.contains(&mask_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_group_extrect_grows_with_a_drop_shadow() {
|
||||
let mut pool = ShapesPool::new();
|
||||
pool.initialize(3);
|
||||
|
||||
let group_id = Uuid::new_v4();
|
||||
let mask_id = Uuid::new_v4();
|
||||
let content_id = Uuid::new_v4();
|
||||
|
||||
{
|
||||
let group = pool.add_shape(group_id);
|
||||
group.set_shape_type(Type::Group(Group { masked: true }));
|
||||
group.set_selrect(0.0, 0.0, 50.0, 50.0);
|
||||
group.children = vec![mask_id, content_id];
|
||||
group.add_shadow(drop_shadow(4.0, 0.0, (20.0, 20.0)));
|
||||
}
|
||||
|
||||
{
|
||||
let mask = pool.add_shape(mask_id);
|
||||
mask.set_shape_type(Type::Rect(Rect::default()));
|
||||
mask.set_selrect(0.0, 0.0, 50.0, 50.0);
|
||||
mask.set_parent(group_id);
|
||||
}
|
||||
|
||||
{
|
||||
let content = pool.add_shape(content_id);
|
||||
content.set_shape_type(Type::Rect(Rect::default()));
|
||||
content.set_selrect(0.0, 0.0, 50.0, 50.0);
|
||||
content.set_parent(group_id);
|
||||
}
|
||||
|
||||
let group = pool.get(&group_id).expect("group should exist");
|
||||
let extrect = group.calculate_extrect(&pool, 1.0);
|
||||
|
||||
assert!(extrect.right > 50.0 + 20.0);
|
||||
assert!(extrect.bottom > 50.0 + 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_transform() {
|
||||
let mut shape = Shape::new(Uuid::new_v4());
|
||||
|
||||
@ -12,6 +12,19 @@ pub fn radius_to_sigma(radius: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`radius_to_sigma`].
|
||||
///
|
||||
/// Sigmas below the constant term have no radius that produces them, so they
|
||||
/// collapse to zero — a sub-pixel blur, which Skia would round away anyway.
|
||||
#[inline]
|
||||
pub fn sigma_to_radius(sigma: f32) -> f32 {
|
||||
if sigma > 0.5 {
|
||||
(sigma - 0.5) / BLUR_SIGMA_SCALE
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BlurType {
|
||||
LayerBlur,
|
||||
@ -45,3 +58,22 @@ impl Blur {
|
||||
radius_to_sigma(self.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sigma_to_radius_round_trips() {
|
||||
for radius in [0.0_f32, 1.0, 4.0, 20.0, 200.0] {
|
||||
let sigma = radius_to_sigma(radius);
|
||||
assert!((sigma_to_radius(sigma) - radius).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_pixel_sigma_has_no_radius() {
|
||||
assert_eq!(sigma_to_radius(0.0), 0.0);
|
||||
assert_eq!(sigma_to_radius(0.5), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use skia_safe::{self as skia, image_filters, ImageFilter, Paint};
|
||||
|
||||
use super::blurs::radius_to_sigma;
|
||||
use super::blurs::{radius_to_sigma, sigma_to_radius};
|
||||
use super::Color;
|
||||
use crate::render::filters::compose_filters;
|
||||
|
||||
@ -149,6 +149,22 @@ impl Shadow {
|
||||
self.offset.0 *= value;
|
||||
self.offset.1 *= value;
|
||||
}
|
||||
|
||||
/// Scales this shadow into device units, for a filter built on a canvas
|
||||
/// that carries no transform of its own.
|
||||
///
|
||||
/// Not the same as [`Self::scale_content`]. `radius_to_sigma` is affine
|
||||
/// (`k·r + 0.5`), so scaling the radius applies its constant term once at
|
||||
/// device scale, while a filter built in document space has that term
|
||||
/// scaled by the canvas matrix along with everything else. The radius is
|
||||
/// pre-compensated here so both land on the same sigma — otherwise the
|
||||
/// same shadow blurs differently depending on which path drew it, by
|
||||
/// `0.5 · (scale - 1)` sigma.
|
||||
pub fn scale_to_device(&mut self, scale: f32) {
|
||||
let device_sigma = radius_to_sigma(self.blur) * scale;
|
||||
self.scale_content(scale);
|
||||
self.blur = sigma_to_radius(device_sigma);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -184,6 +200,45 @@ mod tests {
|
||||
assert!(shadow(32.0, 0.0, 0.0, 0.0).is_perceptible_at_scale_for(0.13, true));
|
||||
}
|
||||
|
||||
/// A filter built on an untransformed canvas must reach the same sigma a
|
||||
/// document-space filter does once the canvas matrix scales it, or the same
|
||||
/// shadow blurs differently depending on which path drew it.
|
||||
#[test]
|
||||
fn scale_to_device_matches_a_document_space_sigma() {
|
||||
for scale in [0.5_f32, 1.0, 2.0, 4.0, 8.0] {
|
||||
let original = shadow(10.0, 6.0, 3.0, -2.0);
|
||||
let mut device = original;
|
||||
device.scale_to_device(scale);
|
||||
|
||||
assert!(
|
||||
(radius_to_sigma(device.blur) - radius_to_sigma(original.blur) * scale).abs()
|
||||
< 0.001,
|
||||
"sigma disagreement at scale {scale}"
|
||||
);
|
||||
|
||||
// Spread and offset are linear, so they scale straight through.
|
||||
assert!((device.spread - original.spread * scale).abs() < 0.001);
|
||||
assert!((device.offset.0 - original.offset.0 * scale).abs() < 0.001);
|
||||
assert!((device.offset.1 - original.offset.1 * scale).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scaling the radius instead would apply the affine constant once at
|
||||
/// device scale, blurring narrower by `0.5 · (scale - 1)` sigma.
|
||||
#[test]
|
||||
fn scale_to_device_differs_from_scale_content_above_unit_scale() {
|
||||
let mut device = shadow(10.0, 0.0, 0.0, 0.0);
|
||||
device.scale_to_device(4.0);
|
||||
let mut naive = shadow(10.0, 0.0, 0.0, 0.0);
|
||||
naive.scale_content(4.0);
|
||||
|
||||
let gap = radius_to_sigma(device.blur) - radius_to_sigma(naive.blur);
|
||||
assert!(
|
||||
(gap - 0.5 * 3.0).abs() < 0.001,
|
||||
"expected 1.5 sigma, got {gap}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_scale_vs_extent() {
|
||||
// At 0.038 even blur 50 is only ~1.9px — below leaf floor.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user