From 720ce5ed7315d48d8d73146b3cecf5448bfa47f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 04:00:46 -0700 Subject: [PATCH] Effects: Drop Shadow has Darkness, the object's own colors with black added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference app's Drop Shadow has a Color and a Darkness, the percentage of black added to the object's own colors. Drop Shadow had only a Color, so every shadow was one color. Choosing one or the other, and the defaults, are VectorCraft's own. Drop Shadow now takes `shade` ("color" or "darkness", default "color") and `darkness` (%, default 100). Its dialog and the Appearance panel's inline editor show Color and Darkness as radio buttons. With Darkness d, a CMYK document keeps the object's cyan, magenta and yellow and raises its black from K to K + d × (1 − K), and an RGB document multiplies each channel by 1 − d, so in RGB 0% gives the object's color and 100% gives black. A shadow saved without these keys draws as before. SVG export writes a Darkness shadow with feComponentTransfer and SVG import reads it back; CSS export leaves it out. Docs: docs/target-app-parity.md. --- crates/effects/src/lib.rs | 4 +- crates/effects/src/raster.rs | 6 + crates/effects/src/tests.rs | 17 ++- crates/eps/src/import/ai/style.rs | 2 +- crates/render/src/fx.rs | 183 +++++++++++++++++++++--- crates/svg/src/css.rs | 4 +- crates/svg/src/export.rs | 12 +- crates/svg/src/import/fx.rs | 81 +++++++++-- crates/svg/tests/css_rules.rs | 10 ++ crates/svg/tests/import_live.rs | 58 +++++++- crates/ui-egui/src/dialogs/effect.rs | 55 ++++++- crates/ui-egui/src/dialogs/mod.rs | 2 +- crates/ui-egui/src/dialogs/tests.rs | 45 ++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/appearance.rs | 53 ++++++- docs/target-app-parity.md | 2 + 29 files changed, 497 insertions(+), 51 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c32558fad..45b8df31a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -308,8 +308,8 @@ pub fn effect_catalog() -> Vec { "stylize.dropShadow", "Drop Shadow…", STYLIZE, - "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}", - json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}), + "{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)", + json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}), ), r( "stylize.innerGlow", diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 3da990a59..31cc56976 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -21,6 +21,9 @@ pub enum RasterFx { dy: f64, blur: f64, color: Color, + /// Darkness (0..1): the shadow is the object's own colors with this share of black added + /// instead of `color`; `None` when it is `color`. + darkness: Option, }, OuterGlow { mode: BlendMode, @@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { dy: num(&p, "y", 7.0).clamp(-1e4, 1e4), blur, color: color_param(&p, Color::BLACK), + darkness: text(&p, "shade", "color") + .eq_ignore_ascii_case("darkness") + .then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32), }, "stylize.outerGlow" => RasterFx::OuterGlow { mode: blend_mode(text(&p, "mode", "screen")), diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 30a8cab2f..bd0517f0e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -565,11 +565,12 @@ fn raster_effects_parse_and_outset() { let r = raster_effects(&list); assert_eq!(r.len(), 5); match &r[0] { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => { assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply); assert!(close(*opacity as f64, 0.5, 1e-6)); assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0)); assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]); + assert_eq!(*darkness, None); } other => panic!("{other:?}"), } @@ -578,6 +579,20 @@ fn raster_effects_parse_and_outset() { assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0); } +#[test] +fn drop_shadow_darkness_replaces_its_color_when_chosen() { + let darkness = |params: Value| match raster_effects(&[fx("stylize.dropShadow", params)]).as_slice() { + [RasterFx::DropShadow { darkness, .. }] => *darkness, + other => panic!("{other:?}"), + }; + assert_eq!(darkness(json!({"shade": "darkness", "darkness": 60})), Some(0.6)); + assert_eq!(darkness(json!({"shade": "darkness"})), Some(1.0)); + assert_eq!(darkness(json!({"shade": "darkness", "darkness": 250})), Some(1.0)); + // Color is the default: a Darkness value alone leaves the shadow its color. + assert_eq!(darkness(json!({"darkness": 60})), None); + assert_eq!(darkness(json!({})), None); +} + #[test] fn blend_mode_names() { use vectorcraft_doc::color::BlendMode; diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index aab101475..37e02fc7e 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -344,7 +344,7 @@ fn style(filters: &[Filter]) -> ArtStyle { }), visible: true, }), - // A shadow darkening the object's own colours isn't one effects describe. + // A shadow darkening the object's own colors is not read as Drop Shadow's Darkness. SHADOW => plain = false, _ => {} } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 4299b77a3..cf5df89e8 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -308,6 +308,27 @@ fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter { }) } +/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred. +fn shade_filter(blur: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None }) +} + +/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints: +/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales +/// the complemented black plane, as it scales screen colors, by 1 − `darkness`. +fn shade(ink: Ink, darkness: f32) -> f32 { + match ink { + Ink::Cmy => 0.0, + Ink::K | Ink::Display | Ink::RichBlack => darkness, + } +} + +/// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its +/// premultiplied RGBA when the shadow takes the object's own colors (Darkness). +fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec { + if colored { pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect() } else { pm.data().iter().map(|p| p.a).collect() } +} + fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color { let [r, g, b] = ink.rgb(c); peniko::Color::new([r, g, b, 1.0]) @@ -434,7 +455,7 @@ impl ShadowEntry { /// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]). pub(crate) struct Made { /// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA, - /// or their alpha alone under a shadow or glow. + /// or their alpha alone under a shadow or glow that is one color ([`silhouette`]). pixels: Vec, /// The effects run on them. fx: Vec, @@ -684,9 +705,9 @@ impl Renderer { owner: Option<&Arc>, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) -> bool { - let (mode, opacity, dx, dy, blur, color) = match fx { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), - RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color), + let (mode, opacity, dx, dy, blur, color, darkness) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), _ => return false, }; let linear = linear_key(f.view); @@ -718,7 +739,8 @@ impl Renderer { return false; } let (w, h) = (w as u16, h as u16); - // Silhouette (the object's painted alpha), offset by the shadow distance. + // Silhouette (the object's painted alpha, and its colors under Darkness), offset by + // the shadow distance. let mut off = f.offscreen_context(w, h); let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; let before = self.stats.moved; @@ -730,15 +752,36 @@ impl Renderer { off.render(&mut pm, &mut self.resources); // What the raster is made from, for its object once it moves (`move_shadow`). let made = - Made { pixels: pm.data().iter().map(|p| p.a).collect(), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; + Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None }; let (wu, hu) = (w as usize, h as usize); - let mut a: Vec = pm.data().iter().map(|p| p.a as f32).collect(); - effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px); - let [cr, cg, cb] = f.ink.rgb(&color); - let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; - for (px, av) in pm.data_mut().iter_mut().zip(&a) { - let al = (av * opacity).clamp(0.0, 255.0); - *px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 }; + let sigma = blur / 2.0 / f.px; + if let Some(darkness) = darkness { + // The object's own colors, blurred, with the black Darkness adds (premultiplied). + let keep = 1.0 - shade(f.ink, darkness); + let mut planes: [Vec; 4] = [ + pm.data().iter().map(|p| p.r as f32).collect(), + pm.data().iter().map(|p| p.g as f32).collect(), + pm.data().iter().map(|p| p.b as f32).collect(), + pm.data().iter().map(|p| p.a as f32).collect(), + ]; + for plane in &mut planes { + effects::pixel::blur_plane(plane, wu, hu, sigma); + } + let [r, g, b, a] = &planes; + for (k, px) in pm.data_mut().iter_mut().enumerate() { + let al = (a[k] * opacity).clamp(0.0, 255.0).round(); + let value = |plane: &Vec| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8; + *px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 }; + } + } else { + let mut a: Vec = pm.data().iter().map(|p| p.a as f32).collect(); + effects::pixel::blur_plane(&mut a, wu, hu, sigma); + let [cr, cg, cb] = f.ink.rgb(&color); + let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8; + for (px, av) in pm.data_mut().iter_mut().zip(&a) { + let al = (av * opacity).clamp(0.0, 255.0); + *px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 }; + } } let image = std::sync::Arc::new(pm); let anchor = reach.origin(); @@ -768,8 +811,9 @@ impl Renderer { /// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with /// the same `inputs`) when only the silhouette's place changed after that frame: made by the /// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's - /// bounds moved by the shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn - /// into a raster placed as that one was against its anchor. The raster is kept as `n`'s, marked + /// bounds moved by the shadow's offset), and whose alpha (and colors, under Darkness) are those + /// of `n`'s silhouette drawn into a raster placed as that one was against its anchor + /// ([`silhouette`]). The raster is kept as `n`'s, marked /// moved, and is drawn at the nearest whole pixel. #[allow(clippy::too_many_arguments)] fn move_shadow( @@ -783,9 +827,9 @@ impl Renderer { inputs: &Inputs, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { - let (dx, dy) = match fx { - RasterFx::DropShadow { dx, dy, .. } => (*dx, *dy), - _ => (0.0, 0.0), + let (dx, dy, colored) = match fx { + RasterFx::DropShadow { dx, dy, darkness, .. } => (*dx, *dy, darkness.is_some()), + _ => (0.0, 0.0, false), }; let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size()); let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| { @@ -809,7 +853,7 @@ impl Renderer { off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); - if !self.shadows.get(&old).is_some_and(|e| pm.data().iter().map(|p| p.a).eq(e.made.pixels.iter().copied())) { + if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) { return; } if let Some(mut e) = self.shadows.remove(&old) { @@ -1028,12 +1072,15 @@ impl Renderer { { continue; } - let (mode, opacity, dx, dy, blur, color) = match fx { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), - RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color), + let (mode, opacity, dx, dy, blur, color, darkness) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None), _ => continue, }; - let filter = shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)); + let filter = match darkness { + Some(_) => shade_filter(blur), + None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)), + }; // The offset moves the content rather than the filter: vello_cpu drops layer content // that lies entirely outside the viewport before filtering. let reach = reach + Vec2::new(dx, dy); @@ -1041,6 +1088,14 @@ impl Renderer { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); paint(r, c, &shifted(fr, dx, dy)); + // Darkness: black over the object's own colors, keeping their alpha. + if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) { + c.set_transform(fr.view); + c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop)); + c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black])); + c.fill_rect(&reach); + c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal)); + } c.pop_layer(); }); } @@ -1702,6 +1757,80 @@ mod tests { assert_eq!(lum(img.pixel(15, 15)), WHITE); } + /// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and + /// right; `shade` "color" or "darkness". + fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document { + let mut d = doc_with( + Rect::new(20.0, 20.0, 60.0, 60.0), + fill, + vec![( + "stylize.dropShadow", + json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal", "shade": shade, "darkness": darkness}), + )], + ); + d.color_mode = mode; + d + } + + /// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB + /// document (the reference app's guide gives the object's color at 0%, + /// page 428), in the cached + /// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with. + #[test] + fn drop_shadow_darkness_darkens_the_objects_own_color() { + let rgb = vectorcraft_doc::ColorMode::Rgb; + let red = Color::rgb(1.0, 0.0, 0.0); + // At 6000 %, the view's center is (65, 65), where only the shadow lies. + let zoomed = Affine::translate((50.0, 50.0)) * Affine::scale(60.0) * Affine::translate((-65.0, -65.0)); + for (darkness, want) in [(0.0, 255), (50.0, 128), (100.0, 0)] { + let d = shaded_doc(rgb, red, "darkness", darkness); + let px = render(&d).pixel(66, 66); + assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}%: {px:?}"); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let px = Renderer::new().render(&d, 100, 100, zoomed, &opts).pixel(50, 50); + assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}% at 6000 %: {px:?}"); + } + } + + /// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow + /// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under + /// full black, and a black object's shadow is 100% black). + #[test] + fn drop_shadow_darkness_adds_black_in_a_cmyk_document() { + let cmyk = vectorcraft_doc::ColorMode::Cmyk; + let inks = |d: &Document| { + let i = Renderer::new().render_region_inks(d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &RenderOptions::default()); + let at = (66 * 100 + 66) * 4; + [i[at], i[at + 1], i[at + 2], i[at + 3]] + }; + let red = Color::cmyk(0.0, 1.0, 1.0, 0.0); + for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] { + let got = inks(&shaded_doc(cmyk, red, "darkness", darkness)); + assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}"); + } + // Half black ink, darkened by half: K 50 + 50 × 50% = K 75. + let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0)); + assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}"); + let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0)); + assert_eq!(got, [0, 0, 0, 255]); + } + + /// Color stays the default: a shadow without the new keys, and one with Color chosen and a + /// Darkness value, draw the same pixels, which a shadow with Darkness does not. + #[test] + fn a_drop_shadow_with_color_draws_as_before() { + let rgb = vectorcraft_doc::ColorMode::Rgb; + let red = Color::rgb(1.0, 0.0, 0.0); + let old = doc_with( + Rect::new(20.0, 20.0, 60.0, 60.0), + red, + vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))], + ); + let before = render(&old).pixels; + assert_eq!(render(&shaded_doc(rgb, red, "color", 40.0)).pixels, before); + assert_ne!(render(&shaded_doc(rgb, red, "darkness", 40.0)).pixels, before); + } + #[test] fn shadow_of_offscreen_object_is_not_culled() { let d = doc_with( @@ -2009,6 +2138,14 @@ mod tests { doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]); assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color"); assert_eq!(drag(&shadowed, &moved(&shadowed, false, ¬ch)), (0, true), "a shadow under a notch"); + // A shadow with Darkness is made from the object's colors: recolored, it is made again. + let shaded = doc_with( + Rect::new(20.0, 20.0, 50.0, 46.0), + Color::rgb(0.9, 0.4, 0.1), + vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "shade": "darkness", "darkness": 50}))], + ); + assert_eq!(drag(&shaded, &moved(&shaded, false, &|_| {})), (1, true), "a shadow with Darkness, moved"); + assert_eq!(drag(&shaded, &moved(&shaded, false, &recolor)), (0, true), "a shadow with Darkness under a new color"); } /// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 58dc65088..aef875a40 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -570,7 +570,8 @@ impl Rules<'_> { } } - /// Shadows and glows as `box-shadow` (type: `text-shadow`), a Gaussian blur as `filter`. + /// Shadows and glows as `box-shadow` (type: `text-shadow`), a Gaussian blur as `filter`. A drop + /// shadow with Darkness is left out and the art marked unsupported. fn effects(&mut self, n: &Node, text: bool, art: &mut Art) { let fx = &n.appearance.effects; if fx.iter().any(|e| e.visible && !vectorcraft_effects::is_raster(&e.id)) { @@ -580,6 +581,7 @@ impl Rules<'_> { let mut filter = None; for f in vectorcraft_effects::raster_effects(fx) { match f { + RasterFx::DropShadow { darkness: Some(_), .. } => art.unsupported("drop shadows with Darkness"), RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => { shadows.push(format!("{} {} {} {}", self.len(dx), self.len(dy), self.len(blur), color_css(&color, opacity))) } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 41632cd0c..b609c1ed2 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1581,7 +1581,8 @@ impl Writer<'_> { } /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (the reference app's stacking order). Photoshop-style + /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow + /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn /// such objects into images first). fn filtered(&mut self, n: &Node) { @@ -1628,6 +1629,15 @@ impl Writer<'_> { format!("", c.to_hex(), fmt_num(o as f64, 3)) }; let body = match f { + // Darkness: the object blurred and moved, each color channel times 1 − darkness. + RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( + "", + sd(*blur), + self.num(*dx), + self.num(*dy), + fmt_num(*opacity as f64, 3), + keep = fmt_num(1.0 - *darkness as f64, 3) + ), RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( "{}", sd(*blur), diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 9e2d55a20..25f595a30 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -2,14 +2,15 @@ //! source, its silhouette blurred, moved or inverted, a colour flood, a coloured silhouette…), //! and the result recognised as one of the effects the canvas draws: Gaussian Blur, Drop Shadow, //! Outer Glow, Inner Glow or Feather. That covers `feGaussianBlur`, `feDropShadow`, the usual -//! shadow chains (offset, blur, flood or colour matrix, composite, merge) and what our export -//! writes, so export → import keeps the effects. +//! shadow chains (offset, blur, flood or color matrix, composite, merge), the object moved and +//! blurred with its colors scaled by `feComponentTransfer` (a drop shadow with Darkness) and what +//! our export writes, so export → import keeps the effects. use std::collections::HashMap; use std::sync::Arc; use serde_json::json; -use usvg::filter::{ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction}; +use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction}; use usvg::roxmltree; use vectorcraft_color::Color; use vectorcraft_doc::Effect; @@ -52,8 +53,8 @@ impl Shape { enum V { /// `SourceGraphic`. Graphic, - /// The source blurred (σ). - Blurred(f64), + /// The source blurred (σ), on linear RGB values when `true`. + Blurred(f64, bool), /// A black silhouette. Alpha(Shape), Flood(Color, f32), @@ -66,6 +67,16 @@ enum V { }, /// The source inside its own blurred silhouette (σ). Feathered(f64), + /// The source blurred (σ `sd`, on linear RGB values when `linear`), moved, its colors times + /// `keep` and its alpha times `opacity`: a drop shadow with Darkness 1 − `keep`. + Shade { + sd: f64, + linear: bool, + dx: f64, + dy: f64, + keep: f32, + opacity: f32, + }, /// Layers, bottom first. Stack(Vec), } @@ -78,17 +89,18 @@ impl V { } } - /// Blurred by σ `sd` (blurs add up as variances). - fn blur(self, sd: f64) -> Option { + /// Blurred by σ `sd` (blurs add up as variances), on linear RGB values when `linear`. + fn blur(self, sd: f64, linear: bool) -> Option { let add = |a: f64| (a * a + sd * sd).sqrt(); Some(match self { v if sd <= 0.0 => v, - V::Graphic => V::Blurred(sd), - V::Blurred(a) => V::Blurred(add(a)), + V::Graphic => V::Blurred(sd, linear), + V::Blurred(a, l) => V::Blurred(add(a), l || linear), V::Alpha(s) => V::Alpha(Shape { sd: add(s.sd), ..s }), V::Paint { shape, color, opacity, clipped: false } => { V::Paint { shape: Shape { sd: add(shape.sd), ..shape }, color, opacity, clipped: false } } + V::Shade { sd: s, linear: l, dx, dy, keep, opacity } => V::Shade { sd: add(s), linear: l || linear, dx, dy, keep, opacity }, _ => return None, }) } @@ -98,11 +110,26 @@ impl V { Some(match self { V::Alpha(s) => V::Alpha(moved(s)), V::Paint { shape, color, opacity, clipped: false } => V::Paint { shape: moved(shape), color, opacity, clipped: false }, - v @ (V::Graphic | V::Blurred(_)) if dx == 0.0 && dy == 0.0 => v, + v @ (V::Graphic | V::Blurred(..)) if dx == 0.0 && dy == 0.0 => v, + V::Graphic => V::Shade { sd: 0.0, linear: false, dx, dy, keep: 1.0, opacity: 1.0 }, + V::Blurred(sd, linear) => V::Shade { sd, linear, dx, dy, keep: 1.0, opacity: 1.0 }, + V::Shade { sd, linear, dx: x, dy: y, keep, opacity } => V::Shade { sd, linear, dx: x + dx, dy: y + dy, keep, opacity }, _ => return None, }) } + /// The source, blurred or moved, with each color channel times `keep` and its alpha times + /// `opacity` (`feComponentTransfer` with linear functions through 0). + fn scaled(self, keep: f32, opacity: f32) -> Option { + let (sd, linear, dx, dy, k, o) = match self { + V::Graphic => (0.0, false, 0.0, 0.0, 1.0, 1.0), + V::Blurred(sd, linear) => (sd, linear, 0.0, 0.0, 1.0, 1.0), + V::Shade { sd, linear, dx, dy, keep, opacity } => (sd, linear, dx, dy, keep, opacity), + _ => return None, + }; + Some(V::Shade { sd, linear, dx, dy, keep: k * keep, opacity: o * opacity }) + } + /// `self` in the alpha of `mask` (`feComposite operator="in"`). fn inside(self, mask: V) -> Option { // The source's alpha is its silhouette. @@ -124,7 +151,7 @@ impl V { fn color_matrix(self, m: &[f32]) -> Option { let shape = match self { V::Alpha(s) => s, - V::Blurred(sd) => Shape { sd, ..Shape::default() }, + V::Blurred(sd, _) => Shape { sd, ..Shape::default() }, _ => return None, }; let row = |r: usize| m.get(r * 5..r * 5 + 5); @@ -163,7 +190,10 @@ fn eval(f: &Filter) -> Option { } }; let v = match p.kind() { - Kind::GaussianBlur(b) => input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()))?, + Kind::GaussianBlur(b) => { + let linear = p.color_interpolation() == ColorInterpolation::LinearRGB; + input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()), linear)? + } Kind::Offset(o) => input(o.input())?.offset(o.dx() as f64, o.dy() as f64)?, Kind::Flood(fl) => V::Flood(color(fl.color()), fl.opacity().get()), Kind::Composite(c) => { @@ -186,8 +216,22 @@ fn eval(f: &Filter) -> Option { TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0, _ => false, }; - match input(c.input())? { - V::Alpha(s) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }), + // A scale (a linear function through 0, or none) of each channel. + let scale = |f: &TransferFunction| match f { + TransferFunction::Identity => Some(1.0), + TransferFunction::Linear { slope, intercept } if *intercept == 0.0 && (0.0..=1.0).contains(slope) => Some(*slope), + _ => None, + }; + // The same scale of R, G and B, on sRGB values (a scale of 0 or 1 is the same on + // linear ones). + let srgb = p.color_interpolation() == ColorInterpolation::SRGB; + let keep = match [c.func_r(), c.func_g(), c.func_b()].map(scale) { + [Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r), + _ => None, + }; + match (input(c.input())?, keep, scale(c.func_a())) { + (V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }), + (v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity)?, _ => return None, } } @@ -219,7 +263,7 @@ pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap continue, - V::Blurred(sd) => ("blur.gaussian", json!({ "radius": blur(sd) })), + V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })), V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })), V::Stack(layers) => match layers.as_slice() { [V::Paint { shape, color, opacity, clipped: false }, V::Graphic] if !shape.inverted => { @@ -232,6 +276,13 @@ pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap { + let v = along(*dx, *dy); + let (darkness, opacity) = (num((1.0 - *keep as f64) * 100.0), num(*opacity as f64 * 100.0)); + let p = json!({ "x": num(v.x), "y": num(v.y), "blur": blur(*sd), "shade": "darkness", "darkness": darkness, "opacity": opacity, "mode": mode }); + ("stylize.dropShadow", p) + } [V::Graphic, V::Paint { shape, color, opacity, clipped: true }] if !shape.moved => { let (color, opacity) = paint(*color, *opacity); let source = if shape.inverted { "edge" } else { "center" }; diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index e50d29a0b..654a522db 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -220,6 +220,16 @@ fn shadows_and_unsupported_art() { let id = add(&mut d, n); let r = one(&d, id, &CssOptions::default()); assert_eq!(prop(&r, "box-shadow"), Some("2px 3px 4px rgba(0, 0, 0, 0.5)")); + // A shadow with Darkness takes the object's own colors, which a CSS shadow can't. + let mut n = rect(&mut d, (0.0, 0.0, 50.0, 50.0), 0.0); + n.appearance.effects.push(Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 2, "y": 3, "blur": 4, "opacity": 50, "shade": "darkness"}), + visible: true, + }); + let shaded = add(&mut d, n); + let r = one(&d, shaded, &CssOptions::default()); + assert!(prop(&r, "box-shadow").is_none() && r.unsupported.is_some(), "{r:?}"); // A star is no box: described as its box, or with rasterize as its picture. let star = Node::path( d.alloc_id(), diff --git a/crates/svg/tests/import_live.rs b/crates/svg/tests/import_live.rs index 3dcc152bc..8715727cc 100644 --- a/crates/svg/tests/import_live.rs +++ b/crates/svg/tests/import_live.rs @@ -184,8 +184,9 @@ fn a_gaussian_blur_becomes_the_effect_with_its_radius() { fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) { let fx = vectorcraft_effects::raster_effects(&effects(d)); match fx.as_slice() { - [RasterFx::DropShadow { dx, dy, blur, color, opacity, mode }] => { + [RasterFx::DropShadow { dx, dy, blur, color, opacity, mode, darkness }] => { assert_eq!(*mode, BlendMode::Normal, "filters composite normally"); + assert_eq!(*darkness, None); (*dx, *dy, *blur, *color, (opacity * 100.0).round()) } other => panic!("{other:?}"), @@ -214,6 +215,38 @@ fn drop_shadow_chains_become_drop_shadows() { } } +/// The object blurred, moved and its colors scaled under it is a drop shadow with Darkness when +/// the blur and the scale work on sRGB values, or when the scale is 0; otherwise, on linear RGB +/// values (the SVG default), it is reported. A blur on linear values whose result goes unused +/// does not count. +#[test] +fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() { + let chain = |space: &str, transfer: &str, slope: f64| { + format!( + r##""## + ) + }; + let srgb = r#" color-interpolation-filters="sRGB""#; + // The scale 0 makes black whatever values it works on. + for (space, slope, darkness) in [(srgb, 0.5, 50.0), ("", 0.0, 100.0)] { + let (d, w) = open(&chain(space, "", slope)); + assert!(w.is_empty(), "{w:?}"); + let fx = effects(&d); + let p = &fx[0].params; + assert_eq!( + (fx[0].id.as_str(), &p["shade"], &p["darkness"], &p["opacity"]), + ("stylize.dropShadow", &json!("darkness"), &json!(darkness), &json!(60.0)) + ); + assert_eq!((&p["x"], &p["y"], &p["blur"]), (&json!(4.0), &json!(5.0), &json!(4.0))); + } + // Scaled on linear values, or blurred on them before an sRGB scale. + for (space, transfer) in [("", ""), ("", srgb)] { + let (d, w) = open(&chain(space, transfer, 0.5)); + assert!(w.iter().any(|w| w.contains("filter")), "{transfer}: {w:?}"); + assert!(effects(&d).is_empty()); + } +} + #[test] fn other_filters_are_reported() { let (d, w) = @@ -239,6 +272,7 @@ fn exported_effects_import_as_the_same_effects() { let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true }; let cases = [ vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "color": "#203040"}))], + vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "shade": "darkness", "darkness": 40.0}))], vec![fx("stylize.outerGlow", json!({"blur": 8.0, "color": "#ffcc00", "opacity": 80.0}))], vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#ffffff", "source": "edge", "mode": "normal"}))], vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#00ff00", "source": "center", "mode": "multiply"}))], @@ -259,6 +293,28 @@ fn exported_effects_import_as_the_same_effects() { } } +#[test] +fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() { + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 50.0}); + n.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + let (canvas, exported) = (render_artboard(&d), resvg_render(&svg, 200, 120)); + // Where only the shadow lies: the red at half its value. + for img in [&canvas, &exported] { + let [r, g, b] = img.over_white(115, 90); + assert!(r.abs_diff(128) <= 2 && g <= 2 && b <= 2, "{:?}", [r, g, b]); + } + assert_similar(&canvas, &exported, 40.0, 0.02); +} + #[test] fn symbols_and_effects_export_import_export_is_stable() { // A doc with a symbol instance carrying a shadow: export → import → export is stable. diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index da3a84760..e95055f75 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -139,6 +139,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), None if id == ZIG_ZAG => zig_zag_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 => { let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default(); @@ -197,6 +198,7 @@ fn choices(effect: &str, key: &str) -> Option { ("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]), ("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]), ("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]), + ("stylize.dropShadow", "shade") => Some(SHADES), ("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]), ("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]), _ => None, @@ -385,6 +387,49 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit values_changed(d) } +/// Drop Shadow (Effect › Stylize › Drop Shadow…). +const DROP_SHADOW: &str = "stylize.dropShadow"; + +/// What Drop Shadow's shadow takes its color from (`shade`), as (label, value). +pub(crate) const SHADES: form::Choices = &[("Color", "color"), ("Darkness", "darkness")]; + +/// Drop Shadow's dialog: Mode, Opacity, the offsets and Blur as the other effects show them, then +/// Color and Darkness as radio buttons, each with its field (the field of the one not chosen is +/// dimmed). Returns whether the values differ from those last previewed. +fn drop_shadow_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool { + let doc = vectorcraft_effects::effect_info(DROP_SHADOW).map(|e| e.params).unwrap_or_default(); + // The generic rows, without the three this dialog shows as radio buttons. + let own: Vec<(String, Value)> = ["shade", "color", "darkness"].iter().filter_map(|k| d.fields.remove(*k).map(|v| (k.to_string(), v))).collect(); + form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(DROP_SHADOW, k, false), &|k| choices(DROP_SHADOW, k), &|k| doc_rank(doc, k), unit); + d.fields.extend(own); + let darkness = d.str("shade").eq_ignore_ascii_case("darkness"); + ui.add_space(4.0); + egui::Grid::new("fx-shade").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + if widgets::radio(ui, tl!("Color"), !darkness, true) { + d.fields.insert("shade".into(), json!("color")); + } + ui.add_enabled_ui(!darkness, |ui| match d.fields.get("color") { + Some(Value::String(_)) | None => { + form::text(ui, d, "color", 128.0); + } + Some(other) => { + widgets::dim_label(ui, &other.to_string()); + } + }); + ui.end_row(); + if widgets::radio(ui, tl!("Darkness"), darkness, true) { + d.fields.insert("shade".into(), json!("darkness")); + } + ui.add_enabled_ui(darkness, |ui| { + if let Some(v) = widgets::range_field(ui, "fx-darkness", d.f64("darkness", 100.0), 0.0..=100.0, "%", 0, 70.0) { + d.fields.insert("darkness".into(), json!(v)); + } + }); + ui.end_row(); + }); + values_changed(d) +} + /// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it /// between its neighbours, press on the graph away from the points to add one there, drag one /// off the graph to remove it (two always stay). @@ -499,9 +544,13 @@ mod tests { #[test] fn fields_that_pick_a_value_are_dropdowns_of_its_values() { // The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`). - for (effect, key) in - [("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points"), ("stylize.innerGlow", "source")] - { + for (effect, key) in [ + ("path.offsetPath", "joins"), + ("distort.zigZag", "points"), + ("distort.roughen", "points"), + ("stylize.innerGlow", "source"), + (DROP_SHADOW, "shade"), + ] { let doc = vectorcraft_effects::effect_info(effect).unwrap().params; let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string(); let values: Vec = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect(); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index c54ec1465..4bb017ae0 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -103,8 +103,8 @@ use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; -pub(crate) use effect::FILM_GRAIN_FIELDS; pub use effect::open as open_effect_dialog; +pub(crate) use effect::{FILM_GRAIN_FIELDS, SHADES}; pub use export_as::open as open_export_as; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index e3f6e0726..f60a29d1a 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -748,6 +748,51 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga assert!(text.lines().any(|l| l == "Reference Point"), "{text}"); } +/// Drop Shadow's dialog has Color and Darkness as radio buttons, each with its field; a click on +/// Darkness previews the shadow with it and OK applies it. +#[test] +fn drop_shadow_chooses_color_or_darkness() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.dialog", json!({"effect": "stylize.dropShadow"})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + run(&mut app, vec![]); + let out = run(&mut app, vec![]); + let text = crate::tests_labels::shapes_text(&out); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + for want in ["Mode:", "Opacity:", "Blur:", "Color", "Darkness"] { + assert!(lines.contains(&want), "no {want:?} in {lines:?}"); + } + assert!(!lines.contains(&"Shade:"), "{lines:?}"); + fn find(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Text(t) if t.galley.text().trim() == "Darkness" => Some(t.visual_bounding_rect().center()), + egui::Shape::Vec(v) => v.iter().find_map(find), + _ => None, + } + } + let at = out.shapes.iter().find_map(|s| find(&s.shape)).unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)], vec![], vec![]] { + run(&mut app, events); + } + let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone(); + assert!(app.session.in_interaction(), "the preview runs"); + assert_eq!(effect(&app).params["shade"], json!("darkness")); + confirm(&mut app).unwrap(); + let params = effect(&app).params; + assert_eq!((params["shade"].clone(), params["darkness"].clone()), (json!("darkness"), json!(100.0))); +} + #[test] fn artboard_options_position_moves_art_only_with_the_tool_option_on() { for move_art in [false, true] { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9834a73f4..71d0548d0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -436,6 +436,7 @@ Round Corners… Zaoblit rohy… Scribble… Čmáranice… Drop Shadow… Vržený stín… + Darkness Tmavost Inner Glow… Vnitřní záře… Outer Glow… Vnější záře… Feather… Měkké okraje… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 840a5f1cc..68eba1886 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -638,6 +638,7 @@ Cyan Cyan Dark Dunkel Darken Abdunkeln + Darkness Dunkelheit dash Strich Dash Pattern Strichmuster Dashed Gestrichelt diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index eab75bf95..8a5717927 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -652,6 +652,7 @@ Cyan Cian Dark Oscuro Darken Oscurecer + Darkness Oscuridad dash guion Dash Pattern Patrón de guiones Dashed Discontinuo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d0eb66c23..1ef3d9db3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -654,6 +654,7 @@ Cyan Cyan Dark Sombre Darken Obscurcir + Darkness Obscurité dash tiret Dash Pattern Motif de tirets Dashed Pointillés diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e2622acd..5989a4079 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -867,6 +867,7 @@ Dark Strokes Sötét vonások Dark Strokes… Sötét vonások… Darken Sötétítés + Darkness Sötétség dash vonal Dash Pattern Szaggatás mintázata Dashed Szaggatott diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d32558078..4fc5a29b2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -652,6 +652,7 @@ Cyan Ciano Dark Scuro Darken Scurisci + Darkness Oscurità dash trattino Dash Pattern Schema del tratteggio Dashed Tratteggiato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 60b0feee0..c126dac5b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1135,6 +1135,7 @@ DXF Options DXF オプション DXF Options Dialog DXF オプションダイアログ Darken 比較 (暗) + Darkness 濃さ Dashed 破線 Dashed Line 破線 Data Recovery データ復帰 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 35e877f4d..ff0a0b02f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1130,6 +1130,7 @@ DXF Options DXF 옵션 DXF Options Dialog DXF 옵션 대화상자 Darken 어둡게 + Darkness 어둡기 Dashed 점선 Dashed Line 점선 Data Recovery 데이터 복구 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aa8178fe4..9af5c261d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -396,6 +396,7 @@ Round Corners… Zaokrąglij narożniki… Scribble… Bazgroły… Drop Shadow… Cień… + Darkness Zaciemnienie Inner Glow… Blask wewnętrzny… Outer Glow… Blask zewnętrzny… Feather… Wtapianie… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c793256e8..f03b1efdf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -650,6 +650,7 @@ Cyan Голубой Dark Тёмный Darken Замена тёмным + Darkness Затемнение dash штрих Dash Pattern Шаблон штрихов Dashed Штриховой diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a2c4a40ca..87fbe7b57 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -646,6 +646,7 @@ Cyan Блакитний Dark Темний Darken Затемнення + Darkness Темність dash штрих Dash Pattern Шаблон штрихів Dashed Штриховий diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a66ca0641..ad468749a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -764,6 +764,7 @@ Dark Strokes Nét tối Dark Strokes… Nét tối… Darken Làm tối + Darkness Độ tối dash nét đứt Dash Pattern Mẫu nét đứt Dashed Nét đứt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a845dfc8d..3dd105ef2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -610,6 +610,7 @@ Cyan 青 Dark 深色 Darken 变暗 + Darkness 暗度 dash 虚线 Dash Pattern 虚线样式 Dashed 虚线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 20f589c21..920b54bae 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -625,6 +625,7 @@ Cyan 青色 Dark 深色 Darken 變暗 + Darkness 暗度 dash 虛線 Dash Pattern 虛線樣式 Dashed 虛線 diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 27b3be5eb..fcafdedb5 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -724,8 +724,8 @@ fn has_options(id: &str) -> bool { vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty())) } -/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours); -/// edits go through `effect.setParams`. +/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colors, and +/// Drop Shadow's Color or Darkness as radio buttons); edits go through `effect.setParams`. fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect) { let t = Tokens::get(ui.ctx()); let defaults = vectorcraft_effects::default_params(&e.id).unwrap_or(Value::Null); @@ -755,6 +755,15 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } return; } + if e.id == "stylize.dropShadow" && key == "shade" { + let cur = v.as_str().unwrap_or_default(); + for (label, value) in crate::dialogs::SHADES { + if widgets::radio(ui, label, cur.eq_ignore_ascii_case(value), true) && !cur.eq_ignore_ascii_case(value) { + change = Some((key.clone(), json!(value))); + } + } + return; + } match v { Value::Bool(b) => { if widgets::check(ui, "", *b, true) { @@ -980,6 +989,46 @@ mod tests { assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow")); } + /// A Drop Shadow's inline editor has Color and Darkness as radio buttons in its Shade row; a + /// click on Darkness sets the effect's `shade`. + #[test] + fn the_drop_shadow_editor_chooses_color_or_darkness() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out + }; + run(&mut app, vec![]); + let out = run(&mut app, vec![]); + // The Shade row's Darkness, below the Darkness value's own row. + fn find(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) if t.galley.text() == "Darkness" => out.push(t.visual_bounding_rect().center()), + egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, out)), + _ => {} + } + } + let mut found = vec![]; + out.shapes.iter().for_each(|c| find(&c.shape, &mut found)); + assert_eq!(found.len(), 2, "{found:?}"); + let at = found.into_iter().max_by(|a, b| a.y.total_cmp(&b.y)).unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness")); + } + #[test] fn drops_pick_the_command() { use vectorcraft_doc::{Appearance, FillLayer}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c0ab119f7..56d685c69 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -578,6 +578,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). +- **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness the shadow is the object's own colors with black added: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K), and in an RGB document each channel is multiplied by 1 − darkness. Darkness 0% gives the object's color; 100% gives black in an RGB document and keeps the colors under 100% black in a CMYK one. SVG export writes the object blurred, moved and darkened with `feComponentTransfer` (screen colors, in a CMYK document too), and SVG import reads it back; CSS export leaves the shadow out and marks the object unsupported. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434). + - **Live objects after document changes:** a blend, envelope or repeat, with or without an effect of its own, is evaluated again when one of these changes: the tiling of a pattern whose tiles an envelope bends (Distort Pattern Fills), a symbol shown inside a symbol, a pattern or an opacity mask, the fonts of its type, a placed document's file once the file is read, and the color settings. A gradient mesh, with or without an effect of its own, is colored again when the color settings change. - **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged.