Files
figcraft/crates/render/src/spread_tests.rs
T
samhyunandClaude Opus 5.5 55821565ac M8: a spread round a stroke with corners keeps its whole shadow
A dashed stroke is cut into dashes with corners wherever they fall round
the outline, and a width profile or a brush can give the stroke corners
of its own. A drop shadow's spread mitres those corners out, but a whole
ellipse or a box still counted as growing by exactly its spread, so the
shadow was cut off at the layer's edge (up to 255 levels).

Such a node now gets the room a star does. Whether the outline's own
mitres end on its box is a check of its own, which the stroke's reach
keeps using, so a node with no spread keeps its bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:21:33 +09:00

250 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shadow spread offsets the shape's outline: ellipses grow as ellipses (not squares), star points
//! stay sharp and reach out along their tips, inner shadows shrink the outline the same way. The
//! renderer and the PDF export share the shadow code (`shadow`), so they agree.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, NodeId, NodeKind, Shadow};
use super::*;
fn shadow(spread: f64) -> Shadow {
Shadow { color: Color::BLACK, offset: (0.0, 0.0), radius: 0.0, spread, show_behind: true, ..Default::default() }
}
/// A white shape of `kind` at (100, 100), 200 × 200, on a transparent page, with `effects`.
fn shape(kind: NodeKind, effects: Vec<EffectKind>) -> (Document, NodeId) {
let mut d = Document::new("spread");
let p = d.first_page().unwrap();
let n = d.create(p, kind, "S", 100.0, 100.0, 200.0, 200.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::solid(Color::WHITE)];
node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect();
(d, n)
}
/// Node `id` drawn on a transparent 400 × 400 view of the page.
fn render(d: &Document, id: NodeId) -> Pixmap {
let mut pix = Pixmap::new(400, 400).unwrap();
render_nodes_into(d, &[id], &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }, &mut pix);
pix
}
/// Alpha at the page point `(x, y)` (pixel centres at +0.5).
fn alpha(p: &Pixmap, x: f64, y: f64) -> u8 {
let i = ((y as u32 * p.width() + x as u32) * 4) as usize;
p.data()[i + 3]
}
fn ellipse() -> NodeKind {
NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }
}
#[test]
fn drop_shadow_spread_grows_an_ellipse_as_an_ellipse() {
let (d, n) = shape(ellipse(), vec![EffectKind::DropShadow(shadow(20.0))]);
let p = render(&d, n);
// Along the diagonal, 100 from the centre (200, 200) is the edge: the shadow ends 20 further.
let at = |r: f64| alpha(&p, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2);
assert_eq!(at(117.0), 255);
assert_eq!(at(123.0), 0, "a square spread would reach ~128 along the diagonal");
// And along the axes.
assert_eq!(alpha(&p, 200.5, 82.0), 255);
assert_eq!(alpha(&p, 200.5, 78.0), 0);
}
#[test]
fn negative_spread_shrinks_an_ellipse_as_an_ellipse() {
// Shown behind the (half transparent) shape, a shrunk shadow ends 20 inside the edge.
let (mut d, n) = shape(ellipse(), vec![EffectKind::DropShadow(shadow(-20.0))]);
d.get_mut(n).unwrap().fills = vec![];
d.get_mut(n).unwrap().strokes = vec![];
let sil = shadow::silhouette(&d, d.node(n).unwrap());
assert!(sil.is_none(), "no fill, no stroke: nothing casts by outline");
let (d, n) = shape(ellipse(), vec![EffectKind::DropShadow(shadow(-20.0))]);
let node = d.node(n).unwrap();
let sil = shadow::silhouette(&d, node).unwrap();
let xf = Affine::translate((100.0, 100.0));
let a = shadow::spread_alpha(&sil, -20.0, xf, 400, 400).unwrap();
let at = |r: f64| alpha(&a, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2);
assert_eq!(at(77.0), 255);
assert_eq!(at(83.0), 0);
}
#[test]
fn spread_keeps_star_points_sharp() {
let star = NodeKind::Star { count: 5, inner_ratio: 0.381_966 };
let (d, n) = shape(star, vec![EffectKind::DropShadow(shadow(10.0))]);
let p = render(&d, n);
// The top point is at (200, 100) with a 36° tip: offsetting its sides by 10 moves the mitred
// tip 10 / sin(18°) ≈ 32 up. A square or round spread ends 10 above it.
assert_eq!(alpha(&p, 200.5, 75.0), 255);
assert_eq!(alpha(&p, 200.5, 64.0), 0);
}
#[test]
fn rectangle_spread_grows_the_corner_radius() {
let (mut d, n) = shape(NodeKind::Rectangle, vec![EffectKind::DropShadow(shadow(20.0))]);
d.get_mut(n).unwrap().corners.radii = [40.0; 4];
let p = render(&d, n);
// Corner centre at (140, 140); the shadow's corner is a radius 60 arc around it.
let at = |r: f64| alpha(&p, 140.0 - r * std::f64::consts::FRAC_1_SQRT_2, 140.0 - r * std::f64::consts::FRAC_1_SQRT_2);
assert_eq!(at(57.0), 255);
assert_eq!(at(63.0), 0);
// Sharp corners stay sharp (CSS box-shadow spread).
let (d, n) = shape(NodeKind::Rectangle, vec![EffectKind::DropShadow(shadow(20.0))]);
let p = render(&d, n);
assert_eq!(alpha(&p, 81.0, 81.0), 255);
}
#[test]
fn inner_shadow_spread_shrinks_the_outline() {
// A black inner shadow, no blur or offset: a ring 20 wide inside the ellipse's edge.
let (d, n) = shape(ellipse(), vec![EffectKind::InnerShadow(shadow(20.0))]);
let p = render(&d, n);
let red = |r: f64| {
let (x, y) = (200.0 + r * std::f64::consts::FRAC_1_SQRT_2, 200.0 + r * std::f64::consts::FRAC_1_SQRT_2);
let i = ((y as u32 * p.width() + x as u32) * 4) as usize;
p.data()[i]
};
assert_eq!(red(83.0), 0, "inside the ring: shadow");
assert_eq!(red(77.0), 255, "past the ring: the white fill");
}
#[test]
fn spread_of_layers_without_one_outline_grows_their_pixels() {
// A group has no outline of its own: its pixels spread (square), as before.
let mut d = Document::new("g");
let p = d.first_page().unwrap();
let g = d.create(p, NodeKind::Group, "G", 100.0, 100.0, 200.0, 200.0).unwrap();
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::WHITE)];
d.get_mut(g).unwrap().effects = vec![Effect { kind: EffectKind::DropShadow(shadow(10.0)), visible: true }];
assert!(shadow::silhouette(&d, d.node(g).unwrap()).is_none());
let pix = render(&d, g);
assert_eq!(alpha(&pix, 92.0, 92.0), 255);
assert_eq!(alpha(&pix, 88.0, 88.0), 0);
}
#[test]
fn spread_bands_are_cached_and_hostile_values_are_safe() {
let (d, n) = shape(ellipse(), vec![]);
let sil = shadow::silhouette(&d, d.node(n).unwrap()).unwrap();
let a = shadow::spread_alpha(&sil, 7.0, Affine::IDENTITY, 300, 300).unwrap();
let b = shadow::spread_alpha(&sil, 7.0, Affine::IDENTITY, 300, 300).unwrap();
assert_eq!(a.data(), b.data());
for s in [f64::NAN, f64::INFINITY, -f64::INFINITY, 1e12, -1e12, 0.0] {
assert!(shadow::spread_alpha(&sil, s, Affine::IDENTITY, 50, 50).is_some());
assert!(shadow::spread_alpha(&sil, 3.0, Affine::scale(s), 50, 50).is_some());
}
for s in [f64::NAN, 1e12, -1e12] {
let (d, n) =
shape(NodeKind::Star { count: 5, inner_ratio: 0.4 }, vec![EffectKind::DropShadow(shadow(s)), EffectKind::InnerShadow(shadow(s))]);
let _ = render(&d, n);
}
}
/// An arc (a pie slice, or a ring cut short) has sharp corners where its ends meet its sides, and
/// a spread mitres them out past the spread: its layer makes room for that, as a star's does. A
/// clear shadow spread four times as far gives the same layer room to the full mitre: the shadow
/// it casts is the same. (Both layers end at the view here, so they line up pixel for pixel;
/// layers starting at different pixels can differ by a few levels at their edges.)
#[test]
fn arc_spread_mitres_are_not_cut_off() {
for kind in [
NodeKind::Ellipse { start: 1.0, sweep: 0.3, inner: 0.0 },
NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.0 },
NodeKind::Ellipse { start: 1.0, sweep: 2.0, inner: 0.8 },
] {
for spread in [25.0, 40.0] {
let (d, n) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread))]);
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(spread * 4.0) };
let (roomy, m) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread)), EffectKind::DropShadow(clear)]);
let (a, b) = (render(&d, n), render(&roomy, m));
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
assert!(worst <= 2, "{kind:?}, a spread of {spread}: {worst} levels cut off");
}
}
}
/// A mitred stroke on an arc reaches out past its width at the arc's corners too, and a spread
/// grows from there: a thin slice with both keeps its whole shadow.
#[test]
fn arc_stroke_mitres_are_not_cut_off() {
use figcraft_doc::{StrokeAlign, StrokeJoin};
let kind = NodeKind::Ellipse { start: std::f64::consts::FRAC_PI_2 - 0.015, sweep: 0.03, inner: 0.0 };
for align in [StrokeAlign::Center, StrokeAlign::Outside] {
let make = |effects: Vec<EffectKind>| {
let mut d = Document::new("arc");
let p = d.first_page().unwrap();
let n = d.create(p, kind.clone(), "S", 0.0, 190.0, 400.0, 20.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::solid(Color::WHITE)];
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (20.0, align, StrokeJoin::Miter, 4.0);
node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect();
(d, n)
};
let (d, n) = make(vec![EffectKind::DropShadow(shadow(10.0))]);
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(80.0) };
let (roomy, m) = make(vec![EffectKind::DropShadow(shadow(10.0)), EffectKind::DropShadow(clear)]);
let (a, b) = (render(&d, n), render(&roomy, m));
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
assert!(worst <= 2, "{align:?}: {worst} levels cut off");
}
}
/// A dashed stroke is cut into separate dashes with square ends, a width profile can step its
/// width and a brush lays down shapes of its own: round a whole ellipse or a rounded box, a spread
/// mitres out at their corners, past the box grown by the spread, and keeps its whole shadow.
#[test]
fn stroke_corners_spread_mitres_are_not_cut_off() {
use figcraft_doc::{Brush, Node, StrokeAlign, StrokeCap, WidthProfile};
type Style = fn(&mut Node);
let strokes: [(&str, Style); 4] = [
("dashed", |n| n.stroke.dashes = vec![30.0, 30.0]),
("dashed with square caps", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![30.0, 30.0], Some(StrokeCap::Square))),
("stepped", |n| n.stroke.width_profile = Some(WidthProfile::new([(0.0, 1.0), (0.25, 4.0), (0.26, 0.2), (0.75, 4.0)]))),
("starry", |n| n.stroke.brush = Some(Brush::new("stars"))),
];
let shapes = [(NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, 0.0), (NodeKind::Rectangle, 100.0)];
for (kind, radius) in shapes {
for (name, style) in strokes {
for align in [StrokeAlign::Outside, StrokeAlign::Center] {
let make = |effects: Vec<EffectKind>| {
let (mut d, n) = shape(kind.clone(), effects);
let node = d.get_mut(n).unwrap();
node.corners.radii = [radius; 4];
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align) = (20.0, align);
style(node);
(d, n)
};
let (d, n) = make(vec![EffectKind::DropShadow(shadow(30.0))]);
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(120.0) };
let (roomy, m) = make(vec![EffectKind::DropShadow(shadow(30.0)), EffectKind::DropShadow(clear)]);
let (a, b) = (render(&d, n), render(&roomy, m));
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
// The stroke lands a little differently on a layer of another size, even when
// both have room to spare: up to 16 levels at a pixel or two. Cut off is 255.
assert!(worst <= 16, "{kind:?} radius {radius}, {name} {align:?}: {worst} levels cut off");
}
}
}
}
/// Without a spread, a dashed stroke reaches no further than a solid one: the bounds an export
/// goes by stay the box grown by the stroke.
#[test]
fn a_dashed_stroke_without_a_spread_keeps_its_bounds() {
use figcraft_doc::StrokeAlign;
for kind in [NodeKind::Rectangle, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }] {
let (mut d, n) = shape(kind.clone(), vec![]);
let node = d.get_mut(n).unwrap();
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align) = (20.0, StrokeAlign::Outside);
node.stroke.dashes = vec![30.0, 30.0];
let b = crate::export_bounds(&d, n).unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (80.0, 80.0, 320.0, 320.0), "{kind:?}");
}
}