Part of ROADMAP gap 2: Radial Blur, Smart Blur and Unsharp Mask on a pixel-filter pipeline
Effect › Blur › Radial Blur (spin/zoom, draft/good/best) and Smart Blur (Normal mode), Effect › Sharpen › Unsharp Mask: Photoshop-style filters in vectorcraft_effects::pixel, run by the renderer on the object's pixels in stack order with Gaussian Blur and Feather, cached while panning; lengths in points, bounded work per pixel. PDF, Expand Appearance and SVG export write the effected objects as images. Dialogs with preview (method/quality dropdowns, fields in documented order), menu entries, translations, tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
256702bab0
commit
1e7f5084d8
17
ROADMAP.md
17
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -5,7 +5,8 @@
|
|||
//!
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Unsharp Mask) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
|
|
@ -36,6 +37,7 @@ mod distort;
|
|||
mod group;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
pub mod stroke;
|
||||
|
|
@ -61,6 +63,7 @@ pub use group::{
|
|||
};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
|
@ -118,7 +121,7 @@ fn lengths_of(id: &str) -> Lengths {
|
|||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
"stylize.dropShadow" => always(&["x", "y", "blur"]),
|
||||
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
|
||||
|
|
@ -134,6 +137,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
|
|||
const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
|
||||
|
|
@ -265,6 +269,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
|||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
BLUR,
|
||||
"{amount: 1..100 (10; spin: an arc of `amount`°, zoom: the content scaled by up to about ±amount/2 %), method: \"spin\"|\"zoom\" (\"spin\"), quality: \"draft\"|\"good\"|\"best\" (\"good\"; 16, 64 or 256 samples)} blurs around the object's centre",
|
||||
json!({"amount": 10.0, "method": "spin", "quality": "good"}),
|
||||
),
|
||||
r(
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
|
||||
),
|
||||
r(
|
||||
"sharpen.unsharpMask",
|
||||
"Unsharp Mask…",
|
||||
SHARPEN,
|
||||
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
|
||||
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
|
||||
),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
|
|
@ -428,6 +453,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
|||
/// Is `id` a raster (painted) effect?
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
//! Blur › Radial Blur and Smart Blur.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::{PixelSpace, Px16, box_blur, to8, to16};
|
||||
|
||||
/// Zoom's total spread, in log scale: `amount` 100 scales the content between 1/√2 and √2.
|
||||
pub(super) fn zoom_extent(amount: f64) -> f64 {
|
||||
(1.0 + amount / 100.0).ln()
|
||||
}
|
||||
|
||||
/// `buf` (`w` × `h`) sampled bilinearly at pixel index position (`x`, `y`) (pixel centres at
|
||||
/// whole coordinates); transparent outside.
|
||||
fn bilinear(buf: &[Px16], w: usize, h: usize, x: f64, y: f64) -> [f32; 4] {
|
||||
if !(x > -1.0 && y > -1.0 && x < w as f64 && y < h as f64) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = ((x - x0) as f32, (y - y0) as f32);
|
||||
let (xi, yi) = (x0 as isize, y0 as isize);
|
||||
let at = |cx: isize, cy: isize| -> [f32; 4] {
|
||||
if cx < 0 || cy < 0 || cx as usize >= w || cy as usize >= h {
|
||||
return [0.0; 4];
|
||||
}
|
||||
buf.get(cy as usize * w + cx as usize).map_or([0.0; 4], |p| p.map(f32::from))
|
||||
};
|
||||
let (a, b, c, d) = (at(xi, yi), at(xi + 1, yi), at(xi, yi + 1), at(xi + 1, yi + 1));
|
||||
let (wa, wb, wc, wd) = ((1.0 - fx) * (1.0 - fy), fx * (1.0 - fy), (1.0 - fx) * fy, fx * fy);
|
||||
std::array::from_fn(|i| a[i] * wa + b[i] * wb + c[i] * wc + d[i] * wd)
|
||||
}
|
||||
|
||||
/// Radial Blur: the average of the content turned (spin) or scaled (zoom) about the centre over
|
||||
/// evenly spaced steps spanning `amount`. Each pass averages the previous result with itself
|
||||
/// turned both ways by half the last step, so `passes` passes average 2^`passes` samples at two
|
||||
/// lookups a pixel each.
|
||||
pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, amount: f64, zoom: bool, passes: u32) {
|
||||
// The centre in pixel index coordinates (pixel centres at whole numbers).
|
||||
let c = space.to_doc.inverse() * space.center - Vec2::new(0.5, 0.5);
|
||||
if !(c.x.is_finite() && c.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let total = if zoom { zoom_extent(amount) } else { amount.to_radians() };
|
||||
let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2());
|
||||
// A pixel's reflection in the view (one axis flipped) only turns the spin the other way,
|
||||
// which a symmetric blur doesn't notice.
|
||||
let step = |d: f64| if zoom { about(Affine::scale(d.exp())) } else { about(Affine::rotate(d)) };
|
||||
let mut cur = to16(px);
|
||||
let mut next = vec![[0u16; 4]; cur.len()];
|
||||
for j in 1..=passes.min(12) {
|
||||
let d = total / f64::powi(2.0, j as i32 + 1);
|
||||
let (fwd, back) = (step(d), step(-d));
|
||||
// Where a pixel's two samples lie moves by a fixed step along a row.
|
||||
let (df, db) = (fwd * Point::new(1.0, 0.0) - fwd * Point::ZERO, back * Point::new(1.0, 0.0) - back * Point::ZERO);
|
||||
for (y, row) in next.chunks_exact_mut(w).enumerate().take(h) {
|
||||
let (mut f, mut b) = (fwd * Point::new(0.0, y as f64), back * Point::new(0.0, y as f64));
|
||||
for out in row.iter_mut() {
|
||||
let (s, t) = (bilinear(&cur, w, h, f.x, f.y), bilinear(&cur, w, h, b.x, b.y));
|
||||
*out = std::array::from_fn(|i| ((s[i] + t[i]) * 0.5).round().clamp(0.0, 65535.0) as u16);
|
||||
(f, b) = (f + df, b + db);
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut cur, &mut next);
|
||||
}
|
||||
for (p, q) in px.iter_mut().zip(&cur) {
|
||||
*p = q.map(to8);
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest radius (pixels) Smart Blur looks over: farther neighbours are as good as gone.
|
||||
const MAX_SMART: f64 = 4096.0;
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
/// over the gaps between them so the blur stays smooth (a neighbour counts when both it and its
|
||||
/// blurred surroundings are within the threshold, so at most a fringe under the threshold crosses
|
||||
/// an edge).
|
||||
pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32) {
|
||||
if radius.is_nan() || radius < 0.5 {
|
||||
return;
|
||||
}
|
||||
let r = radius.min(MAX_SMART);
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
};
|
||||
let offsets: Vec<(isize, isize)> =
|
||||
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let src = to16(px);
|
||||
let blurred;
|
||||
let near: &[Px16] = if prefilter > 0 {
|
||||
let mut b = src.clone();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
blurred = b;
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let t = (threshold.clamp(0.0, 255.0) * 257.0) as i32;
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let mut acc = c.map(u64::from);
|
||||
let mut count = 1u64;
|
||||
for (dx, dy) in &offsets {
|
||||
let (sx, sy) = (x as isize + dx, y as isize + dy);
|
||||
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
let i = sy as usize * w + sx as usize;
|
||||
let (Some(s), Some(own)) = (near.get(i), src.get(i)) else { continue };
|
||||
// The neighbour itself and its surroundings both close enough: edges stay sharp.
|
||||
let close = |p: &Px16| p.iter().zip(c).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t);
|
||||
if close(own) && close(s) {
|
||||
for (a, v) in acc.iter_mut().zip(s) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
*o = acc.map(|a| to8(((a + count / 2) / count).min(65535) as u16));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
|
||||
//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8.
|
||||
//!
|
||||
//! - Every distance is in document units (points) and becomes pixels through the raster's
|
||||
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
|
||||
//! Settings resolution (as Gaussian Blur's radius does).
|
||||
//! - The work per pixel is bounded whatever the parameters: blurs are box approximations whose
|
||||
//! cost doesn't grow with the radius, and sample counts come from the effect's quality.
|
||||
//! - Beyond the raster's edges is transparency.
|
||||
|
||||
mod blur;
|
||||
mod sharpen;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::util::{num, text};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"];
|
||||
|
||||
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
|
||||
/// are in document units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
|
||||
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
|
||||
RadialBlur { amount: f64, zoom: bool, passes: u32 },
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32 },
|
||||
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
|
||||
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
|
||||
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
|
||||
}
|
||||
|
||||
/// Where a raster's pixels lie in the document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PixelSpace {
|
||||
/// Pixel coordinates (x right, y down, (0, 0) the top-left corner of the first pixel) →
|
||||
/// document coordinates.
|
||||
pub to_doc: Affine,
|
||||
/// The size of a pixel in document units.
|
||||
pub px: f64,
|
||||
/// The object's centre (document coordinates): where Radial Blur blurs around.
|
||||
pub center: Point,
|
||||
}
|
||||
|
||||
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
|
||||
pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
let quality = |levels: [(&str, u32); 3], default: u32| {
|
||||
let q = text(p, "quality", "");
|
||||
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
|
||||
};
|
||||
Some(match id {
|
||||
"blur.radial" => PixelFx::RadialBlur {
|
||||
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
|
||||
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
|
||||
passes: quality([("draft", 4), ("good", 6), ("best", 8)], 6),
|
||||
},
|
||||
"blur.smart" => PixelFx::SmartBlur {
|
||||
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
|
||||
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
|
||||
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
|
||||
},
|
||||
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
|
||||
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
|
||||
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
|
||||
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
impl PixelFx {
|
||||
/// How far beyond `bounds` (the content's, document units) the effect can paint.
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
// From the centre to the farthest corner, and to the nearest side.
|
||||
let far = 0.5 * bounds.width().hypot(bounds.height());
|
||||
let near = 0.5 * bounds.width().min(bounds.height());
|
||||
match *self {
|
||||
// A rotated corner reaches the circle through the corners.
|
||||
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
|
||||
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
|
||||
PixelFx::SmartBlur { radius, .. } => radius,
|
||||
PixelFx::UnsharpMask { .. } => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content that decides it lies; `None` when that
|
||||
/// is anywhere in the object (Radial Blur).
|
||||
pub fn reach(&self) -> Option<f64> {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says. Does nothing when the sizes don't match.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) {
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
|
||||
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
(w > 0 && h > 0 && rest.is_empty() && w.checked_mul(h) == Some(px.len())).then_some(px)
|
||||
}
|
||||
|
||||
/// The largest box radius the blurs use: wider boxes look the same on any raster we make.
|
||||
const MAX_BOX: usize = 1 << 17;
|
||||
|
||||
/// Radii of three box blurs approximating a Gaussian of `sigma` pixels.
|
||||
pub fn gauss_boxes(sigma: f64) -> [usize; 3] {
|
||||
let sigma = if sigma.is_finite() { sigma.clamp(0.0, MAX_BOX as f64 / 2.0) } else { 0.0 };
|
||||
let n = 3.0;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i64;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||||
let m = m_ideal.round() as i64;
|
||||
let r = |i: i64| ((((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).min(MAX_BOX);
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// One horizontal box blur of radius `r` over the rows of `w` values of a plane (running sum,
|
||||
/// zero past the ends).
|
||||
fn box_plane(src: &[f32], dst: &mut [f32], w: usize, r: usize) {
|
||||
if r == 0 {
|
||||
dst.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
let mut acc: f32 = row.iter().take(r + 1).sum();
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = acc * norm;
|
||||
if let Some(v) = row.get(x + r + 1) {
|
||||
acc += v;
|
||||
}
|
||||
if let Some(v) = x.checked_sub(r).and_then(|i| row.get(i)) {
|
||||
acc -= v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose<T: Copy>(src: &[T], dst: &mut [T], w: usize, h: usize) {
|
||||
for (y, row) in src.chunks_exact(w).enumerate().take(h) {
|
||||
for (x, v) in row.iter().enumerate() {
|
||||
if let Some(d) = dst.get_mut(x * h + y) {
|
||||
*d = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur a `w` × `h` plane of values in place (three box passes per axis, zero past the
|
||||
/// edges). Does nothing when the sizes don't match.
|
||||
pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
if sigma < 0.2 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
|
||||
return;
|
||||
}
|
||||
let boxes = gauss_boxes(sigma);
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for (len, other) in [(w, h), (h, w)] {
|
||||
for r in boxes {
|
||||
box_plane(a, &mut tmp, len, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, len, other);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
|
||||
pub(super) type Px16 = [u16; 4];
|
||||
|
||||
pub(super) fn to16(px: &[[u8; 4]]) -> Vec<Px16> {
|
||||
px.iter().map(|p| p.map(|v| v as u16 * 257)).collect()
|
||||
}
|
||||
|
||||
pub(super) fn to8(v: u16) -> u8 {
|
||||
((v as u32 + 128) / 257).min(255) as u8
|
||||
}
|
||||
|
||||
fn add(acc: &mut [u64; 4], p: &Px16) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a += *v as u64;
|
||||
}
|
||||
}
|
||||
|
||||
fn sub(acc: &mut [u64; 4], p: &Px16) {
|
||||
for (a, v) in acc.iter_mut().zip(p) {
|
||||
*a = a.saturating_sub(*v as u64);
|
||||
}
|
||||
}
|
||||
|
||||
fn mean(acc: &[u64; 4], n: u64) -> Px16 {
|
||||
acc.map(|a| ((a + n / 2) / n.max(1)).min(65535) as u16)
|
||||
}
|
||||
|
||||
/// One box blur of radius `r` along the rows of `w` pixels (transparent past the ends).
|
||||
fn box_rows(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
|
||||
let n = 2 * r as u64 + 1;
|
||||
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
|
||||
let mut acc = [0u64; 4];
|
||||
row.iter().take(r + 1).for_each(|p| add(&mut acc, p));
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = mean(&acc, n);
|
||||
if let Some(p) = row.get(x + r + 1) {
|
||||
add(&mut acc, p);
|
||||
}
|
||||
if let Some(p) = x.checked_sub(r).and_then(|i| row.get(i)) {
|
||||
sub(&mut acc, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One box blur of radius `r` down the columns of `w`-wide rows (transparent past the ends), row
|
||||
/// by row with a running sum per column.
|
||||
fn box_cols(src: &[Px16], dst: &mut [Px16], w: usize, r: usize) {
|
||||
let n = 2 * r as u64 + 1;
|
||||
let rows: Vec<&[Px16]> = src.chunks_exact(w).collect();
|
||||
let mut acc = vec![[0u64; 4]; w];
|
||||
let add_row = |acc: &mut [[u64; 4]], row: &[Px16]| acc.iter_mut().zip(row).for_each(|(a, p)| add(a, p));
|
||||
rows.iter().take(r + 1).for_each(|row| add_row(&mut acc, row));
|
||||
for (y, out) in dst.chunks_exact_mut(w).enumerate() {
|
||||
for (o, a) in out.iter_mut().zip(&acc) {
|
||||
*o = mean(a, n);
|
||||
}
|
||||
if let Some(row) = rows.get(y + r + 1) {
|
||||
add_row(&mut acc, row);
|
||||
}
|
||||
if let Some(row) = y.checked_sub(r).and_then(|i| rows.get(i)) {
|
||||
acc.iter_mut().zip(*row).for_each(|(a, p)| sub(a, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Box-blur `w`-wide rows of pixels in place with radius `r` along both axes.
|
||||
pub(super) fn box_blur(px: &mut [Px16], w: usize, r: usize) {
|
||||
if r == 0 || w == 0 {
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![[0u16; 4]; px.len()];
|
||||
box_rows(px, &mut tmp, w, r);
|
||||
box_cols(&tmp, px, w, r);
|
||||
}
|
||||
|
||||
/// Gaussian-blur `w`-wide rows of premultiplied pixels in place (σ = `sigma` pixels, three box
|
||||
/// passes per axis, transparent past the edges).
|
||||
pub(super) fn gaussian(px: &mut [Px16], w: usize, sigma: f64) {
|
||||
if sigma < 0.2 || w == 0 {
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![[0u16; 4]; px.len()];
|
||||
for r in gauss_boxes(sigma) {
|
||||
box_rows(px, &mut tmp, w, r);
|
||||
px.copy_from_slice(&tmp);
|
||||
}
|
||||
for r in gauss_boxes(sigma) {
|
||||
box_cols(px, &mut tmp, w, r);
|
||||
px.copy_from_slice(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur `data`, `w` × `h` premultiplied RGBA8 pixels, in place (σ = `sigma` pixels). Does
|
||||
/// nothing when the sizes don't match.
|
||||
pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
|
||||
if let Some(px) = pixels(data, w, h)
|
||||
&& sigma >= 0.2
|
||||
{
|
||||
let mut wide = to16(px);
|
||||
gaussian(&mut wide, w, sigma);
|
||||
for (p, q) in px.iter_mut().zip(&wide) {
|
||||
*p = q.map(to8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
//! Sharpen › Unsharp Mask.
|
||||
|
||||
use super::{gaussian, to16};
|
||||
|
||||
/// Unsharp Mask: each colour channel pushed away from a Gaussian blur of the pixels (σ = `sigma`
|
||||
/// pixels) by `amount` times their difference, where that difference reaches `threshold` levels.
|
||||
/// The blur is premultiplied, so edges against transparency don't darken; coverage stays as it is.
|
||||
pub(super) fn unsharp(px: &mut [[u8; 4]], w: usize, h: usize, amount: f64, sigma: f64, threshold: f64) {
|
||||
if sigma.is_nan() || sigma < 0.2 || amount.is_nan() || amount <= 0.0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
let mut blurred = to16(px);
|
||||
gaussian(&mut blurred, w, sigma);
|
||||
let (amount, threshold) = (amount as f32, threshold as f32);
|
||||
for (p, b) in px.iter_mut().zip(&blurred) {
|
||||
let a = p[3] as f32;
|
||||
if a == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for i in 0..3 {
|
||||
let own = p[i] as f32 * 255.0 / a;
|
||||
let around = if b[3] > 0 { b[i] as f32 * 255.0 / b[3] as f32 } else { own };
|
||||
let diff = own - around;
|
||||
if diff.abs() < threshold || diff == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let v = (own + amount * diff).clamp(0.0, 255.0);
|
||||
p[i] = (v * a / 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::{RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are document points (origin top-left).
|
||||
fn space(w: usize, h: usize) -> PixelSpace {
|
||||
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0) }
|
||||
}
|
||||
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
out.extend(f(x, y));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * w + x) * 4;
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
|
||||
let e = Effect { id: id.into(), params: p, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(p)) => *p,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(v: u8) -> [u8; 4] {
|
||||
[v, v, v, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("blur.radial", json!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6 });
|
||||
assert_eq!(
|
||||
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})),
|
||||
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
|
||||
);
|
||||
assert_eq!(fx("blur.radial", json!({"amount": -5, "quality": "nonsense"})), PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6 });
|
||||
assert_eq!(fx("blur.smart", json!({})), PixelFx::SmartBlur { radius: 3.0, threshold: 25.0, samples: 7 });
|
||||
assert_eq!(
|
||||
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low"})),
|
||||
PixelFx::SmartBlur { radius: 3.0, threshold: 0.1, samples: 5 }
|
||||
);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})), PixelFx::UnsharpMask { amount: 0.5, radius: 1.0, threshold: 0.0 });
|
||||
assert_eq!(
|
||||
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
|
||||
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
|
||||
);
|
||||
for id in PIXEL_EFFECTS {
|
||||
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outsets_follow_the_object() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let spin = fx("blur.radial", json!({}));
|
||||
let far = 0.5 * 100f64.hypot(50.0);
|
||||
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
|
||||
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
|
||||
assert_eq!(spin.reach(), None);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_sizes_and_empty_rasters_are_left_alone() {
|
||||
for id in PIXEL_EFFECTS {
|
||||
let f = fx(id, json!({}));
|
||||
let mut d = vec![200u8; 4 * 10];
|
||||
f.apply(&mut d, 3, 3, &space(3, 3));
|
||||
f.apply(&mut d, 0, 10, &space(0, 10));
|
||||
f.apply(&mut [], 0, 0, &space(0, 0));
|
||||
assert!(d.iter().all(|v| *v == 200), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_parameters_finish() {
|
||||
let (w, h) = (40, 30);
|
||||
let src = image(w, h, |x, y| if (x / 5 + y / 5) % 2 == 0 { grey(250) } else { [0, 0, 0, 0] });
|
||||
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
|
||||
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
|
||||
for id in PIXEL_EFFECTS {
|
||||
for p in [json!({"amount": 1e308, "radius": 1e308, "threshold": 1e308, "quality": "best"}), json!({"amount": 0, "radius": 0})] {
|
||||
for s in [tiny, huge, space(w, h)] {
|
||||
let mut d = src.clone();
|
||||
fx(id, p.clone()).apply(&mut d, w, h, &s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_effect_is_deterministic() {
|
||||
let (w, h) = (32, 24);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 { 255 } else { 128 };
|
||||
[((x * 8) as u8).min(a), ((y * 10) as u8).min(a), ((x * y % 256) as u8).min(a), a]
|
||||
});
|
||||
for id in PIXEL_EFFECTS {
|
||||
let f = fx(id, json!({"amount": 40, "radius": 3, "threshold": 40}));
|
||||
let (mut a, mut b) = (src.clone(), src.clone());
|
||||
f.apply(&mut a, w, h, &space(w, h));
|
||||
f.apply(&mut b, w, h, &space(w, h));
|
||||
assert_eq!(a, b, "{id}");
|
||||
assert_ne!(a, src, "{id} changes the image");
|
||||
// Premultiplied stays premultiplied.
|
||||
assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
|
||||
let (w, h) = (41, 41);
|
||||
// A white dot 12 px right of the centre (20.5, 20.5), and one on the centre.
|
||||
let src = image(w, h, |x, y| if (x == 32 || x == 20) && y == 20 { grey(255) } else { [0, 0, 0, 0] });
|
||||
let mut d = src.clone();
|
||||
fx("blur.radial", json!({"amount": 60, "quality": "best"})).apply(&mut d, w, h, &space(w, h));
|
||||
// 60° of arc around the centre: up to 30° either way, 12 px out: (20.5 + 12 cos 20°, 20.5 ± 12 sin 20°).
|
||||
assert!(at(&d, w, 31, 16)[3] > 0 && at(&d, w, 31, 24)[3] > 0, "the dot spreads along its circle");
|
||||
assert_eq!(at(&d, w, 32, 10)[3], 0, "not beyond the arc");
|
||||
assert_eq!(at(&d, w, 26, 20)[3], 0, "not towards the centre");
|
||||
assert!(at(&d, w, 20, 20)[3] > 100, "the centre barely moves: {:?}", at(&d, w, 20, 20));
|
||||
// Zoom smears along the ray instead.
|
||||
let mut z = src.clone();
|
||||
fx("blur.radial", json!({"amount": 60, "method": "zoom", "quality": "best"})).apply(&mut z, w, h, &space(w, h));
|
||||
assert!(at(&z, w, 35, 20)[3] > 0 && at(&z, w, 29, 20)[3] > 0, "the dot streaks along the ray");
|
||||
assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_smooths_noise_and_keeps_edges() {
|
||||
let (w, h) = (40, 20);
|
||||
// Left: grey 100 with ±6 noise; right: white.
|
||||
let src = image(w, h, |x, y| if x < 20 { grey(if (x + y) % 2 == 0 { 94 } else { 106 }) } else { grey(255) });
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3, "threshold": 25})).apply(&mut d, w, h, &space(w, h));
|
||||
let v = at(&d, w, 8, 10)[0];
|
||||
assert!((97..=103).contains(&v), "noise smoothed: {v}");
|
||||
assert!(at(&d, w, 19, 10)[0] < 120, "the edge's dark side doesn't take the white");
|
||||
assert_eq!(at(&d, w, 20, 10)[0], 255, "the edge's white side stays white");
|
||||
// A threshold above the step blurs across it.
|
||||
let mut all = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3, "threshold": 100})).apply(&mut all, w, h, &space(w, h));
|
||||
assert_eq!(at(&all, w, 20, 10)[0], 255, "a 155-level step is still past 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
let src = image(w, h, |x, _| grey(if x < 20 { 100 } else { 150 }));
|
||||
let mut d = src.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, w, h, &space(w, h));
|
||||
assert!(at(&d, w, 19, 5)[0] < 95, "dark side darker: {:?}", at(&d, w, 19, 5));
|
||||
assert!(at(&d, w, 20, 5)[0] > 155, "light side lighter: {:?}", at(&d, w, 20, 5));
|
||||
assert_eq!(at(&d, w, 5, 5), grey(100), "flat areas stay");
|
||||
assert_eq!(at(&d, w, 35, 5), grey(150));
|
||||
// Differences under the threshold are left alone.
|
||||
let mut t = src.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2, "threshold": 60})).apply(&mut t, w, h, &space(w, h));
|
||||
assert_eq!(t, src);
|
||||
// Coverage stays, and a shape's edge against transparency doesn't darken.
|
||||
let shape = image(w, h, |x, _| if x < 20 { [200, 0, 0, 255] } else { [0, 0, 0, 0] });
|
||||
let mut s = shape.clone();
|
||||
fx("sharpen.unsharpMask", json!({"amount": 300, "radius": 3})).apply(&mut s, w, h, &space(w, h));
|
||||
assert_eq!(s, shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_document_units() {
|
||||
// The same art at twice the resolution, sharpened with the same radius in points: the
|
||||
// overshoot spans twice the pixels, the same distance.
|
||||
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
|
||||
let run = |scale: usize| {
|
||||
let mut d = edge(scale);
|
||||
let s = PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO };
|
||||
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
|
||||
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
|
||||
};
|
||||
let (one, two) = (run(1), run(2));
|
||||
assert!(one > 1 && (two as f64 / one as f64 - 2.0).abs() < 0.5, "{one} px at 1×, {two} px at 2×");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_and_plane_blurs_keep_their_mass() {
|
||||
let (w, h) = (30, 30);
|
||||
let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] });
|
||||
let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
gaussian_rgba(&mut d, w, h, 2.0);
|
||||
let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum();
|
||||
assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}");
|
||||
assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255);
|
||||
let mut plane: Vec<f32> = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect();
|
||||
blur_plane(&mut plane, w, h, 2.0);
|
||||
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
|
||||
}
|
||||
|
||||
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
|
||||
/// --nocapture pixel_timings`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pixel_timings() {
|
||||
let (w, h) = (2000, 2000);
|
||||
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
|
||||
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0) };
|
||||
let cases = [
|
||||
("blur.radial", json!({"quality": "draft"})),
|
||||
("blur.radial", json!({"quality": "good"})),
|
||||
("blur.radial", json!({"quality": "best", "method": "zoom"})),
|
||||
("blur.smart", json!({"quality": "low"})),
|
||||
("blur.smart", json!({"quality": "high", "radius": 30})),
|
||||
("sharpen.unsharpMask", json!({"radius": 20})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let mut d = src.clone();
|
||||
let t = std::time::Instant::now();
|
||||
fx(id, p.clone()).apply(&mut d, w, h, &s);
|
||||
println!("{id} {p}: {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,10 @@ use serde_json::Value;
|
|||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx};
|
||||
use crate::util::*;
|
||||
|
||||
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
|
||||
|
|
@ -40,6 +43,8 @@ pub enum RasterFx {
|
|||
GaussianBlur {
|
||||
radius: f64,
|
||||
},
|
||||
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
|
||||
Pixel(PixelFx),
|
||||
}
|
||||
|
||||
impl RasterFx {
|
||||
|
|
@ -47,13 +52,15 @@ impl RasterFx {
|
|||
pub fn is_below(&self) -> bool {
|
||||
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
|
||||
}
|
||||
/// How far this effect reaches beyond the geometry.
|
||||
pub fn outset(&self) -> f64 {
|
||||
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
|
||||
/// further around larger objects).
|
||||
pub fn outset(&self, bounds: Rect) -> f64 {
|
||||
match self {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
|
||||
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
|
||||
RasterFx::GaussianBlur { radius } => 1.5 * radius,
|
||||
RasterFx::Pixel(p) => p.outset(bounds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,14 +120,14 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
|||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
_ => return None,
|
||||
id => RasterFx::Pixel(pixel::parse(id, &p)?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in
|
||||
/// document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect]) -> f64 {
|
||||
raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max)
|
||||
/// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected)
|
||||
/// geometry's, in document units. Geometry effects are accounted for by evaluating them.
|
||||
pub fn outset(effects: &[Effect], bounds: Rect) -> f64 {
|
||||
raster_effects(effects).iter().map(|fx| fx.outset(bounds)).fold(0.0, f64::max)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,8 +301,8 @@ fn raster_effects_parse_and_outset() {
|
|||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. }));
|
||||
assert!(close(outset(&list), 10.0 + 9.0, 1e-9));
|
||||
assert_eq!(outset(&[]), 0.0);
|
||||
assert!(close(outset(&list, vectorcraft_geom::Rect::ZERO), 10.0 + 9.0, 1e-9));
|
||||
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -170,8 +170,10 @@ impl Session {
|
|||
return None;
|
||||
}
|
||||
let d = self.clipboard.to_document();
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = super::rasterfx::flatten_pixel_effects(&d);
|
||||
Some(vectorcraft_svg::export(
|
||||
&d,
|
||||
flat.as_ref().unwrap_or(&d),
|
||||
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
|||
let (opts, boards, chosen) = plan(doc, p)?;
|
||||
// Preserve editing embeds the native document (once, shared by every file).
|
||||
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded::default();
|
||||
for artboard in boards {
|
||||
// A chosen artboard's file holds the art over it (#550).
|
||||
|
|
|
|||
|
|
@ -1265,11 +1265,11 @@ fn reach(n: &Node) -> Option<Rect> {
|
|||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&n.appearance.effects), f64::max);
|
||||
let b = match n.children() {
|
||||
Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?,
|
||||
_ => n.visual_bounds()?,
|
||||
};
|
||||
let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max);
|
||||
Some(b.inflate(fx + 1.0, fx + 1.0))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
|
||||
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
|
||||
//! with the image. Expand Appearance makes its images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
|
||||
//! into images the same way ([`flatten_pixel_effects`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -167,7 +171,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
|||
// effects reach beyond that.
|
||||
let b = vectorcraft_render::painted_bounds(whole)?;
|
||||
let look = out.raster_effects.clone();
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
|
||||
let b = b.inflate(reach, reach);
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
|
||||
|
|
@ -337,20 +341,39 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
|||
Some(Node { kind: NodeKind::Group { children: vec![Arc::new(art)], clip: false }, ..n.clone() })
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects PDF export draws: its own, its fills' and strokes', its opacity
|
||||
/// mask's or (groups and layers) its members'? Hidden objects and template layers aren't drawn.
|
||||
fn needs(n: &Node) -> bool {
|
||||
/// Which objects' raster effects become images.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
|
||||
Pixel,
|
||||
}
|
||||
|
||||
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
|
||||
fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
|
||||
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
|
||||
/// aren't drawn.
|
||||
fn needs(n: &Node, which: Which) -> bool {
|
||||
n.visible
|
||||
&& !matches!(n.kind, NodeKind::Layer { template: true, .. })
|
||||
&& (!raster_fx(n).is_empty()
|
||||
|| n.mask.as_ref().is_some_and(|m| needs(&m.art))
|
||||
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c)))))
|
||||
&& (imaged(n, which)
|
||||
|| n.mask.as_ref().is_some_and(|m| needs(&m.art, which))
|
||||
|| (is_container(n) && n.children().is_some_and(|ch| ch.iter().any(|c| needs(c, which)))))
|
||||
}
|
||||
|
||||
/// The art of `n`'s opacity mask with its raster effects as images.
|
||||
fn walk_mask(out: &mut Document, n: &mut Node) {
|
||||
fn walk_mask(out: &mut Document, n: &mut Node, which: Which) {
|
||||
if let Some(m) = n.mask.as_mut()
|
||||
&& let Some(art) = walk(out, &m.art)
|
||||
&& let Some(art) = walk(out, &m.art, which)
|
||||
{
|
||||
m.art = Arc::new(art);
|
||||
}
|
||||
|
|
@ -359,15 +382,15 @@ fn walk_mask(out: &mut Document, n: &mut Node) {
|
|||
/// `n` with its raster effects (and its members' and its opacity mask's) as images, `None` when it
|
||||
/// has none. An object whose effects all paint below it keeps its vector art over an image of
|
||||
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
|
||||
fn walk(out: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs(n) {
|
||||
fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
|
||||
if !needs(n, which) {
|
||||
return None;
|
||||
}
|
||||
if raster_fx(n).is_empty() {
|
||||
if !imaged(n, which) {
|
||||
let mut m = n.clone();
|
||||
walk_mask(out, &mut m);
|
||||
walk_mask(out, &mut m, which);
|
||||
if is_container(&m)
|
||||
&& let Some(ch) = walk_all(out, n.children()?)
|
||||
&& let Some(ch) = walk_all(out, n.children()?, which)
|
||||
&& let Some(slot) = m.children_mut()
|
||||
{
|
||||
*slot = ch;
|
||||
|
|
@ -375,30 +398,30 @@ fn walk(out: &mut Document, n: &Node) -> Option<Node> {
|
|||
return Some(m);
|
||||
}
|
||||
if let Some(g) = placed_symbol(out, n) {
|
||||
return Some(walk(out, &g).unwrap_or(g));
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
let below = below_only(n);
|
||||
if !below && let Some(g) = split_items(out, n) {
|
||||
return Some(walk(out, &g).unwrap_or(g));
|
||||
return Some(walk(out, &g, which).unwrap_or(g));
|
||||
}
|
||||
// An image that can't be made leaves the object as it is (the writer reports its effects).
|
||||
let image = raster_image(out, n, below, false)?;
|
||||
let mut v = n.clone();
|
||||
strip_raster(&mut v, false);
|
||||
let mut m = if below {
|
||||
let mut m = walk(out, &v).unwrap_or(v);
|
||||
let mut m = walk(out, &v, which).unwrap_or(v);
|
||||
put_below(out, &mut m, image);
|
||||
m
|
||||
} else {
|
||||
replace_with_image(v, image)
|
||||
};
|
||||
walk_mask(out, &mut m);
|
||||
walk_mask(out, &mut m, which);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `nodes` with their raster effects as images, `None` when none has any.
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n)).then(|| nodes.iter().map(|n| walk(out, n).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
fn walk_all(out: &mut Document, nodes: &[Arc<Node>], which: Which) -> Option<Vec<Arc<Node>>> {
|
||||
nodes.iter().any(|n| needs(n, which)).then(|| nodes.iter().map(|n| walk(out, n, which).map(Arc::new).unwrap_or_else(|| n.clone())).collect())
|
||||
}
|
||||
|
||||
/// A copy of `doc` with raster effects turned into images at the document's raster effects
|
||||
|
|
@ -406,26 +429,37 @@ fn walk_all(out: &mut Document, nodes: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
|||
/// type, images, symbol instances, live objects) and on single fills and strokes, in the layers,
|
||||
/// opacity masks, symbol definitions and pattern tiles.
|
||||
pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
flatten(doc, Which::All)
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
|
||||
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
|
||||
/// other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, Which::Pixel)
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, which: Which) -> Option<Document> {
|
||||
let any = |nodes: &[Arc<Node>]| nodes.iter().any(|n| needs(n, which));
|
||||
if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) {
|
||||
return None;
|
||||
}
|
||||
// Geometry effects first, so the vector objects kept above shadows are final.
|
||||
let baked = effects::bake_document(doc);
|
||||
let src = baked.as_ref().unwrap_or(doc);
|
||||
let mut out = src.clone();
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers) {
|
||||
if let Some(layers) = walk_all(&mut out, &src.layers, which) {
|
||||
out.layers = layers;
|
||||
}
|
||||
for i in 0..out.symbols.len() {
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a))
|
||||
if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| walk(&mut out, &a, which))
|
||||
&& let Some(s) = out.symbols.get_mut(i)
|
||||
{
|
||||
s.art = Arc::new(art);
|
||||
}
|
||||
}
|
||||
for i in 0..out.patterns.len() {
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a))
|
||||
if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| walk_all(&mut out, &a, which))
|
||||
&& let Some(p) = out.patterns.get_mut(i)
|
||||
{
|
||||
p.art = art;
|
||||
|
|
|
|||
|
|
@ -1552,6 +1552,8 @@ mod tests_proxyitems;
|
|||
#[cfg(test)]
|
||||
mod tests_puppetwarp;
|
||||
#[cfg(test)]
|
||||
mod tests_rasterfilters;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen ›
|
||||
//! Unsharp Mask): applied and edited as commands, listed in the catalogue, drawn on the canvas,
|
||||
//! and written to PDF and SVG as images of the effected object.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::{Rendered, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The new effects with parameters that change a striped square visibly.
|
||||
const EFFECTS: [(&str, &str); 3] = [
|
||||
("blur.radial", r#"{"amount": 30, "quality": "draft"}"#),
|
||||
("blur.smart", r#"{"radius": 8, "threshold": 100}"#),
|
||||
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
|
||||
];
|
||||
|
||||
/// A group of a red square and a darker red stripe across it; returns the group's id.
|
||||
fn striped() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#e03020", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap();
|
||||
let stripe = s.execute("shape.rectangle", &json!({"x": 90, "y": 50, "width": 20, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#b04848", "ids": [stripe]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [stripe]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id, stripe]})).unwrap();
|
||||
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
(s, g)
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> Rendered {
|
||||
Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true)
|
||||
}
|
||||
|
||||
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
|
||||
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||||
sum as f64 / a.pixels.len() as f64 / 255.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_applied_edited_and_undone() {
|
||||
let (mut s, g) = striped();
|
||||
let list = s.execute("effect.list", &json!({})).unwrap();
|
||||
for (id, _) in EFFECTS {
|
||||
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} listed"));
|
||||
assert_eq!(e["raster"], json!(true), "{id}");
|
||||
assert!(e["params"].as_str().unwrap().contains("radius") || id == "blur.radial", "{id}");
|
||||
}
|
||||
let menus: Vec<Value> =
|
||||
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
|
||||
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
|
||||
// Junk parameters are stored as given and read clamped; the canvas still draws.
|
||||
for (id, _) in EFFECTS {
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
|
||||
.unwrap();
|
||||
}
|
||||
let _ = render(&s);
|
||||
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 3);
|
||||
assert_eq!(n.appearance.effects[0].params["method"], json!("spin"), "defaults fill the rest");
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"method": "zoom"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects[0].params["method"], json!("zoom"));
|
||||
for _ in 0..4 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
|
||||
for (id, params) in EFFECTS {
|
||||
let (mut s, g) = striped();
|
||||
let plain = render(&s);
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
let a = render(&s);
|
||||
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
|
||||
assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic");
|
||||
// The multithreaded pipeline draws the same.
|
||||
let mut mt = Renderer::new();
|
||||
mt.threads = 4;
|
||||
let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spin_blur_reaches_past_the_corners() {
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.radial", "ids": [g], "params": {"amount": 60, "quality": "good"}})).unwrap();
|
||||
let img = render(&s);
|
||||
// Turned corners sweep beyond the square's sides (centre 100, 100; corner 70.7 pt out).
|
||||
let p = img.pixel(100, 46);
|
||||
assert!(p[1] < 250, "the arc of the corners reaches above the square: {p:?}");
|
||||
assert!(img.pixel(100, 20)[1] > 250, "but not beyond the corners' circle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_and_svg_write_the_effected_object_as_an_image() {
|
||||
for (id, params) in EFFECTS {
|
||||
let (mut s, g) = striped();
|
||||
let params: Value = serde_json::from_str(params).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
// PDF: an image that reads back as the canvas shows it.
|
||||
let r = crate::cmd::rasterfx::export_pdf_with_report(&doc, &Default::default()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{id}: {:?}", r.warnings);
|
||||
let back = vectorcraft_pdf::import(&r.bytes).unwrap();
|
||||
let diff = mean_diff(&render(&s), &Renderer::new().render_region(&back, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true));
|
||||
assert!(diff < 0.03, "{id}: PDF reads back {:.2}% off", diff * 100.0);
|
||||
// SVG: the object becomes an image; a Gaussian Blur alone stays a live filter.
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains("<image") && !svg.contains("<filter"), "{id}: {svg}");
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(&doc).unwrap();
|
||||
assert!(matches!(flat.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)), "{id}");
|
||||
}
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [g]})).unwrap();
|
||||
assert!(crate::cmd::rasterfx::flatten_pixel_effects(&s.doc().unwrap().doc).is_none());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains("feGaussianBlur") && !svg.contains("<image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_makes_an_image() {
|
||||
let (mut s, g) = striped();
|
||||
s.execute("effect.apply", &json!({"effect": "sharpen.unsharpMask", "ids": [g]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g]})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.node(NodeId(g)).unwrap().kind, NodeKind::Image(_)));
|
||||
}
|
||||
|
|
@ -257,6 +257,9 @@ fn structured_junk() {
|
|||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||||
("effect.apply", json!({"effect": "distort.transform", "params": {"copies": 1e308}})),
|
||||
("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"blur": 1e308, "opacity": -1}})),
|
||||
("effect.apply", json!({"effect": "blur.radial", "params": {"amount": 1e308, "method": 5, "quality": "best"}})),
|
||||
("effect.apply", json!({"effect": "blur.smart", "params": {"radius": 1e308, "threshold": -1e308, "quality": null}})),
|
||||
("effect.apply", json!({"effect": "sharpen.unsharpMask", "params": {"amount": -1, "radius": "1e999", "threshold": 1e308}})),
|
||||
("effect.apply", json!({"effect": "no.such.effect"})),
|
||||
("effect.remove", json!({"index": 99})),
|
||||
("effect.setParams", json!({"index": 0, "params": null})),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@
|
|||
//! - Gaussian Blur: the object painted inside a Gaussian filter layer;
|
||||
//! - 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.
|
||||
//! to the shape and painted above it;
|
||||
//! - Photoshop-style filters (Radial Blur, Smart Blur, Unsharp Mask…): the content is rendered
|
||||
//! offscreen and its pixels run through the content-changing effects in stack order (those
|
||||
//! filters, Gaussian Blur and Feather, see [`effects::pixel`]), then drawn back
|
||||
//! ([`Renderer::pixel_content`]). The result is cached per object while it and the zoom don't
|
||||
//! change.
|
||||
//!
|
||||
//! 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
|
||||
|
|
@ -132,8 +137,12 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
|||
}
|
||||
}
|
||||
}
|
||||
let o = n.appearance.outset() + effects::outset(&n.appearance.effects);
|
||||
r.map(|r| r.inflate(o, o))
|
||||
let s = n.appearance.outset();
|
||||
r.map(|r| {
|
||||
let r = r.inflate(s, s);
|
||||
let o = effects::outset(&n.appearance.effects, r);
|
||||
r.inflate(o, o)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in
|
||||
|
|
@ -151,7 +160,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
|||
} else {
|
||||
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
|
||||
}?;
|
||||
let o = effects::outset(&n.appearance.effects);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
|
|
@ -161,7 +170,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
|||
// 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);
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
_ => n.visual_bounds(),
|
||||
|
|
@ -187,6 +196,56 @@ fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
|
|||
peniko::Color::new([r, g, b, 1.0])
|
||||
}
|
||||
|
||||
/// Most pixels the raster of [`Renderer::pixel_content`] holds (a 4K screen's): larger content is
|
||||
/// cropped to the view when its effects only look nearby, else rendered at a lower resolution.
|
||||
const PIXEL_BUDGET: f64 = 8.4e6;
|
||||
|
||||
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
|
||||
/// content itself), in stack order.
|
||||
fn content_changing(fx: &&RasterFx) -> bool {
|
||||
matches!(fx, RasterFx::Feather { .. } | RasterFx::GaussianBlur { .. } | RasterFx::Pixel(_))
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when
|
||||
/// anywhere in the object.
|
||||
fn looks_around(fx: &RasterFx) -> Option<f64> {
|
||||
match fx {
|
||||
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius),
|
||||
RasterFx::Pixel(p) => p.reach(),
|
||||
_ => Some(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of an object with Photoshop-style effects as filtered pixels, drawn at
|
||||
/// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]).
|
||||
pub(crate) struct PixelEntry {
|
||||
node: Node,
|
||||
linear: [u64; 4],
|
||||
anchor: vectorcraft_geom::Point,
|
||||
rel: (f64, f64),
|
||||
k: f64,
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// The four linear coefficients of `view` (zoom and rotation, not the pan), as cache keys.
|
||||
fn linear_key(view: Affine) -> [u64; 4] {
|
||||
let c = view.as_coeffs();
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
|
||||
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
|
||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||
ctx.set_transform(Affine::translate((x, y)) * Affine::scale(1.0 / k));
|
||||
// On whole pixels at full size the cheapest sampler is exact.
|
||||
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
|
||||
/// the view-space position of `anchor` (a document point).
|
||||
pub(crate) struct ShadowEntry {
|
||||
|
|
@ -199,73 +258,6 @@ pub(crate) struct ShadowEntry {
|
|||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// Box sizes approximating a Gaussian of `sigma` with three box blurs.
|
||||
fn gauss_boxes(sigma: f64) -> [usize; 3] {
|
||||
let n = 3.0;
|
||||
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
|
||||
let mut wl = w_ideal.floor() as i64;
|
||||
if wl % 2 == 0 {
|
||||
wl -= 1;
|
||||
}
|
||||
let wu = wl + 2;
|
||||
let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||||
let m = m_ideal.round() as i64;
|
||||
let r = |i: i64| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize;
|
||||
[r(0), r(1), r(2)]
|
||||
}
|
||||
|
||||
/// One horizontal box-blur pass of radius `r` over rows of `w` (running sum, edges as zero).
|
||||
fn box_blur_h(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize) {
|
||||
if r == 0 {
|
||||
dst.copy_from_slice(src);
|
||||
return;
|
||||
}
|
||||
let norm = 1.0 / (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
let row = &src[y * w..(y + 1) * w];
|
||||
let out = &mut dst[y * w..(y + 1) * w];
|
||||
let mut acc: f32 = row.iter().take(r + 1).sum();
|
||||
for x in 0..w {
|
||||
out[x] = acc * norm;
|
||||
if x + r + 1 < w {
|
||||
acc += row[x + r + 1];
|
||||
}
|
||||
if x >= r {
|
||||
acc -= row[x - r];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose(src: &[f32], dst: &mut [f32], w: usize, h: usize) {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
dst[x * h + y] = src[y * w + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian-blur an alpha plane in place (three box passes per axis).
|
||||
fn blur_alpha(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
|
||||
if sigma < 0.2 {
|
||||
return;
|
||||
}
|
||||
let boxes = gauss_boxes(sigma);
|
||||
let mut tmp = vec![0.0; a.len()];
|
||||
for r in boxes {
|
||||
box_blur_h(a, &mut tmp, w, h, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, w, h);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
for r in boxes {
|
||||
box_blur_h(a, &mut tmp, h, w, r);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
transpose(a, &mut tmp, h, w);
|
||||
std::mem::swap(a, &mut tmp);
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// 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
|
||||
|
|
@ -287,8 +279,7 @@ impl Renderer {
|
|||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
|
||||
_ => return false,
|
||||
};
|
||||
let c = f.view.as_coeffs();
|
||||
let linear = [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()];
|
||||
let linear = linear_key(f.view);
|
||||
let key = (n as *const Node as usize, i, f.ink);
|
||||
let stamp = self.stamp;
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n);
|
||||
|
|
@ -317,7 +308,7 @@ impl Renderer {
|
|||
off.render(&mut pm, &mut self.resources);
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
|
||||
blur_alpha(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
|
||||
|
|
@ -504,7 +495,7 @@ impl Renderer {
|
|||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
// The content itself (blurred / feathered).
|
||||
// The content itself (blurred / feathered / filtered).
|
||||
let blur: f64 = rfx
|
||||
.iter()
|
||||
.map(|fx| match fx {
|
||||
|
|
@ -512,7 +503,9 @@ impl Renderer {
|
|||
_ => 0.0,
|
||||
})
|
||||
.sum();
|
||||
if blur > 0.0 {
|
||||
if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
|
||||
self.pixel_content(ctx, f, content, rfx, paint);
|
||||
} else if blur > 0.0 {
|
||||
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
|
||||
let mut layers = 0;
|
||||
for fx in rfx {
|
||||
|
|
@ -580,6 +573,105 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather and the
|
||||
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
|
||||
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
|
||||
/// larger content is cropped to the view when every effect only looks nearby, else rendered
|
||||
/// at a lower resolution. A whole raster at full resolution is cached while the object and the
|
||||
/// zoom stay the same (panning only moves it).
|
||||
fn pixel_content(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
content: &Content,
|
||||
rfx: &[RasterFx],
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
|
||||
// Where the result can paint: each effect spreads what the ones before it painted.
|
||||
let reach = chain.iter().fold(content.reach, |r, fx| {
|
||||
let o = fx.outset(r);
|
||||
r.inflate(o, o)
|
||||
});
|
||||
let full = f.view.transform_rect_bbox(reach).inflate(2.0, 2.0);
|
||||
let (sw, sh) = (ctx.width() as f64, ctx.height() as f64);
|
||||
if !(full.area().is_finite() && full.x1 > 0.0 && full.y1 > 0.0 && full.x0 < sw && full.y0 < sh) {
|
||||
return;
|
||||
}
|
||||
let fits = full.area() <= PIXEL_BUDGET;
|
||||
// Device pixels from a pixel to the content deciding it (`None`: anywhere).
|
||||
let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px));
|
||||
let region = match margin {
|
||||
Some(m) if !fits => full.intersect(Rect::new(0.0, 0.0, sw, sh).inflate(m, m)),
|
||||
_ => full,
|
||||
};
|
||||
let (x0, y0) = (region.x0.floor(), region.y0.floor());
|
||||
let (rw, rh) = ((region.x1 - x0).max(1.0), (region.y1 - y0).max(1.0));
|
||||
let side = u16::MAX as f64;
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
|
||||
// The cached raster, when the whole content was rendered at full size at this zoom.
|
||||
let linear = linear_key(f.view);
|
||||
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
|
||||
let stamp = self.stamp;
|
||||
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node == *content.node) {
|
||||
e.stamp = stamp;
|
||||
let p = f.view * e.anchor;
|
||||
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
|
||||
draw_scaled(ctx, image, at, k);
|
||||
return;
|
||||
}
|
||||
let (wu, hu) = (w as u16, h as u16);
|
||||
let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view;
|
||||
let to_doc = view.inverse();
|
||||
let px = f.px / k;
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let mut off = f.offscreen_context(wu, hu);
|
||||
self.inside_layer(|r| paint(r, &mut off, &frame));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(wu, hu);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
let space = effects::PixelSpace { to_doc, px, center: content.reach.center() };
|
||||
let (wz, hz) = (wu as usize, hu as usize);
|
||||
for fx in chain {
|
||||
match fx {
|
||||
RasterFx::GaussianBlur { radius } => effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px),
|
||||
RasterFx::Feather { radius } => {
|
||||
effects::pixel::gaussian_rgba(pm.data_as_u8_slice_mut(), wz, hz, radius / 2.0 / px);
|
||||
if let Some((g, rule)) = content.outline {
|
||||
self.clip_pixels(&mut pm, &frame, g, rule);
|
||||
}
|
||||
}
|
||||
RasterFx::Pixel(p) => p.apply(pm.data_as_u8_slice_mut(), wz, hz, &space),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let image = std::sync::Arc::new(pm);
|
||||
if let Some(key) = key {
|
||||
let anchor = content.reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
self.pixel_fx.insert(key, PixelEntry { node: content.node.clone(), linear, anchor, rel, k, image: image.clone(), stamp });
|
||||
}
|
||||
draw_scaled(ctx, image, (x0, y0), k);
|
||||
}
|
||||
|
||||
/// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip).
|
||||
fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) {
|
||||
let mut m = frame.offscreen_context(pm.width(), pm.height());
|
||||
m.set_transform(frame.view);
|
||||
m.set_fill_rule(fill_rule(rule));
|
||||
m.set_paint(peniko::Color::BLACK);
|
||||
m.fill_path(g);
|
||||
m.flush();
|
||||
let mut mask = vello_cpu::Pixmap::new(pm.width(), pm.height());
|
||||
m.render(&mut mask, &mut self.resources);
|
||||
for (p, c) in pm.data_mut().iter_mut().zip(mask.data()) {
|
||||
let keep = |v: u8| ((v as u32 * c.a as u32 + 127) / 255) as u8;
|
||||
*p = vello_cpu::color::PremulRgba8 { r: keep(p.r), g: keep(p.g), b: keep(p.b), a: keep(p.a) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw`
|
||||
/// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context
|
||||
/// the layer is drawn into an offscreen single-threaded context covering `reach` (document
|
||||
|
|
@ -885,4 +977,38 @@ mod tests {
|
|||
assert!(worst <= 4, "{name}: max channel difference {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_draw_and_follow_a_pan_from_the_cache() {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![("blur.radial", json!({"amount": 40, "quality": "draft"}))]);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert!(lum(a.pixel(31, 31)) > 60, "the spin softens the corners: {:?}", a.pixel(31, 31));
|
||||
assert_eq!(lum(a.pixel(50, 50)), 0, "the centre stays");
|
||||
assert!(lum(a.pixel(50, 28)) < WHITE, "turned corners reach past the sides");
|
||||
// Panned: the cached raster moves with the view.
|
||||
let b = r.render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts);
|
||||
for (x, y) in [(31, 31), (50, 28), (69, 50), (40, 66)] {
|
||||
assert_eq!(a.pixel(x, y), b.pixel(x + 10, y + 3), "({x}, {y})");
|
||||
}
|
||||
// A fresh renderer draws the panned view the same.
|
||||
let c = Renderer::new().render(&d, 100, 100, Affine::translate((10.0, 3.0)), &opts);
|
||||
assert_eq!(b.pixels, c.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_on_content_larger_than_the_budget() {
|
||||
// Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view.
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let view = Affine::translate((100.0, 100.0)) * Affine::scale(200.0) * Affine::translate((-70.0, -50.0));
|
||||
for (id, p) in [("sharpen.unsharpMask", json!({"amount": 200})), ("blur.smart", json!({})), ("blur.radial", json!({"quality": "draft"}))] {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::rgb(0.2, 0.4, 0.8), vec![(id, p)]);
|
||||
let img = Renderer::new().render(&d, 200, 200, view, &opts);
|
||||
// The view shows the square's right side half way down: inside left, outside right.
|
||||
assert!(lum(img.pixel(40, 100)) < 600, "{id}: inside {:?}", img.pixel(40, 100));
|
||||
// (Smart Blur's wide samples read a blurred copy: a faint fringe under its threshold.)
|
||||
assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,9 @@ pub struct Renderer {
|
|||
live: live::LiveCache,
|
||||
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
|
||||
shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>,
|
||||
/// Objects' content run through Photoshop-style effects, per object and content slot (see
|
||||
/// `fx`): kept only while drawn.
|
||||
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
|
||||
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
/// Address of the knockout-group element being drawn as its knockout shape: at full object
|
||||
|
|
@ -421,6 +424,7 @@ impl Renderer {
|
|||
stats: FrameStats::default(),
|
||||
brushes: Default::default(),
|
||||
shadows: PtrMap::default(),
|
||||
pixel_fx: PtrMap::default(),
|
||||
live: live::LiveCache::default(),
|
||||
knockout: false,
|
||||
shape_of: 0,
|
||||
|
|
@ -491,6 +495,8 @@ impl Renderer {
|
|||
if self.shadows.len() > 256 {
|
||||
self.shadows.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
// Filtered rasters are large: only those of the last frame stay.
|
||||
self.pixel_fx.retain(|_, e| e.stamp == g);
|
||||
if !inks {
|
||||
proof::post(&mut pixels, opts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -584,6 +584,7 @@ impl Rules<'_> {
|
|||
}
|
||||
RasterFx::GaussianBlur { radius } => filter = Some(format!("blur({})", self.len(radius / 2.0))),
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => art.unsupported("inner glows or feathering"),
|
||||
RasterFx::Pixel(_) => art.unsupported("Photoshop-style raster effects"),
|
||||
}
|
||||
}
|
||||
if !shadows.is_empty() {
|
||||
|
|
|
|||
|
|
@ -1486,7 +1486,9 @@ impl Writer<'_> {
|
|||
}
|
||||
|
||||
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
|
||||
/// per effect, the first effect innermost (the reference app's stacking order).
|
||||
/// per effect, the first effect innermost (the reference app's stacking order). Photoshop-style
|
||||
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
|
||||
/// such objects into images first).
|
||||
fn filtered(&mut self, n: &Node) {
|
||||
let mut inner = n.clone();
|
||||
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
|
||||
|
|
@ -1506,9 +1508,15 @@ impl Writer<'_> {
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
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 bounds = n.visual_bounds();
|
||||
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
|
||||
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
|
||||
let mut opened = 0;
|
||||
for f in fx.iter().rev() {
|
||||
if matches!(f, RasterFx::Pixel(_)) {
|
||||
self.warn("SVG has no Photoshop-style raster effects (Radial Blur, Smart Blur, Unsharp Mask…): they are left out");
|
||||
continue;
|
||||
}
|
||||
let fid = self.fresh_id("filter");
|
||||
let region_attr = match region {
|
||||
Some(r) => format!(
|
||||
|
|
@ -1557,6 +1565,7 @@ impl Writer<'_> {
|
|||
sd(*radius)
|
||||
),
|
||||
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
|
||||
RasterFx::Pixel(_) => continue,
|
||||
};
|
||||
// Filters composite normally: a shadow's or glow's other blend mode is recorded for import.
|
||||
let mode = match f {
|
||||
|
|
@ -1570,8 +1579,9 @@ impl Writer<'_> {
|
|||
self.def(1, &format!("<filter id=\"{fid}\"{region_attr}{mode} color-interpolation-filters=\"sRGB\">{body}</filter>"));
|
||||
self.line(&format!("<g filter=\"url(#{fid})\">"));
|
||||
self.depth += 1;
|
||||
opened += 1;
|
||||
}
|
||||
fx.len()
|
||||
opened
|
||||
}
|
||||
|
||||
fn close_filters(&mut self, opened: usize) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
|||
body: |app, ui, d| {
|
||||
let command = d.str("__command");
|
||||
let lengths = lengths(&command);
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), app.session.general_unit());
|
||||
form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &|_| 0, app.session.general_unit());
|
||||
false
|
||||
},
|
||||
confirm,
|
||||
|
|
|
|||
|
|
@ -131,7 +131,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
|
||||
None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|_| None, app.session.general_unit()),
|
||||
None => {
|
||||
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), &|k| choices(&id, k), &|k| doc_rank(doc, k), unit)
|
||||
}
|
||||
};
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
|
|
@ -148,6 +152,26 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
/// The parameters of the effect dialogs that pick one of some values, as (label, value).
|
||||
fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
||||
match (effect, key) {
|
||||
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
|
||||
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
|
||||
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where parameter `key` comes in the effect's parameter documentation `doc` (`{radius: …,
|
||||
/// threshold: …}`), which lists them in its dialog's order; the end when it isn't there.
|
||||
pub(super) fn doc_rank(doc: &str, key: &str) -> usize {
|
||||
let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' ');
|
||||
doc.match_indices(key)
|
||||
.map(|(at, _)| at)
|
||||
.find(|at| starts(*at) && doc.get(at + key.len()..).is_some_and(|rest| rest.starts_with(':') || rest.starts_with("?:")))
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1).
|
||||
type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32);
|
||||
|
||||
|
|
|
|||
|
|
@ -197,19 +197,22 @@ pub(super) type Choices = &'static [(&'static str, &'static str)];
|
|||
|
||||
/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the
|
||||
/// parameters `is_length` names are distances shown and typed in `unit`, those `choices` gives
|
||||
/// choices for are dropdowns. Returns true when a value changed.
|
||||
/// choices for are dropdowns. Fields come in `rank` order (by name among equals). Returns true when
|
||||
/// a value changed.
|
||||
pub(super) fn param_fields(
|
||||
ui: &mut egui::Ui,
|
||||
d: &mut Dialog,
|
||||
is_length: &dyn Fn(&str) -> bool,
|
||||
choices: &dyn Fn(&str) -> Option<Choices>,
|
||||
rank: &dyn Fn(&str) -> usize,
|
||||
unit: Unit,
|
||||
) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let keys: Vec<(String, Value)> =
|
||||
let mut keys: Vec<(String, Value)> =
|
||||
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
keys.sort_by_key(|(k, _)| rank(k));
|
||||
for (k, v) in keys {
|
||||
ui.label(egui::RichText::new(humanized(&k)).color(t.text));
|
||||
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,30 @@ fn effect_preview_is_kept_as_one_undo_step() {
|
|||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_filter_dialogs_preview_and_apply() {
|
||||
for (effect, key, value) in [("blur.radial", "method", "zoom"), ("blur.smart", "quality", "high"), ("sharpen.unsharpMask", "amount", "120")] {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect": effect})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let v: serde_json::Value = value.parse::<f64>().map_or_else(|_| json!(value), |n| json!(n));
|
||||
d.fields.insert(key.into(), v.clone());
|
||||
frame(&mut app, Default::default());
|
||||
assert!(app.session.in_interaction(), "{effect}: previews");
|
||||
confirm(&mut app).unwrap();
|
||||
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.len(), 1, "{effect}");
|
||||
assert_eq!(n.appearance.effects[0].params[key], v, "{effect}");
|
||||
}
|
||||
// Fields follow the effect's documented order (its dialog's), not the alphabet.
|
||||
let doc = vectorcraft_effects::effect_info("blur.smart").unwrap().params;
|
||||
let ranks: Vec<usize> = ["radius", "threshold", "quality"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
|
||||
assert!(ranks.windows(2).all(|w| w[0] < w[1]), "{ranks:?}");
|
||||
assert_eq!(super::effect::doc_rank(doc, "nope"), usize::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_dialog_applies_to_its_appearance_item() {
|
||||
let mut app = app();
|
||||
|
|
|
|||
|
|
@ -643,3 +643,9 @@
|
|||
Constrain Omezit
|
||||
Free Transform Volná transformace
|
||||
Perspective Distort Perspektivní deformace
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Radiální rozostření…
|
||||
Smart Blur… Chytré rozostření…
|
||||
Sharpen Zostření
|
||||
Unsharp Mask… Maska rozostření…
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -11,6 +11,23 @@
|
|||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Desenfoque radial…
|
||||
Smart Blur… Desenfoque inteligente…
|
||||
Sharpen Enfocar
|
||||
Unsharp Mask… Máscara de enfoque…
|
||||
Radial Blur Desenfoque radial
|
||||
Smart Blur Desenfoque inteligente
|
||||
Unsharp Mask Máscara de enfoque
|
||||
Gaussian Blur Desenfoque gaussiano
|
||||
Method Método
|
||||
Quality Calidad
|
||||
Radius Radio
|
||||
Spin Giro
|
||||
Draft Borrador
|
||||
Good Buena
|
||||
Best Óptima
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -12,6 +12,23 @@
|
|||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Flou radial…
|
||||
Smart Blur… Flou intelligent…
|
||||
Sharpen Netteté
|
||||
Unsharp Mask… Masque de netteté…
|
||||
Radial Blur Flou radial
|
||||
Smart Blur Flou intelligent
|
||||
Unsharp Mask Masque de netteté
|
||||
Gaussian Blur Flou gaussien
|
||||
Method Méthode
|
||||
Quality Qualité
|
||||
Radius Rayon
|
||||
Spin Rotation
|
||||
Draft Brouillon
|
||||
Good Bonne
|
||||
Best Optimale
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -11,6 +11,23 @@
|
|||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Sfocatura radiale…
|
||||
Smart Blur… Sfocatura intelligente…
|
||||
Sharpen Nitidezza
|
||||
Unsharp Mask… Maschera di contrasto…
|
||||
Radial Blur Sfocatura radiale
|
||||
Smart Blur Sfocatura intelligente
|
||||
Unsharp Mask Maschera di contrasto
|
||||
Gaussian Blur Sfocatura gaussiana
|
||||
Method Metodo
|
||||
Quality Qualità
|
||||
Radius Raggio
|
||||
Spin Rotazione
|
||||
Draft Bozza
|
||||
Good Buona
|
||||
Best Ottima
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3105,3 +3105,20 @@ theme Appearance 外観
|
|||
Constrain 制限
|
||||
Free Transform 自由変形
|
||||
Perspective Distort 遠近変形
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… ぼかし (放射状)…
|
||||
Smart Blur… ぼかし (詳細)…
|
||||
Sharpen シャープ
|
||||
Unsharp Mask… アンシャープマスク…
|
||||
Radial Blur ぼかし (放射状)
|
||||
Smart Blur ぼかし (詳細)
|
||||
Unsharp Mask アンシャープマスク
|
||||
Gaussian Blur ぼかし (ガウス)
|
||||
Method 方法
|
||||
Quality 画質
|
||||
Radius 半径
|
||||
Spin 回転
|
||||
Draft ドラフト
|
||||
Good 標準
|
||||
Best 高品質
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2676,3 +2676,9 @@
|
|||
Constrain Restringir
|
||||
Free Transform Transformação Livre
|
||||
Perspective Distort Distorção de Perspectiva
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Desfoque Radial…
|
||||
Smart Blur… Desfoque Inteligente…
|
||||
Sharpen Nitidez
|
||||
Unsharp Mask… Máscara de Nitidez…
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -9,6 +9,23 @@
|
|||
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… Радиальное размытие…
|
||||
Smart Blur… Умное размытие…
|
||||
Sharpen Резкость
|
||||
Unsharp Mask… Нерезкая маска…
|
||||
Radial Blur Радиальное размытие
|
||||
Smart Blur Умное размытие
|
||||
Unsharp Mask Нерезкая маска
|
||||
Gaussian Blur Размытие по Гауссу
|
||||
Method Метод
|
||||
Quality Качество
|
||||
Radius Радиус
|
||||
Spin Вращение
|
||||
Draft Черновое
|
||||
Good Хорошее
|
||||
Best Наилучшее
|
||||
|
||||
# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests).
|
||||
# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the
|
||||
# leading/trailing spaces of fragments exactly as they are.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3357,3 +3357,20 @@ axis Both 两者
|
|||
Select Shaper Face 选择 Shaper 区域
|
||||
Constrain 约束
|
||||
Perspective Distort 透视扭曲
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… 径向模糊…
|
||||
Smart Blur… 智能模糊…
|
||||
Sharpen 锐化
|
||||
Unsharp Mask… 反锐化遮罩…
|
||||
Radial Blur 径向模糊
|
||||
Smart Blur 智能模糊
|
||||
Unsharp Mask 反锐化遮罩
|
||||
Gaussian Blur 高斯模糊
|
||||
Method 方法
|
||||
Quality 质量
|
||||
Radius 半径
|
||||
Spin 旋转
|
||||
Draft 草稿
|
||||
Good 良好
|
||||
Best 最佳
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3135,3 +3135,20 @@ axis Both 兩者
|
|||
Constrain 強制
|
||||
Free Transform 任意變形
|
||||
Perspective Distort 透視扭曲
|
||||
|
||||
# Photoshop-style raster effects (Effect menu, their dialogs).
|
||||
Radial Blur… 放射狀模糊…
|
||||
Smart Blur… 智慧型模糊…
|
||||
Sharpen 銳利化
|
||||
Unsharp Mask… 反銳利化遮罩…
|
||||
Radial Blur 放射狀模糊
|
||||
Smart Blur 智慧型模糊
|
||||
Unsharp Mask 反銳利化遮罩
|
||||
Gaussian Blur 高斯模糊
|
||||
Method 方法
|
||||
Quality 品質
|
||||
Radius 半徑
|
||||
Spin 旋轉
|
||||
Draft 草稿
|
||||
Good 良好
|
||||
Best 最佳
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3223,6 +3223,10 @@ fn font_items() -> Vec<Item> {
|
|||
items.1.clone()
|
||||
}
|
||||
|
||||
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
|
||||
/// vector effects): (submenu, the catalogue's menu path of its effects).
|
||||
const RASTER_MENUS: [(&str, &[&str]); 2] = [("Blur", &["Effect", "Blur"]), ("Sharpen", &["Effect", "Sharpen"])];
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
|
||||
fn effect_menu() -> Vec<Item> {
|
||||
let cat = vectorcraft_effects::effect_catalog();
|
||||
|
|
@ -3246,33 +3250,25 @@ fn effect_menu() -> Vec<Item> {
|
|||
"Stylize",
|
||||
"SVG Filters",
|
||||
"Warp",
|
||||
"Blur",
|
||||
];
|
||||
let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label);
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
continue;
|
||||
}
|
||||
let items: Vec<Item> = cat
|
||||
.iter()
|
||||
.filter(|e| e.menu.last().copied() == Some(sub_name))
|
||||
// The effects whose catalogue menu path is `path`.
|
||||
let items_at = |path: &[&str]| -> Vec<Item> {
|
||||
cat.iter()
|
||||
.filter(|e| e.menu == path)
|
||||
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
|
||||
// No options (Effect → Pathfinder): apply directly, like Illustrator.
|
||||
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
|
||||
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
|
||||
})
|
||||
.collect();
|
||||
if sub_name == "Blur" {
|
||||
// Live-effect plug-ins close the vector effects.
|
||||
out.extend(crate::dialogs::plugin::effect_menu());
|
||||
if !items.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
.collect()
|
||||
};
|
||||
for sub_name in order {
|
||||
if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) {
|
||||
out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })));
|
||||
continue;
|
||||
}
|
||||
let items = items_at(&["Effect", sub_name]);
|
||||
if items.is_empty() {
|
||||
let placeholder = match sub_name {
|
||||
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
|
||||
|
|
@ -3284,8 +3280,25 @@ fn effect_menu() -> Vec<Item> {
|
|||
out.push(sub(sub_name, items));
|
||||
}
|
||||
}
|
||||
// Live-effect plug-ins close the vector effects.
|
||||
out.extend(crate::dialogs::plugin::effect_menu());
|
||||
let raster: Vec<Item> = RASTER_MENUS
|
||||
.iter()
|
||||
.filter_map(|(name, path)| {
|
||||
let items = items_at(path);
|
||||
(!items.is_empty()).then(|| sub(name, items))
|
||||
})
|
||||
.collect();
|
||||
if !raster.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.extend(raster);
|
||||
}
|
||||
// Anything not placed above (future effects) still shows up.
|
||||
for e in cat.iter().filter(|e| !top_level(e) && !e.menu.last().is_some_and(|m| order.contains(m))) {
|
||||
let placed = |e: &vectorcraft_effects::EffectInfo| {
|
||||
top_level(e) || order.iter().any(|s| e.menu == ["Effect", *s]) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path)
|
||||
};
|
||||
for e in cat.iter().filter(|e| !placed(e)) {
|
||||
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
|
||||
}
|
||||
out
|
||||
|
|
|
|||
Loading…
Reference in New Issue