Files
vectorcraft/crates/effects/src/stroke/width.rs
T

516 lines
22 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 (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, Rect, 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)
}
/// Outline only flattened segments near `near`, retaining each run's original fractions of its
/// whole subpath for the width profile. `None` means no segments could be omitted.
pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64, near: Rect) -> Option<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();
let mut cut = false;
for (r, closed) in subpaths(bp) {
if closed {
return None;
}
let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol);
let n = pts.len();
if n < 2 {
continue;
}
let count = n - 1;
let mut lengths = Vec::with_capacity(count);
let mut kept = Vec::with_capacity(count);
let mut total = 0.0;
for i in 0..count {
let (a, b) = (pts[i], pts[i + 1]);
let len = a.distance(b);
if !len.is_finite() {
return None;
}
total += len;
lengths.push(len);
kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1));
}
if kept.iter().all(|v| *v) {
ctx.subpath(&mut out, &pts, &corner, closed, (0.0, 1.0));
continue;
}
cut = true;
if total <= 1e-12 {
continue;
}
let mut walked = 0.0;
let mut run = Vec::new();
let mut run_corner = Vec::new();
let mut from = 0.0;
for i in 0..count {
if kept[i] {
if run.is_empty() {
from = walked / total;
run.push(pts[i]);
run_corner.push(corner[i]);
}
run.push(pts[i + 1]);
run_corner.push(corner[i + 1]);
} else if run.len() > 1 {
ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total));
run.clear();
run_corner.clear();
}
walked += lengths[i];
}
if run.len() > 1 {
ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total));
}
}
cut.then_some(out)
}
/// [`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.
pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>, Vec<bool>) {
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),
// Bounded per curve: a projection can blow a small curve up enough that kurbo sizes
// an enormous buffer from it.
_ => vectorcraft_geom::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)
}
/// The most samples added between two width points.
const MAX_SPAN_SAMPLES: usize = 64;
/// The most samples [`densify`] adds between width points along one piece of a subpath.
const MAX_CURVE_SAMPLES: usize = 4096;
/// Insert samples where the profile has width points (the two points of a discontinuous point
/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so
/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps
/// the subpath onto the profile (see [`outline_spans`]).
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec<Point>, Vec<bool>) {
let total = length(pts, closed);
let range = span.1 - span.0;
if total <= 1e-12 || range <= 1e-12 {
return (pts.to_vec(), corner.to_vec());
}
let along = |t: f64| (t - span.0) / range * total;
// Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
let mut marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect();
let mut added = 0;
for shift in [0.0, 1.0] {
for w in profile.points.windows(2) {
let (a, b) = (&w[0], &w[1]);
let (t0, t1) = (a.0 + shift, b.0 + shift);
if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 {
continue;
}
// A span whose curve is the straight line between its points (a two-point taper,
// points on one line) needs nothing between them. A cubic off the line is off it at a
// quarter or three quarters of the way.
let off_line = |u: f64| {
let (l, r) = profile.at(a.0 + (b.0 - a.0) * u);
half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs())
};
if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 {
continue;
}
// A monotone cubic span bends by at most 12 times its change in width over the span
// squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it.
let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs());
let n = (1.5 * change / tol).sqrt().ceil();
let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES };
for k in 1..n {
if added >= MAX_CURVE_SAMPLES {
break;
}
marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64));
added += 1;
}
}
}
split_at(pts, corner, closed, &marks)
}
/// Length of a flattened subpath (see [`flatten`]).
pub(super) fn length(pts: &[Point], closed: bool) -> f64 {
let n = pts.len();
let seg_count = if closed { n } else { n.saturating_sub(1) };
(0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum()
}
/// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its
/// start; those on a point or off the subpath are ignored).
pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec<Point>, Vec<bool>) {
let n = pts.len();
let seg_count = if closed { n } else { n.saturating_sub(1) };
let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()));
let mut acc = 0.0;
// Sorted once, so each segment finds its marks by search, not by a pass over all of them.
let mut marks = marks.to_vec();
marks.sort_by(f64::total_cmp);
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 from = marks.partition_point(|d| *d <= acc + 1e-9);
let mut inner: Vec<f64> = marks.iter().skip(from).copied().take_while(|d| *d < acc + len - 1e-9).collect();
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 && n > 0 {
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, self.half, self.tol);
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let seg_dir: Vec<Vec2> = (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<Point>, 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<Point>| 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<Point>, 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<Point>, 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<Item = Point>) {
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);
}
/// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side.
#[test]
fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() {
// Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it
// covers 7/12 of the full-width band (a diamond would cover half).
let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01));
assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{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}");
}
/// #5714: between width points a straight run follows the profile's smooth curve, not a
/// straight line from one width to the next.
#[test]
fn a_straight_run_follows_the_curve_between_width_points() {
// 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve
// is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt).
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] };
let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01);
for y in [-11.0, 11.0] {
assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}");
}
for y in [-11.5, 11.5] {
assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}");
}
// Round the widest point the side is flat: no corner sticks out above 15 pt.
let bb = o.bounding_box();
assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}");
}
/// A taper is a straight ramp: its outline gets no samples between the width points.
#[test]
fn a_straight_taper_gets_no_samples_between_its_points() {
let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01);
assert_eq!(o.elements().len(), 5, "{:?}", o.elements());
}
/// A profile read from a file may have thousands of points and absurd widths: the samples added
/// between them stay bounded and the outline finite.
#[test]
fn samples_between_width_points_are_capped() {
let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect();
let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01);
let n = o.elements().len();
assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}");
assert!(o.bounding_box().area().is_finite());
}
#[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}");
}
}