diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 2bbf328c..9a4ca346 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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), } } diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 54c5c811..acc8a757 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -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()); } diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index a00d8b30..91933087 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -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}"); + } +} diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index aef94bf6..41d1481d 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -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, bool)> { - let mut out: Vec<(Vec, bool)> = vec![]; - let mut cur: Vec = vec![]; - let push = |cur: &mut Vec, 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, Vec) { + 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 { +/// 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, Vec) { 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 = 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 = 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 = (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 = (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, 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| 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, 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 = 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}"); } diff --git a/crates/render/tests/dash_align.rs b/crates/render/tests/dash_align.rs index ff5ba25f..e6250c6e 100644 --- a/crates/render/tests/dash_align.rs +++ b/crates/render/tests/dash_align.rs @@ -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"); +}