Merge wp/C3: dash alignment and variable-width dashes (M3.38, M3.39)

# Conflicts:
#	crates/pdf/src/lib.rs
#	crates/ui-egui/src/panels/mod.rs
This commit is contained in:
bflatastic 2026-10-04 23:48:47 +07:00
commit 3981acdb06
16 changed files with 939 additions and 185 deletions

View File

@ -3,9 +3,9 @@
//!
//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
//! - [`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;
//! - [`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, 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,6 +27,9 @@ 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: 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.
#[derive(Clone, Debug)]
@ -84,34 +87,43 @@ 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),
}
}
/// Do segments `a` and `b` (`b` following `a`) meet at a corner?
pub(crate) fn kink(a: &PathSeg, b: &PathSeg) -> bool {
tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS
}
/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point.
pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 {
let d = match s {
@ -183,3 +195,5 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_fit;

View File

@ -1,29 +1,43 @@
//! 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.
//!
//! Exact dashes keep the pattern's lengths and start `offset` into it. Dashes fitted to corners
//! ([`Dash::align_corners`]) ignore the offset: every run between corners and path ends holds a
//! whole number of periods, stretched or squeezed to fit, starting and ending in the middle of the
//! first dash, so a dash is centred on every corner and on both ends of an open path.
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
use vectorcraft_doc::{Dash, LineCap};
use super::{ARCLEN_ACCURACY, push_seg, segments, subpaths, tangent};
use super::{ARCLEN_ACCURACY, kink, 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 pattern whose longest entry is shorter than this can't advance along the path (each entry
/// ends within the generator's tolerance of where it starts), so it draws the line solid.
const MIN_ENTRY: f64 = 1e-6;
/// A run between corners shorter than this keeps the pattern's own lengths (too short to fit).
const MIN_RUN: f64 = 1e-6;
/// Arc-length tolerance of the generator.
const EPS: f64 = 1e-9;
/// 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,
/// Fraction of its subpath's length at the dot (where a width profile is read).
pub t: f64,
}
/// 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,
/// For each dash (subpath of `path`, in order), the fractions of its subpath's length where it
/// starts and ends. The end passes 1 for a dash running on through the start of a closed path.
pub spans: Vec<(f64, f64)>,
pub dots: Vec<Dot>,
}
@ -43,13 +57,18 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
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 {
let plans: Vec<Plan> = subpaths(bp)
.into_iter()
.map(|(r, closed)| {
let segs = segments(&bp.elements()[r]);
let lens = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
if d.align_corners { Plan::fitted(segs, lens, closed, period) } else { Plan::exact(segs, lens, closed, period) }
})
.collect();
if plans.iter().map(|p| p.periods).sum::<f64>() * pat.len() as f64 > MAX_DASHES {
return None;
}
// Where the pattern starts: `offset` into it.
// Where an exact pattern starts: `offset` into it.
let mut off = d.offset.rem_euclid(period);
let mut ix0 = 0;
while off > pat[ix0] {
@ -58,12 +77,75 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
}
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);
for plan in &plans {
dash_subpath(&mut out, plan, &pat, phase);
}
Some(out)
}
/// How the pattern runs along one subpath.
struct Plan {
segs: Vec<PathSeg>,
lens: Vec<f64>,
closed: bool,
/// The pattern's scale on each segment (1 for exact dashes).
scale: Vec<f64>,
/// Segments that start a run between corners: the pattern restarts there in the middle of its
/// first dash (fitted dashes only).
restart: Vec<bool>,
/// Arc length from the subpath's start to the start of `segs` (a fitted closed subpath starts
/// at its first corner).
shift: f64,
/// Periods of the pattern drawn.
periods: f64,
}
impl Plan {
fn exact(segs: Vec<PathSeg>, lens: Vec<f64>, closed: bool, period: f64) -> Self {
let n = segs.len();
let periods = lens.iter().sum::<f64>() / period;
Plan { segs, lens, closed, scale: vec![1.0; n], restart: vec![false; n], shift: 0.0, periods }
}
fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self {
let n = segs.len();
// Corners: where segments with length meet at a kink (zero-length ones have no direction).
let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect();
let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect();
let mut rot = 0;
if closed && let (Some(&first), Some(&last)) = (live.first(), live.last()) {
if kink(&segs[last], &segs[first]) {
corners.insert(0, first);
}
// Start at a corner so that no run crosses the start point.
rot = corners.first().copied().unwrap_or(0);
}
segs.rotate_left(rot);
lens.rotate_left(rot);
let shift = lens[n - rot..].iter().sum();
let mut restart = vec![false; n];
restart[0] = true;
for c in corners {
restart[(c + n - rot) % n] = true;
}
let (mut scale, mut periods) = (vec![1.0; n], 0.0);
let mut i = 0;
while i < n {
let j = (i + 1..n).find(|&j| restart[j]).unwrap_or(n);
let run: f64 = lens[i..j].iter().sum();
if run > MIN_RUN {
let k = (run / period).round().max(1.0);
scale[i..j].fill(run / (k * period));
periods += k;
} else {
periods += run / period;
}
i = j;
}
Plan { segs, lens, closed, scale, restart, shift, periods }
}
}
/// Position in the pattern: entry index, length left in it, and whether it is a dash.
#[derive(Clone, Copy)]
struct Phase {
@ -72,20 +154,56 @@ struct Phase {
on: bool,
}
fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool, pat: &[f64], mut ph: Phase) {
const EPS: f64 = 1e-9;
/// A dash being collected: its segments and where it starts and ends along its plan.
#[derive(Default)]
struct Piece {
segs: Vec<PathSeg>,
start: f64,
end: f64,
}
impl Piece {
fn push(&mut self, seg: PathSeg, at: f64) {
if self.segs.is_empty() {
self.start = at;
}
self.segs.push(seg);
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
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 total: f64 = plan.lens.iter().sum();
// Fraction of the subpath's length at `x` along the plan.
let frac = |x: f64| {
if total <= 0.0 {
return 0.0;
}
let f = (x + plan.shift) / total;
if f > 1.0 { f - 1.0 } else { f }
};
let mut dashes: Vec<Piece> = vec![];
let mut cur = Piece::default();
let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false);
for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() {
let k = plan.scale[i];
if plan.restart[i] {
// The dash running into a corner goes on past it; a dot there ends it.
if pat[0] <= 0.0 && !cur.segs.is_empty() {
cur.end = acc;
dashes.push(std::mem::take(&mut cur));
}
ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true };
}
if i == 0 {
starts_on = ph.on && ph.rem > 0.0;
}
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));
cur.push(sub(seg, len, s, len), acc + s);
}
ph.rem -= left;
break;
@ -94,40 +212,52 @@ fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool,
if ph.on {
if pat[ph.ix] > 0.0 {
if end > s {
cur.push(sub(seg, len, s, end));
cur.push(sub(seg, len, s, end), acc + s);
}
if !cur.is_empty() {
if !cur.segs.is_empty() {
cur.end = acc + end;
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) });
push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) });
}
}
cuts += 1;
s = end;
ph.ix = (ph.ix + 1) % pat.len();
ph.rem = pat[ph.ix];
ph.rem = pat[ph.ix] * k;
ph.on = !ph.on;
}
acc += len;
}
let ends_on = !cur.is_empty();
// A fitted open path whose pattern starts with a dot ends on one, whatever the rounding.
if !plan.closed
&& plan.restart[0]
&& pat[0] <= 0.0
&& let Some(seg) = plan.segs.last()
{
push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 });
}
let ends_on = !cur.segs.is_empty();
if ends_on {
cur.end = total;
dashes.push(cur);
}
if closed {
if plan.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);
let d = dashes.pop().unwrap_or_default();
write(&mut out.path, &d.segs);
out.path.close_path();
out.spans.push((frac(0.0), frac(0.0) + 1.0));
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;
last.segs.append(&mut dashes[0].segs);
dashes[0] = Piece { segs: last.segs, start: last.start, end: dashes[0].end + total };
}
// 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 {
@ -135,7 +265,17 @@ fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool,
}
}
for d in &dashes {
write(&mut out.path, d);
write(&mut out.path, &d.segs);
let t0 = frac(d.start);
out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 }));
}
}
/// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on
/// a dot meets the next one starting on it.
fn push_dot(out: &mut Dashed, first: usize, dot: Dot) {
if out.dots[first..].last().is_none_or(|d| d.at.distance(dot.at) > 1e-6) {
out.dots.push(dot);
}
}

View File

@ -90,7 +90,7 @@ fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
#[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 dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 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);
@ -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

@ -0,0 +1,224 @@
//! 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, LineJoin, StrokeLayer, WidthProfile};
use super::*;
fn fitted(pattern: &[f64], offset: f64) -> Dash {
Dash { pattern: pattern.to_vec(), offset, align_corners: true }
}
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
fn line(x1: f64) -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
b.line_to((x1, 0.0));
b
}
fn moves(bp: &BezPath) -> usize {
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
}
fn length(bp: &BezPath) -> f64 {
bp.segments().map(|s| s.arclen(1e-9)).sum()
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
// ---------------------------------------------------------------- dashes fitted to corners
#[test]
fn every_corner_of_a_rectangle_falls_mid_dash() {
// 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3
// (50/54), so every half dash at a corner is 6 · 100/108 long.
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let d = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 5 + 2 + 5 + 2 + 4, "inner dashes of each side plus one per corner");
assert!(near(length(&d.path), 200.0, 1e-6), "two thirds of the perimeter");
let half = 6.0 * 100.0 / 108.0;
let o = line_outline(&r, &stroke(2.0, |s| s.dash = Some(fitted(&[12.0, 6.0], 0.0))), 2.0, 1e-3);
for (c, along) in [
((0.0, 0.0), [(1.0, 0.0), (0.0, 1.0)]),
((100.0, 0.0), [(-1.0, 0.0), (0.0, 1.0)]),
((100.0, 50.0), [(-1.0, 0.0), (0.0, -1.0)]),
((0.0, 50.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
let c = Point::from(c);
assert!(o.contains(c), "corner {c:?} is painted");
for v in along {
let v = kurbo::Vec2::from(v);
assert!(o.contains(c + v * (half - 0.3)), "dash runs on from {c:?} along {v:?}");
assert!(!o.contains(c + v * (half + 0.3)), "and stops half a dash from {c:?} along {v:?}");
}
}
// The spans of the dashes cover the same length.
let covered: f64 = d.spans.iter().map(|(a, b)| b - a).sum();
assert!(near(covered * 300.0, 200.0, 1e-6), "{:?}", d.spans);
}
#[test]
fn open_paths_start_and_end_mid_dash_and_runs_hold_whole_periods() {
// 100 long, 10/10: five periods, half a dash at each end.
let d = dash(&line(100.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 50.0, 1e-6));
let ends: Vec<f64> = d.path.elements().iter().filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert!(near(ends[0], 0.0, 1e-9) && near(ends[1], 5.0, 1e-9) && near(*ends.last().unwrap(), 100.0, 1e-9), "{ends:?}");
// 92 long: 4.6 periods round to 5, squeezed to fit.
let d = dash(&line(92.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 46.0, 1e-6));
// A run shorter than a period still gets one: half dashes at its ends.
let d = dash(&line(4.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 2);
assert!(near(length(&d.path), 2.0, 1e-6));
}
#[test]
fn fitted_dashes_ignore_the_offset_and_keep_exact_dashes_unchanged() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let a = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
let b = dash(&r, &fitted(&[12.0, 6.0], 5.0)).unwrap();
assert_eq!(a.path, b.path);
// Exact dashes still start at the path's start, `offset` into the pattern.
let exact = dash(&line(100.0), &Dash { pattern: vec![10.0, 10.0], offset: 5.0, align_corners: false }).unwrap();
let first: Vec<f64> = exact.path.elements().iter().take(2).filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert_eq!(first, vec![0.0, 5.0]);
assert_eq!(moves(&exact.path), 6);
}
#[test]
fn smooth_closed_paths_hold_a_whole_number_of_dashes() {
// A circle has no corners: one run all the way round, 17 periods of 12/6 (314 / 18 ≈ 17.5).
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
let d = dash(&c, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 17, "the dash on the start point is one dash");
let circumference = 2.0 * std::f64::consts::PI * 50.0;
assert!(near(length(&d.path), circumference * 12.0 / 18.0, 1e-3));
}
#[test]
fn fitted_dots_sit_on_every_corner_and_end_once() {
// 60 pt sides with a dot every 7: 9 periods a side (60 / 63 scale), the corner dots shared.
let sq = Rect::new(0.0, 0.0, 60.0, 60.0).to_path(0.1);
let d = dash(&sq, &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 36);
for c in [(0.0, 0.0), (60.0, 0.0), (60.0, 60.0), (0.0, 60.0)] {
assert_eq!(d.dots.iter().filter(|p| p.at.distance(Point::from(c)) < 1e-6).count(), 1, "one dot on {c:?}");
}
let d = dash(&line(60.0), &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 10);
assert!(near(d.dots[0].at.x, 0.0, 1e-9) && near(d.dots[9].at.x, 60.0, 1e-9));
assert!(near(d.dots[9].t, 1.0, 1e-9));
}
#[test]
fn fitted_dashes_on_curves_and_degenerate_paths_stay_finite() {
let mut mixed = BezPath::new();
mixed.move_to((0.0, 0.0));
mixed.line_to((0.0, 0.0));
mixed.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
mixed.line_to((100.0, 0.0));
mixed.line_to((50.0, 60.0));
mixed.close_path();
mixed.move_to((200.0, 0.0));
mixed.line_to((200.0, 1e-7));
for pat in [&[12.0, 6.0][..], &[0.0, 5.0], &[3.0], &[5.0, 0.0], &[1e-12, 4.0, 0.0, 2.0]] {
for profile in [None, Some(WidthProfile::lens())] {
let st = stroke(4.0, |s| {
s.dash = Some(fitted(pat, 0.0));
s.cap = LineCap::Round;
s.profile = profile.clone();
});
let o = line_outline(&mixed, &st, 4.0, 0.01);
assert!(o.elements().iter().filter_map(|e| e.end_point()).all(|p| p.x.is_finite() && p.y.is_finite()), "{pat:?}");
}
}
}
// ---------------------------------------------------------------- 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

@ -14,7 +14,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 (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, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}",
has_doc,
stroke_set
),
@ -90,14 +90,19 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
st.dash = if pattern.is_empty() {
None
} else {
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
};
// A new pattern keeps the offset and alignment of the one it replaces.
st.dash = (!pattern.is_empty()).then(|| Dash { pattern, ..st.dash.take().unwrap_or_default() });
}
_ => {}
}
if let Some(d) = st.dash.as_mut() {
if let Some(o) = p.get("dashOffset").and_then(Value::as_f64) {
d.offset = o;
}
if let Some(a) = p.get("alignDashes").and_then(Value::as_bool) {
d.align_corners = a;
}
}
if let Some(a) = sa {
st.start_arrow = a;
}

View File

@ -750,6 +750,8 @@ mod tests_cmdsplit;
#[cfg(test)]
mod tests_colormgmt;
#[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_distort;
#[cfg(test)]
mod tests_draw2;

View File

@ -0,0 +1,78 @@
//! Dashes fitted to corners and path ends from `stroke.set` (`alignDashes`, `dashOffset`), and
//! Outline Stroke of fitted dashes.
use kurbo::Shape;
use serde_json::json;
use vectorcraft_doc::Dash;
use super::tests_outlinestroke::{outline, path_of};
use super::*;
fn session_with_rect() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn dash_of(s: &Session, id: NodeId) -> Option<Dash> {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().dash.clone()
}
fn set(s: &mut Session, p: serde_json::Value) {
s.execute("stroke.set", &p).unwrap();
}
#[test]
fn align_dashes_and_the_offset_edit_the_pattern_in_place() {
let (mut s, id) = session_with_rect();
// Without a dash pattern there is nothing to align or offset.
set(&mut s, json!({"alignDashes": true, "dashOffset": 4}));
assert_eq!(dash_of(&s, id), None);
// A new pattern is exact unless asked otherwise.
set(&mut s, json!({"dash": [12, 6], "dashOffset": 3}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![12.0, 6.0], offset: 3.0, align_corners: false }));
// The flag on its own keeps the pattern and the offset.
set(&mut s, json!({"alignDashes": true}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![12.0, 6.0], offset: 3.0, align_corners: true }));
// So does a new pattern (as the panel's dash fields send it), and the offset alone.
set(&mut s, json!({"dash": [4, 2]}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![4.0, 2.0], offset: 3.0, align_corners: true }));
set(&mut s, json!({"dashOffset": 1.5}));
assert_eq!(dash_of(&s, id), Some(Dash { pattern: vec![4.0, 2.0], offset: 1.5, align_corners: true }));
// Each edit is one undo step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(dash_of(&s, id).unwrap().offset, 3.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(dash_of(&s, id).unwrap().pattern, vec![12.0, 6.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!dash_of(&s, id).unwrap().align_corners);
// The params doc tells agents about both modes.
let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap();
assert!(spec.params.contains("alignDashes?: bool") && spec.params.contains("dashOffset? (exact dashes only)"));
}
#[test]
fn fitted_dashes_survive_the_native_format() {
let (mut s, id) = session_with_rect();
set(&mut s, json!({"dash": [12, 6], "dashOffset": 2, "alignDashes": true}));
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap().dash, dash_of(&s, id));
}
#[test]
fn outline_stroke_of_fitted_dashes_wraps_every_corner() {
let (mut s, id) = session_with_rect();
set(&mut s, json!({"weight": 2, "dash": [12, 6], "alignDashes": true}));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
// The fill, then the outlined dashes.
let ring = path_of(&outline(&mut s).children().unwrap()[1]);
let bp = ring.to_bezpath();
// 5 + 2 + 5 + 2 dashes inside the sides and one wrapped round each corner.
assert_eq!(ring.subpaths.len(), 18);
let half = 6.0 * 100.0 / 108.0;
for (x, y) in [(20.0, 20.0), (120.0, 20.0), (120.0, 70.0), (20.0, 70.0)] {
assert!(bp.contains(kurbo::Point::new(x, y)), "corner ({x}, {y})");
}
assert!(bp.contains(kurbo::Point::new(20.0 + half - 0.3, 20.0)) && !bp.contains(kurbo::Point::new(20.0 + half + 0.3, 20.0)));
}

View File

@ -23,13 +23,13 @@ fn line(s: &mut Session, opts: Value) -> NodeId {
id
}
fn outline(s: &mut Session) -> Node {
pub(super) fn outline(s: &mut Session) -> Node {
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
fn path_of(n: &Node) -> PathData {
pub(super) fn path_of(n: &Node) -> PathData {
match &n.kind {
NodeKind::Path { path, .. } => path.clone(),
NodeKind::Compound { children, .. } => PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()),

View File

@ -108,6 +108,8 @@ pub enum PdfError {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_fx;
#[cfg(test)]
mod tests_stroke;

View File

@ -0,0 +1,47 @@
//! Dashes fitted to corners are written as the canvas's filled outlines; exact dashes stay a
//! PDF dash array.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, NodeId, StrokeLayer};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
use crate::tests_stroke::{line_doc, painted_boxes};
use crate::*;
fn dashed(align_corners: bool) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
st.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners });
st
}
#[test]
fn fitted_dashes_are_written_as_fills_and_exact_ones_as_a_dash_array() {
// 60 pt: three periods with half a dash at each end, so the dashes reach both end points.
let (fills, strokes) = painted_boxes(&line_doc(dashed(true)).0);
assert!(strokes.is_empty(), "{strokes:?}");
assert_eq!(fills.len(), 1);
let b = fills[0];
assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && (b.height() - 4.0).abs() < 0.01, "{b:?}");
let (d, _) = line_doc(dashed(false));
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut dashes = vec![];
back.walk(|m| dashes.extend(m.appearance.stroke().and_then(|s| s.dash.clone())));
assert_eq!(dashes.len(), 1);
assert_eq!(dashes[0].pattern, vec![10.0, 10.0]);
}
#[test]
fn fitted_dashes_on_a_rectangle_look_as_on_the_canvas() {
let mut d = Document::new(140.0, 90.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], effects: vec![] },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 24.0, 0.002);
}

View File

@ -0,0 +1,78 @@
//! 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, LineJoin, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use vectorcraft_testkit::fixtures::DocBuilder;
use vectorcraft_testkit::raster::{Image, render_region};
/// Pixels per point in these tests.
const S: f64 = 4.0;
fn doc(path: PathData, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Document {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
let mut b = DocBuilder::new(160.0, 120.0);
b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, |_| {});
b.build()
}
fn render(d: &Document) -> Image {
render_region(d, Rect::new(0.0, 0.0, 160.0, 120.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 rect_doc(align_corners: bool) -> Document {
doc(shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)), 2.0, |s| s.dash = Some(Dash { pattern: vec![12.0, 6.0], offset: 0.0, align_corners }))
}
#[test]
fn fitted_dashes_wrap_every_corner_of_a_rectangle() {
let img = render(&rect_doc(true));
// Half a dash (6 · 100/108) runs each way from every corner, then a gap of the same length.
let half = 6.0 * 100.0 / 108.0;
for (c, dirs) in [
((20.0, 20.0), [(1.0, 0.0), (0.0, 1.0)]),
((120.0, 20.0), [(-1.0, 0.0), (0.0, 1.0)]),
((120.0, 70.0), [(-1.0, 0.0), (0.0, -1.0)]),
((20.0, 70.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
for (dx, dy) in dirs {
let at = |d: f64| (c.0 + dx * d, c.1 + dy * d);
let (x, y) = at(half / 2.0);
assert!(dark(&img, x, y), "dash beside corner {c:?}");
let (x, y) = at(half * 1.5);
assert!(!dark(&img, x, y), "gap after corner {c:?} along ({dx}, {dy})");
}
}
// 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");
}

View File

@ -0,0 +1,41 @@
//! Dashes fitted to corners export as the canvas's filled outlines (a dash array can't fit them);
//! exact dashes stay a `stroke-dasharray`. Checked by rendering the exported SVG with resvg.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, StrokeLayer};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_svg::{ExportOptions, export};
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
fn rect_doc(align_corners: bool) -> Document {
let mut d = Document::new(140.0, 90.0);
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
st.dash = Some(Dash { pattern: vec![12.0, 6.0], offset: 0.0, align_corners });
let n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)),
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The SVG rendered by resvg on white at 1 px/pt.
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
pm.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
Image { width: w, height: h, rgba: pm.data().to_vec() }
}
#[test]
fn fitted_dashes_export_as_outlines_that_render_like_the_canvas() {
let d = rect_doc(true);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("stroke-dasharray") && !svg.contains("stroke="), "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 140, 90), 24.0, 0.002);
// Exact dashes are still a dash array.
assert!(export(&rect_doc(false), &ExportOptions::default()).contains("stroke-dasharray=\"12 6\""));
}

View File

@ -358,3 +358,5 @@ mod tests {
mod tests_appearance;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_stroke;

View File

@ -3,7 +3,7 @@
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
use super::{current_stroke, pstate, set_pstate};
use crate::theme::Tokens;
@ -38,6 +38,15 @@ pub fn dash_fields(pattern: &[f64]) -> [Option<f64>; 6] {
out
}
/// The dash/gap fields and alignment the Dashed Line section shows: the stroke's dashes, or with
/// dashes off the last ones turned off (`last`), at first 12 pt dashes fitted to corners.
fn dash_state(dash: Option<&Dash>, last: Option<([Option<f64>; 6], bool)>) -> ([Option<f64>; 6], bool) {
match dash {
Some(d) => (dash_fields(&d.pattern), d.align_corners),
None => last.unwrap_or(([Some(12.0), None, None, None, None, None], true)),
}
}
/// Six dash/gap fields → a dash pattern: stops at the first empty dash; a dash without a gap
/// repeats its length as the gap (Illustrator's behaviour).
pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
@ -136,16 +145,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
widgets::divider(ui);
// Dashed line.
let dash = st.as_ref().and_then(|s| s.dash.clone());
let fields: [Option<f64>; 6] = match &dash {
Some(d) => dash_fields(&d.pattern),
None => pstate::<Option<[Option<f64>; 6]>>(ui.ctx(), "stroke-dash-last").unwrap_or([Some(12.0), None, None, None, None, None]),
};
let align_corners = dash.as_ref().is_some_and(|d| d.align_corners);
let dash = st.as_ref().and_then(|s| s.dash.as_ref());
let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last"));
ui.horizontal(|ui| {
if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) {
if dash.is_some() {
set_pstate(ui.ctx(), "stroke-dash-last", Some(fields));
set_pstate(ui.ctx(), "stroke-dash-last", Some((fields, align_corners)));
set(app, json!({"dash": null}));
} else {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": align_corners}));
@ -154,10 +159,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space((ui.available_width() - 56.0).max(0.0));
let on = dash.is_some();
if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false}));
set(app, json!({"alignDashes": false}));
}
if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true}));
set(app, json!({"alignDashes": true}));
}
});
ui.horizontal(|ui| {
@ -173,7 +178,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
if changed && dash.is_some() {
set(app, json!({"dash": dash_pattern(&nf), "alignDashes": align_corners}));
// The command keeps the dash offset and alignment.
set(app, json!({"dash": dash_pattern(&nf)}));
}
});
ui.horizontal(|ui| {

View File

@ -0,0 +1,25 @@
//! Headless frames of the Stroke panel's Dashed Line section.
use serde_json::json;
use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect};
use super::*;
/// Click the Dashed Line check box and return the stroke's dash afterwards.
fn toggle_dashes(ctx: &egui::Context, app: &mut VectorcraftApp) -> Option<vectorcraft_doc::Dash> {
let at = text_rect(&frame_events(ctx, app, vec![], stroke::show), "Dashed Line").center();
click(ctx, app, at, stroke::show);
current_stroke(app).unwrap().dash
}
#[test]
fn turning_dashes_on_fits_them_to_corners_and_turning_back_on_restores_the_last_ones() {
let (ctx, mut app) = (egui::Context::default(), app_with_rect());
let d = toggle_dashes(&ctx, &mut app).expect("dashes on");
assert_eq!((d.pattern, d.align_corners), (vec![12.0, 12.0], true));
// Exact 4/2 dashes turned off and on again come back as they were.
run(&mut app, "stroke.set", json!({"dash": [4, 2], "alignDashes": false}));
assert!(toggle_dashes(&ctx, &mut app).is_none());
let d = toggle_dashes(&ctx, &mut app).expect("dashes on");
assert_eq!((d.pattern, d.align_corners), (vec![4.0, 2.0], false));
}