diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d1ee648f7..7f85813d7 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -948,7 +948,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 @@ -964,9 +964,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), @@ -974,9 +975,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; diff --git a/crates/render/src/spread_tests.rs b/crates/render/src/spread_tests.rs index 6b30766fc..ec41dd8dd 100644 --- a/crates/render/src/spread_tests.rs +++ b/crates/render/src/spread_tests.rs @@ -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| { + 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:?}"); + } +}