Alejandro Alonso a76401596e
Skip imperceptible shadows and simplify low-scale strokes (#11102)
*  Skip drop shadows that are imperceptible at current scale

Filter drop shadows by on-screen footprint (stricter for recursive
shapes) so overview HQ avoids expensive blur passes that barely show.

*  Simplify Path and Bool strokes at low scale

At overview zooms, Inner/Outer strokes fall back to Center and
dash/dotted styles become solid when the pattern is subpixel.
Strokes are never skipped so stroke-only icons stay visible.

*  Drain GPU work on partial render frames

Partial frames only flushed the Backbuffer, so tile GPU commands
queued until present_frame's flush_and_submit and stalled the
browser on large files. Submit the context each partial frame
without presenting Target or re-composing the tile atlas.

*  Prefer direct painting when effects are imperceptible

Skip the Fills/Strokes layered path when drop/inner shadows would
not paint at the current scale, and allow stroke-only shapes
(fills_none) on the direct path. Apply the same footprint LOD to
inner-shadow painting.
2026-08-06 15:58:11 +02:00

1185 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::math::{Matrix, Point, Rect};
use crate::shapes::{
merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs,
Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::{Error, Result};
use crate::get_resources;
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_stroke_on_rect(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
corners: &Option<Corners>,
paint: &skia::Paint,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// Per-side widths render as a band between an outer and an inner rect.
if let Some(widths) = stroke.per_side_widths() {
draw_per_side_stroke_on_rect(canvas, stroke, rect, corners, &paint, widths, antialias);
return;
}
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
let draw_stroke = || match corners {
Some(radii) => {
let radii = stroke.outer_corners(radii);
let rrect = RRect::new_rect_radii(stroke_rect, &radii);
canvas.draw_rrect(rrect, &paint);
}
None => {
canvas.draw_rect(stroke_rect, &paint);
}
};
// Dotted inner/outer strokes need clipping to prevent the dotted
// pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, clip_op, antialias);
}
None => {
canvas.clip_rect(*rect, clip_op, antialias);
}
}
draw_stroke();
canvas.restore();
} else if stroke.kind == StrokeKind::Inner
&& (stroke.width >= rect.width() || stroke.width >= rect.height())
{
// When the inner stroke width exceeds a shape dimension, the inset
// rect goes negative and the stroke overflows outside the shape.
// Fall back to the same approach as the SVG renderer: draw with
// doubled width centered on the original shape and clip to it.
canvas.save();
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, skia::ClipOp::Intersect, antialias);
}
None => {
canvas.clip_rect(*rect, skia::ClipOp::Intersect, antialias);
}
}
let mut inner_paint = paint.clone();
inner_paint.set_stroke_width(stroke.width * 2.0);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.draw_rrect(rrect, &inner_paint);
}
None => {
canvas.draw_rect(*rect, &inner_paint);
}
}
canvas.restore();
} else {
draw_stroke();
}
}
/// Draws a rect/frame stroke whose sides have different widths as the area
/// between an outer and an inner (rounded) rect, mitered like CSS borders.
/// The band is filled with the stroke fill; dashed/dotted patterns are not
/// supported per side and render solid.
#[allow(clippy::too_many_arguments)]
fn draw_per_side_stroke_on_rect(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
corners: &Option<Corners>,
paint: &skia::Paint,
widths: [f32; 4],
antialias: bool,
) {
let [top, right, bottom, left] = widths;
// Fraction of each side width growing outward / inward from the shape
// boundary, per stroke alignment.
let (out_f, in_f) = match stroke.kind {
StrokeKind::Inner => (0.0, 1.0),
StrokeKind::Center => (0.5, 0.5),
StrokeKind::Outer => (1.0, 0.0),
};
let outer = Rect::from_ltrb(
rect.left - left * out_f,
rect.top - top * out_f,
rect.right + right * out_f,
rect.bottom + bottom * out_f,
);
let inner = Rect::from_ltrb(
rect.left + left * in_f,
rect.top + top * in_f,
rect.right - right * in_f,
rect.bottom - bottom * in_f,
);
// When a side width exceeds the shape dimension the inner rect collapses
// and the whole outer rect is filled, like CSS collapsing borders.
let has_hole = inner.width() > 0.0 && inner.height() > 0.0;
let mut fill_paint = paint.clone();
fill_paint.set_style(skia::PaintStyle::Fill);
fill_paint.set_path_effect(None);
fill_paint.set_anti_alias(antialias);
let mut pb = skia::PathBuilder::new();
match corners {
Some(radii) => {
// Straight (zero-radius) corners stay sharp; rounded ones keep
// their curvature parallel to the shape edge, like CSS borders.
let grow = |radius: f32, delta: f32| {
if radius > 0.0 {
radius + delta
} else {
0.0
}
};
let shrink = |radius: f32, delta: f32| (radius - delta).max(0.0);
// Adjacent side widths per corner, matching the Skia radii order:
// [top-left, top-right, bottom-right, bottom-left].
let side_deltas = [(left, top), (right, top), (right, bottom), (left, bottom)];
let mut outer_radii = *radii;
let mut inner_radii = *radii;
for (i, (dx, dy)) in side_deltas.into_iter().enumerate() {
outer_radii[i].x = grow(outer_radii[i].x, dx * out_f);
outer_radii[i].y = grow(outer_radii[i].y, dy * out_f);
inner_radii[i].x = shrink(inner_radii[i].x, dx * in_f);
inner_radii[i].y = shrink(inner_radii[i].y, dy * in_f);
}
pb.add_rrect(RRect::new_rect_radii(outer, &outer_radii), None, None);
if has_hole {
pb.add_rrect(RRect::new_rect_radii(inner, &inner_radii), None, None);
}
}
None => {
pb.add_rect(outer, None, None);
if has_hole {
pb.add_rect(inner, None, None);
}
}
}
let mut path = pb.detach();
path.set_fill_type(skia::PathFillType::EvenOdd);
canvas.draw_path(&path, &fill_paint);
}
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_stroke_on_circle(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
paint: &skia::Paint,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
// Dotted inner/outer strokes need clipping to prevent the dotted
// pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
// Use a neutral layer (no extra paint) so opacity and filters
// come solely from the stroke paint. This avoids applying
// stroke alpha twice for dotted inner/outer strokes.
let layer_rec = skia::canvas::SaveLayerRec::default();
canvas.save_layer(&layer_rec);
let clip_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(rect, None, None);
pb.detach()
};
canvas.clip_path(&clip_path, clip_op, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
} else if stroke.kind == StrokeKind::Inner
&& (stroke.width >= rect.width() || stroke.width >= rect.height())
{
// When the inner stroke width exceeds a shape dimension, the inset
// rect goes negative and the stroke overflows outside the shape.
// Fall back to the same approach as the SVG renderer: draw with
// doubled width centered on the original shape and clip to it.
canvas.save();
let clip_path = {
let mut pb = skia::PathBuilder::new();
pb.add_oval(rect, None, None);
pb.detach()
};
canvas.clip_path(&clip_path, skia::ClipOp::Intersect, antialias);
let mut inner_paint = paint.clone();
inner_paint.set_stroke_width(stroke.width * 2.0);
canvas.draw_oval(*rect, &inner_paint);
canvas.restore();
} else {
canvas.draw_oval(stroke_rect, &paint);
}
}
fn draw_outer_stroke_path(
canvas: &skia::Canvas,
path: &skia::Path,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let mut outer_paint = skia::Paint::default();
outer_paint.set_blend_mode(skia::BlendMode::SrcOver);
outer_paint.set_anti_alias(antialias);
if let Some(filter) = blur {
outer_paint.set_image_filter(filter.clone());
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&outer_paint);
canvas.save_layer(&layer_rec);
canvas.draw_path(path, paint);
let mut clear_paint = skia::Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(antialias);
canvas.draw_path(path, &clear_paint);
canvas.restore();
}
// For inner stroke we draw a center stroke (with double width) and clip to the original path (that way the extra outer stroke is removed)
fn draw_inner_stroke_path(
canvas: &skia::Canvas,
path: &skia::Path,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
antialias: bool,
) {
let mut inner_paint = skia::Paint::default();
inner_paint.set_anti_alias(antialias);
if let Some(filter) = blur {
inner_paint.set_image_filter(filter.clone());
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&inner_paint);
canvas.save_layer(&layer_rec);
canvas.clip_path(path, skia::ClipOp::Intersect, antialias);
canvas.draw_path(path, paint);
canvas.restore();
}
// For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_path(
canvas: &skia::Canvas,
stroke: &Stroke,
path: &Path,
paint: &skia::Paint,
path_transform: Option<&Matrix>,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
svg_attrs: Option<&SvgAttrs>,
antialias: bool,
) {
let is_open = path.is_open();
let mut draw_paint = paint.clone();
let filter = compose_filters(blur, shadow);
draw_paint.set_image_filter(filter);
// Move path_transform from the path geometry to the canvas so the
// stroke width is not distorted by non-uniform shape scaling.
// The path coordinates are already in world space, so we draw the
// raw path on a canvas where the shape transform has been undone:
// canvas * path_transform = View × parents (no shape scale/rotation)
// This matches the SVG renderer, which bakes the transform into path
// coordinates and never sets a transform attribute on the element.
let save_count = canvas.save();
if let Some(pt) = path_transform {
canvas.concat(pt);
}
let skia_path = path.to_skia_path(svg_attrs);
match stroke.render_kind(is_open) {
StrokeKind::Inner => {
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
}
StrokeKind::Center => {
canvas.draw_path(&skia_path, &draw_paint);
}
StrokeKind::Outer => {
draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
}
}
handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias);
canvas.restore_to_count(save_count);
}
fn handle_stroke_cap(
canvas: &skia::Canvas,
cap: StrokeCap,
width: f32,
paint: &mut skia::Paint,
p1: &Point,
p2: &Point,
) {
paint.set_style(skia::PaintStyle::Fill);
match cap {
StrokeCap::LineArrow => {
// We also draw this square cap to fill the gap between the path and the arrow
draw_square_cap(canvas, paint, p1, p2, width, 0.);
paint.set_style(skia::PaintStyle::Stroke);
draw_arrow_cap(canvas, paint, p1, p2, width * 4.);
}
StrokeCap::TriangleArrow => {
draw_triangle_cap(canvas, paint, p1, p2, width * 4.);
}
StrokeCap::SquareMarker => {
draw_square_cap(canvas, paint, p1, p2, width * 4., 0.);
}
StrokeCap::CircleMarker => {
canvas.draw_circle((p1.x, p1.y), width * 2., paint);
}
StrokeCap::DiamondMarker => {
draw_square_cap(canvas, paint, p1, p2, width * 4., 45.);
}
StrokeCap::Round => {
canvas.draw_circle((p1.x, p1.y), width / 2.0, paint);
}
StrokeCap::Square => {
draw_square_cap(canvas, paint, p1, p2, width, 0.);
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_stroke_caps(
path: &skia::Path,
stroke: &Stroke,
canvas: &skia::Canvas,
is_open: bool,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
_antialias: bool,
) {
// Closed shapes don't have caps
if !is_open {
return;
}
// When both ends share the same simple line cap, Skia already drew it
// natively via `PaintCap` on the stroke paint, so skip the manual overlay.
if stroke.to_skia_linecap().is_some() {
return;
}
// Curves can have duplicated points, so let's remove consecutive duplicated points
let mut points = path.points().to_vec();
points.dedup();
if let [first_point, .., last_point] = points.as_slice() {
let mut paint_stroke = paint.clone();
if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone());
}
if let Some(cap) = stroke.cap_start {
handle_stroke_cap(
canvas,
cap,
stroke.width,
&mut paint_stroke,
first_point,
&points[1],
);
}
if let Some(cap) = stroke.cap_end {
handle_stroke_cap(
canvas,
cap,
stroke.width,
&mut paint_stroke,
last_point,
&points[points.len() - 2],
);
}
}
}
fn draw_square_cap(
canvas: &skia::Canvas,
paint: &skia::Paint,
center: &Point,
direction: &Point,
size: f32,
extra_rotation: f32,
) {
let dx = direction.x - center.x;
let dy = direction.y - center.y;
let angle = dy.atan2(dx);
let mut matrix = Matrix::new_identity();
matrix.pre_rotate(
angle.to_degrees() + extra_rotation,
Point::new(center.x, center.y),
);
let half_size = size / 2.0;
let rect = Rect::from_xywh(center.x - half_size, center.y - half_size, size, size);
let points = [
Point::new(rect.left(), rect.top()),
Point::new(rect.right(), rect.top()),
Point::new(rect.right(), rect.bottom()),
Point::new(rect.left(), rect.bottom()),
];
let mut transformed_points = points;
matrix.map_points(&mut transformed_points, &points);
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to(Point::new(center.x, center.y));
pb.move_to(transformed_points[0]);
pb.line_to(transformed_points[1]);
pb.line_to(transformed_points[2]);
pb.line_to(transformed_points[3]);
pb.close();
pb.detach()
};
canvas.draw_path(&path, paint);
}
fn draw_arrow_cap(
canvas: &skia::Canvas,
paint: &skia::Paint,
center: &Point,
direction: &Point,
size: f32,
) {
let dx = direction.x - center.x;
let dy = direction.y - center.y;
let angle = dy.atan2(dx);
let mut matrix = Matrix::new_identity();
matrix.pre_rotate(angle.to_degrees() - 90., Point::new(center.x, center.y));
let half_height = size / 2.;
let points = [
Point::new(center.x, center.y - half_height),
Point::new(center.x - size, center.y + half_height),
Point::new(center.x + size, center.y + half_height),
];
let mut transformed_points = points;
matrix.map_points(&mut transformed_points, &points);
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to(transformed_points[1]);
pb.line_to(transformed_points[0]);
pb.line_to(transformed_points[2]);
pb.move_to(Point::new(center.x, center.y));
pb.line_to(transformed_points[0]);
pb.detach()
};
canvas.draw_path(&path, paint);
}
fn draw_triangle_cap(
canvas: &skia::Canvas,
paint: &skia::Paint,
center: &Point,
direction: &Point,
size: f32,
) {
let dx = direction.x - center.x;
let dy = direction.y - center.y;
let angle = dy.atan2(dx);
let mut matrix = Matrix::new_identity();
matrix.pre_rotate(angle.to_degrees() - 90., Point::new(center.x, center.y));
let half_height = size / 2.;
let points = [
Point::new(center.x, center.y - half_height),
Point::new(center.x - size, center.y + half_height),
Point::new(center.x + size, center.y + half_height),
];
let mut transformed_points = points;
matrix.map_points(&mut transformed_points, &points);
let path = {
let mut pb = skia::PathBuilder::new();
pb.move_to(transformed_points[0]);
pb.line_to(transformed_points[1]);
pb.line_to(transformed_points[2]);
pb.close();
pb.detach()
};
canvas.draw_path(&path, paint);
}
fn draw_image_stroke_in_container(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
image_fill: &ImageFill,
antialias: bool,
surface_id: SurfaceId,
) -> Result<()> {
let scale = render_state.get_scale();
let lod_stroke;
let stroke = if matches!(shape.shape_type, Type::Path(_) | Type::Bool(_)) {
lod_stroke = stroke.path_lod_at_scale(shape.is_open(), scale);
&lod_stroke
} else {
stroke
};
let Some(image) = get_resources().images.get(&image_fill.id()) else {
return Ok(());
};
let size = image.dimensions();
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
// Save canvas and layer state
let mut pb = skia::Paint::default();
pb.set_blend_mode(skia::BlendMode::SrcOver);
pb.set_anti_alias(antialias);
if let Some(filter) = shape.image_filter(1.) {
pb.set_image_filter(filter);
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&pb);
canvas.save_layer(&layer_rec);
// Draw the stroke based on the shape type, we are using this stroke as
// a "selector" of the area of the image we want to show.
let outer_rect = stroke.aligned_rect(container, scale);
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
container,
&shape_type.corners(),
&paint,
scale,
None,
None,
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas, stroke, container, &paint, scale, None, None, antialias,
);
}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(p) = shape_type.path() {
canvas.save();
let path = p.to_skia_path(svg_attrs).make_transform(
&path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?,
);
let stroke_kind = stroke.render_kind(p.is_open());
match stroke_kind {
StrokeKind::Inner => {
canvas.clip_path(&path, skia::ClipOp::Intersect, antialias);
}
StrokeKind::Center => {}
StrokeKind::Outer => {
canvas.clip_path(&path, skia::ClipOp::Difference, antialias);
}
}
let is_open = p.is_open();
let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill
// and avoid unnecesary artifacts.
let mut thin_paint = paint.clone();
thin_paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &thin_paint);
}
handle_stroke_caps(
&path,
stroke,
canvas,
is_open,
&paint,
shape.image_filter(1.).as_ref(),
antialias,
);
canvas.restore();
}
}
_ => unreachable!("This shape should not have strokes"),
}
// Draw the image. We are using now the SrcIn blend mode,
// so the rendered piece of image will the area of the
// stroke over the image.
let mut image_paint = skia::Paint::default();
image_paint.set_blend_mode(skia::BlendMode::SrcIn);
image_paint.set_anti_alias(antialias);
if let Some(filter) = shape.image_filter(1.) {
image_paint.set_image_filter(filter);
}
let src_rect = get_source_rect(size, container, image_fill);
let dest_rect = get_dest_rect(container, stroke.delta());
canvas.clip_rect(dest_rect, skia::ClipOp::Intersect, antialias);
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
dest_rect,
get_resources().sampling_options,
&image_paint,
);
// Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area.
if let Type::Path(p) = &shape.shape_type {
if stroke.render_kind(p.is_open()) == StrokeKind::Outer {
let path = p.to_skia_path(svg_attrs).make_transform(
&path_transform.ok_or(Error::CriticalError("No path transform".to_string()))?,
);
let mut clear_paint = skia::Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(antialias);
canvas.draw_path(&path, &clear_paint);
}
}
// Restore canvas state
canvas.restore();
Ok(())
}
/// Renders all strokes for a shape. Merges strokes that share the same
/// geometry (kind, width, style, caps) into a single draw call to avoid
/// anti-aliasing edge bleed between them.
pub fn render(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
outset: Option<f32>,
) -> Result<()> {
if strokes.is_empty() {
return Ok(());
}
let has_image_fills = strokes.iter().any(|s| matches!(s.fill, Fill::Image(_)));
let can_merge = !has_image_fills && strokes.len() > 1 && strokes_share_geometry(strokes);
if !can_merge {
// When blur is active, render all strokes into a single offscreen surface
// and apply blur once to the composite. This prevents blur from making
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let max_margin = strokes
.iter()
.map(|s| s.bounds_width(shape.is_open()))
.fold(0.0f32, f32::max);
if max_margin > 0.0 {
content_bounds.inset((-max_margin, -max_margin));
}
let max_cap = strokes
.iter()
.map(|s| s.cap_bounds_margin())
.fold(0.0f32, f32::max);
if max_cap > 0.0 {
content_bounds.inset((-max_cap, -max_cap));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
// Use save_layer with the blur filter so it applies once
// to the composite of all strokes, not per-stroke.
let canvas = state.surfaces.canvas(temp_surface);
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(image_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
for stroke in strokes.iter().rev() {
// bypass_filter=true prevents each stroke from creating
// its own filter surface. The blur on the paint inside
// draw functions is harmless — it composes with the
// layer's filter but the layer filter is the dominant one.
render_single_internal(
state,
shape,
stroke,
Some(temp_surface),
None,
antialias,
true,
true,
outset,
)?;
}
state.surfaces.canvas(temp_surface).restore();
Ok(())
},
)? {
return Ok(());
}
}
// No blur or filter surface unavailable — draw strokes individually.
for stroke in strokes.iter().rev() {
render_single(
render_state,
shape,
stroke,
surface_id,
None,
antialias,
outset,
)?;
}
return Ok(());
}
render_merged(
render_state,
shape,
strokes,
surface_id,
antialias,
false,
outset,
)
}
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
strokes.windows(2).all(|pair| {
pair[0].kind == pair[1].kind
&& pair[0].width == pair[1].width
&& pair[0].widths == pair[1].widths
&& pair[0].style == pair[1].style
&& pair[0].cap_start == pair[1].cap_start
&& pair[0].cap_end == pair[1].cap_end
})
}
fn render_merged(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
outset: Option<f32>,
) -> Result<()> {
let representative = *strokes
.last()
.expect("render_merged expects at least one stroke");
let blur_filter = if bypass_filter {
None
} else {
shape.image_filter(1.)
};
// Handle blur filter
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = representative.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = representative.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
let blur_filter = image_filter.clone();
state.surfaces.apply_mut(temp_surface as u32, |surface| {
let canvas = surface.canvas();
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
});
render_merged(
state,
shape,
strokes,
Some(temp_surface),
antialias,
true,
outset,
)?;
state.surfaces.apply_mut(temp_surface as u32, |surface| {
surface.canvas().restore();
});
Ok(())
},
)? {
return Ok(());
}
}
}
// `merge_fills` puts fills[0] on top (each new fill goes under the accumulated shader
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
// Expand selrect if outset is provided
let selrect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
let merged = merge_fills(&fills, selrect);
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let svg_attrs = shape.svg_attrs.as_ref();
let path_transform = shape.to_path_transform();
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_rect(
canvas,
representative,
&selrect,
&shape_type.corners(),
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Circle => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_circle(
canvas,
representative,
&selrect,
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let lod_stroke = representative.path_lod_at_scale(is_open, scale);
let mut paint =
lod_stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_path(
canvas,
&lod_stroke,
path,
&paint,
path_transform.as_ref(),
None,
blur_filter.as_ref(),
svg_attrs,
antialias,
);
}
}
_ => unreachable!("This shape should not have strokes"),
}
Ok(())
}
/// Renders a single stroke. Used by the shadow module which needs per-stroke
/// shadow filters.
#[allow(clippy::too_many_arguments)]
pub fn render_single(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
outset: Option<f32>,
) -> Result<()> {
render_single_internal(
render_state,
shape,
stroke,
surface_id,
shadow,
antialias,
false,
false,
outset,
)
}
#[allow(clippy::too_many_arguments)]
fn render_single_internal(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
outset: Option<f32>,
) -> Result<()> {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = stroke.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
render_single_internal(
state,
shape,
stroke,
Some(temp_surface),
shadow,
antialias,
true,
true,
outset,
)
},
)? {
return Ok(());
}
}
}
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let selrect = shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
let blur = if skip_blur {
None
} else {
shape.image_filter(1.)
};
if !matches!(shape.shape_type, Type::Text(_))
&& shadow.is_none()
&& matches!(stroke.fill, Fill::Image(_))
{
if let Fill::Image(image_fill) = &stroke.fill {
draw_image_stroke_in_container(
render_state,
shape,
stroke,
image_fill,
antialias,
target_surface,
)?;
}
} else {
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
&selrect,
&shape_type.corners(),
&paint,
scale,
shadow,
blur.as_ref(),
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&paint,
scale,
shadow,
blur.as_ref(),
antialias,
);
}
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let lod_stroke = stroke.path_lod_at_scale(is_open, scale);
let stroke = &lod_stroke;
let mut paint =
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
// Apply outset by increasing stroke width
if let Some(s) = outset.filter(|&s| s > 0.0) {
let current_width = paint.stroke_width();
// Path stroke kinds are built differently:
// - Center uses the stroke width directly.
// - Inner/Outer use a doubled width plus clipping/clearing logic.
// Compensate outset so visual growth is comparable across kinds.
let outset_growth = match stroke.render_kind(is_open) {
StrokeKind::Center => s * 2.0,
StrokeKind::Inner | StrokeKind::Outer => s * 4.0,
};
paint.set_stroke_width(current_width + outset_growth);
}
draw_stroke_on_path(
canvas,
stroke,
path,
&paint,
path_transform.as_ref(),
shadow,
blur.as_ref(),
svg_attrs,
antialias,
);
}
}
_ => unreachable!("This shape should not have strokes"),
}
}
Ok(())
}
// Render text paths (unused)
#[allow(dead_code)]
pub fn render_text_paths(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
paths: &Vec<(skia::Path, skia::Paint)>,
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
) {
let canvas = render_state
.surfaces
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes));
let selrect = &shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let mut paint: skia_safe::Handle<_> =
stroke.to_text_stroked_paint(false, selrect, svg_attrs, antialias);
if let Some(filter) = shadow {
paint.set_image_filter(filter.clone());
}
match stroke.render_kind(false) {
StrokeKind::Inner => {
for (path, _) in paths {
draw_inner_stroke_path(canvas, path, &paint, None, antialias);
}
}
StrokeKind::Center => {
for (path, _) in paths {
canvas.draw_path(path, &paint);
}
}
StrokeKind::Outer => {
for (path, _) in paths {
draw_outer_stroke_path(canvas, path, &paint, None, antialias);
}
}
}
}