//! Variable-width strokes: the shape Stroke's **Taper** (start/end length, width and ease) and //! **Wave** (amount, wavelength or cycles, phase), with dashes cut along the same arc length. //! //! The stroke is built as a union of positively-wound primitives: one trapezoid per flattened //! segment (its two ends at the local widths), a disc at every bend (round joins) and the caps. //! Filled with the non-zero rule the union is exact, whatever the width profile does. use kurbo::{BezPath, PathEl, Point, Shape, Vec2}; use crate::{Cap, StrokeStyle}; /// Stroke Taper. Lengths are pixels, or fractions of each path's length when `percent`; widths and /// eases are fractions (0–1). A width of 0 tapers to a point. #[derive(Clone, Debug, PartialEq)] pub struct Taper { pub percent: bool, pub start_len: f64, pub end_len: f64, pub start_width: f64, pub end_width: f64, pub start_ease: f64, pub end_ease: f64, } impl Taper { pub fn is_active(&self) -> bool { (self.start_len > 0.0 && self.start_width < 1.0) || (self.end_len > 0.0 && self.end_width < 1.0) } } /// Stroke Wave: the width oscillates between `1 - amount` and 1 of the stroke width. #[derive(Clone, Debug, PartialEq)] pub struct Wave { /// 0–1. pub amount: f64, /// Units: `Some(cycles)` = cycles along each path, else `wavelength` in pixels. pub cycles: Option, pub wavelength: f64, /// Degrees. pub phase: f64, } impl Wave { pub fn is_active(&self) -> bool { self.amount > 0.0 && self.cycles.map_or(self.wavelength > 0.0, |c| c > 0.0) } } /// Ease profile from the tip (`u = 0`) to full width (`u = 1`): linear at ease 0, a quarter /// circle (bulging outwards) at ease 1. fn ease(u: f64, e: f64) -> f64 { let u = u.clamp(0.0, 1.0); let round = (1.0 - (1.0 - u) * (1.0 - u)).max(0.0).sqrt(); u + (round - u) * e.clamp(0.0, 1.0) } /// Width factor (0–1) at arc length `s` of a path `len` long. pub fn width_factor(taper: Option<&Taper>, wave: Option<&Wave>, s: f64, len: f64) -> f64 { let mut k = 1.0; if let Some(t) = taper.filter(|t| t.is_active()) { let (mut ls, mut le) = if t.percent { (t.start_len * len, t.end_len * len) } else { (t.start_len, t.end_len) }; ls = ls.max(0.0); le = le.max(0.0); // Overlapping tapers share the path in proportion. if ls + le > len && ls + le > 0.0 { let f = len / (ls + le); ls *= f; le *= f; } let sw = t.start_width.clamp(0.0, 1.0); let ew = t.end_width.clamp(0.0, 1.0); if ls > 0.0 && s < ls { k = f64::min(k, sw + (1.0 - sw) * ease(s / ls, t.start_ease)); } if le > 0.0 && s > len - le { k = f64::min(k, ew + (1.0 - ew) * ease((len - s) / le, t.end_ease)); } } if let Some(w) = wave.filter(|w| w.is_active()) { let lambda = match w.cycles { Some(c) => len / c, None => w.wavelength, }; if lambda > 0.0 { let ph = std::f64::consts::TAU * s / lambda + w.phase.to_radians(); k *= 1.0 - w.amount.clamp(0.0, 1.0) * (0.5 - 0.5 * ph.cos()); } } k } /// A flattened subpath: points and their cumulative arc lengths. struct Poly { pts: Vec, s: Vec, closed: bool, } impl Poly { fn len(&self) -> f64 { self.s.last().copied().unwrap_or(0.0) } /// Point at arc length `t` (clamped), and the index of the segment it lies on. fn at(&self, t: f64) -> (Point, usize) { let i = self.s.partition_point(|&x| x <= t).clamp(1, self.pts.len() - 1); let (a, b) = (self.s[i - 1], self.s[i]); let u = if b > a { ((t - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; (self.pts[i - 1].lerp(self.pts[i], u), i) } } /// Flatten each subpath, then subdivide so no segment is longer than `step`. fn flatten(path: &BezPath, tol: f64, step: f64) -> Vec { let mut out: Vec = Vec::new(); let mut cur: Vec = Vec::new(); let mut closed = false; let flush = |cur: &mut Vec, closed: bool, out: &mut Vec| { let mut pts: Vec = Vec::with_capacity(cur.len()); for &p in cur.iter() { if pts.last().is_none_or(|q: &Point| q.distance(p) > 1e-9) { pts.push(p); } } if closed && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) > 1e-9 { pts.push(pts[0]); } cur.clear(); if pts.len() < 2 { return; } // Subdivide. let mut fine = vec![pts[0]]; for w in pts.windows(2) { let d = w[0].distance(w[1]); let n = (d / step).ceil().max(1.0) as usize; for k in 1..=n { fine.push(w[0].lerp(w[1], k as f64 / n as f64)); } } let mut s = Vec::with_capacity(fine.len()); let mut acc = 0.0; s.push(0.0); for w in fine.windows(2) { acc += w[0].distance(w[1]); s.push(acc); } out.push(Poly { pts: fine, s, closed }); }; kurbo::flatten(path.elements().iter().copied(), tol, |el| match el { PathEl::MoveTo(p) => { flush(&mut cur, closed, &mut out); closed = false; cur.push(p); } PathEl::LineTo(p) => cur.push(p), PathEl::ClosePath => { closed = true; flush(&mut cur, closed, &mut out); closed = false; } _ => {} }); flush(&mut cur, closed, &mut out); out } /// Arc-length intervals that are "on" for a dash pattern on a path `len` long. pub fn dash_intervals(pattern: &[f64], offset: f64, len: f64) -> Vec<(f64, f64)> { let pat: Vec = pattern.iter().map(|v| v.max(0.0)).collect(); let pat = if pat.len() % 2 == 1 { [pat.clone(), pat].concat() } else { pat }; let period: f64 = pat.iter().sum(); if pat.len() < 2 || period <= 1e-9 || len <= 0.0 { return vec![(0.0, len)]; } // Pattern position at s = 0. let mut pos = offset.rem_euclid(period); let mut idx = 0; while pos >= pat[idx] { pos -= pat[idx]; idx = (idx + 1) % pat.len(); } let mut s = 0.0; let mut out = Vec::new(); let mut guard = 0usize; while s < len && guard < 1_000_000 { guard += 1; let seg = pat[idx] - pos; let e = (s + seg).min(len); if idx % 2 == 0 && e >= s { out.push((s, e)); } s += seg; pos = 0.0; idx = (idx + 1) % pat.len(); } out } /// Append `p` to `out`, wound positively. fn push_pos(out: &mut BezPath, p: BezPath) { let p = if p.area() < 0.0 { p.reverse_subpaths() } else { p }; out.extend(p.elements().iter().copied()); } fn quad(out: &mut BezPath, pts: [Point; 4]) { let mut p = BezPath::new(); p.move_to(pts[0]); p.line_to(pts[1]); p.line_to(pts[2]); p.line_to(pts[3]); p.close_path(); if p.area().abs() > 1e-12 { push_pos(out, p); } } fn disc(out: &mut BezPath, c: Point, r: f64) { if r > 1e-6 { push_pos(out, kurbo::Circle::new(c, r).to_path(0.05)); } } /// Outline of a tapered / waved stroke (local space; `res_scale` = device/local scale). pub fn outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option { if style.width <= 0.0 { return None; } let rs = res_scale.max(0.01); let mut out = BezPath::new(); let half = style.width / 2.0; for path in paths { // Fine enough for the width profile: ½ device pixel, at most ~20 000 samples per path. let rough = flatten(path, 0.1 / rs, f64::INFINITY); for poly0 in rough { let len0 = poly0.len(); if len0 <= 0.0 { continue; } let step = (0.5 / rs).max(len0 / 20_000.0); let mut bp = BezPath::new(); bp.move_to(poly0.pts[0]); for p in &poly0.pts[1..] { bp.line_to(*p); } let poly = flatten(&bp, 0.1 / rs, step).pop()?; let poly = Poly { closed: poly0.closed, ..poly }; let len = poly.len(); let wf = |s: f64| half * width_factor(style.taper.as_ref(), style.wave.as_ref(), s, len); let intervals = match &style.dash { Some((d, off)) if d.iter().any(|v| *v > 0.0) => dash_intervals(d, *off, len), _ => vec![(0.0, len)], }; let whole = intervals.len() == 1 && intervals[0].0 <= 0.0 && intervals[0].1 >= len; for (a, b) in intervals { // The piece's points: interpolated ends plus every vertex strictly inside. let (pa, ia) = poly.at(a); let (pb, ib) = poly.at(b); let mut pts = vec![(pa, a)]; for i in ia..ib { pts.push((poly.pts[i], poly.s[i])); } pts.push((pb, b)); pts.dedup_by(|x, y| x.0.distance(y.0) < 1e-9); if pts.len() < 2 { // A zero-length dash: only its caps show. if matches!(style.cap, Cap::Round) { disc(&mut out, pa, wf(a)); } continue; } let closed = whole && poly.closed; let dir = |i: usize| (pts[i + 1].0 - pts[i].0).normalize(); for i in 0..pts.len() - 1 { let d = dir(i); let n = Vec2::new(-d.y, d.x); let (p0, s0) = pts[i]; let (p1, s1) = pts[i + 1]; let (w0, w1) = (wf(s0), wf(s1)); quad(&mut out, [p0 + n * w0, p1 + n * w1, p1 - n * w1, p0 - n * w0]); // Joins: a disc where the direction turns. if i > 0 && dir(i - 1).dot(d) < 0.9999 { disc(&mut out, p0, w0); } } if closed { disc(&mut out, pts[0].0, wf(0.0)); continue; } // Caps. let last = pts.len() - 1; let ends = [(pts[0].0, wf(pts[0].1), -dir(0)), (pts[last].0, wf(pts[last].1), dir(last - 1))]; for (p, w, outward) in ends { match style.cap { Cap::Butt => {} Cap::Round => disc(&mut out, p, w), Cap::Square => { let n = Vec2::new(-outward.y, outward.x); let q = p + outward * w; quad(&mut out, [p + n * w, q + n * w, q - n * w, p - n * w]); } } } } } } Some(out) } #[cfg(test)] mod tests { use super::*; use crate::{FillRule, fill_coverage}; use effectcraft_geom::Mat3; fn line(len: f64) -> BezPath { let mut p = BezPath::new(); p.move_to((10.0, 50.0)); p.line_to((10.0 + len, 50.0)); p } fn column(m: &effectcraft_raster::Mask, x: usize) -> f32 { (0..m.height as usize).map(|y| m.data[y * m.width as usize + x]).sum() } #[test] fn taper_width_profile() { let t = Taper { percent: false, start_len: 50.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 }; assert!(width_factor(Some(&t), None, 0.0, 200.0).abs() < 1e-9); assert!((width_factor(Some(&t), None, 25.0, 200.0) - 0.5).abs() < 1e-9); assert!((width_factor(Some(&t), None, 100.0, 200.0) - 1.0).abs() < 1e-9); // Ease bulges the profile outwards. let e = Taper { start_ease: 1.0, ..t.clone() }; assert!(width_factor(Some(&e), None, 25.0, 200.0) > 0.8); // Percent units and overlapping tapers. let p = Taper { percent: true, start_len: 0.75, end_len: 0.75, start_width: 0.0, end_width: 0.0, ..t }; assert!((width_factor(Some(&p), None, 100.0, 200.0) - 1.0).abs() < 1e-9, "meet in the middle at full width"); assert!(width_factor(Some(&p), None, 50.0, 200.0) < 0.6); } #[test] fn wave_width_profile() { let w = Wave { amount: 1.0, cycles: None, wavelength: 40.0, phase: 0.0 }; assert!((width_factor(None, Some(&w), 0.0, 200.0) - 1.0).abs() < 1e-9); assert!(width_factor(None, Some(&w), 20.0, 200.0).abs() < 1e-9); let c = Wave { cycles: Some(5.0), ..w.clone() }; assert!(width_factor(None, Some(&c), 20.0, 200.0).abs() < 1e-9, "5 cycles over 200 px = 40 px"); let ph = Wave { phase: 180.0, ..w }; assert!(width_factor(None, Some(&ph), 0.0, 200.0).abs() < 1e-9); } #[test] fn tapered_line_coverage_grows_along_the_path() { let t = Taper { percent: false, start_len: 100.0, end_len: 0.0, start_width: 0.0, end_width: 1.0, start_ease: 0.0, end_ease: 0.0 }; let st = StrokeStyle { width: 20.0, taper: Some(t), ..Default::default() }; let o = outline(&[line(150.0)], &st, 1.0).unwrap(); let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero); let (c1, c2, c3) = (column(&m, 30), column(&m, 70), column(&m, 140)); assert!((c1 - 4.0).abs() < 1.0, "20% along the taper → 4 px: {c1}"); assert!((c2 - 12.0).abs() < 1.0, "{c2}"); assert!((c3 - 20.0).abs() < 0.6, "{c3}"); let area: f32 = m.data.iter().sum(); assert!((area - (100.0 * 10.0 + 50.0 * 20.0)).abs() < 40.0, "{area}"); } #[test] fn dash_intervals_multi_segment() { let iv = dash_intervals(&[10.0, 5.0, 2.0, 3.0], 0.0, 45.0); assert_eq!(iv, vec![(0.0, 10.0), (15.0, 17.0), (20.0, 30.0), (35.0, 37.0), (40.0, 45.0)]); let off = dash_intervals(&[10.0, 10.0], 5.0, 30.0); assert_eq!(off, vec![(0.0, 5.0), (15.0, 25.0)]); } #[test] fn waved_dashed_stroke_has_gaps() { let st = StrokeStyle { width: 10.0, dash: Some((vec![20.0, 10.0], 0.0)), wave: Some(Wave { amount: 0.5, cycles: None, wavelength: 30.0, phase: 0.0 }), ..Default::default() }; let o = outline(&[line(150.0)], &st, 1.0).unwrap(); let m = fill_coverage(&[o], &Mat3::IDENTITY, 200, 100, FillRule::NonZero); assert!(column(&m, 41) > 9.0, "dash at full width (wave crest): {}", column(&m, 41)); assert!(column(&m, 35) < 0.01, "gap"); assert!(column(&m, 55) > 4.0 && column(&m, 55) < 6.5, "wave trough ≈ half width: {}", column(&m, 55)); } #[test] fn closed_tapered_path_is_continuous() { let r = crate::rect([60.0, 60.0], [50.0, 50.0], 0.0); let st = StrokeStyle { width: 6.0, wave: Some(Wave { amount: 0.2, cycles: Some(4.0), wavelength: 0.0, phase: 0.0 }), ..Default::default() }; let o = outline(&[r], &st, 1.0).unwrap(); let m = fill_coverage(&[o], &Mat3::IDENTITY, 100, 100, FillRule::NonZero); assert!(m.data.iter().all(|v| *v <= 1.0001)); let area: f32 = m.data.iter().sum(); assert!(area > 240.0 * 6.0 * 0.8 && area < 240.0 * 6.0 * 1.05, "{area}"); } }