diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 91d6523b..8c75ccde 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -2,11 +2,14 @@ //! consumer paints the same shapes: //! //! - [`stroke_pieces`]: the centre line and the arrowheads as filled outlines; +//! - [`dash`]: the dash pattern; 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; -//! - [`line_outline`]: the line part of a stroke as one filled outline (profile or dashes, caps -//! and joins). +//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots, +//! caps and joins). mod arrow; +mod dash; mod width; use std::borrow::Cow; @@ -15,8 +18,12 @@ use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; pub use arrow::Arrow; +pub use dash::{Dashed, Dot, dash, dot_outline}; pub use width::width_outline; +/// Arc-length accuracy for dashing and trimming (document points). +const ARCLEN_ACCURACY: f64 = 1e-6; + /// The geometry one stroke paints, in the coordinate space of its path. #[derive(Clone, Debug)] pub struct StrokePieces<'a> { @@ -71,8 +78,8 @@ pub fn is_dashed(st: &StrokeLayer) -> bool { /// 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 the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not -/// included. +/// pattern with its dots, 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(); @@ -81,16 +88,15 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) { return width_outline(line, width, profile, st.cap, tol); } - let mut style = style(st, width); - if let Some(d) = st.dash.as_ref().filter(|_| is_dashed(st)) { - // An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF). - let mut pat = d.pattern.clone(); - if pat.len() % 2 == 1 { - pat.extend_from_within(..); + 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); + out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); + out } - style = style.with_dashes(d.offset, pat); + None => kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol), } - kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol) } /// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. @@ -122,5 +128,45 @@ pub(crate) fn unit(v: Vec2) -> Vec2 { if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } } +/// Append `seg` to `out` (which must already have a current point at its start). +pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) { + match seg { + PathSeg::Line(l) => out.line_to(l.p1), + PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), + PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), + } +} + +/// Element ranges of the subpaths of `bp` (each with whether it is closed). +pub(crate) fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range, bool)> { + let els = bp.elements(); + let mut out = vec![]; + let mut start = 0; + for (i, el) in els.iter().enumerate() { + match el { + PathEl::MoveTo(_) if i > start => { + out.push((start..i, false)); + start = i; + } + PathEl::ClosePath => { + out.push((start..i + 1, true)); + start = i + 1; + } + _ => {} + } + } + if start < els.len() { + out.push((start..els.len(), false)); + } + // A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing. + out.retain(|(r, _)| r.len() > 1); + out +} + +/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line). +pub(crate) fn segments(els: &[PathEl]) -> Vec { + kurbo::segments(els.iter().copied()).collect() +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs new file mode 100644 index 00000000..b4eb7f2b --- /dev/null +++ b/crates/effects/src/stroke/dash.rs @@ -0,0 +1,188 @@ +//! Dash patterns. Dashes follow the path's arc length, restart on every subpath and join across +//! the start of a closed subpath. A dash of zero length is a [`Dot`]: with a round cap it paints +//! a disc, with a projecting cap a square turned along the path, with a butt cap nothing. + +use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; +use vectorcraft_doc::{Dash, LineCap}; + +use super::{ARCLEN_ACCURACY, push_seg, segments, subpaths, tangent}; + +/// More dashes than this (a tiny pattern on a long path) draws the line solid instead. +const MAX_DASHES: f64 = 200_000.0; + +/// A zero-length dash: where it sits and the path's direction there. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dot { + pub at: Point, + pub dir: Vec2, +} + +/// A dashed path: the dashes (open subpaths to stroke without a dash pattern) and the dots. +#[derive(Clone, Debug, Default)] +pub struct Dashed { + pub path: BezPath, + pub dots: Vec, +} + +/// Apply dash pattern `d` to `bp`. `None` when the pattern has no positive length (the line is +/// solid) or is too fine to draw. +pub fn dash(bp: &BezPath, d: &Dash) -> Option { + if d.pattern.iter().any(|v| !v.is_finite()) || !d.offset.is_finite() { + return None; + } + let mut pat: Vec = d.pattern.iter().map(|v| v.max(0.0)).collect(); + if !pat.iter().any(|v| *v > 0.0) { + return None; + } + // An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF). + if pat.len() % 2 == 1 { + pat.extend_from_within(..); + } + let period: f64 = pat.iter().sum(); + let subs: Vec<(Vec, bool)> = subpaths(bp).into_iter().map(|(r, closed)| (segments(&bp.elements()[r]), closed)).collect(); + let lens: Vec> = subs.iter().map(|(segs, _)| segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect()).collect(); + let total: f64 = lens.iter().flatten().sum(); + if total / period * pat.len() as f64 > MAX_DASHES { + return None; + } + // Where the pattern starts: `offset` into it. + let mut off = d.offset.rem_euclid(period); + let mut ix0 = 0; + while off > pat[ix0] { + off -= pat[ix0]; + ix0 = (ix0 + 1) % pat.len(); + } + let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; + let mut out = Dashed::default(); + for ((segs, closed), lens) in subs.iter().zip(&lens) { + dash_subpath(&mut out, segs, lens, *closed, &pat, phase); + } + Some(out) +} + +/// Position in the pattern: entry index, length left in it, and whether it is a dash. +#[derive(Clone, Copy)] +struct Phase { + ix: usize, + rem: f64, + on: bool, +} + +fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool, pat: &[f64], mut ph: Phase) { + const EPS: f64 = 1e-9; + let first_dot = out.dots.len(); + let starts_on = ph.on && ph.rem > 0.0; + let mut dashes: Vec> = vec![]; + let mut cur: Vec = vec![]; + let mut cuts = 0; + for (seg, &len) in segs.iter().zip(lens) { + let mut s = 0.0; + loop { + let left = len - s; + if ph.rem > left + EPS { + if ph.on && left > EPS { + cur.push(sub(seg, len, s, len)); + } + ph.rem -= left; + break; + } + let end = (s + ph.rem).min(len); + if ph.on { + if pat[ph.ix] > 0.0 { + if end > s { + cur.push(sub(seg, len, s, end)); + } + if !cur.is_empty() { + dashes.push(std::mem::take(&mut cur)); + } + } else { + let t = param(seg, len, end); + out.dots.push(Dot { at: seg.eval(t), dir: tangent(seg, t) }); + } + } + cuts += 1; + s = end; + ph.ix = (ph.ix + 1) % pat.len(); + ph.rem = pat[ph.ix]; + ph.on = !ph.on; + } + } + let ends_on = !cur.is_empty(); + if ends_on { + dashes.push(cur); + } + if closed { + if cuts == 0 && ends_on { + // One dash all the way round: keep it closed so the start gets a join, not caps. + let segs = dashes.pop().unwrap_or_default(); + write(&mut out.path, &segs); + out.path.close_path(); + return; + } + if starts_on && ends_on && dashes.len() > 1 { + // The dash running through the start point is one dash. + let mut last = dashes.pop().unwrap_or_default(); + last.append(&mut dashes[0]); + dashes[0] = last; + } + // A dot on the start point is reached again at the end. + if out.dots.len() > first_dot + 1 && out.dots[first_dot].at.distance(out.dots[out.dots.len() - 1].at) < 1e-6 { + out.dots.pop(); + } + } + for d in &dashes { + write(&mut out.path, d); + } +} + +fn write(out: &mut BezPath, segs: &[PathSeg]) { + let Some(first) = segs.first() else { return }; + out.move_to(first.start()); + for s in segs { + push_seg(out, s); + } +} + +/// Curve parameter at arc length `s` along `seg` (of total length `len`). +fn param(seg: &PathSeg, len: f64, s: f64) -> f64 { + if s <= 0.0 { + 0.0 + } else if s >= len { + 1.0 + } else if let PathSeg::Line(_) = seg { + s / len + } else { + seg.inv_arclen(s, ARCLEN_ACCURACY) + } +} + +/// The part of `seg` between arc lengths `s0` and `s1`. +fn sub(seg: &PathSeg, len: f64, s0: f64, s1: f64) -> PathSeg { + seg.subsegment(param(seg, len, s0)..param(seg, len, s1)) +} + +/// Outlines of `dots` for a stroke of `width` with `cap`: discs (round), squares turned along the +/// path (projecting) or nothing (butt). Each outline winds like kurbo's stroke outlines, so a dot +/// touching a dash doesn't cancel it under the non-zero rule. +pub fn dot_outline(dots: &[Dot], width: f64, cap: LineCap, tol: f64) -> BezPath { + let mut out = BezPath::new(); + let r = width / 2.0; + if r <= 0.0 { + return out; + } + for d in dots { + match cap { + LineCap::Butt => {} + LineCap::Round => out.extend(kurbo::Circle::new(d.at, r).path_elements(tol)), + LineCap::Square => { + let (u, n) = (d.dir * r, Vec2::new(-d.dir.y, d.dir.x) * r); + out.move_to(d.at - u - n); + out.line_to(d.at + u - n); + out.line_to(d.at + u + n); + out.line_to(d.at - u + n); + out.close_path(); + } + } + } + out +} diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 49e03c3b..d7f3b20a 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -1,6 +1,6 @@ -use kurbo::{BezPath, Point, Shape}; +use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Arrowhead, Dash, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile}; use super::*; @@ -11,6 +11,10 @@ fn line(x0: f64, x1: f64) -> BezPath { b } +fn square(size: f64) -> BezPath { + Rect::new(0.0, 0.0, size, size).to_path(0.1) +} + fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); f(&mut st); @@ -21,10 +25,91 @@ fn dashed(pattern: &[f64], offset: f64) -> Dash { Dash { pattern: pattern.to_vec(), offset, align_corners: false } } +fn length(bp: &BezPath) -> f64 { + bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-9)).sum() +} + +fn moves(bp: &BezPath) -> usize { + bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() +} + fn near(a: f64, b: f64, tol: f64) -> bool { (a - b).abs() <= tol } +// ---------------------------------------------------------------- dashes and dots + +#[test] +fn zero_length_dashes_are_dots_along_the_tangent() { + let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap(); + assert!(d.path.elements().is_empty(), "no dash has length"); + let xs: Vec = d.dots.iter().map(|p| p.at.x).collect(); + assert_eq!(d.dots.len(), 11, "{xs:?}"); + for (i, dot) in d.dots.iter().enumerate() { + assert!(near(dot.at.x, 6.0 * i as f64, 1e-9) && near(dot.at.y, 0.0, 1e-12)); + assert!(near(dot.dir.x, 1.0, 1e-12)); + } + // The offset shifts the dots along the path. + let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 3.0)).unwrap(); + assert_eq!(d.dots.len(), 10); + assert!(near(d.dots[0].at.x, 3.0, 1e-9)); + // Dots on a curve follow its direction. + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6); + let d = dash(&c, &dashed(&[0.0, 10.0], 0.0)).unwrap(); + for dot in &d.dots { + assert!(dot.dir.dot(dot.at.to_vec2()).abs() < 1e-3, "tangent ⟂ radius at {:?}", dot.at); + } +} + +#[test] +fn dots_on_a_closed_path_are_not_doubled_at_the_start() { + let d = dash(&square(60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap(); + assert_eq!(d.dots.len(), 40); +} + +#[test] +fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() { + let d = dash(&line(10.0, 90.0), &dashed(&[10.0, 10.0], 0.0)).unwrap(); + assert_eq!(moves(&d.path), 4); + assert!(near(length(&d.path), 40.0, 1e-6)); + // An odd pattern repeats with dashes and gaps swapped: 10 on, 10 off, 10 on… + let odd = dash(&line(0.0, 80.0), &dashed(&[10.0], 0.0)).unwrap(); + assert_eq!(moves(&odd.path), 4); + // Perimeter 400, dashes of 20 starting 10 into the pattern: the dash at 390..410 runs + // through the start point and is one dash. + let sq = dash(&square(100.0), &dashed(&[20.0, 20.0], 10.0)).unwrap(); + assert_eq!(moves(&sq.path), 10); + assert!(near(length(&sq.path), 200.0, 1e-6)); + // A pattern without length is a solid line; one dash round a closed path stays closed. + assert!(dash(&line(0.0, 10.0), &dashed(&[0.0, 0.0], 0.0)).is_none()); + let whole = dash(&square(10.0), &dashed(&[100.0, 1.0], 0.0)).unwrap(); + assert!(is_closed(&whole.path)); + // A pattern far too fine for the path is drawn solid rather than as millions of dashes. + assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none()); +} + +#[test] +fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() { + let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0) }]; + let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4); + assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3)); + let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4); + assert!(near(sq.area(), 16.0, 1e-9)); + assert!(sq.contains(Point::new(11.9, 1.9)) && !round.contains(Point::new(11.9, 1.9))); + assert!(dot_outline(&dots, 4.0, LineCap::Butt, 1e-4).elements().is_empty()); + // A dot overlapping a dash adds to it under the non-zero rule instead of cancelling it. + let dash_outline = kurbo::stroke(line(0.0, 10.0).iter(), &kurbo::Stroke::new(4.0), &Default::default(), 1e-4); + for o in [&round, &sq] { + assert_eq!(o.area().signum(), dash_outline.area().signum()); + } + let st = stroke(4.0, |s| { + s.cap = LineCap::Round; + s.dash = Some(dashed(&[10.0, 1.0, 0.0, 1.0], 0.0)); + }); + let o = line_outline(&line(0.0, 12.0), &st, 4.0, 1e-3); + assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted"); +} + #[test] fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let l = line(0.0, 100.0); @@ -50,6 +135,8 @@ fn aligned_width_doubles_only_closed_inside_and_outside_strokes() { assert_eq!(aligned_width(&st, true), 3.0); } +// ---------------------------------------------------------------- arrowheads + #[test] fn no_heads_borrows_the_path() { let l = line(0.0, 50.0); diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 0ddf153e..b32f440d 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", has_doc, stroke_set ), diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs new file mode 100644 index 00000000..f2d5215f --- /dev/null +++ b/crates/render/tests/stroke_geometry.rs @@ -0,0 +1,77 @@ +//! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas: +//! dotted lines (zero-length dashes). + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineCap, StrokeLayer}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, assert_similar, render_region}; +use vectorcraft_testkit::svg; + +/// Pixels per point in these tests. +const S: f64 = 4.0; + +fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + f(&mut st); + Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] } +} + +/// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard. +fn line_doc(x0: f64, x1: f64, app: Appearance, opacity: f32) -> Document { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::line(Point::new(x0, 50.0), Point::new(x1, 50.0)), app, |n| n.opacity = opacity); + b.build() +} + +fn render(d: &Document) -> Image { + render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S) +} + +/// Is the pixel at document point (x, y) dark? +fn dark(img: &Image, x: f64, y: f64) -> bool { + img.over_white((x * S) as u32, (y * S) as u32)[0] < 128 +} + +fn dotted(cap: LineCap) -> Document { + line_doc( + 20.0, + 80.0, + stroked(4.0, |s| { + s.cap = cap; + s.dash = Some(Dash { pattern: vec![0.0, 6.0], offset: 0.0, align_corners: false }); + }), + 1.0, + ) +} + +#[test] +fn zero_length_dashes_draw_dots_with_a_round_cap_and_squares_with_a_projecting_cap() { + let round = render(&dotted(LineCap::Round)); + // 60 pt with a dot every 6 pt: 11 separate dots along the centre line. + let row: Vec = (0..round.width).map(|x| round.over_white(x, (50.0 * S) as u32)[0] < 128).collect(); + let runs = row.windows(2).filter(|w| !w[0] && w[1]).count(); + assert_eq!(runs, 11, "dots along the line"); + for k in 0..=10 { + let x = 20.0 + 6.0 * k as f64; + assert!(dark(&round, x, 50.0), "dot {k}"); + assert!(!dark(&round, x + 3.0, 50.0), "gap after dot {k}"); + } + // A disc's corner region is empty; a square fills it. + assert!(!dark(&round, 20.0 + 1.8, 50.0 + 1.8)); + let square = render(&dotted(LineCap::Square)); + assert!(dark(&square, 20.0 + 1.8, 50.0 + 1.8)); + assert!(dark(&square, 26.0 - 1.8, 50.0 - 1.8)); + // A butt cap gives a zero-length dash no area at all. + assert_eq!(render(&dotted(LineCap::Butt)).ink(), 0); +} + +#[test] +fn dotted_lines_survive_svg_export_and_render_the_same() { + for cap in [LineCap::Round, LineCap::Square] { + let d = dotted(cap); + let text = svg::export(&d, &Default::default()); + let back = svg::import(&text).unwrap(); + assert_similar(&render(&d), &render(&back), 0.05, 0.001); + } +}