mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
10ea4d5daf
commit
0f1565aed2
+212
-65
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user