effectcraft/crates/path/src/varstroke.rs

400 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<f64>,
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<Point>,
s: Vec<f64>,
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<Poly> {
let mut out: Vec<Poly> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
let mut closed = false;
let flush = |cur: &mut Vec<Point>, closed: bool, out: &mut Vec<Poly>| {
let mut pts: Vec<Point> = 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<f64> = 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<BezPath> {
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}");
}
}