mirror of
https://github.com/penpot/penpot.git
synced 2026-10-31 06:50:33 +00:00
* 🐛 Fix render_to_pdf call in the ToUnicode test The test passed a scale argument that render_to_pdf does not take, so the render-wasm test build failed to compile. * 🐛 Draw masked group inner shadows in PDF and multi-element export The CPU renderer behind PDF and multi-element raster export drew a masked group's drop shadow but never its inner shadow, so the masked content came out flat. Wrap the masked content in a layer that draws the inner shadows over it, as the GPU single export does. * 🐛 Export every visible inner shadow of a masked group The workspace skips shadows too small to see at the current zoom, to render faster. Export has no such need, so draw every visible inner shadow instead of filtering by scale.
2874 lines
94 KiB
Rust
2874 lines
94 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;
|
|
self.invalidate_extrect();
|
|
}
|
|
|
|
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(¤t_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)
|
|
}
|
|
|
|
/// Whether a drop-shadow spread grows this shape through its strokes
|
|
/// ([`Self::apply_shadow_spread`]) instead of a geometric fill outset.
|
|
pub fn spreads_through_strokes(&self) -> bool {
|
|
matches!(
|
|
self.shape_type,
|
|
Type::Path(_) | Type::Bool(_) | Type::Circle
|
|
)
|
|
}
|
|
|
|
/// Offsets the shape outline by `spread` with the shape's own joins:
|
|
/// every stroke widens by `spread` on each side and a black `2·spread`
|
|
/// center stroke grows the fill.
|
|
pub fn apply_shadow_spread(&mut self, spread: f32) {
|
|
let is_open = self.is_open();
|
|
for stroke in self.strokes.iter_mut() {
|
|
stroke.grow_by_spread(spread, is_open);
|
|
}
|
|
let mut outline =
|
|
Stroke::new_center_stroke(2.0 * spread, StrokeStyle::Solid, None, None, None, None);
|
|
outline.fill = Fill::Solid(SolidColor(skia::Color::BLACK));
|
|
self.add_stroke(outline);
|
|
}
|
|
|
|
pub fn set_last_stroke_widths(&mut self, widths: [f32; 4]) -> Result<(), String> {
|
|
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 {
|
|
// A path spread is a mitered stroke (`apply_shadow_spread`): its tips
|
|
// reach up to Skia's miter limit (4) half-widths from the vertex.
|
|
const MITER_LIMIT: f32 = 4.0;
|
|
let mitered = matches!(self.shape_type, Type::Path(_) | Type::Bool(_));
|
|
let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open());
|
|
|
|
let mut rect = bounds.to_rect();
|
|
for shadow in self.drop_shadows_visible() {
|
|
if let Some(filter) = shadow.get_drop_shadow_filter() {
|
|
let mut source = rect;
|
|
if mitered && shadow.spread > 0.0 {
|
|
let tip = (MITER_LIMIT - 1.0) * (max_stroke + shadow.spread);
|
|
source.outset((tip, tip));
|
|
}
|
|
rect.join(filter.compute_fast_bounds(source));
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
pub(crate) fn own_extrect_bounds(&self) -> Bounds {
|
|
self.expand_own_bounds(self.own_base_bounds(), true)
|
|
}
|
|
|
|
/// Bounds for a headless export rooted at this shape.
|
|
///
|
|
/// Frames keep the classic board-box crop of overflowing children, but
|
|
/// still expand by their own strokes / drop shadows / layer blur so those
|
|
/// effects are not clipped (parity with single-export `extrect` for the
|
|
/// board itself). Other shapes use full [`Self::extrect`].
|
|
pub fn export_root_bounds(&self, tree: ShapesPoolRef, scale: f32) -> math::Rect {
|
|
if matches!(self.shape_type, Type::Frame(_)) {
|
|
let mut bounds = self.own_extrect_bounds();
|
|
if !self.transform.is_identity() {
|
|
bounds.transform_mut(&self.centered_transform());
|
|
}
|
|
bounds.to_rect()
|
|
} else {
|
|
self.extrect(tree, scale)
|
|
}
|
|
}
|
|
|
|
/// 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(¤t_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 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()
|
|
}
|
|
|
|
/// Whether this fill-less leaf paints its group's fills (SVG inheritance,
|
|
/// broken by `fill="none"`).
|
|
pub fn inherits_fills(&self) -> bool {
|
|
self.fills.is_empty()
|
|
&& !matches!(self.shape_type, Type::Group(_) | Type::Frame(_))
|
|
&& !self.svg_attrs.as_ref().is_some_and(|attrs| attrs.fill_none)
|
|
}
|
|
|
|
/// Fills a fill-less child inherits when rendering starts at this shape:
|
|
/// the nearest group's fills, or none past a frame (seeds `nested_fills`).
|
|
pub fn inherited_fills(&self, shapes: ShapesPoolRef) -> Vec<Fill> {
|
|
let mut parent_id = self.parent_id;
|
|
while let Some(id) = parent_id {
|
|
let Some(parent) = shapes.get(&id) else {
|
|
break;
|
|
};
|
|
match parent.shape_type {
|
|
Type::Group(_) => return parent.fills.clone(),
|
|
Type::Frame(_) => break,
|
|
_ => parent_id = parent.parent_id,
|
|
}
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
/// How far the masked-group layer filter reaches past the content, in
|
|
/// document units: the widest drop shadow plus the layer blur.
|
|
pub fn masked_group_filter_reach(&self) -> f32 {
|
|
let reach = |filter: Option<skia::ImageFilter>| {
|
|
filter.map_or(0.0, |f| {
|
|
let r = f.compute_fast_bounds(math::Rect::default());
|
|
(-r.left).max(-r.top).max(r.right).max(r.bottom)
|
|
})
|
|
};
|
|
let shadows = self
|
|
.drop_shadows_visible()
|
|
.map(|shadow| reach(shadow.get_drop_shadow_filter()))
|
|
.fold(0.0, f32::max);
|
|
let blur = self.masked_group_layer_blur().map_or(0.0, |blur| {
|
|
let sigma = radius_to_sigma(blur.value);
|
|
reach(skia::image_filters::blur((sigma, sigma), None, None, None))
|
|
});
|
|
shadows + blur
|
|
}
|
|
|
|
/// Shadows of the given style that the masked-group layer filter must
|
|
/// 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
|
|
})
|
|
}
|
|
|
|
/// Source graphic with every visible inner shadow of the masked group over
|
|
/// it, for the CPU export renderer; `None` without inner shadows.
|
|
pub fn masked_group_inner_shadow_filter(&self) -> Option<skia::ImageFilter> {
|
|
if !self.is_masked_group() {
|
|
return None;
|
|
}
|
|
|
|
let mut layers: Vec<Option<skia::ImageFilter>> = vec![None];
|
|
for shadow in self.inner_shadows_visible() {
|
|
layers.push(shadow.get_inner_shadow_filter());
|
|
}
|
|
|
|
if layers.len() > 1 {
|
|
skia::image_filters::merge(layers, None)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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_layer_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 !self.is_filled_frame() {
|
|
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.is_filled_frame() && self.descendants_contained_for_frame_shadow(tree, self.selrect())
|
|
}
|
|
|
|
fn is_filled_frame(&self) -> bool {
|
|
matches!(self.shape_type, Type::Frame(_)) && self.has_fills()
|
|
}
|
|
|
|
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_inner_shadow_filter_needs_an_inner_shadow() {
|
|
let drop_only = masked_group_with(vec![drop_shadow(4.0, 0.0, (10.0, 10.0))], None);
|
|
assert!(drop_only.masked_group_inner_shadow_filter().is_none());
|
|
|
|
let inner = masked_group_with(vec![inner_shadow(4.0, (10.0, 10.0))], None);
|
|
assert!(inner.masked_group_inner_shadow_filter().is_some());
|
|
|
|
let mut unmasked = inner.clone();
|
|
unmasked.set_masked(false);
|
|
assert!(unmasked.masked_group_inner_shadow_filter().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn masked_group_inner_shadow_filter_keeps_imperceptible_shadows() {
|
|
// Too small for the workspace filter at 1x, but export draws it.
|
|
let shape = masked_group_with(vec![inner_shadow(1.0, (0.0, 0.0))], None);
|
|
|
|
assert!(shape.masked_group_layer_filter(1.0, false, false).is_none());
|
|
assert!(shape.masked_group_inner_shadow_filter().is_some());
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
/// Inner shadows are drawn inside the shape; they must not inflate export
|
|
/// size (WebGL PNG surface uses `extrect`). Regression for #12386.
|
|
#[test]
|
|
fn leaf_extrect_ignores_inner_shadow_offset() {
|
|
let mut pool = ShapesPool::new();
|
|
pool.initialize(1);
|
|
|
|
let shape_id = Uuid::new_v4();
|
|
{
|
|
let shape = pool.add_shape(shape_id);
|
|
shape.set_shape_type(Type::Rect(Rect::default()));
|
|
shape.set_selrect(0.0, 0.0, 200.0, 200.0);
|
|
shape.add_shadow(inner_shadow(0.0, (0.0, 1.0)));
|
|
}
|
|
|
|
let shape = pool.get(&shape_id).expect("shape should exist");
|
|
let baseline = {
|
|
let mut no_shadow = shape.clone();
|
|
no_shadow.clear_shadows();
|
|
no_shadow.calculate_extrect(&pool, 1.0)
|
|
};
|
|
let extrect = shape.calculate_extrect(&pool, 1.0);
|
|
|
|
assert_eq!(extrect.height(), baseline.height());
|
|
assert_eq!(extrect.width(), baseline.width());
|
|
assert_eq!(extrect.height(), 200.0);
|
|
assert_eq!(extrect.width(), 200.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 filled_group_does_not_cover_shadow_descendants() {
|
|
let (mut pool, group_id) =
|
|
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::BLACK)), 1.0);
|
|
pool.get_mut(&group_id)
|
|
.expect("group")
|
|
.set_shape_type(Type::Group(Group { masked: false }));
|
|
let group = pool.get(&group_id).expect("group");
|
|
assert!(!group.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));
|
|
}
|
|
}
|