diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 83a6c6d0bc..8d41e4fb62 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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, + 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 { let mut result = Vec::new(); @@ -137,10 +147,27 @@ impl NodeRenderState { offset: Option<(f32, f32)>, clip_inset: Option, ) -> Option { - 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, + offset: Option<(f32, f32)>, + clip_inset: Option, + ) -> Option { + 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, + pass: SilhouettePass, + ) -> Result<()> { + let mut black_shadow: Cow = 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, + 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 = 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, + 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, @@ -3237,6 +3440,24 @@ impl RenderState { extra_layer_blur: Option, 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 = 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)); + } +} diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index 19f90542cf..66400f5f44 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -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(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index b59ed17bce..0aef001ca7 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -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 { 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 + '_ { + 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 { + 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> = 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, blur: Option) -> 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 = 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()); diff --git a/render-wasm/src/shapes/blurs.rs b/render-wasm/src/shapes/blurs.rs index 828dcb10d3..533e2424c2 100644 --- a/render-wasm/src/shapes/blurs.rs +++ b/render-wasm/src/shapes/blurs.rs @@ -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); + } +} diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 6f446e3223..d77ad8bdd9 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -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.