//! Variable-width strokes (Width tool / width profiles). //! //! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and //! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the //! subpath's length) are placed along the normal. Corners of the path take the stroke's miter, //! round or bevel join on their outer side (smooth points bend round), open subpaths get butt, //! round or projecting caps (the round cap blends the two side widths), and closed subpaths become //! two loops of opposite orientation filled with the non-zero rule. Dashes are pieces of a longer //! subpath and read the profile where they sit along it ([`outline_spans`]). use std::f64::consts::PI; use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; use super::{kink, segments, subpaths, unit}; /// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter /// limit of `st`, flattened to `tol`. pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath { outline_spans(bp, &[], width, profile, st, tol) } /// [`width_outline`] of pieces of longer subpaths: subpath `i` of `bp` covers the fractions /// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans)); /// subpaths without a span are whole. pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath { let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; let mut out = BezPath::new(); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() { let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); if pts.len() > 1 { ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); } } out } /// Left normal in y-down space (left of the direction of travel). pub(super) fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) } /// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't /// repeat its start), each marked when it is a corner of the path. fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) { let Some(s0) = segs.first() else { return (vec![], vec![]) }; let (mut pts, mut corner) = (vec![s0.start()], vec![false]); // The first and the last segment that drew something. let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None); for seg in segs { let at = pts.len() - 1; let mut push = |p: Point| { if pts.last().is_none_or(|l| l.distance(p) > 1e-9) { pts.push(p); corner.push(false); } }; match seg { PathSeg::Line(l) => push(l.p1), _ => kurbo::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| { if let PathEl::LineTo(p) = el { push(p); } }), } if pts.len() > at + 1 { match prev { Some(p) => corner[at] = kink(p, seg), None => first = Some(seg), } prev = Some(seg); } } if closed { if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 { pts.pop(); corner.pop(); } if let (Some(a), Some(b)) = (prev, first) { corner[0] = kink(a, b); } } (pts, corner) } /// Insert samples where the profile has width points (so the piecewise-linear profile is exact /// along straight runs; the two points of a discontinuous point share one sample). `span` maps the /// subpath onto the profile (see [`outline_spans`]). fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec, Vec) { let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let total: f64 = (0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum(); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { return (pts.to_vec(), corner.to_vec()); } // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). let marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect(); let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); inner.sort_by(f64::total_cmp); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { out.0.push(a.lerp(b, (d - acc) / len)); out.1.push(false); } acc += len; } if !closed { out.0.push(pts[n - 1]); out.1.push(corner[n - 1]); } out } /// What outlines one variable-width stroke. struct Ctx<'a> { half: f64, profile: &'a WidthProfile, cap: LineCap, join: LineJoin, miter_limit: f64, tol: f64, } /// A vertex of a flattened subpath: where it is, the directions in and out, the lengths of the /// segments either side and whether it is a corner of the path (else a smooth point). struct Vertex { p: Point, a: Vec2, b: Vec2, la: f64, lb: f64, corner: bool, } impl Ctx<'_> { fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) { let (pts, corner) = densify(pts, corner, closed, self.profile, span); let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); let mut cum = vec![0.0; n + 1]; for i in 0..seg_count { cum[i + 1] = cum[i] + pts[i].distance(pts[(i + 1) % n]); } let total = cum[seg_count]; if total <= 1e-12 { return; } // Left and right offsets just before and just after `d` along this piece (they differ // at a discontinuous point). let width_at = |d: f64| { let t = span.0 + (span.1 - span.0) * d / total; let (b, a) = self.profile.around(if t > 1.0 { t - 1.0 } else { t }); let side = |(l, r): (f64, f64)| (self.half * l.max(0.0), self.half * r.max(0.0)); (side(b), side(a)) }; let mut lpts = Vec::with_capacity(n); let mut rpts = Vec::with_capacity(n); for (i, &p) in pts.iter().enumerate() { let (before, after) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { let nm = left(seg_dir[i.min(seg_count - 1)]); let (wl, wr) = if i == 0 { after } else { before }; lpts.push(p + nm * wl); rpts.push(p - nm * wr); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; // A step: the side goes straight out (or in) from the width before to the one after. for (k, (wl, wr)) in [before, after].into_iter().enumerate() { if k == 0 || (wl, wr) != before { self.join(&mut lpts, &v, wl, 1.0); self.join(&mut rpts, &v, wr, -1.0); } } } if closed { polygon(out, lpts.into_iter()); polygon(out, rpts.into_iter().rev()); return; } let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap); ring.extend(rpts.into_iter().rev()); // Start cap: from the right side around the start back to the left side. cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap); polygon(out, ring.into_iter()); } /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the /// join on the outer side of a turn, the inner miter point (or a detour through the vertex /// when that would overshoot a segment) on the inner side. fn join(&self, pts: &mut Vec, v: &Vertex, w: f64, side: f64) { let (na, nb) = (left(v.a) * side, left(v.b) * side); let mid = na + nb; // Cosine of half the turn; the miter point is `w / c` out along `mid`. let c = mid.hypot() / 2.0; let miter = |pts: &mut Vec| pts.push(v.p + mid * (w / (2.0 * c * c))); if w <= 0.0 { pts.push(v.p); } else if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; match self.join { _ if excess <= self.tol => miter(pts), LineJoin::Miter if v.corner && 1.0 / c <= self.miter_limit => miter(pts), LineJoin::Miter | LineJoin::Bevel if v.corner => pts.extend([v.p + na * w, v.p + nb * w]), _ => self.arc(pts, v, na, nb, w), } } else if c > 1e-9 && w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { miter(pts); } else { pts.extend([v.p + na * w, v.p, v.p + nb * w]); } } /// A round join from `na` to `nb` (unit normals) at radius `w` around `v`; a full reversal /// rounds through the direction of travel. fn arc(&self, pts: &mut Vec, v: &Vertex, na: Vec2, nb: Vec2, w: f64) { let phi = if (na + nb).hypot() < 1e-9 { if Vec2::new(-na.y, na.x).dot(v.a) > 0.0 { PI } else { -PI } } else { na.cross(nb).atan2(na.dot(nb)) }; let step = 2.0 * (1.0 - (self.tol / w).min(1.0)).acos(); let steps = ((phi.abs() / step.max(1e-3)).ceil() as usize).clamp(1, 256); for k in 0..=steps { let (s, c) = (phi * k as f64 / steps as f64).sin_cos(); pts.push(v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w); } } } /// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. fn cap_points(ring: &mut Vec, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) { match cap { LineCap::Butt => {} LineCap::Square => { let h = (r0 + r1) / 2.0; ring.push(c + u * r0 + v * h); ring.push(c - u * r1 + v * h); } LineCap::Round => { let steps = 16; for k in 1..steps { let a = std::f64::consts::PI * k as f64 / steps as f64; let r = r0 + (r1 - r0) * (k as f64 / steps as f64); ring.push(c + u * (a.cos() * r) + v * (a.sin() * r)); } } } } fn polygon(out: &mut BezPath, mut pts: impl Iterator) { let Some(first) = pts.next() else { return }; out.move_to(first); for p in pts { out.line_to(p); } out.close_path(); } #[cfg(test)] mod tests { use super::*; use kurbo::Shape; fn line() -> BezPath { let mut b = BezPath::new(); b.move_to((0.0, 0.0)); b.line_to((100.0, 0.0)); b } fn area(b: &BezPath) -> f64 { b.area().abs() } fn capped(cap: LineCap) -> StrokeLayer { let mut st = StrokeLayer::new(vectorcraft_color::Paint::None, 10.0); st.cap = cap; st } #[test] fn uniform_profile_matches_a_plain_stroke() { let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01)) - 1000.0).abs() < 1e-6); let round = area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Round), 0.01)); let want = 1000.0 + std::f64::consts::PI * 25.0; assert!((round - want).abs() / want < 0.01, "{round} vs {want}"); assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } #[test] fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() { let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); assert!((a - 500.0).abs() < 1e-6, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 500.0).abs() < 1e-6); let bb = o.bounding_box(); // Travelling +x, the left side is up (negative y). assert!(bb.y0 < -4.99 && bb.y1 <= 1e-9, "{bb:?}"); } #[test] fn a_discontinuous_point_steps_the_width() { // 5 pt either side for the first half, 10 pt for the second: a step at the middle. let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)] }; let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 1500.0).abs() < 1e-6, "{}", area(&o)); // No ramp: just before the middle the outline is still 5 pt from the line. assert!(!o.contains(kurbo::Point::new(49.0, -6.0)) && o.contains(kurbo::Point::new(51.0, -6.0))); // A step at a corner of a polyline renders too. let mut l = line(); l.line_to((100.0, 100.0)); let a = area(&width_outline(&l, 10.0, &p, &capped(LineCap::Butt), 0.01)); assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } #[test] fn closed_paths_make_a_ring() { let mut sq = BezPath::new(); sq.move_to((0.0, 0.0)); sq.line_to((100.0, 0.0)); sq.line_to((100.0, 100.0)); sq.line_to((0.0, 100.0)); sq.close_path(); let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; let a = area(&width_outline(&sq, 10.0, &p, &capped(LineCap::Butt), 0.01)); assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 1e-6, "{a}"); } #[test] fn curves_are_offset_by_the_profile() { // A circle of radius 50 with a uniform 10 pt stroke: ring area 2π·50·10. let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4); let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; let a = area(&width_outline(&c, 10.0, &p, &capped(LineCap::Butt), 0.001)); let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } }