M3.19: dotted lines: zero-length dashes draw discs or squares

The shared stroke geometry now has its own arc-length dash generator: a dash
of length 0 becomes a dot that a round cap paints as a disc and a projecting
cap as a square turned along the path (a butt cap paints nothing). Dashes
restart per subpath, join across the start of closed subpaths, and a pattern
too fine for the path draws solid. The canvas and raster export use it through
line_outline; the stroke.set params doc says how to get dots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 17:18:13 +07:00
parent 09d7aaa7f4
commit 23f19a861b
5 changed files with 413 additions and 15 deletions

View File

@ -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<usize>, 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<PathSeg> {
kurbo::segments(els.iter().copied()).collect()
}
#[cfg(test)]
mod tests;

View File

@ -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<Dot>,
}
/// 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<Dashed> {
if d.pattern.iter().any(|v| !v.is_finite()) || !d.offset.is_finite() {
return None;
}
let mut pat: Vec<f64> = 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<PathSeg>, bool)> = subpaths(bp).into_iter().map(|(r, closed)| (segments(&bp.elements()[r]), closed)).collect();
let lens: Vec<Vec<f64>> = 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<PathSeg>> = vec![];
let mut cur: Vec<PathSeg> = 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
}

View File

@ -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<f64> = 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);

View File

@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),

View File

@ -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<bool> = (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);
}
}