Effects: Radial Blur turns about a Blur Center set in its dialog

Effect > Blur > Radial Blur... has a Blur Center box. A press or a drag
in the box sets the point the spin or zoom starts from, the arrow keys
move it 1% (10% with Shift), and Horizontal and Vertical fields take the
same position in percent. Screen readers get the box with both values.

- blur.radial takes centerX and centerY, from 0 to 1 across and down the
  object's bounds (0.5 each by default), so the center moves and scales
  with the object. An effect saved without them draws as before.
- effect.apply and effect.setParams refuse a value outside 0 to 1, or
  one that is not a number; a saved one draws at the nearest edge.
- The raster's margin is measured from the center, and the center stays
  in the object's own bounds when a Rasterize splits the stack.
- Params text, docs/mcp.md, the evidence line in
  docs/target-app-parity.md and a Blur Center row in every catalog.
This commit is contained in:
dotCooCoo
2026-11-14 13:39:57 -08:00
parent 0adbdce6cb
commit 5831b1d23a
47 changed files with 454 additions and 33 deletions
+2 -2
View File
@@ -468,8 +468,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"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"}),
"{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), centerX: 0..1 (0.5; the Blur Center across the object's bounds, 0 their left edge), centerY: 0..1 (0.5; the Blur Center down the object's bounds, 0 their top edge)} blurs around the Blur Center, which moves and scales with the object. A centerX or centerY outside 0..1 is refused",
json!({"amount": 10.0, "method": "spin", "quality": "good", "centerX": 0.5, "centerY": 0.5}),
),
r(
"blur.smart",
+18 -13
View File
@@ -30,7 +30,7 @@ mod texture;
mod video;
use serde_json::Value;
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::util::{flag, num, text};
@@ -158,9 +158,10 @@ pub enum PixelFx {
/// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as
/// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// 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 › Radial Blur around its Blur Center, `center` (0..1 across and down the object's
/// bounds; (0.5, 0.5) 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, center: [f64; 2] },
/// 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.
@@ -314,9 +315,12 @@ pub struct PixelSpace {
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, and where the
/// Pixelate filters' patterns are anchored (so they move with the object).
/// The object's centre (document coordinates): where Radial Blur blurs around by default, and
/// where the Pixelate filters' patterns are anchored (so they move with the object).
pub center: Point,
/// The width and height of the object's bounds (document units), centred on `center`: Radial
/// Blur's Blur Center lies at a share of them.
pub size: Vec2,
/// What the colour channels hold.
pub channels: Channels,
/// The height of a line of the document's raster grid (document units: 72 / Document Raster
@@ -421,6 +425,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
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),
center: [num(p, "centerX", 0.5).clamp(0.0, 1.0), num(p, "centerY", 0.5).clamp(0.0, 1.0)],
},
"blur.smart" => PixelFx::SmartBlur {
radius: num(p, "radius", 3.0).clamp(0.1, 100.0),
@@ -608,13 +613,13 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
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),
// A rotated corner reaches the circle about the Blur Center through the farthest corner.
PixelFx::RadialBlur { zoom: false, center, .. } => {
let (far, near) = blur::center_reach(bounds, center);
(far - near).max(0.0)
}
PixelFx::RadialBlur { amount, zoom: true, center, .. } => blur::center_reach(bounds, center).0 * (blur::zoom_extent(amount).exp() - 1.0),
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::AccentedEdges { .. } => 0.0,
// Strokes reach out of the object as far as they reach into it.
@@ -786,7 +791,7 @@ impl PixelFx {
PixelFx::RoughPastels { length, detail, ref texture, scaling, relief, light, invert } => {
artistic::rough_pastels(px, w, h, space, length, detail, texture, scaling, relief, light, invert)
}
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::RadialBlur { amount, zoom, passes, center } => blur::radial(px, w, h, space, amount, zoom, passes, center),
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
blur::smart(px, w, h, to_px(radius), threshold, samples)
}
+25 -7
View File
@@ -1,6 +1,6 @@
//! Blur › Radial Blur and Smart Blur (Normal, Edge Only and Overlay Edge).
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::{PixelSpace, Px16, box_blur, pick, to8, to16};
@@ -29,13 +29,31 @@ fn bilinear(buf: &[Px16], w: usize, h: usize, x: f64, y: f64) -> [f32; 4] {
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) {
/// How far Radial Blur's Blur Center `center` (0..1 across and down `bounds`) lies from the
/// farthest corner of `bounds`, and from its nearest side.
pub(super) fn center_reach(bounds: Rect, center: [f64; 2]) -> (f64, f64) {
// The share of the width (height) on the far side of the center, doubled: 1 at the middle.
let far = |f: f64| 1.0 + (2.0 * f - 1.0).abs();
let (w, h) = (bounds.width(), bounds.height());
let near = ((2.0 - far(center[0])) * w).min((2.0 - far(center[1])) * h);
(0.5 * (far(center[0]) * w).hypot(far(center[1]) * h), 0.5 * near)
}
/// Where Radial Blur's Blur Center `center` (0..1 across and down the object's bounds) lies in the
/// document: the object's centre at (0.5, 0.5).
pub(super) fn blur_center(space: &PixelSpace, center: [f64; 2]) -> Point {
let shift = Vec2::new((center[0] - 0.5) * space.size.x, (center[1] - 0.5) * space.size.y);
if shift.x.is_finite() && shift.y.is_finite() { space.center + shift } else { space.center }
}
/// Radial Blur: the average of the content turned (spin) or scaled (zoom) about the Blur Center
/// (`center`, [`blur_center`]) 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.
#[allow(clippy::too_many_arguments)] // the effect's four options
pub(super) fn radial(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, amount: f64, zoom: bool, passes: u32, center: [f64; 2]) {
// 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);
let c = space.to_doc.inverse() * blur_center(space, center) - Vec2::new(0.5, 0.5);
if !(c.x.is_finite() && c.y.is_finite()) {
return;
}
+58 -5
View File
@@ -12,6 +12,7 @@ fn space(w: usize, h: usize) -> PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
size: vectorcraft_geom::Vec2::new(w, h),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
@@ -479,12 +480,16 @@ fn film_grain_leaves_invalid_coordinate_spaces_alone() {
#[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!({})), PixelFx::RadialBlur { amount: 10.0, zoom: false, passes: 6, center: [0.5, 0.5] });
assert_eq!(
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best"})),
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8 }
fx("blur.radial", json!({"amount": 1e308, "method": "ZOOM", "quality": "best", "centerX": 0.25, "centerY": 1})),
PixelFx::RadialBlur { amount: 100.0, zoom: true, passes: 8, center: [0.25, 1.0] }
);
// A saved center outside the bounds (the commands refuse one) draws at the nearest edge.
assert_eq!(
fx("blur.radial", json!({"amount": -5, "quality": "nonsense", "centerX": -3, "centerY": 7})),
PixelFx::RadialBlur { amount: 1.0, zoom: false, passes: 6, center: [0.0, 1.0] }
);
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, mode: SmartBlurMode::Normal });
assert_eq!(
fx("blur.smart", json!({"radius": "1e999", "threshold": -1, "quality": "low", "blurMode": "EDGEONLY"})),
@@ -519,6 +524,11 @@ fn outsets_follow_the_object() {
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);
// About a corner the spin reaches the circle through the opposite corner, the whole diagonal out.
let corner = fx("blur.radial", json!({"centerX": 0, "centerY": 0}));
assert!((corner.outset(b) - 100f64.hypot(50.0)).abs() < 1e-9, "{}", corner.outset(b));
let zoom = |center: f64| fx("blur.radial", json!({"method": "zoom", "amount": 100, "centerX": center, "centerY": center})).outset(b);
assert!((zoom(1.0) - 2.0 * zoom(0.5)).abs() < 1e-9, "a zoom about a corner reaches twice as far");
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(1.0), Some(4.0));
// The edge modes also read the copy blurred over a wide radius's samples.
@@ -633,6 +643,40 @@ fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
assert_eq!(at(&z, w, 32, 16)[3], 0, "not across it");
}
/// The Blur Center moves the point the blur turns about: a dot on it stays sharp, a dot on the
/// object's centre then spreads, and the center follows the object when it moves or is scaled.
#[test]
fn radial_blur_turns_about_its_blur_center() {
let (w, h) = (41, 41);
// A dot on pixel (10, 30), whose centre (10.5, 30.5) is the Blur Center below, and one on the
// object's centre.
let src = image(w, h, |x, y| if (x == 10 && y == 30) || (x == 20 && y == 20) { grey(255) } else { [0, 0, 0, 0] });
let blur = |p: serde_json::Value, s: &PixelSpace, src: &[u8]| {
let mut d = src.to_vec();
fx("blur.radial", p).apply(&mut d, w, h, s);
d
};
let off = json!({"amount": 60, "quality": "best", "centerX": 10.5 / 41.0, "centerY": 30.5 / 41.0});
let d = blur(off.clone(), &space(w, h), &src);
assert!(at(&d, w, 10, 30)[3] > 100, "the dot on the Blur Center barely moves: {:?}", at(&d, w, 10, 30));
assert!(at(&d, w, 20, 20)[3] < 100, "the dot on the object's centre spreads: {:?}", at(&d, w, 20, 20));
// The default center is the object's centre, the same as no center at all.
assert_eq!(blur(json!({"amount": 60, "centerX": 0.5, "centerY": 0.5}), &space(w, h), &src), blur(json!({"amount": 60}), &space(w, h), &src));
// Moved 7 pt right and 3 pt down, the object draws the same pixels.
let moved =
PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: space(w, h).center + vectorcraft_geom::Vec2::new(7.0, 3.0), ..space(w, h) };
assert_eq!(blur(off.clone(), &moved, &src), d, "the center moves with the object");
// Drawn at twice the size (two points a pixel), the center lies on the same pixels.
let big = PixelSpace {
to_doc: Affine::scale(2.0),
px: 2.0,
center: Point::new(41.0, 41.0),
size: vectorcraft_geom::Vec2::new(82.0, 82.0),
..space(w, h)
};
assert_eq!(blur(off, &big, &src), d, "the center scales with the object");
}
#[test]
fn smart_blur_smooths_noise_and_keeps_edges() {
let (w, h) = (40, 20);
@@ -862,6 +906,7 @@ fn lengths_are_document_units() {
to_doc: Affine::scale(1.0 / scale as f64),
px: 1.0 / scale as f64,
center: Point::ZERO,
size: vectorcraft_geom::Vec2::ZERO,
channels: Channels::Rgb,
line: 1.0,
extent: 0.0,
@@ -916,7 +961,15 @@ fn linear_light_blur_mixes_light_not_values() {
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), channels: Channels::Rgb, line: 1.0, extent: 0.0 };
let s = PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(1000.0, 1000.0),
size: vectorcraft_geom::Vec2::new(2000.0, 2000.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.0,
};
let cases = [
("blur.radial", json!({"quality": "draft"})),
("blur.radial", json!({"quality": "good"})),
+1
View File
@@ -14,6 +14,7 @@ fn space(w: usize, h: usize) -> PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
size: vectorcraft_geom::Vec2::new(w, h),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
+1
View File
@@ -14,6 +14,7 @@ fn space(w: usize, h: usize) -> PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
size: vectorcraft_geom::Vec2::new(w, h),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
+9 -1
View File
@@ -10,7 +10,15 @@ use crate::{PixelFx, RasterFx, raster_effects};
/// resolution whose pixels are `line` points wide.
fn space(w: usize, h: usize, px: f64, line: f64) -> PixelSpace {
let (wd, hd) = (w as f64 * px, h as f64 * px);
PixelSpace { to_doc: Affine::scale(px), px, center: Point::new(wd / 2.0, hd / 2.0), channels: Channels::Rgb, line, extent: 0.5 * wd.hypot(hd) }
PixelSpace {
to_doc: Affine::scale(px),
px,
center: Point::new(wd / 2.0, hd / 2.0),
size: vectorcraft_geom::Vec2::new(wd, hd),
channels: Channels::Rgb,
line,
extent: 0.5 * wd.hypot(hd),
}
}
/// `w` × `h` premultiplied pixels: `f(x, y)` each.
+1
View File
@@ -1850,6 +1850,7 @@ fn rasterize_compose_leaves_the_raster_alone_when_the_sizes_do_not_fit() {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(1.0, 1.0),
size: vectorcraft_geom::Vec2::new(2.0, 2.0),
channels: pixel::Channels::Rgb,
line: 1.0,
extent: std::f64::consts::SQRT_2,
+2
View File
@@ -427,6 +427,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
super::params::check_effect_color(C, id, &params)?;
super::params::check_effect_channel(C, id, &params)?;
super::params::check_effect_center(C, id, &params)?;
// Crop Marks take their style from the preferences when applied.
if id == effects::CROP_MARKS && params.get("style").is_none() {
params["style"] = json!(s.crop_mark_style().id());
@@ -740,6 +741,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
for effect in &edited {
super::params::check_effect_color(C, effect, &params)?;
super::params::check_effect_channel(C, effect, &params)?;
super::params::check_effect_center(C, effect, &params)?;
}
if edited.iter().any(|e| texture_load_effect(e)) {
load_texture(&mut params, C)?;
+66
View File
@@ -1016,3 +1016,69 @@ fn a_3d_shadow_is_one_undo_step_saved_exported_and_expanded() {
});
assert_eq!(shadows, 1);
}
/// Radial Blur's Blur Center: stored as given, drawn about that point of the object's bounds, and
/// a value outside 0 to 1 (or not a number) is refused with nothing changed and no undo step.
#[test]
fn radial_blur_center_is_stored_drawn_and_checked() {
let (mut s, id) = session_with_rect();
let draw = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default()).pixels
};
s.execute("effect.apply", &json!({"effect": "blur.radial", "params": {"amount": 40}})).unwrap();
assert_eq!(node(&s, id).appearance.effects[0].params["centerX"], json!(0.5), "the default center is stored");
let middle = draw(&s);
s.execute("effect.setParams", &json!({"index": 0, "params": {"centerX": 0.5, "centerY": 0.5}})).unwrap();
assert_eq!(draw(&s), middle, "the default center draws as before");
s.execute("effect.setParams", &json!({"index": 0, "params": {"centerX": 0, "centerY": 1}})).unwrap();
let params = node(&s, id).appearance.effects[0].params.clone();
assert_eq!((params["centerX"].clone(), params["centerY"].clone()), (json!(0), json!(1)));
let corner = draw(&s);
assert_ne!(corner, middle, "the blur turns about the bottom-left corner");
let undo = s.doc().unwrap().history.undo.len();
for (cmd, params) in [
("effect.setParams", json!({"index": 0, "params": {"centerX": 1.5}})),
("effect.setParams", json!({"index": 0, "params": {"centerY": -0.01}})),
("effect.setParams", json!({"index": 0, "params": {"centerX": "0.5"}})),
("effect.apply", json!({"effect": "blur.radial", "params": {"centerY": 2}})),
] {
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains("must be a number"), "{cmd} {params}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {params}");
assert_eq!(node(&s, id).appearance.effects.len(), 1, "{cmd} {params}");
assert_eq!(draw(&s), corner, "{cmd} {params}");
}
// A replayed journal is checked too: the refusal is the command's own.
let e = s.replay("effect.setParams", &json!({"index": 0, "params": {"centerX": 7}})).unwrap_err().to_string();
assert!(e.contains("`centerX` must be a number from 0 to 1"), "{e}");
// Moving the object moves the blur with it.
s.execute("object.move", &json!({"ids": [id.0], "dx": 40, "dy": 20})).unwrap();
let moved = draw(&s);
let at = |px: &[u8], x: usize, y: usize| px[(y * 300 + x) * 4..][..4].to_vec();
for (x, y) in [(95, 150), (150, 205), (205, 95), (120, 120)] {
assert_eq!(at(&moved, x + 40, y + 20), at(&corner, x, y), "({x}, {y})");
}
}
/// A Rasterize with transparent room around the art, before another Rasterize, splits the stack,
/// and the part after the split paints around content the room spreads. The Blur Center still
/// lies in the object's own bounds, so the blur draws as it does without the room.
#[test]
fn radial_blur_center_stays_in_the_object_when_the_stack_splits() {
let draw = |room: bool| {
let (mut s, _) = session_with_rect();
let mut stack = vec![("rasterize", json!({})), ("blur.radial", json!({"amount": 60, "centerX": 0, "centerY": 0}))];
if room {
stack.insert(0, ("rasterize", json!({"addAround": 40})));
}
for (effect, params) in stack {
s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap();
}
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default()).pixels
};
let (plain, split) = (draw(false), draw(true));
let alpha = |px: &[u8]| px.iter().skip(3).step_by(4).map(|a| u64::from(*a)).sum::<u64>();
assert!(alpha(&plain) > 0, "the blurred art draws");
let differ = (0..300 * 300).filter(|i| plain[i * 4 + 3].abs_diff(split[i * 4 + 3]) > 8).count();
assert!(differ < 50, "{differ} pixels' coverage moved with the room around the art");
}
+18 -1
View File
@@ -382,7 +382,8 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
return Err(bad(id, format!("`version` must be 0 or {newest}")));
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
check_effect_channel(id, effect, params)?;
check_effect_center(id, effect, params)
}
/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key
@@ -500,6 +501,22 @@ pub(crate) fn check_effect_channel(id: &str, effect: &str, params: &Value) -> Re
Err(bad(id, format!("`{key}` must be one of {}", vectorcraft_render::effects::CHANNELS.join(", "))))
}
/// The Blur Center (`centerX`, `centerY`) in `params` of Radial Blur (`effect`), as command `id`
/// was given it: a parameter error unless each is a number from 0 to 1 (a share of the object's
/// bounds). `effect.apply` and `effect.setParams` run this themselves before they edit, as they
/// run [`check_effect_channel`].
pub(crate) fn check_effect_center(id: &str, effect: &str, params: &Value) -> Result<()> {
if effect != "blur.radial" {
return Ok(());
}
for key in ["centerX", "centerY"] {
if params.get(key).is_some_and(|v| !v.as_f64().is_some_and(|x| (0.0..=1.0).contains(&x))) {
return Err(bad(id, format!("`{key}` must be a number from 0 to 1")));
}
}
Ok(())
}
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
/// descriptions couldn't follow (a list of keys in a loop, a whole object read as settings), so
/// their descriptions aren't proven to name every key they take. Sorted; take a command out once
+4 -1
View File
@@ -143,19 +143,22 @@ fn listed_applied_edited_and_undone() {
}
}
// Numbers far out of range are stored as given and read clamped; the canvas still draws.
// Radial Blur's Blur Center is refused outside 0 to 1 instead.
let center = |id: &str, k: &str| id == "blur.radial" && matches!(k, "centerX" | "centerY");
for (id, _) in EFFECTS {
let defaults = vectorcraft_render::effects::default_params(id).unwrap();
let junk: serde_json::Map<String, Value> = defaults
.as_object()
.unwrap()
.iter()
.filter(|(_, v)| v.is_number())
.filter(|(k, v)| v.is_number() && !center(id, k))
.enumerate()
.map(|(i, (k, _))| (k.clone(), json!(if i % 2 == 0 { 1e308 } else { -1e308 })))
.collect();
assert!(!junk.is_empty(), "{id}");
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": junk})).unwrap();
}
assert!(s.execute("effect.apply", &json!({"effect": "blur.radial", "ids": [g], "params": {"centerX": 1e308}})).is_err());
let _ = render(&s);
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
assert_eq!(n.appearance.effects.len(), 3);
+3
View File
@@ -14,6 +14,9 @@ pub(super) struct Content<'a> {
pub(super) cache: Option<usize>,
/// Document-space bounds of the painted content.
pub(super) reach: Rect,
/// `reach` before the effects of an earlier part of a split stack spread it: the bounds Radial
/// Blur's Blur Center is placed in, so the center stays put wherever the stack splits.
pub(super) object: Rect,
/// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and
/// Inner Glow is skipped).
pub(super) outline: Option<(&'a BezPath, FillRule)>,
+5 -1
View File
@@ -241,7 +241,10 @@ impl Renderer {
outline: feather_outline(&chain, content, content.reach.origin()),
});
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
let space = effects::PixelSpace { to_doc, px, center, channels, line, extent };
// The Blur Center lies in the object's bounds; a split stack's later part spreads `reach`
// evenly, so the centre stays the same.
let size = content.object.size().to_vec2();
let space = effects::PixelSpace { to_doc, px, center, size, channels, line, extent };
let (wz, hz) = (wu as usize, hu as usize);
// What the content covers after the effects run so far: Rasterize makes its image over it.
let mut bounds = content.bounds.unwrap_or(content.reach);
@@ -708,6 +711,7 @@ impl Renderer {
owner: None,
cache: Some((j + 1) << 8),
reach: ib.inflate(outset, outset),
object: ib.inflate(outset, outset),
outline: Some((&outline, orule)),
bounds,
kept: None,
+13 -2
View File
@@ -389,7 +389,7 @@ impl Renderer {
let bounds = rasterize_bounds(f.symbols, n, &rfx);
// Under Rasterize the content reaches as far as its fills' and strokes' raster effects paint.
let reach = bounds.map_or(reach, |b| reach.union(b));
let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)), bounds, kept: None };
let content = Content { node: n, owner: None, cache: Some(0), reach, object: reach, outline: Some((g, rule)), bounds, kept: None };
let mut items = items;
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, paints, g, rule, &gctx, items.as_deref_mut()));
ctx.set_transform(Affine::IDENTITY);
@@ -446,7 +446,16 @@ impl Renderer {
None => rasterize_bounds(f.symbols, a, &rfx),
};
let reach = bounds.map_or(reach, |b| reach.union(b));
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None };
let content = Content {
node: a,
owner: Some(a),
cache,
reach,
object: reach,
outline: region.as_deref().map(|r| (&r.0, r.1)),
bounds,
kept: None,
};
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
ctx.set_transform(Affine::IDENTITY);
}
@@ -770,6 +779,7 @@ impl Renderer {
owner: content.owner,
cache: None,
reach: content.reach,
object: content.object,
outline: content.outline,
bounds: content.bounds,
kept: kept.as_ref(),
@@ -782,6 +792,7 @@ impl Renderer {
owner: content.owner,
cache: content.cache.filter(|_| !over),
reach,
object: content.object,
outline,
bounds: spread,
kept: content.kept.filter(|_| split == at),
+187
View File
@@ -227,6 +227,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
None if id == TWEAK => tweak_fields(ui, d, app.session.general_unit()),
None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()),
None if id == "artistic.filmGrain" => film_grain_fields(ui, d),
None if id == RADIAL_BLUR => radial_blur_fields(ui, d, app.session.general_unit()),
None if id == free_distort::FREE_DISTORT => {
free_distort::fields(app, ui, d);
values_changed(d)
@@ -287,6 +288,127 @@ fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
before != d.fields
}
/// The Radial Blur effect (Effect › Blur › Radial Blur…).
const RADIAL_BLUR: &str = "blur.radial";
/// Radial Blur's Blur Center: its parameters (0..1 across and down the object's bounds).
const BLUR_CENTER: [&str; 2] = ["centerX", "centerY"];
/// Radial Blur: Amount, Blur Method and Quality as the other effects show their parameters, then
/// the Blur Center box with its Horizontal and Vertical fields. Returns whether the values differ
/// from those last previewed.
fn radial_blur_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
let before = d.fields.clone();
// The center is the box's, not a pair of plain number rows.
let center: Vec<(String, Value)> = BLUR_CENTER.iter().filter_map(|k| d.fields.remove(*k).map(|v| (k.to_string(), v))).collect();
let doc = vectorcraft_effects::effect_info(RADIAL_BLUR).map(|e| e.params).unwrap_or_default();
form::param_fields(ui, d, &|_| false, &|k| choices(RADIAL_BLUR, k), &|k| doc_rank(doc, k), unit);
d.fields.extend(center);
ui.add_space(6.0);
blur_center(ui, d);
before != d.fields
}
/// The Blur Center box's side (points).
const CENTER_BOX: f32 = 120.0;
/// A Blur Center coordinate kept to a hundredth of a percent of the box.
fn center_round(x: f64) -> f64 {
(x * 1e4).round() / 1e4
}
/// Radial Blur's Blur Center: a box standing for the object's bounds, with the blur's pattern (rays
/// for Zoom, rings for Spin) drawn about the center. A press or a drag in the box puts the center
/// there; with the keyboard focus the arrow keys move it 1 % of the box (10 % with Shift). The
/// Horizontal and Vertical fields beside it type the same position in percent of the bounds' width
/// and height. Changes go to `d.fields["centerX"]` and `["centerY"]`.
fn blur_center(ui: &mut egui::Ui, d: &mut Dialog) {
use egui::{Key, Modifiers, Sense, Stroke};
let [kx, ky] = BLUR_CENTER;
let mut c = [d.f64(kx, 0.5).clamp(0.0, 1.0), d.f64(ky, 0.5).clamp(0.0, 1.0)];
let before = c;
let t = Tokens::get(ui.ctx());
let zoom = d.str("method").eq_ignore_ascii_case("zoom");
form::caption(ui, "Blur Center");
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(CENTER_BOX, CENTER_BOX), Sense::hover());
let id = ui.scope_id().with("radial-blur-center");
let resp = ui.interact(rect, id, Sense::click_and_drag());
let percent = |v: f64| (v * 1000.0).round() / 10.0;
let name = tl!("Blur Center").to_string();
let value = format!("{}: {}%, {}: {}%", tl!("Horizontal"), percent(c[0]), tl!("Vertical"), percent(c[1]));
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo {
current_text_value: Some(value.clone()),
..egui::WidgetInfo::labeled(egui::Role::Slider, enabled, &name)
});
if resp.drag_started() || resp.clicked() {
resp.request_focus();
}
if let Some(p) = resp.interact_pointer_pos()
&& (resp.dragged() || resp.clicked() || resp.drag_started())
{
let unit = |v: f32, lo: f32| f64::from(((v - lo) / CENTER_BOX).clamp(0.0, 1.0));
c = [center_round(unit(p.x, rect.left())), center_round(unit(p.y, rect.top()))];
}
if resp.has_focus() {
ui.memory_mut(|m| {
m.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
});
for (key, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)] {
// Shift first: a key without modifiers also matches the key with Shift.
for (modifiers, step) in [(Modifiers::SHIFT, 0.1), (Modifiers::NONE, 0.01)] {
if ui.input_mut(|i| i.consume_key(modifiers, key)) {
c = [center_round((c[0] + dx * step).clamp(0.0, 1.0)), center_round((c[1] + dy * step).clamp(0.0, 1.0))];
}
}
}
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
// The box, and the pattern about the center: rays for Zoom, rings for Spin.
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 2.0, t.input);
let at = egui::pos2(rect.left() + c[0] as f32 * CENTER_BOX, rect.top() + c[1] as f32 * CENTER_BOX);
let faint = Stroke::new(1.0, t.text_dim.gamma_multiply(0.6));
let reach = CENTER_BOX * std::f32::consts::SQRT_2;
if zoom {
for k in 0..24 {
let a = k as f32 * std::f32::consts::TAU / 24.0;
let dir = egui::vec2(a.cos(), a.sin());
painter.line_segment([at + dir * 6.0, at + dir * reach], faint);
}
} else {
for k in 1..=12 {
painter.circle_stroke(at, k as f32 * reach / 12.0, faint);
}
}
painter.circle(at, 3.5, if resp.has_focus() { t.accent } else { t.panel }, Stroke::new(1.5, t.text));
let border = if resp.has_focus() { t.accent } else { t.input_border };
painter.rect_stroke(rect, 2.0, Stroke::new(1.0, border), egui::StrokeKind::Inside);
// The same position typed in percent.
ui.vertical(|ui| {
egui::Grid::new("blur-center-fields").num_columns(2).spacing([8.0, 8.0]).show(ui, |ui| {
for (i, label) in ["Horizontal", "Vertical"].into_iter().enumerate() {
let row = ui.label(egui::RichText::new(format!("{}:", tl!(label))).color(t.text));
ui.label_inputs_by(row.id, |ui| {
if let Some(x) =
widgets::range_field_named(ui, ("blur-center", i), None, c[i] * 100.0, 0.0..=100.0, &form::unit_suffix("%"), 1, 72.0)
{
c[i] = center_round((x / 100.0).clamp(0.0, 1.0));
}
});
ui.end_row();
}
});
});
});
if c != before {
d.fields.insert(kx.into(), json!(c[0]));
d.fields.insert(ky.into(), json!(c[1]));
}
}
/// The Glass effect (Effect › Distort › Glass…).
const GLASS: &str = "distort.glass";
/// The Texturizer effect (Effect › Texture › Texturizer…).
@@ -1258,4 +1380,69 @@ mod tests {
assert_eq!((param("opacity"), param("blur")), (Some(99.0), Some(0.0)), "{effect}: OK applies them");
}
}
/// Effect > Blur > Radial Blur: a press in the Blur Center box puts the center there, the
/// arrow keys move it 1 % (10 % with Shift) and stop at the edges, screen readers get its
/// position, the generic rows leave it to the box, and OK applies it.
#[test]
fn radial_blur_center_box_sets_the_center_by_pointer_and_keys() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
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": RADIAL_BLUR})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let time = std::cell::Cell::new(0.0);
// One frame → the box's screen-reader node (bounds, value) and the labels drawn.
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time.set(time.get() + 0.05);
let mut out = ctx.run_ui(egui::RawInput { time: Some(time.get()), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
let nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
let labels: Vec<String> = nodes.iter().filter_map(|(_, n)| n.label().map(str::to_string)).collect();
let center = nodes.iter().find(|(_, n)| n.label() == Some("Blur Center") && n.role() == egui::accesskit::Role::Slider).map(|(_, n)| {
let b = n.bounds().unwrap_or_default();
(egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)), n.value().map(str::to_string))
});
(center, labels)
};
for _ in 0..6 {
frame(&mut app, vec![]);
}
let (Some((rect, value)), labels) = frame(&mut app, vec![]) else { panic!("the dialog has a Blur Center box") };
assert_eq!(value.as_deref(), Some("Horizontal: 50%, Vertical: 50%"), "the default center is the middle");
assert!(!labels.iter().any(|l| l.starts_with("Center")), "no plain Center X or Center Y rows: {labels:?}");
let field = |app: &VectorcraftApp, k: &str| app.ui.dialog.as_ref().unwrap().fields.get(k).and_then(Value::as_f64);
let pos = rect.min + egui::vec2(0.25 * rect.width(), 0.75 * rect.height());
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]);
frame(&mut app, vec![button(false)]);
frame(&mut app, vec![]);
let at = |app: &VectorcraftApp| (field(app, "centerX").unwrap_or(-1.0), field(app, "centerY").unwrap_or(-1.0));
let (x, y) = at(&app);
assert!((x - 0.25).abs() < 0.01 && (y - 0.75).abs() < 0.01, "the press puts the center there: {x}, {y}");
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
app.ui.dialog.as_mut().unwrap().fields.insert("centerX".into(), json!(0.25));
app.ui.dialog.as_mut().unwrap().fields.insert("centerY".into(), json!(0.75));
frame(&mut app, vec![key(egui::Key::ArrowRight, egui::Modifiers::NONE)]);
frame(&mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::SHIFT)]);
assert_eq!(at(&app), (0.26, 0.65), "the arrow keys move it 1 %, 10 % with Shift");
for _ in 0..10 {
frame(&mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::SHIFT)]);
}
assert_eq!(at(&app), (0.26, 1.0), "it stops at the edge");
let (Some((_, value)), _) = frame(&mut app, vec![]) else { panic!("the box is still there") };
assert_eq!(value.as_deref(), Some("Horizontal: 26%, Vertical: 100%"));
super::super::confirm(&mut app).unwrap();
let st = app.session.doc().unwrap();
let fx = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0];
assert_eq!(
(fx.id.as_str(), fx.params["centerX"].as_f64(), fx.params["centerY"].as_f64()),
(RADIAL_BLUR, Some(0.26), Some(1.0)),
"OK applies it"
);
}
}
@@ -271,6 +271,7 @@ fn preview_space(w: usize, h: usize) -> PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(wf / 2.0, hf / 2.0),
size: vectorcraft_geom::Vec2::new(wf, hf),
channels: vectorcraft_effects::pixel::Channels::Rgb,
line: 1.0,
extent: 0.5 * wf.hypot(hf),
+1
View File
@@ -13,6 +13,7 @@
Edge Brightness Яркост на ръба
Smoothness Гладкост
Radial Blur… Лъчезарно размазване…
Blur Center Център на размазването
Smart Blur… Интелигентно размазване…
Sharpen Изостряне
Unsharp Mask… Контурна маска…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -959,6 +959,7 @@
Free Transform Slobodna transformacija
Perspective Distort Izobličenje perspektive
Radial Blur… Radijalno zamućenje…
Blur Center Središte zamućenja
Smart Blur… Pametno zamućenje…
Sharpen Izoštri
Unsharp Mask… Maska izoštravanja…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3391,6 +3391,7 @@ digits Malayalam Malaiàlam
Radial Radial
Radial Blur Desenfocament radial
Radial Blur… Desenfocament radial…
Blur Center Centre del desenfocament
Radial Dividers: Divisors radials:
Radial Glows Resplendors radials
Radial Gradient Degradat radial
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -978,6 +978,7 @@
# Photoshop-style raster effects (Effect menu, their dialogs).
Radial Blur… Radiální rozostření…
Blur Center Střed rozostření
Smart Blur… Chytré rozostření…
Sharpen Zostření
Unsharp Mask… Maska rozostření…
Can't render this file because it is too large.
+1
View File
@@ -901,6 +901,7 @@
Free Transform Fri transformering
Perspective Distort Perspektivforvrængning
Radial Blur… Radial sløring…
Blur Center Sløringens midtpunkt
Smart Blur… Smart sløring…
Sharpen Skarphed
Unsharp Mask… Uskarp maskering…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -25,6 +25,7 @@
Edge Brightness Kantenhelligkeit
Smoothness Glättung
Radial Blur… Radialer Weichzeichner…
Blur Center Mittelpunkt der Weichzeichnung
Smart Blur… Selektiver Weichzeichner…
Sharpen Scharfzeichnen
Unsharp Mask… Unscharf maskieren…
Can't render this file because it is too large.
+1
View File
@@ -10,6 +10,7 @@
Edge Brightness Φωτεινότητα ακμών
Smoothness Ομαλότητα
Radial Blur… Ακτινική θόλωση…
Blur Center Κέντρο θόλωσης
Smart Blur… Έξυπνη θόλωση…
Sharpen Όξυνση
Unsharp Mask… Μάσκα όξυνσης…
Can't render this file because it is too large.
+1
View File
@@ -18,6 +18,7 @@
Edge Brightness Brillo del borde
Smoothness Suavizado
Radial Blur… Desenfoque radial…
Blur Center Centro del desenfoque
Smart Blur… Desenfoque inteligente…
Sharpen Enfocar
Unsharp Mask… Máscara de enfoque…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -962,6 +962,7 @@
Free Transform Vapaa muunnos
Perspective Distort Perspektiivivääristys
Radial Blur… Säteittäinen sumennus…
Blur Center Sumennuksen keskipiste
Smart Blur… Älykäs sumennus…
Sharpen Terävöitä
Unsharp Mask… Epäterävä maski…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -19,6 +19,7 @@
Edge Brightness Luminosité du bord
Smoothness Lissage
Radial Blur… Flou radial…
Blur Center Centre du flou
Smart Blur… Flou optimisé…
Sharpen Renforcement
Unsharp Mask… Accentuation…
Can't render this file because it is too large.
+1
View File
@@ -2765,6 +2765,7 @@
Radar Graph Tool રડાર ગ્રાફ ટૂલ
Radial રેડિયલ
Radial Blur… રેડિયલ બ્લર…
Blur Center બ્લરનું કેન્દ્ર
Radial Dividers: રેડિયલ વિભાજક:
Radial Glows રેડિયલ ઝગમગાટ
Radial Gradient રેડિયલ ગ્રેડિઅન્ટ
Can't render this file because it contains an unexpected character in line 72 and column 5.
+1
View File
@@ -3727,6 +3727,7 @@ antiAlias Sharp शार्प
Radial रेडियल
Radial Blur रेडियल ब्लर
Radial Blur… रेडियल ब्लर…
Blur Center ब्लर का केंद्र
Radial Dividers: रेडियल विभाजक:
Radial Glows रेडियल चमक
Radial Gradient रेडियल ग्रेडिएंट
Can't render this file because it is too large.
+1
View File
@@ -3256,6 +3256,7 @@ mark-style Japanese Japán
Radial Körkörös
Radial Blur Körkörös elmosás
Radial Blur… Körkörös elmosás…
Blur Center Elmosás középpontja
Radial Glows Körkörös ragyogások
Organic Fades Organikus áttűnések
Holographic Holografikus
Can't render this file because it is too large.
+1
View File
@@ -901,6 +901,7 @@
Free Transform Transformasi bebas
Perspective Distort Distorsi perspektif
Radial Blur… Kabur radial…
Blur Center Pusat kabur
Smart Blur… Kabur cerdas…
Sharpen Pertajam
Unsharp Mask… Unsharp Mask…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
901
902
903
904
905
906
907
+1
View File
@@ -18,6 +18,7 @@
Edge Brightness Luminosità bordo
Smoothness Uniformità
Radial Blur… Sfocatura radiale…
Blur Center Centro della sfocatura
Smart Blur… Sfocatura intelligente…
Sharpen Nitidezza
Unsharp Mask… Maschera di contrasto…
Can't render this file because it is too large.
+1
View File
@@ -4247,6 +4247,7 @@ theme Appearance 外観
# Photoshop-style raster effects (Effect menu, their dialogs).
Radial Blur… ぼかし (放射状)…
Blur Center ぼかしの中心
Smart Blur… ぼかし (詳細)…
Sharpen シャープ
Unsharp Mask… アンシャープマスク…
Can't render this file because it is too large.
+1
View File
@@ -867,6 +867,7 @@
Free Transform თავისუფალი გარდაქმნა
Perspective Distort პერსპექტიული დეფორმაცია
Radial Blur… რადიალური გაბუნდოვნება…
Blur Center გაბუნდოვნების ცენტრი
Smart Blur… ჭკვიანი გაბუნდოვნება…
Sharpen გამკვეთრება
Unsharp Mask… არამკვეთრი ნიღაბი…
Can't render this file because it is too large.
+1
View File
@@ -4227,6 +4227,7 @@ theme Appearance 외관
Free Transform 자유 변형
Perspective Distort 원근 왜곡
Radial Blur… 방사형 흐림…
Blur Center 흐림 중심
Smart Blur… 스마트 흐림…
Sharpen 선명하게
Unsharp Mask… 언샤프 마스크…
Can't render this file because it is too large.
+1
View File
@@ -1419,6 +1419,7 @@
Free Transform Vrij transformeren
Perspective Distort Perspectief vervormen
Radial Blur… Radiaal vervagen…
Blur Center Middelpunt van vervaging
Smart Blur… Slim vervagen…
Sharpen Verscherpen
Unsharp Mask… Onscherp maskeren…
Can't render this file because it is too large.
+1
View File
@@ -919,6 +919,7 @@
Free Transform Swobodne przekształcanie
Perspective Distort Zniekształcenie perspektywiczne
Radial Blur… Rozmycie promieniste…
Blur Center Środek rozmycia
Smart Blur… Inteligentne rozmycie…
Sharpen Wyostrzanie
Unsharp Mask… Maska wyostrzająca…
Can't render this file because it is too large.
+1
View File
@@ -3469,6 +3469,7 @@ imageTrace Outline Contorno
Free Transform Transformação Livre
Perspective Distort Distorção de Perspectiva
Radial Blur… Desfoque Radial…
Blur Center Centro do Desfoque
Smart Blur… Desfoque Inteligente…
Sharpen Nitidez
Unsharp Mask… Máscara de Nitidez…
Can't render this file because it is too large.
+1
View File
@@ -16,6 +16,7 @@
Edge Brightness Яркость края
Smoothness Сглаживание
Radial Blur… Радиальное размытие…
Blur Center Центр размытия
Smart Blur… Умное размытие…
Sharpen Резкость
Unsharp Mask… Нерезкая маска…
Can't render this file because it is too large.
+1
View File
@@ -15,6 +15,7 @@
Edge Brightness Kantljusstyrka
Smoothness Mjukhet
Radial Blur… Radiell oskärpa…
Blur Center Oskärpans mittpunkt
Smart Blur… Smart oskärpa…
Sharpen Skärpa
Unsharp Mask… Oskarp mask…
Can't render this file because it is too large.
+1
View File
@@ -15,6 +15,7 @@
Edge Brightness Kenar Parlaklığı
Smoothness Pürüzsüzlük
Radial Blur… Radyal Bulanıklık…
Blur Center Bulanıklık Merkezi
Smart Blur… Akıllı Bulanıklaştırma…
Sharpen Keskinleştir
Unsharp Mask… Keskinliği Azaltma Maskesi…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -17,6 +17,7 @@
Smoothness Згладжування
Radial Blur… Радіальне розмиття…
Blur Center Центр розмиття
Smart Blur… Розумне розмиття…
Sharpen Різкість
Unsharp Mask… Нерізка маска…
Can't render this file because it is too large.
+1
View File
@@ -3042,6 +3042,7 @@ mark-style Japanese Kiểu Nhật
Radial Hướng tâm
Radial Blur Mờ hướng tâm
Radial Blur… Mờ hướng tâm…
Blur Center Tâm làm mờ
Radial Dividers: Đường chia hướng tâm:
Radial Glows Ánh sáng hướng tâm
Organic Fades Mờ dần tự nhiên
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -5557,6 +5557,7 @@ axis Both 两者
# Photoshop-style raster effects (Effect menu, their dialogs).
Radial Blur… 径向模糊…
Blur Center 模糊中心
Smart Blur… 智能模糊…
Sharpen 锐化
Unsharp Mask… 反锐化遮罩…
Can't render this file because it is too large.
+1
View File
@@ -5392,6 +5392,7 @@ axis Both 兩者
# Photoshop-style raster effects (Effect menu, their dialogs).
Radial Blur… 放射狀模糊…
Blur Center 模糊中心
Smart Blur… 智慧型模糊…
Sharpen 銳利化
Unsharp Mask… 反銳利化遮罩…
Can't render this file because it is too large.
+10
View File
@@ -1696,6 +1696,16 @@ one opens as before and draws it as `rgb`:
→ invalid parameters for `effect.setParams`: `channel` must be one of rgb, red, green, blue
```
Radial Blur's Blur Center, `centerX` and `centerY`, must each be a number from 0 to 1: the share of the object's bounds
across from its left edge and down from its top edge (0.5 each, the object's center, by default). The effect turns or
zooms about that point, which moves and scales with the object. `effect.apply` and `effect.setParams` refuse any other
value themselves in the same way, and a document saved with one draws it at the nearest edge:
```json
{"id": "effect.setParams", "params": {"index": 0, "params": {"centerX": 1.5}}}
→ invalid parameters for `effect.setParams`: `centerX` must be a number from 0 to 1
```
A number outside the range the effect documents is still stored as given and clamped when the effect draws. A
plug-in effect's parameters are resolved against its manifest instead. `effect.setParams` as a step of
`command.batch` is checked when the step runs, against the effects the steps before it added, and the batch then
+2
View File
@@ -1963,6 +1963,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html).
- **Radial Blur center:** Effect › Blur › Radial Blur… has a Blur Center box. A press or a drag in the box sets the point the spin or zoom starts from, and the arrow keys move it 1% (10% with Shift). Horizontal and Vertical fields beside the box take the same position in percent. The effect stores the point as `centerX` and `centerY`, from 0 to 1 across and down the object's bounds (0.5 each by default, VectorCraft's own form), so it moves and scales with the object. An effect saved without them draws about the object's center as before, and `effect.apply` and `effect.setParams` refuse a value outside 0 to 1. Tests: `radial_blur_turns_about_its_blur_center` in `crates/effects/src/pixel/tests.rs` and `radial_blur_center_is_stored_drawn_and_checked` in `crates/engine/src/cmd/effectcmd/tests.rs`. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html).
- **Dragging objects with raster effects when zoomed in (#2558):** during a drag with a tool on the canvas, the renderer draws filtered content (Gaussian Blur, Feather and the Photoshop-style filters) within 1,000,000 pixels, at a lower resolution where it would need more, unless it can draw the content from an earlier frame's raster. In the desktop app it does the same for drop shadows, outer glows and inner glows. No raster made that way is kept. The frame after the drag renders them as a still frame does, and a dialog's preview, exports and printing render as before. `zoomed_drag_perf` times a drag's frames at 100% to 6400%.
- **Dragging other art over a wide blur (#2558):** an effect raster too large to keep whole (a wide blur, a shadow or glow, or any blur zoomed in) is cropped to what can show in the view, or made at a lower resolution and resampled to the view's pixels, and kept while the view stays the same, so dragging other art over the object draws its effects from the still frame instead of re-rendering them at the drag's lower resolution. A drag stays on the render worker once the worker drew the frame shown, since only that renderer holds the rasters. `tests_viewraster` covers each effect, and `a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame` the canvas on and off the worker. Not yet: a worker frame shows one display frame after the overlays, and the first frame of an interaction can land on the renderer that has not made the rasters yet (follow-up: render every canvas frame on the worker, waiting briefly for fast ones).
- **Opening a heavy document (#4155):** the canvas renders a document's first frame (when it opens, or its tab is chosen) on the render worker, as it renders the frames of a slow document, instead of on the UI thread, where a heavy one froze the window until it was done. The UI thread waits for it up to 16 ms (`FIRST_FRAME_GRACE`), so a light document's art shows in the same frame as before, without a frame of bare paper; otherwise the document's paper and artboards show, the window stays live, and the art shows when it lands. The web build, which has no worker, renders it as before. Measured on an M3 Pro, one run each, from launch with the file: the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms (its art lands as before, after about 9 s of rendering); a 50,000-path SVG 1.4 s, now 62 ms. `a_documents_first_frame_renders_on_the_worker` and `the_render_worker_waits_for_a_frame_only_as_long_as_asked` (`crates/ui-egui/src/canvas/tests.rs`).