diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 6ec7e8245..3ac81afdb 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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; diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 012fca429..696a02e92 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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); diff --git a/crates/render/src/spread_tests.rs b/crates/render/src/spread_tests.rs index 00fbb6333..6b30766fc 100644 --- a/crates/render/src/spread_tests.rs +++ b/crates/render/src/spread_tests.rs @@ -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| { + 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"); + } +}