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) }