mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 14:41:19 +00:00
516 lines
22 KiB
Rust
516 lines
22 KiB
Rust
//! 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}");
|
||
}
|
||
}
|