M3.43: effects render on type, placed images, symbol instances, blends, envelopes, meshes and repeats; a fill or stroke's own raster effects render and export as SVG filters on that element

Geometry effects reach objects that aren't paths through their outlines (vectorcraft_effects::
reshape: glyph outlines, evaluated live art, the symbol's art, an image clipped by its reshaped
frame), applied to the whole art as one piece; the renderer caches that art per object and
paints raster effects around it in the same filter layers paths use (shared Renderer::raster_fx,
also used per fill/stroke). Exports bake the reshaped art; SVG writes per-item filters; PDF
warns about raster effects on strokes as well as fills and objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 23:15:13 +07:00
co-authored by Claude Opus 5.5
parent 10ea4d5daf
commit 0f1565aed2
11 changed files with 700 additions and 81 deletions
+212 -65
View File
@@ -12,10 +12,16 @@
//! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward;
//! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped
//! to the shape and painted above it.
//!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
//! drawn offscreen in the filter layers, reshaped first by their geometry effects.
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
@@ -38,6 +44,30 @@ pub(crate) fn has_fx(n: &Node) -> bool {
})
}
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
/// symbol instances, live objects; see [`Renderer::draw_object_fx`])?
pub(crate) fn has_object_fx(n: &Node) -> bool {
effects::needs_outline(n) && visible(&n.appearance.effects)
}
/// What [`Renderer::raster_fx`] paints effects around.
struct Content<'a> {
/// The object the effects belong to (cached shadows are checked against it).
node: &'a Node,
/// First shadow-cache slot of these effects (`None`: don't cache).
cache: Option<usize>,
/// Document-space bounds of the painted content.
reach: Rect,
/// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and
/// Inner Glow is skipped).
outline: Option<(&'a BezPath, FillRule)>,
}
/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset).
fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> {
Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f }
}
fn node_bezpath(n: &Node) -> Option<BezPath> {
match &n.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
@@ -106,6 +136,12 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
}
_ if has_fx(n) => visual_bounds(n),
// The reshaped art (a symbol's art needs the document: its instance box stands in).
_ if has_object_fx(n) => {
let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?;
let o = effects::outset(&n.appearance.effects);
Some(b.inflate(o, o))
}
_ => n.visual_bounds(),
}
}
@@ -209,7 +245,7 @@ fn blur_alpha(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
}
impl Renderer {
/// Drop shadow / outer glow `fx` (index `i` in the object's raster effects) from a cached raster:
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
/// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when
/// the effect isn't a shadow/glow or the raster would be too large (caller falls back).
@@ -221,10 +257,8 @@ impl Renderer {
n: &Node,
i: usize,
fx: &RasterFx,
g: &BezPath,
rule: FillRule,
gctx: &GeomContext,
reach: Rect,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> bool {
let (mode, opacity, dx, dy, blur, color) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color),
@@ -244,7 +278,7 @@ impl Renderer {
}
None => {
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
let reach = reach + vectorcraft_geom::Vec2::new(dx, dy);
let reach = reach + Vec2::new(dx, dy);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
@@ -254,8 +288,8 @@ impl Renderer {
let (w, h) = (w as u16, h as u16);
// Silhouette (the object's painted alpha), offset by the shadow distance.
let mut off = crate::single_threaded_context(w, h);
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view * Affine::translate((dx, dy)), ..*f };
self.paint_items(&mut off, &shifted, n, g, rule, gctx);
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
paint(self, &mut off, &shifted(&crop, dx, dy));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
@@ -303,33 +337,118 @@ impl Renderer {
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
let reach = g.bounding_box().inflate(outset, outset);
// Below the object: shadows and outer glows.
let content = Content { node: n, cache: Some(0), reach, outline: Some((&g, rule)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, &g, rule, &gctx));
ctx.set_transform(Affine::IDENTITY);
}
/// Draw type, an image, a symbol instance or a live object with object-level effects
/// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry
/// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths,
/// the art standing for the object's silhouette.
pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
let layered = !f.opts.outline && (a.opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate);
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(a.blend)), Some(a.opacity), None, None);
}
let art = self.fx_art(f.doc, a, cache);
let rfx = effects::raster_effects(&a.appearance.effects);
match cull_bounds(&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 { .. }));
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, cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true));
ctx.set_transform(Affine::IDENTITY);
}
_ => self.draw_node(ctx, f, &art, true),
}
if layered {
ctx.pop_layer();
}
self.stats.drawn += 1;
}
/// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`):
/// reshaped by its geometry effects, else type and images as themselves and the others as
/// their evaluated art; without the object's transparency and effects.
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize;
if cache
&& let Some((node, art)) = self.fx_arts.get(&key)
&& Arc::ptr_eq(node, a)
{
return art.clone();
}
let symbol = match &a.kind {
NodeKind::SymbolInstance { symbol, .. } => {
doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a))
}
_ => None,
};
let art = match effects::reshape(a, symbol.as_ref()) {
Some(r) => r,
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(),
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
};
let art = Arc::new(Node {
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: false,
mask: None,
appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance },
..art
});
if cache {
if self.fx_arts.len() > 1024 {
self.fx_arts.clear();
}
self.fx_arts.insert(key, (a.clone(), art.clone()));
}
art
}
/// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows
/// below it, the content blurred or feathered, inner glows above it (clipped to its outline).
/// `paint` draws the content in the frame it is given.
fn raster_fx(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
content: &Content,
rfx: &[RasterFx],
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let reach = content.reach;
// Below the content: shadows and outer glows.
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
if self.draw_shadow_cached(ctx, f, n, i, fx, &g, rule, &gctx, reach) {
if let Some(slot) = content.cache
&& self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, paint)
{
continue;
}
// The offset is applied to the geometry rather than in the filter: vello_cpu drops
// layer content that lies entirely outside the viewport before filtering.
let (mode, opacity, dx, dy, blur, filter) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
(*mode, *opacity, *dx, *dy, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
}
RasterFx::OuterGlow { mode, opacity, blur, color } => {
(*mode, *opacity, 0.0, 0.0, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
}
let (mode, opacity, dx, dy, blur, color) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
_ => continue,
};
let mut gs = g.clone();
gs.apply_affine(Affine::translate((dx, dy)));
let reach = reach + vectorcraft_geom::Vec2::new(dx, dy);
let filter = shadow_filter(0.0, 0.0, blur, pcolor(&color));
// The offset moves the content rather than the filter: vello_cpu drops layer content
// that lies entirely outside the viewport before filtering.
let reach = reach + Vec2::new(dx, dy);
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
c.set_transform(fr.view);
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
r.paint_items(c, fr, n, &gs, rule, &gctx);
paint(r, c, &shifted(fr, dx, dy));
c.pop_layer();
});
}
// The object itself (blurred / feathered).
// The content itself (blurred / feathered).
let blur: f64 = rfx
.iter()
.map(|fx| match fx {
@@ -340,14 +459,17 @@ impl Renderer {
if blur > 0.0 {
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
let mut layers = 0;
for fx in &rfx {
for fx in rfx {
match fx {
RasterFx::Feather { radius } if *radius > 0.0 => {
c.set_transform(fr.view);
c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(&g);
if let Some((g, rule)) = content.outline {
c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(g);
layers += 1;
}
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 2;
layers += 1;
}
RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
c.set_transform(fr.view);
@@ -357,33 +479,34 @@ impl Renderer {
_ => {}
}
}
r.paint_items(c, fr, n, &g, rule, &gctx);
paint(r, c, fr);
for _ in 0..layers {
c.pop_layer();
}
});
} else {
self.paint_items(ctx, f, n, &g, rule, &gctx);
paint(self, ctx, f);
}
// Above the object: inner glows, clipped to the shape.
for fx in &rfx {
// Above the content: inner glows, clipped to its outline.
let Some((g, rule)) = content.outline else { return };
for fx in rfx {
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color));
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| {
// The blend layer goes outside the clip: a clip layer is isolated, so a blend
// inside it would mix with nothing instead of the object below.
// inside it would mix with nothing instead of the content below.
if let Some((m, o)) = comp {
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
}
c.set_transform(fr.view);
c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(&g);
c.push_clip_layer(g);
c.push_layer(None, None, None, None, Some(filter));
c.set_paint(peniko::Color::BLACK);
if *center {
c.set_fill_rule(fill_rule(rule));
c.fill_path(&g);
c.fill_path(g);
} else {
// Everything outside the shape, so the glow bleeds in from the edges.
let pad = blur * 2.0 + 4.0 * fr.px;
@@ -399,7 +522,6 @@ impl Renderer {
}
});
}
ctx.set_transform(Affine::IDENTITY);
}
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw`
@@ -448,41 +570,66 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY);
}
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects.
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and
/// painting per-item raster effects around that item alone.
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
let bounds = g.bounding_box();
for item in &n.appearance.items {
for (j, item) in n.appearance.items.iter().enumerate() {
if !item.visible() || item.paint().is_none() {
continue;
}
let ig = apply(item_effects(item), g, gctx);
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(&ig);
}
if layered {
ctx.pop_layer();
}
}
let rfx = effects::raster_effects(item_effects(item));
if rfx.is_empty() {
self.paint_item(ctx, f, n, item, &ig, rule, ib);
continue;
}
// The item's silhouette: its fill area, or the outline of its stroke.
let outline = match item {
AppearanceItem::Fill(_) => ig.clone(),
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
// Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush).
if st.brush.is_some() && self.draw_brush(ctx, f, n, &ig, st) {
continue;
}
self.draw_stroke(ctx, f, &ig, rule, st, ib);
let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(&ig));
effects::stroke::line_outline(&effects::stroke::stroke_pieces(&ig, st).line, st, w, f.px * 0.25)
}
};
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, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) };
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);
}
}
/// Paint one fill or stroke of `n` on its geometry `ig` (bounds `ib`).
#[allow(clippy::too_many_arguments)]
fn paint_item(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, item: &AppearanceItem, ig: &BezPath, rule: FillRule, ib: Rect) {
match item {
AppearanceItem::Fill(fl) => {
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(ig);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => {
if st.width <= 0.0 {
return;
}
// Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush).
if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) {
return;
}
self.draw_stroke(ctx, f, ig, rule, st, ib);
}
}
}
+11
View File
@@ -230,6 +230,8 @@ pub struct Renderer {
live: live::LiveCache,
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
shadows: PtrMap<(usize, usize), fx::ShadowEntry>,
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
fx_arts: PtrMap<usize, (Arc<Node>, Arc<Node>)>,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -277,6 +279,7 @@ impl Renderer {
brushes: Default::default(),
shadows: PtrMap::default(),
live: live::LiveCache::default(),
fx_arts: PtrMap::default(),
}
}
@@ -527,6 +530,9 @@ impl Renderer {
self.stats.drawn += 1;
return;
}
if fx::has_object_fx(a) {
return self.draw_object_fx(ctx, f, a, true);
}
if let NodeKind::Text(t) = &a.kind
&& a.opacity >= 1.0
&& a.blend == vectorcraft_color::BlendMode::Normal
@@ -558,6 +564,9 @@ impl Renderer {
} else if !n.is_container() {
return;
}
if fx::has_object_fx(n) {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
let outline = f.opts.outline;
let mut layers = 0;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
@@ -1012,3 +1021,5 @@ fn now() -> u64 {
mod tests;
#[cfg(test)]
mod tests_clip;
#[cfg(test)]
mod tests_objectfx;
+152
View File
@@ -0,0 +1,152 @@
//! Effects on every object kind: raster effects on type, images and symbol instances through an
//! offscreen layer of their art, geometry effects through their outlines, and one fill or
//! stroke's own raster effects.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_geom::{Point, shapes};
use super::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100×100 document with `n` (its id allocated) on the first layer.
fn doc_with(mut d: Document, mut n: Node) -> Document {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn render_threads(d: &Document, threads: u16) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn render(d: &Document) -> Rendered {
render_threads(d, 0)
}
fn lum(p: [u8; 4]) -> u32 {
p[0] as u32 + p[1] as u32 + p[2] as u32
}
const WHITE: u32 = 765;
/// A 30×30 red image placed at (20, 20).
fn image_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let mut n = Node::new(
NodeId(0),
NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }),
);
n.appearance.effects = effects;
doc_with(d, n)
}
/// An instance at (50, 50) of a symbol whose art is a black 20×20 square centred on its origin.
fn symbol_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) });
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) });
n.appearance.effects = effects;
doc_with(d, n)
}
/// Black 40 pt "IIII" with its baseline at (5, 60).
fn text_doc(effects: Vec<Effect>) -> Document {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
n.appearance.effects = effects;
doc_with(Document::new(100.0, 100.0), n)
}
/// Columns (x) holding any dark pixel.
fn inked_columns(img: &Rendered) -> Vec<u32> {
(0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect()
}
#[test]
fn a_shadow_on_an_image_paints_offset_pixels() {
let plain = render(&image_doc(vec![]));
assert_eq!(lum(plain.pixel(55, 55)), WHITE);
let d = image_doc(vec![fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))]);
let img = render(&d);
assert!(lum(img.pixel(55, 55)) < 100, "shadow below-right: {:?}", img.pixel(55, 55));
assert_eq!(img.pixel(35, 35), [255, 0, 0, 255], "the image stays on top");
assert_eq!(lum(img.pixel(15, 15)), WHITE);
// The multithreaded pipeline filters offscreen and matches.
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}
#[test]
fn blur_on_a_symbol_instance_softens_its_edges() {
let plain = render(&symbol_doc(vec![]));
assert_eq!(lum(plain.pixel(38, 50)), WHITE);
assert_eq!(lum(plain.pixel(50, 50)), 0);
let img = render(&symbol_doc(vec![fx("blur.gaussian", json!({"radius": 8}))]));
let edge = lum(img.pixel(38, 50));
assert!(edge > 0 && edge < WHITE, "the blur spreads past the edge: {edge}");
assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark");
}
#[test]
fn geometry_effects_reshape_type_through_its_outlines() {
let plain = inked_columns(&render(&text_doc(vec![])));
assert!(!plain.is_empty() && plain[0] < 30, "{plain:?}");
// Transform moves the outlines as one piece.
let moved = inked_columns(&render(&text_doc(vec![fx("distort.transform", json!({"moveH": 30}))])));
assert_eq!(moved.first().copied(), plain.first().map(|x| x + 30));
// Roughen redraws the glyphs as rough outlines.
let d = text_doc(vec![fx("distort.roughen", json!({"size": 4, "relative": false, "detail": 30, "points": "corner"}))]);
let rough = render(&d);
let smooth = render(&text_doc(vec![]));
let changed = rough.pixels.chunks(4).zip(smooth.pixels.chunks(4)).filter(|(a, b)| a != b).count();
assert!(changed > 100, "{changed} pixels changed");
// The reshaped art is plain paths painted with the text's fill.
let n = &d.layers[0].children().unwrap()[0];
let art = vectorcraft_effects::reshape(n, None).unwrap();
let (mut text, mut black) = (0, 0);
art.walk(&mut |m| {
text += usize::from(matches!(m.kind, NodeKind::Text(_)));
black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK));
});
assert!(text == 0 && black > 0, "{art:?}");
}
#[test]
fn a_strokes_own_glow_paints_around_that_stroke_only() {
let mut d = Document::new(100.0, 100.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow", json!({"color": "#00ff00", "mode": "normal", "opacity": 100, "blur": 6})));
d = doc_with(d, n);
let img = render(&d);
let g = img.pixel(27, 50);
assert!(g[1] > g[0] + 20 && g[1] > g[2] + 20, "green glow outside the stroke: {g:?}");
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the fill has no glow");
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}