Files
vectorcraft/crates/effects/src/stroke/gradient.rs
T
Taha-Mahmoodi 2bad646821 Gradients: a Perceptual gradient mixes its colours in OKLab, and SVG and PDF write it as an image
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.

- Gradient gains `method` (Classic | Perceptual; serde default Classic,
  written only when Perceptual, so older files open as Classic and Classic
  files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
  sample_keep_model for the stroke meshes, Expand and blends); opacity stays
  linear. Renderers that mix linearly get stops on the perceptual curve,
  placed by halving each half segment where a straight line strays more than
  0.4 of a colour level (a nearly straight segment needs none). Classic
  gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
  Perceptual fill or stroke as an image at the document's raster effects
  resolution, clipped to what it paints. A gradient along or across a stroke
  stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
  noise (the canvas's dithered grids are keyed by method too); SVG and PDF
  write it as an image with the noise, and a dithered Perceptual stroke as an
  image without it, as the canvas draws it (GradientPaint::pixels, SVG's
  raster_paint and PDF's area are told whether they paint a fill).
2026-10-23 01:53:29 +07:00

433 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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};
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<bool>) -> (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<Rib> {
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<Vec<Rib>> {
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<f64> =
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<bool>, heads: (f64, f64)) -> Vec<Rib> {
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let dir: Vec<Vec2> = (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<Slice> {
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<Point> = 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<WrittenSlices> {
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<GradientMesh> {
let across = st.gradient_mode == StrokeGradientMode::Across;
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
let marks: Vec<f64> = 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<f64> = 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<Point> = (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<Point>| 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()
}