diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 424beebe17..67677656e6 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -468,8 +468,8 @@ pub fn effect_catalog() -> Vec { "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", diff --git a/crates/effects/src/pixel.rs b/crates/effects/src/pixel.rs index 89eb541b19..0c159f8485 100644 --- a/crates/effects/src/pixel.rs +++ b/crates/effects/src/pixel.rs @@ -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 { 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 { 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) } diff --git a/crates/effects/src/pixel/blur.rs b/crates/effects/src/pixel/blur.rs index 0a061571ba..877061922d 100644 --- a/crates/effects/src/pixel/blur.rs +++ b/crates/effects/src/pixel/blur.rs @@ -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; } diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index a1a083f0fe..a1e73eab33 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -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"})), diff --git a/crates/effects/src/pixel/tests_paint.rs b/crates/effects/src/pixel/tests_paint.rs index e84e587c9a..20fb8d10f2 100644 --- a/crates/effects/src/pixel/tests_paint.rs +++ b/crates/effects/src/pixel/tests_paint.rs @@ -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), diff --git a/crates/effects/src/pixel/tests_sketch.rs b/crates/effects/src/pixel/tests_sketch.rs index fd467a23ca..127b79c5f0 100644 --- a/crates/effects/src/pixel/tests_sketch.rs +++ b/crates/effects/src/pixel/tests_sketch.rs @@ -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), diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index df1956f3f0..f93cb53124 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -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. diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index c43d383c19..c605b6461f 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -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, diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 65672f8b6b..53dd02cd88 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -427,6 +427,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } 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 @@ fn set_params(s: &mut Session, p: &Value) -> Result { 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)?; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 306aaa4797..b3389e190d 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -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::(); + 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"); +} diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index fe8431a6c6..7347fa41aa 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -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 diff --git a/crates/engine/src/tests/rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs index 534f79b1bc..521ba0adbf 100644 --- a/crates/engine/src/tests/rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -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 = 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); diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index 7b80677e2c..1d9e21a64e 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -14,6 +14,9 @@ pub(super) struct Content<'a> { pub(super) cache: Option, /// 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)>, diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index cdf817f1e4..f4a441f3cb 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -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, diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 190d433973..f5dcf7bcb9 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -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), diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 777c872494..2c2673d468 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -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| { + 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 = 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" + ); + } } diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index 03c862171d..c78854d439 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -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), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..12a218f82d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -13,6 +13,7 @@ Edge Brightness Яркост на ръба Smoothness Гладкост Radial Blur… Лъчезарно размазване… + Blur Center Център на размазването Smart Blur… Интелигентно размазване… Sharpen Изостряне Unsharp Mask… Контурна маска… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..295fdc562f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..58aedc348a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..b48737e17d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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í… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..22a30253d7 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..38d81ba089 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..b65ec1485a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -10,6 +10,7 @@ Edge Brightness Φωτεινότητα ακμών Smoothness Ομαλότητα Radial Blur… Ακτινική θόλωση… + Blur Center Κέντρο θόλωσης Smart Blur… Έξυπνη θόλωση… Sharpen Όξυνση Unsharp Mask… Μάσκα όξυνσης… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..6b689eb46d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..cb1f34e152 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..67c75000a2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..c88a9e7210 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -2765,6 +2765,7 @@ Radar Graph Tool રડાર ગ્રાફ ટૂલ Radial રેડિયલ Radial Blur… રેડિયલ બ્લર… + Blur Center બ્લરનું કેન્દ્ર Radial Dividers: રેડિયલ વિભાજક: Radial Glows રેડિયલ ઝગમગાટ Radial Gradient રેડિયલ ગ્રેડિઅન્ટ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..c8e8743034 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3727,6 +3727,7 @@ antiAlias Sharp शार्प Radial रेडियल Radial Blur रेडियल ब्लर Radial Blur… रेडियल ब्लर… + Blur Center ब्लर का केंद्र Radial Dividers: रेडियल विभाजक: Radial Glows रेडियल चमक Radial Gradient रेडियल ग्रेडिएंट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a1f6100b2f..f36b02dc9c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..dd025a2fb9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6743704785..a2c46cc202 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9cdb8fcbe..81fd56d690 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4247,6 +4247,7 @@ theme Appearance 外観 # Photoshop-style raster effects (Effect menu, their dialogs). Radial Blur… ぼかし (放射状)… + Blur Center ぼかしの中心 Smart Blur… ぼかし (詳細)… Sharpen シャープ Unsharp Mask… アンシャープマスク… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..cbbb4cdf42 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -867,6 +867,7 @@ Free Transform თავისუფალი გარდაქმნა Perspective Distort პერსპექტიული დეფორმაცია Radial Blur… რადიალური გაბუნდოვნება… + Blur Center გაბუნდოვნების ცენტრი Smart Blur… ჭკვიანი გაბუნდოვნება… Sharpen გამკვეთრება Unsharp Mask… არამკვეთრი ნიღაბი… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..f824d760ae 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4227,6 +4227,7 @@ theme Appearance 외관 Free Transform 자유 변형 Perspective Distort 원근 왜곡 Radial Blur… 방사형 흐림… + Blur Center 흐림 중심 Smart Blur… 스마트 흐림… Sharpen 선명하게 Unsharp Mask… 언샤프 마스크… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..5f73fb3be1 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..c6f4450482 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..3cc426a9c4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..98edef24e5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -16,6 +16,7 @@ Edge Brightness Яркость края Smoothness Сглаживание Radial Blur… Радиальное размытие… + Blur Center Центр размытия Smart Blur… Умное размытие… Sharpen Резкость Unsharp Mask… Нерезкая маска… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..668f015d9b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..5bcc6e0f41 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..a1d8d76196 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -17,6 +17,7 @@ Smoothness Згладжування Radial Blur… Радіальне розмиття… + Blur Center Центр розмиття Smart Blur… Розумне розмиття… Sharpen Різкість Unsharp Mask… Нерізка маска… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..00f73ea619 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..becf8ee606 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5557,6 +5557,7 @@ axis Both 两者 # Photoshop-style raster effects (Effect menu, their dialogs). Radial Blur… 径向模糊… + Blur Center 模糊中心 Smart Blur… 智能模糊… Sharpen 锐化 Unsharp Mask… 反锐化遮罩… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..fa66fce608 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5392,6 +5392,7 @@ axis Both 兩者 # Photoshop-style raster effects (Effect menu, their dialogs). Radial Blur… 放射狀模糊… + Blur Center 模糊中心 Smart Blur… 智慧型模糊… Sharpen 銳利化 Unsharp Mask… 反銳利化遮罩… diff --git a/docs/mcp.md b/docs/mcp.md index 2e43c14d0e..454eb309af 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6375f3aa3..2c43b1b9dc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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`).