mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
Point SKIA_BINARIES_URL at the 0.153.3 simd prebuild and adapt fonts, path stroke expansion, and gradients to the new API.
1541 lines
53 KiB
Rust
1541 lines
53 KiB
Rust
use crate::math::{Matrix, Point, Rect};
|
||
|
||
use crate::shapes::{
|
||
arrow_cap_path, merge_fills, square_cap_path, triangle_cap_path, Corners, Fill, ImageFill,
|
||
Path, Shape, Side, Stroke, StrokeCap, StrokeKind, StrokeStyle, 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>,
|
||
miter: Option<[f32; 4]>,
|
||
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, scale, miter, antialias,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Radii as Skia fits them to the shape, so the stroke follows the fill.
|
||
let shape_radii = corners.map(|radii| *RRect::new_rect_radii(*rect, &radii).radii_ref());
|
||
|
||
// 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 &shape_radii {
|
||
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()
|
||
|| inner_corners_collapse(stroke, rect, &shape_radii))
|
||
{
|
||
// When the inner stroke width exceeds a shape dimension, the inset
|
||
// rect goes negative and the stroke overflows outside the shape.
|
||
// Same when the inset radii can't follow the shape corners.
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
/// Whether the inset radii clamp to zero or get rescaled by Skia to fit.
|
||
fn inner_corners_collapse(stroke: &Stroke, rect: &Rect, radii: &Option<Corners>) -> bool {
|
||
let Some(radii) = radii else {
|
||
return false;
|
||
};
|
||
if matches!(stroke.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
|
||
return false;
|
||
}
|
||
let wanted = stroke.outer_corners(radii);
|
||
let inset = RRect::new_rect_radii(stroke.outer_rect(rect), &wanted);
|
||
radii
|
||
.iter()
|
||
.zip(wanted.iter().zip(inset.radii_ref()))
|
||
.any(|(shape, (want, got))| shape.x > 0.0 && (want.x <= 0.0 || want != got))
|
||
}
|
||
|
||
/// 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.
|
||
/// Dashed/dotted styles cut that band per side, each side fitting a whole
|
||
/// number of dashes the way a browser fits a dashed border.
|
||
#[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],
|
||
scale: f32,
|
||
miter: Option<[f32; 4]>,
|
||
antialias: bool,
|
||
) {
|
||
// Strokes sharing a border box are shaped by the whole box: a side this one
|
||
// does not paint still sets the radii its corners curve to, as in CSS.
|
||
let band_widths: [f32; 4] = match miter {
|
||
Some(profile) => std::array::from_fn(|side| {
|
||
if widths[side] > 0.0 {
|
||
widths[side]
|
||
} else {
|
||
profile[side]
|
||
}
|
||
}),
|
||
None => widths,
|
||
};
|
||
let [top, right, bottom, left] = band_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();
|
||
let (outer_radii, inner_radii) = 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);
|
||
}
|
||
(Some(outer_radii), Some(inner_radii))
|
||
}
|
||
None => {
|
||
pb.add_rect(outer, None, None);
|
||
if has_hole {
|
||
pb.add_rect(inner, None, None);
|
||
}
|
||
(None, None)
|
||
}
|
||
};
|
||
|
||
let mut band = pb.detach();
|
||
band.set_fill_type(skia::PathFillType::EvenOdd);
|
||
|
||
// Solid is the common case and the whole band is one fill. A collapsed
|
||
// band has no sides left to walk, so it fills solid too.
|
||
let style = stroke.with_width(stroke.max_width()).style_at_scale(scale);
|
||
let pattern = if style == StrokeStyle::Solid || !has_hole {
|
||
None
|
||
} else {
|
||
expand_side_patterns(
|
||
stroke,
|
||
style,
|
||
rect,
|
||
&outer,
|
||
&inner,
|
||
inner_radii,
|
||
widths,
|
||
antialias,
|
||
)
|
||
};
|
||
|
||
// Sides this stroke shares with another per-side stroke meet on a diagonal,
|
||
// so each one keeps only its own wedge of the corner.
|
||
let wedges = miter.and_then(|profile| side_wedges(&outer, outer_radii, profile, widths));
|
||
|
||
if pattern.is_none() && wedges.is_none() {
|
||
canvas.draw_path(&band, &fill_paint);
|
||
return;
|
||
}
|
||
|
||
// A filtered stroke draws inside a filtered layer, so the blur or shadow
|
||
// sees the clipped geometry rather than the whole band.
|
||
match fill_paint.image_filter() {
|
||
Some(filter) => {
|
||
fill_paint.set_image_filter(None);
|
||
let mut layer_paint = skia::Paint::default();
|
||
layer_paint.set_image_filter(filter);
|
||
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
|
||
canvas.save_layer(&layer_rec);
|
||
}
|
||
None => {
|
||
canvas.save();
|
||
}
|
||
}
|
||
|
||
if let Some(wedges) = &wedges {
|
||
canvas.clip_path(wedges, skia::ClipOp::Intersect, antialias);
|
||
}
|
||
|
||
match &pattern {
|
||
Some(pattern) => {
|
||
// Square corners need no clip: the side strips tile the band exactly.
|
||
// Rounded corners curve past them, and dots straddle the boundary.
|
||
let rounded = corners.is_some_and(|radii| radii.iter().any(|r| r.x > 0.0 || r.y > 0.0));
|
||
if rounded || style == StrokeStyle::Dotted {
|
||
canvas.clip_path(&band, skia::ClipOp::Intersect, antialias);
|
||
}
|
||
canvas.draw_path(pattern, &fill_paint);
|
||
}
|
||
None => {
|
||
canvas.draw_path(&band, &fill_paint);
|
||
}
|
||
}
|
||
canvas.restore();
|
||
}
|
||
|
||
/// Where a corner's miter line sits at the outer edge and at `depth` into the
|
||
/// side. It runs through the centre of curvature, so a corner splits on its arc.
|
||
fn miter_offsets(radius: (f32, f32), own: f32, adjacent: f32, depth: f32) -> (f32, f32) {
|
||
if own <= 0.0 {
|
||
return (0.0, adjacent);
|
||
}
|
||
// A corner with no neighbour to meet belongs to this side alone.
|
||
let (along, across) = if adjacent > 0.0 { radius } else { (0.0, 0.0) };
|
||
let slope = adjacent / own;
|
||
(along - across * slope, along - (across - depth) * slope)
|
||
}
|
||
|
||
/// The CSS miter wedges for the sides this stroke paints, each narrowing to a
|
||
/// diagonal where it meets a neighbour. `None` when it already fills the profile.
|
||
fn side_wedges(
|
||
outer: &Rect,
|
||
outer_radii: Option<Corners>,
|
||
profile: [f32; 4],
|
||
widths: [f32; 4],
|
||
) -> Option<skia::Path> {
|
||
if widths == profile {
|
||
return None;
|
||
}
|
||
let [top, right, bottom, left] = profile;
|
||
let radii = outer_radii.unwrap_or_default();
|
||
|
||
let mut pb = skia::PathBuilder::new();
|
||
let mut wedged = false;
|
||
for side in Side::ALL {
|
||
if widths[side.index()] <= 0.0 {
|
||
continue;
|
||
}
|
||
// Each side runs from its own corner to the next one clockwise.
|
||
let lead = side.index();
|
||
let trail = (lead + 1) % 4;
|
||
let (own, ccw, cw) = match side {
|
||
Side::Top => (top, left, right),
|
||
Side::Right => (right, top, bottom),
|
||
Side::Bottom => (bottom, right, left),
|
||
Side::Left => (left, bottom, top),
|
||
};
|
||
// A corner radius is an ellipse: the component along the side sets where
|
||
// the miter meets it, the one across it sets how deep the corner runs.
|
||
let (lead_radius, trail_radius) = match side {
|
||
Side::Top | Side::Bottom => (
|
||
(radii[lead].x, radii[lead].y),
|
||
(radii[trail].x, radii[trail].y),
|
||
),
|
||
Side::Right | Side::Left => (
|
||
(radii[lead].y, radii[lead].x),
|
||
(radii[trail].y, radii[trail].x),
|
||
),
|
||
};
|
||
// A rounded corner bulges past the straight strip, so the wedge reaches
|
||
// deeper than the side is thick. The band trims whatever overshoots.
|
||
let depth = own + lead_radius.1.max(trail_radius.1);
|
||
let (a_out, a_in) = miter_offsets(lead_radius, own, ccw, depth);
|
||
let (b_out, b_in) = miter_offsets(trail_radius, own, cw, depth);
|
||
let quad = match side {
|
||
Side::Top => [
|
||
(outer.left + a_out, outer.top),
|
||
(outer.right - b_out, outer.top),
|
||
(outer.right - b_in, outer.top + depth),
|
||
(outer.left + a_in, outer.top + depth),
|
||
],
|
||
Side::Right => [
|
||
(outer.right, outer.top + a_out),
|
||
(outer.right, outer.bottom - b_out),
|
||
(outer.right - depth, outer.bottom - b_in),
|
||
(outer.right - depth, outer.top + a_in),
|
||
],
|
||
Side::Bottom => [
|
||
(outer.right - a_out, outer.bottom),
|
||
(outer.left + b_out, outer.bottom),
|
||
(outer.left + b_in, outer.bottom - depth),
|
||
(outer.right - a_in, outer.bottom - depth),
|
||
],
|
||
Side::Left => [
|
||
(outer.left, outer.bottom - a_out),
|
||
(outer.left, outer.top + b_out),
|
||
(outer.left + depth, outer.top + b_in),
|
||
(outer.left + depth, outer.bottom - a_in),
|
||
],
|
||
};
|
||
pb.add_polygon(&quad.map(Point::from), true);
|
||
wedged = true;
|
||
}
|
||
wedged.then(|| pb.detach())
|
||
}
|
||
|
||
/// Expands each side's dash or dot pattern into the geometry it paints, fitted
|
||
/// so the side begins and ends on a dash. `None` when no side produced any.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn expand_side_patterns(
|
||
stroke: &Stroke,
|
||
style: StrokeStyle,
|
||
rect: &Rect,
|
||
outer: &Rect,
|
||
inner: &Rect,
|
||
inner_radii: Option<Corners>,
|
||
widths: [f32; 4],
|
||
antialias: bool,
|
||
) -> Option<skia::Path> {
|
||
let mut pb = skia::PathBuilder::new();
|
||
let mut expanded = false;
|
||
|
||
let mut stamp_paint = skia::Paint::default();
|
||
stamp_paint.set_style(skia::PaintStyle::Stroke);
|
||
stamp_paint.set_stroke_cap(skia::paint::Cap::Butt);
|
||
stamp_paint.set_anti_alias(antialias);
|
||
|
||
for side in Side::ALL {
|
||
let width = widths[side.index()];
|
||
if width <= 0.0 {
|
||
continue;
|
||
}
|
||
|
||
let mut side_stroke = stroke.with_width(width);
|
||
side_stroke.style = style;
|
||
|
||
// Where the uniform renderer would stroke this width, so inner/outer
|
||
// dots still come out as half circles once the band clips them.
|
||
let line = side_stroke.outer_rect(rect);
|
||
let (start, end) = match side {
|
||
Side::Top => ((outer.left, line.top), (outer.right, line.top)),
|
||
Side::Right => ((line.right, outer.top), (line.right, outer.bottom)),
|
||
Side::Bottom => ((outer.right, line.bottom), (outer.left, line.bottom)),
|
||
Side::Left => ((line.left, outer.bottom), (line.left, outer.top)),
|
||
};
|
||
let length = match side {
|
||
Side::Top | Side::Bottom => outer.width(),
|
||
Side::Right | Side::Left => outer.height(),
|
||
};
|
||
|
||
let Some(effect) = side_stroke.path_effect_fitted(length) else {
|
||
continue;
|
||
};
|
||
stamp_paint.set_stroke_width(width);
|
||
stamp_paint.set_path_effect(effect);
|
||
|
||
let line_path = {
|
||
let mut line_pb = skia::PathBuilder::new();
|
||
line_pb.move_to(start);
|
||
line_pb.line_to(end);
|
||
line_pb.detach()
|
||
};
|
||
|
||
let mut outline = skia::PathBuilder::new();
|
||
if skia::path_utils::fill_path_with_paint(
|
||
&line_path,
|
||
&stamp_paint,
|
||
&mut outline,
|
||
None,
|
||
None,
|
||
) {
|
||
pb.add_path(&outline.detach(), None);
|
||
expanded = true;
|
||
}
|
||
}
|
||
|
||
// A rounded band's corner notches paint solid, as a browser does for uneven
|
||
// widths. Dots are discrete and must not weld into a solid corner.
|
||
if style != StrokeStyle::Dotted {
|
||
if let Some(radii) = inner_radii {
|
||
for patch in corner_patches(outer, inner, &radii) {
|
||
pb.add_rect(patch, None, None);
|
||
expanded = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
expanded.then(|| pb.detach())
|
||
}
|
||
|
||
/// The corner boxes of a rounded band, from each side's leading outer corner to
|
||
/// where that corner's inner arc meets the straight edges.
|
||
fn corner_patches(outer: &Rect, inner: &Rect, inner_radii: &Corners) -> Vec<Rect> {
|
||
Side::ALL
|
||
.into_iter()
|
||
.filter_map(|side| {
|
||
let radius = inner_radii[side.index()];
|
||
if radius.x <= 0.0 && radius.y <= 0.0 {
|
||
return None;
|
||
}
|
||
let (left, right) = match side {
|
||
Side::Top | Side::Left => (outer.left, inner.left + radius.x),
|
||
Side::Right | Side::Bottom => (inner.right - radius.x, outer.right),
|
||
};
|
||
let (top, bottom) = match side {
|
||
Side::Top | Side::Right => (outer.top, inner.top + radius.y),
|
||
Side::Bottom | Side::Left => (inner.bottom - radius.y, outer.bottom),
|
||
};
|
||
let patch = Rect::from_ltrb(left, top, right, bottom);
|
||
(patch.width() > 0.0 && patch.height() > 0.0).then_some(patch)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[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();
|
||
|
||
paint_stroke.set_path_effect(None);
|
||
|
||
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,
|
||
) {
|
||
canvas.draw_path(
|
||
&square_cap_path(center, direction, size, extra_rotation),
|
||
paint,
|
||
);
|
||
}
|
||
|
||
fn draw_arrow_cap(
|
||
canvas: &skia::Canvas,
|
||
paint: &skia::Paint,
|
||
center: &Point,
|
||
direction: &Point,
|
||
size: f32,
|
||
) {
|
||
canvas.draw_path(&arrow_cap_path(center, direction, size), paint);
|
||
}
|
||
|
||
fn draw_triangle_cap(
|
||
canvas: &skia::Canvas,
|
||
paint: &skia::Paint,
|
||
center: &Point,
|
||
direction: &Point,
|
||
size: f32,
|
||
) {
|
||
canvas.draw_path(&triangle_cap_path(center, direction, size), 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,
|
||
Stroke::per_side_profile(shape.visible_strokes()),
|
||
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(),
|
||
Stroke::per_side_profile(shape.visible_strokes()),
|
||
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(),
|
||
Stroke::per_side_profile(shape.visible_strokes()),
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod per_side_tests {
|
||
use super::*;
|
||
use crate::shapes::StrokeStyle;
|
||
|
||
/// Outer-aligned radii for a 220x140 rect, radius 20, widths [10, 20, 15, 5]:
|
||
/// each corner grows by its two adjacent widths, so they are wider than tall.
|
||
fn outer_radii() -> Corners {
|
||
[
|
||
skia::Point::new(25.0, 30.0),
|
||
skia::Point::new(40.0, 30.0),
|
||
skia::Point::new(40.0, 35.0),
|
||
skia::Point::new(25.0, 35.0),
|
||
]
|
||
}
|
||
|
||
/// Reads a wedge path back as a flat list of points.
|
||
fn wedge_points(path: &skia::Path) -> Vec<(f32, f32)> {
|
||
path.points().iter().map(|p| (p.x, p.y)).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_wedge_reads_the_radius_along_the_side_it_runs_on() {
|
||
// The outer rect for an outer-aligned stroke: each edge pushed out by
|
||
// that side's width.
|
||
let outer = Rect::from_ltrb(-5.0, -10.0, 240.0, 155.0);
|
||
let profile = [10.0, 20.0, 15.0, 5.0];
|
||
|
||
// Only the right side is painted, as one stroke of a per-side colour set.
|
||
let wedge = side_wedges(&outer, Some(outer_radii()), profile, [0.0, 20.0, 0.0, 0.0])
|
||
.expect("the right side is painted, so it gets a wedge");
|
||
|
||
// The corner ellipses are 40 wide and 30/35 tall, and a vertical side
|
||
// reads the tall one, so the wedge meets the top edge at the corner.
|
||
assert_eq!(
|
||
wedge_points(&wedge),
|
||
vec![(240.0, 0.0), (240.0, 150.0), (180.0, 105.0), (180.0, 30.0),]
|
||
);
|
||
}
|
||
|
||
/// Whether `point` sits on the infinite line through `a` and `b`.
|
||
fn on_line(a: (f32, f32), b: (f32, f32), point: (f32, f32)) -> bool {
|
||
let cross = (b.0 - a.0) * (point.1 - a.1) - (b.1 - a.1) * (point.0 - a.0);
|
||
cross.abs() < 1e-3
|
||
}
|
||
|
||
#[test]
|
||
fn adjacent_wedges_share_one_miter_line() {
|
||
let outer = Rect::from_ltrb(-5.0, -10.0, 240.0, 155.0);
|
||
let profile = [10.0, 20.0, 15.0, 5.0];
|
||
let radii = Some(outer_radii());
|
||
|
||
let top = wedge_points(
|
||
&side_wedges(&outer, radii, profile, [10.0, 0.0, 0.0, 0.0]).expect("top wedge"),
|
||
);
|
||
let right = wedge_points(
|
||
&side_wedges(&outer, radii, profile, [0.0, 20.0, 0.0, 0.0]).expect("right wedge"),
|
||
);
|
||
|
||
// Both edges facing the top-right corner run through its centre of
|
||
// curvature, so the two sides abut with neither a gap nor an overlap.
|
||
let centre = (240.0 - 40.0, -10.0 + 30.0);
|
||
assert!(on_line(top[1], top[2], centre), "top edge missed: {top:?}");
|
||
assert!(
|
||
on_line(right[0], right[3], centre),
|
||
"right edge missed: {right:?}"
|
||
);
|
||
// And they are the same line, not merely two lines through one point.
|
||
assert!(
|
||
on_line(top[1], top[2], right[0]),
|
||
"not collinear: {right:?}"
|
||
);
|
||
}
|
||
|
||
/// Inner-aligned pattern for a 235x175 rect with a nominal 30/30 dash:
|
||
/// neither side holds whole periods, so each stretches by its own amount.
|
||
fn dashed_pattern() -> skia::Path {
|
||
let rect = Rect::from_ltrb(0.0, 0.0, 235.0, 175.0);
|
||
let widths = [16.0, 16.0, 16.0, 24.0];
|
||
let mut stroke = Stroke::new_inner_stroke(
|
||
16.0,
|
||
StrokeStyle::Dashed,
|
||
None,
|
||
None,
|
||
Some(30.0),
|
||
Some(30.0),
|
||
);
|
||
stroke.widths = Some(widths);
|
||
let inner = Rect::from_ltrb(24.0, 16.0, 235.0 - 16.0, 175.0 - 16.0);
|
||
expand_side_patterns(
|
||
&stroke,
|
||
StrokeStyle::Dashed,
|
||
&rect,
|
||
&rect,
|
||
&inner,
|
||
None,
|
||
widths,
|
||
true,
|
||
)
|
||
.expect("dashed sides must produce a pattern")
|
||
}
|
||
|
||
#[test]
|
||
fn each_side_starts_and_ends_on_a_dash() {
|
||
let pattern = dashed_pattern();
|
||
|
||
// Top side, over x. Sampled clear of the left and right strips so the
|
||
// reading is the top side's own pattern.
|
||
assert!(pattern.contains((30.0, 8.0)), "top must open with a dash");
|
||
assert!(pattern.contains((210.0, 8.0)), "top must close with a dash");
|
||
|
||
// Right side, over y, at the vertical stroke line (235 - 16 / 2).
|
||
assert!(
|
||
pattern.contains((227.0, 30.0)),
|
||
"right must open with a dash"
|
||
);
|
||
assert!(
|
||
pattern.contains((227.0, 145.0)),
|
||
"right must close with a dash"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sides_keep_their_own_gaps() {
|
||
let pattern = dashed_pattern();
|
||
// 235 stretches to 4 dashes of 33.57: the first gap is 33.57..67.14.
|
||
assert!(!pattern.contains((50.0, 8.0)), "top gap was filled");
|
||
// 175 stretches to 3 dashes of 35: the first gap is 35..70.
|
||
assert!(!pattern.contains((227.0, 50.0)), "right gap was filled");
|
||
}
|
||
}
|