mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 20:10:37 +00:00
🐛 Keep drop shadows out from under translucent shapes (#12904)
* 🐛 Keep drop shadows out from under translucent shapes at every zoom Cut the shape out of its drop shadow, as CSS box-shadow does, and stop large shadows from being cut off at high zoom. AI-assisted-by: claude-opus-5-5 * 🐛 Keep drop shadows out from under translucent shapes in SVG and PDF Match the canvas: clip the shape out of its drop shadow on export. AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
c9d3cbb383
commit
7f0b62b994
@ -764,6 +764,13 @@ impl RenderState {
|
||||
Self::union_stroke_outset(base, stroke_outset)
|
||||
}
|
||||
|
||||
/// Fill ∪ outer stroke outline in shape-local space: the area a drop
|
||||
/// shadow must not show under (vector, SVG).
|
||||
pub(crate) fn drop_shadow_clear_path(shape: &Shape) -> skia::Path {
|
||||
let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open());
|
||||
Self::background_blur_clip_path(shape, stroke_outset)
|
||||
}
|
||||
|
||||
/// Unions a centered stroke of width `2 * stroke_outset` into `base`.
|
||||
/// The inward half disappears in the union, leaving `stroke_outset` beyond
|
||||
/// the fill outline.
|
||||
@ -3431,20 +3438,11 @@ impl RenderState {
|
||||
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,
|
||||
&Shadow::silhouette(),
|
||||
clip_bounds.clone(),
|
||||
pass.scale,
|
||||
pass.extra_layer_blur,
|
||||
@ -3649,7 +3647,19 @@ impl RenderState {
|
||||
// High zoom with blur: use render_into_filter_surface to ensure blur has enough space
|
||||
// Apply spread geometrically to avoid dilate filter rounding issues
|
||||
let layer_blur_value = combined_blur.map(|b| b.value).unwrap_or(0.0);
|
||||
let cache_key = clip_bounds.is_none().then(|| {
|
||||
|
||||
// Only the part that can blur into this tile matters. A shadow bigger
|
||||
// than the filter surface would otherwise be cut at MIN_FIT_SCALE.
|
||||
let mut filter_bounds = bounds;
|
||||
if target_surface != SurfaceId::Export {
|
||||
let reach = 2.0 * (shadow.blur + layer_blur_value) + 1.0;
|
||||
let tile_reach = self.render_area_with_margins.with_outset((reach, reach));
|
||||
if !filter_bounds.intersect(tile_reach) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let cache_key = (clip_bounds.is_none() && filter_bounds == bounds).then(|| {
|
||||
shadows::DropShadowFilterCacheKey::for_shape(
|
||||
shape.id,
|
||||
shadow,
|
||||
@ -3682,7 +3692,7 @@ impl RenderState {
|
||||
// Apply spread geometrically to avoid dilate filter rounding issues
|
||||
let filter_result = filters::render_into_filter_surface(
|
||||
self,
|
||||
bounds,
|
||||
filter_bounds,
|
||||
blur_downscale,
|
||||
|state, temp_surface| {
|
||||
let canvas = state.surfaces.canvas(temp_surface);
|
||||
@ -3712,7 +3722,8 @@ impl RenderState {
|
||||
)?;
|
||||
|
||||
if let Some((mut surface, filter_scale)) = filter_result {
|
||||
let cached = shadows::CachedDropShadowFilter::new(bounds, filter_scale, &mut surface);
|
||||
let cached =
|
||||
shadows::CachedDropShadowFilter::new(filter_bounds, filter_scale, &mut surface);
|
||||
shadows::blit_cached_drop_shadow_filter(
|
||||
&mut self.surfaces,
|
||||
&cached,
|
||||
@ -3819,6 +3830,27 @@ impl RenderState {
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.draw_paint(&paint);
|
||||
|
||||
// Like CSS box-shadow, the shadow never shows under the shape.
|
||||
if element.clears_drop_shadow_underneath() {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_blend_mode(skia::BlendMode::DstOut);
|
||||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.save_layer(&layer_rec);
|
||||
self.render_drop_black_shadow(
|
||||
element,
|
||||
tree,
|
||||
element_extrect,
|
||||
&Shadow::silhouette(),
|
||||
clip_bounds.clone(),
|
||||
scale,
|
||||
None,
|
||||
target_surface,
|
||||
)?;
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
|
||||
self.surfaces.canvas(SurfaceId::DropShadows).restore();
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ use crate::shapes::{radius_to_sigma, Fill, Shadow, Shape, Type};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use crate::render::vector::draw_shape_geometry;
|
||||
use crate::render::RenderState;
|
||||
|
||||
// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a
|
||||
// `save_layer`, so composite effects rendered with `save_layer` (opacity,
|
||||
@ -222,6 +223,35 @@ impl SvgLayerCanvas {
|
||||
self.finish_clip_path_fragment(id, canvas);
|
||||
}
|
||||
|
||||
/// Opens a group whose mask hides `element`'s outline, so its drop shadows
|
||||
/// never show under it (CSS `box-shadow`). Returns whether it opened one.
|
||||
pub(super) fn open_drop_shadow_clear_group(&mut self, element: &Shape) -> bool {
|
||||
if !element.clears_drop_shadow_underneath() {
|
||||
return false;
|
||||
}
|
||||
let id = self.unique("dsmask");
|
||||
let canvas = self.new_fragment();
|
||||
{
|
||||
let cv: &skia::Canvas = &canvas;
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(skia::Color::WHITE);
|
||||
cv.save();
|
||||
cv.reset_matrix();
|
||||
cv.draw_rect(
|
||||
skia::Rect::from_wh(self.page_rect.width(), self.page_rect.height()),
|
||||
&paint,
|
||||
);
|
||||
cv.restore();
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_color(skia::Color::BLACK);
|
||||
cv.concat(&element.centered_transform());
|
||||
cv.draw_path(&RenderState::drop_shadow_clear_path(element), &paint);
|
||||
}
|
||||
self.finish_mask_fragment(&id, canvas);
|
||||
self.open_group(&format!("mask=\"url(#{id})\""));
|
||||
true
|
||||
}
|
||||
|
||||
/// Finalizes a fragment canvas as a `<clipPath>` def.
|
||||
///
|
||||
/// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd
|
||||
|
||||
@ -34,6 +34,7 @@ pub(super) fn render_frame(
|
||||
// (GPU), filter only blurs/tints — keeps stroke-ring width and rotation.
|
||||
if !builder.suppress_filters {
|
||||
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
|
||||
let cleared = !drops.is_empty() && builder.open_drop_shadow_clear_group(element);
|
||||
for shadow in &drops {
|
||||
let id = push_container_drop_filter(builder, shadow);
|
||||
builder.open_group(&format!("filter=\"url(#{id})\""));
|
||||
@ -49,6 +50,9 @@ pub(super) fn render_frame(
|
||||
builder.suppress_filters = prev_suppress;
|
||||
builder.close_group();
|
||||
}
|
||||
if cleared {
|
||||
builder.close_group();
|
||||
}
|
||||
}
|
||||
|
||||
let effects = content_effect_attrs(builder, element);
|
||||
|
||||
@ -298,6 +298,7 @@ fn render_leaf(
|
||||
// feMorphology rounding on circles). Filter only blurs/tints.
|
||||
if !builder.suppress_filters {
|
||||
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
|
||||
let cleared = !drops.is_empty() && builder.open_drop_shadow_clear_group(element);
|
||||
for shadow in &drops {
|
||||
let id = push_container_drop_filter(builder, shadow);
|
||||
builder.open_group(&format!("filter=\"url(#{id})\""));
|
||||
@ -313,6 +314,9 @@ fn render_leaf(
|
||||
builder.suppress_filters = prev_suppress;
|
||||
builder.close_group();
|
||||
}
|
||||
if cleared {
|
||||
builder.close_group();
|
||||
}
|
||||
}
|
||||
|
||||
let effects = content_effect_attrs(builder, element);
|
||||
|
||||
@ -577,6 +577,43 @@ fn exports_leaf_drop_shadow_as_svg_filter() {
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
fn render_rect_with_drop_shadow(fill: skia::Color) -> String {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 100.0, 80.0), fill);
|
||||
pool.get_mut(&id).unwrap().add_shadow(Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
8.0,
|
||||
0.0,
|
||||
(4.0, 6.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
render(&pool, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translucent_leaf_hides_drop_shadow_underneath() {
|
||||
let svg = render_rect_with_drop_shadow(skia::Color::from_argb(100, 255, 0, 0));
|
||||
let mask_at = svg
|
||||
.find("mask=\"url(#dsmask")
|
||||
.unwrap_or_else(|| panic!("drop shadow must be masked under a translucent fill: {svg}"));
|
||||
let filter_at = svg.find("filter=\"url(#fx").expect("drop filter");
|
||||
assert!(
|
||||
mask_at < filter_at,
|
||||
"the mask must wrap the drop shadow group: {svg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_leaf_keeps_drop_shadow_unmasked() {
|
||||
let svg = render_rect_with_drop_shadow(skia::Color::from_rgb(255, 0, 0));
|
||||
assert!(
|
||||
!svg.contains("dsmask"),
|
||||
"an opaque fill hides the shadow already: {svg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_drop_offset_follows_rotation_in_user_space() {
|
||||
let mut pool = ShapesPool::new();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use skia_safe::{self as skia, Canvas, Paint, RRect};
|
||||
use skia_safe::{self as skia, Canvas, Matrix, Paint, RRect};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{
|
||||
@ -129,6 +129,8 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
|
||||
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
|
||||
let layer_bounds = shape.layer_bounds();
|
||||
self.canvas.save();
|
||||
clip_out_drop_shadow_underneath(self.canvas, shape, &Matrix::default());
|
||||
for shadow in shape.drop_shadows_visible() {
|
||||
if let Some(filter) = shadow.get_drop_shadow_blur_filter() {
|
||||
let mut paint = Paint::default();
|
||||
@ -150,6 +152,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
|
||||
self.canvas.restore();
|
||||
}
|
||||
}
|
||||
self.canvas.restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1026,6 +1029,8 @@ fn render_container_drop_shadows(
|
||||
opts: &TreeOpts,
|
||||
) -> Result<()> {
|
||||
let subtree_bounds = element.extrect(tree, scale);
|
||||
canvas.save();
|
||||
clip_out_drop_shadow_underneath(canvas, element, &element.centered_transform());
|
||||
for shadow in element.drop_shadows_visible() {
|
||||
let Some(filter) = shadow.get_drop_shadow_blur_filter() else {
|
||||
continue;
|
||||
@ -1064,9 +1069,19 @@ fn render_container_drop_shadows(
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
canvas.restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Like CSS `box-shadow`, a drop shadow never shows under its shape.
|
||||
/// `local_to_canvas` maps shape-local space to the current canvas space.
|
||||
fn clip_out_drop_shadow_underneath(canvas: &Canvas, shape: &Shape, local_to_canvas: &Matrix) {
|
||||
if shape.clears_drop_shadow_underneath() {
|
||||
let path = RenderState::drop_shadow_clear_path(shape).make_transform(local_to_canvas);
|
||||
canvas.clip_path(&path, skia::ClipOp::Difference, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// `shape` grown by a drop-shadow `spread` for its silhouette, through its own
|
||||
/// strokes and joins (as the GPU does for paths). Empty frames and containers
|
||||
/// have no outline of their own to grow.
|
||||
|
||||
@ -1497,6 +1497,12 @@ impl Shape {
|
||||
.filter(|shadow| shadow.style() == ShadowStyle::Drop && !shadow.hidden())
|
||||
}
|
||||
|
||||
pub fn clears_drop_shadow_underneath(&self) -> bool {
|
||||
!matches!(self.shape_type, Type::Text(_))
|
||||
&& self.has_fills()
|
||||
&& !self.fills.iter().any(Fill::is_opaque)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn inner_shadows(&self) -> impl DoubleEndedIterator<Item = &Shadow> {
|
||||
self.shadows
|
||||
|
||||
@ -264,6 +264,16 @@ impl Fill {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_opaque(&self) -> bool {
|
||||
match self {
|
||||
Fill::Solid(SolidColor(color)) => color.a() == 255,
|
||||
Fill::LinearGradient(g) | Fill::RadialGradient(g) => {
|
||||
g.opacity == 255 && g.colors.iter().all(|c| c.a() == 255)
|
||||
}
|
||||
Fill::Image(i) => i.opacity == 255,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_full_opacity(&self) -> Fill {
|
||||
match self {
|
||||
Fill::Solid(SolidColor(color)) => Fill::Solid(SolidColor(skia::Color::from_argb(
|
||||
|
||||
@ -60,6 +60,18 @@ impl Shadow {
|
||||
self.hidden
|
||||
}
|
||||
|
||||
/// The bare shape silhouette: no offset, blur or spread.
|
||||
pub fn silhouette() -> Self {
|
||||
Self::new(
|
||||
skia::Color::BLACK,
|
||||
0.0,
|
||||
0.0,
|
||||
(0.0, 0.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Approximate on-screen footprint (blur/spread + offset) at `scale` (zoom×dpr).
|
||||
#[inline]
|
||||
pub fn device_extent(&self, scale: f32) -> f32 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user