mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M3.38: dashes fitted to corners and path ends, a flag of their own that keeps the offset
With alignDashes every run between corners and path ends holds a whole number of periods, scaled to fit, with a dash (or dot) centred on each corner and on both ends of an open path. It lives in the shared dash generator, so the canvas, SVG and PDF export (as outlines) and Outline Stroke all draw it. stroke.set: alignDashes and dashOffset edit the current pattern on their own, and a new dash array keeps the offset and alignment it replaces. The Stroke panel turns dashes on fitted to corners (or as last turned off), and its Exact/Fit buttons send only the flag. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b85ef3de5a
commit
42ad4540b7
16 files changed
+613
-54
No files matched your search
@@ -3,8 +3,8 @@
|
||||
//!
|
||||
//! - [`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`]);
|
||||
//! - [`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;
|
||||
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
|
||||
//! caps and joins);
|
||||
@@ -27,6 +27,8 @@ pub use width::width_outline;
|
||||
|
||||
/// Arc-length accuracy for dashing and trimming (document points).
|
||||
const ARCLEN_ACCURACY: f64 = 1e-6;
|
||||
/// Tangents meeting at more than about 1° (this cosine) make a corner that fitted dashes centre on.
|
||||
const CORNER_COS: f64 = 0.9998;
|
||||
|
||||
/// The geometry one stroke paints, in the coordinate space of its path.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -112,6 +114,11 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +190,5 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fit;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Dashes fitted to corners and path ends.
|
||||
|
||||
use kurbo::{BezPath, ParamCurveArclen, PathEl, Point, Rect, Shape};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Dash, LineCap, 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -746,6 +746,8 @@ mod tests_cmdsplit;
|
||||
#[cfg(test)]
|
||||
mod tests_colormgmt;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
#[cfg(test)]
|
||||
mod tests_draw2;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -108,6 +108,8 @@ pub enum PdfError {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeout;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Pixel tests for dashes fitted to corners, drawn on the canvas from the shared stroke geometry
|
||||
//! (`vectorcraft_effects::stroke`).
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||||
use vectorcraft_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);
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
@@ -356,3 +356,5 @@ mod tests {
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
@@ -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| {
|
||||
|
||||
@@ -6,7 +6,7 @@ use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn app_with_rect() -> VectorcraftApp {
|
||||
pub(super) fn app_with_rect() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone();
|
||||
@@ -14,7 +14,7 @@ fn app_with_rect() -> VectorcraftApp {
|
||||
app
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.session.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fn frame_in(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut Ve
|
||||
}
|
||||
|
||||
/// One headless frame with input `events`: every text drawn, with its screen rect.
|
||||
fn frame_events(
|
||||
pub(super) fn frame_events(
|
||||
ctx: &egui::Context,
|
||||
app: &mut VectorcraftApp,
|
||||
events: Vec<egui::Event>,
|
||||
@@ -52,7 +52,7 @@ fn frame_events(
|
||||
}
|
||||
|
||||
/// Click at `pos` over three frames of `f` (hover, press, release).
|
||||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
pub(super) fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
|
||||
frame_events(ctx, app, vec![e], &mut f);
|
||||
@@ -60,7 +60,7 @@ fn click(ctx: &egui::Context, app: &mut VectorcraftApp, pos: egui::Pos2, mut f:
|
||||
}
|
||||
|
||||
/// The rect of the first drawn text equal to `s`.
|
||||
fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
|
||||
pub(super) fn text_rect(texts: &[(String, egui::Rect)], s: &str) -> egui::Rect {
|
||||
texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("`{s}` not drawn: {texts:?}")).1
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user