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:
parent
09d7aaa7f4
commit
23f19a861b
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue