diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs
index 318c3525..0708352e 100644
--- a/crates/svg/src/import/fx.rs
+++ b/crates/svg/src/import/fx.rs
@@ -4,8 +4,10 @@
//! it, shadows cut into it, or blurred, maps exactly onto drop shadows, inner shadows and a layer
//! blur: `feDropShadow`; `feOffset` + `feGaussianBlur` + `feFlood` + `feComposite` + `feMerge`;
//! the colour-matrix form (alpha → offset → blur → matrix tint → blend), with `feMorphology` as
-//! spread and arithmetic or `out` composites for inner shadows. Anything else is reported back as
-//! unsupported and the layer is drawn without it.
+//! spread and arithmetic or `out` composites for inner shadows. A shadow made from the blurred
+//! layer's tinted alpha, and cut out by that layer (what our exports write, as the canvas casts
+//! drop shadows from what the layer blurs leave), is a drop shadow of that blurred layer. Anything
+//! else is reported back as unsupported and the layer is drawn without it.
use std::collections::HashMap;
@@ -32,11 +34,14 @@ struct Sh {
/// Everything but the shadow (a flood with the shadow cut out): becomes an inner shadow once
/// it's cut to the layer.
inverted: bool,
+ /// Cast from the layer blurred by this sigma rather than from its alpha (our exports: a drop
+ /// shadow is cast from the layer its layer blurs leave).
+ cast: f64,
}
impl Sh {
fn alpha() -> Self {
- Sh { dx: 0.0, dy: 0.0, sigma: 0.0, spread: 0.0, color: Color::BLACK, knockout: false, inner: false, inverted: false }
+ Sh { dx: 0.0, dy: 0.0, sigma: 0.0, spread: 0.0, color: Color::BLACK, knockout: false, inner: false, inverted: false, cast: 0.0 }
}
/// Still exactly the layer's alpha.
fn is_plain(&self) -> bool {
@@ -132,6 +137,8 @@ fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option
) -> Option
{
let s = match get(cm.input())? {
Img::Shadow(s) => s,
Img::Layer(0.0) => Sh::alpha(),
+ // A tint of the blurred layer: a shadow cast from it.
+ Img::Layer(b) => Sh { cast: b, ..Sh::alpha() },
_ => return None,
};
tint(v, s)?
@@ -193,6 +200,10 @@ fn composite(a: Img, b: Img, op: CompositeOperator) -> Option
{
(CompositeOperator::Out, Img::Shadow(s), Img::Shadow(l)) if l.is_plain() && !s.inner && !s.inverted => {
Img::Shadow(Sh { knockout: true, ..s })
}
+ // Cut out where the blurred layer it was cast from is.
+ (CompositeOperator::Out, Img::Shadow(s), Img::Layer(b)) if b > 0.0 && b == s.cast && !s.inner && !s.inverted => {
+ Img::Shadow(Sh { knockout: true, ..s })
+ }
// layer − shadow: inside the layer where the offset copy isn't.
(CompositeOperator::Arithmetic { k1, k2, k3, k4 }, Img::Shadow(s), Img::Shadow(l))
if k1 == 0.0 && k4 == 0.0 && k2 == -1.0 && k3 == 1.0 && l.is_plain() && !s.inner =>
@@ -211,6 +222,14 @@ fn to_effects(img: Img, world: Affine) -> Option> {
return None;
}
let k = length_scale(world);
+ // A shadow cast from a blurred layer is one of its drop shadows only when that is the layer drawn.
+ let drawn = match layers.get(at) {
+ Some(Img::Layer(b)) => *b,
+ _ => 0.0,
+ };
+ if layers.iter().any(|l| matches!(l, Img::Shadow(s) if s.cast > 0.0 && (s.cast != drawn || s.inner || s.inverted))) {
+ return None;
+ }
let mut out = Vec::new();
for (i, l) in layers.iter().enumerate() {
match l {
diff --git a/crates/svg/src/import/fx_tests.rs b/crates/svg/src/import/fx_tests.rs
index 365d8d1c..8fa77e86 100644
--- a/crates/svg/src/import/fx_tests.rs
+++ b/crates/svg/src/import/fx_tests.rs
@@ -128,3 +128,103 @@ fn exported_effects_come_back_as_they_were() {
let c = s.color;
assert!((c.r - 0.1).abs() < 1e-3 && (c.g - 0.2).abs() < 1e-3 && (c.b - 0.3).abs() < 1e-3 && (c.a - 0.4).abs() < 1e-3, "{c:?}");
}
+
+/// Layer blurs one after another, and a drop shadow cast from what they leave, as the canvas
+/// draws them: the export chains the blurs and casts the shadow from the last, and the import
+/// reads that back as one blur as strong as the stack (blurs add their variances) and the same
+/// shadow, drawing as the original did.
+#[test]
+fn stacked_blurs_and_their_shadow_come_back_drawing_the_same() {
+ let mut d = Document::new("t");
+ let p = d.first_page().unwrap();
+ let r = d.create(p, NodeKind::Rectangle, "R", 40.0, 30.0, 80.0, 50.0).unwrap();
+ let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.8), offset: (0.0, 24.0), radius: 4.0, ..Shadow::default() };
+ let node = d.get_mut(r).unwrap();
+ node.fills = vec![Paint::solid(Color::from_hex("336699").unwrap())];
+ node.effects = vec![
+ Effect { kind: EffectKind::LayerBlur(Blur { radius: 12.0, ..Blur::default() }), visible: true },
+ Effect { kind: EffectKind::LayerBlur(Blur { radius: 16.0, ..Blur::default() }), visible: true },
+ Effect { kind: EffectKind::DropShadow(shadow.clone()), visible: true },
+ ];
+ let svg = crate::export_svg(&d, &[r], &crate::SvgOptions::default()).unwrap();
+ assert!(
+ svg.contains(
+ ""
+ ),
+ "{svg}"
+ );
+ assert!(
+ svg.contains(">()[..] else {
+ panic!("{:?}", back.effects)
+ };
+ assert!((b.radius - 20.0).abs() < 1e-3, "two blurs of 12 and 16 as one of 20: {}", b.radius);
+ assert_eq!((s.offset, s.radius, s.show_behind), (shadow.offset, shadow.radius, false));
+ // Drawn by the canvas, the import and the original match (one blur of 20 for two of 12 and
+ // 16: the canvas's box blurs add their variances only nearly).
+ let draw = |d: &Document, id| {
+ let at = d.world_render_bounds(id).unwrap();
+ let v = figcraft_render::View { origin: (at.x0 - 10.0, at.y0 - 10.0), scale: 1.0, width: 220, height: 220 };
+ figcraft_render::render_view_single(d, d.first_page().unwrap(), &v, &figcraft_render::Options::default()).unwrap()
+ };
+ let (a, z) = (draw(&d, r), draw(&d2, back.id));
+ let worst = a.data().iter().zip(z.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
+ assert!(worst <= 6, "the import draws {worst} levels off the original");
+}
+
+/// What our exports write for a blurred layer's shadows comes back as the same effects: a glow
+/// (no offset), a shadow showing behind, one with spread, two shadows; and a blur of 0 changes
+/// nothing about the shadow.
+#[test]
+fn blurred_layers_shadows_come_back_as_they_were() {
+ let sh = |offset: (f64, f64), spread: f64, show_behind: bool| {
+ EffectKind::DropShadow(Shadow { color: Color::new(0.2, 0.0, 0.4, 0.6), offset, radius: 6.0, spread, show_behind, ..Shadow::default() })
+ };
+ let blur = |r: f64| EffectKind::LayerBlur(Blur { radius: r, ..Blur::default() });
+ let cases = [
+ ("a glow", vec![blur(8.0), sh((0.0, 0.0), 0.0, false)]),
+ ("showing behind", vec![blur(8.0), sh((4.0, 6.0), 0.0, true)]),
+ ("two shadows", vec![blur(8.0), sh((4.0, 6.0), 0.0, false), sh((-3.0, 0.0), 0.0, true)]),
+ ("a blur of 0", vec![blur(0.0), sh((4.0, 6.0), 0.0, true)]),
+ ];
+ for (why, effects) in cases {
+ let mut d = Document::new("t");
+ let p = d.first_page().unwrap();
+ let r = d.create(p, NodeKind::Rectangle, "R", 20.0, 20.0, 60.0, 40.0).unwrap();
+ let node = d.get_mut(r).unwrap();
+ node.fills = vec![Paint::solid(Color::from_hex("336699").unwrap())];
+ node.effects = effects.iter().cloned().map(|kind| Effect { kind, visible: true }).collect();
+ let svg = crate::export_svg(&d, &[r], &crate::SvgOptions::default()).unwrap();
+ let mut d2 = Document::new("back");
+ let p2 = d2.first_page().unwrap();
+ let i = import_svg_with_notes(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").unwrap();
+ assert!(i.notes.is_empty(), "{why}: {:?}", i.notes);
+ let back = d2.subtree(i.frame).into_iter().filter_map(|id| d2.node(id)).find(|n| n.kind == NodeKind::Rectangle).unwrap();
+ let shadows = |fx: &mut dyn Iterator- | {
+ fx.filter_map(|k| if let EffectKind::DropShadow(s) = k { Some((s.offset, s.radius, s.show_behind)) } else { None }).collect::>()
+ };
+ assert_eq!(shadows(&mut back.effects.iter().map(|e| e.kind.clone())), shadows(&mut effects.into_iter()), "{why}: {:?}", back.effects);
+ let blurs = back.effects.iter().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).count();
+ assert_eq!(blurs, usize::from(why != "a blur of 0"), "{why}: {:?}", back.effects);
+ }
+}
+
+/// Copies of the blurred layer in its own colours aren't shadows: still reported, not imported
+/// as black ones.
+#[test]
+fn coloured_copies_of_a_blurred_layer_arent_shadows() {
+ for filter in [
+ r##""##,
+ r##""##,
+ ] {
+ let (fx, notes) = effects(filter, "");
+ assert!(!fx.iter().any(|k| matches!(k, EffectKind::DropShadow(_))), "{filter}: {fx:?}");
+ assert!(!notes.is_empty(), "{filter}: reported");
+ }
+}
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 49c4cfd6..0fff3cd0 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -330,7 +330,8 @@ impl W<'_> {
}
/// The layer's filter: drop shadows (but those drawn as `exact` geometry: see
- /// [`W::drop_shadows`]) and layer blur.
+ /// [`W::drop_shadows`]) and layer blurs, as the canvas draws them: the blurs one after another,
+ /// the shadows cast from and cut out by the layer they leave.
fn filter_for(&mut self, n: &Node, exact: bool, xf: Affine) -> Option {
let fx: Vec<&figcraft_doc::Effect> = n
.effects
@@ -362,56 +363,73 @@ impl W<'_> {
),
None => format!(""),
};
- // Shadows that don't show behind transparent areas are cut out where the layer is: by
- // what its paints cover, or by its pixels (like the renderer).
+ // Layer blurs one after another, each blurring what the one before it left (like the
+ // renderer's layer effects); the last result is the layer drawn. A blur of 0 does nothing.
+ let mut layer = "SourceGraphic".to_string();
+ for (i, e) in fx.iter().enumerate() {
+ if let EffectKind::LayerBlur(b) = &e.kind
+ && b.sanitized().radius > 0.0
+ {
+ let _ = write!(f, "", num(b.sanitized().radius / 2.0));
+ layer = format!("l{i}");
+ }
+ }
+ let blurred = layer != "SourceGraphic";
+ let has_shadows = fx.iter().any(|e| matches!(e.kind, EffectKind::DropShadow(_)));
+ // Shadows are cast from the blurred layer's alpha, and those that don't show behind
+ // transparent areas are cut out where the layer is: by what its paints cover, or by its
+ // pixels (blurred).
+ if blurred && has_shadows {
+ let _ = write!(f, "");
+ }
let cover = self.cover_input(n, xf);
- let knock = if cover.is_some() { "cover" } else { "SourceAlpha" };
+ let cast = if blurred { "la" } else { "SourceAlpha" };
+ let knock = if cover.is_some() {
+ "cover"
+ } else if blurred {
+ layer.as_str()
+ } else {
+ "SourceAlpha"
+ };
f.push_str(cover.as_deref().unwrap_or_default());
let mut merge = Vec::new();
for (i, e) in fx.iter().enumerate() {
- match &e.kind {
- EffectKind::DropShadow(s) => {
- // Spread here (layers that aren't one outline) grows the pixels, like the renderer.
- let morph = if s.spread != 0.0 {
- format!(
- "",
- if s.spread > 0.0 { "dilate" } else { "erode" },
- num(s.spread.abs())
- )
- } else {
+ if let EffectKind::DropShadow(s) = &e.kind {
+ // Spread here (layers that aren't one outline) grows the pixels, like the renderer.
+ let morph = if s.spread != 0.0 {
+ format!(
+ "",
+ if s.spread > 0.0 { "dilate" } else { "erode" },
+ num(s.spread.abs())
+ )
+ } else {
+ String::new()
+ };
+ let src = if morph.is_empty() { cast.to_string() } else { format!("s{i}m") };
+ let _ = write!(
+ f,
+ "{morph}{}",
+ num(s.offset.0),
+ num(s.offset.1),
+ num(s.radius / 2.0),
+ num(f64::from(s.color.r)),
+ num(f64::from(s.color.g)),
+ num(f64::from(s.color.b)),
+ num(f64::from(s.color.a)),
+ if s.show_behind {
String::new()
- };
- let src = if morph.is_empty() { "SourceAlpha".to_string() } else { format!("s{i}m") };
- let _ = write!(
- f,
- "{morph}{}",
- num(s.offset.0),
- num(s.offset.1),
- num(s.radius / 2.0),
- num(f64::from(s.color.r)),
- num(f64::from(s.color.g)),
- num(f64::from(s.color.b)),
- num(f64::from(s.color.a)),
- if s.show_behind {
- String::new()
- } else {
- format!("")
- }
- );
- merge.push(format!("s{i}"));
- }
- EffectKind::LayerBlur(b) => {
- let _ = write!(f, "", num(b.sanitized().radius / 2.0));
- }
- _ => {}
+ } else {
+ format!("")
+ }
+ );
+ merge.push(format!("s{i}"));
}
}
- let has_blur = fx.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur { .. }));
f.push_str("");
for m in &merge {
let _ = write!(f, "");
}
- let _ = write!(f, "", if has_blur { "blur" } else { "SourceGraphic" });
+ let _ = write!(f, "");
self.defs.push_str(&f);
Some(fid)
}