mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Effects: Drop Shadow has Darkness, the object's own colors with black added
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.
This commit is contained in:
@@ -308,8 +308,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"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",
|
||||
|
||||
@@ -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<f32>,
|
||||
},
|
||||
OuterGlow {
|
||||
mode: BlendMode,
|
||||
@@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
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")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
+160
-23
@@ -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<u8> {
|
||||
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<u8>,
|
||||
/// The effects run on them.
|
||||
fx: Vec<RasterFx>,
|
||||
@@ -684,9 +705,9 @@ impl Renderer {
|
||||
owner: Option<&Arc<Node>>,
|
||||
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<f32> = 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<f32>; 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<f32>| (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<f32> = 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%,
|
||||
/// <https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf> 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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -1581,7 +1581,8 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
|
||||
/// per effect, the first effect innermost (the reference app's stacking order). 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!("<feFlood flood-color=\"{}\" flood-opacity=\"{}\"/>", 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!(
|
||||
"<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\"/><feComponentTransfer result=\"paint\"><feFuncR type=\"linear\" slope=\"{keep}\"/><feFuncG type=\"linear\" slope=\"{keep}\"/><feFuncB type=\"linear\" slope=\"{keep}\"/><feFuncA type=\"linear\" slope=\"{}\"/></feComponentTransfer><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
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!(
|
||||
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\" result=\"shadow\"/>{}<feComposite in2=\"shadow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
sd(*blur),
|
||||
|
||||
+66
-15
@@ -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<V>),
|
||||
}
|
||||
@@ -78,17 +89,18 @@ impl V {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blurred by σ `sd` (blurs add up as variances).
|
||||
fn blur(self, sd: f64) -> Option<V> {
|
||||
/// Blurred by σ `sd` (blurs add up as variances), on linear RGB values when `linear`.
|
||||
fn blur(self, sd: f64, linear: bool) -> Option<V> {
|
||||
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<V> {
|
||||
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<V> {
|
||||
// The source's alpha is its silhouette.
|
||||
@@ -124,7 +151,7 @@ impl V {
|
||||
fn color_matrix(self, m: &[f32]) -> Option<V> {
|
||||
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<V> {
|
||||
}
|
||||
};
|
||||
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<V> {
|
||||
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<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
|
||||
let (id, params) = match eval(f)? {
|
||||
V::Graphic => 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<Filter>], ts: Affine, blends: &HashMap<Stri
|
||||
("stylize.outerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode }))
|
||||
}
|
||||
}
|
||||
// A drop shadow with Darkness below 100% blurs the object's sRGB values.
|
||||
[V::Shade { sd, linear, dx, dy, keep, opacity }, V::Graphic] if !*linear || *keep == 0.0 => {
|
||||
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" };
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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##"<defs><filter id="s"{space}><feGaussianBlur in="SourceGraphic" stdDeviation="3" color-interpolation-filters="linearRGB" result="unused"/><feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="4" dy="5"/><feComponentTransfer result="p"{transfer}><feFuncR type="linear" slope="{slope}"/><feFuncG type="linear" slope="{slope}"/><feFuncB type="linear" slope="{slope}"/><feFuncA type="linear" slope="0.6"/></feComponentTransfer><feMerge><feMergeNode in="p"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="50" y="30" width="60" height="40" fill="#f00" filter="url(#s)"/>"##
|
||||
)
|
||||
};
|
||||
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.
|
||||
|
||||
@@ -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<form::Choices> {
|
||||
("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<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<egui::Event>| {
|
||||
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<egui::Pos2> {
|
||||
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] {
|
||||
|
||||
@@ -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…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -638,6 +638,7 @@
|
||||
Cyan Cyan
|
||||
Dark Dunkel
|
||||
Darken Abdunkeln
|
||||
Darkness Dunkelheit
|
||||
dash Strich
|
||||
Dash Pattern Strichmuster
|
||||
Dashed Gestrichelt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -652,6 +652,7 @@
|
||||
Cyan Cian
|
||||
Dark Oscuro
|
||||
Darken Oscurecer
|
||||
Darkness Oscuridad
|
||||
dash guion
|
||||
Dash Pattern Patrón de guiones
|
||||
Dashed Discontinuo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -654,6 +654,7 @@
|
||||
Cyan Cyan
|
||||
Dark Sombre
|
||||
Darken Obscurcir
|
||||
Darkness Obscurité
|
||||
dash tiret
|
||||
Dash Pattern Motif de tirets
|
||||
Dashed Pointillés
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -652,6 +652,7 @@
|
||||
Cyan Ciano
|
||||
Dark Scuro
|
||||
Darken Scurisci
|
||||
Darkness Oscurità
|
||||
dash trattino
|
||||
Dash Pattern Schema del tratteggio
|
||||
Dashed Tratteggiato
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1135,6 +1135,7 @@
|
||||
DXF Options DXF オプション
|
||||
DXF Options Dialog DXF オプションダイアログ
|
||||
Darken 比較 (暗)
|
||||
Darkness 濃さ
|
||||
Dashed 破線
|
||||
Dashed Line 破線
|
||||
Data Recovery データ復帰
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1130,6 +1130,7 @@
|
||||
DXF Options DXF 옵션
|
||||
DXF Options Dialog DXF 옵션 대화상자
|
||||
Darken 어둡게
|
||||
Darkness 어둡기
|
||||
Dashed 점선
|
||||
Dashed Line 점선
|
||||
Data Recovery 데이터 복구
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -650,6 +650,7 @@
|
||||
Cyan Голубой
|
||||
Dark Тёмный
|
||||
Darken Замена тёмным
|
||||
Darkness Затемнение
|
||||
dash штрих
|
||||
Dash Pattern Шаблон штрихов
|
||||
Dashed Штриховой
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -646,6 +646,7 @@
|
||||
Cyan Блакитний
|
||||
Dark Темний
|
||||
Darken Затемнення
|
||||
Darkness Темність
|
||||
dash штрих
|
||||
Dash Pattern Шаблон штрихів
|
||||
Dashed Штриховий
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -610,6 +610,7 @@
|
||||
Cyan 青
|
||||
Dark 深色
|
||||
Darken 变暗
|
||||
Darkness 暗度
|
||||
dash 虚线
|
||||
Dash Pattern 虚线样式
|
||||
Dashed 虚线
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -625,6 +625,7 @@
|
||||
Cyan 青色
|
||||
Dark 深色
|
||||
Darken 變暗
|
||||
Darkness 暗度
|
||||
dash 虛線
|
||||
Dash Pattern 虛線樣式
|
||||
Dashed 虛線
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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<usize>, 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<usize>, 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<egui::Event>| {
|
||||
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<egui::Pos2>) {
|
||||
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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user