diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 38a7b347..9a4ca346 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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 { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_fit; diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 16590f59..5c47c074 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -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, } @@ -43,13 +57,18 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option { pat.extend_from_within(..); } let period: f64 = pat.iter().sum(); - let subs: Vec<(Vec, bool)> = subpaths(bp).into_iter().map(|(r, closed)| (segments(&bp.elements()[r]), closed)).collect(); - let lens: Vec> = subs.iter().map(|(segs, _)| segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect()).collect(); - let total: f64 = lens.iter().flatten().sum(); - if total / period * pat.len() as f64 > MAX_DASHES { + let plans: Vec = 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::() * 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 { } 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, + lens: Vec, + closed: bool, + /// The pattern's scale on each segment (1 for exact dashes). + scale: Vec, + /// Segments that start a run between corners: the pattern restarts there in the middle of its + /// first dash (fitted dashes only). + restart: Vec, + /// 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, lens: Vec, closed: bool, period: f64) -> Self { + let n = segs.len(); + let periods = lens.iter().sum::() / period; + Plan { segs, lens, closed, scale: vec![1.0; n], restart: vec![false; n], shift: 0.0, periods } + } + + fn fitted(mut segs: Vec, mut lens: Vec, 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 = (0..n).filter(|&i| lens[i] > EPS).collect(); + let mut corners: Vec = 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, + 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![]; - let mut cur: Vec = 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 = 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); } } diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 6811e810..acc8a757 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -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()); } diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs new file mode 100644 index 00000000..91933087 --- /dev/null +++ b/crates/effects/src/stroke/tests_fit.rs @@ -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 = 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 = 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}"); + } +} diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index aef94bf6..41d1481d 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -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, bool)> { - let mut out: Vec<(Vec, bool)> = vec![]; - let mut cur: Vec = vec![]; - let push = |cur: &mut Vec, 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, Vec) { + 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 { +/// 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, Vec) { 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 = 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 = 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 = (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 = (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, 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| 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, 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 = 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}"); } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 1e2c7e91..79296a27 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -14,7 +14,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (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 { Some(Value::Null) => st.dash = None, Some(Value::Array(a)) => { let pattern: Vec = 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; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2e044ada..f5247955 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_dashalign.rs b/crates/engine/src/tests_dashalign.rs new file mode 100644 index 00000000..3c6a9018 --- /dev/null +++ b/crates/engine/src/tests_dashalign.rs @@ -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 { + 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))); +} diff --git a/crates/engine/src/tests_outlinestroke.rs b/crates/engine/src/tests_outlinestroke.rs index d9298ffd..6249df2e 100644 --- a/crates/engine/src/tests_outlinestroke.rs +++ b/crates/engine/src/tests_outlinestroke.rs @@ -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()), diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index fcf997bf..ec4052af 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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; diff --git a/crates/pdf/src/tests_dashalign.rs b/crates/pdf/src/tests_dashalign.rs new file mode 100644 index 00000000..fbce3e3e --- /dev/null +++ b/crates/pdf/src/tests_dashalign.rs @@ -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); +} diff --git a/crates/render/tests/dash_align.rs b/crates/render/tests/dash_align.rs new file mode 100644 index 00000000..e6250c6e --- /dev/null +++ b/crates/render/tests/dash_align.rs @@ -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"); +} diff --git a/crates/svg/tests/dash_align.rs b/crates/svg/tests/dash_align.rs new file mode 100644 index 00000000..7dc63b0a --- /dev/null +++ b/crates/svg/tests/dash_align.rs @@ -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\"")); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 65672903..0b0613ed 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -358,3 +358,5 @@ mod tests { mod tests_appearance; #[cfg(test)] mod tests_effectedit; +#[cfg(test)] +mod tests_stroke; diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 542ee333..4b6de5e3 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -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; 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; 6], bool)>) -> ([Option; 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; 6]) -> Vec { @@ -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; 6] = match &dash { - Some(d) => dash_fields(&d.pattern), - None => pstate::; 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| { diff --git a/crates/ui-egui/src/panels/tests_stroke.rs b/crates/ui-egui/src/panels/tests_stroke.rs new file mode 100644 index 00000000..c1290b02 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_stroke.rs @@ -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 { + 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)); +}