//! Variable width strokes: a path measured by length, and the outline of a stroke whose width //! follows a [`WidthProfile`] along each subpath. //! //! The path is flattened into polylines (sampled densely enough for the width to vary smoothly), //! each point is offset along the polyline's normals by the interpolated half-width of that side //! (the two sides can differ: one-sided widths), the sides are joined (round, miter or bevel on the //! outer side, through the centre on the inner side) and capped. The result may overlap itself; //! fill it with the non-zero rule. use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile}; use figcraft_geom::kurbo::{self, BezPath, Point, Vec2}; use figcraft_geom::measure::FLATTEN_TOL; /// The most points the outline works with; longer paths fall back to a uniform stroke. pub use figcraft_geom::measure::MAX_SAMPLES as MAX_WIDTH_SAMPLES; pub use figcraft_geom::measure::{Polyline, polylines}; /// Every subpath gets at least this many width samples, so the width varies smoothly along long /// straight runs. const SAMPLES_PER_SUBPATH: f64 = 128.0; /// The most dashes a dashed variable width stroke makes. const MAX_DASHES: usize = 20_000; /// The nearest point of the stroke path to `p`: (subpath index, t by length, distance). pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> { lines.iter().enumerate().filter_map(|(i, l)| l.nearest(p).map(|(t, d)| (i, t, d))).min_by(|a, b| a.2.total_cmp(&b.2)) } /// Half-widths at a sample: `[along the normal, against it]`. The normal is the direction turned /// clockwise on screen (y down), so the first is the right side looking along the path. type Half = [f64; 2]; /// The half-width of `h` on side `sigma` (+1 = along the normal). fn on(h: Half, sigma: f64) -> f64 { if sigma > 0.0 { h[0] } else { h[1] } } /// One straight piece of an offset polyline: its ends, the half-widths there, its direction and /// normal. struct Seg { a: Point, b: Point, ha: Half, hb: Half, d: Vec2, n: Vec2, } #[derive(Clone, Copy)] struct Style { join: StrokeJoin, miter_limit: f64, cap_start: StrokeCap, cap_end: StrokeCap, } /// The cap shape an end cap draws (arrowheads are added by the caller; their stroke ends round /// or square like the uniform stroke does). fn cap_shape(c: StrokeCap) -> StrokeCap { match c { StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round, StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square, _ => StrokeCap::None, } } /// Points of an arc around `c` from the direction of `from` turning by `sweep` radians, with the /// radius going from `r0` to `r1` (ends excluded). fn arc(out: &mut Vec, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64) { let r = r0.max(r1); if r <= FLATTEN_TOL || !sweep.is_finite() { return; } let step = 2.0 * (1.0 - FLATTEN_TOL / r).clamp(-1.0, 1.0).acos(); let n = ((sweep.abs() / step.max(1e-3)).ceil() as usize).clamp(2, 64); let a0 = from.atan2(); for k in 1..n { let f = k as f64 / n as f64; out.push(c + Vec2::from_angle(a0 + sweep * f) * (r0 + (r1 - r0) * f)); } } /// The corner between segments `p` and `c` on side `sigma` (+1 = the normal's side). fn join(out: &mut Vec, p: &Seg, c: &Seg, sigma: f64, style: Style) { let (phb, cha) = (on(p.hb, sigma), on(c.ha, sigma)); let pe = p.b + p.n * (sigma * phb); let cs = c.a + c.n * (sigma * cha); out.push(pe); let (cross, dot) = (p.d.cross(c.d), p.d.dot(c.d)); if cross.abs() < 1e-12 && dot > 0.0 { out.push(cs); return; } // Turning towards the normal puts that side inside the corner. let outer = if cross.abs() < 1e-12 { sigma < 0.0 } else { cross * sigma < 0.0 }; let v = c.a; if outer { let theta = if cross.abs() < 1e-12 { std::f64::consts::PI } else { cross.atan2(dot) }; match style.join { StrokeJoin::Round => arc(out, v, pe - v, theta, phb, cha), StrokeJoin::Miter => { let half = (theta / 2.0).cos(); let bis = p.n + c.n; if half > 1e-6 && 1.0 / half <= style.miter_limit && bis.hypot() > 1e-9 { out.push(v + bis * (sigma * (phb + cha) / 2.0 / half / bis.hypot())); } } StrokeJoin::Bevel => {} } } else { // Through the centre: keeps the winding right when segments are shorter than the width. out.push(v); } out.push(cs); } /// One side of the offset polyline (`sigma` = +1 along the normals, -1 against). fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec { let mut out = Vec::with_capacity(segs.len() * 3 + 2); for (j, s) in segs.iter().enumerate() { let prev = if j == 0 { closed.then(|| segs.last()).flatten() } else { segs.get(j - 1) }; match prev { Some(p) => join(&mut out, p, s, sigma, style), None => out.push(s.a + s.n * (sigma * on(s.ha, sigma))), } } if !closed && let Some(l) = segs.last() { out.push(l.b + l.n * (sigma * on(l.hb, sigma))); } out } /// A cap at `c` facing `dir` (unit, outwards) from the `dir.turn_90()` side, `from` wide, to the /// other side, `to` wide. Lopsided ends are capped around the middle of the end. fn cap(out: &mut Vec, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) { let n = dir.turn_90(); let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0); match cap_shape(kind) { StrokeCap::Square => { out.push(m + n * r + dir * r); out.push(m - n * r + dir * r); } StrokeCap::Round => arc(out, m, n, -std::f64::consts::PI, r, r), _ => {} } } fn push_contour(path: &mut BezPath, pts: impl IntoIterator) { let mut it = pts.into_iter(); let Some(first) = it.next() else { return }; path.move_to(first); for p in it { path.line_to(p); } path.close_path(); } /// Outline one piece (a subpath or a dash) with its half-widths. fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, style: Style) { let segs: Vec = pts .windows(2) .zip(hw.windows(2)) .filter_map(|(p, h)| { let (a, b) = (p[0], p[1]); let len = (b - a).hypot(); (len > 1e-9).then(|| { let d = (b - a) / len; Seg { a, b, ha: h[0], hb: h[1], d, n: d.turn_90() } }) }) .collect(); let (Some(first), Some(last)) = (segs.first(), segs.last()) else { // A zero-length piece is a dot with round caps. let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0); if let Some(p) = pts.first() && cap_shape(style.cap_start) == StrokeCap::Round && r > 0.0 { path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL)); } return; }; let left = side(&segs, 1.0, closed, style); let right = side(&segs, -1.0, closed, style); if closed { push_contour(path, left); push_contour(path, right.into_iter().rev()); return; } let mut ring = left; cap(&mut ring, last.b, last.d, last.hb[0], last.hb[1], style.cap_end); ring.extend(right.into_iter().rev()); cap(&mut ring, first.a, -first.d, first.ha[1], first.ha[0], style.cap_start); push_contour(path, ring); } /// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths). fn dashes(pts: &[Point], hw: &[Half], pattern: &[f64]) -> Vec<(Vec, Vec)> { let mut out = Vec::new(); let Some(&first) = pattern.first() else { return out }; let (mut idx, mut left, mut on) = (0usize, first, true); let mut cur: (Vec, Vec) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect()); for (p, h) in pts.windows(2).zip(hw.windows(2)) { let (a, b, ha, hb) = (p[0], p[1], h[0], h[1]); let len = (b - a).hypot(); let mut used = 0.0; while len - used > left { used += left; let f = if len > 0.0 { used / len } else { 0.0 }; let (q, hq) = (a.lerp(b, f), [ha[0] + (hb[0] - ha[0]) * f, ha[1] + (hb[1] - ha[1]) * f]); if on { cur.0.push(q); cur.1.push(hq); out.push(std::mem::take(&mut cur)); if out.len() >= MAX_DASHES { return out; } } else { cur = (vec![q], vec![hq]); } on = !on; idx = (idx + 1) % pattern.len(); left = pattern.get(idx).copied().unwrap_or(first); } left -= len - used; if on { cur.0.push(b); cur.1.push(hb); } } if on && !cur.0.is_empty() { out.push(cur); } out } /// The dash pattern a stroke uses (odd lengths repeat twice), or `None` for a solid stroke. pub fn dash_pattern(props: &StrokeProps) -> Option> { let dashes: Vec = props.dashes.iter().copied().filter(|d| d.is_finite() && *d >= 0.0).collect(); if dashes.is_empty() || dashes.iter().sum::() <= 0.01 { return None; } Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes }) } /// Resample a polyline so no step is longer than `max_step`; returns the points and the length at /// each, or `None` past the sample budget. fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec, Vec)> { let mut pts: Vec = Vec::with_capacity(line.points.len()); let mut lens: Vec = Vec::with_capacity(line.points.len()); for (i, p) in line.points.iter().enumerate() { let s = line.lengths.get(i).copied().unwrap_or(0.0); if let (Some(&prev), Some(&ps)) = (pts.last(), lens.last()) { let n = ((s - ps) / max_step).ceil().clamp(1.0, MAX_WIDTH_SAMPLES as f64) as usize; for k in 1..n { let f = k as f64 / n as f64; pts.push(Point::lerp(prev, *p, f)); lens.push(ps + (s - ps) * f); } } pts.push(*p); lens.push(s); if pts.len() > *budget { return None; } } *budget -= pts.len(); Some((pts, lens)) } /// The filled outline of a stroke of `width` along `path` whose width follows `profile` (its /// scales multiply `width`). `None` when the path is invalid or too long (the caller strokes it /// uniformly instead). pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile: &WidthProfile) -> Option { if !(width.is_finite() && width > 0.0) { return Some(BezPath::new()); } let profile = profile.sanitized(); let lines = polylines(path)?; let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start: props.cap_start, cap_end: props.cap_end }; let pattern = dash_pattern(props); let mut budget = MAX_WIDTH_SAMPLES; let mut out = BezPath::new(); for line in &lines { let len = line.length(); if len <= 1e-9 { continue; } let (pts, lens) = resample(line, len / SAMPLES_PER_SUBPATH, &mut budget)?; let hw: Vec = lens .iter() .map(|s| { let (left, right) = profile.sides_at(s / len); [0.5 * width * right, 0.5 * width * left] }) .collect(); match &pattern { Some(pat) if len / pat.iter().sum::() * (pat.len() as f64) < MAX_DASHES as f64 => { for (p, h) in dashes(&pts, &hw, pat) { outline_piece(&mut out, &p, &h, false, style); } } _ => outline_piece(&mut out, &pts, &hw, line.closed, style), } } Some(out) }