M8: an arc's spread shadow and mitred stroke get room for their corners (#1802)

* M8: an arc's spread shadow gets room for its mitred corners

Rectangles, frames and ellipses grow by exactly their spread, so their
layers only make room for the spread itself; other shapes make room for
the mitre at sharp corners (up to four times the spread). An ellipse
with arc data (a pie slice, or a ring cut short) counted as an ellipse,
but its ends meet its sides in sharp corners: a spread of 25 on a slice
of 0.3 rad cut its shadow off at the layer's edge (up to 255 levels).

Only an ellipse that goes all the way round now grows by exactly its
spread. `figcraft_geom::full_sweep` is what `ellipse` already used to
tell a whole ellipse or ring from an arc, so the two agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: an arc's mitred stroke gets room for its corners too

Review: `outline_reach` counted every ellipse as an outline whose stroke
mitres end on the grown box, the same mistake as the spread. A thin
slice with a 20-unit mitred stroke and a spread of 10 still had its
shadow cut off (255 levels). It now asks `grows_by_exactly_its_spread`,
which picks the same shapes and leaves arcs out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
김삼현
2026-11-01 14:12:27 -05:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6b2e464361
commit 264bcb1c03
3 changed files with 68 additions and 7 deletions
+11 -6
View File
@@ -942,9 +942,9 @@ impl Node {
matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow)));
let caps =
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, ellipses) end their mitres on the grown box.
let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like();
let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
// 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) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
} else {
0.0
@@ -960,14 +960,19 @@ 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).
/// 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 {
matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like()
match self.kind {
NodeKind::Rectangle | NodeKind::Section { .. } => true,
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
_ => self.kind.is_frame_like(),
}
}
/// 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 ellipses grow by exactly the spread.
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread.
fn spread_miter_reach(&self) -> f64 {
if self.grows_by_exactly_its_spread() {
return 0.0;
+7 -1
View File
@@ -319,6 +319,12 @@ fn line_or_move(path: &mut BezPath, p: Point) {
}
}
/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring,
/// without the sharp corners where an arc's ends meet its sides.
pub fn full_sweep(sweep: f64) -> bool {
finite(sweep).abs() >= TAU - 1e-9
}
/// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in
/// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1.
pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath {
@@ -327,7 +333,7 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let sweep = finite(sweep).clamp(-TAU, TAU);
let start = finite(start);
let inner = finite(inner).clamp(0.0, 1.0);
let full = sweep.abs() >= TAU - 1e-9;
let full = full_sweep(sweep);
let mut path = BezPath::new();
if full && inner <= 0.0 {
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
+50
View File
@@ -142,3 +142,53 @@ fn spread_bands_are_cached_and_hostile_values_are_safe() {
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");
}
}