From 42ad4540b735a99df76163ad4ce8adc8ecd29046 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:37:58 +0700 Subject: [PATCH 1/2] 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 --- crates/effects/src/stroke.rs | 13 +- crates/effects/src/stroke/dash.rs | 194 +++++++++++++++--- crates/effects/src/stroke/tests.rs | 2 +- crates/effects/src/stroke/tests_fit.rs | 147 +++++++++++++ crates/engine/src/cmd/stroke.rs | 17 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_dashalign.rs | 78 +++++++ crates/engine/src/tests_outlinestroke.rs | 4 +- crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_dashalign.rs | 47 +++++ crates/render/tests/dash_align.rs | 55 +++++ crates/svg/tests/dash_align.rs | 41 ++++ crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/stroke.rs | 28 ++- crates/ui-egui/src/panels/tests_appearance.rs | 10 +- crates/ui-egui/src/panels/tests_stroke.rs | 25 +++ 16 files changed, 613 insertions(+), 54 deletions(-) create mode 100644 crates/effects/src/stroke/tests_fit.rs create mode 100644 crates/engine/src/tests_dashalign.rs create mode 100644 crates/pdf/src/tests_dashalign.rs create mode 100644 crates/render/tests/dash_align.rs create mode 100644 crates/svg/tests/dash_align.rs create mode 100644 crates/ui-egui/src/panels/tests_stroke.rs diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 38a7b347..2bbf328c 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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 { #[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..54c5c811 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); diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs new file mode 100644 index 00000000..a00d8b30 --- /dev/null +++ b/crates/effects/src/stroke/tests_fit.rs @@ -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 = 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:?}"); + } + } +} 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 c9aaaca2..ed49186a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; 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 e0a18467..9a79efe1 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_stroke; #[cfg(test)] mod tests_strokeout; 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..ff5ba25f --- /dev/null +++ b/crates/render/tests/dash_align.rs @@ -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); +} 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 6efe1ca8..2f198834 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -356,3 +356,5 @@ mod tests { } #[cfg(test)] mod tests_appearance; +#[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_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index fdfc53d4..e0769c07 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -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, @@ -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 } 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)); +} From 454c5f8620d23c05d266d2059456bfd9d5a7d0b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:40:52 +0700 Subject: [PATCH 2/2] M3.39: width profiles follow the path through dashes, and their corners take the join Each dash and dot of a profile stroke is as wide as the profile where it sits along the whole path (before, dashes dropped the profile). Corners of profile strokes take the stroke's miter (within the miter limit), round or bevel join on the outer side and stay clean on the inner side, instead of a miter clamped at 4x; smooth points of curves bend round. Canvas, SVG/PDF outlines and Outline Stroke share the geometry. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stroke.rs | 41 ++-- crates/effects/src/stroke/tests.rs | 5 +- crates/effects/src/stroke/tests_fit.rs | 81 ++++++- crates/effects/src/stroke/width.rs | 323 ++++++++++++++++--------- crates/render/tests/dash_align.rs | 31 ++- 5 files changed, 338 insertions(+), 143 deletions(-) diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 2bbf328c..9a4ca346 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -5,7 +5,7 @@ //! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign); //! - [`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; +//! - [`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,7 +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. +/// 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. @@ -86,31 +87,35 @@ 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), } } diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 54c5c811..acc8a757 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -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 index a00d8b30..91933087 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -1,8 +1,8 @@ -//! Dashes fitted to corners and path ends. +//! 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, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{Dash, LineCap, LineJoin, StrokeLayer, WidthProfile}; use super::*; @@ -145,3 +145,80 @@ fn fitted_dashes_on_curves_and_degenerate_paths_stay_finite() { } } } + +// ---------------------------------------------------------------- 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/render/tests/dash_align.rs b/crates/render/tests/dash_align.rs index ff5ba25f..e6250c6e 100644 --- a/crates/render/tests/dash_align.rs +++ b/crates/render/tests/dash_align.rs @@ -1,9 +1,9 @@ -//! Pixel tests for dashes fitted to corners, drawn on the canvas from the shared stroke geometry -//! (`vectorcraft_effects::stroke`). +//! 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, StrokeLayer}; -use vectorcraft_geom::{PathData, Rect, shapes}; +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}; @@ -53,3 +53,26 @@ fn fitted_dashes_wrap_every_corner_of_a_rectangle() { // 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"); +}