diff --git a/crates/svg/src/effects_tests.rs b/crates/svg/src/effects_tests.rs index 5177e992b..f81d8e04c 100644 --- a/crates/svg/src/effects_tests.rs +++ b/crates/svg/src/effects_tests.rs @@ -83,3 +83,45 @@ fn hostile_shadow_values_still_export() { assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some()); } } + +/// The filter a spread shadow is blurred in makes room for the spread as the node's transform +/// draws it: on a star scaled three times, the spread's mitres reach three times as far, and on a +/// skewed one further across than down. +#[test] +fn a_scaled_spread_shadow_keeps_its_filter_room() { + use figcraft_doc::SPREAD_MITER_LIMIT; + use figcraft_geom::{Affine, kurbo}; + let star = NodeKind::Star { count: 5, inner_ratio: 0.4 }; + let s = + Shadow { color: Color::from_hex("000000").unwrap(), offset: (6.0, -9.0), radius: 4.0, spread: 10.0, show_behind: true, ..Default::default() }; + for (why, xf) in [ + ("scaled 3", Affine::scale(3.0)), + ("scaled (3, 1)", Affine::scale_non_uniform(3.0, 1.0)), + ("skewed", Affine::new([1.0, 0.0, 2.0, 1.0, 0.0, 0.0])), + ("turned and stretched", Affine::rotate(0.5) * Affine::scale_non_uniform(3.0, 1.0)), + ("as drawn", Affine::IDENTITY), + ] { + let (mut d, n) = with_effects(star.clone(), vec![EffectKind::DropShadow(s.clone())]); + let t = d.get(n).unwrap().transform; + d.get_mut(n).unwrap().transform = t * xf; + let svg = export(&d, n); + let tag = &svg[svg.find(" f64 { + let at = tag.find(&format!(" {name}=\"")).unwrap() + name.len() + 3; + tag[at..at + tag[at..].find('"').unwrap()].parse().unwrap() + }; + let region = kurbo::Rect::new(attr("x"), attr("y"), attr("x") + attr("width"), attr("y") + attr("height")); + // What the shadow paints, as the renderer draws it: the outline stroked twice the spread + // wide with mitred joins, through the node's transform, moved by the offset (in the + // export's frame, which starts at the node's export bounds) and blurred. + let bounds = figcraft_render::export_bounds(&d, n).unwrap(); + let at = Affine::translate((s.offset.0 - bounds.x0, s.offset.1 - bounds.y0)) * d.world_transform(n); + let shape = figcraft_render::node_outline(&d, d.get(n).unwrap()).unwrap(); + let stroke = kurbo::Stroke::new(s.spread * 2.0).with_join(kurbo::Join::Miter).with_miter_limit(SPREAD_MITER_LIMIT); + let mut grown = kurbo::stroke(shape.0.iter(), &stroke, &Default::default(), 0.01); + grown.apply_affine(at); + let blur = s.radius * 1.5; + let painted = figcraft_geom::Shape::bounding_box(&grown).inflate(blur, blur); + assert!(region.union(painted) == region, "{why}: the filter {region:?} cuts the shadow at {painted:?}"); + } +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index de98795d3..58d80ac91 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -1052,10 +1052,14 @@ impl W<'_> { else { continue; }; - let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0; + // The spread grows the outline in its own units (mitres up to the limit), drawn through + // `xf`: as far across and down as a local step that long goes. The blur is in page units. + let spread = if s.spread.is_finite() { s.spread.abs() * SPREAD_MITER_LIMIT } else { 0.0 }; + let [a, b_, c, d, _, _] = xf.as_coeffs(); + let blur = s.radius.abs() * 1.5 + 2.0; + let region = b.inflate(spread * a.hypot(c) + blur, spread * b_.hypot(d) + blur); let cover = if s.show_behind { None } else { self.cover_input(n, xf) }; - let filter = - self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); + let filter = self.blur_filter(s.radius, region, cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0)); let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { String::new()