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:
parent
10ea4d5daf
commit
0f1565aed2
|
|
@ -2,7 +2,8 @@
|
|||
//!
|
||||
//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a
|
||||
//! copy of the document in which every geometry effect — object level, per fill/stroke and
|
||||
//! Effect → Pathfinder — is evaluated exactly like the renderer does. Raster effects (shadows,
|
||||
//! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their
|
||||
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows,
|
||||
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -10,7 +11,9 @@ use std::sync::Arc;
|
|||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::{GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, pathfinder_children};
|
||||
use crate::{
|
||||
GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
match item {
|
||||
|
|
@ -67,6 +70,15 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
|||
if !needs_bake(n) {
|
||||
return None;
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines.
|
||||
if needs_outline(n) && has_geometry(&n.appearance.effects) {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
if let Some(result) = pathfinder_children(n, None) {
|
||||
let mut m = n.clone();
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
//! renderer, the exporters and Outline Stroke.
|
||||
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
|
||||
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
|
||||
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
|
||||
//! through their outlines.
|
||||
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
|
||||
//! PDF writers.
|
||||
//!
|
||||
|
|
@ -24,6 +26,7 @@ mod clip;
|
|||
mod distort;
|
||||
mod group;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
|
|
@ -40,6 +43,7 @@ pub use bake::{bake_document, needs_bake};
|
|||
pub use clip::clip_outline;
|
||||
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{needs_outline, outline_art, outline_text, reshape};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
|
|
@ -312,7 +316,7 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
|||
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
|
||||
}
|
||||
|
||||
fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
use util::*;
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
//! Object-level effects on objects that aren't paths: type, placed images, symbol instances and
|
||||
//! live objects (blends, envelopes, meshes, repeats).
|
||||
//!
|
||||
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
|
||||
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
|
||||
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
|
||||
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
|
||||
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
|
||||
/// neither a path nor a container)?
|
||||
pub fn needs_outline(n: &Node) -> bool {
|
||||
matches!(
|
||||
n.kind,
|
||||
NodeKind::Text(_)
|
||||
| NodeKind::Image(_)
|
||||
| NodeKind::SymbolInstance { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { .. }
|
||||
| NodeKind::Mesh(_)
|
||||
| NodeKind::Repeat(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
|
||||
/// then (when the object has its own fills/strokes) the whole outline with the object's stack,
|
||||
/// painted over the characters as the renderer does.
|
||||
pub fn outline_text(n: &Node) -> Option<Node> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
|
||||
let mut all = kurbo::BezPath::new();
|
||||
for g in &layout.glyphs {
|
||||
if let Some(r) = runs.get_mut(g.run) {
|
||||
r.extend(g.outline.iter());
|
||||
}
|
||||
all.extend(g.outline.iter());
|
||||
}
|
||||
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
|
||||
let mut children: Vec<Arc<Node>> = runs
|
||||
.iter()
|
||||
.zip(&t.runs)
|
||||
.filter(|(bp, _)| !bp.elements().is_empty())
|
||||
.map(|(bp, run)| {
|
||||
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] };
|
||||
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 {
|
||||
ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width)));
|
||||
}
|
||||
Arc::new(Node::path(n.id, path(bp), ap))
|
||||
})
|
||||
.collect();
|
||||
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
|
||||
let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] };
|
||||
children.push(Arc::new(Node::path(n.id, path(&all), ap)));
|
||||
}
|
||||
Some(Node::group(n.id, children))
|
||||
}
|
||||
|
||||
/// `n` as plain art that geometry effects can reshape, without its transparency and object
|
||||
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
|
||||
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
|
||||
/// the instance's transform. `None` for other kinds (images, paths, containers).
|
||||
pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Text(_) => outline_text(n)?,
|
||||
NodeKind::SymbolInstance { xf, .. } => {
|
||||
let mut a = symbol_art?.clone();
|
||||
a.transform(*xf, false);
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
|
||||
_ => return None,
|
||||
};
|
||||
outline_nested_text(&mut art);
|
||||
art.opacity = 1.0;
|
||||
art.blend = Default::default();
|
||||
art.isolate = false;
|
||||
art.knockout = false;
|
||||
art.mask = None;
|
||||
art.appearance.effects.clear();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Replace type anywhere inside `n` by its outlines.
|
||||
fn outline_nested_text(n: &mut Node) {
|
||||
let Some(ch) = n.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
if let Some(o) = outline_text(c) {
|
||||
*c = Arc::new(o);
|
||||
} else if c.children().is_some() {
|
||||
outline_nested_text(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
|
||||
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
|
||||
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
|
||||
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let fx: Vec<_> = n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
|
||||
if fx.is_empty() || !needs_outline(n) {
|
||||
return None;
|
||||
}
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
|
||||
let mut clip = Node::path(n.id, frame, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) };
|
||||
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
|
||||
}
|
||||
_ => outline_art(n, symbol_art)?,
|
||||
};
|
||||
for e in fx {
|
||||
// Every leaf takes the effect with the whole art's bounds as its reference box.
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
for_each_leaf(&mut art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
let w = leaf.appearance.stroke_width();
|
||||
let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &ctx);
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
out.opacity = n.opacity;
|
||||
out.blend = n.blend;
|
||||
out.isolate = n.isolate;
|
||||
out.knockout = n.knockout;
|
||||
out.mask = n.mask.clone();
|
||||
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Run `f` on every path and compound path under `n` (guides skipped).
|
||||
fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => f(n),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
for_each_leaf(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the geometry of path or compound `n` (a compound when `path` has several subpaths).
|
||||
fn set_geometry(n: &mut Node, path: PathData, rule: FillRule) {
|
||||
let clipping = matches!(n.kind, NodeKind::Path { clipping: true, .. });
|
||||
n.kind = if path.subpaths.len() > 1 {
|
||||
let id = n.id;
|
||||
let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))).collect();
|
||||
NodeKind::Compound { children, rule }
|
||||
} else {
|
||||
NodeKind::Path { path, rule, live: None, clipping, guide: false }
|
||||
};
|
||||
}
|
||||
|
|
@ -227,6 +227,13 @@ impl Exporter<'_> {
|
|||
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into()
|
||||
}
|
||||
|
||||
/// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect.
|
||||
fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect]) {
|
||||
if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
|
||||
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: impl Into<String>) {
|
||||
let w = w.into();
|
||||
if !self.warnings.contains(&w) {
|
||||
|
|
@ -360,9 +367,7 @@ impl Exporter<'_> {
|
|||
None if !n.is_container() => return,
|
||||
_ => {}
|
||||
}
|
||||
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
|
||||
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
|
||||
}
|
||||
self.warn_raster(&n.appearance.effects);
|
||||
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
|
||||
let mut pushes = 0;
|
||||
if n.blend != BlendMode::Normal {
|
||||
|
|
@ -449,9 +454,7 @@ impl Exporter<'_> {
|
|||
if !fl.visible || fl.paint.is_none() {
|
||||
continue;
|
||||
}
|
||||
if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
|
||||
self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
|
||||
}
|
||||
self.warn_raster(&fl.effects);
|
||||
// Pattern fills: the tile instances covering the shape, clipped to it.
|
||||
let doc = self.doc;
|
||||
if let Paint::Pattern { pattern, xf } = &fl.paint
|
||||
|
|
@ -486,6 +489,7 @@ impl Exporter<'_> {
|
|||
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
self.warn_raster(&st.effects);
|
||||
self.stroke(s, bp, &path, r, st, bounds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,4 +106,6 @@ pub enum PdfError {
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
//! Raster effects in PDF export: not written yet, so a fill's, a stroke's or the object's own
|
||||
//! raster effect is reported.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn warnings(item: Option<usize>) -> Vec<String> {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)),
|
||||
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
|
||||
);
|
||||
n.appearance.effects_mut(item).unwrap().push(Effect { id: "stylize.outerGlow".into(), params: Default::default(), visible: true });
|
||||
n.id = d.alloc_id();
|
||||
let layer = d.default_layer().unwrap();
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
export_with_report(&d, &PdfOptions::default()).unwrap().warnings
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_effects_warn_on_fills_strokes_and_objects() {
|
||||
for item in [None, Some(0), Some(1)] {
|
||||
assert!(warnings(item).iter().any(|w| w.starts_with("raster effects")), "{item:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -446,6 +446,7 @@ impl Writer<'_> {
|
|||
let strokes = items.len() - fills;
|
||||
let simple = fills <= 1
|
||||
&& strokes <= 1
|
||||
&& !items.iter().any(|i| has_raster(i.effects()))
|
||||
&& items.iter().all(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.blend == BlendMode::Normal,
|
||||
AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center,
|
||||
|
|
@ -476,6 +477,8 @@ impl Writer<'_> {
|
|||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
for it in items {
|
||||
// The item's own raster effects filter that item alone.
|
||||
let filters = self.open_filters(n, it.effects());
|
||||
let mut p = Props::new();
|
||||
match it {
|
||||
AppearanceItem::Fill(f) => {
|
||||
|
|
@ -525,6 +528,7 @@ impl Writer<'_> {
|
|||
self.line(&format!("<path d=\"{d}\"{a}{extra}/>"));
|
||||
}
|
||||
}
|
||||
self.close_filters(filters);
|
||||
}
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
|
|
@ -573,7 +577,7 @@ impl Writer<'_> {
|
|||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
|
||||
if has_raster(&n.appearance.effects) {
|
||||
return self.filtered(n);
|
||||
}
|
||||
if let Some(m) = n.mask.as_deref()
|
||||
|
|
@ -679,15 +683,22 @@ impl Writer<'_> {
|
|||
}
|
||||
|
||||
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
|
||||
/// per effect, the first effect innermost (Illustrator's stacking order).
|
||||
/// per effect, the first effect innermost (the reference app's stacking order).
|
||||
fn filtered(&mut self, n: &Node) {
|
||||
use vectorcraft_effects::RasterFx;
|
||||
let fx = vectorcraft_effects::raster_effects(&n.appearance.effects);
|
||||
let mut inner = n.clone();
|
||||
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
|
||||
let opened = self.open_filters(n, &n.appearance.effects);
|
||||
self.node(&inner);
|
||||
self.close_filters(opened);
|
||||
}
|
||||
|
||||
/// Open one `<g filter>` per visible raster effect of `effects` (an object's, or one fill or
|
||||
/// stroke's) on `n`, outermost last effect; returns how many to close.
|
||||
fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize {
|
||||
use vectorcraft_effects::RasterFx;
|
||||
let fx = vectorcraft_effects::raster_effects(effects);
|
||||
let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0;
|
||||
let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
let fid = self.fresh_id("filter");
|
||||
let region_attr = match region {
|
||||
|
|
@ -741,9 +752,11 @@ impl Writer<'_> {
|
|||
self.def(1, &format!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>"));
|
||||
self.line(&format!("<g filter=\"url(#{fid})\">"));
|
||||
self.depth += 1;
|
||||
opened += 1;
|
||||
}
|
||||
self.node(&inner);
|
||||
fx.len()
|
||||
}
|
||||
|
||||
fn close_filters(&mut self, opened: usize) {
|
||||
for _ in 0..opened {
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
|
|
@ -948,3 +961,8 @@ impl Writer<'_> {
|
|||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
|
||||
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
|
||||
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
//! Live effects in SVG export: a fill or stroke's own raster effects become filters on that item's
|
||||
//! element, and geometry effects on type are baked through its outlines.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export};
|
||||
|
||||
fn doc_with(mut n: Node) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn fx(id: &str) -> Effect {
|
||||
Effect { id: id.into(), params: json!({}), visible: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_filters_the_stroke_element_only() {
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)),
|
||||
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
|
||||
);
|
||||
n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow"));
|
||||
let svg = export(&doc_with(n), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<filter ").count(), 1, "{svg}");
|
||||
// The filter group holds the stroke's path, and the fill's path comes before it, unfiltered.
|
||||
let open = svg.find("<g filter=\"url(#").expect("a filter group");
|
||||
let inside = &svg[open..svg[open..].find("</g>").map(|i| open + i).unwrap()];
|
||||
assert!(inside.contains("stroke=\"#000000\"") && !inside.contains("fill=\"#ffffff\""), "{inside}");
|
||||
assert!(svg[..open].contains("fill=\"#ffffff\""), "{svg}");
|
||||
// A hidden effect exports nothing.
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)),
|
||||
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
|
||||
);
|
||||
n.appearance.items[1].effects_mut().push(Effect { visible: false, ..fx("stylize.outerGlow") });
|
||||
assert!(!export(&doc_with(n), &ExportOptions::default()).contains("<filter "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_effects_on_type_export_as_its_reshaped_outlines() {
|
||||
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(10.0, 60.0), "Hi", style))));
|
||||
let plain = export(&doc_with(n.clone()), &ExportOptions::default());
|
||||
assert!(plain.contains("<text"), "{plain}");
|
||||
n.appearance.effects =
|
||||
vec![Effect { id: "distort.roughen".into(), params: json!({"size": 3, "relative": false}), visible: true }, fx("stylize.dropShadow")];
|
||||
let svg = export(&doc_with(n), &ExportOptions::default());
|
||||
assert!(!svg.contains("<text") && svg.contains("<path"), "{svg}");
|
||||
assert!(svg.contains("<filter "), "the shadow stays a filter: {svg}");
|
||||
}
|
||||
Loading…
Reference in New Issue