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)); +}