Files
designcraft/crates/render/src/fx.rs
T

1520 lines
73 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.
//! Soft object effects (Effects panel): drop shadow, outer glow, inner shadow and basic feather,
//! drawn with vello filter layers.
//!
//! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded
//! pipeline each effect is rendered into an offscreen single-threaded context cropped to its
//! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts
//! draw directly. A very wide blur goes offscreen in both, at a lower resolution; the object
//! itself and the clip to its shape stay at full resolution.
//!
//! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text,
//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2),
//! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's
//! opacity rides on the filter colour's alpha.
//! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top.
//! - Inner glow, bevel and emboss, satin: built like the inner shadow (an offset inverse of the
//! shape, blurred and clipped) with screen / multiply blending; satin shows where two offset
//! copies' coverages differ.
//! - Feather: the object is masked by its shape inset by half the width and blurred, so the
//! edge fades from ~0 at the path to full strength `width` inside. The basic, directional and
//! gradient feathers fade the inner effects with the object.
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_doc::{Asset, AssetId, DocSettings, Document, InkManager, Item, ItemId, Layer, StrokeStyleDef};
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, kurbo};
use crate::{Frame, RenderOptions, Renderer, color_of};
use designcraft_compose::ShownOn;
/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.
pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
let a = angle.to_radians();
let distance = bound(distance);
Vec2::new(-a.cos() * distance, a.sin() * distance)
}
/// How far (points) the effects of `it` reach outside its geometry.
pub(crate) fn outset(it: &Item) -> f64 {
let e = &it.effects;
let mut o: f64 = 0.0;
if e.drop_shadow.on {
o = o.max(bound(e.drop_shadow.distance).abs() + bound(e.drop_shadow.size).abs() * 1.6 + 1.0);
}
if e.outer_glow.on {
o = o.max(bound(e.outer_glow.size).abs() * 1.6 + 1.0);
}
o
}
/// Whether `it` has a basic feather: a width on a closed shape.
fn basic_feather(it: &Item) -> bool {
it.effects.feather > 0.0 && it.path.is_closed()
}
/// Whether any of `it`'s feathers (basic, directional, gradient) fades it.
fn fades(it: &Item) -> bool {
let e = &it.effects;
basic_feather(it) || e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0) || e.gradient_feather.on
}
/// (blur σ, growth) in points for an effect `size` with `pct` percent hardened.
fn split(size: f64, pct: f64) -> (f64, f64) {
let size = bound(size).max(0.0);
let p = (pct / 100.0).clamp(0.0, 1.0);
(size * (1.0 - p) / 2.0, size * p)
}
/// Largest blur standard deviation handed to the filter planner, in points: far wider than any
/// page, so a larger one looks the same. Its planner squares the value in f32, so an unbounded
/// finite size would overflow to infinity there and never finish.
const MAX_SIGMA: f64 = 1.0e6;
/// Largest effect extent (size, distance, feather width) used for geometry, in points: twice the
/// largest blur, so a size of any finite value, or infinity, cannot overflow later arithmetic
/// (`sigma * 3.0`, padding, reach). NaN is 0.
fn bound(v: f64) -> f64 {
if v.is_nan() { 0.0 } else { v.clamp(-2.0 * MAX_SIGMA, 2.0 * MAX_SIGMA) }
}
fn std_dev(sigma: f64) -> f32 {
if sigma.is_nan() { 0.0 } else { sigma.clamp(0.0, MAX_SIGMA) as f32 }
}
fn blur(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: std_dev(sigma), edge_mode: EdgeMode::None })
}
fn shadow(sigma: f64, color: peniko::Color) -> Filter {
Filter::from_primitive(FilterPrimitive::DropShadowOnly { dx: 0.0, dy: 0.0, std_deviation: std_dev(sigma), color, edge_mode: EdgeMode::None })
}
/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the
/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry,
/// and past the gradient's ends its end opacities carry on.
pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) {
let gf = &it.effects.gradient_feather;
let (p0, p1) = gf.points(it.feather_bounds());
let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8)));
let stops = [stop(0.0, gf.start), stop(1.0, gf.end)];
let grad = if gf.radial {
peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice())
} else {
peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())
};
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
ctx.set_paint(grad);
ctx.set_paint_transform(f.view * xf);
ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())));
ctx.reset_paint_transform();
ctx.pop_layer();
}
impl Renderer {
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {
let doc = f.doc;
let e = &it.effects;
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
// Spread-space reach of what the object paints.
let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);
// The silhouette is the shape when the object paints its area (fill / image); text frames
// without a fill cast the shadow of their text only, which can't be grown.
let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)) || it.has_nested_items();
let mut below = vec![];
if e.outer_glow.on {
let g = &e.outer_glow;
below.push((Slot::OuterGlow, Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode));
}
if e.drop_shadow.on {
let d = &e.drop_shadow;
below.push((
Slot::DropShadow,
offset(doc.light_angle(d.angle, d.global_light), d.distance),
d.size,
d.spread,
d.color.clone(),
d.opacity,
d.mode,
));
}
for (slot, off, size, pct, color, opacity, mode) in below {
let Some(c) = doc.resolve_color(&color, 1.0) else { continue };
let (sigma, grow) = split(size, pct);
let paint = color_of(&c, opacity.clamp(0.0, 1.0));
let filter = shadow(sigma, paint);
// The offset moves the geometry, not the filter: vello drops layer content that lies
// entirely outside the viewport before filtering.
let r = (reach + off).inflate(grow, grow);
// The effect's blend sees what's behind the object: a layer around the filtered one.
let mix = crate::blend_mode(mode).mix;
if mix != Mix::Normal {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None);
}
let look = Look { slot, off, sigma, grow, paint: paint.components };
self.with_kept_filters(ctx, f, it, xf, page, look, r, |me, c, fr| {
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
c.set_transform(moved.view);
c.push_layer(None, None, None, None, Some(filter));
me.draw_body(c, &moved, it, bp, xf, page);
if grow > 0.0 && solid {
c.set_transform(moved.view * xf);
c.set_paint(peniko::Color::BLACK);
c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round));
c.stroke_path(bp);
}
c.pop_layer();
});
if mix != Mix::Normal {
ctx.pop_layer();
}
}
// The object, feathered or not, drawn in its own layer and faded there. A basic feather
// clips the body to the shape at device resolution; only its blurred mask may be drawn
// offscreen at a lower one.
let fading = fades(it);
let basic = basic_feather(it);
if fading {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
}
if basic {
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(bp);
}
self.draw_body(ctx, f, it, bp, xf, page);
if basic {
ctx.pop_layer();
}
if fading {
self.fade(ctx, f, it, bp, xf, reach);
ctx.pop_layer();
}
if !it.path.is_closed() {
return;
}
// The feathers fade the inner effects with the object: each one is faded in its own
// layer before it blends with what is behind.
let fade = fading.then_some(it);
// An image's edge is where it is opaque, not the frame: inner shadows, glows and bevels
// follow what is painted when the frame holds a graphic.
let painted = matches!(it.content, designcraft_doc::Content::Graphic(_)).then_some((it, page));
// Inner shadow, clipped to the shape.
if e.inner_shadow.on
&& let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0)
{
let s = &e.inner_shadow;
let (sigma, choke) = split(s.size, s.choke);
let off = offset(doc.light_angle(s.angle, s.global_light), s.distance);
self.inner_edge(
ctx,
f,
reach,
bp,
xf,
off,
sigma,
choke,
color_of(&c, s.opacity.clamp(0.0, 1.0)),
crate::blend_mode(s.mode).mix,
painted,
fade,
);
}
// Inner glow: from the edges inwards (or out from the centre), screened over the object.
if e.inner_glow.on
&& let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0)
{
let g = &e.inner_glow;
let (sigma, choke) = split(g.size, g.choke);
// Precise keeps the glow close to the edge: a tighter blur.
let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma };
let mix = crate::blend_mode(g.mode).mix;
let paint = color_of(&c, g.opacity.clamp(0.0, 1.0));
if g.center
&& let Some((it, page)) = painted
{
// With a graphic, the glow fills what the object paints, shrunk by the band: the
// object's coverage is drawn once offscreen and eroded by the band there.
let band = (bound(g.size) - choke).max(0.5);
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |me, c, fr| {
let spread = (sigma * 3.0 + band) / fr.px + 2.0;
let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return };
let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * fr.view;
me.draw_body(&mut off, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page);
off.flush();
let mut pm = vello_cpu::Pixmap::new(o.width, o.height);
off.render(&mut pm, &mut me.resources);
let mask = erode(&pm, band / (fr.px * o.scale));
// The eroded coverage in the glow color, ...
c.set_transform(fr.view);
c.push_layer(None, None, None, None, None);
c.push_layer(None, None, None, None, Some(blur(sigma)));
c.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale));
c.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(mask)),
sampler: peniko::ImageSampler::default(),
});
c.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height)));
let pad = sigma * 4.0 + band + 4.0 * fr.px;
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None);
c.set_transform(fr.view);
c.set_paint(paint);
c.fill_rect(&reach.inflate(pad, pad));
c.pop_layer();
c.pop_layer();
// ... blurred and kept only where the object paints.
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
me.draw_body(c, fr, it, bp, xf, page);
c.pop_layer();
c.pop_layer();
});
} else if g.center {
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |_, c, fr| {
c.set_transform(fr.view);
c.push_layer(None, None, None, None, Some(blur(sigma)));
c.set_transform(fr.view * xf);
c.set_paint(paint);
c.fill_path(bp);
// Fade towards the edge: cut a band of the glow's size away.
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
c.set_transform(fr.view * xf);
c.set_paint(peniko::Color::BLACK);
c.set_stroke(kurbo::Stroke::new((bound(g.size) - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round));
c.stroke_path(bp);
c.pop_layer();
c.pop_layer();
});
} else {
self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix, painted, fade);
}
}
// Satin: the shape shifted both ways along the angle, blurred, where exactly one covers.
// With a graphic, the shape is what the object paints, as for the inner shadow. Plain
// satin on a graphic with no distance compares two equal copies and is not drawn.
if e.satin.on
&& !(painted.is_some() && !e.satin.invert && e.satin.distance == 0.0)
&& let Some(c) = doc.resolve_color(&e.satin.color, 1.0)
{
let st = &e.satin;
let off = offset(st.angle, st.distance / 2.0);
let sigma = bound(st.size).max(0.0) / 2.0;
let paint = color_of(&c, st.opacity.clamp(0.0, 1.0));
let invert = st.invert;
if let Some((it, page)) = painted {
// With a graphic, the object's coverage is drawn once offscreen and the two shifted
// copies are compared there, pixel by pixel, so a partly transparent pixel that
// both copies cover equally gets no satin.
self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), fade, |me, c, fr| {
// The copies' shift on the device.
let shift = fr.view * off.to_point() - fr.view * kurbo::Point::ZERO;
let spread = (sigma * 3.0) / fr.px + shift.hypot() + 2.0;
let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return };
let mut off_ctx = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * fr.view;
me.draw_body(&mut off_ctx, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page);
off_ctx.flush();
let mut pm = vello_cpu::Pixmap::new(o.width, o.height);
off_ctx.render(&mut pm, &mut me.resources);
let mask = satin_mask(&pm, shift / o.scale, invert);
// The satin colour where the copies differ, ...
c.set_transform(fr.view);
c.push_layer(None, None, None, None, None);
c.push_layer(None, None, None, None, Some(blur(sigma)));
c.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale));
c.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(mask)),
sampler: peniko::ImageSampler::default(),
});
c.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height)));
let pad = sigma * 4.0 + off.hypot() + 4.0 * fr.px;
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None);
c.set_transform(fr.view);
c.set_paint(paint);
c.fill_rect(&reach.inflate(pad, pad));
c.pop_layer();
c.pop_layer();
// ... blurred and kept only where the object paints.
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
me.draw_body(c, fr, it, bp, xf, page);
c.pop_layer();
c.pop_layer();
});
} else {
self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), fade, |_, c, fr| {
// One copy of the shape in the satin colour, moved by `by`.
let copy = |c: &mut RenderContext, by: Vec2| {
c.set_transform(fr.view * Affine::translate(by) * xf);
c.set_paint(paint);
c.fill_path(bp);
};
c.set_transform(fr.view);
c.push_layer(None, None, None, None, Some(blur(sigma)));
if invert {
copy(c, Vec2::ZERO);
}
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None);
copy(c, off);
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None);
copy(c, -off);
c.pop_layer();
c.pop_layer();
c.pop_layer();
});
}
}
// Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other.
if e.bevel.on {
let b = &e.bevel;
let off = offset(doc.light_angle(b.angle, b.global_light), bound(b.size) * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5);
let sigma = bound(b.size).max(0.0) / 2.0;
if let Some(c) = doc.resolve_color(&b.highlight, 1.0) {
// The inverse shape moved away from the light lights the edges facing it.
let paint = color_of(&c, b.highlight_opacity.clamp(0.0, 1.0));
self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, paint, Mix::Screen, painted, fade);
}
if let Some(c) = doc.resolve_color(&b.shadow, 1.0) {
let paint = color_of(&c, b.shadow_opacity.clamp(0.0, 1.0));
self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, paint, Mix::Multiply, painted, fade);
}
}
}
/// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by
/// `sigma`, clipped to the shape and blended with `mix`. With `painted` (an object and the
/// page it shows on), the shape is what the object paints, so a transparent part of an image
/// is outside it: the edge runs along the image's opaque pixels, not the frame. With `fade`,
/// the edge fades with that object's feathers.
#[allow(clippy::too_many_arguments)]
fn inner_edge(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
reach: Rect,
bp: &BezPath,
xf: Affine,
off: Vec2,
sigma: f64,
choke: f64,
paint: peniko::Color,
mix: Mix,
painted: Option<(&Item, ShownOn)>,
fade: Option<&Item>,
) {
let sigma = bound(sigma).max(0.0);
let choke = bound(choke);
if let Some((it, page)) = painted {
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |me, c, fr| {
let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px;
let area = reach.inflate(pad, pad);
// The colour everywhere but under the painted pixels moved by `off`, blurred, ...
c.set_transform(fr.view);
c.push_layer(None, None, None, None, None);
c.push_layer(None, None, None, None, Some(blur(sigma)));
c.set_paint(paint);
c.fill_rect(&area);
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
me.draw_body(c, &moved, it, bp, xf, page);
c.pop_layer();
c.pop_layer();
// ... kept only where the object paints.
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
me.draw_body(c, fr, it, bp, xf, page);
c.pop_layer();
c.pop_layer();
});
return;
}
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), fade, |_, c, fr| {
c.set_transform(fr.view);
c.push_layer(None, None, None, None, Some(blur(sigma)));
// Everything outside the offset shape, so the colour bleeds in from the edges.
let m = Affine::translate(off) * xf;
let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px;
let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1);
let mut shape = bp.clone();
shape.apply_affine(m);
inv.extend(shape.iter());
c.set_paint(paint);
c.set_fill_rule(peniko::Fill::EvenOdd);
c.fill_path(&inv);
c.set_fill_rule(peniko::Fill::NonZero);
if choke > 0.0 {
c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round));
c.stroke_path(&shape);
}
c.pop_layer();
});
}
/// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with
/// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen
/// filter layers. Putting it inside the clip blends with transparent pixels instead. With
/// `fade`, what `draw` paints is faded by that object's feathers before it blends.
#[allow(clippy::too_many_arguments)]
fn with_blended_filters(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
reach: Rect,
sigma: f64,
mix: Mix,
clip: (&BezPath, Affine),
fade: Option<&Item>,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) {
let layered = mix != Mix::Normal || fade.is_some();
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None);
}
self.with_filters(ctx, f, reach, sigma, Some(clip), draw);
if let Some(it) = fade {
self.fade(ctx, f, it, clip.0, clip.1, reach);
}
if layered {
ctx.pop_layer();
}
}
/// Fade what was drawn in the current layer by `it`'s feathers: the basic feather's blurred
/// inset shape, a ramp for each directional feather side and the gradient feather. `bp` and
/// `xf` are the object's shape and spread transform, `reach` (spread space) what it paints.
fn fade(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, reach: Rect) {
let e = &it.effects;
// Choke keeps that much of the width opaque (a shorter fade); Sharp and Rounded corners
// follow the inset shape more tightly than Diffused.
if basic_feather(it) {
let fo = &e.feather_options;
let w = bound(e.feather) * (1.0 - fo.choke.clamp(0.0, 100.0) / 100.0).max(0.01);
let (sigma, join) = match fo.corners {
designcraft_doc::FeatherCorners::Diffused => (w / 4.0, kurbo::Join::Round),
designcraft_doc::FeatherCorners::Rounded => (w / 3.0, kurbo::Join::Round),
designcraft_doc::FeatherCorners::Sharp => (w / 6.0, kurbo::Join::Miter),
};
// Keep what was drawn where the blurred inset silhouette is.
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
self.with_filters(ctx, f, reach, sigma, None, |_, c, fr| {
c.set_transform(fr.view);
c.push_layer(None, None, None, None, Some(blur(sigma)));
c.set_transform(fr.view * xf);
c.set_paint(peniko::Color::BLACK);
c.fill_path(bp);
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
c.set_transform(fr.view * xf);
c.set_stroke(kurbo::Stroke::new(w).with_join(join));
c.stroke_path(bp);
c.pop_layer();
c.pop_layer();
});
ctx.pop_layer();
}
// Directional feather: one fading ramp per side, multiplied in.
let df = &e.directional_feather;
if df.on && df.widths.iter().any(|w| *w > 0.0) {
let b = it.inner_bounds();
let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { b };
let ramps = [
(df.widths[0], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0, b.y0 + df.widths[0])),
(df.widths[1], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0 + df.widths[1], b.y0)),
(df.widths[2], kurbo::Point::new(b.x0, b.y1), kurbo::Point::new(b.x0, b.y1 - df.widths[2])),
(df.widths[3], kurbo::Point::new(b.x1, b.y0), kurbo::Point::new(b.x1 - df.widths[3], b.y0)),
];
for (w, p0, p1) in ramps {
if w <= 0.0 {
continue;
}
let stops = [
peniko::ColorStop::from((0.0, peniko::Color::from_rgba8(0, 0, 0, 0))),
peniko::ColorStop::from((1.0, peniko::Color::from_rgba8(0, 0, 0, 255))),
];
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
ctx.set_transform(f.view * xf);
ctx.set_paint(peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()));
ctx.fill_rect(&area.inflate(4.0, 4.0));
ctx.reset_paint_transform();
ctx.pop_layer();
}
}
if e.gradient_feather.on {
gradient_feather_mask(ctx, f, it, xf);
}
}
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a
/// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into
/// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread,
/// then composited back. `clip` (a path and its spread transform) clips what `draw` paints;
/// at a lower resolution it clips the composited image on `ctx`, so its edge stays sharp.
pub(crate) fn with_filters(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
reach: Rect,
sigma: f64,
clip: Option<(&BezPath, Affine)>,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) {
let sigma = bound(sigma).max(0.0);
// 3σ of the Gaussian in pixels, plus a pixel of antialiasing.
let spread = sigma * 3.0 / f.px + 2.0;
if !f.mt && spread <= MAX_BLUR_SPREAD {
push_clip(ctx, f, clip);
draw(self, ctx, f);
pop_clip(ctx, clip);
return;
}
let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return };
// Clip where the result is at full resolution: offscreen unless that is reduced.
let (inner, outer) = if o.scale > 1.0 { (None, clip) } else { (clip, None) };
let pm = self.draw_offscreen(f, &o, inner, draw);
push_clip(ctx, f, outer);
place(ctx, o.x0, o.y0, o.scale, Arc::new(pm));
pop_clip(ctx, outer);
}
/// `draw` into the single-threaded offscreen image `o`.
fn draw_offscreen(
&mut self,
f: &Frame,
o: &Offscreen,
clip: Option<(&BezPath, Affine)>,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) -> Pixmap {
let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view;
let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f };
push_clip(&mut off, &shifted, clip);
draw(self, &mut off, &shifted);
pop_clip(&mut off, clip);
off.flush();
let mut pm = Pixmap::new(o.width, o.height);
off.render(&mut pm, &mut self.resources);
pm
}
/// [`Renderer::with_filters`] for a drop shadow or an outer glow of `it`, keeping its image
/// between renders. While the object, its effect, the view's scale and rotation, the render
/// options and the document's colors, styles, layers and images stay the same, a move of the object
/// or of the view by whole device pixels places the kept image again instead of drawing and
/// blurring it. Only multithreaded contexts (the canvas) keep images, and only of objects
/// without text or nested items.
#[allow(clippy::too_many_arguments)]
fn with_kept_filters(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
it: &Item,
xf: Affine,
page: ShownOn,
look: Look,
reach: Rect,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) {
let keepable = f.mt && it.text_frame().is_none() && it.children().is_empty() && it.media.is_none();
if !keepable {
self.with_filters(ctx, f, reach, look.sigma, None, draw);
return;
}
let [a, b, c, d, e, g] = (f.view * xf).as_coeffs();
let [va, vb, vc, vd, _, _] = f.view.as_coeffs();
let (linear, anchor) = ([a, b, c, d, va, vb, vc, vd], Vec2::new(e, g));
let plate = crate::PLATE.with(std::cell::Cell::get);
let rich_black = crate::color::RICH_BLACK.with(std::cell::Cell::get);
let key = (it.id, page, look.slot, plate);
let tick = self.kept.tick;
if let Some(k) = self.kept.entries.get_mut(&key)
&& k.look == look
&& k.linear == linear
&& k.px == f.px
&& k.rich_black == rich_black
&& k.opts == *f.opts
&& (Item { xf: k.item.xf, ..it.clone() }) == k.item
{
let shift = anchor - k.anchor;
let (dx, dy) = (shift.x.round(), shift.y.round());
if (shift.x - dx).abs() < 1e-3 && (shift.y - dy).abs() < 1e-3 {
k.used = tick;
let (x0, y0, scale, image) = (k.x0 + dx, k.y0 + dy, k.scale, k.image.clone());
place(ctx, x0, y0, scale, image);
return;
}
}
// The whole effect, not only the part in view, so that it can be placed anywhere later.
let spread = bound(look.sigma).max(0.0) * 3.0 / f.px + 2.0;
let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let covers = |o: &Offscreen| f64::from(o.width) * o.scale >= whole.x1 - o.x0 && f64::from(o.height) * o.scale >= whole.y1 - o.y0;
let Some(o) = fit(whole, spread).filter(|o| usize::from(o.width) * usize::from(o.height) <= KEEP_MAX_PIXELS && covers(o)) else {
self.with_filters(ctx, f, reach, look.sigma, None, draw);
return;
};
let image = Arc::new(self.draw_offscreen(f, &o, None, draw));
#[cfg(test)]
{
self.kept.drawn += 1;
}
place(ctx, o.x0, o.y0, o.scale, image.clone());
let kept = Kept {
item: it.clone(),
linear,
anchor,
look,
opts: f.opts.clone(),
px: f.px,
rich_black,
x0: o.x0,
y0: o.y0,
scale: o.scale,
image,
used: tick,
};
self.kept.insert(f.doc, key, kept);
}
}
/// Paint `image` with its top-left corner at device pixel (`x0`, `y0`), each of its pixels
/// covering `scale` device pixels.
fn place(ctx: &mut RenderContext, x0: f64, y0: f64, scale: f64, image: Arc<Pixmap>) {
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
ctx.set_transform(Affine::translate((x0, y0)) * Affine::scale(scale));
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler: peniko::ImageSampler::default() });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_transform(Affine::IDENTITY);
}
/// Pixels one kept effect image may have; a larger one is drawn each time.
const KEEP_MAX_PIXELS: usize = 4 << 20;
/// Pixels all kept effect images may have together (128 MiB).
const KEEP_BUDGET: usize = 32 << 20;
/// Renders an image no render has placed is kept for.
const KEEP_RENDERS: u64 = 256;
/// The effect below an object a kept image is of.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Slot {
OuterGlow,
DropShadow,
}
/// What an effect image looks like apart from the object: its offset (spread space), blur,
/// growth and colour.
#[derive(Clone, Copy, PartialEq)]
struct Look {
slot: Slot,
off: Vec2,
sigma: f64,
grow: f64,
paint: [f32; 4],
}
/// One kept effect image and what it was drawn from.
struct Kept {
/// The object, with the transform it had.
item: Item,
/// The linear parts of the object's spread → device transform and of the view (the effect offset turns with the view).
linear: [f64; 8],
/// The device position of the object's origin.
anchor: Vec2,
look: Look,
opts: RenderOptions,
px: f64,
rich_black: bool,
x0: f64,
y0: f64,
scale: f64,
image: Arc<Pixmap>,
used: u64,
}
/// What every kept image depends on besides its object.
struct Deps {
colors: designcraft_color::cms::ColorSettings,
swatches: Vec<designcraft_color::Swatch>,
settings: DocSettings,
inks: InkManager,
stroke_styles: Vec<StrokeStyleDef>,
layers: Vec<Layer>,
/// Held, so that an asset changed in place or a new one at the same address is a new `Arc`.
assets: Vec<(AssetId, Arc<Asset>)>,
}
impl Deps {
fn of(doc: &Document) -> Self {
Deps {
colors: designcraft_color::cms::active_settings(),
swatches: doc.swatches.clone(),
settings: doc.settings.clone(),
inks: doc.inks.clone(),
stroke_styles: doc.stroke_styles.clone(),
layers: doc.layers.clone(),
assets: doc.assets.iter().map(|(id, a)| (*id, a.clone())).collect(),
}
}
fn same(&self, doc: &Document) -> bool {
self.colors == designcraft_color::cms::active_settings()
&& self.swatches == doc.swatches
&& self.settings == doc.settings
&& self.inks == doc.inks
&& self.stroke_styles == doc.stroke_styles
&& self.layers == doc.layers
&& self.assets.len() == doc.assets.len()
&& self.assets.iter().zip(&doc.assets).all(|((a, p), (b, x))| a == b && Arc::ptr_eq(p, x))
}
}
/// Drop shadows and outer glows kept between renders ([`Renderer::with_kept_filters`]), at most
/// [`KEEP_BUDGET`] pixels; the images used longest ago go first, and an image no render placed
/// in [`KEEP_RENDERS`] renders goes too.
#[derive(Default)]
pub(crate) struct KeptEffects {
deps: Option<Deps>,
entries: HashMap<(ItemId, ShownOn, Slot, Option<u8>), Kept>,
pixels: usize,
tick: u64,
/// Effect images drawn for keeping (tests).
#[cfg(test)]
pub(crate) drawn: usize,
}
impl KeptEffects {
/// Start a render of `doc`: drop every image when what they depend on changed.
pub(crate) fn begin(&mut self, doc: &Document) {
self.tick += 1;
if !self.deps.as_ref().is_some_and(|d| d.same(doc)) {
self.clear();
return;
}
let tick = self.tick;
let before = self.entries.len();
self.entries.retain(|_, k| k.used + KEEP_RENDERS >= tick);
if self.entries.len() != before {
self.pixels = self.entries.values().map(|k| k.image.width() as usize * k.image.height() as usize).sum();
}
}
pub(crate) fn clear(&mut self) {
self.entries.clear();
self.pixels = 0;
self.deps = None;
}
fn insert(&mut self, doc: &Document, key: (ItemId, ShownOn, Slot, Option<u8>), kept: Kept) {
if self.deps.is_none() {
self.deps = Some(Deps::of(doc));
}
let size = |k: &Kept| k.image.width() as usize * k.image.height() as usize;
self.pixels += size(&kept);
if let Some(old) = self.entries.insert(key, kept) {
self.pixels -= size(&old);
}
while self.pixels > KEEP_BUDGET {
let Some(oldest) = self.entries.iter().min_by_key(|(_, k)| k.used).map(|(key, _)| *key) else { break };
if let Some(old) = self.entries.remove(&oldest) {
self.pixels -= size(&old);
}
}
}
}
fn push_clip(ctx: &mut RenderContext, f: &Frame, clip: Option<(&BezPath, Affine)>) {
if let Some((bp, xf)) = clip {
ctx.set_transform(f.view * xf);
ctx.push_clip_layer(bp);
}
}
fn pop_clip(ctx: &mut RenderContext, clip: Option<(&BezPath, Affine)>) {
if clip.is_some() {
ctx.pop_layer();
}
}
/// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the
/// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution
/// instead, where it is this wide, and has no detail to lose there.
const MAX_BLUR_SPREAD: f64 = 1024.0;
/// An offscreen image for [`Renderer::with_filters`]: where it goes on the device, how many device
/// pixels one of its pixels covers, and its size.
#[derive(Debug, PartialEq)]
struct Offscreen {
x0: f64,
y0: f64,
scale: f64,
width: u16,
height: u16,
}
/// The offscreen image an effect is drawn in: `reach` (device pixels) widened by the blur's
/// `spread`, within the window widened the same way, at a resolution that keeps the blur within
/// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows.
fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscreen> {
let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);
fit(reach.inflate(spread, spread).intersect(screen), spread)
}
/// The offscreen image covering `r` (device pixels) for a blur of `spread`, or `None` when it is
/// empty, not finite or more than `MAX_SIDE` a side.
fn fit(r: Rect, spread: f64) -> Option<Offscreen> {
if !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()) {
return None;
}
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let scale = (spread / MAX_BLUR_SPREAD).max(1.0);
let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64);
let (w, h) = (side(r.x1.ceil() - x0), side(r.y1.ceil() - y0));
(w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 })
}
/// The most pixels a coverage mask drawn offscreen ([`mask_offscreen`]) holds: 4096 × 4096.
const MAX_MASK_PIXELS: f64 = 4096.0 * 4096.0;
/// The offscreen image an object's coverage is drawn in to build an effect's mask: `reach` (device
/// pixels) widened by `spread`, within the window widened the same way, as [`offscreen`] finds it,
/// at a resolution low enough that the whole area fits within [`crate::MAX_SIDE`] a side and
/// [`MAX_MASK_PIXELS`] in all. `None` when none of it shows.
fn mask_offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscreen> {
let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);
let r = reach.inflate(spread, spread).intersect(screen);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
if !(w >= 1.0 && h >= 1.0) {
return None;
}
let scale = (spread / MAX_BLUR_SPREAD).max(1.0).max(w.max(h) / crate::MAX_SIDE as f64).max((w * h / MAX_MASK_PIXELS).sqrt());
let side = |len: f64| (len / scale).ceil().clamp(1.0, crate::MAX_SIDE as f64);
Some(Offscreen { x0, y0, scale, width: side(w) as u16, height: side(h) as u16 })
}
/// `pm`'s coverage shrunk by `r` pixels: each pixel takes the smallest alpha within an octagon of
/// radius `r` around it, as white premultiplied by that alpha. Pixels outside `pm` count as
/// transparent.
fn erode(pm: &vello_cpu::Pixmap, r: f64) -> vello_cpu::Pixmap {
let (w, h) = (pm.width() as usize, pm.height() as usize);
let radius = r.max(0.0).round().min(f64::from(u16::MAX)) as usize;
// The octagon is a square of half-width `side` widened by `diag` diagonal steps each way along
// both diagonals, so it reaches `radius` along the axes and about `radius` diagonally.
let diag = (radius as f64 * (1.0 - std::f64::consts::FRAC_1_SQRT_2)).round() as usize;
let side = radius.saturating_sub(2 * diag);
let mut alpha: Vec<u8> = pm.data().iter().map(|p| p.a).collect();
for (step, k) in [((1, 0), side), ((0, 1), side), ((1, 1), diag), ((1, -1), diag)] {
alpha = erode_lines(&alpha, w, h, step, k);
}
let data = alpha.into_iter().map(|a| vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }).collect();
vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())
}
/// `src` (`w` × `h`, row by row) with each value replaced by the smallest within `k` steps of it
/// along the lines that `step` walks. Steps past the edge count as 0.
fn erode_lines(src: &[u8], w: usize, h: usize, step: (isize, isize), k: usize) -> Vec<u8> {
if k == 0 {
return src.to_vec();
}
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h;
let mut out = vec![0; src.len()];
let mut line = Vec::new();
let mut window = std::collections::VecDeque::new();
for y in 0..h as isize {
for x in 0..w as isize {
// Each line starts at the pixel whose step back leaves the image.
if inside(x - step.0, y - step.1) {
continue;
}
line.clear();
let (mut cx, mut cy) = (x, y);
while inside(cx, cy) {
line.push(cy as usize * w + cx as usize);
(cx, cy) = (cx + step.0, cy + step.1);
}
// A running minimum over each pixel's window, with the values still in it in rising order.
window.clear();
let mut next = 0;
for i in 0..line.len() {
while next < line.len() && next <= i + k {
let v = src[line[next]];
while window.back().is_some_and(|&(_, b)| b >= v) {
window.pop_back();
}
window.push_back((next, v));
next += 1;
}
while window.front().is_some_and(|&(j, _)| j + k < i) {
window.pop_front();
}
let whole = i >= k && i + k < line.len();
out[line[i]] = if whole { window.front().map_or(0, |&(_, v)| v) } else { 0 };
}
}
}
out
}
/// The satin coverage of `pm`'s two copies moved by `shift` and `-shift` pixels: at each pixel,
/// how much the copies' coverages differ, or with `invert`, `pm`'s own coverage where they
/// agree, as white premultiplied by that amount. Copies are sampled bilinearly; pixels outside
/// `pm` count as transparent.
fn satin_mask(pm: &vello_cpu::Pixmap, shift: Vec2, invert: bool) -> vello_cpu::Pixmap {
let (w, h) = (i64::from(pm.width()), i64::from(pm.height()));
let src = pm.data();
let alpha = |x: i64, y: i64| if x < 0 || y < 0 || x >= w || y >= h { 0.0 } else { f64::from(src[(y * w + x) as usize].a) / 255.0 };
let sample = |x: f64, y: f64| {
let (fx, fy) = (x.floor(), y.floor());
let (tx, ty) = (x - fx, y - fy);
let (ix, iy) = (fx as i64, fy as i64);
let top = alpha(ix, iy) * (1.0 - tx) + alpha(ix.saturating_add(1), iy) * tx;
let bottom = alpha(ix, iy.saturating_add(1)) * (1.0 - tx) + alpha(ix.saturating_add(1), iy.saturating_add(1)) * tx;
top * (1.0 - ty) + bottom * ty
};
let mut data = Vec::with_capacity(src.len());
for y in 0..h {
for x in 0..w {
let (px, py) = (x as f64, y as f64);
let diff = (sample(px - shift.x, py - shift.y) - sample(px + shift.x, py + shift.y)).abs();
let v = if invert { alpha(x, y) * (1.0 - diff) } else { diff };
let a = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
data.push(vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a });
}
}
vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())
}
#[cfg(test)]
mod tests {
#[test]
fn a_huge_blur_size_reaches_the_planner_finite_and_squarable() {
use super::{MAX_SIGMA, bound, std_dev};
for v in [f64::MAX, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
let b = bound(v);
assert!(b.is_finite() && (b * 3.0 + 4.0 * b).is_finite(), "{v}");
}
assert_eq!(bound(7.5), 7.5);
assert_eq!(super::split(f64::MAX, 50.0), (MAX_SIGMA / 2.0 * 2.0 * 0.5, 2.0 * MAX_SIGMA * 0.5));
for s in [1.0e300, f64::MAX, f64::INFINITY] {
let d = std_dev(s);
assert_eq!(f64::from(d), MAX_SIGMA);
assert!((d * d).is_finite());
}
assert_eq!(std_dev(-3.0), 0.0);
assert_eq!(std_dev(f64::NAN), 0.0);
assert_eq!(std_dev(12.5), 12.5);
}
use super::*;
#[test]
fn a_wide_blur_is_drawn_offscreen_at_a_lower_resolution() {
let object = Rect::new(150.0, 150.0, 350.0, 350.0);
let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 };
assert_eq!(offscreen(object, 10.0, 1440, 900), Some(small));
// A 10,000 pt drop shadow at 100%: 3σ is 15,000 px, which asked for 30,204 px a side and as
// much padding again inside vello's filter layer. Drawn at 1/14.65 it is 1,024 px wide.
let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 };
assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide));
}
/// A coverage mask covers the whole widened area at a lower resolution when it is wider than
/// `MAX_SIDE` or holds more than `MAX_MASK_PIXELS`; a small one stays at full resolution.
#[test]
fn a_coverage_mask_covers_its_whole_area_within_its_pixel_budget() {
let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 };
assert_eq!(mask_offscreen(Rect::new(150.0, 150.0, 350.0, 350.0), 10.0, 1440, 900), Some(small));
for (reach, w, h) in [(Rect::new(0.0, 0.0, 65_535.0, 100.0), u16::MAX, 100), (Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 20_000, 20_000)] {
let o = mask_offscreen(reach, 10.0, w, h).unwrap();
assert_eq!((o.x0, o.y0), (-10.0, -10.0));
assert!(o.x0 + f64::from(o.width) * o.scale >= reach.x1 + 10.0, "{o:?}");
assert!(o.y0 + f64::from(o.height) * o.scale >= reach.y1 + 10.0, "{o:?}");
assert!(f64::from(o.width) * f64::from(o.height) <= MAX_MASK_PIXELS + f64::from(o.width) + f64::from(o.height), "{o:?}");
}
}
/// An inner shadow on a frame holding an image whose left half is transparent: the shadow
/// lines the image's opaque part (its edge in the middle of the frame), not only the frame.
#[test]
fn an_inner_shadow_follows_a_transparent_images_edge() {
let (d, mut it) = half_image_frame();
let s = &mut it.effects.inner_shadow;
s.on = true;
s.color = "[Black]".into();
s.opacity = 1.0;
s.distance = 0.0;
s.size = 20.0;
let lum = render_row(d, it);
// The image's opaque part starts at x = 200: just inside it the shadow darkens it; in its
// middle (x = 250, 50 pt from every edge but the frame's right one) it stays light.
assert!(lum(204) + 60 < lum(250), "shadow at the image's own edge: {} vs {}", lum(204), lum(250));
// The transparent half shows the page: no shadow painted there.
assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150));
}
/// Satin on a frame holding an image whose left half is transparent multiplies only over the
/// image's opaque part, plain or inverted: the page shows through the transparent half.
#[test]
fn satin_follows_a_transparent_images_edge() {
for invert in [false, true] {
let (d, mut it) = half_image_frame();
let s = &mut it.effects.satin;
s.on = true;
s.color = "[Black]".into();
s.opacity = 1.0;
s.angle = 0.0;
s.distance = 100.0;
s.size = 4.0;
s.invert = invert;
let lum = render_row(d, it);
// The copies are 50 pt to each side. Over the frame they leave the satin on 100 to 150
// (plain) or 150 to 250 (inverted), over the transparent half of the image.
let clear = if invert { 175 } else { 125 };
assert!(lum(clear) > 700, "invert {invert}: satin over the transparent part at x = {clear}: {}", lum(clear));
// Over the image's opaque part the copies (150 to 250 and 250 to 350) leave plain satin on
// all of it and inverted satin on none of it.
let shaded = lum(225) < 100;
assert_eq!(shaded, !invert, "invert {invert}: lum at x = 225 is {}", lum(225));
}
}
/// Satin on a frame holding a uniformly half-transparent image: where both copies cover the
/// same partial coverage (all of the image with a distance of 0, its middle with a short one),
/// plain satin leaves the image as it is; inverted satin shades it.
#[test]
fn satin_leaves_a_uniform_half_transparent_image_plain() {
for (distance, invert) in [(0.0, false), (40.0, false), (0.0, true)] {
let (d, mut it) = image_frame(|_| image::Rgba([255, 255, 255, 128]));
let s = &mut it.effects.satin;
s.on = true;
s.color = "[Black]".into();
s.opacity = 1.0;
s.angle = 0.0;
s.distance = distance;
s.size = 4.0;
s.invert = invert;
let lum = render_row(d, it);
// The image is white over a white page; satin multiplies black over it.
let shaded = lum(200) < 700;
assert_eq!(shaded, invert, "distance {distance}, invert {invert}: lum at x = 200 is {}", lum(200));
}
}
/// The satin mask is the difference of the shifted coverages: none where both copies cover the
/// same half coverage, half along an edge only one copy reaches, and the inverse keeps the
/// pixmap's own coverage where they agree.
#[test]
fn the_satin_mask_compares_partial_coverage() {
let half = vello_cpu::color::PremulRgba8 { r: 128, g: 128, b: 128, a: 128 };
let pm = vello_cpu::Pixmap::from_parts(vec![half; 40 * 40], 40, 40);
let plain = satin_mask(&pm, Vec2::new(3.0, 0.0), false);
assert_eq!(plain.sample(20, 20).a, 0);
assert_eq!(plain.sample(1, 20).a, 128);
let inverted = satin_mask(&pm, Vec2::new(3.0, 0.0), true);
assert_eq!(inverted.sample(20, 20).a, 128);
assert_eq!(inverted.sample(1, 20).a, 64);
}
/// An inner glow from the center on a frame holding an image whose left half is transparent
/// fills only the image's opaque part, fading toward that part's own edge.
#[test]
fn a_center_inner_glow_follows_a_transparent_images_edge() {
let (d, mut it) = half_image_frame();
let g = &mut it.effects.inner_glow;
g.on = true;
g.center = true;
g.color = "[Black]".into();
g.opacity = 1.0;
g.size = 20.0;
g.mode = Default::default();
let lum = render_row(d, it);
// The transparent half shows the page: no glow painted there.
assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150));
// The opaque part runs from x = 200 to 300: dark in its middle, light at its own left edge.
assert!(lum(250) + 60 < lum(204), "glow fades toward the image's own edge: {} vs {}", lum(250), lum(204));
}
/// Eroding keeps a half-transparent area at half coverage away from its edge and clears the
/// band along the edge.
#[test]
fn erosion_keeps_partial_coverage_and_clears_the_edge_band() {
let half = vello_cpu::color::PremulRgba8 { r: 128, g: 128, b: 128, a: 128 };
let pm = vello_cpu::Pixmap::from_parts(vec![half; 40 * 40], 40, 40);
let out = erode(&pm, 5.0);
assert_eq!(out.sample(20, 20).a, 128);
assert_eq!(out.sample(2, 20).a, 0);
assert_eq!(out.sample(20, 37).a, 0);
assert_eq!(out.sample(6, 20).a, 128);
}
/// Eroding clears every pixel within the radius of a one-pixel hole, along the axes and the
/// diagonals, and keeps the pixels beyond it.
#[test]
fn erosion_clears_around_a_small_hole() {
let opaque = vello_cpu::color::PremulRgba8 { r: 255, g: 255, b: 255, a: 255 };
let mut data = vec![opaque; 40 * 40];
data[20 * 40 + 20] = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 };
let out = erode(&vello_cpu::Pixmap::from_parts(data, 40, 40), 5.0);
for (x, y) in [(20, 20), (24, 20), (16, 20), (20, 24), (20, 16), (23, 23), (17, 17), (23, 17), (17, 23)] {
assert_eq!(out.sample(x, y).a, 0, "({x}, {y}) is within 5 pixels of the hole");
}
for (x, y) in [(27, 20), (20, 13), (25, 25), (14, 14)] {
assert_eq!(out.sample(x, y).a, 255, "({x}, {y}) is more than 5 pixels from the hole");
}
}
/// A 200 pt frame at (100, 100) holding a 20 × 20 image with a transparent left half and an
/// opaque white right half, in a new document.
fn half_image_frame() -> (designcraft_doc::Document, Item) {
image_frame(|x| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) })
}
/// A 200 pt frame at (100, 100) holding a 20 × 20 image whose pixels in column x are
/// `pixel(x)`, in a new document.
fn image_frame(pixel: impl Fn(u32) -> image::Rgba<u8>) -> (designcraft_doc::Document, Item) {
use designcraft_doc::Document;
use designcraft_doc::build::NewDocument;
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let img = image::RgbaImage::from_fn(20, 20, |x, _| pixel(x));
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let aid = designcraft_doc::AssetId(d.alloc());
d.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
id: aid,
name: "half.png".into(),
mime: "image/png".into(),
data: std::sync::Arc::new(png),
pixels: Some((20, 20)),
..Default::default()
}),
);
let mut it = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)),
);
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (200.0, 200.0),
xf: Affine::translate((100.0, 100.0)),
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
(d, it)
}
/// The first page with `it` added, rendered at 100%: the sum of R, G and B at x on row 200.
fn render_row(mut d: designcraft_doc::Document, it: Item) -> impl Fn(u32) -> u32 {
d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None).unwrap();
let cache = crate::Cache::new();
let mut r = Renderer::new();
r.threads = 0;
let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap();
move |x: u32| {
let p = page.pixel(x, 200);
u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2])
}
}
/// A document with one image frame casting a drop shadow, and the frame's id.
fn shadowed_image() -> (designcraft_doc::Document, designcraft_doc::ItemId) {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, SpreadRef};
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let img = image::RgbaImage::from_fn(64, 48, |x, y| image::Rgba([(x * 4) as u8, (y * 5) as u8, 128, 255]));
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let aid = designcraft_doc::AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(designcraft_doc::Asset {
id: aid,
name: "photo.png".into(),
mime: "image/png".into(),
data: Arc::new(png),
pixels: Some((64, 48)),
..Default::default()
}),
);
let id = designcraft_doc::ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 120.0)));
it.xf = Affine::translate((100.0, 100.0));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (160.0, 120.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
let s = &mut it.effects.drop_shadow;
(s.on, s.color, s.opacity, s.size, s.distance) = (true, "[Black]".into(), 0.75, 12.0, 9.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
(d, id)
}
/// Moving a shadowed frame, or the view, by whole pixels places the kept shadow again: the
/// shadow is drawn and blurred once, and every frame matches a render that draws it afresh.
/// A new effect setting, zoom, colour or a move by part of a pixel draws it again.
#[test]
fn a_moved_shadow_is_drawn_once_and_placed_again() {
use designcraft_doc::SpreadRef;
let (mut d, id) = shadowed_image();
let cache = crate::Cache::new();
let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut kept = Renderer::new();
kept.threads = 2;
let render =
|r: &mut Renderer, d: &designcraft_doc::Document, size: (u32, u32), view: Affine| r.render(d, &cache, &placed, size, view, &opts);
let fresh = |d: &designcraft_doc::Document, size: (u32, u32), view: Affine| {
let mut r = Renderer::new();
r.threads = 2;
r.render(d, &crate::Cache::new(), &placed, size, view, &opts)
};
let close = |a: &crate::Rendered, b: &crate::Rendered, what: &str| {
let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(a.pixels.len() == b.pixels.len() && worst <= 1, "{what}: off by {worst}");
};
let view = Affine::scale(1.5);
close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "first render");
assert_eq!(kept.kept.drawn, 1);
for step in 1..=4 {
let it = d.item_mut(id).unwrap();
it.xf = Affine::translate((2.0, 4.0 / 3.0)) * it.xf;
close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "after a move");
// A patch of the view, as the canvas repaints after an edit.
let patch = Affine::translate((-150.0, -140.0)) * view;
close(&render(&mut kept, &d, (120, 90), patch), &fresh(&d, (120, 90), patch), "a patch");
assert_eq!(kept.kept.drawn, 1, "step {step}: placed again, not drawn");
}
// Panning by whole pixels.
let panned = Affine::translate((-37.0, 11.0)) * view;
close(&render(&mut kept, &d, (500, 450), panned), &fresh(&d, (500, 450), panned), "panned");
assert_eq!(kept.kept.drawn, 1);
// Part of a pixel, another zoom, another size, another colour: drawn again.
let it = d.item_mut(id).unwrap();
it.xf = Affine::translate((0.25, 0.0)) * it.xf;
render(&mut kept, &d, (500, 450), view);
assert_eq!(kept.kept.drawn, 2);
render(&mut kept, &d, (500, 450), Affine::scale(2.0));
assert_eq!(kept.kept.drawn, 3);
d.item_mut(id).unwrap().effects.drop_shadow.size = 20.0;
close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "a new size");
assert_eq!(kept.kept.drawn, 4);
d.swatches.push(designcraft_color::Swatch::color("Extra", designcraft_color::Color::rgb8(1, 2, 3)));
render(&mut kept, &d, (500, 450), view);
assert_eq!(kept.kept.drawn, 5, "a swatch change drops kept images");
// An image changed in place (its only document owner editing it).
let asset = d.assets.values_mut().next().unwrap();
Arc::make_mut(asset).name = "edited.png".into();
render(&mut kept, &d, (500, 450), view);
assert_eq!(kept.kept.drawn, 6, "an edited image drops kept images");
// Single-threaded renders (exports) draw as before and keep nothing.
let mut single = Renderer::new();
single.threads = 0;
render(&mut single, &d, (500, 450), view);
assert_eq!(single.kept.drawn, 0);
}
/// In the CMYK blend space view the ink passes keep an image per plate, placed again on a move.
#[test]
fn the_ink_passes_keep_their_shadows() {
use designcraft_doc::SpreadRef;
let (mut d, id) = shadowed_image();
d.settings.blend_space = designcraft_doc::BlendSpace::Cmyk;
let cache = crate::Cache::new();
let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = crate::RenderOptions { blend_space_view: true, background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts);
// One for each ink pass, and one for the ordinary drawing around their area.
assert_eq!(r.kept.drawn, 3);
for _ in 0..3 {
let it = d.item_mut(id).unwrap();
it.xf = Affine::translate((3.0, 1.0)) * it.xf;
r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts);
}
assert_eq!(r.kept.drawn, 3);
// Renders served from the CMYK viewport snapshot count towards the expiry too.
assert!(!r.kept.entries.is_empty());
for _ in 0..=KEEP_RENDERS {
r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts);
}
assert_eq!(r.kept.drawn, 3, "the snapshot served every render");
assert!(r.kept.entries.is_empty(), "images unused for {KEEP_RENDERS} renders are dropped");
}
/// A view turned 90° with the frame turned back by 90° leaves the frame where it was on the
/// device, but turns the shadow's offset: the shadow is drawn again.
#[test]
fn a_turned_view_draws_the_shadow_again() {
use designcraft_doc::SpreadRef;
let (mut d, id) = shadowed_image();
let cache = crate::Cache::new();
let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut kept = Renderer::new();
kept.threads = 2;
let view = Affine::scale(1.5);
kept.render(&d, &cache, &placed, (500, 450), view, &opts);
assert_eq!(kept.kept.drawn, 1);
// Exact quarter turns, so that the frame's spread → device transform stays the same.
let (turn, back) = (Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]), Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]));
let it = d.item_mut(id).unwrap();
it.xf = back * it.xf;
assert_eq!((view * turn * it.xf).as_coeffs(), (view * Affine::translate((100.0, 100.0))).as_coeffs());
let turned = view * turn;
let image = kept.render(&d, &cache, &placed, (500, 450), turned, &opts);
let mut r = Renderer::new();
r.threads = 2;
let fresh = r.render(&d, &crate::Cache::new(), &placed, (500, 450), turned, &opts);
let worst = image.pixels.iter().zip(&fresh.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= 1, "off by {worst}");
assert_eq!(kept.kept.drawn, 2);
}
/// A feathered object fades to transparent at its edge, its bevel and inner glow included: where
/// the feather shows what is behind the object, the highlight and shadow leave it as it is.
#[test]
fn feathers_fade_the_inner_effects_with_the_object() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Fill, SpreadRef};
let doc = |image: bool, feather: u8, inner: u8| {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut back = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 350.0, 350.0)),
);
back.fill = Fill::swatch("C=0 M=100 Y=0 K=0");
d.insert_item(SpreadRef::Doc(0), back, None).unwrap();
let mut it = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)),
);
if image {
let img = image::RgbaImage::from_pixel(20, 20, image::Rgba([40, 160, 90, 255]));
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let aid = designcraft_doc::AssetId(d.alloc());
d.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
id: aid,
name: "green.png".into(),
mime: "image/png".into(),
data: std::sync::Arc::new(png),
pixels: Some((20, 20)),
..Default::default()
}),
);
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (200.0, 200.0),
xf: Affine::translate((100.0, 100.0)),
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
} else {
it.fill = Fill::swatch("C=100 M=0 Y=0 K=0");
}
let e = &mut it.effects;
match feather {
0 => e.feather = 30.0,
1 => (e.directional_feather.on, e.directional_feather.widths) = (true, [30.0; 4]),
_ => e.gradient_feather.on = true,
}
match inner {
0 => {}
1 => (e.bevel.on, e.bevel.size) = (true, 12.0),
2 => (e.inner_glow.on, e.inner_glow.color, e.inner_glow.opacity, e.inner_glow.size) = (true, "[Paper]".into(), 1.0, 20.0),
3 => {
(e.inner_glow.on, e.inner_glow.color, e.inner_glow.opacity, e.inner_glow.size) = (true, "[Black]".into(), 1.0, 20.0);
e.inner_glow.center = true;
}
_ => {
(e.satin.on, e.satin.color, e.satin.opacity) = (true, "[Black]".into(), 1.0);
(e.satin.distance, e.satin.invert) = (0.0, true);
}
}
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
d
};
let px = |d: &Document, threads: u16, x: u32| {
let mut r = Renderer::new();
r.threads = threads;
r.render_page(d, &crate::Cache::new(), 0, 1.0, false, &crate::RenderOptions::default()).unwrap().pixel(x, 200)
};
let diff = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).map(|(p, q)| u32::from(p.abs_diff(q))).sum::<u32>();
for image in [false, true] {
for feather in 0..3 {
for threads in [0, 4] {
let plain = doc(image, feather, 0);
// The object's faded edges: the left and right ones for the basic and directional
// feathers, the right one for the gradient feather (opaque on the left).
let edges: &[u32] = if feather == 2 { &[299] } else { &[100, 299] };
for &x in edges {
let behind = px(&plain, threads, 70);
let faded = px(&plain, threads, x);
assert!(diff(faded, behind) < 40, "image {image} feather {feather}: the edge at {x} shows what is behind");
for inner in [1, 2, 3, 4] {
let with = px(&doc(image, feather, inner), threads, x);
assert!(
diff(with, faded) < 30,
"image {image} feather {feather} inner {inner} threads {threads}: {with:?} over the faded edge at {x}, {faded:?} without"
);
}
}
// Inside the bevel and glow bands the effect still shows, faded with the object
// where the feather reaches.
let inside = px(&plain, threads, 110);
for inner in [1, 2] {
let with = px(&doc(image, feather, inner), threads, 110);
assert!(
diff(with, inside) > 0,
"image {image} feather {feather} inner {inner} threads {threads}: {with:?} at 110, {inside:?} without the effect"
);
}
}
}
}
}
}