M3.39: width profiles follow the path through dashes, and their corners take the join

Each dash and dot of a profile stroke is as wide as the profile where it sits
along the whole path (before, dashes dropped the profile). Corners of profile
strokes take the stroke's miter (within the miter limit), round or bevel join on
the outer side and stay clean on the inner side, instead of a miter clamped at
4x; smooth points of curves bend round. Canvas, SVG/PDF outlines and Outline
Stroke share the geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:40:52 +07:00
parent 42ad4540b7
commit 454c5f8620
5 changed files with 338 additions and 143 deletions

View File

@ -5,7 +5,7 @@
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
//! - [`dash`]: the dash pattern, exact or fitted to corners and path ends; zero-length dashes
//! become [`Dot`]s that a round or projecting cap turns into discs or squares ([`dot_outline`]);
//! - [`width_outline`]: variable-width (profile) strokes;
//! - [`width_outline`]: variable-width (profile) strokes, with the stroke's joins at corners;
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
//! caps and joins);
//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke.
@ -27,7 +27,8 @@ pub use width::width_outline;
/// Arc-length accuracy for dashing and trimming (document points).
const ARCLEN_ACCURACY: f64 = 1e-6;
/// Tangents meeting at more than about 1° (this cosine) make a corner that fitted dashes centre on.
/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it
/// and variable-width strokes give it the stroke's join.
const CORNER_COS: f64 = 0.9998;
/// The geometry one stroke paints, in the coordinate space of its path.
@ -86,31 +87,35 @@ pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke {
.with_miter_limit(st.miter_limit)
}
/// Does the dash pattern of `st` draw anything other than a solid line?
pub fn is_dashed(st: &StrokeLayer) -> bool {
st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0))
}
/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the width profile, or the dash
/// pattern with its dots, with the stroke's caps and joins. Fill it with the non-zero rule.
/// Arrowheads are not included.
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the dash pattern with its dots and
/// the width profile (each dash and dot as wide as the profile where it sits along the path), with
/// the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not included.
pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
if line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
return BezPath::new();
}
let tol = tol.max(1e-4);
if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) {
return width_outline(line, width, profile, st.cap, tol);
}
let style = style(st, width);
match st.dash.as_ref().and_then(|d| dash(line, d)) {
Some(d) => {
let mut out = kurbo::stroke(d.path.iter(), &style, &kurbo::StrokeOpts::default(), tol);
let dashed = st.dash.as_ref().and_then(|d| dash(line, d));
match (st.profile.as_ref(), dashed) {
(Some(profile), None) => width_outline(line, width, profile, st, tol),
(Some(profile), Some(d)) => {
let mut out = width::outline_spans(&d.path, &d.spans, width, profile, st, tol);
for dot in &d.dots {
// The dot spans the profile's left and right widths there.
let (l, r) = profile.at(dot.t);
let (l, r) = (l.max(0.0), r.max(0.0));
let at = dot.at + width::left(dot.dir) * (width * (l - r) / 4.0);
out.extend(dot_outline(&[Dot { at, ..*dot }], width * (l + r) / 2.0, st.cap, tol).iter());
}
out
}
(None, Some(d)) => {
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
None => kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol),
(None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol),
}
}

View File

@ -117,12 +117,13 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
assert!(near(plain.area().abs(), 1000.0, 1e-6));
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
assert!(near(lens.area().abs(), 500.0, 1e-6));
// Dashes take precedence over a profile (as before).
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
});
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0, 1e-6));
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6));
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
}

View File

@ -1,8 +1,8 @@
//! Dashes fitted to corners and path ends.
//! Dashes fitted to corners and path ends, and variable-width strokes with dashes and joins.
use kurbo::{BezPath, ParamCurveArclen, PathEl, Point, Rect, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Dash, LineCap, StrokeLayer, WidthProfile};
use vectorcraft_doc::{Dash, LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::*;
@ -145,3 +145,80 @@ fn fitted_dashes_on_curves_and_degenerate_paths_stay_finite() {
}
}
}
// ---------------------------------------------------------------- width profiles with dashes and joins
#[test]
fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip.
assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide");
assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin");
assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty");
// Dots too: a round dot near the middle is bigger than one near an end.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.cap = LineCap::Round;
s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5)));
}
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
fn bend(sharp: bool) -> BezPath {
let mut b = line(100.0);
b.line_to(if sharp { (0.0, 20.0) } else { (100.0, 100.0) });
b
}
fn uniform(join: LineJoin, miter_limit: f64) -> StrokeLayer {
stroke(20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
s.miter_limit = miter_limit;
})
}
#[test]
fn profile_corners_take_the_stroke_join() {
let out = |join, limit, sharp| line_outline(&bend(sharp), &uniform(join, limit), 20.0, 1e-3);
// The L: a miter fills the outer corner, a round join leaves it, a bevel cuts it.
let corner = Point::new(109.0, -9.0);
assert!(out(LineJoin::Miter, 10.0, false).contains(corner));
assert!(!out(LineJoin::Round, 10.0, false).contains(corner));
assert!(out(LineJoin::Round, 10.0, false).contains(Point::new(106.5, -6.5)), "round join reaches 10 out");
assert!(!out(LineJoin::Bevel, 10.0, false).contains(Point::new(106.5, -6.5)));
assert!(out(LineJoin::Bevel, 10.0, false).contains(Point::new(104.5, -4.5)));
// The inside of the L is filled up to the inner miter and no further.
let l = out(LineJoin::Round, 10.0, false);
assert!(l.contains(Point::new(91.0, 9.0)) && !l.contains(Point::new(89.0, 11.0)));
// The sharp V: its miter (about 10 × the half width) is past the limit of 10, so it bevels;
// no join spikes out past the round join's reach.
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let x1 = out(join, 10.0, true).bounding_box().x1;
assert!(x1 <= 110.0 + 1e-6, "{join:?}: {x1}");
}
// A limit above it keeps the full miter.
let x1 = out(LineJoin::Miter, 20.0, true).bounding_box().x1;
assert!(x1 > 195.0, "{x1}");
}
#[test]
fn smooth_points_of_curves_stay_smooth_with_any_join() {
// A circle stroked with a uniform profile is the same ring whatever the join.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4);
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
});
let a = line_outline(&c, &st, 10.0, 1e-3).area().abs();
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
}
}

View File

@ -2,150 +2,233 @@
//!
//! 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 (mitred) normal. Open subpaths get butt, round or
//! projecting caps (the round cap blends the two side widths); closed subpaths become two loops
//! of opposite orientation filled with the non-zero rule.
//! 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 kurbo::{BezPath, PathEl, Point, Vec2};
use vectorcraft_doc::{LineCap, WidthProfile};
use std::f64::consts::PI;
use super::unit;
use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile};
/// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`.
pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath {
let mut out = BezPath::new();
for (pts, closed) in subpaths(bp, tol.max(1e-4)) {
outline_subpath(&mut out, &pts, closed, width, profile, cap);
}
out
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)
}
/// Flattened subpaths (consecutive duplicates removed; closed ones don't repeat the start).
fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> {
let mut out: Vec<(Vec<Point>, bool)> = vec![];
let mut cur: Vec<Point> = vec![];
let push = |cur: &mut Vec<Point>, p: Point| {
if cur.last().is_none_or(|l| l.distance(p) > 1e-9) {
cur.push(p);
/// [`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)));
}
};
kurbo::flatten(bp.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push((std::mem::take(&mut cur), false));
}
cur.clear();
cur.push(p);
}
PathEl::LineTo(p) => push(&mut cur, p),
PathEl::ClosePath => {
if cur.len() > 1 && cur.first().unwrap().distance(*cur.last().unwrap()) <= 1e-9 {
cur.pop();
}
if cur.len() > 1 {
let start = cur[0];
out.push((std::mem::take(&mut cur), true));
cur.push(start);
} else {
cur.clear();
}
}
_ => {}
});
if cur.len() > 1 {
out.push((cur, false));
}
out
}
/// Left normal in y-down space (left of the direction of travel).
fn left(t: Vec2) -> Vec2 {
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<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),
_ => 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).
fn densify(pts: &[Point], closed: bool, profile: &WidthProfile) -> Vec<Point> {
/// along straight runs). `span` maps the subpath onto the profile (see [`outline_spans`]).
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec<Point>, Vec<bool>) {
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();
if total <= 1e-12 {
return pts.to_vec();
let range = span.1 - span.0;
if total <= 1e-12 || range <= 1e-12 {
return (pts.to_vec(), corner.to_vec());
}
let mut out = Vec::with_capacity(n + profile.points.len());
// Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
let marks: Vec<f64> = 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.push(a);
for (t, _, _) in &profile.points {
let d = t * total;
if d > acc + 1e-9 && d < acc + len - 1e-9 {
out.push(a.lerp(b, (d - acc) / len));
}
out.0.push(a);
out.1.push(corner[i]);
let mut inner: Vec<f64> = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect();
inner.sort_by(f64::total_cmp);
for d in inner {
out.0.push(a.lerp(b, (d - acc) / len));
out.1.push(false);
}
acc += len;
}
if !closed {
out.push(pts[n - 1]);
out.0.push(pts[n - 1]);
out.1.push(corner[n - 1]);
}
out
}
fn outline_subpath(out: &mut BezPath, pts: &[Point], closed: bool, width: f64, profile: &WidthProfile, cap: LineCap) {
let pts = &densify(pts, closed, profile)[..];
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;
}
// Vertex normals (mitred, limited to 4× so sharp corners don't spike).
let normal_at = |i: usize| -> (Vec2, f64) {
let (a, b) = if closed {
(seg_dir[(i + seg_count - 1) % seg_count], seg_dir[i % seg_count])
} else if i == 0 {
(seg_dir[0], seg_dir[0])
} else if i == n - 1 {
(seg_dir[seg_count - 1], seg_dir[seg_count - 1])
} else {
(seg_dir[i - 1], seg_dir[i])
/// 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<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 at `d` along this piece.
let width_at = |d: f64| {
let t = span.0 + (span.1 - span.0) * d / total;
let (l, r) = self.profile.at(if t > 1.0 { t - 1.0 } else { t });
(self.half * l.max(0.0), self.half * r.max(0.0))
};
let (na, nb) = (left(a), left(b));
let nm = unit(na + nb);
let c = nm.dot(nb).max(0.25);
(nm, 1.0 / c)
};
let half = width / 2.0;
let mut lpts = Vec::with_capacity(n);
let mut rpts = Vec::with_capacity(n);
for (i, p) in pts.iter().enumerate() {
let (nm, miter) = normal_at(i);
let (fl, fr) = profile.at(cum[i] / total);
lpts.push(*p + nm * (miter * half * fl.max(0.0)));
rpts.push(*p - nm * (miter * half * fr.max(0.0)));
let mut lpts = Vec::with_capacity(n);
let mut rpts = Vec::with_capacity(n);
for (i, &p) in pts.iter().enumerate() {
let (wl, wr) = width_at(cum[i]);
if !closed && (i == 0 || i == n - 1) {
let nm = left(seg_dir[i.min(seg_count - 1)]);
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] };
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), width_at(total));
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());
}
if closed {
polygon(out, lpts.iter().copied());
polygon(out, rpts.iter().rev().copied());
return;
/// 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);
}
}
let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]);
let (fl0, fr0) = profile.at(0.0);
let (fl1, fr1) = profile.at(1.0);
let (wl0, wr0, wl1, wr1) = (half * fl0.max(0.0), half * fr0.max(0.0), half * fl1.max(0.0), half * fr1.max(0.0));
let mut ring: Vec<Point> = lpts.clone();
// 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, cap);
ring.extend(rpts.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, cap);
polygon(out, ring.into_iter());
}
/// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`.
@ -193,22 +276,28 @@ mod tests {
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, LineCap::Butt, 0.01)) - 1000.0).abs() < 1e-6);
let round = area(&width_outline(&line(), 10.0, &p, LineCap::Round, 0.01));
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, LineCap::Square, 0.01)) - 1100.0).abs() < 1e-6);
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(), LineCap::Butt, 0.01));
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, LineCap::Butt, 0.01);
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).
@ -224,7 +313,7 @@ mod tests {
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, LineCap::Butt, 0.01));
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}");
}
@ -233,7 +322,7 @@ mod tests {
// 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, LineCap::Butt, 0.001));
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}");
}

View File

@ -1,9 +1,9 @@
//! Pixel tests for dashes fitted to corners, drawn on the canvas from the shared stroke geometry
//! (`vectorcraft_effects::stroke`).
//! Pixel tests for dashes fitted to corners and for width profiles with dashes and joins, drawn
//! on the canvas from the shared stroke geometry (`vectorcraft_effects::stroke`).
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, StrokeLayer};
use vectorcraft_geom::{PathData, Rect, shapes};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineJoin, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, render_region};
@ -53,3 +53,26 @@ fn fitted_dashes_wrap_every_corner_of_a_rectangle() {
// Exact dashes keep their lengths instead (the exact golden covers how they look).
assert_ne!(render(&rect_doc(false)).rgba, img.rgba);
}
#[test]
fn dashes_of_a_lens_stroke_widen_towards_the_middle() {
let img = render(&doc(shapes::line(Point::new(30.0, 60.0), Point::new(130.0, 60.0)), 10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
}));
// The dash over 40..50 of the 100 pt line is nearly full width, the one over 0..10 thin.
assert!(dark(&img, 75.0, 60.0 - 3.8) && dark(&img, 75.0, 60.0 + 3.8));
assert!(dark(&img, 35.0, 60.0) && !dark(&img, 35.0, 60.0 - 1.5));
assert!(!dark(&img, 45.0, 60.0), "gap");
}
#[test]
fn a_round_join_on_a_profile_stroke_has_no_spike() {
let l = SubPath::polyline(&[Point::new(20.0, 40.0), Point::new(100.0, 40.0), Point::new(100.0, 110.0)], false);
let img = render(&doc(PathData::single(l), 20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = LineJoin::Round;
}));
assert!(dark(&img, 106.0, 34.0), "the round join covers the corner");
assert!(!dark(&img, 109.0, 31.0), "but not the miter's point");
}