M8: a scaled node's spread shadow keeps its room in SVG (#1803)

The SVG export blurs a spread drop shadow in a filter whose region is
the silhouette's page bounds grown by the spread's mitre and the blur.
The spread grows the outline in the node's own units, though, and the
transform then scales it: on a star scaled three times, a spread of 10
mitred out to some 97 units while the region only made room for 48, so
SVG viewers cut the shadow's points off. The spread now grows the region
as far across and down as the transform takes a local step of that
length, as the paint bounds already do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
김삼현andClaude Opus 5.5 authored and GitHub committed 2026-11-01 14:12:23 -05:00
1 parent 83c28cf3ae
commit 6b2e464361
2 files changed
+49 -3

No files matched your search

+42
View File
@@ -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("<filter ").unwrap()..];
let attr = |name: &str| -> 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:?}");
}
}
+7 -3
View File
@@ -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()