From 025d758abe2442ba72ea289820573e6361f3f951 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 05:47:30 +0700 Subject: [PATCH] M3.83: gradients within, along or across a stroke The Gradient panel's Stroke buttons now work: with the Stroke proxy on a linear or radial gradient they lay it within the stroke (placed on the page, as before), along the path (start to end of each subpath) or across it (left edge to right edge; inside/outside strokes span their visible side). `paint.editGradient {stroke: true, strokeMode}` sets it (new art too with nothing selected); `document.inspect` reports strokeOptions.gradientMode. Model: StrokeLayer.gradient_mode (default Within, not written). Shared geometry in effects::stroke::gradient: ribs on the bisectors of the flattened path (fans round sharp corners, widened under arrowheads, following width profiles) cut slices each painted with a linear piece of the gradient (Gradient::span). The canvas and raster export clip them to the stroke's outline and arrowheads, grown a pixel and composited with copy so there are no seams and translucent stops paint once. SVG and PDF write the same slices clipped to the outline, with a warning (new vectorcraft_svg::export_with_report). Outline Stroke and Expand make gradient meshes clipped to the outline. Mesh quads now grow along their edges so thin quads show no seams. Dashes, profiles and arrowheads keep working in both modes. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 3 + assets/icons/dc-grad-stroke-across.svg | 1 + assets/icons/dc-grad-stroke-along.svg | 1 + assets/icons/dc-grad-stroke-within.svg | 1 + crates/color/src/gradient.rs | 26 ++ crates/doc/src/appearance.rs | 45 ++ crates/doc/src/lib.rs | 1 + crates/effects/src/stroke.rs | 7 +- crates/effects/src/stroke/gradient.rs | 432 ++++++++++++++++++++ crates/effects/src/stroke/outline.rs | 13 +- crates/effects/src/stroke/tests_gradient.rs | 213 ++++++++++ crates/effects/src/stroke/width.rs | 23 +- crates/engine/src/cmd/gradient.rs | 21 +- crates/engine/src/cmd/pathops.rs | 15 +- crates/engine/src/inspect.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_strokegradient.rs | 147 +++++++ crates/pdf/src/export.rs | 24 ++ crates/render/src/lib.rs | 92 ++++- crates/render/src/live.rs | 17 +- crates/render/src/tests_strokegradient.rs | 114 ++++++ crates/svg/src/export.rs | 47 ++- crates/svg/src/lib.rs | 5 + crates/ui-egui/src/icon_data.rs | 3 + crates/ui-egui/src/panels/gradient.rs | 77 +++- docs/mcp.md | 15 + 26 files changed, 1317 insertions(+), 32 deletions(-) create mode 100644 assets/icons/dc-grad-stroke-across.svg create mode 100644 assets/icons/dc-grad-stroke-along.svg create mode 100644 assets/icons/dc-grad-stroke-within.svg create mode 100644 crates/effects/src/stroke/gradient.rs create mode 100644 crates/effects/src/stroke/tests_gradient.rs create mode 100644 crates/engine/src/tests_strokegradient.rs create mode 100644 crates/render/src/tests_strokegradient.rs diff --git a/ASSETS.md b/ASSETS.md index 350780d1..55a7e82f 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -131,6 +131,9 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-grad-stroke-across.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-grad-stroke-along.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-grad-stroke-within.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-gradient.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-graphic-styles.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grid-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/assets/icons/dc-grad-stroke-across.svg b/assets/icons/dc-grad-stroke-across.svg new file mode 100644 index 00000000..15e64cd4 --- /dev/null +++ b/assets/icons/dc-grad-stroke-across.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grad-stroke-along.svg b/assets/icons/dc-grad-stroke-along.svg new file mode 100644 index 00000000..0431f022 --- /dev/null +++ b/assets/icons/dc-grad-stroke-along.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-grad-stroke-within.svg b/assets/icons/dc-grad-stroke-within.svg new file mode 100644 index 00000000..556a3cde --- /dev/null +++ b/assets/icons/dc-grad-stroke-within.svg @@ -0,0 +1 @@ + diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 46537300..f793a9d4 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -137,6 +137,32 @@ impl Gradient { pub fn sort(&mut self) { self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); } + /// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient + /// of its own running 0 → 1: the same colours (midpoints become stops, as in + /// [`Self::expanded_stops`]). One colour throughout when `from == to`. + pub fn span(&self, from: f32, to: f32) -> Gradient { + if to < from { + let mut g = self.span(to, from); + g.reverse(); + return g; + } + let stop = |offset: f32, t: f32| { + let (color, opacity) = self.sample(t); + GradientStop { opacity, ..GradientStop::new(offset, color) } + }; + let d = to - from; + let mut stops = vec![stop(0.0, from)]; + if d > 1e-6 { + stops.extend( + self.expanded_stops() + .into_iter() + .filter(|(t, ..)| *t > from && *t < to) + .map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }), + ); + } + stops.push(stop(1.0, to)); + Gradient { kind: GradientKind::Linear, stops } + } } // ---------- stop editing (the Gradient panel, the annotator and agents share these) ---------- diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 5b8ae41e..48e6d47f 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -384,6 +384,38 @@ pub struct StrokeLayer { /// Overprint Stroke (see [`FillLayer::overprint`]). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub overprint: bool, + /// How a gradient paint maps onto the stroke (within, along or across it). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub gradient_mode: StrokeGradientMode, +} + +/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant +/// name: new modes are appended, never renamed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StrokeGradientMode { + /// Placed on the page like a fill's gradient and seen through the stroke. + #[default] + Within, + /// From the start of each subpath to its end, following the path. + Along, + /// From the stroke's left edge to its right edge (left of the path's direction), all along it. + Across, +} + +impl StrokeGradientMode { + pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across]; + /// The name commands use (`within`, `along`, `across`). + pub fn name(self) -> &'static str { + match self { + StrokeGradientMode::Within => "within", + StrokeGradientMode::Along => "along", + StrokeGradientMode::Across => "across", + } + } + /// Parse a mode name (any case). + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s)) + } } /// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are @@ -424,6 +456,7 @@ impl StrokeLayer { effects: vec![], arrow_align: ArrowAlign::Extend, overprint: false, + gradient_mode: StrokeGradientMode::Within, } } /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least @@ -476,6 +509,18 @@ impl StrokeLayer { pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { stroke_paint_bounds(geometric, self.width) } + /// The gradient this stroke lays along or across its path: its linear or radial gradient + /// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within). + pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> { + match &self.paint { + Paint::Gradient(g) + if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform => + { + Some(g) + } + _ => None, + } + } } /// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8682cb2e..07d1dfed 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -36,6 +36,7 @@ pub(crate) mod skip { } } +pub use appearance::StrokeGradientMode; pub use appearance::{ Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 22c9187c..5dc045dd 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -8,10 +8,12 @@ //! - [`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. +//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke; +//! - [`gradient_slices`] and [`gradient_meshes`]: gradients laid along or across a stroke. mod arrow; mod dash; +mod gradient; mod outline; mod width; @@ -23,6 +25,7 @@ pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit}; pub use arrow::Arrow; pub use dash::{Dashed, Dot, dash, dot_outline}; +pub use gradient::{Slice, WrittenSlices, gradient_meshes, gradient_slices, written_slices}; pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region}; pub use width::width_outline; @@ -133,4 +136,6 @@ mod tests_arrows; #[cfg(test)] mod tests_fit; #[cfg(test)] +mod tests_gradient; +#[cfg(test)] mod tests_reach; diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs new file mode 100644 index 00000000..62d1d536 --- /dev/null +++ b/crates/effects/src/stroke/gradient.rs @@ -0,0 +1,432 @@ +//! Gradients laid along or across a stroke ([`StrokeGradientMode::Along`] and +//! [`StrokeGradientMode::Across`]). +//! +//! Each subpath becomes a strip of *ribs*: cross-sections through the flattened path that reach +//! past everything the stroke paints there (its width profile, joins, caps and arrowheads). Between +//! two ribs lies a slice painted with a linear piece of the gradient: along, from the first rib's +//! centre to the next one's (padded beyond, so the outside of a turn keeps the colour of the point +//! it turns at); across, from the stroke's left edge to its right edge. Ribs sit on the bisectors +//! of gentle turns, so neighbouring slices meet where both give the same colour; a sharp corner +//! gets a fan of ribs round its outside, painted in the corner's colour. +//! +//! Clipped to the stroke's outline, the slices paint it: the canvas and the SVG and PDF writers +//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh +//! ([`gradient_meshes`]). + +use kurbo::{BezPath, Point, Shape, Vec2}; +use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; +use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color}; +use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; +use vectorcraft_geom::FillRule; + +use super::width::{flatten, left, length, split_at}; +use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit}; + +/// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the +/// corner gets a fan of ribs round its outside. +const SHARP: f64 = 1.25; +/// Past this stretch (a turn of about 150°) the inside of a corner is not cut on its bisector, +/// which would reach far back along both sides: the slices either side overlap there instead. +const HAIRPIN: f64 = 4.0; +/// Rows of a mesh across the stroke (more where the gradient has stops). +const MESH_ROWS: usize = 16; +/// The most of a subpath one column of a mesh along the stroke spans. +const MESH_STEP: f64 = 1.0 / 32.0; +/// The most of a subpath one slice spans while a width profile shapes the across gradient. +const PROFILE_STEP: f64 = 1.0 / 64.0; +/// Flattening tolerance, cover margin and growth (see [`gradient_slices`]) of meshes and written +/// slices (document points). +const SLICE_TOL: f64 = 0.05; +const SLICE_MARGIN: f64 = 0.5; +const SLICE_GROW: f64 = 0.25; + +/// A cross-section of the strip a stroke's gradient is laid on. +#[derive(Clone, Copy, Debug)] +struct Rib { + /// Where it crosses the path. + c: Point, + /// Its left and right ends: the strip runs from `l` through `c` to `r`. + l: Point, + r: Point, + /// How far across the stroke `l` and `r` stand: a point `x` left of the path (negative: + /// right) lies `x / reach` of the way from `c` to the end on its side. + reach: (f64, f64), + /// Fraction of the subpath's length at `c`. + t: f64, + /// Where the across gradient starts and ends: this far left and right of the path. + band: (f64, f64), +} + +impl Rib { + /// A rib square to direction `d`, reaching `r` either side of `c`. + fn square(c: Point, d: Vec2, r: f64, t: f64, band: (f64, f64)) -> Self { + let n = left(d); + Rib { c, l: c + n * r, r: c - n * r, reach: (r, r), t, band } + } + /// The point `x` left of the path along the rib (negative: right). + fn at(&self, x: f64) -> Point { + if x >= 0.0 { + self.c + (self.l - self.c) * (x / self.reach.0.max(1e-12)) + } else { + self.c + (self.r - self.c) * (-x / self.reach.1.max(1e-12)) + } + } +} + +/// What lays out the ribs of one stroke. +struct Lay<'a> { + st: &'a StrokeLayer, + /// Half the stroked width ([`aligned_width`]). + hw: f64, + /// How far ribs reach either side of the path where nothing wider is near. + cover: f64, + margin: f64, + /// How far a strip runs on past the start and end of an open subpath (caps and heads). + ext: (f64, f64), +} + +impl Lay<'_> { + /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: + /// left; `None`: both, a centred stroke). + fn band(&self, t: f64, side: Option) -> (f64, f64) { + let (l, r) = self.st.profile.as_ref().map_or((1.0, 1.0), |p| p.at(t)); + let (l, r) = (self.hw * l.max(0.0), self.hw * r.max(0.0)); + match side { + None => (l, r), + Some(true) => (l, 0.0), + Some(false) => (0.0, r), + } + } + + /// The ribs at a turn at `p` from direction `din` to `dout` (unit vectors), reaching `r` + /// across: one on the bisector, or a fan round the outside of a sharp corner (its miter, or the + /// box round its round or bevel join). + fn turn(&self, p: Point, din: Vec2, dout: Vec2, r: f64, t: f64, band: (f64, f64)) -> Vec { + let (nin, nout) = (left(din), left(dout)); + let den = 1.0 + nin.dot(nout); + let stretch = if den > 1e-12 { (2.0 / den).sqrt() } else { f64::INFINITY }; + if stretch <= SHARP { + let m = (nin + nout) / den; + return vec![Rib { c: p, l: p + m * r, r: p - m * r, reach: (r, r), t, band }]; + } + // `s` is 1 when the outside of the turn is on the left; `out_dir` the outward bisector. + let s = if dout.dot(nin) > 0.0 { -1.0 } else { 1.0 }; + let sum = nin + nout; + let out_dir = if sum.hypot() > 1e-9 { unit(sum) * s } else { din }; + let (o1, o2) = (p + nin * (s * r), p + nout * (s * r)); + let mitered = stretch <= std::f64::consts::SQRT_2 || (self.st.join == LineJoin::Miter && stretch <= self.st.miter_limit); + let outer = if mitered { vec![o1, p + out_dir * (stretch * r), o2] } else { vec![o1, o1 + din * r, o2 - dout * r, o2] }; + let last = outer.len() - 1; + outer + .into_iter() + .enumerate() + .map(|(k, o)| { + let (inner, inner_reach) = match k { + _ if stretch <= HAIRPIN => (p - out_dir * (stretch * r), r), + 0 => (p - nin * (s * r), r), + _ if k == last => (p - nout * (s * r), r), + _ => (p, 0.0), + }; + let o_reach = o.distance(p); + if s > 0.0 { + Rib { c: p, l: o, r: inner, reach: (o_reach, inner_reach), t, band } + } else { + Rib { c: p, l: inner, r: o, reach: (inner_reach, o_reach), t, band } + } + }) + .collect() + } +} + +/// The ribs of each subpath of `bp` (filled with `rule`) under stroke `st`, flattened to `tol`, +/// reaching `margin` past what the stroke paints. Fractions `marks` of each subpath get ribs of +/// their own, and no two are more than `max_dt` of it apart. +fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, marks: &[f64], max_dt: f64) -> Vec> { + let closed_path = is_closed(bp); + let hw = aligned_width(st, closed_path) / 2.0; + if !(hw > 0.0 && hw.is_finite()) { + return vec![]; + } + let full = hw * st.profile_max(); + let heads = (st.head_reach(false), st.head_reach(true)); + let cap = cap_extent(st.cap, 2.0 * full); + let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) }; + let subs = subpaths(bp); + let count = subs.len(); + let mut out = vec![]; + for (k, (range, closed)) in subs.into_iter().enumerate() { + let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4)); + let side = + (st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0))); + // Arrowheads sit on the start of the first subpath and the end of the last one. + let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 }); + let ribs = match pts.len() { + 0 => continue, + // A subpath of no length: a dot (round and projecting caps paint it). + 1 => { + let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1)); + vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)] + } + _ => { + let total = length(&pts, closed); + if total <= 1e-12 { + continue; + } + let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 }; + let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0)); + let at: Vec = + marks.iter().copied().chain(profile).chain((1..steps).map(|i| i as f64 / steps as f64)).map(|t| t * total).collect(); + let (pts, _) = split_at(&pts, &corner, closed, &at); + strip(&lay, &pts, closed, total, side, if closed { (0.0, 0.0) } else { heads }) + } + }; + out.push(ribs); + } + out +} + +/// Is the visible band of an inside or outside stroke (`align`) on the left of the closed +/// subpath `pts` of `bp`? Probes `off` left of the middle of its longest segment (past where +/// flattening moved it). +fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign, off: f64) -> bool { + let n = pts.len(); + let Some(i) = (0..n).max_by(|&a, &b| pts[a].distance(pts[(a + 1) % n]).total_cmp(&pts[b].distance(pts[(b + 1) % n]))) else { return true }; + let (a, b) = (pts[i], pts[(i + 1) % n]); + let probe = a.midpoint(b) + left(unit(b - a)) * off.max(1e-6); + let w = bp.winding(probe); + let inside = if rule == FillRule::EvenOdd { w % 2 != 0 } else { w != 0 }; + inside == (align == StrokeAlign::Inside) +} + +/// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads +/// reach from its start and end). +fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64)) -> Vec { + let n = pts.len(); + let seg_count = if closed { n } else { n - 1 }; + let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); + // Ribs near an arrowhead reach across it. + let reach = |s: f64| { + let mut r = lay.cover; + if heads.0 > 0.0 && s <= heads.0 { + r = r.max(heads.0 + lay.margin); + } + if heads.1 > 0.0 && total - s <= heads.1 { + r = r.max(heads.1 + lay.margin); + } + r + }; + let mut out = Vec::with_capacity(n + 4); + let mut s = 0.0; + if !closed { + let (d, b) = (dir[0], lay.band(0.0, side)); + out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b)); + out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b)); + s = pts[0].distance(pts[1]); + } + let (first, last) = if closed { (0, n) } else { (1, n - 1) }; + for i in first..last { + let v = i % n; + let t = s / total; + let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side)); + // A closed subpath starts after the fan of its first corner and ends with it. + let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 }; + out.extend(ribs.into_iter().skip(skip)); + s += pts[v].distance(pts[(v + 1) % n]); + } + if closed { + out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side))); + } else { + let (d, b) = (dir[seg_count - 1], lay.band(1.0, side)); + out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b)); + out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b)); + } + out +} + +/// A piece of a stroke whose gradient runs along or across it ([`gradient_slices`]). +#[derive(Clone, Debug)] +pub struct Slice { + /// The area it paints (clipped to the stroke's outline). + pub shape: BezPath, + /// Its piece of the gradient runs from position `span.0` at `from` to `span.1` at `to`, and + /// on unchanged past them; one colour when the span is empty. + pub from: Point, + pub to: Point, + pub span: (f64, f64), +} + +impl Slice { + /// The slice's paint: its piece of `g` as a linear gradient placed from `from` to `to`. + pub fn paint(&self, g: &Gradient) -> Paint { + let gradient = g.span(self.span.0 as f32, self.span.1 as f32); + let end = if self.from.distance(self.to) > 1e-9 { self.to } else { self.from + Vec2::new(1.0, 0.0) }; + let geom = GradientGeom { start: self.from, end, aspect: 1.0, focal: None }; + Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle: geom.angle_deg(), ..GradientPaint::new(gradient) })) + } +} + +/// The slices that paint stroke `st` of `bp` (filled with `rule`) when its gradient runs along or +/// across the path (see the module docs): flattened to `tol`, reaching `margin` past the +/// stroke's edge. Clip them to the stroke's outline and fill them in order. With `grow` > 0 each +/// slice reaches that much past its edges into its neighbours, so antialiased edges show no +/// seams: filled so that each replaces what it covers (copy), they paint each point once; filled +/// over each other, only opaque gradients may grow (translucent ones would darken there). +pub fn gradient_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, grow: f64) -> Vec { + let across = st.gradient_mode == StrokeGradientMode::Across; + // A width profile changes the across band along the path: ribs close enough to follow it. + let max_dt = if across && st.profile.is_some() { PROFILE_STEP } else { 1.0 }; + let mut out = vec![]; + for ribs in strips(bp, rule, st, tol, margin, &[], max_dt) { + for w in ribs.windows(2) { + let (a, b) = (&w[0], &w[1]); + let shape = polygon(&[a.l, b.l, b.c, b.r, a.r, a.c], grow); + if shape.elements().is_empty() { + continue; + } + let (from, to, span) = if across { + let axis = b.c - a.c; + let n = if axis.hypot() > 1e-9 { left(unit(axis)) } else { unit((a.l - a.r) + (b.l - b.r)) }; + let mid = a.c.midpoint(b.c); + let (l, r) = ((a.band.0 + b.band.0) / 2.0, (a.band.1 + b.band.1) / 2.0); + (mid + n * l, mid - n * r, (0.0, 1.0)) + } else { + (a.c, b.c, (a.t, b.t)) + }; + out.push(Slice { shape, from, to, span }); + } + } + out +} + +/// The closed polygon through `pts`, each edge moved out by `grow` (mitred, at most 4 × `grow` +/// out at a corner). Repeated points and spikes are dropped; empty when no area is left. +fn polygon(pts: &[Point], grow: f64) -> BezPath { + let mut v: Vec = pts.to_vec(); + // Drop repeated points and spikes (there and straight back), which have no outward side. + loop { + let n = v.len(); + let same = |a: Point, b: Point| a.distance(b) <= 1e-9; + let Some(i) = (0..n).find(|&i| n > 2 && (same(v[i], v[(i + 1) % n]) || same(v[(i + n - 1) % n], v[(i + 1) % n]))) else { break }; + v.remove(i); + } + let mut out = BezPath::new(); + if v.len() < 3 { + return out; + } + let n = v.len(); + if grow > 0.0 { + let area: f64 = (0..n).map(|i| v[i].to_vec2().cross(v[(i + 1) % n].to_vec2())).sum(); + let side = if area >= 0.0 { 1.0 } else { -1.0 }; + let normal = |i: usize| { + let d = unit(v[(i + 1) % n] - v[i]); + Vec2::new(d.y, -d.x) * side + }; + v = (0..n) + .map(|i| { + let (n1, n2) = (normal((i + n - 1) % n), normal(i)); + let den = 1.0 + n1.dot(n2); + let m = if den > 1e-9 { (n1 + n2) / den } else { n1 }; + let m = if m.hypot() > 4.0 { m * (4.0 / m.hypot()) } else { m }; + v[i] + m * grow + }) + .collect(); + } + out.move_to(v[0]); + for p in &v[1..] { + out.line_to(*p); + } + out.close_path(); + out +} + +/// What a writer paints for a stroke whose gradient runs along or across it. +#[derive(Clone, Debug)] +pub struct WrittenSlices { + /// The outlines the stroke is written as, as one path (non-zero rule) to clip the slices. + pub clip: BezPath, + /// The slices in paint order, each with its piece of the gradient. + pub slices: Vec<(BezPath, Paint)>, +} + +/// How a file writer paints stroke `st` of `bp` (filled with `rule`) when its gradient runs along +/// or across it ([`StrokeLayer::path_gradient`]), given the filled outlines it writes for the +/// stroke ([`for_writer`](super::for_writer)): [`gradient_slices`] clipped to them. Where the +/// gradient is opaque the slices overlap a little, so viewers that antialias every shape show no +/// seams. `None` without such a gradient. +pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: &[BezPath]) -> Option { + let g = &st.path_gradient()?.gradient; + let clip = match outlines { + [one] => one.clone(), + _ => vectorcraft_pathops::stroke_region(outlines).to_bezpath(), + }; + let grow = if g.stops.iter().all(|s| s.opacity >= 1.0) { SLICE_GROW } else { 0.0 }; + let slices = gradient_slices(bp, rule, st, SLICE_TOL, SLICE_MARGIN, grow) + .into_iter() + .map(|s| { + let paint = s.paint(g); + (s.shape, paint) + }) + .collect(); + Some(WrittenSlices { clip, slices }) +} + +/// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or +/// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the +/// stroke's outline), coloured as [`gradient_slices`] paint it. +pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { + let across = st.gradient_mode == StrokeGradientMode::Across; + let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color); + // Along: a column at each stop (colours are linear between columns), close enough to look smooth. + let marks: Vec = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; + let max_dt = match (across, st.profile.is_some()) { + (false, _) => MESH_STEP, + (true, true) => PROFILE_STEP, + (true, false) => 1.0, + }; + // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. + let mut us: Vec = if across { + (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(g.expanded_stops().iter().map(|s| s.0 as f64)).collect() + } else { + vec![0.5] + }; + us.sort_by(f64::total_cmp); + us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) + .into_iter() + .filter(|ribs| ribs.len() > 1) + .map(|ribs| { + let (rows, cols) = (us.len() + 1, ribs.len() - 1); + // Each level across: a point on each rib and the gradient position there. + let level = |row: usize, rib: &Rib| -> (Point, f64) { + let t = |u: f64| if across { u } else { rib.t }; + match row { + 0 => (rib.l, t(0.0)), + _ if row == rows => (rib.r, t(1.0)), + _ if across => { + let (l, r) = rib.band; + (rib.at(l - us[row - 1] * (l + r)), us[row - 1]) + } + _ => (rib.c, rib.t), + } + }; + let level = &level; + let pos: Vec = (0..=rows).flat_map(|row| ribs.iter().map(move |rib| level(row, rib).0)).collect(); + let at = |row: usize, col: usize| pos[row * (cols + 1) + col]; + let mut points = Vec::with_capacity(pos.len()); + for row in 0..=rows { + for (col, rib) in ribs.iter().enumerate() { + let p = at(row, col); + let toward = |o: Option| o.map_or(Vec2::ZERO, |o| (o - p) / 3.0); + let mut handles = [Vec2::ZERO; 4]; + handles[H_RIGHT] = toward((col < cols).then(|| at(row, col + 1))); + handles[H_LEFT] = toward(col.checked_sub(1).map(|c| at(row, c))); + handles[H_DOWN] = toward((row < rows).then(|| at(row + 1, col))); + handles[H_UP] = toward(row.checked_sub(1).map(|r| at(r, col))); + let (color, opacity) = color(level(row, rib).1); + points.push(MeshPoint { p, color, opacity, handles }); + } + } + GradientMesh { rows: rows as u32, cols: cols as u32, points } + }) + .collect() +} diff --git a/crates/effects/src/stroke/outline.rs b/crates/effects/src/stroke/outline.rs index 1688b3c2..b3310f58 100644 --- a/crates/effects/src/stroke/outline.rs +++ b/crates/effects/src/stroke/outline.rs @@ -33,12 +33,15 @@ pub enum WrittenShape { /// and dash pattern. Stroke { width: f64 }, /// Filled outlines (non-zero rule): the line, then each arrowhead. They overlap, so they take - /// the stroke's opacity once, as a group. + /// the stroke's opacity once, as a group. A gradient along or across the stroke + /// ([`StrokeLayer::path_gradient`]) paints its [`gradient_slices`](super::gradient_slices) + /// clipped to them instead. Fill(Vec), } /// Does `st` draw as a plain centred stroke on every path (no alignment, arrowheads, width -/// profile, brush, fitted dashes or dots)? Writers then need no geometry for it. +/// profile, brush, fitted dashes, dots or gradient along or across it)? Writers then need no +/// geometry for it. pub fn is_plain(st: &StrokeLayer) -> bool { st.align == StrokeAlign::Center && st.start_arrow.is_none() @@ -46,6 +49,7 @@ pub fn is_plain(st: &StrokeLayer) -> bool { && st.profile.is_none() && st.brush.is_none() && !needs_outline_dashes(st) + && st.path_gradient().is_none() } /// Dashes a plain stroke can't draw like the canvas: fitted to corners, or zero-length dashes @@ -54,9 +58,10 @@ fn needs_outline_dashes(st: &StrokeLayer) -> bool { st.dash.as_ref().is_some_and(|d| d.is_dashed() && (d.align_corners || (st.cap != LineCap::Butt && d.pattern.contains(&0.0)))) } -/// Do these pieces of `st` need filled outlines (see [`is_plain`])? Alignment alone doesn't. +/// Do these pieces of `st` need filled outlines (see [`is_plain`])? Alignment alone doesn't; a +/// gradient along or across the stroke paints slices clipped to them. fn needs_outline(pieces: &StrokePieces, st: &StrokeLayer) -> bool { - !pieces.heads.is_empty() || st.profile.is_some() || needs_outline_dashes(st) + !pieces.heads.is_empty() || st.profile.is_some() || needs_outline_dashes(st) || st.path_gradient().is_some() } /// How a writer draws stroke `st` along `bp` (brushes aside) to match the canvas. diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs new file mode 100644 index 00000000..4562c374 --- /dev/null +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -0,0 +1,213 @@ +use kurbo::{BezPath, Point, Shape, Vec2}; +use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeGradientMode, StrokeLayer, WidthProfile}; +use vectorcraft_geom::FillRule; + +use super::*; + +fn stroke(width: f64, mode: StrokeGradientMode) -> StrokeLayer { + let mut st = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), width); + st.gradient_mode = mode; + st +} + +fn poly(pts: &[(f64, f64)], closed: bool) -> BezPath { + let mut b = BezPath::new(); + b.move_to(pts[0]); + for p in &pts[1..] { + b.line_to(*p); + } + if closed { + b.close_path(); + } + b +} + +/// The gradient position the slices give `p` (the last slice holding it wins, as when filled). +fn param(slices: &[Slice], p: Point) -> Option { + slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| { + let d = s.to - s.from; + let u = if d.hypot2() > 1e-18 { ((p - s.from).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + s.span.0 + (s.span.1 - s.span.0) * u + }) +} + +fn slices(bp: &BezPath, st: &StrokeLayer) -> Vec { + gradient_slices(bp, FillRule::NonZero, st, 0.01, 0.5, 0.0) +} + +/// Every point the stroke paints (on a grid over each of its outlines) lies in a slice. +fn assert_covered(bp: &BezPath, st: &StrokeLayer) { + let sl = slices(bp, st); + let pieces = stroke_pieces(bp, st); + let mut parts = vec![line_outline(&pieces.line, st, aligned_width(st, is_closed(bp)), 0.01)]; + parts.extend(pieces.heads.into_iter().map(|h| h.outline)); + for part in &parts { + let b = part.bounding_box(); + let n = 60; + for i in 0..=n { + for j in 0..=n { + let p = Point::new(b.x0 + b.width() * i as f64 / n as f64, b.y0 + b.height() * j as f64 / n as f64); + if part.contains(p) { + assert!(param(&sl, p).is_some(), "{p:?} of the stroke is in no slice ({:?})", st.join); + } + } + } + } +} + +#[test] +fn span_is_the_part_of_the_gradient_between_two_positions() { + let g = Gradient::default(); + let grey = |g: &Gradient, t: f32| g.sample(t).0.to_rgb()[0]; + let s = g.span(0.25, 0.75); + assert_eq!(s.stops.len(), 2); + assert!((grey(&s, 0.0) - 0.75).abs() < 1e-5 && (grey(&s, 1.0) - 0.25).abs() < 1e-5); + assert!((grey(&s, 0.5) - 0.5).abs() < 1e-5); + // Backwards, and one colour when empty. + let r = g.span(0.75, 0.25); + assert!((grey(&r, 0.0) - 0.25).abs() < 1e-5 && (grey(&r, 1.0) - 0.75).abs() < 1e-5); + let c = g.span(0.4, 0.4); + assert!((grey(&c, 0.0) - grey(&c, 1.0)).abs() < 1e-6); + // Stops and off-centre midpoints inside the span keep their colours. + let mut g3 = Gradient::default(); + g3.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); + g3.stops[0].midpoint = 0.25; + let s3 = g3.span(0.0, 1.0); + for t in [0.05, 0.125, 0.3, 0.5, 0.7, 0.95] { + let (a, b) = (g3.sample(t).0.to_rgb(), s3.sample(t).0.to_rgb()); + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{t}: {a:?} vs {b:?}"); + } +} + +#[test] +fn along_maps_the_length_of_the_path_in_any_direction() { + let st = stroke(10.0, StrokeGradientMode::Along); + for angle in [0.0f64, 37.0, 90.0, 200.0, 315.0] { + let d = Vec2::new(angle.to_radians().cos(), angle.to_radians().sin()); + let start = Point::new(50.0, 50.0); + let sl = slices(&poly(&[(start.x, start.y), (start.x + 100.0 * d.x, start.y + 100.0 * d.y)], false), &st); + let n = Vec2::new(-d.y, d.x); + for (at, across) in [(25.0, 0.0), (25.0, 4.0), (25.0, -4.0), (60.0, 2.0)] { + let t = param(&sl, start + d * at + n * across).unwrap(); + assert!((t - at / 100.0).abs() < 1e-6, "{angle}°: {t} at {at}"); + } + // Past the ends (under caps and arrowheads) the end colours carry on. + assert_eq!(param(&sl, start - d * 0.3), Some(0.0)); + // The slices hug the stroke. + assert_eq!(param(&sl, start + d * 50.0 + n * 7.0), None); + } + // Round a closed square: positions are fractions of its perimeter. + let sl = slices(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true), &st); + assert!((param(&sl, Point::new(50.0, 2.0)).unwrap() - 0.125).abs() < 1e-6); + assert!((param(&sl, Point::new(102.0, 50.0)).unwrap() - 0.375).abs() < 1e-6); + assert!((param(&sl, Point::new(101.0, -1.0)).unwrap() - 0.25).abs() < 1e-6); +} + +#[test] +fn along_keeps_a_corners_colour_round_its_outside() { + let st = stroke(20.0, StrokeGradientMode::Along); + // Right then down: the outside of the corner is up and to the right. + let sl = slices(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false), &st); + for p in [(105.0, -5.0), (109.0, -9.0), (101.0, -8.0), (108.0, -1.0)] { + assert!((param(&sl, p.into()).unwrap() - 0.5).abs() < 1e-6, "{p:?}"); + } + // Inside the corner each side takes the nearest point of its own segment. + assert!((param(&sl, Point::new(92.0, 5.0)).unwrap() - 0.46).abs() < 1e-6); + assert!((param(&sl, Point::new(95.0, 8.0)).unwrap() - 0.54).abs() < 1e-6); +} + +#[test] +fn across_runs_from_the_left_edge_to_the_right() { + let st = stroke(10.0, StrokeGradientMode::Across); + // Travelling +x (y down), the left edge is up. + let line = poly(&[(0.0, 0.0), (100.0, 0.0)], false); + let sl = slices(&line, &st); + for (y, want) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (4.0, 0.9), (5.0, 1.0)] { + assert!((param(&sl, Point::new(37.0, y)).unwrap() - want).abs() < 1e-6, "{y}"); + } + // The same all round a curve: half way across is half way, wherever. + let sl = slices(&kurbo::Circle::new((0.0, 0.0), 60.0).to_path(1e-3), &st); + for k in 0..12 { + let dir = Vec2::from_angle(k as f64 * 0.5); + let (inner, mid) = (param(&sl, (dir * 56.0).to_point()).unwrap(), param(&sl, (dir * 60.0).to_point()).unwrap()); + assert!((mid - 0.5).abs() < 0.02 && (inner - 0.5).abs() > 0.3, "{mid} {inner}"); + } + // With a width profile, across runs between the stroke's edges where they are. + let mut lens = st.clone(); + lens.profile = Some(WidthProfile::lens()); + let sl = slices(&line, &lens); + // A quarter of the way along, the lens is half as wide: its top edge is 2.5 up. + assert!(param(&sl, Point::new(25.0, -2.4)).unwrap() < 0.05); + assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); +} + +#[test] +fn an_aligned_stroke_spans_the_gradient_across_the_side_it_shows_on() { + for align in [StrokeAlign::Inside, StrokeAlign::Outside] { + for reverse in [false, true] { + let mut st = stroke(10.0, StrokeGradientMode::Across); + st.align = align; + let mut pts = vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + if reverse { + pts.reverse(); + } + let sl = slices(&poly(&pts, true), &st); + // Across the top edge: from the path to 10 pt into the side the stroke shows on. + let dy = if align == StrokeAlign::Inside { 1.0 } else { -1.0 }; + let (at_path, far) = (param(&sl, Point::new(50.0, 0.0)).unwrap(), param(&sl, Point::new(50.0, 10.0 * dy)).unwrap()); + assert!((at_path - far).abs() > 0.99, "{align:?} {reverse}: {at_path} → {far}"); + assert!((param(&sl, Point::new(50.0, 5.0 * dy)).unwrap() - 0.5).abs() < 1e-6); + } + } +} + +#[test] +fn slices_cover_everything_the_stroke_paints() { + let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false); + let mut curve = BezPath::new(); + curve.move_to((0.0, 0.0)); + curve.curve_to((80.0, -60.0), (120.0, 120.0), (10.0, 60.0)); + let triangle = poly(&[(0.0, 0.0), (100.0, 0.0), (50.0, 30.0)], true); + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] { + let mut st = stroke(12.0, mode); + st.join = join; + st.cap = LineCap::Square; + assert_covered(&zig, &st); + assert_covered(&curve, &st); + assert_covered(&triangle, &st); + st.start_arrow = Some(Arrowhead::Triangle); + st.end_arrow = Some(Arrowhead::CircleOpen); + assert_covered(&zig, &st); + st.arrow_align = ArrowAlign::Tip; + assert_covered(&curve, &st); + st.profile = WidthProfile::preset("wave"); + st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() }); + assert_covered(&zig, &st); + st.align = StrokeAlign::Outside; + assert_covered(&triangle, &st); + } + } +} + +#[test] +fn meshes_colour_the_stroke_as_the_slices_do() { + let g = Gradient::default(); + let shape = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0)], false); + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let st = stroke(10.0, mode); + let sl = slices(&shape, &st); + let meshes = gradient_meshes(&shape, FillRule::NonZero, &st, &g); + assert_eq!(meshes.len(), 1); + assert!(meshes[0].is_valid()); + let quads = meshes[0].quads(4); + for p in [(25.0, 0.0), (40.0, -3.0), (70.0, 4.0), (100.0, 30.0), (98.0, 50.0), (103.0, 20.0)] { + let p = Point::new(p.0, p.1); + let want = 1.0 - param(&sl, p).unwrap(); + let q = quads.iter().find(|q| poly(&q.pts.map(|v| (v.x, v.y)), true).contains(p)).expect("in the mesh"); + let got = q.color.to_rgb()[0] as f64; + assert!((got - want).abs() < 0.05, "{mode:?} {p:?}: mesh {got} vs {want}"); + } + } +} diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index e2eff27b..44d24343 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -43,7 +43,7 @@ pub(super) fn left(t: Vec2) -> Vec2 { /// 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) { +pub(super) 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. @@ -88,15 +88,28 @@ fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) /// along straight runs; the two points of a discontinuous point share one sample). `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(); + let total = length(pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { return (pts.to_vec(), corner.to_vec()); } // 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(); + split_at(pts, corner, closed, &marks) +} + +/// Length of a flattened subpath (see [`flatten`]). +pub(super) fn length(pts: &[Point], closed: bool) -> f64 { + let n = pts.len(); + let seg_count = if closed { n } else { n.saturating_sub(1) }; + (0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum() +} + +/// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its +/// start; those on a point or off the subpath are ignored). +pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec, Vec) { + let n = pts.len(); + let seg_count = if closed { n } else { n.saturating_sub(1) }; 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 { @@ -113,7 +126,7 @@ fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, } acc += len; } - if !closed { + if !closed && n > 0 { out.0.push(pts[n - 1]); out.1.push(corner[n - 1]); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 3f584634..1c091827 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; -use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_tools::params::color_json; @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within)} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -339,6 +339,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { let stroke = edits_stroke(s, p, item, !s.fill_active)?; let item = item.of_kind(s, !stroke); let ids = item.targets(s, p)?; + // How the gradient lies on a stroke (the panel's Stroke buttons). + let mode = match str_param(p, "strokeMode") { + None => None, + Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")), + Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?), + }; // Stops naming swatches take their colours from them. let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?; // Validate against the defaults first so bad params fail without touching the document. @@ -365,8 +371,19 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { }; let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?; n.appearance.set_paint_at(index, !stroke, np); + if let Some(m) = mode + && let Some(st) = n.appearance.stroke_at_mut(index) + { + st.gradient_mode = m; + } Ok(()) })?; + if let Some(m) = mode + && ids.is_empty() + && p.get("ids").is_none() + { + s.new_art_stroke_mut().gradient_mode = m; + } // The last gradient is the one the first object now shows (the defaults' without objects). let shown = match ids.first() { Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())), diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index e7b920d3..5a3623d8 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -459,7 +459,8 @@ pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, ble /// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the /// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode -/// and effects. `None` when it paints nothing. +/// and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped +/// like the outline). `None` when it paints nothing. pub(crate) fn outlined_stroke( d: &mut Document, brushes: &[vectorcraft_brush::Brush], @@ -481,6 +482,18 @@ pub(crate) fn outlined_stroke( if outline.is_empty() { return None; } + if let Some(g) = st.path_gradient() { + // A gradient along or across the stroke: gradient meshes clipped to its outline. + let meshes = vectorcraft_render::effects::stroke::gradient_meshes(&path.to_bezpath(), rule, st, &g.gradient); + let mut clip = shape_node(d, outline, None); + super::object::as_clipping_path(&mut clip).ok()?; + let meshes: Vec = meshes.into_iter().map(|m| Node::new(d.alloc_id(), NodeKind::Mesh(m))).collect(); + let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect(); + let group = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }); + let mut art = with_transparency(d, group, st.opacity, st.blend); + art.appearance.effects = st.effects.clone(); + return Some(art); + } let mut n = shape_node(d, outline, None); let fill = vectorcraft_doc::appearance::FillLayer { opacity: st.opacity, diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index f7aec419..d2440cb7 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -96,7 +96,8 @@ pub fn node_stroke(n: &Node) -> Option { /// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when /// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile, -/// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform). +/// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform) +/// and gradientMode: how a gradient lies on it (`paint.editGradient` strokeMode). pub fn stroke_options(st: &StrokeLayer) -> Value { json!({ "cap": name_of(&CAPS, st.cap), @@ -111,6 +112,7 @@ pub fn stroke_options(st: &StrokeLayer) -> Value { "arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" }, "profile": WidthProfile::id_of(st.profile.as_ref()), "widthPoints": st.profile.as_ref().map(|p| &p.points), + "gradientMode": st.gradient_mode.name(), }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ab72a2e..49c1e4bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -938,6 +938,8 @@ mod tests_scalestrokes; #[cfg(test)] mod tests_strokegeom; #[cfg(test)] +mod tests_strokegradient; +#[cfg(test)] mod tests_strokereach; #[cfg(test)] mod tests_strokeux; diff --git a/crates/engine/src/tests_strokegradient.rs b/crates/engine/src/tests_strokegradient.rs new file mode 100644 index 00000000..fce22425 --- /dev/null +++ b/crates/engine/src/tests_strokegradient.rs @@ -0,0 +1,147 @@ +//! Gradients within, along or across a stroke: `paint.editGradient {strokeMode}`, the saved mode, +//! and the same picture on the canvas, after Outline Stroke and Expand, and in SVG and PDF. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Document, NodeKind, StrokeGradientMode}; +use vectorcraft_geom::Rect; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &Document { + &s.doc().unwrap().doc +} + +/// A selected open path (an L: right, then down) with a 20 pt white → black stroke laid `mode`. +fn setup(mode: &str) -> (Session, NodeId) { + let mut s = session(); + let anchors = json!([{"x": 20, "y": 40}, {"x": 160, "y": 40}, {"x": 160, "y": 180}]); + let id = run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [id]})); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "stroke.set", json!({"weight": 20})); + run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": mode})); + (s, NodeId(id)) +} + +fn mode_of(s: &Session, id: NodeId) -> StrokeGradientMode { + doc(s).node(id).unwrap().appearance.stroke().unwrap().gradient_mode +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true) +} + +fn grey(img: &Rendered, x: u32, y: u32) -> f64 { + img.pixel(x, y)[0] as f64 / 255.0 +} + +/// Points inside the stroke (away from its edges) where two pictures are compared. +const PROBES: [(u32, u32); 8] = [(30, 40), (60, 35), (100, 44), (150, 40), (163, 37), (160, 80), (156, 130), (164, 170)]; + +/// `b` paints the stroke as `a` does, within `tol` at every probe. +fn assert_same(a: &Rendered, b: &Rendered, tol: f64, what: &str) { + for (x, y) in PROBES { + let (ga, gb) = (grey(a, x, y), grey(b, x, y)); + assert!((ga - gb).abs() <= tol, "{what} at ({x}, {y}): {gb} vs {ga}"); + } +} + +#[test] +fn stroke_mode_sets_how_the_gradient_lies_with_one_undo_step() { + let (mut s, id) = setup("along"); + assert_eq!(mode_of(&s, id), StrokeGradientMode::Along); + assert!(matches!(doc(&s).node(id).unwrap().appearance.stroke_paint(), Paint::Gradient(_)), "a solid stroke became a gradient"); + let o = &run(&mut s, "document.inspect", json!({}))["layers"][0]["children"][0]["strokeOptions"]; + assert_eq!(o["gradientMode"], "along"); + run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": "across"})); + assert_eq!(mode_of(&s, id), StrokeGradientMode::Across); + run(&mut s, "edit.undo", json!({})); + assert_eq!(mode_of(&s, id), StrokeGradientMode::Along); + // Bad values and fills are refused without touching the document. + assert!(s.execute("paint.editGradient", &json!({"stroke": true, "strokeMode": "sideways"})).is_err()); + assert!(s.execute("paint.editGradient", &json!({"stroke": false, "strokeMode": "along"})).is_err()); + assert_eq!(mode_of(&s, id), StrokeGradientMode::Along); + // With nothing selected it sets up the next object drawn. + run(&mut s, "select.set", json!({"ids": []})); + run(&mut s, "paint.editGradient", json!({"stroke": true, "strokeMode": "across"})); + let r = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap(); + assert_eq!(mode_of(&s, NodeId(r)), StrokeGradientMode::Across); +} + +#[test] +fn the_mode_round_trips_through_the_native_format_and_old_files_paint_within() { + let (s, id) = setup("across"); + let back = vectorcraft_format::load(&vectorcraft_format::save_file(doc(&s))).unwrap(); + assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap().gradient_mode, StrokeGradientMode::Across); + let (s, id) = setup("within"); + let json = serde_json::to_string(doc(&s).node(id).unwrap().appearance.stroke().unwrap()).unwrap(); + assert!(!json.contains("gradient_mode"), "the default is not written: {json}"); + let old: vectorcraft_doc::StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); + assert_eq!(old.gradient_mode, StrokeGradientMode::Within); +} + +#[test] +fn along_and_across_paint_the_canvas_by_position_on_the_path() { + // The path is 280 long: (90, 40) is a quarter of the way, (160, 110) three quarters. + let (s, _) = setup("along"); + let img = render(doc(&s)); + for (x, y, t) in [(90, 40, 0.25), (90, 47, 0.25), (160, 110, 0.75), (154, 110, 0.75), (166, 34, 0.5)] { + assert!((grey(&img, x, y) - (1.0 - t)).abs() < 0.03, "along ({x}, {y}): {}", grey(&img, x, y)); + } + // Across: travelling right the left edge is the top one; going down, the right-hand one. + let (s, _) = setup("across"); + let img = render(doc(&s)); + assert!(grey(&img, 90, 32) > 0.8 && grey(&img, 90, 48) < 0.2); + assert!(grey(&img, 168, 120) > 0.8 && grey(&img, 152, 120) < 0.2); +} + +#[test] +fn outline_stroke_and_expand_keep_the_picture() { + for mode in ["along", "across"] { + let (mut s, _) = setup(mode); + let before = render(doc(&s)); + let ids = run(&mut s, "object.path.outlineStroke", json!({}))["ids"].clone(); + let n = doc(&s).node(NodeId(ids[0].as_u64().unwrap())).unwrap().clone(); + let NodeKind::Group { children, clip: true } = &n.kind else { panic!("a clip group: {:?}", n.kind_label()) }; + assert!(children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes"); + assert_same(&before, &render(doc(&s)), 0.06, &format!("{mode} outlined")); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "object.expand", json!({})); + assert_same(&before, &render(doc(&s)), 0.06, &format!("{mode} expanded")); + } +} + +#[test] +fn svg_and_pdf_write_the_same_gradient_as_slices_with_a_warning() { + for mode in ["along", "across"] { + let (s, _) = setup(mode); + let before = render(doc(&s)); + let (svg, warnings) = vectorcraft_svg::export_with_report(doc(&s), &Default::default()); + assert!(warnings.iter().any(|w| w.contains("along or across strokes")), "{warnings:?}"); + assert!(svg.contains(" 3, "{mode}: sliced"); + let back = vectorcraft_svg::import(&svg).unwrap(); + assert_same(&before, &render(&back), 0.04, &format!("{mode} SVG")); + let opts = vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }; + let r = vectorcraft_pdf::export_with_report(doc(&s), &opts).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("along or across strokes")), "{:?}", r.warnings); + assert!(String::from_utf8_lossy(&r.bytes).matches("/ShadingType 2").count() > 3, "{mode}: axial shadings"); + let back = vectorcraft_pdf::import(&r.bytes).unwrap(); + assert_same(&before, &render(&back), 0.04, &format!("{mode} PDF")); + } + // Within, a stroke is still written as one plain stroke. + let (s, _) = setup("within"); + let (svg, warnings) = vectorcraft_svg::export_with_report(doc(&s), &Default::default()); + assert!(warnings.is_empty() && !svg.contains(" { s.draw_path(path); s.set_stroke(None); } + WrittenShape::Fill(outlines) if st.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines, under + // the stroke's opacity as a group. + if let Some(ws) = stroke::written_slices(bp, r, st, outlines) + && let Some(clip) = to_path(&ws.clip) + { + self.warn("gradients along or across strokes are exported as slices of linear gradients"); + if st.opacity < 1.0 { + s.push_opacity(norm(st.opacity)); + pushes += 1; + } + s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); + s.set_stroke(None); + for (shape, paint) in &ws.slices { + let b = shape.bounding_box(); + if let (Some(paint), Some(p)) = (self.paint(paint, b), to_path(shape)) { + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_path(&p); + } + } + s.set_fill(None); + s.pop(); + } + } WrittenShape::Fill(outlines) => { // The line and its arrowheads overlap: they take the opacity once, as a group. let grouped = outlines.len() > 1 && st.opacity < 1.0; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b9855405..383404e1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -202,6 +202,16 @@ struct StrokeEntry { stamp: u64, } +/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds. +type Slices = Arc>; + +/// [`Slices`] cached for a stroke: (owning node, slices, last frame used). +struct SliceEntry { + node: Arc, + slices: Slices, + stamp: u64, +} + /// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node /// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the /// address can't be reused) is an exact cache hit — no invalidation logic needed. @@ -268,6 +278,8 @@ pub struct Renderer { clip_paints: PtrMap, /// Placed images as painted in ink planes (see [`ink`]). ink_images: ink::InkImages, + /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). + stroke_slices: PtrMap<(usize, i32), SliceEntry>, } #[derive(Clone, Copy, Debug, Default)] @@ -326,6 +338,7 @@ impl Renderer { clip_paths: vec![], clip_paints: PtrMap::default(), ink_images: Default::default(), + stroke_slices: PtrMap::default(), } } @@ -369,6 +382,9 @@ impl Renderer { if self.strokes.len() > 1024 { self.strokes.retain(|_, e| g - e.stamp <= 3); } + if self.stroke_slices.len() > 256 { + self.stroke_slices.retain(|_, e| g - e.stamp <= 3); + } if self.shadows.len() > 256 { self.shadows.retain(|_, e| g - e.stamp <= 3); } @@ -908,7 +924,9 @@ impl Renderer { } // One paint box for the line and the heads, so a gradient runs on into the heads. let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0); - if paint::set_paint(ctx, &st.paint, paint_bounds, f) { + if let Some(g) = st.path_gradient() { + self.fill_path_gradient(ctx, f, bp, rule, st, &pieces, width, &g.gradient); + } else if paint::set_paint(ctx, &st.paint, paint_bounds, f) { self.fold_alpha(ctx, f.ink, &st.paint); ctx.set_fill_rule(peniko::Fill::NonZero); match self.cached_stroke(f, &pieces.line, st, width) { @@ -938,6 +956,76 @@ impl Renderer { } } + /// Paint the line and arrowheads (`pieces`) of stroke `st` of `bp` (filled with `rule`) with + /// its gradient `g` laid along or across the path ([`effects::stroke::gradient_slices`]). Each + /// piece clips the slices, grown a pixel into each other and each replacing what it covers, so + /// neighbours meet without seams and translucent colours paint once. + #[allow(clippy::too_many_arguments)] + fn fill_path_gradient( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + bp: &BezPath, + rule: FillRule, + st: &StrokeLayer, + pieces: &effects::stroke::StrokePieces, + width: f64, + g: &vectorcraft_color::Gradient, + ) { + let level = (f.px * 0.25).log2().floor() as i32; + let make = || -> Slices { + let tol = 2f64.powi(level); + // Reach a pixel past the stroke, so its antialiased edge has colour under it. + let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px); + Arc::new( + slices + .into_iter() + .map(|s| { + let (b, paint) = (s.shape.bounding_box(), s.paint(g)); + (s.shape, paint, b) + }) + .collect(), + ) + }; + let slices = match self.cur.clone() { + Some(node) => { + let key = (st as *const StrokeLayer as usize, level); + match self.stroke_slices.get_mut(&key) { + Some(e) if Arc::ptr_eq(&e.node, &node) => { + e.stamp = self.stamp; + e.slices.clone() + } + _ => { + let slices = make(); + self.stroke_slices.insert(key, SliceEntry { node, slices: slices.clone(), stamp: self.stamp }); + slices + } + } + } + None => make(), + }; + let line = match self.cached_stroke(f, &pieces.line, st, width) { + Some(o) => o, + None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), + }; + ctx.set_fill_rule(peniko::Fill::NonZero); + for clip in std::iter::once(&*line).chain(pieces.heads.iter().map(|h| &h.outline)) { + let cb = clip.bounding_box(); + if clip.elements().is_empty() || !cb.is_finite() { + continue; + } + ctx.push_clip_layer(clip); + ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::Copy)); + for (shape, paint, b) in slices.iter() { + if rects_overlap(*b, cb) && paint::set_paint(ctx, paint, *b, f) { + ctx.fill_path(shape); + } + } + ctx.set_blend_mode(BlendMode::default()); + ctx.pop_layer(); + } + } + /// The line part of a stroke of the fast-path node `self.cur` (see /// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25 /// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses @@ -1248,4 +1336,6 @@ mod tests_knockout; #[cfg(test)] mod tests_objectfx; #[cfg(test)] +mod tests_strokegradient; +#[cfg(test)] mod tests_tileedge; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 31906f3a..1af0de42 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -4,14 +4,15 @@ //! by `Arc` identity of the live node (so the steps keep stable `Arc`s and hit the geometry cache //! frame after frame). Gradient meshes are tessellated into many small solid-colour quads (vello //! has no mesh shading), with the density chosen from the on-screen patch size; each quad is -//! grown by half a device pixel so neighbours overlap and no antialiasing seams show. +//! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams +//! show. use std::collections::HashMap; use std::sync::Arc; use vectorcraft_doc::live::{self, GradientMesh, MeshQuad}; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::{BezPath, PathData, Point}; +use vectorcraft_geom::{BezPath, PathData, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko; @@ -158,12 +159,16 @@ impl Renderer { if q.opacity <= 0.0 { continue; } - let c = Point::new((q.pts[0].x + q.pts[1].x + q.pts[2].x + q.pts[3].x) / 4.0, (q.pts[0].y + q.pts[1].y + q.pts[2].y + q.pts[3].y) / 4.0); let mut bp = BezPath::new(); for (i, p) in q.pts.iter().enumerate() { - let d = *p - c; - let len = d.hypot(); - let p2 = if len > 1e-12 { *p + d * (grow / len) } else { *p }; + // Out along both edges at the corner, so thin quads grow across too. + let d: Vec2 = [q.pts[(i + 3) % 4], q.pts[(i + 1) % 4]] + .iter() + .map(|n| *p - *n) + .filter(|e| e.hypot() > 1e-12) + .map(|e| e / e.hypot()) + .fold(Vec2::ZERO, |a, e| a + e); + let p2 = *p + d * grow; if i == 0 { bp.move_to(p2); } else { diff --git a/crates/render/src/tests_strokegradient.rs b/crates/render/src/tests_strokegradient.rs new file mode 100644 index 00000000..4742c540 --- /dev/null +++ b/crates/render/src/tests_strokegradient.rs @@ -0,0 +1,114 @@ +//! Gradients along and across strokes: positions along the path and across the stroke, no seams +//! between the slices, translucent stops painted once. + +use vectorcraft_color::{Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, StrokeGradientMode, StrokeLayer}; +use vectorcraft_geom::PathData; + +use super::*; + +const SIZE: u32 = 200; + +/// `bp` stroked 20 pt with `g` laid `mode`, on a 200×200 page (white, or transparent). +fn render(bp: &BezPath, g: Gradient, mode: StrokeGradientMode, white: bool) -> Rendered { + let mut st = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(g))), 20.0); + st.gradient_mode = mode; + let mut d = Document::new(SIZE as f64, SIZE as f64); + let n = Node::path(d.alloc_id(), PathData::from_bezpath(bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let background = white.then_some([255, 255, 255, 255]); + Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background, ..Default::default() }) +} + +fn line(a: (f64, f64), b: (f64, f64)) -> BezPath { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + p +} + +/// Grey level (0..1) at the centre of pixel (x, y). +fn grey(img: &Rendered, x: f64, y: f64) -> f64 { + img.pixel(x as u32, y as u32)[0] as f64 / 255.0 +} + +#[test] +fn along_a_quarter_of_the_length_is_a_quarter_of_the_gradient_in_any_direction() { + for (a, b) in [((20.0, 100.0), (180.0, 100.0)), ((100.0, 180.0), (100.0, 20.0)), ((30.0, 30.0), (170.0, 170.0))] { + let img = render(&line(a, b), Gradient::default(), StrokeGradientMode::Along, true); + for (f, off) in [(0.25, 0.0), (0.25, 6.0), (0.75, -6.0), (0.5, 0.0)] { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + let (x, y) = (a.0 + dx * f - dy / len * off, a.1 + dy * f + dx / len * off); + let want = 1.0 - f; + assert!((grey(&img, x, y) - want).abs() < 0.03, "{a:?}→{b:?} at {f}: {} vs {want}", grey(&img, x, y)); + } + } +} + +#[test] +fn across_runs_from_the_left_edge_to_the_right() { + // Travelling right, the left edge is the top one (y 90), the right edge the bottom (y 110). + let img = render(&line((20.0, 100.0), (180.0, 100.0)), Gradient::default(), StrokeGradientMode::Across, true); + for (y, want) in [(91.0, 0.925), (95.0, 0.725), (100.0, 0.475), (108.0, 0.075)] { + for x in [40.0, 100.0, 160.0] { + assert!((grey(&img, x, y) - want).abs() < 0.03, "({x}, {y}): {} vs {want}", grey(&img, x, y)); + } + } + // Within (the default) the gradient lies on the page: left to right here. + let within = render(&line((20.0, 100.0), (180.0, 100.0)), Gradient::default(), StrokeGradientMode::Within, true); + assert!(grey(&within, 40.0, 91.0) > grey(&within, 160.0, 91.0) + 0.5); +} + +#[test] +fn slices_meet_without_seams_and_translucent_stops_paint_once() { + let circle = kurbo::Circle::new((100.0, 100.0), 60.0).to_path(0.01); + let mut half = Gradient::default(); + for s in &mut half.stops { + s.opacity = 0.5; + } + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + let solid = render(&circle, Gradient::default(), mode, false); + let translucent = render(&circle, half.clone(), mode, false); + for k in 0..720 { + let a = k as f64 * std::f64::consts::TAU / 720.0; + for r in [55.0, 60.0, 65.0] { + let (x, y) = (100.0 + r * a.cos(), 100.0 + r * a.sin()); + assert_eq!(solid.pixel(x as u32, y as u32)[3], 255, "{mode:?}: a seam at ({x:.1}, {y:.1})"); + let alpha = translucent.pixel(x as u32, y as u32)[3] as i32; + assert!((alpha - 128).abs() <= 2, "{mode:?}: alpha {alpha} at ({x:.1}, {y:.1})"); + } + } + } +} + +#[test] +fn corners_and_tight_turns_have_no_holes() { + let mut corner = line((40.0, 40.0), (160.0, 40.0)); + corner.line_to((160.0, 160.0)); + corner.line_to((60.0, 120.0)); + let tight = kurbo::Circle::new((100.0, 100.0), 6.0).to_path(0.01); + let mut half = Gradient::default(); + for s in &mut half.stops { + s.opacity = 0.5; + } + for mode in [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across] { + for (bp, probes) in [ + ( + &corner, + vec![(160.0, 40.0), (156.0, 36.0), (165.0, 45.0), (163.0, 37.0), (157.0, 43.0), (160.0, 160.0), (166.0, 158.0), (152.0, 157.0)], + ), + // The stroke's inside edge folds over the centre: a 4 pt hole stays there. + (&tight, vec![(105.5, 100.0), (95.0, 103.0), (108.0, 100.0), (100.0, 113.0), (91.0, 92.0), (100.0, 94.0)]), + ] { + let solid = render(bp, Gradient::default(), mode, false); + let translucent = render(bp, half.clone(), mode, false); + for (x, y) in probes { + assert_eq!(solid.pixel(x as u32, y as u32)[3], 255, "{mode:?}: a hole at ({x}, {y})"); + let alpha = translucent.pixel(x as u32, y as u32)[3] as i32; + assert!((alpha - 128).abs() <= 2, "{mode:?}: alpha {alpha} at ({x}, {y})"); + } + } + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8c7e2e6f..447f72f4 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -22,7 +22,7 @@ struct AreaLine<'t> { hyphenated: bool, } -pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { +pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec) { // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); @@ -49,6 +49,7 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { knockout: doc.page_knockout, anonymous: false, knockout_filter: None, + warnings: vec![], }; w.assign_name_ids(); // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. @@ -97,7 +98,7 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { out.push_str(&w.body); out.push_str(""); out.push_str(nl); - out + (out, w.warnings) } struct Writer<'a> { @@ -123,6 +124,8 @@ struct Writer<'a> { knockout_filter: Option, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, + /// Features written approximately (once each). + warnings: Vec, } /// How one stroke is written. @@ -619,6 +622,14 @@ impl Writer<'_> { let a = self.attrs(&p); self.line(&format!("")); } + WrittenShape::Fill(outlines) if s.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines. + if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) { + p.extend((s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3)))); + p.extend(blend); + self.sliced(&ws, &p, &side); + } + } WrittenShape::Fill(outlines) => { let paint = self.paint(&s.paint, paint_bounds(s)); let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); @@ -654,6 +665,38 @@ impl Writer<'_> { self.line(""); } + /// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with + /// `props` and the `side` attribute) of its slices, clipped to its outlines. + fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) { + self.warn("gradients along or across strokes are written as slices of linear gradients"); + let cid = self.fresh_id("clip-path"); + let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + let a = self.attrs(props); + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + self.depth += 1; + for (shape, paint) in &ws.slices { + let fill = self.paint(paint, None); + let fill = self.attrs(&vec![("fill", fill)]); + let d = self.path_d(&PathData::from_bezpath(shape), self.xf); + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + self.depth -= 1; + self.line(""); + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + /// An object with an opacity mask: `` around the unmasked object. The mask /// art goes in ``; no-clip adds a white backdrop and invert a colour-inverting filter. fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 6aaa7623..5ab87049 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -94,6 +94,11 @@ pub enum SvgError { /// Export a document as an SVG string. pub fn export(doc: &Document, opts: &ExportOptions) -> String { + export::export(doc, opts).0 +} + +/// Export a document as an SVG string, also returning warnings about approximated features. +pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec) { export::export(doc, opts) } diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index f78ae698..546cd609 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -86,6 +86,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-grad-freeform", include_bytes!("../../../assets/icons/dc-grad-freeform.svg")), ("dc-grad-linear", include_bytes!("../../../assets/icons/dc-grad-linear.svg")), ("dc-grad-radial", include_bytes!("../../../assets/icons/dc-grad-radial.svg")), + ("dc-grad-stroke-across", include_bytes!("../../../assets/icons/dc-grad-stroke-across.svg")), + ("dc-grad-stroke-along", include_bytes!("../../../assets/icons/dc-grad-stroke-along.svg")), + ("dc-grad-stroke-within", include_bytes!("../../../assets/icons/dc-grad-stroke-within.svg")), ("dc-gradient", include_bytes!("../../../assets/icons/dc-gradient.svg")), ("dc-graphic-styles", include_bytes!("../../../assets/icons/dc-graphic-styles.svg")), ("dc-grid-view", include_bytes!("../../../assets/icons/dc-grid-view.svg")), diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 51e85521..dd071b11 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -10,6 +10,10 @@ //! editing the swatch recolours the stop. The thumbnail menu marks the gradient swatch the //! gradient was applied from. Hide Options leaves the thumbnail, the proxy and the slider. //! +//! With the Stroke proxy in front, the Stroke buttons lay a linear or radial gradient within the +//! stroke (placed on the page like a fill's), along it or across it (angle and aspect ratio then +//! don't apply). +//! //! A freeform gradient shows its section instead of the angle, aspect and slider: the Draw //! toggle (Points or Lines: how the Gradient tool adds points) and the selected point's colour //! (edited in the Color panel), opacity and spread, with Delete Point. @@ -20,7 +24,7 @@ use vectorcraft_color::gradient::{ MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors, }; use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{NodeId, StrokeGradientMode}; use super::{active_paint, live_run, pstate, set_pstate}; use crate::state::Dialog; @@ -205,17 +209,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } return; } + // How the gradient lies on the stroke behind the Stroke proxy (a linear or radial one). + let stroke_mode = (!app.session.fill_active && is_grad && !freeform).then(|| app.session.shown_stroke().map(|st| st.gradient_mode)).flatten(); ui.add_space(4.0); ui.horizontal(|ui| { widgets::dim_label(ui, "Stroke:"); - for (icon, tip) in [ - ("dc-stroke-center", "Gradient inside stroke"), - ("dc-stroke-outside", "Gradient along stroke (on the roadmap)"), - ("dc-stroke-inside", "Gradient across stroke (on the roadmap)"), + for (m, icon, tip) in [ + (StrokeGradientMode::Within, "dc-grad-stroke-within", "Gradient within stroke"), + (StrokeGradientMode::Along, "dc-grad-stroke-along", "Gradient along stroke"), + (StrokeGradientMode::Across, "dc-grad-stroke-across", "Gradient across stroke"), ] { - widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0); + let on = stroke_mode == Some(m); + if widgets::icon_button_enabled(ui, icon, tip, on, stroke_mode.is_some(), 22.0).clicked() && !on { + edit(app, json!({"strokeMode": m.name()}), Live::Released); + } } }); + // Along and across, the gradient follows the path: its angle and aspect ratio don't apply. + let placed = stroke_mode.is_none_or(|m| m == StrokeGradientMode::Within); if freeform { freeform_section(app, ui, &g); return; @@ -226,16 +237,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); ui.vertical(|ui| { ui.horizontal(|ui| { - icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle"); + icons::icon(ui, "rotate-ccw", 15.0, if placed { t.icon } else { t.text_disabled }).on_hover_text("Angle"); let angle = g.geom.map_or(g.angle, |x| x.angle_deg()); - if let Some(a) = widgets::spin_plain(ui, "grad-angle", (angle * 10.0).round() / 10.0, "°", 1, 104.0, 1.0, -180.0, &ANGLE_PRESETS) { + let set = ui + .add_enabled_ui(placed, |ui| { + widgets::spin_plain(ui, "grad-angle", (angle * 10.0).round() / 10.0, "°", 1, 104.0, 1.0, -180.0, &ANGLE_PRESETS) + }) + .inner; + if let Some(a) = set { edit(app, json!({"angle": a}), Live::Released); } if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() { edit(app, json!({"reverse": true}), Live::Released); } }); - let radial = kind == GradientKind::Radial; + let radial = kind == GradientKind::Radial && placed; ui.horizontal(|ui| { icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio"); let asp = g.geom.map_or(100.0, |x| (x.aspect * 1000.0).round() / 10.0); @@ -773,6 +789,24 @@ mod tests { self.frame(app, vec![], Modifiers::NONE) } + /// The `size`-square widgets right of `at` on its row (last frame), left to right. + fn row_after(&self, at: Pos2, size: f32) -> Vec { + let mut v: Vec = self.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .map(|w| w.rect) + .filter(|r| { + (r.width() - size).abs() < 0.5 && (r.height() - size).abs() < 0.5 && (r.center().y - at.y).abs() < 8.0 && r.left() > at.x + }) + .collect() + }); + v.sort_by(|a, b| a.left().total_cmp(&b.left())); + v.dedup(); + v + } + /// Press at `from`, move to `to` in steps and release there, with `m` held. fn drag(&mut self, app: &mut VectorcraftApp, from: Pos2, to: Pos2, m: Modifiers) { let b = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: m }; @@ -814,6 +848,31 @@ mod tests { p.widget(("grad-stop", 1)); } + #[test] + fn the_stroke_buttons_lay_the_gradient_within_along_or_across_the_stroke() { + let mut app = app(json!({"gradient": {}})); + app.run("paint.setStroke", json!({"gradient": {}, "focus": false})).unwrap(); + assert!(app.session.fill_active); + let mode = |app: &VectorcraftApp| app.session.shown_stroke().unwrap().gradient_mode; + let mut p = Panel::new(); + let texts = p.frame(&mut app, vec![], Modifiers::NONE); + let label = texts.iter().find(|(t, _)| t == "Stroke:").expect("the Stroke buttons").1; + // With the Fill proxy in front they are off. + let buttons = p.row_after(label, 22.0); + assert_eq!(buttons.len(), 3); + p.click(&mut app, buttons[1].center()); + assert_eq!(mode(&app), StrokeGradientMode::Within); + let stroke = p.widget("stroke-proxy"); + p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0)); + assert!(!app.session.fill_active); + for (i, want) in [(1, StrokeGradientMode::Along), (2, StrokeGradientMode::Across), (0, StrokeGradientMode::Within)] { + p.click(&mut app, buttons[i].center()); + assert_eq!(mode(&app), want); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(mode(&app), StrokeGradientMode::Across, "one undo step per click"); + } + #[test] fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() { let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]); diff --git a/docs/mcp.md b/docs/mcp.md index 9a902455..4898dfba 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -729,3 +729,18 @@ resets it. {"name":"run_command","arguments":{"command":"stroke.set","params":{"cap":"round","dash":[6,3]}}} {"name":"run_command","arguments":{"command":"appearance.newArt","params":{}}} ``` + +## Gradients on strokes + +A linear or radial gradient on a stroke lies within it (placed on the page like a fill's gradient: the default), along +it (from the start of each subpath to its end) or across it (from the stroke's left edge to its right edge, left of the +path's direction; an inside or outside stroke spans the side it shows on). `paint.editGradient {stroke: true, +strokeMode: "within"|"along"|"across"}` sets it (the Gradient panel's Stroke buttons), and `document.inspect` reports it +as `strokeOptions.gradientMode`. Width profiles, dashes and arrowheads keep working: the gradient runs on through gaps +and into the heads. Outline Stroke and Expand turn such a stroke into gradient meshes clipped to its outline; SVG and PDF +write it as slices of linear gradients clipped to the outline (`vectorcraft_svg::export_with_report` and the PDF +report warn about it). + +```json +{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stroke":true,"strokeMode":"along"}}} +```