Merge pull request #2409 from dotCooCoo/feat/live-rasterize-effect

Effects: Effect › Rasterize draws an object as pixels while its paths stay editable
This commit is contained in:
BFlat
2026-10-17 20:02:59 +07:00
committed by GitHub
42 changed files with 1712 additions and 118 deletions
+532 -43
View File
@@ -17,6 +17,12 @@
//! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
//! change.
//! - Rasterize: the content drawn again on the image's own grid, as Object › Rasterize draws it
//! ([`Renderer::rasterize_image`]), then into the content's raster
//! ([`effects::Rasterize::compose`]; an image too large for that is made from the content's
//! raster, [`effects::Rasterize::apply`]), clipped to its outline with Create Clipping Mask. The
//! effects before it in the stack, and the shadows and glows after it, are drawn around it by
//! splitting the stack there ([`Renderer::raster_fx`]).
//!
//! While [`crate::RenderOptions::interacting`], an object whose content only moved draws its
//! cached shadows, glows and filtered content at its new place instead of running the effects
@@ -110,6 +116,41 @@ struct Content<'a> {
/// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and
/// Inner Glow is skipped).
outline: Option<(&'a BezPath, FillRule)>,
/// What Rasterize makes its image over: the object's [`drawn_bounds`] as far as the effects
/// before it spread them (`None`: `reach`).
bounds: Option<Rect>,
/// Where the image of the Rasterize first in these effects is kept for the effects after a
/// split ([`Renderer::raster_fx`]), which draw this content more than once.
kept: Option<&'a Kept>,
}
/// The image of the last Rasterize before a split in an effect stack ([`Renderer::raster_fx`]). It
/// is made the first time the effects after the split draw the content and drawn from here the
/// other times, so the content before the split is drawn once.
struct Kept {
/// Where the effects after the split take the content from: the visible area grown by how
/// far they paint.
window: Rect,
/// The view and pixel size of the split's frame, when the image is over
/// [`Renderer::rasterize_image`]'s limits: the content is then drawn in that frame.
frame: Option<(Affine, f64)>,
image: std::cell::RefCell<Option<KeptImage>>,
}
#[derive(Clone)]
enum KeptImage {
/// The image on its own grid, as [`Renderer::rasterize_image`] makes it.
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>),
/// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the
/// Rasterize applied, as the first draw made it, and the transform from its pixels to
/// document space.
Raster(Arc<vello_cpu::Pixmap>, Affine),
}
/// The [`Content::bounds`] of `n` under raster effects `rfx`: its [`drawn_bounds`] when `rfx`
/// holds a Rasterize.
fn rasterize_bounds(symbols: &SymbolBoxes, n: &Node, rfx: &[RasterFx]) -> Option<Rect> {
rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| drawn_bounds(symbols, n)).flatten()
}
/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset).
@@ -157,7 +198,7 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
return cull_bounds(symbols, &art).map(|b| {
let o = effects::outset(&n.appearance.effects, b);
let o = outset(symbols, &n.appearance.effects, b);
b.inflate(o, o)
});
}
@@ -178,11 +219,173 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let s = n.appearance.outset();
r.map(|r| {
let r = r.inflate(s, s);
let o = effects::outset(&n.appearance.effects, r);
// The fills' and strokes' own raster effects, then the object's.
let r = n.appearance.items.iter().filter(|i| paints(i)).map(|i| spread(symbols, item_effects(i), r)).fold(r, |acc, b| acc.union(b));
let o = outset(symbols, &n.appearance.effects, r);
r.inflate(o, o)
})
}
/// Does `item` paint (as [`Renderer::paint_items`] draws it)?
fn paints(item: &AppearanceItem) -> bool {
item.visible() && !item.paint().is_none()
}
/// The bounds of what `n` draws, its own and its members' geometry effects applied and the raster
/// effects of its members and of its fills and strokes included (each fill's and stroke's
/// spreading what it paints, [`item_bounds`]), with strokes measured as
/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects
/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over.
pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = art_bounds(symbols, n)?;
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
}
/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path
/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects,
/// 3D art, a brushed stroke and an item that draws no path.
fn item_bounds(n: &Node, item: &AppearanceItem) -> Option<Rect> {
let stroke = matches!(item, AppearanceItem::Stroke(_));
if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) {
return None;
}
let g = effected_path(n, &node_bezpath(n)?);
let fx = item_effects(item);
if effects::has_geometry(fx) {
let ig = apply(fx, &g, &GeomContext::of(n).item(item));
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o));
}
let one = vectorcraft_doc::Appearance { items: vec![item.clone()], ..Default::default() };
(!g.elements().is_empty()).then(|| one.stroked_bounds(g.bounding_box(), || g.clone()))
}
/// `b` grown by how far raster effects `fx` paint past it, each spreading what the ones before it
/// paint (Rasterize at the document's raster effects resolution where it takes it).
fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect {
let ppi = doc_raster_ppi(symbols.document());
effects::raster_effects(fx).iter().fold(b, |r, e| e.at_document_ppi(ppi).spread(r, 72.0 / ppi))
}
/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of
/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches).
fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = shape_bounds(symbols, n);
let brushed = vectorcraft_brush::has_brush(n)
.then(|| vectorcraft_brush::expand(symbols.document(), n))
.flatten()
.and_then(|art| art.visual_bounds_with(symbols));
let b = vectorcraft_geom::union_opt(b, brushed)?;
let pad = if matches!(n.kind, NodeKind::SymbolInstance { .. }) { crate::brush_fx::pad(symbols, n) } else { 0.0 };
Some(b.inflate(pad, pad))
}
/// [`art_bounds`] without brush art.
fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
// The visible members of `g` (a Shaper group's first member is its source art, which paints
// nothing).
let union = |g: &Node| {
let members = g.children()?.get(usize::from(g.extras().shaper.is_some())..).unwrap_or_default();
members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
};
match &n.kind {
_ if effects::has_container_appearance(n) => {
let art = effects::evaluate_container(n);
let art = art.as_ref().unwrap_or(n);
if art.clips() { art.visual_bounds_with(symbols) } else { union(art) }
}
NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n),
_ if has_fx(n) => {
// A 3D effect's art.
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
return drawn_bounds(symbols, &art);
}
let g = effected_path(n, &node_bezpath(n)?);
// The path as the fills and strokes without geometry effects of their own paint it
// (as every fill and stroke would when none paints).
let mut plain = n.appearance.clone();
plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i))));
let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints);
let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone()));
let ctx = GeomContext::of(n);
for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
r = vectorcraft_geom::union_opt(r, (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o)));
}
r
}
// As [`cull_bounds_in`] measures them, without the object's own raster effects: the
// reshaped art (type's outlines measured one by one with the effects of its own fills and
// strokes), grown by how far the effects in a symbol's art and a live object's content
// paint past it.
_ if has_object_fx(n) => {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
let reshaped = effects::reshape(n, symbol.as_deref());
let b = match &reshaped {
Some(r) if matches!(n.kind, NodeKind::Text(_)) => {
r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
}
Some(r) => r.visual_bounds_with(symbols),
None => None,
}
.or_else(|| n.visual_bounds_with(symbols))?;
let a = match &reshaped {
Some(r) => reshaped_art_effects_reach(symbols, n, r),
None => art_effects_reach(symbols, n),
};
let a = a.max(live_content_reach(symbols, n));
Some(b.inflate(a, a))
}
_ => {
let b = n.visual_bounds_with(symbols)?;
let a = art_effects_reach(symbols, n).max(live_content_reach(symbols, n));
Some(b.inflate(a, a))
}
}
}
/// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop
/// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster
/// effects inside the art.
pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = effects::crop_marks_art(n) {
return drawn_bounds(symbols, &art);
}
drawn_bounds(symbols, n).map(|b| spread(symbols, &n.appearance.effects, b))
}
/// The outline the renderer clips `n`'s Feather, Inner Glow and Rasterize with Create Clipping
/// Mask to: a path's or compound path's geometry with its object-level geometry effects applied,
/// the outline of the art [`Renderer::fx_art`] makes for an object with object effects, and with
/// Crop Marks the object's own. For other objects it is [`effects::clip_outline`].
pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
if let Some(art) = effects::crop_marks_art(n) {
return drawn_outline(doc, art.children()?.first()?);
}
if has_object_fx(n) {
let art = Renderer::new().fx_art(doc, &Arc::new(n.clone()), false);
return effects::clip_outline(&art);
}
match &n.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => {
Some((effected_path(n, &node_bezpath(n)?), *rule))
}
_ => effects::clip_outline(n),
}
}
/// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint
/// ([`spread`]).
fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 {
let s = spread(symbols, fx, b);
[b.x0 - s.x0, b.y0 - s.y0, s.x1 - b.x1, s.y1 - b.y1].into_iter().fold(0.0, f64::max)
}
/// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in
/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are
/// measured by their art from `symbols`.
@@ -209,7 +412,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}?;
let o = effects::outset(&n.appearance.effects, b);
let o = outset(symbols, &n.appearance.effects, b);
Some(b.inflate(o, o))
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
@@ -239,7 +442,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
};
let a = a.max(live_content_reach(symbols, n));
let b = b.inflate(a, a);
let o = effects::outset(&n.appearance.effects, b);
let o = outset(symbols, &n.appearance.effects, b);
Some(b.inflate(o, o))
}
NodeKind::SymbolInstance { .. } => {
@@ -351,10 +554,43 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
const PIXEL_BUDGET: f64 = 8.4e6;
/// Most Rasterize effects of one stack that [`Renderer::raster_fx`] draws (the last ones): each
/// draws the content again inside the one after it.
const MAX_RASTERIZE: usize = 16;
/// The columns and rows of the image Rasterize `r` makes over `rect` (whole pixels of the image,
/// `line` points wide at the document's raster effects resolution). `None` when the image would
/// hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than [`crate::MAX_RASTER_SIDE`].
fn image_size(r: &effects::Rasterize, rect: Rect, line: f64) -> Option<(u16, u16)> {
let cell = r.cell(line);
let (cols, rows) = ((rect.width() / cell).round(), (rect.height() / cell).round());
let side = f64::from(crate::MAX_RASTER_SIDE);
(cols >= 1.0 && rows >= 1.0 && cols <= side && rows <= side && cols * rows <= PIXEL_BUDGET).then_some((cols as u16, rows as u16))
}
#[cfg(test)]
thread_local! {
/// How many times a Rasterize has drawn the content before it on this thread.
pub(crate) static RASTERIZED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
/// number).
fn doc_raster_ppi(doc: &Document) -> f64 {
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
}
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
/// content itself), in stack order.
fn content_changing(fx: &&RasterFx) -> bool {
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_))
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_) | RasterFx::Rasterize(_))
}
/// Is the content drawn with hard edges for `chain` into a raster of pixels `px` wide (document
/// units): does it go first through a Rasterize with anti-aliasing off whose pixels are no
/// smaller? (Smaller ones show as the share of each raster pixel they cover, as smooth edges do.)
fn hard_edged(chain: &[&RasterFx], px: f64) -> bool {
matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px)
}
/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when
@@ -363,6 +599,7 @@ fn looks_around(fx: &RasterFx) -> Option<f64> {
match fx {
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius),
RasterFx::Pixel(p) => p.reach(),
RasterFx::Rasterize(r) => Some(r.reach()),
_ => Some(0.0),
}
}
@@ -442,6 +679,23 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
ctx.set_transform(Affine::IDENTITY);
}
/// Draw `image` with its pixels placed by `xf` (pixels to device pixels). On whole pixels at full
/// size it is drawn as [`draw_scaled`] draws it.
fn draw_placed(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, xf: Affine) {
let [a, b, c, d, e, f] = xf.as_coeffs();
let near = |v: f64, to: f64| (v - to).abs() < 1e-6;
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
return draw_scaled(ctx, image, (e.round(), f.round()), 1.0);
}
let (w, h) = (image.width() as f64, image.height() as f64);
ctx.set_transform(xf);
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() };
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_transform(Affine::IDENTITY);
}
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
/// the view-space position of `anchor` (a document point).
pub(crate) struct ShadowEntry {
@@ -939,7 +1193,10 @@ impl Renderer {
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
let reach = g.bounding_box().inflate(outset, outset);
let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)) };
let bounds = rasterize_bounds(f.symbols, n, &rfx);
// Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint.
let reach = bounds.map_or(reach, |b| reach.union(b));
let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None };
let mut items = items;
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut()));
ctx.set_transform(Affine::IDENTITY);
@@ -973,12 +1230,16 @@ impl Renderer {
let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) };
match cull_bounds(f.symbols, &art) {
Some(reach) if !f.opts.outline && !rfx.is_empty() => {
// Feather and Inner Glow clip to the art's outline.
let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }));
// Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline.
let clipped = rfx.iter().any(|x| {
matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }) || matches!(x, RasterFx::Rasterize(r) if r.clipping_mask)
});
let region = if clipped { self.clip_of(&art) } else { None };
// A symbol's art can change while the instance doesn't: no cached shadows.
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
let bounds = rasterize_bounds(f.symbols, a, &rfx);
let reach = bounds.map_or(reach, |b| reach.union(b));
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
ctx.set_transform(Affine::IDENTITY);
}
@@ -1093,6 +1354,102 @@ impl Renderer {
rfx: &[RasterFx],
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
// Only the last MAX_RASTERIZE Rasterize effects are drawn.
let count = rfx.iter().filter(|fx| matches!(fx, RasterFx::Rasterize(_))).count();
let capped: Vec<RasterFx>;
let rfx = if count > MAX_RASTERIZE {
let mut left_out = count - MAX_RASTERIZE;
capped = rfx
.iter()
.filter(|fx| {
let out = left_out > 0 && matches!(fx, RasterFx::Rasterize(_));
left_out -= usize::from(out);
!out
})
.cloned()
.collect();
&capped[..]
} else {
rfx
};
// Rasterize at the document's raster effects resolution takes it here.
let resolved: Vec<RasterFx>;
let rfx = if rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(r) if r.ppi.is_none())) {
let ppi = doc_raster_ppi(f.doc);
resolved = rfx.iter().map(|fx| fx.at_document_ppi(ppi)).collect();
&resolved[..]
} else {
rfx
};
// Rasterize makes an image of everything the effects before it paint, shadows and glows
// included, and the shadows and glows after it paint around that image. When some come
// after the last Rasterize, the stack splits after it, else when any effect comes before
// it, the stack splits before it; the effects after the split paint around the content
// drawn with those before it. Rasterize then comes first in its part.
if let Some(at) = rfx.iter().rposition(|fx| matches!(fx, RasterFx::Rasterize(_))) {
let paints_around = |fx: &[RasterFx]| fx.iter().any(|fx| !content_changing(&fx));
let (_, rest) = rfx.split_at(at + 1);
let split = if paints_around(rest) { Some(at + 1) } else { (at > 0).then_some(at) };
if let Some(split) = split {
let (before, after) = rfx.split_at(split);
let line = 72.0 / doc_raster_ppi(f.doc);
let (mut reach, mut spread) = (content.reach, content.bounds);
// After a Rasterize on white without a clipping mask, the silhouette is its image,
// and it stays the silhouette through the effects after it (as in `pixel_content`).
let mut image = None;
// Whether the image of a Rasterize before the split is over `rasterize_image`'s
// limits. Such an image is kept only as far as the view needs it, so the effects
// after the split are not cached.
let (mut over, mut last_over) = (false, false);
for fx in before {
if let RasterFx::Rasterize(r) = fx {
last_over = image_size(r, r.image(spread.unwrap_or(reach), line), line).is_none();
over |= last_over;
}
let o = fx.outset(reach);
reach = reach.inflate(o, o);
spread = spread.map(|b| fx.spread(b, line));
if let RasterFx::Rasterize(r) = fx
&& r.white
&& !r.clipping_mask
{
image = Some(spread.unwrap_or(reach).to_path(0.1));
}
}
// When the split comes after the Rasterize, the shadows and glows after it and the
// content each draw the content before the split. The first draw makes the image,
// and the others draw the one it keeps.
let window = after.iter().fold(f.visible, |w, fx| {
let o = fx.outset(w);
w.inflate(o, o)
});
let frame = last_over.then_some((f.view, f.px));
let kept = (split > at).then(|| Kept { window, frame, image: std::cell::RefCell::new(None) });
let inner = Content {
node: content.node,
owner: content.owner,
cache: None,
reach: content.reach,
outline: content.outline,
bounds: content.bounds,
kept: kept.as_ref(),
};
let outline = image.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline);
// When the split comes before the Rasterize, the part from it on draws the content
// once, and its image is the one kept for a split above.
let outer = Content {
node: content.node,
owner: content.owner,
cache: content.cache.filter(|_| !over),
reach,
outline,
bounds: spread,
kept: content.kept.filter(|_| split == at),
};
self.raster_fx(ctx, f, &outer, after, &mut |r, c, fr| r.raster_fx(c, fr, &inner, before, paint));
return;
}
}
let reach = content.reach;
// The `Arc` drawn: the caller's, or the node `draw_arc` is drawing.
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
@@ -1134,7 +1491,7 @@ impl Renderer {
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
// and looked blocky as the view moved by parts of a pixel (#755).
let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0));
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_) | RasterFx::Rasterize(_))) {
self.pixel_content(ctx, f, content, rfx, paint);
} else {
paint(self, ctx, f);
@@ -1176,11 +1533,15 @@ impl Renderer {
}
}
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
/// larger content is cropped to the view when every effect only looks nearby, else rendered
/// at a lower resolution. A whole raster at full resolution is cached while the object, the
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the
/// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over
/// all it can reach, filtered on the CPU and drawn back. A Rasterize first in the chain draws the
/// content on its image's own grid instead ([`Self::rasterize_image`]) when that image is within
/// the limits below; content with a [`Kept`] image draws that image after its first draw. The
/// raster holds at most [`PIXEL_BUDGET`] pixels and [`crate::MAX_RASTER_SIDE`] a side: content
/// over the budget is cropped to the view (content with a [`Kept`] image to its window) when
/// every effect only looks nearby, and content still over either limit is rendered at a lower
/// resolution. A whole raster at full resolution is cached while the object, the
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
fn pixel_content(
@@ -1192,26 +1553,42 @@ impl Renderer {
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
// When the first draw of this content kept its raster, that raster is drawn.
if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) {
draw_placed(ctx, image, f.view * place);
return;
}
// Content kept with an image over the limits is drawn in the split's frame, over where the
// effects after the split take it from, then placed in this one.
let call = f.view;
let split_frame;
let (f, screen) = match content.kept.and_then(|k| k.frame.map(|frame| (k.window, frame))) {
Some((window, (view, px))) => {
split_frame = Frame { view, px, ..*f };
(&split_frame, view.transform_rect_bbox(window))
}
None => (f, Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64)),
};
// Where the result can paint: each effect spreads what the ones before it painted.
let reach = chain.iter().fold(content.reach, |r, fx| {
let o = fx.outset(r);
r.inflate(o, o)
});
let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0);
let (sw, sh) = (ctx.width() as f64, ctx.height() as f64);
if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) {
if !(full.area().is_finite() && full.x1 > screen.x0 && full.y1 > screen.y0 && full.x0 < screen.x1 && full.y0 < screen.y1) {
return;
}
let fits = full.area() <= PIXEL_BUDGET;
// Device pixels from a pixel to the content deciding it (`None`: anywhere).
let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px));
let region = match margin {
Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)),
Some(m) if !fits => full.intersect(screen.inflate(m, m)),
_ => full,
};
let (x0, y0) = (region.x0.floor(), region.y0.floor());
let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0));
let side = u16::MAX as f64;
// (vello_cpu panics drawing near `u16::MAX` pixels a side.)
let side = f64::from(crate::MAX_RASTER_SIDE);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
// The cached raster, when the whole content was rendered at full size at this zoom.
@@ -1225,12 +1602,12 @@ impl Renderer {
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let ppi = doc_raster_ppi(f.doc);
// What the content reads (as the raster kept for it found it, else found now), with the
// channels the effects run on and, under De-Interlace, the resolution.
// channels the effects run on and, under De-Interlace or Rasterize, the resolution.
let inputs = key.map(|key| {
let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads);
let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. })));
let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_)));
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
});
let interacting = f.opts.interacting;
@@ -1256,15 +1633,45 @@ impl Renderer {
let to_doc = view.inverse();
let px = f.px / k;
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
let mut off = f.offscreen_context(wu, hu);
let line = 72.0 / ppi;
let (before, decoded) = (self.stats.moved, crate::paint::decode_failures());
self.inside_layer(|r| paint(r, &mut off, &frame));
// A Rasterize first in the chain draws the content on its image's own grid instead, unless
// that image is too large: the content is drawn once.
let mut rasterized = match chain.first() {
Some(RasterFx::Rasterize(r)) => {
let rect = r.image(content.bounds.unwrap_or(content.reach), line);
let kept = content.kept.and_then(|k| match &*k.image.borrow() {
Some(KeptImage::Grid(image)) => Some(image.clone()),
_ => None,
});
let image = kept.or_else(|| {
let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new);
if let (Some(k), Some(image)) = (content.kept, &image) {
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone()));
}
image
});
image.map(|image| (image, rect))
}
_ => None,
};
let mut pm = vello_cpu::Pixmap::new(wu, hu);
if rasterized.is_none() {
#[cfg(test)]
if matches!(chain.first(), Some(RasterFx::Rasterize(_))) {
RASTERIZED.with(|n| n.set(n.get() + 1));
}
let mut off = f.offscreen_context(wu, hu);
if hard_edged(&chain, px) {
off.set_aliasing_threshold(crate::AntiAlias::None.threshold());
}
self.inside_layer(|r| paint(r, &mut off, &frame));
off.flush();
off.render(&mut pm, &mut self.resources);
}
let failed = crate::paint::decode_failures() - decoded;
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
off.flush();
let mut pm = vello_cpu::Pixmap::new(wu, hu);
off.render(&mut pm, &mut self.resources);
// What the raster is made from, for its object once it moves (`move_pixel_content`).
let made = key.map(|_| Made {
pixels: pm.data_as_u8_slice().to_vec(),
@@ -1274,18 +1681,41 @@ impl Renderer {
outline: feather_outline(&chain, content, content.reach.origin()),
});
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
let space = effects::PixelSpace { to_doc, px, center, channels, line, extent };
let (wz, hz) = (wu as usize, hu as usize);
for fx in chain {
// What the content covers after the effects run so far: Rasterize makes its image over it.
let mut bounds = content.bounds.unwrap_or(content.reach);
// The silhouette Feather and Create Clipping Mask clip to: the content's, or after a Rasterize
// on white without a clipping mask, its image.
let mut image_outline: Option<BezPath> = None;
for (i, fx) in chain.into_iter().enumerate() {
let before = bounds;
bounds = fx.spread(before, space.line);
let outline = image_outline.as_ref().map(|g| (g, FillRule::NonZero)).or(content.outline);
match fx {
RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px),
RasterFx::Feather { radius } => {
effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px);
if let Some((g, rule)) = content.outline {
if let Some((g, rule)) = outline {
self.clip_pixels(&mut pm, &frame, g, rule);
}
}
RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space),
RasterFx::Rasterize(r) => {
match rasterized.take().filter(|_| i == 0) {
Some((image, rect)) => {
let (pixels, cols, rows) = &*image;
r.compose(pm.data_as_u8_slice_mut(), wz, hz, &space, pixels, *cols, *rows, rect)
}
None => r.apply(pm.data_as_u8_slice_mut(), wz, hz, &space, before),
}
if let Some((g, rule)) = outline.filter(|_| r.clipping_mask) {
self.clip_pixels(&mut pm, &frame, g, rule);
}
if r.white && !r.clipping_mask {
image_outline = Some(bounds.to_path(0.1));
}
}
_ => {}
}
}
@@ -1302,7 +1732,15 @@ impl Renderer {
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
draw_scaled(ctx, image, (x0, y0), k);
// An image over the limits was drawn at a frame's resolution: its raster is kept.
match content.kept.filter(|kept| kept.image.borrow().is_none()) {
Some(kept) => {
let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k);
*kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place));
if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) }
}
None => draw_scaled(ctx, image, (x0, y0), k),
}
}
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
@@ -1312,7 +1750,7 @@ impl Renderer {
/// (and the same Feather outline against its anchor), and whose pixels are the pixels of
/// `content` drawn into a raster placed as that one was against its anchor. The raster is kept
/// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under
/// De-Interlace takes none.
/// De-Interlace, or under a Rasterize first in the chain, takes none.
#[allow(clippy::too_many_arguments)]
fn move_pixel_content(
&mut self,
@@ -1324,7 +1762,8 @@ impl Renderer {
inputs: &Inputs,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) {
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) || matches!(chain.first(), Some(RasterFx::Rasterize(_)))
{
return;
}
let (linear, stamp, anchor) = (linear_key(f.view), self.stamp, content.reach.origin());
@@ -1348,6 +1787,9 @@ impl Renderer {
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
let frame = Frame { mt: false, view, visible, ..*f };
let mut off = f.offscreen_context(w, h);
if hard_edged(chain, f.px) {
off.set_aliasing_threshold(crate::AntiAlias::None.threshold());
}
self.inside_layer(|r| paint(r, &mut off, &frame));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
@@ -1363,17 +1805,19 @@ impl Renderer {
}
/// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the
/// color settings, the edge smoothing, the knockout `n` inherits and, while `n` holds the
/// isolated group, isolation mode; the pattern definitions, symbols and brush library it
/// reads; for images and placed documents their pixels and files, the layer's Dim Images and
/// the options they are drawn with; for type the fonts, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or
/// pattern art and an object in the document's symbol or pattern art has one.
/// color settings, the edge smoothing, the document's raster effects resolution (which a
/// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and,
/// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and
/// brush library it reads; for images and placed documents their pixels and files, the
/// layer's Dim Images and the options they are drawn with; for type the fonts, whether
/// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution
/// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and
/// when it shows symbol or pattern art and an object in the document's symbol or pattern art
/// has one.
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
let doc = self.doc_inputs(f.doc);
let mut h = DefaultHasher::new();
(doc.cms, f.opts.anti_alias.threshold(), self.knockout).hash(&mut h);
(doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h);
if let Some((isolated, path)) = self.isolation_path(f)
&& n.id != isolated
&& path.contains(&n.id)
@@ -1466,6 +1910,42 @@ impl Renderer {
}
}
/// The image Effect › Rasterize `r` makes of the content `paint` draws over `rect` (whole
/// pixels of the image): drawn on the image's own grid as Object › Rasterize draws it (with
/// hard edges when `r` doesn't anti-alias), then given `r`'s background and color model. `None`
/// when it would hold more than [`PIXEL_BUDGET`] pixels or be wider or taller than
/// [`crate::MAX_RASTER_SIDE`]. → (pixels, columns, rows)
fn rasterize_image(
&mut self,
f: &Frame,
r: &effects::Rasterize,
rect: Rect,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> Option<(Vec<u8>, usize, usize)> {
let line = 72.0 / doc_raster_ppi(f.doc);
let (w, h) = image_size(r, rect, line)?;
#[cfg(test)]
RASTERIZED.with(|n| n.set(n.get() + 1));
let cell = r.cell(line);
let view = Affine::scale(1.0 / cell) * Affine::translate(-rect.origin().to_vec2());
// The effect's own anti-aliasing, whatever the view or the export draws with.
let anti_alias = match (r.anti_alias, r.hinted) {
(false, _) => crate::AntiAlias::None,
(true, true) => crate::AntiAlias::Type,
(true, false) => crate::AntiAlias::Art,
};
let opts = crate::RenderOptions { anti_alias, ..f.opts.clone() };
let frame = Frame { mt: false, view, visible: rect, px: cell, opts: &opts, ..*f };
let mut off = frame.offscreen_context(w, h);
self.inside_layer(|s| paint(s, &mut off, &frame));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
let mut pixels = pm.data_as_u8_slice().to_vec();
r.finish(&mut pixels);
Some((pixels, usize::from(w), usize::from(h)))
}
/// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip).
fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) {
let mut m = frame.offscreen_context(pm.width(), pm.height());
@@ -1564,8 +2044,17 @@ impl Renderer {
let outset = n.appearance.outset();
let orule = if item.is_fill() { rule } else { FillRule::NonZero };
// Each item's shadows get their own cache slots.
let content =
Content { node: n, owner: None, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) };
// Rasterize makes its image over the item's silhouette.
let bounds = rfx.iter().any(|fx| matches!(fx, RasterFx::Rasterize(_))).then(|| outline.bounding_box());
let content = Content {
node: n,
owner: None,
cache: Some((j + 1) << 8),
reach: ib.inflate(outset, outset),
outline: Some((&outline, orule)),
bounds,
kept: None,
};
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib));
ctx.set_transform(Affine::IDENTITY);
}
+15
View File
@@ -64,6 +64,19 @@ pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
}
/// Bounds of what `n` draws with its own and its members' geometry effects applied and with the
/// reach of its own and its members' raster effects, measured as [`Node::visual_bounds_with`]
/// measures art without effects: what Object › Rasterize makes its image over.
pub fn drawn_bounds(doc: &Document, n: &Node) -> Option<Rect> {
fx::painted_reach(&doc.symbol_boxes(), n)
}
/// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with
/// Create Clipping Mask to. Object › Rasterize's clipping mask follows it.
pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
fx::drawn_outline(doc, n)
}
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
pub const MAX_RASTER_SIDE: u32 = 32_768;
@@ -2428,6 +2441,8 @@ mod tests_objectfx;
#[cfg(test)]
mod tests_placed_document;
#[cfg(test)]
mod tests_rasterize;
#[cfg(test)]
mod tests_setup;
#[cfg(test)]
mod tests_strokegradient;
+423
View File
@@ -0,0 +1,423 @@
//! Effect › Rasterize on the canvas: the art drawn as square pixels of the effect's resolution
//! over its bounds and the room around them, on white or on nothing, its paths left as they are.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Effect, Node};
use vectorcraft_geom::{Anchor, PathData, Point, SubPath, shapes};
use super::*;
/// A red shape (`path`, over 10..30 pt) with the effects `fx` (id, params), in a document whose
/// raster effects resolution is `ppi`.
fn doc(path: PathData, fx: &[(&str, Value)], ppi: f64) -> Document {
let mut d = Document::new(100.0, 100.0);
d.raster_effects_ppi = ppi;
let l = d.layers[0].id;
let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
n.appearance.effects = fx.iter().map(|(id, params)| Effect { id: (*id).into(), params: params.clone(), visible: true }).collect();
d.insert(Some(l), 0, n).unwrap();
d
}
/// A right triangle over 10..30 pt whose long side runs diagonally.
fn triangle() -> PathData {
let corner = |x, y| Anchor::corner(Point::new(x, y));
PathData::single(SubPath::new(vec![corner(10.0, 10.0), corner(30.0, 10.0), corner(10.0, 30.0)], true))
}
/// `d` drawn at `zoom` device pixels a point on a transparent canvas `size` pixels square.
fn draw(d: &Document, zoom: f64, size: u32) -> Rendered {
Renderer::new().render(d, size, size, Affine::scale(zoom), &RenderOptions { background: None, artboards: false, ..Default::default() })
}
fn pixel(r: &Rendered, x: usize, y: usize) -> [u8; 4] {
let i = (y * r.width as usize + x) * 4;
[r.pixels[i], r.pixels[i + 1], r.pixels[i + 2], r.pixels[i + 3]]
}
/// Whether each of the `n` × `n` squares `side` device pixels wide from (`at`, `at`) is one color.
fn squares_are_flat(r: &Rendered, at: usize, side: usize, n: usize) -> bool {
(0..n * n).all(|k| {
let (x0, y0) = (at + (k % n) * side, at + (k / n) * side);
let first = pixel(r, x0, y0);
(0..side * side).all(|i| pixel(r, x0 + i % side, y0 + i / side) == first)
})
}
#[test]
fn rasterize_draws_the_art_in_pixels_of_its_resolution() {
// (params, the document's resolution, device pixels a point, device pixels an image pixel)
let cases = [
(json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 10.0, 10),
(json!({"useDocumentResolution": false, "resolution": 72}), 300.0, 2.0, 2),
(json!({"useDocumentResolution": false, "resolution": 300}), 72.0, 25.0, 6),
(json!({}), 150.0, 25.0, 12),
];
for (params, ppi, zoom, side) in cases {
let at = (10.0 * zoom) as usize;
// The whole image pixels inside the 20 pt triangle's bounds.
let n = (20.0 * zoom) as usize / side;
let canvas = (40.0 * zoom) as u32;
let rasterized = draw(&doc(triangle(), &[("rasterize", params.clone())], ppi), zoom, canvas);
assert!(squares_are_flat(&rasterized, at, side, n), "{params}");
// Along the diagonal an image pixel takes the mean of the art it covers.
let partly = (0..n).map(|k| pixel(&rasterized, at + k * side, at + (n - 1 - k) * side)[3]).filter(|a| *a > 0 && *a < 255).count();
assert!(partly > n / 2, "{params}: {partly} of {n}");
// Without the effect the diagonal edge runs through those squares.
assert!(!squares_are_flat(&draw(&doc(triangle(), &[], ppi), zoom, canvas), at, side, n));
}
}
#[test]
fn rasterize_is_drawn_where_only_its_image_reaches_the_view() {
// At 1 ppi a pixel of the image is 72 pt wide: the 20 pt square's image runs to 82 pt.
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 1.0);
// A view of 40..80 pt across, where only the image's white reaches.
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-40.0, 0.0)), &opts);
assert_eq!(pixel(&r, 20, 20)[3], 255);
// A blur of 4 pt (6 pt of spread) and 10 pt of white around it: the white reaches 46 pt.
let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"background": "white", "addAround": 10}))];
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0);
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-45.0, 0.0)), &opts);
assert_eq!(pixel(&r, 0, 20), [255; 4]);
// A fill's own Rasterize with 100 pt of white around: the white reaches 130 pt.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
n.appearance.items[0].effects_mut().push(Effect {
id: "rasterize".into(),
params: json!({"background": "white", "addAround": 100}),
visible: true,
});
let r = Renderer::new().render(&d, 40, 40, Affine::translate((-115.0, 0.0)), &opts);
assert_eq!(pixel(&r, 5, 20), [255; 4]);
}
#[test]
fn art_that_paints_nothing_takes_no_room_in_the_image() {
// A second fill, hidden, under a Gaussian Blur that would spread 15 pt.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
let mut hidden = n.appearance.items[0].clone();
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden {
f.visible = false;
f.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 10}), visible: true });
}
n.appearance.items.push(hidden.clone());
// A third, hidden too, offset 5 pt outward.
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut hidden {
f.effects = vec![Effect { id: "path.offsetPath".into(), params: json!({"offset": 5}), visible: true }];
}
n.appearance.items.push(hidden);
let r = draw(&d, 10.0, 400);
assert_eq!((pixel(&r, 105, 105), pixel(&r, 95, 200)[3]), ([255, 0, 0, 255], 0));
// Nor does a hidden member of a group.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
let mut away = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), Appearance::default());
away.visible = false;
let l = d.layers[0].id;
let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
members.push(Arc::new(away));
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true });
d.insert(Some(l), 0, group).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0);
// Nor does a Shaper group's source art, its first member, which paints nothing.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[], 72.0);
let none = Appearance::basic(Paint::None, Paint::None, 0.0);
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(34.0, 10.0, 38.0, 30.0)), none);
let l = d.layers[0].id;
let mut members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
members.insert(0, Arc::new(source));
let mut group = Node::group(d.alloc_id(), members);
group.extras_mut().shaper = Some(Box::new(vectorcraft_doc::shaper::ShaperSpec { faces: vec![], edges: vec![] }));
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white"}), visible: true });
d.insert(Some(l), 0, group).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 350, 200)[3], 0);
// Nor does the path where its only fill's own Transform effect moves the fill away from it.
let mut d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
if let vectorcraft_doc::AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
f.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 40}), visible: true });
}
let r = draw(&d, 10.0, 800);
assert_eq!((pixel(&r, 600, 200), pixel(&r, 200, 200)[3]), ([255, 0, 0, 255], 0));
}
#[test]
fn rasterize_covers_the_share_of_a_screen_pixel_its_image_covers() {
// A 1 pt square from x = 10.25 at 72 ppi and 100%: its image pixel covers a quarter of
// screen column 11, on white and on nothing.
let square = || shapes::rectangle(Rect::new(10.25, 10.0, 11.25, 11.0));
for (params, want) in [(json!({}), [64, 0, 0, 64]), (json!({"background": "white"}), [64, 0, 0, 64])] {
let r = draw(&doc(square(), &[("rasterize", params.clone())], 72.0), 1.0, 20);
assert_eq!(pixel(&r, 11, 10), want, "{params}");
assert_eq!(pixel(&r, 12, 10)[3], 0, "{params}");
}
}
#[test]
fn objects_wider_than_a_raster_can_be_are_drawn_with_their_pixel_effects() {
// 65,530 pt across at 100%: wider than the rasters the effects are made in can safely be.
let bar = || shapes::rectangle(Rect::new(0.0, 10.0, 65_530.0, 11.0));
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
for fx in [("rasterize", json!({"background": "white"})), ("blur.gaussian", json!({"radius": 1}))] {
let r = Renderer::new().render(&doc(bar(), std::slice::from_ref(&fx), 72.0), 40, 20, Affine::translate((-65_500.0, 0.0)), &opts);
assert!(pixel(&r, 10, 10)[3] > 0, "{}", fx.0);
}
}
#[test]
fn a_stack_of_ten_thousand_rasterize_effects_is_drawn() {
let fx = vec![("rasterize", json!({"background": "white"})); 10_000];
let r = draw(&doc(shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), &fx, 72.0), 1.0, 40);
assert_eq!(pixel(&r, 20, 20), [255, 0, 0, 255]);
}
#[test]
fn a_stack_of_rasterize_effects_with_shadows_draws_the_content_of_each_once() {
let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"}));
let glow = ("stylize.outerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#00ff00"}));
// Five Rasterize effects, each followed by a drop shadow and an outer glow: on a 20 pt square
// at 72 ppi, and on a 300 pt square at 2,400 ppi, whose images are too large for the canvas to
// draw on their own grid.
for (ppi, side, zoom) in [(72.0, 20.0, 1.0), (2400.0, 300.0, 0.1)] {
let rasterize = ("rasterize", json!({"useDocumentResolution": false, "resolution": ppi}));
let fx: Vec<_> = (0..5).flat_map(|_| [rasterize.clone(), shadow.clone(), glow.clone()]).collect();
let d = doc(shapes::rectangle(Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side)), &fx, 72.0);
let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get);
let before = drawn();
let r = draw(&d, zoom, 40);
assert_eq!(drawn() - before, 5, "{ppi} ppi");
assert_eq!(pixel(&r, 15, 15), [255, 0, 0, 255], "{ppi} ppi");
}
}
#[test]
fn a_new_document_resolution_redraws_a_rasterized_member_of_a_blurred_group() {
let mut d = doc(triangle(), &[("rasterize", json!({}))], 72.0);
let l = d.layers[0].id;
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 0.5}), visible: true });
d.insert(Some(l), 0, group).unwrap();
let opts = RenderOptions { background: None, artboards: false, ..Default::default() };
let mut r = Renderer::new();
r.render(&d, 400, 400, Affine::scale(10.0), &opts);
d.raster_effects_ppi = 18.0;
let again = r.render(&d, 400, 400, Affine::scale(10.0), &opts);
assert_eq!(again.pixels, Renderer::new().render(&d, 400, 400, Affine::scale(10.0), &opts).pixels);
}
#[test]
fn an_export_draws_the_image_with_the_effects_own_anti_aliasing() {
// A supersampled Art Optimized export draws its art with hard edges and then averages them;
// the effect's image keeps its smooth diagonal.
let d = doc(triangle(), &[("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))], 72.0);
let opts = RenderOptions { background: None, artboards: false, anti_alias: AntiAlias::Art, supersample: true, ..Default::default() };
let r = Renderer::new().render_region_with(&d, Rect::new(0.0, 0.0, 40.0, 40.0), 10.0, &opts);
let partly = (0..20).map(|k| pixel(&r, 105 + k * 10, 105 + (19 - k) * 10)[3]).filter(|a| *a > 0 && *a < 255).count();
assert!(partly > 10, "{partly} of 20");
}
#[test]
fn rasterize_without_anti_aliasing_has_hard_edges_at_any_zoom() {
let fx = [("rasterize", json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}))];
for zoom in [10.0, 1.0] {
let r = draw(&doc(triangle(), &fx, 72.0), zoom, (40.0 * zoom) as u32);
let alphas: Vec<u8> = r.pixels.chunks(4).map(|p| p[3]).collect();
assert!(alphas.iter().all(|a| *a == 0 || *a == 255), "zoom {zoom}");
assert!(alphas.contains(&255));
}
// A bar 0.6 pt wide covers the center of the image pixel it starts, which it then fills, and
// which at 50% covers half a screen pixel: it shows there, partly covering it.
let bar = || shapes::rectangle(Rect::new(10.3, 10.0, 10.9, 30.0));
assert_eq!(pixel(&draw(&doc(bar(), &fx, 72.0), 10.0, 400), 105, 200)[3], 255);
let half = pixel(&draw(&doc(bar(), &fx, 72.0), 0.5, 20), 5, 10)[3];
assert!(half > 0 && half < 255, "{half}");
// At 100% that image pixel, 10.3..11.3 pt, covers 70% of screen column 10 and 30% of column 11.
let full = draw(&doc(bar(), &fx, 72.0), 1.0, 40);
assert_eq!((pixel(&full, 10, 20)[3], pixel(&full, 11, 20)[3]), (178, 77));
}
#[test]
fn rasterize_keeps_what_an_earlier_effect_spreads_beyond_the_bounds() {
let fx = [("blur.gaussian", json!({"radius": 4})), ("rasterize", json!({"useDocumentResolution": false, "resolution": 72}))];
let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400);
// 1.5 pt left of the triangle's bounds, the blur still shows.
assert!(pixel(&r, 85, 150)[3] > 0);
// So does a blur of the fill's own.
let mut d = doc(triangle(), &fx[1..], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
n.appearance.items[0].effects_mut().push(Effect { id: "blur.gaussian".into(), params: json!({"radius": 4}), visible: true });
assert!(pixel(&draw(&d, 10.0, 400), 85, 150)[3] > 0);
// A fill's own Add Around that stays inside a 40 pt stroke takes no room past the stroke.
let mut d = doc(shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), &[("rasterize", json!({"background": "white"}))], 72.0);
let l = d.layers[0].id;
let n = Arc::make_mut(&mut d.children_mut(Some(l)).unwrap()[0]);
n.appearance.items[0].effects_mut().push(Effect { id: "rasterize".into(), params: json!({"addAround": 10}), visible: true });
n.appearance.items[1] = vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 40.0));
let r = draw(&d, 2.0, 200);
assert_eq!((pixel(&r, 156, 100)[3], pixel(&r, 170, 100)[3]), (255, 0));
}
#[test]
fn rasterize_and_shadows_paint_in_stack_order() {
let square = || shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0));
let shadow = ("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 5, "y": 5, "blur": 0, "color": "#0000ff"}));
let gray = ("rasterize", json!({"colorModel": "grayscale"}));
// Below the square's corner, only the shadow paints.
let first = draw(&doc(square(), &[shadow.clone(), gray.clone()], 72.0), 10.0, 400);
let [r, g, b, a] = pixel(&first, 330, 330);
assert!(a == 255 && r == g && g == b, "the shadow is rasterized in grayscale: {:?}", [r, g, b, a]);
let after = draw(&doc(square(), &[gray.clone(), shadow.clone()], 72.0), 10.0, 400);
assert_eq!(pixel(&after, 330, 330), [0, 0, 255, 255], "a shadow after Rasterize keeps its color");
// A shadow after Rasterize is the image's: a circle's square image on white casts a square shadow.
let circle = shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0));
let cast = draw(&doc(circle, &[("rasterize", json!({"background": "white"})), shadow.clone()], 72.0), 10.0, 400);
assert_eq!(pixel(&cast, 320, 160), [0, 0, 255, 255]);
// Between two Rasterize effects, the second one takes it in.
let between = draw(&doc(square(), &[("rasterize", json!({})), shadow.clone(), gray], 72.0), 10.0, 400);
let [r, g, b, _] = pixel(&between, 330, 330);
assert!(r == g && g == b && b > 0, "{:?}", [r, g, b]);
// A member's shadow is part of a group's art.
let mut d = doc(square(), std::slice::from_ref(&shadow), 72.0);
let l = d.layers[0].id;
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
d.insert(Some(l), 0, group).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]);
// So are a member's Crop Marks: the top right mark runs from x 39 to 57 at y 10.
let mut d = doc(square(), &[("cropMarks", json!({}))], 72.0);
let l = d.layers[0].id;
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
let mut group = Node::group(d.alloc_id(), members);
group.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
d.insert(Some(l), 0, group).unwrap();
let r = draw(&d, 10.0, 400);
assert!((95..105).any(|y| pixel(&r, 395, y)[3] > 0));
// So is the shadow in a symbol's art, for its instance.
let mut d = Document::new(100.0, 100.0);
let mut art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
art.appearance.effects.push(Effect { id: shadow.0.into(), params: shadow.1.clone(), visible: true });
d.symbols.push(vectorcraft_doc::Symbol { name: "Shadowed".into(), art: Arc::new(art) });
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shadowed".into(), xf: Affine::translate((20.0, 20.0)) });
instance.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, instance).unwrap();
assert_eq!(pixel(&draw(&d, 10.0, 400), 330, 330), [0, 0, 255, 255]);
}
#[test]
fn rasterize_takes_in_the_art_of_a_3d_effect() {
let square = || shapes::rectangle(Rect::new(30.0, 30.0, 50.0, 50.0));
let extrude = ("threeD.extrude", json!({}));
let solid = draw(&doc(square(), std::slice::from_ref(&extrude), 72.0), 10.0, 800);
// Pixels the extruded art covers outside the square.
let outside: Vec<(usize, usize)> = (0..800 * 800)
.map(|i| (i % 800, i / 800))
.filter(|(x, y)| !((300..500).contains(x) && (300..500).contains(y)) && pixel(&solid, *x, *y)[3] == 255)
.collect();
assert!(!outside.is_empty());
let rasterized = draw(&doc(square(), &[extrude, ("rasterize", json!({}))], 72.0), 10.0, 800);
assert!(outside.iter().all(|(x, y)| pixel(&rasterized, *x, *y)[3] > 0));
}
#[test]
fn rasterize_takes_in_brush_art_wider_than_the_stroke() {
// A 1 pt line under the 10 pt. Oval brush, which paints about 2 pt from its center line.
let mut d = Document::new(200.0, 100.0);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("10 pt. Oval".into());
let mut n = Node::path(d.alloc_id(), shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap);
n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() });
assert!(pixel(&r, 100, 52)[3] > 128, "{:?}", pixel(&r, 100, 52));
}
#[test]
fn rasterize_takes_in_type_that_its_own_fills_effects_move() {
// "IIII" is set at 40 pt from x 5 pt with a red fill of its own under the characters, and a
// Transform effect on that fill moves it 100 pt to the right.
let typed = |fx: &[Effect]| {
let mut d = Document::new(200.0, 100.0);
let style = vectorcraft_doc::text::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let text = vectorcraft_doc::text::TextObject::point(Point::new(5.0, 60.0), "IIII", style);
let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Text(Box::new(text)));
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 100}), visible: true });
n.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(fill));
n.appearance.set_contents_at(1);
n.appearance.effects = fx.to_vec();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &RenderOptions { background: None, artboards: false, ..Default::default() })
};
// The pixels right of x 100 that the moved fill paints.
let moved = |r: &Rendered| (0..200 * 100).filter(|i| i % 200 >= 100 && pixel(r, i % 200, i / 200)[0] > 128).count();
assert!(moved(&typed(&[])) > 50);
let rasterize = Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true };
assert!(moved(&typed(&[rasterize])) > 50);
}
#[test]
fn rasterize_fills_its_area_on_white_and_add_around_grows_it() {
let circle = || shapes::ellipse(Rect::new(10.0, 10.0, 30.0, 30.0));
let at = |params: Value| draw(&doc(circle(), &[("rasterize", params)], 72.0), 10.0, 400);
let white = at(json!({"background": "white"}));
// Inside the bounds, outside the circle: white; outside the bounds: nothing.
assert_eq!((pixel(&white, 105, 105), pixel(&white, 200, 200)), ([255; 4], [255, 0, 0, 255]));
assert_eq!(pixel(&white, 95, 200)[3], 0);
let clear = at(json!({"background": "transparent"}));
assert_eq!((pixel(&clear, 105, 105)[3], pixel(&clear, 200, 200)), (0, [255, 0, 0, 255]));
// Add Around Object: 2 pt more on every side.
let around = at(json!({"background": "white", "addAround": 2}));
assert_eq!((pixel(&around, 85, 200), pixel(&around, 75, 200)[3]), ([255; 4], 0));
// A second Rasterize makes its image over the first one's whole pixels, no larger.
let twice = draw(&doc(circle(), &[("rasterize", json!({})), ("rasterize", json!({"background": "white"}))], 72.0), 10.0, 400);
assert_eq!((pixel(&twice, 105, 105), pixel(&twice, 95, 200)[3], pixel(&twice, 305, 200)[3]), ([255; 4], 0, 0));
// A Feather or an Inner Glow after a Rasterize on white works on the image's square edge.
let white = ("rasterize", json!({"background": "white"}));
let feathered = draw(&doc(circle(), &[white.clone(), ("stylize.feather", json!({"radius": 1}))], 72.0), 10.0, 400);
assert!(pixel(&feathered, 106, 106)[3] > 0);
let glow = ("stylize.innerGlow", json!({"mode": "normal", "opacity": 100, "blur": 2, "color": "#ff0000", "source": "edge"}));
let glowing = draw(&doc(circle(), &[white.clone(), glow.clone()], 72.0), 10.0, 400);
assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102));
// With a Rasterize on nothing between them, the glow follows the same edge.
let glowing = draw(&doc(circle(), &[white, ("rasterize", json!({})), glow], 72.0), 10.0, 400);
assert!(pixel(&glowing, 102, 102)[1] < 200, "{:?}", pixel(&glowing, 102, 102));
// Create Clipping Mask: the white stays inside the circle.
let clipped = at(json!({"background": "white", "clippingMask": true}));
assert_eq!((pixel(&clipped, 105, 105)[3], pixel(&clipped, 200, 200)), (0, [255, 0, 0, 255]));
// Inside the outline the white shows where the art paints nothing: a circle stroked only.
let mut ring = doc(circle(), &[], 72.0);
let l = ring.layers[0].id;
let n = Arc::make_mut(&mut ring.children_mut(Some(l)).unwrap()[0]);
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into();
n.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({"background": "white", "clippingMask": true}), visible: true });
let ring = draw(&ring, 10.0, 400);
assert_eq!((pixel(&ring, 200, 200), pixel(&ring, 105, 105)[3]), ([255; 4], 0));
// A stroke 1 pt wide widens the image by half a point a side, as it widens the visual bounds.
let mut stroked = doc(circle(), &[], 72.0);
let n = Arc::make_mut(&mut stroked.children_mut(Some(l)).unwrap()[0]);
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0).into();
n.appearance.effects.push(Effect {
id: "rasterize".into(),
params: json!({"background": "white", "resolution": 720, "useDocumentResolution": false}),
visible: true,
});
let stroked = draw(&stroked, 10.0, 400);
assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0));
}