mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:10:13 +00:00
Merge pull request #7680 from dotCooCoo/feat/radial-blur-center
Effects: Radial Blur turns about a Blur Center set in its dialog
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -427,6 +427,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
super::params::check_effect_channel(C, id, ¶ms)?;
|
||||
super::params::check_effect_center(C, id, ¶ms)?;
|
||||
// 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 @@ pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
super::params::check_effect_channel(C, effect, ¶ms)?;
|
||||
super::params::check_effect_center(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| texture_load_effect(e)) {
|
||||
load_texture(&mut params, C)?;
|
||||
|
||||
@@ -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, ¶ms).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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -447,7 +447,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);
|
||||
}
|
||||
@@ -771,6 +780,7 @@ impl Renderer {
|
||||
owner: content.owner,
|
||||
cache: None,
|
||||
reach: content.reach,
|
||||
object: content.object,
|
||||
outline: content.outline,
|
||||
bounds: content.bounds,
|
||||
kept: kept.as_ref(),
|
||||
@@ -783,6 +793,7 @@ impl Renderer {
|
||||
owner: content.owner,
|
||||
cache: content.cache.filter(|_| !over),
|
||||
reach,
|
||||
object: content.object,
|
||||
outline,
|
||||
bounds: spread,
|
||||
kept: content.kept.filter(|_| split == at),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
@@ -971,6 +971,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.
|
@@ -3396,6 +3396,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.
|
@@ -990,6 +990,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.
|
@@ -1522,6 +1522,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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -974,6 +974,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.
|
@@ -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.
|
@@ -2770,6 +2770,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.
|
@@ -3732,6 +3732,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.
|
@@ -3261,6 +3261,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.
|
@@ -913,6 +913,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…
|
||||
|
||||
|
@@ -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.
|
@@ -4265,6 +4265,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.
|
@@ -879,6 +879,7 @@
|
||||
Free Transform თავისუფალი გარდაქმნა
|
||||
Perspective Distort პერსპექტიული დეფორმაცია
|
||||
Radial Blur… რადიალური გაბუნდოვნება…
|
||||
Blur Center გაბუნდოვნების ცენტრი
|
||||
Smart Blur… ჭკვიანი გაბუნდოვნება…
|
||||
Sharpen გამკვეთრება
|
||||
Unsharp Mask… არამკვეთრი ნიღაბი…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4245,6 +4245,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.
|
@@ -1431,6 +1431,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.
|
@@ -931,6 +931,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.
|
@@ -3486,6 +3486,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.
|
@@ -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.
|
@@ -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.
|
@@ -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 is too large.
|
@@ -17,6 +17,7 @@
|
||||
Smoothness Згладжування
|
||||
|
||||
Radial Blur… Радіальне розмиття…
|
||||
Blur Center Центр розмиття
|
||||
Smart Blur… Розумне розмиття…
|
||||
Sharpen Різкість
|
||||
Unsharp Mask… Нерізка маска…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3047,6 +3047,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.
|
@@ -5575,6 +5575,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.
|
@@ -5410,6 +5410,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
@@ -1736,6 +1736,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
|
||||
|
||||
@@ -1998,6 +1998,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`).
|
||||
|
||||
Reference in New Issue
Block a user