Files
penpot/render-wasm/src/shapes.rs
Belén Albeza 0581c35452 🐛 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
2026-09-18 13:04:11 +02:00

2701 lines
87 KiB
Rust

use skia_safe::{self as skia};
use indexmap::IndexSet;
use crate::uuid::Uuid;
use std::borrow::Cow;
use std::cell::{OnceCell, RefCell};
use std::collections::HashSet;
use std::iter::once;
mod blend;
mod blurs;
mod bools;
mod corners;
mod fills;
mod fonts;
mod frames;
mod groups;
mod layouts;
pub mod modifiers;
mod paths;
mod rects;
mod shadows;
mod shape_to_path;
mod stroke_paths;
mod strokes;
mod svg_attrs;
mod svgraw;
mod text;
pub mod text_paths;
mod transform;
pub use blend::*;
pub use blurs::{radius_to_sigma, Blur, BlurType};
pub use bools::*;
pub use corners::*;
pub use fills::*;
pub use fonts::*;
pub use frames::*;
pub use groups::*;
pub use layouts::*;
pub use modifiers::*;
pub use paths::*;
pub use rects::*;
pub use shadows::*;
pub use shape_to_path::*;
pub use stroke_paths::*;
pub use strokes::*;
pub use svg_attrs::*;
pub use svgraw::*;
pub use text::*;
pub use transform::*;
use crate::math::{self, Bounds, Matrix, Point};
use crate::state::ShapesPoolRef;
const MIN_VISIBLE_SIZE: f32 = 2.0;
const MIN_STROKE_WIDTH: f32 = 0.001;
#[derive(Debug, Clone, PartialEq)]
pub enum Type {
Frame(Frame),
Group(Group),
Bool(Bool),
Rect(Rect),
Path(Path),
Text(TextContent),
Circle, // FIXME: shouldn't this have a rect inside, like the Rect variant?
SVGRaw(SVGRaw),
}
impl Type {
pub fn corners(&self) -> Option<Corners> {
match self {
Type::Rect(Rect { corners, .. }) => *corners,
Type::Frame(Frame { corners, .. }) => *corners,
_ => None,
}
}
pub fn set_corners(&mut self, corners: Corners) {
match self {
Type::Rect(data) => {
data.corners = Some(corners);
}
Type::Frame(data) => {
data.corners = Some(corners);
}
_ => {}
}
}
pub fn clear_corners(&mut self) {
match self {
Type::Rect(data) => {
data.corners = None;
}
Type::Frame(data) => {
data.corners = None;
}
_ => {}
}
}
pub fn path(&self) -> Option<&Path> {
match self {
Type::Path(path) => Some(path),
Type::Bool(Bool { path, .. }) => Some(path),
_ => None,
}
}
pub fn path_mut(&mut self) -> Option<&mut Path> {
match self {
Type::Path(path) => Some(path),
Type::Bool(Bool { path, .. }) => Some(path),
_ => None,
}
}
pub fn scale_content(&mut self, value: f32) {
match self {
Type::Rect(Rect {
corners: Some(corners),
..
}) => {
corners::scale_corners(corners, value);
}
Type::Frame(Frame { corners, layout }) => {
if let Some(corners) = corners {
corners::scale_corners(corners, value);
}
if let Some(layout) = layout {
layout.scale_content(value);
}
}
Type::Text(TextContent { paragraphs, .. }) => {
paragraphs.iter_mut().for_each(|p| p.scale_content(value));
}
_ => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub enum ConstraintH {
Left,
Right,
LeftRight,
Center,
Scale,
}
#[derive(Debug, Clone, PartialEq, Copy)]
#[repr(u8)]
pub enum VerticalAlign {
Top = 0,
Center = 1,
Bottom = 2,
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub enum ConstraintV {
Top,
Bottom,
TopBottom,
Center,
Scale,
}
pub type Color = skia::Color;
#[derive(Debug, Clone)]
pub struct Shape {
pub id: Uuid,
pub parent_id: Option<Uuid>,
pub shape_type: Type,
pub children: Vec<Uuid>,
pub selrect: math::Rect,
pub transform: Matrix,
pub rotation: f32,
pub constraint_h: Option<ConstraintH>,
pub constraint_v: Option<ConstraintV>,
pub clip_content: bool,
pub fills: Vec<Fill>,
pub strokes: Vec<Stroke>,
pub blend_mode: BlendMode,
pub vertical_align: VerticalAlign,
pub blur: Option<Blur>,
pub background_blur: Option<Blur>,
pub opacity: f32,
pub hidden: bool,
pub svg: Option<skia::svg::Dom>,
pub svg_attrs: Option<SvgAttrs>,
pub shadows: Vec<Shadow>,
pub layout_item: Option<LayoutItem>,
pub bounds: OnceCell<math::Bounds>,
pub extrect_cache: RefCell<Option<math::Rect>>,
pub svg_transform: Option<Matrix>,
pub ignore_constraints: bool,
deleted: bool,
/// Fills from a cold-load batch, held until text content is uploaded and laid out.
deferred_batch_fills: Option<Vec<Fill>>,
/// Strokes from a cold-load batch, applied together with deferred fills.
deferred_batch_strokes: Option<Vec<Stroke>>,
}
// Returns all ancestor shapes of this shape, traversing up the parent hierarchy
//
// This function walks up the parent chain starting from this shape's parent,
// collecting all ancestor IDs. It stops when it reaches a nil UUID or when
// an ancestor is hidden (unless include_hidden is true).
//
// # Arguments
// * `shapes` - The shapes pool containing all shapes
// * `include_hidden` - Whether to include hidden ancestors in the result
//
// # Returns
// A set of ancestor UUIDs in traversal order (closest ancestor first)
pub fn all_with_ancestors(
shapes: &[Uuid],
shapes_pool: ShapesPoolRef,
include_hidden: bool,
) -> Vec<Uuid> {
let mut pending = Vec::from_iter(shapes.iter());
let mut result = Vec::new();
let mut seen = HashSet::new();
while !pending.is_empty() {
let Some(current_id) = pending.pop() else {
break;
};
if !seen.insert(*current_id) {
continue;
}
result.push(*current_id);
let Some(parent_id) = shapes_pool.get(current_id).and_then(|s| s.parent_id) else {
continue;
};
if parent_id == Uuid::nil() {
continue;
}
if seen.contains(&parent_id) {
continue;
}
// Check if the ancestor is hidden
let Some(parent) = shapes_pool.get(&parent_id) else {
continue;
};
if !include_hidden && parent.hidden() {
continue;
}
pending.push(&parent.id);
}
result
}
impl Shape {
pub fn get_relative_point(
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
) -> Option<Point> {
let inv_view_matrix = view_matrix.invert()?;
let inv_shape_matrix = shape_matrix.invert()?;
let transform_matrix: Matrix = Matrix::concat(&inv_shape_matrix, &inv_view_matrix);
let shape_relative_point = transform_matrix.map_point(*point);
Some(shape_relative_point)
}
pub fn new(id: Uuid) -> Self {
Self {
id,
parent_id: None,
shape_type: Type::Rect(Rect::default()),
children: Vec::new(),
selrect: math::Rect::new_empty(),
transform: Matrix::default(),
rotation: 0.,
constraint_h: None,
constraint_v: None,
clip_content: true,
fills: Vec::with_capacity(1),
strokes: Vec::with_capacity(1),
blend_mode: BlendMode::default(),
vertical_align: VerticalAlign::Top,
opacity: 1.,
hidden: false,
blur: None,
background_blur: None,
svg: None,
svg_attrs: None,
shadows: Vec::with_capacity(1),
layout_item: None,
bounds: OnceCell::new(),
extrect_cache: RefCell::new(None),
svg_transform: None,
ignore_constraints: false,
deleted: false,
deferred_batch_fills: None,
deferred_batch_strokes: None,
}
}
pub fn scale_content(&mut self, value: f32) {
self.ignore_constraints = true;
self.shape_type.scale_content(value);
self.strokes.iter_mut().for_each(|s| s.scale_content(value));
self.shadows.iter_mut().for_each(|s| s.scale_content(value));
if let Some(blur) = self.blur.as_mut() {
blur.scale_content(value);
}
if let Some(background_blur) = self.background_blur.as_mut() {
background_blur.scale_content(value);
}
self.layout_item
.iter_mut()
.for_each(|i| i.scale_content(value));
}
pub fn invalidate_bounds(&mut self) {
self.bounds = OnceCell::new();
}
pub fn invalidate_extrect(&mut self) {
*self.extrect_cache.borrow_mut() = None;
}
pub fn set_parent(&mut self, id: Uuid) {
self.parent_id = Some(id);
}
pub fn set_shape_type(&mut self, shape_type: Type) {
self.shape_type = shape_type;
}
#[allow(dead_code)]
pub fn is_frame(&self) -> bool {
matches!(self.shape_type, Type::Frame(_))
}
pub fn is_bool(&self) -> bool {
matches!(self.shape_type, Type::Bool(_))
}
pub fn is_group_like(&self) -> bool {
matches!(self.shape_type, Type::Group(_)) || matches!(self.shape_type, Type::Bool(_))
}
pub fn has_layout(&self) -> bool {
matches!(
self.shape_type,
Type::Frame(Frame {
layout: Some(_),
..
})
)
}
#[allow(dead_code)]
pub fn is_flex(&self) -> bool {
matches!(
self.shape_type,
Type::Frame(Frame {
layout: Some(layouts::Layout::FlexLayout(_, _)),
..
})
)
}
pub fn is_flex_reverse(&self) -> bool {
matches!(
self.shape_type,
Type::Frame(Frame {
layout: Some(layouts::Layout::FlexLayout(
_,
FlexData {
direction: layouts::FlexDirection::RowReverse
| layouts::FlexDirection::ColumnReverse,
..
}
)),
..
})
)
}
pub fn set_selrect(&mut self, left: f32, top: f32, right: f32, bottom: f32) {
self.invalidate_bounds();
self.invalidate_extrect();
self.selrect.set_ltrb(left, top, right, bottom);
if let Type::Text(ref mut text) = self.shape_type {
// `update_layout` syncs bounds via set_xywh before baking fill paints.
text.update_layout(self.selrect);
}
}
pub fn set_masked(&mut self, masked: bool) {
if let Type::Group(data) = &mut self.shape_type {
data.masked = masked;
}
}
pub fn set_clip(&mut self, value: bool) {
self.clip_content = value;
}
pub fn set_rotation(&mut self, angle: f32) {
self.rotation = angle;
self.invalidate_extrect();
}
pub fn set_transform(&mut self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) {
self.transform = Matrix::new_all(a, c, e, b, d, f, 0.0, 0.0, 1.0);
self.invalidate_extrect();
}
pub fn set_opacity(&mut self, opacity: f32) {
self.opacity = opacity;
}
pub fn set_vertical_align(&mut self, align: VerticalAlign) {
self.vertical_align = align;
}
pub fn vertical_align(&self) -> VerticalAlign {
self.vertical_align
}
pub fn clear_constraints(&mut self) {
self.constraint_h = None;
self.constraint_v = None;
}
pub fn set_constraint_h(&mut self, constraint: Option<ConstraintH>) {
self.constraint_h = constraint;
}
pub fn constraint_h(&self, default: ConstraintH) -> ConstraintH {
self.constraint_h.unwrap_or(default)
}
pub fn set_constraint_v(&mut self, constraint: Option<ConstraintV>) {
self.constraint_v = constraint;
}
pub fn constraint_v(&self, default: ConstraintV) -> ConstraintV {
self.constraint_v.unwrap_or(default)
}
pub fn set_hidden(&mut self, value: bool) {
self.hidden = value;
}
pub fn svg_transform(&self) -> Option<Matrix> {
self.svg_transform
}
pub fn set_deleted(&mut self, value: bool) {
self.deleted = value;
}
pub fn deleted(&self) -> bool {
self.deleted
}
// FIXME: These arguments could be grouped or simplified
#[allow(clippy::too_many_arguments)]
pub fn set_flex_layout_child_data(
&mut self,
margin_top: f32,
margin_right: f32,
margin_bottom: f32,
margin_left: f32,
h_sizing: Sizing,
v_sizing: Sizing,
max_h: Option<f32>,
min_h: Option<f32>,
max_w: Option<f32>,
min_w: Option<f32>,
align_self: Option<AlignSelf>,
is_absolute: bool,
z_index: Option<i32>,
) {
self.layout_item = Some(LayoutItem {
margin_top,
margin_right,
margin_bottom,
margin_left,
h_sizing,
v_sizing,
max_h,
min_h,
max_w,
min_w,
is_absolute,
z_index,
align_self,
});
}
pub fn clear_layout(&mut self) {
self.layout_item = None;
if let Type::Frame(data) = &mut self.shape_type {
data.layout = None;
}
}
// FIXME: These arguments could be grouped or simplified
#[allow(clippy::too_many_arguments)]
pub fn set_flex_layout_data(
&mut self,
direction: FlexDirection,
row_gap: f32,
column_gap: f32,
align_items: AlignItems,
align_content: AlignContent,
justify_items: JustifyItems,
justify_content: JustifyContent,
wrap_type: WrapType,
padding_top: f32,
padding_right: f32,
padding_bottom: f32,
padding_left: f32,
) {
if let Type::Frame(data) = &mut self.shape_type {
let layout_data = LayoutData {
align_items,
align_content,
justify_items,
justify_content,
padding_top,
padding_right,
padding_bottom,
padding_left,
row_gap,
column_gap,
};
let flex_data = FlexData {
direction,
wrap_type,
};
data.layout = Some(Layout::FlexLayout(layout_data, flex_data));
}
}
// FIXME: These argumoents could be grouped or simplified
#[allow(clippy::too_many_arguments)]
pub fn set_grid_layout_data(
&mut self,
direction: GridDirection,
row_gap: f32,
column_gap: f32,
align_items: AlignItems,
align_content: AlignContent,
justify_items: JustifyItems,
justify_content: JustifyContent,
padding_top: f32,
padding_right: f32,
padding_bottom: f32,
padding_left: f32,
) {
if let Type::Frame(data) = &mut self.shape_type {
if let Some(Layout::GridLayout(layout_data, grid_data)) = &mut data.layout {
layout_data.align_items = align_items;
layout_data.align_content = align_content;
layout_data.justify_items = justify_items;
layout_data.justify_content = justify_content;
layout_data.padding_top = padding_top;
layout_data.padding_right = padding_right;
layout_data.padding_bottom = padding_bottom;
layout_data.padding_left = padding_left;
layout_data.row_gap = row_gap;
layout_data.column_gap = column_gap;
grid_data.direction = direction;
} else {
let layout_data = LayoutData {
align_items,
align_content,
justify_items,
justify_content,
padding_top,
padding_right,
padding_bottom,
padding_left,
row_gap,
column_gap,
};
let mut grid_data = GridData::default();
grid_data.direction = direction;
data.layout = Some(Layout::GridLayout(layout_data, grid_data));
}
}
}
pub fn set_grid_columns(&mut self, tracks: Vec<GridTrack>) {
let Type::Frame(frame_data) = &mut self.shape_type else {
return;
};
let Some(Layout::GridLayout(_, grid_data)) = &mut frame_data.layout else {
return;
};
grid_data.columns = tracks;
}
pub fn set_grid_rows(&mut self, tracks: Vec<GridTrack>) {
let Type::Frame(frame_data) = &mut self.shape_type else {
return;
};
let Some(Layout::GridLayout(_, grid_data)) = &mut frame_data.layout else {
return;
};
grid_data.rows = tracks;
}
pub fn set_grid_cells(&mut self, cells: Vec<GridCell>) {
let Type::Frame(frame_data) = &mut self.shape_type else {
return;
};
let Some(Layout::GridLayout(_, grid_data)) = &mut frame_data.layout else {
return;
};
grid_data.cells = cells;
}
pub fn set_blur(&mut self, blur: Option<Blur>) {
self.invalidate_extrect();
self.blur = blur;
}
pub fn set_background_blur(&mut self, blur: Option<Blur>) {
self.invalidate_extrect();
self.background_blur = blur;
}
pub fn visible_background_blur(&self) -> Option<Blur> {
self.background_blur.filter(|blur| !blur.hidden)
}
/// Visible layer blur (`!hidden`, `LayerBlur`, `value > 0`).
pub fn visible_layer_blur(&self) -> Option<Blur> {
self.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
})
}
#[cfg(test)]
pub fn add_child(&mut self, id: Uuid) {
self.children.push(id);
}
pub fn compute_children_differences(&mut self, children: &[Uuid]) -> (Vec<Uuid>, Vec<Uuid>) {
let current_set: HashSet<Uuid> = self.children.iter().copied().collect();
let new_set: HashSet<Uuid> = children.iter().copied().collect();
let added: Vec<Uuid> = new_set.difference(&current_set).copied().collect();
let removed: Vec<Uuid> = current_set.difference(&new_set).copied().collect();
(added, removed)
}
#[allow(dead_code)]
pub fn fills(&self) -> std::slice::Iter<'_, Fill> {
self.fills.iter()
}
pub fn set_fills(&mut self, fills: Vec<Fill>) {
self.deferred_batch_fills = None;
self.fills = fills;
}
pub fn add_fill(&mut self, f: Fill) {
self.fills.push(f);
}
pub fn clear_fills(&mut self) {
self.fills.clear();
}
pub fn visible_strokes(&self) -> impl DoubleEndedIterator<Item = &Stroke> {
self.strokes
.iter()
.filter(|stroke| stroke.max_width() > MIN_STROKE_WIDTH)
}
pub fn has_visible_strokes(&self) -> bool {
self.strokes
.iter()
.any(|stroke| stroke.max_width() > MIN_STROKE_WIDTH)
}
pub fn add_stroke(&mut self, s: Stroke) {
self.invalidate_extrect();
self.strokes.push(s)
}
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")?;
stroke.widths = Some(widths);
self.invalidate_extrect();
Ok(())
}
pub fn set_stroke_fill(&mut self, f: Fill) -> Result<(), String> {
let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
stroke.fill = f;
Ok(())
}
pub fn clear_strokes(&mut self) {
self.deferred_batch_strokes = None;
self.invalidate_extrect();
self.strokes.clear();
}
pub fn set_deferred_batch_fills(&mut self, fills: Vec<Fill>) {
self.deferred_batch_fills = Some(fills);
}
pub fn set_deferred_batch_strokes(&mut self, strokes: Vec<Stroke>) {
self.deferred_batch_strokes = Some(strokes);
}
/// Apply fill/stroke records that were parsed from a batch upload but held
/// back until text content exists and has been laid out.
pub fn apply_deferred_batch_paint(&mut self) {
if let Some(fills) = self.deferred_batch_fills.take() {
self.fills = fills;
}
if let Some(strokes) = self.deferred_batch_strokes.take() {
self.strokes = strokes;
self.invalidate_extrect();
}
}
pub fn set_path_segments(&mut self, segments: Vec<Segment>) {
match &mut self.shape_type {
Type::Bool(Bool { bool_type, .. }) => {
let path = match bool_type {
// Exclusion booleans are computed with even-odd semantics but
// PathData uploads do not carry the fill rule.
BoolType::Exclusion => Path::new(segments).with_even_odd(true),
_ => Path::new(segments),
};
self.shape_type = Type::Bool(Bool {
bool_type: *bool_type,
path,
});
}
Type::Path(_) => {
self.shape_type = Type::Path(Path::new(segments));
}
_ => {}
};
self.invalidate_bounds();
self.invalidate_extrect();
}
pub fn update_svg_raw_content(&mut self, font_manager: skia::FontMgr) {
match &self.shape_type {
Type::SVGRaw(sr) => {
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
match dom_result {
Ok(dom) => {
self.set_svg(dom);
}
Err(e) => {
eprintln!("Error parsing SVG. Error: {}", e);
}
}
}
_ => panic!("Updating SVG raw content on non SVG Raw shape"),
}
}
pub fn set_svg_raw_content(&mut self, content: String) {
self.shape_type = Type::SVGRaw(SVGRaw::from_content(content));
}
pub fn set_blend_mode(&mut self, mode: BlendMode) {
self.blend_mode = mode;
}
pub fn set_bool_type(&mut self, bool_type: BoolType) {
self.shape_type = match &self.shape_type {
Type::Bool(Bool { path, .. }) => Type::Bool(Bool {
bool_type,
path: path.clone(),
}),
_ => Type::Bool(Bool {
bool_type,
path: Path::default(),
}),
};
}
pub fn set_corners(&mut self, raw_corners: (f32, f32, f32, f32)) {
if let Some(corners) = make_corners(raw_corners) {
self.shape_type.set_corners(corners);
} else {
self.shape_type.clear_corners();
}
self.invalidate_bounds();
self.invalidate_extrect();
}
pub fn set_svg(&mut self, svg: skia::svg::Dom) {
self.svg = Some(svg);
}
pub fn blend_mode(&self) -> BlendMode {
self.blend_mode
}
pub fn opacity(&self) -> f32 {
self.opacity
}
pub fn hidden(&self) -> bool {
self.hidden
}
#[allow(dead_code)]
pub fn width(&self) -> f32 {
self.selrect.width()
}
pub fn extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
self.calculate_extrect(shapes_pool, scale)
}
pub fn visually_insignificant(&self, scale: f32, shapes_pool: ShapesPoolRef) -> bool {
let extrect = self.extrect(shapes_pool, scale);
extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE
}
pub fn should_use_antialias(&self, scale: f32, threshold: f32) -> bool {
self.selrect.width() * scale > threshold || self.selrect.height() * scale > threshold
}
pub fn calculate_bounds(&self, apply_transform: bool) -> Bounds {
let mut bounds = Bounds::new(
Point::new(self.selrect.x(), self.selrect.y()),
Point::new(self.selrect.x() + self.selrect.width(), self.selrect.y()),
Point::new(
self.selrect.x() + self.selrect.width(),
self.selrect.y() + self.selrect.height(),
),
Point::new(self.selrect.x(), self.selrect.y() + self.selrect.height()),
);
// Apply this transformation only when self.transform
// is not the identity matrix because if it is,
// the result of applying this transformations would be
// the same identity matrix.
if apply_transform && !self.transform.is_identity() {
let mut matrix = self.transform;
let center = self.center();
matrix.post_translate(center);
matrix.pre_translate(-center);
bounds.transform_mut(&matrix);
}
bounds
}
pub fn bounds(&self) -> Bounds {
*self.bounds.get_or_init(|| self.calculate_bounds(true))
}
pub fn selrect(&self) -> math::Rect {
self.selrect
}
pub fn get_text_content(&self) -> &TextContent {
match &self.shape_type {
crate::shapes::Type::Text(text_content) => text_content,
_ => panic!("Shape is not of type Text"),
}
}
/// Calculates the bounding rectangle for a selrect shape's shadow, taking into account
/// stroke widths and shadow properties.
///
/// This method computes the expanded bounds that would be needed to fully render
/// the shadow effect for a shape. It considers:
/// - The base bounds (selection rectangle)
/// - Maximum stroke width across all strokes, accounting for stroke rendering kind
/// - Shadow offset (x, y displacement)
/// - Shadow blur radius (expands bounds outward)
/// - Whether the shadow is hidden
///
/// # Arguments
/// * `shadow` - The shadow configuration containing offset, blur, and visibility
///
/// # Returns
/// A `math::Rect` representing the bounding rectangle that encompasses the shadow.
/// Returns an empty rectangle if the shadow is hidden.
pub fn get_selrect_shadow_bounds(&self, shadow: &Shadow) -> math::Rect {
let base_bounds = self.selrect();
let mut rect = skia::Rect::new_empty();
let mut max_stroke: Option<f32> = None;
for stroke in self.strokes.iter() {
let width = match stroke.render_kind(false) {
StrokeKind::Inner => -stroke.width / 2.,
StrokeKind::Center => 0.,
StrokeKind::Outer => stroke.width,
};
max_stroke = Some(max_stroke.unwrap_or(f32::MIN).max(width));
}
if !shadow.hidden() {
let (x, y) = shadow.offset;
let mut shadow_rect = base_bounds;
shadow_rect.left += x;
shadow_rect.right += x;
shadow_rect.top += y;
shadow_rect.bottom += y;
shadow_rect.left -= shadow.blur;
shadow_rect.top -= shadow.blur;
shadow_rect.right += shadow.blur;
shadow_rect.bottom += shadow.blur;
if let Some(max_stroke) = max_stroke {
shadow_rect.left -= max_stroke;
shadow_rect.right += max_stroke;
shadow_rect.top -= max_stroke;
shadow_rect.bottom += max_stroke;
}
rect.join(shadow_rect);
}
rect
}
fn apply_stroke_bounds(&self, bounds: Bounds, stroke_width: f32) -> Bounds {
let mut result = bounds.to_rect();
if stroke_width > 0.0 {
let mut expanded_rect = bounds.to_rect();
expanded_rect.inset((-stroke_width, -stroke_width));
result.join(expanded_rect);
}
let cap_margin = self.cap_bounds_margin();
if cap_margin > 0.0 {
let mut cap_rect = bounds.to_rect();
cap_rect.inset((-cap_margin, -cap_margin));
result.join(cap_rect);
}
Bounds::from_rect(&result)
}
fn apply_cap_bounds(&self, bounds: Bounds, cap_margin: f32) -> Bounds {
let mut result = bounds.to_rect();
if cap_margin > 0.0 {
result.inset((-cap_margin, -cap_margin));
}
Bounds::from_rect(&result)
}
fn apply_shadow_bounds(&self, bounds: Bounds) -> Bounds {
let mut rect = bounds.to_rect();
for shadow in self.shadows_visible() {
if !shadow.hidden() {
if let Some(filter) = shadow.get_drop_shadow_filter() {
let shadow_bounds = filter.compute_fast_bounds(rect);
rect.join(shadow_bounds);
}
}
}
Bounds::from_rect(&rect)
}
fn apply_blur_bounds(&self, bounds: Bounds) -> Bounds {
let mut rect = bounds.to_rect();
let image_filter = self.image_filter(1.);
if let Some(image_filter) = image_filter {
let blur_bounds = image_filter.compute_fast_bounds(rect);
rect.join(blur_bounds);
}
Bounds::from_rect(&rect)
}
pub fn extrect_depends_on_children(&self) -> bool {
match self.shape_type {
Type::Group(Group { masked: true }) => true,
Type::Group(_) | Type::Frame(_) => !self.clip_content,
_ => false,
}
}
fn apply_children_bounds(
&self,
bounds: Bounds,
shapes_pool: ShapesPoolRef,
scale: f32,
) -> Bounds {
let mut rect = bounds.to_rect();
match self.shape_type {
Type::Group(Group { masked: true }) => {
let mut mask_rect: Option<math::Rect> = None;
let mut content_rect: Option<math::Rect> = None;
for (index, child_id) in self.children.iter().enumerate() {
if let Some(child_shape) = shapes_pool.get(child_id) {
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
if index == 0 {
mask_rect = Some(child_extrect);
} else {
match content_rect.as_mut() {
Some(r) => r.join(child_extrect),
None => content_rect = Some(child_extrect),
}
}
}
}
match (mask_rect, content_rect) {
(Some(mut mask), Some(content)) => {
if mask.intersect(content) {
rect.join(mask);
}
}
(Some(mask), None) | (None, Some(mask)) => {
rect.join(mask);
}
(None, None) => {}
}
}
Type::Group(_) | Type::Frame(_) if !self.clip_content => {
// For frames and groups, we must always calculate extrect for all children
// to ensure accurate bounds that include nested content across all tiles.
// Using selrect for children can cause frames to be incorrectly omitted from
// tiles where they have nested content.
for child_id in self.children_ids_iter(false) {
if let Some(child_shape) = shapes_pool.get(child_id) {
// Always calculate full extrect for children to ensure accurate bounds
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
rect.join(child_extrect);
}
}
}
_ => {}
}
Bounds::from_rect(&rect)
}
pub fn apply_children_blur(&self, bounds: Bounds, tree: ShapesPoolRef) -> Bounds {
let mut rect = bounds.to_rect();
let mut children_blur = 0.0;
let mut current_parent_id = self.parent_id;
while let Some(parent_id) = current_parent_id {
if parent_id.is_nil() {
break;
}
if let Some(parent) = tree.get(&parent_id) {
match parent.shape_type {
Type::Frame(_) | Type::Group(_) => {
if let Some(blur) = parent.blur {
if !blur.hidden && blur.blur_type == BlurType::LayerBlur {
children_blur += blur.value;
}
}
}
_ => {}
}
current_parent_id = parent.parent_id;
} else {
break;
}
}
let sigma = radius_to_sigma(children_blur);
let blur = skia::image_filters::blur((sigma, sigma), None, None, None);
if let Some(image_filter) = blur {
let blur_bounds = image_filter.compute_fast_bounds(rect);
rect.join(blur_bounds);
}
Bounds::from_rect(&rect)
}
pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
// `scale` is forwarded to children but intentionally NOT part of the cache key.
if let Some(cached_extrect) = *self.extrect_cache.borrow() {
return cached_extrect;
}
let extrect = self.calculate_extrect_uncached(shapes_pool, scale);
*self.extrect_cache.borrow_mut() = Some(extrect);
extrect
}
fn own_extrect_bounds(&self) -> Bounds {
self.expand_own_bounds(self.own_base_bounds(), true)
}
/// The shape's own geometry bounds, before stroke/shadow/blur margins.
fn own_base_bounds(&self) -> Bounds {
let shape = self;
let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open());
match &shape.shape_type {
Type::Path(_) | Type::Bool(_) => {
if let Some(path) = shape.get_skia_path() {
let cap_margin = shape.cap_bounds_margin();
let rect = path
.compute_tight_bounds()
.with_outset((max_stroke, max_stroke));
self.apply_cap_bounds(Bounds::from_rect(&rect), cap_margin)
} else {
shape.calculate_bounds(false)
}
}
Type::Text(text_content) => {
// FIXME: we need to recalculate the text bounds here because the shape's selrect
text_content.calculate_bounds(shape, false)
}
_ => shape.calculate_bounds(false),
}
}
fn expand_own_bounds(&self, bounds: Bounds, include_shadows: bool) -> Bounds {
let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open());
let mut bounds = self.apply_stroke_bounds(bounds, max_stroke);
if include_shadows {
bounds = self.apply_shadow_bounds(bounds);
}
self.apply_blur_bounds(bounds)
}
/// `own_base_bounds` for layer purposes: a text is never tighter than its selrect.
fn own_layer_base_bounds(&self) -> Bounds {
let mut bounds = self.own_base_bounds();
if matches!(self.shape_type, Type::Text(_)) {
let mut rect = bounds.to_rect();
rect.join(self.selrect);
bounds = Bounds::from_rect(&rect);
}
bounds
}
/// Bound for a `SaveLayerRec` wrapping this shape's own drawing, in
/// untransformed space (callers concatenate [`Self::centered_transform`]
/// first). Includes shadow/blur margins, so it is also a valid input bound
/// for a layer whose paint carries an image filter.
pub fn layer_bounds(&self) -> math::Rect {
self.expand_own_bounds(self.own_layer_base_bounds(), true)
.to_rect()
}
/// Geometry, strokes and layer blur in world space, without this shape's own
/// drop shadows: those belong to the shape, not to an ancestor's shadow mask.
pub fn silhouette_rect(&self) -> math::Rect {
let mut bounds = self.expand_own_bounds(self.own_layer_base_bounds(), false);
if !self.transform.is_identity() {
bounds.transform_mut(&self.centered_transform());
}
bounds.to_rect()
}
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
// Own outsets (strokes, shadows, blur) are local-space, so they expand before the
// shape transform. Children extrects are already world-space: join them after it.
let mut bounds = self.own_extrect_bounds();
if !self.transform.is_identity() {
let mut matrix = self.transform;
let center = self.center();
matrix.post_translate(center);
matrix.pre_translate(-center);
bounds.transform_mut(&matrix);
}
bounds = self.apply_children_bounds(bounds, shapes_pool, scale);
bounds = self.apply_children_blur(bounds, shapes_pool);
bounds.to_rect()
}
pub fn left_top(&self) -> Point {
Point::new(self.selrect.left, self.selrect.top)
}
pub fn center(&self) -> Point {
self.selrect.center()
}
// TODO: This can be used in more places
pub fn centered_transform(&self) -> Matrix {
let center = self.center();
let mut matrix = self.transform;
matrix.post_translate(center);
matrix.pre_translate(-center);
matrix
}
pub fn clip(&self) -> bool {
self.clip_content
}
pub fn cap_bounds_margin(&self) -> f32 {
if !self.is_open() {
return 0.0;
}
self.strokes
.iter()
.map(|stroke| stroke.cap_bounds_margin())
.fold(0.0, f32::max)
}
pub fn has_cap_bounds(&self) -> bool {
self.cap_bounds_margin() > 0.0
}
pub fn mask_id(&self) -> Option<&Uuid> {
self.children.first()
}
pub fn children_count(&self) -> usize {
self.children_ids_iter(false).count()
}
pub fn children_ids(&self, include_hidden: bool) -> Vec<Uuid> {
if include_hidden {
return self.children.iter().rev().copied().collect();
}
if let Type::Bool(_) = self.shape_type {
Vec::new()
} else if let Type::Group(group) = self.shape_type {
if group.masked {
self.children
.iter()
.rev()
.take(self.children.len() - 1)
.copied()
.collect()
} else {
self.children.iter().rev().copied().collect()
}
} else {
self.children.iter().rev().copied().collect()
}
}
pub fn children_ids_iter(&self, include_hidden: bool) -> Box<dyn Iterator<Item = &Uuid> + '_> {
if include_hidden {
return Box::new(self.children.iter().rev());
}
if let Type::Bool(_) = self.shape_type {
Box::new([].iter())
} else if let Type::Group(group) = self.shape_type {
if group.masked {
Box::new(self.children.iter().rev().take(self.children.len() - 1))
} else {
Box::new(self.children.iter().rev())
}
} else {
Box::new(self.children.iter().rev())
}
}
/// Returns children in forward (non-reversed) order - useful for layout calculations
pub fn children_ids_iter_forward(
&self,
include_hidden: bool,
) -> Box<dyn Iterator<Item = &Uuid> + '_> {
if include_hidden {
return Box::new(self.children.iter());
}
if let Type::Bool(_) = self.shape_type {
Box::new([].iter())
} else if let Type::Group(group) = self.shape_type {
if group.masked {
Box::new(self.children.iter().skip(1))
} else {
Box::new(self.children.iter())
}
} else {
Box::new(self.children.iter())
}
}
pub fn all_children(
&self,
shapes: ShapesPoolRef,
include_hidden: bool,
include_self: bool,
) -> Vec<Uuid> {
let all_children = self.children_ids_iter(include_hidden).flat_map(|id| {
shapes
.get(id)
.map(|s| s.all_children(shapes, include_hidden, true))
.unwrap_or_default()
});
if include_self {
once(self.id).chain(all_children).collect()
} else {
all_children.collect()
}
}
pub fn all_children_iter<'a>(
&'a self,
shapes: ShapesPoolRef<'a>,
include_hidden: bool,
include_self: bool,
) -> Box<dyn Iterator<Item = Uuid> + 'a> {
let all_children = self.children_ids_iter(include_hidden).flat_map(move |id| {
if let Some(shape) = shapes.get(id) {
shape.all_children_iter(shapes, include_hidden, true)
} else {
Box::new(std::iter::empty())
}
});
if include_self {
Box::new(once(self.id).chain(all_children))
} else {
Box::new(all_children)
}
}
pub fn get_matrix(&self) -> Matrix {
let mut matrix = Matrix::new_identity();
matrix.post_translate(self.left_top());
matrix.post_rotate(self.rotation, self.center());
matrix
}
#[allow(dead_code)]
pub fn get_concatenated_matrix(&self, shapes: ShapesPoolRef) -> Matrix {
let mut matrix = Matrix::new_identity();
let mut current_id = self.id;
while let Some(parent_id) = shapes.get(&current_id).and_then(|s| s.parent_id) {
if parent_id == Uuid::nil() {
break;
}
if let Some(parent) = shapes.get(&parent_id) {
matrix.pre_concat(&parent.get_matrix());
current_id = parent_id;
} else {
// FIXME: This should panic! I've removed it temporarily until
// we fix the problems with shapes without parents.
// panic!("Parent can't be found");
break;
}
}
matrix
}
pub fn image_filter(&self, scale: f32) -> Option<skia::ImageFilter> {
self.blur
.filter(|blur| !blur.hidden)
.and_then(|blur| match blur.blur_type {
BlurType::LayerBlur => {
let sigma = radius_to_sigma(blur.value * scale);
skia::image_filters::blur((sigma, sigma), None, None, None)
}
BlurType::BackgroundBlur => None,
})
}
/// Font families used by this shape (the text spans' families), or an
/// empty vec for non-text shapes.
pub fn font_families(&self) -> Vec<FontFamily> {
match &self.shape_type {
Type::Text(content) => content.font_families(),
_ => Vec::new(),
}
}
#[allow(dead_code)]
pub fn mask_filter(&self, scale: f32) -> Option<skia::MaskFilter> {
self.blur
.filter(|blur| !blur.hidden)
.and_then(|blur| match blur.blur_type {
BlurType::LayerBlur => {
let sigma = radius_to_sigma(blur.value * scale);
skia::MaskFilter::blur(skia::BlurStyle::Normal, sigma, Some(true))
}
BlurType::BackgroundBlur => None,
})
}
pub fn is_recursive(&self) -> bool {
matches!(
self.shape_type,
Type::Frame(_) | Type::Group(_) | Type::Bool(_)
)
}
pub fn is_open(&self) -> bool {
matches!(&self.shape_type, Type::Path(p) if p.is_open())
}
pub fn add_shadow(&mut self, shadow: Shadow) {
self.invalidate_extrect();
self.shadows.push(shadow);
}
pub fn clear_shadows(&mut self) {
self.invalidate_extrect();
self.shadows.clear();
}
#[allow(dead_code)]
pub fn drop_shadows(&self) -> impl DoubleEndedIterator<Item = &Shadow> {
self.shadows
.iter()
.rev()
.filter(|shadow| shadow.style() == ShadowStyle::Drop)
}
pub fn drop_shadows_visible(&self) -> impl DoubleEndedIterator<Item = &Shadow> {
self.shadows
.iter()
.rev()
.filter(|shadow| shadow.style() == ShadowStyle::Drop && !shadow.hidden())
}
#[allow(dead_code)]
pub fn inner_shadows(&self) -> impl DoubleEndedIterator<Item = &Shadow> {
self.shadows
.iter()
.rev()
.filter(|shadow| shadow.style() == ShadowStyle::Inner)
}
pub fn inner_shadows_visible(&self) -> impl DoubleEndedIterator<Item = &Shadow> {
self.shadows
.iter()
.rev()
.filter(|shadow| shadow.style() == ShadowStyle::Inner && !shadow.hidden())
}
pub fn shadows_visible(&self) -> impl DoubleEndedIterator<Item = &Shadow> {
self.shadows.iter().rev().filter(|shadow| !shadow.hidden())
}
pub fn to_path_transform(&self) -> Option<Matrix> {
match self.shape_type {
Type::Path(_) | Type::Bool(_) => {
let center = self.center();
let mut matrix = Matrix::new_identity();
matrix.pre_translate(center);
matrix.pre_concat(&self.transform.invert()?);
matrix.pre_translate(-center);
Some(matrix)
}
_ => None,
}
}
pub fn add_paragraph(&mut self, paragraph: Paragraph) -> Result<(), String> {
match self.shape_type {
Type::Text(ref mut text) => {
text.add_paragraph(paragraph);
Ok(())
}
_ => Err("Shape is not a text".to_string()),
}
}
pub fn clear_text(&mut self) {
self.invalidate_extrect();
if let Type::Text(old_text_content) = &self.shape_type {
let new_text_content = TextContent::new(self.selrect, old_text_content.grow_type());
self.shape_type = Type::Text(new_text_content);
}
}
pub fn get_skia_path(&self) -> Option<skia::Path> {
if let Some(path) = self.shape_type.path() {
let mut skia_path = path.to_skia_path(self.svg_attrs.as_ref());
if !math::identitish(&self.transform) {
if let Some(path_transform) = self.to_path_transform() {
skia_path = skia_path.make_transform(&path_transform);
}
}
Some(skia_path)
} else {
None
}
}
/// Same `concat` applied around [`center`](Self::center) as in `render_shape` (non-text branch).
fn shape_document_transform(&self) -> Matrix {
let c = self.center();
let mut m = self.transform;
m.post_translate(c);
m.pre_translate(-c);
m
}
/// Fill silhouette only, document space (matches fill rendering).
fn drag_crop_fill_clip_path_skia(&self) -> Option<skia::Path> {
match &self.shape_type {
Type::Rect(r) => {
let p = Path::new(shape_to_path::rect_segments(self, r.corners));
Some(p.to_skia_path(self.svg_attrs.as_ref()))
}
Type::Circle => {
let p = Path::new(shape_to_path::circle_segments(self));
Some(p.to_skia_path(self.svg_attrs.as_ref()))
}
Type::Path(_) | Type::Bool(_) => {
let sk = self.get_skia_path()?;
Some(sk.make_transform(&self.shape_document_transform()))
}
_ => None,
}
}
/// Whether this shape may use the backbuffer crop fast path during interactive drag.
///
/// Conservative: only effects and fills that match what we snapshot and clip in
/// [`drag_crop_clip_path`](Self::drag_crop_clip_path). Text is never safe (glyph layout,
/// no `drag_crop_clip_path`).
pub fn is_safe_for_drag_crop_cache(&self, shapes_pool: ShapesPoolRef) -> bool {
if matches!(self.shape_type, Type::Text(_)) {
return false;
}
if matches!(self.shape_type, Type::Group(_)) {
return false;
}
// If a frame shows overflow (clip_content=false) and its visible content exceeds the
// frame bounds, a cached crop anchored to the frame can easily become incorrect while
// moving (children can extend beyond selrect). Be conservative and render live.
if matches!(self.shape_type, Type::Frame(_)) && !self.clip_content {
let extrect = self.extrect(shapes_pool, 1.0);
let sr = self.selrect;
let exceeds = extrect.left < sr.left
|| extrect.top < sr.top
|| extrect.right > sr.right
|| extrect.bottom > sr.bottom;
if exceeds {
return false;
}
}
!self.has_cap_bounds()
&& self.blur.is_none()
&& self.background_blur.is_none()
&& self.shadows.is_empty()
&& (self.opacity - 1.0).abs() <= 1e-4
&& self.blend_mode().0 == skia::BlendMode::SrcOver
}
/// Fill + visible strokes in **document space** for clipping interactive drag textures.
///
/// The backbuffer crop uses an axis-aligned `extrect`; we clip the blit so backdrop pixels
/// outside the real silhouette (fill and stroke regions) are not smeared. Strokes use
/// [`stroke_to_path`](stroke_to_path) like the main renderer, then union with the fill path.
pub fn drag_crop_clip_path(&self) -> Option<skia::Path> {
let mut acc = self.drag_crop_fill_clip_path_skia()?;
if !self.has_visible_strokes() {
return Some(acc);
}
let shape_path = match &self.shape_type {
Type::Rect(r) => Path::new(shape_to_path::rect_segments(self, r.corners)),
Type::Circle => Path::new(shape_to_path::circle_segments(self)),
Type::Path(_) | Type::Bool(_) => self.shape_type.path()?.clone(),
_ => return Some(acc),
};
let path_transform = self.to_path_transform();
for stroke in self.visible_strokes() {
let Some(stroke_region) = stroke_to_path(
stroke,
&shape_path,
path_transform.as_ref(),
&self.selrect,
self.svg_attrs.as_ref(),
true,
) else {
continue;
};
let sk = stroke_region.to_skia_path(self.svg_attrs.as_ref());
acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc);
}
Some(acc)
}
fn transform_selrect(&mut self, transform: &Matrix) {
if math::is_move_only_matrix(transform) {
let tx = transform.translate_x();
let ty = transform.translate_y();
// `self.transform` (rotation/scale around center) is unchanged by translation.
self.selrect = math::Rect::from_xywh(
self.selrect.left + tx,
self.selrect.top + ty,
self.selrect.width(),
self.selrect.height(),
);
return;
}
let mut center = self.selrect.center();
center = transform.map_point(center);
let bounds = self.bounds().transform(transform);
self.transform = bounds.transform_matrix().unwrap_or_default();
let width = bounds.width();
let height = bounds.height();
let new_selrect = math::Rect::from_xywh(
center.x - width / 2.0,
center.y - height / 2.0,
width,
height,
);
self.selrect = new_selrect;
}
pub fn apply_transform(&mut self, transform: &Matrix) {
self.transform_selrect(transform);
// Outsets (strokes, shadows, blur, children) are translation-invariant,
// so the cached extrect can be shifted instead of invalidated.
// The bounds cache must always be invalidated so that callers such as
// grid_cell_data get the updated position after a drag.
if math::is_move_only_matrix(transform) {
let tx = transform.translate_x();
let ty = transform.translate_y();
if let Some(rect) = self.extrect_cache.borrow_mut().as_mut() {
*rect = math::Rect::from_xywh(
rect.left + tx,
rect.top + ty,
rect.width(),
rect.height(),
);
}
self.invalidate_bounds();
} else {
self.invalidate_extrect();
self.invalidate_bounds();
}
if let shape_type @ (Type::Path(_) | Type::Bool(_)) = &mut self.shape_type {
if let Some(path) = shape_type.path_mut() {
path.transform(transform);
}
} else if let Type::Text(text) = &mut self.shape_type {
text.transform(transform);
} else if let Type::SVGRaw(_) = &mut self.shape_type {
self.svg_transform = Some(*transform);
}
}
pub fn apply_structure(&mut self, structure: &Vec<StructureEntry>) {
let mut result = IndexSet::<Uuid>::from_iter(self.children.iter().copied());
let mut to_remove = HashSet::<&Uuid>::new();
for st in structure {
match st.entry_type {
StructureEntryType::AddChild => {
if result.is_empty() {
result.insert(st.id);
} else {
let index = usize::min(result.len() - 1, st.index as usize);
result.shift_insert(index, st.id);
}
}
StructureEntryType::RemoveChild => {
to_remove.insert(&st.id);
}
_ => {}
}
}
self.children = result
.iter()
.filter(|id| !to_remove.contains(id))
.copied()
.collect();
}
pub fn transformed(
&self,
transform: Option<&Matrix>,
structure: Option<&Vec<StructureEntry>>,
) -> Self {
let mut shape: Cow<Shape> = Cow::Borrowed(self);
if let Some(transform) = transform {
shape.to_mut().apply_transform(transform);
}
if let Some(structure) = structure {
shape.to_mut().apply_structure(structure);
}
shape.into_owned()
}
pub fn is_absolute(&self) -> bool {
match &self.layout_item {
Some(LayoutItem { is_absolute, .. }) => *is_absolute,
_ => false,
}
}
pub fn z_index(&self) -> i32 {
match &self.layout_item {
Some(LayoutItem {
z_index: Some(z), ..
}) => *z,
_ => 0,
}
}
#[allow(dead_code)]
pub fn has_z_index(&self) -> bool {
matches!(
&self.layout_item,
Some(LayoutItem {
z_index: Some(_),
..
})
)
}
pub fn is_layout_vertical_auto(&self) -> bool {
match &self.layout_item {
Some(LayoutItem { v_sizing, .. }) => v_sizing == &Sizing::Auto,
_ => false,
}
}
pub fn is_layout_vertical_fill(&self) -> bool {
match &self.layout_item {
Some(LayoutItem { v_sizing, .. }) => v_sizing == &Sizing::Fill,
_ => false,
}
}
pub fn is_layout_horizontal_auto(&self) -> bool {
match &self.layout_item {
Some(LayoutItem { h_sizing, .. }) => h_sizing == &Sizing::Auto,
_ => false,
}
}
pub fn is_layout_horizontal_fill(&self) -> bool {
match &self.layout_item {
Some(LayoutItem { h_sizing, .. }) => h_sizing == &Sizing::Fill,
_ => false,
}
}
pub fn has_fills(&self) -> bool {
!self.fills.is_empty()
}
/// Determines if this frame or group can be flattened (doesn't affect children visually)
/// A container can be flattened if it has no visual effects that affect its children
/// and doesn't render its own content (no fills/strokes)
pub fn can_flatten(&self) -> bool {
// Only frames and groups can be flattened
if !matches!(self.shape_type, Type::Frame(_) | Type::Group(_)) {
return false;
}
// Cannot flatten if it has visual effects that affect children:
if self.clip_content {
return false;
}
if !self.transform.is_identity() {
return false;
}
if self.opacity != 1.0 {
return false;
}
if self.blend_mode() != BlendMode::default() {
return false;
}
if self.blur.is_some() || self.background_blur.is_some() {
return false;
}
if !self.shadows.is_empty() {
return false;
}
if let Type::Group(group) = &self.shape_type {
if group.masked {
return false;
}
}
if self.hidden {
return false;
}
// If the container itself has fills/strokes, it renders something visible
// We cannot flatten containers that render their own background/border
// because they need to be rendered even if they don't affect children
if self.has_fills() || self.has_visible_strokes() {
return false;
}
true
}
/// Checks if this shape needs a layer for rendering due to visual effects
/// (opacity < 1.0, non-default blend mode, or frame clip layer blur)
pub fn needs_layer(&self) -> bool {
self.opacity() < 1.0
|| self.blend_mode().0 != skia::BlendMode::SrcOver
|| self.has_frame_clip_layer_blur()
|| (matches!(self.shape_type, Type::Group(g) if g.masked))
}
/// Checks if this frame has clip layer blur (affects children)
/// A frame has clip layer blur if it clips content and has layer blur
pub fn has_frame_clip_layer_blur(&self) -> bool {
self.frame_clip_layer_blur().is_some()
}
/// Returns the frame clip layer blur if this frame has one
/// A frame has clip layer blur if it clips content and has layer blur
pub fn frame_clip_layer_blur(&self) -> Option<Blur> {
use crate::shapes::BlurType;
match self.shape_type {
Type::Frame(_) if self.clip_content => self.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
}),
_ => None,
}
}
/// 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 {
Type::Group(Group { masked: true }) => self.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
}),
_ => None,
}
}
/// 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 {
!self.shadows.is_empty()
|| self.blur.is_some()
|| !self.strokes.is_empty()
|| !self.transform.is_identity()
|| !math::is_close_to(self.rotation, 0.0)
|| matches!(self.shape_type, Type::Group(_) | Type::Frame(_))
|| matches!(self.shape_type, Type::Text(_))
}
pub fn count_visible_inner_strokes(&self) -> usize {
self.visible_strokes()
.filter(|s| s.kind == StrokeKind::Inner)
.count()
}
/// True when the shape has at least one visible inner stroke (open paths render strokes as center).
pub fn has_inner_stroke(&self) -> bool {
let is_open = self.is_open();
self.visible_strokes()
.any(|s| s.render_kind(is_open) == StrokeKind::Inner)
}
/// When true, the frame drop shadow can use the direct geometry path
/// (`render_direct_frame_drop_shadow`) instead of filter surfaces and
/// descendant silhouettes.
///
/// Requires at least one fill; fill opacity/type does not matter because the fast
/// path shadows the frame geometry as a solid mask.
///
/// The fast path draws fill geometry only. On the slow path, visible strokes also
/// contribute to the shadow silhouette, so frames with outer/center strokes can
/// look slightly narrower here. We keep them eligible anyway for performance.
pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef) -> bool {
if !matches!(self.shape_type, Type::Frame(_)) {
return false;
}
if !self.has_fills() {
return false;
}
if self.blend_mode() != BlendMode::default() {
return false;
}
if self.blur.is_some() || self.background_blur.is_some() {
return false;
}
if self.has_frame_clip_layer_blur() {
return false;
}
if self.clip_content {
return !self.descendants_have_drop_shadows(tree);
}
self.descendants_contained_for_frame_shadow(tree, self.selrect())
}
/// When true, the container's own fill shadow mask is enough and descendant
/// silhouettes can be skipped (same geometry assumption as the direct path).
pub fn container_fill_covers_shadow_descendants(&self, tree: ShapesPoolRef) -> bool {
self.has_fills() && self.descendants_contained_for_frame_shadow(tree, self.selrect())
}
fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool {
for child_id in self.children_ids_iter(false) {
let Some(child) = tree.get(child_id) else {
continue;
};
if child.hidden {
continue;
}
if child.drop_shadows_visible().next().is_some() {
return true;
}
if child.is_recursive() && child.descendants_have_drop_shadows(tree) {
return true;
}
}
false
}
fn descendants_contained_for_frame_shadow(
&self,
tree: ShapesPoolRef,
bounds: math::Rect,
) -> bool {
const MARGIN: f32 = 0.5;
for child_id in self.children_ids_iter(false) {
let Some(child) = tree.get(child_id) else {
continue;
};
if child.hidden {
continue;
}
if !rect_contains_with_margin(bounds, child.silhouette_rect(), MARGIN) {
return false;
}
if child.is_recursive() && !child.descendants_contained_for_frame_shadow(tree, bounds) {
return false;
}
}
true
}
pub fn drop_shadow_paints(&self) -> Vec<skia_safe::Paint> {
let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect();
drop_shadows
.into_iter()
.map(|shadow| {
let mut paint = skia_safe::Paint::default();
let filter = shadow.get_drop_shadow_filter();
paint.set_image_filter(filter);
paint
})
.collect()
}
pub fn inner_shadow_paints(&self) -> Vec<skia_safe::Paint> {
let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect();
inner_shadows
.into_iter()
.map(|shadow| {
let mut paint = skia_safe::Paint::default();
let filter = shadow.get_inner_shadow_filter();
paint.set_image_filter(filter);
paint
})
.collect()
}
}
#[inline]
fn rect_contains_with_margin(outer: math::Rect, inner: math::Rect, margin: f32) -> bool {
inner.left >= outer.left - margin
&& inner.top >= outer.top - margin
&& inner.right <= outer.right + margin
&& inner.bottom <= outer.bottom + margin
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::ShapesPool;
fn any_shape() -> Shape {
Shape::new(Uuid::nil())
}
#[test]
fn add_fill_pushes_a_new_fill() {
let mut shape = any_shape();
assert_eq!(shape.fills.len(), 0);
shape.add_fill(Fill::Solid(SolidColor(Color::TRANSPARENT)));
assert_eq!(
shape.fills.first(),
Some(&Fill::Solid(SolidColor(Color::TRANSPARENT)))
)
}
#[test]
fn layer_blur_and_background_blur_can_coexist() {
let mut shape = any_shape();
let layer_blur = Blur::new(BlurType::LayerBlur, false, 4.0);
let background_blur = Blur::new(BlurType::BackgroundBlur, false, 8.0);
shape.set_blur(Some(layer_blur));
shape.set_background_blur(Some(background_blur));
assert_eq!(shape.blur, Some(layer_blur));
assert_eq!(shape.background_blur, Some(background_blur));
assert_eq!(shape.visible_background_blur(), Some(background_blur));
// Clearing one type must not affect the other.
shape.set_blur(None);
assert_eq!(shape.blur, None);
assert_eq!(shape.background_blur, Some(background_blur));
shape.set_blur(Some(layer_blur));
shape.set_background_blur(None);
assert_eq!(shape.blur, Some(layer_blur));
assert_eq!(shape.background_blur, None);
}
#[test]
fn hidden_background_blur_is_not_visible() {
let mut shape = any_shape();
shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, true, 8.0)));
assert_eq!(shape.visible_background_blur(), None);
}
/// Cases mirrored from Penpot MCP board `layer-blur-cases` (file "blur"):
/// leaf-visible-blur, leaf-hidden-blur, leaf-zero-blur, leaf-background-blur,
/// group-with-blur.
#[test]
fn visible_layer_blur_requires_non_hidden_positive_layer_blur() {
let mut shape = any_shape();
// leaf-visible-blur / group-with-blur
let visible = Blur::new(BlurType::LayerBlur, false, 10.0);
shape.set_blur(Some(visible));
assert_eq!(shape.visible_layer_blur(), Some(visible));
let group_blur = Blur::new(BlurType::LayerBlur, false, 6.0);
shape.set_blur(Some(group_blur));
assert_eq!(shape.visible_layer_blur(), Some(group_blur));
// leaf-hidden-blur
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
assert_eq!(shape.visible_layer_blur(), None);
// leaf-zero-blur
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 0.0)));
assert_eq!(shape.visible_layer_blur(), None);
// no blur
shape.set_blur(None);
assert_eq!(shape.visible_layer_blur(), None);
}
#[test]
fn visible_layer_blur_ignores_background_blur() {
let mut shape = any_shape();
// leaf-background-blur: Plugin API uses `backgroundBlur`, not `blur`.
shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)));
assert_eq!(shape.visible_layer_blur(), None);
assert_eq!(
shape.visible_background_blur(),
Some(Blur::new(BlurType::BackgroundBlur, false, 8.0))
);
let layer = Blur::new(BlurType::LayerBlur, false, 4.0);
shape.set_blur(Some(layer));
assert_eq!(shape.visible_layer_blur(), Some(layer));
}
#[test]
fn test_set_corners() {
let mut shape = any_shape();
shape.set_corners((10.0, 20.0, 30.0, 40.0));
if let Type::Rect(Rect { corners, .. }) = shape.shape_type {
assert_eq!(
corners,
Some([
Point { x: 10.0, y: 10.0 },
Point { x: 20.0, y: 20.0 },
Point { x: 30.0, y: 30.0 },
Point { x: 40.0, y: 40.0 }
])
);
} else {
unreachable!();
}
shape.set_corners((0.0, 0.0, 0.0, 0.0));
if let Type::Rect(Rect { corners, .. }) = shape.shape_type {
assert_eq!(corners, None);
} else {
unreachable!();
}
}
#[test]
fn test_set_masked() {
let mut shape = any_shape();
shape.set_shape_type(Type::Group(Group { masked: false }));
shape.set_masked(true);
if let Type::Group(Group { masked, .. }) = shape.shape_type {
assert!(masked);
} else {
unreachable!()
}
}
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());
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(0.0, 10.0, 10.0, 0.0);
shape.apply_transform(&Matrix::scale((2.0, 2.0)));
assert_eq!(shape.selrect().width(), 20.0);
assert_eq!(shape.selrect().height(), 20.0);
}
#[test]
fn masked_group_extrect_matches_mask_intersection() {
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.children = vec![mask_id, content_id];
}
{
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(-10.0, -10.0, 110.0, 110.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_eq!(extrect.left, 0.0);
assert_eq!(extrect.top, 0.0);
assert_eq!(extrect.right, 50.0);
assert_eq!(extrect.bottom, 50.0);
}
fn frame_with_fill_and_child(fill: Fill, opacity: f32) -> (ShapesPool, Uuid) {
let mut pool = ShapesPool::new();
pool.initialize(2);
let frame_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
{
let frame = pool.add_shape(frame_id);
frame.set_shape_type(Type::Frame(Frame::default()));
frame.set_selrect(0.0, 0.0, 200.0, 100.0);
frame.add_fill(fill);
frame.opacity = opacity;
frame.children = vec![child_id];
}
{
let child = pool.add_shape(child_id);
child.set_shape_type(Type::Rect(Rect::default()));
child.set_selrect(10.0, 10.0, 180.0, 80.0);
child.set_parent(frame_id);
}
(pool, frame_id)
}
#[test]
fn frame_with_any_fill_uses_direct_container_drop_shadow() {
for (fill, opacity) in [
(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0),
(
Fill::Solid(SolidColor(skia::Color::from_argb(128, 255, 255, 255))),
0.5,
),
] {
let (pool, frame_id) = frame_with_fill_and_child(fill, opacity);
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
}
#[test]
fn clipped_frame_with_child_drop_shadow_rejects_direct_path() {
let (mut pool, frame_id) =
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0);
let child_id = pool.get(&frame_id).expect("frame").children[0];
{
let child = pool.get_mut(&child_id).expect("child");
child.add_shadow(Shadow::new(
skia::Color::BLACK,
4.0,
0.0,
(0.0, 4.0),
ShadowStyle::Drop,
false,
));
}
let frame = pool.get(&frame_id).expect("frame");
assert!(!frame.uses_direct_container_drop_shadow(&pool));
}
#[test]
fn clipped_frame_ignores_outside_child_extrect_for_direct_path() {
let mut pool = ShapesPool::new();
pool.initialize(2);
let frame_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
{
let frame = pool.add_shape(frame_id);
frame.set_shape_type(Type::Frame(Frame::default()));
frame.set_selrect(0.0, 0.0, 200.0, 100.0);
frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE)));
frame.set_clip(true);
frame.children = vec![child_id];
}
{
let child = pool.add_shape(child_id);
child.set_shape_type(Type::Rect(Rect::default()));
child.set_selrect(-50.0, -50.0, 250.0, 150.0);
child.set_parent(frame_id);
}
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
#[test]
fn overflow_frame_with_outside_child_rejects_direct_path() {
let mut pool = ShapesPool::new();
pool.initialize(2);
let frame_id = Uuid::new_v4();
let child_id = Uuid::new_v4();
{
let frame = pool.add_shape(frame_id);
frame.set_shape_type(Type::Frame(Frame::default()));
frame.set_selrect(0.0, 0.0, 200.0, 100.0);
frame.add_fill(Fill::Solid(SolidColor(skia::Color::WHITE)));
frame.set_clip(false);
frame.children = vec![child_id];
}
{
let child = pool.add_shape(child_id);
child.set_shape_type(Type::Rect(Rect::default()));
child.set_selrect(-50.0, -50.0, 250.0, 150.0);
child.set_parent(frame_id);
}
let frame = pool.get(&frame_id).expect("frame");
assert!(!frame.uses_direct_container_drop_shadow(&pool));
assert!(!frame.container_fill_covers_shadow_descendants(&pool));
}
#[test]
fn frame_with_contained_child_covers_shadow_descendants() {
let (pool, frame_id) =
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0);
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.container_fill_covers_shadow_descendants(&pool));
}
#[test]
fn unclipped_frame_with_contained_shadowed_child_covers_descendants() {
let (mut pool, frame_id) =
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0);
let child_id = pool.get(&frame_id).expect("frame").children[0];
pool.get_mut(&frame_id).expect("frame").set_clip(false);
{
let child = pool.get_mut(&child_id).expect("child");
// Shadow reaches past the frame; the geometry stays inside.
child.add_shadow(Shadow::new(
skia::Color::BLACK,
20.0,
0.0,
(0.0, 20.0),
ShadowStyle::Drop,
false,
));
}
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.container_fill_covers_shadow_descendants(&pool));
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
#[test]
fn rotated_frame_with_contained_child_uses_direct_container_drop_shadow() {
let (mut pool, frame_id) =
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0);
{
let frame = pool.get_mut(&frame_id).expect("frame");
// 45° rotation around the shape center (100, 50).
let angle = std::f32::consts::FRAC_PI_4;
frame.set_transform(
angle.cos(),
angle.sin(),
-angle.sin(),
angle.cos(),
0.0,
0.0,
);
}
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
}