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>
This commit is contained in:
samhyunandClaude Opus 5.5 committed 2026-11-02 14:21:33 +09:00
1 parent f9b5693621
commit 55821565ac
2 files changed
+72 -5

No files matched your search

+17 -5
View File
@@ -944,7 +944,7 @@ impl Node {
if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w };
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
// box; an arc's corners don't.
let miter = if self.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
} else {
0.0
@@ -960,9 +960,10 @@ impl Node {
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
}
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc
/// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides.
fn grows_by_exactly_its_spread(&self) -> bool {
/// Whether this node's outline has no sharp corners (right-angled ones aside), so mitres along
/// it end on its box grown by as much: a box or a whole ellipse. An arc (an ellipse that doesn't
/// go all the way round) has corners where its ends meet its sides.
fn outline_mitres_end_on_its_box(&self) -> bool {
match self.kind {
NodeKind::Rectangle | NodeKind::Section { .. } => true,
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
@@ -970,9 +971,20 @@ impl Node {
}
}
/// Whether a spread grows this node by exactly the spread: its outline mitres end on its box
/// (see [`Node::outline_mitres_end_on_its_box`]), and so does its stroke. 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, which the spread mitres out.
fn grows_by_exactly_its_spread(&self) -> bool {
let s = &self.stroke;
let cornered = self.has_visible_stroke() && (!s.dashes.is_empty() || s.profile().is_some() || s.brush.is_some());
!cornered && self.outline_mitres_end_on_its_box()
}
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
/// spread moves the edges out in parallel and they meet in a mitre (see
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread.
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread,
/// unless their stroke has corners (see [`Node::grows_by_exactly_its_spread`]).
fn spread_miter_reach(&self) -> f64 {
if self.grows_by_exactly_its_spread() {
return 0.0;
+55
View File
@@ -192,3 +192,58 @@ fn arc_stroke_mitres_are_not_cut_off() {
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:?}");
}
}