Gradients: keep every run of a repeated gradient with many stops

When a repeated or reflected gradient needs more stops than a limit
allows, each run now leaves out the stops that change its colors
least, so every run is still written, instead of writing fewer runs
and padding the rest with the last color. Each run keeps its ends,
sharp steps, swatch-linked stops, tint and CMYK ink changes and spans
shaped by an off-center midpoint, and the colors are compared as the
gradient's method mixes them. This applies to gradients along or across
a stroke, CSS conic gradients, and PDF, EPS and .ai export.

- Outline Stroke's mesh fits the gradient to at most 1,024 stops, and
  splits a stroke into several meshes when one would hold more than
  2^20 points.
- Object > Expand leaves a gradient as it is when the box needs more
  than 256 runs, instead of expanding only the first runs.
- CSS writes the stop positions of repeating gradients with four
  decimals, so the period stays in place across the box.
- A swatch chip of a gradient that repeats gets at least four grid
  cells a run along each side (at most one a point, and 128), so it
  does not show one color when the count matches the chip's cells.
- docs/mcp.md states the stop limits and the PDF import sample budget.
This commit is contained in:
dotCooCoo
2026-11-12 11:30:44 -08:00
parent 2304b38f19
commit 0691e1acbe
11 changed files with 411 additions and 40 deletions
+116 -10
View File
@@ -4,7 +4,8 @@
use kurbo::{Point, Rect, Vec2};
use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop};
use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop};
use crate::Color;
/// Most runs of the stops [`Gradient::padded_over`] writes.
pub const MAX_SPREAD_RUNS: usize = 256;
@@ -33,8 +34,8 @@ pub struct Padded {
pub first: f64,
/// How many runs are written, evenly spaced from [`Self::first`] to 1.
pub runs: usize,
/// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows),
/// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written.
/// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed,
/// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written.
pub capped: bool,
}
@@ -50,7 +51,9 @@ impl Gradient {
self.padded_within(geom, area, MAX_SPREAD_STOPS)
}
/// [`Self::padded_over`] with at most `max_stops` stops (and at least one run).
/// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs
/// written need more stops than that, each run leaves out the stops that change its colors
/// least ([`fit_run`]), so every run is still written.
pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option<Padded> {
let kind = self.kind;
if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() {
@@ -68,8 +71,8 @@ impl Gradient {
// smallest 0 when the area holds the rings' center, else on its edge.
let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() };
let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0);
let one = whole_run(&self.stops);
let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1);
let budget = max_stops.min(MAX_SPREAD_STOPS);
let most = MAX_SPREAD_RUNS.min(budget / 2).max(1);
let capped = hi - lo > most as f64;
// Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear)
// those around the vector's start where the area reaches it, else the area's runs nearest
@@ -79,6 +82,7 @@ impl Gradient {
_ => lo,
};
let n = (hi - lo).min(most as f64).max(1.0);
let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method);
// A radial or diamond writes its runs among the levels from 0, which its stops' offsets
// (single-precision fractions of the outermost level) tell apart only so far out, the
// nearer the closer its stops lie: past that, the runs end there and the gradient pads.
@@ -112,8 +116,9 @@ impl Gradient {
self.unrolled_within(MAX_SPREAD_STOPS)
}
/// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs
/// that fit, the last one pads.
/// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops
/// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with
/// fewer than two stops for each run do the runs past those that fit pad with the last one.
pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> {
use std::borrow::Cow;
if !self.is_spread() {
@@ -123,13 +128,29 @@ impl Gradient {
if self.stops.is_empty() {
return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() });
}
let one = whole_run(&self.stops);
let n = self.repeat_count() as usize;
let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1);
let budget = max_stops.min(MAX_SPREAD_STOPS);
let kept = n.min(budget / 2).max(1);
let one = fit_run(whole_run(&self.stops), budget / kept, self.method);
let stops = self.runs(&one, false, 0.0, kept, n as f64);
Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() })
}
/// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two):
/// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that
/// pads with more stops than that without the stops that change its colors least
/// ([`fit_run`]). Others as they are.
pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> {
use std::borrow::Cow;
if self.is_spread() {
return self.unrolled_within(max_stops);
}
if self.stops.len() <= max_stops.max(2) {
return Cow::Borrowed(self);
}
Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() })
}
/// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as
/// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect),
/// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and
@@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec<GradientStop> {
out
}
/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two):
/// as it is when it fits, else without the stops that change its colors least, left out one at a
/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still
/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or
/// process plates lie from the line between theirs (a whole unit when its swatch link or color
/// model differs from either of theirs, and a whole unit more when its midpoint or the one before
/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose
/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm.
fn fit_run(one: Vec<GradientStop>, budget: usize, method: GradientMethod) -> Vec<GradientStop> {
use std::cmp::Reverse;
use std::collections::BinaryHeap;
let budget = budget.max(2);
let n = one.len();
if n <= budget {
return one;
}
let mix = |x: &Color, y: &Color, v: f32| match method {
GradientMethod::Classic => x.lerp_keep_model(y, v),
m => m.mix(x, y, v),
};
let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 {
let span = b.offset - a.offset;
let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 };
// Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`.
let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb());
let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max);
let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs();
// Process inks, which colors that look alike can print differently: off their line, or a
// change of color model.
let process = match (&a.color, &s.color, &b.color) {
(Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => {
[(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max)
}
_ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0,
_ => 1.0,
};
let color = color.max(process);
// Separations: a change of swatch link, or of tint or process plates off their line.
let ink = if s.swatch != a.swatch || s.swatch != b.swatch {
1.0
} else {
let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs();
let plates = match (a.plates, s.plates, b.plates) {
(None, None, None) => 0.0,
(Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max),
_ => 1.0,
};
tint.max(plates)
};
// Leaving it out merges two spans, and an off-center midpoint shapes one of them.
let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 };
// Non-negative, so the bits order as the numbers do (NaN counts as 0).
(color.max(opacity).max(ink) + shaped).max(0.0).to_bits()
};
// The stops still kept on either side of each stop, and each stop's latest cost entry.
let mut prev: Vec<usize> = (0..n).map(|i| i.saturating_sub(1)).collect();
let mut next: Vec<usize> = (1..=n).collect();
let mut gone = vec![false; n];
let mut version = vec![0u32; n];
let mut heap: BinaryHeap<Reverse<(u32, usize, u32)>> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect();
let mut left = n;
while left > budget {
let Some(Reverse((_, i, v))) = heap.pop() else { break };
if gone[i] || v != version[i] {
continue;
}
gone[i] = true;
left -= 1;
let (a, b) = (prev[i], next[i]);
next[a] = b;
prev[b] = a;
for j in [a, b] {
if j != 0 && j != n - 1 {
version[j] += 1;
heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j])));
}
}
}
one.into_iter()
.enumerate()
.filter(|(i, _)| !gone[*i])
.map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } })
.collect()
}
/// A run mirrored: the stops in reverse, each midpoint measured from the other side.
fn mirror(run: &[GradientStop]) -> Vec<GradientStop> {
let mut g = Gradient::new(GradientKind::Linear, run.to_vec());
+117 -5
View File
@@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() {
}
}
/// A run of the stops far too short for the area, or a gradient of many stops, is written with at
/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without
/// stops have nothing to pad.
/// A run of the stops far too short for the area is written with at most so many runs, and pads
/// past them; a gradient of many stops is written with every run the area needs, each run
/// without the stops that change its colors least, within so many stops; a conic, a padding
/// gradient and one without stops have nothing to pad.
#[test]
fn far_repeats_and_many_stops_are_capped() {
fn far_repeats_are_capped_and_many_stops_are_sampled() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) };
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap();
@@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() {
let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) };
let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom };
let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap();
assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len());
assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len());
for x in [0.7, 33.3, 66.1, 99.5] {
let q = Point::new(x, 0.5);
let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb();
let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}");
}
assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS);
assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads");
assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none());
@@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() {
assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_)));
}
/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run
/// when unrolled, each run without the stops that change its colors least: its last run ends on
/// its last color instead of the rest padding with it.
#[test]
fn unrolled_many_stops_keep_every_run() {
let stops: Vec<GradientStop> = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect();
let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) };
let u = g.unrolled();
assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len());
for t in [0.005, 0.5051, 0.9149, 0.9951] {
let want = g.sample(g.spread_param(t) as f32).0.to_rgb();
let got = u.sample(t as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}");
}
// A padding gradient fitted to fewer stops keeps its ends.
let pad = Gradient::new(GradientKind::Linear, g.stops.clone());
let fitted = pad.fitted(16);
assert_eq!(fitted.stops.len(), 16);
for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] {
assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color);
}
assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_)));
}
/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1)
/// is written within the limit, every run the area needs included.
#[test]
fn a_run_over_the_stop_limit_is_written_within_it() {
let stops: Vec<GradientStop> = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect();
let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) };
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None };
let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap();
assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len());
assert!(g.unrolled_within(256).stops.len() <= 256);
}
/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a
/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose
/// them.
#[test]
fn fitting_keeps_sharp_steps_and_swatch_links() {
let mut stops: Vec<GradientStop> = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect();
let red = Color::rgb(1.0, 0.0, 0.0);
// A band of red from 0.5003 to 0.5013, with a sharp step at each side.
for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] {
let gray = Color::gray(offset);
stops.push(GradientStop::new(offset, color.unwrap_or(gray)));
}
let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) };
stops.push(spot.clone());
// Three stops of one ink that look alike but print at different tints.
for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] {
stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) });
}
// A span shaped by an off-center midpoint.
if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) {
s.midpoint = 0.13;
}
stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
let g = Gradient::new(GradientKind::Linear, stops);
let fitted = g.fitted(32);
assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len());
let red_at = |t: f32| fitted.sample(t).0.to_rgb();
assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays");
assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow");
assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops);
assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays");
assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays");
// Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the
// first stays. (The stop before an ink starts stays as well: leaving it out would blend the
// ink in sooner.)
let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) };
let few = vec![
GradientStop::new(0.0, Color::gray(0.0)),
GradientStop::new(0.2, Color::gray(0.17)),
GradientStop::new(0.3, Color::gray(0.3)),
ink(0.4, 0.2),
ink(0.5, 0.9),
ink(0.6, 0.2),
GradientStop::new(1.0, Color::gray(1.0)),
];
let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned();
let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]);
// A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line
// gives.
let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5));
let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5);
let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)];
let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) };
let fitted = perceptual.fitted(3).into_owned();
let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.5, 1.0]);
// A black of other inks between blacks that look the same prints differently: it stays.
let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 });
let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 });
let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)];
let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned();
let offsets: Vec<f32> = fitted.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.5, 1.0]);
let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() };
let u = spread.unrolled_within(128);
assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run");
}
/// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and
/// PDF write as images) repeat with the stops, for each kind.
#[test]
+27 -6
View File
@@ -32,6 +32,12 @@ const SHARP: f64 = 1.25;
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 stops of the gradient [`gradient_meshes`] puts rows or columns at
/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out).
pub(crate) const MAX_MESH_STOPS: usize = 1024;
/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more:
/// a subpath with more ribs than leave room for a row at each stop becomes several meshes.
pub(crate) const MAX_MESH_POINTS: usize = 1 << 20;
/// 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.
@@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec<f64> {
/// 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.
/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to
/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is
/// split into meshes of fewer ribs ([`rib_pieces`]).
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
let g = &*g.unrolled();
let fitted = g.fitted(MAX_MESH_STOPS);
let across = st.gradient_mode == StrokeGradientMode::Across;
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
let at_stops = mesh_marks(g);
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
let marks: Vec<f64> = if across { vec![] } else { at_stops.clone() };
let marks: Vec<f64> = if across { vec![] } else { mesh_marks(&fitted) };
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(at_stops).collect() } else { vec![0.5] };
let mut us: Vec<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] };
us.sort_by(f64::total_cmp);
us.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
let us = &us;
let g = &*fitted;
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
// A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than
// that leaves room for is split into meshes of fewer ribs.
let per_mesh = MAX_MESH_POINTS / (us.len() + 2);
strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt)
.into_iter()
.filter(|ribs| ribs.len() > 1)
.flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec()))
.map(|ribs| {
let (rows, cols) = (us.len() + 1, ribs.len() - 1);
// Each level across: a point on each rib and the gradient position there.
@@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi
})
.collect()
}
/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip:
/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before,
/// so the meshes meet without a gap.
pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec<std::ops::Range<usize>> {
let per_mesh = per_mesh.max(2);
(0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect()
}
@@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() {
}
}
/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100
/// times: each mesh holds at most so many points, and every run still starts on its first color.
#[test]
fn a_mesh_across_many_ribs_and_stops_stays_bounded() {
use vectorcraft_color::GradientSpread;
let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect();
let bp = poly(&pts, false);
let stops: Vec<GradientStop> = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect();
let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) };
let mut st = stroke(4.0, StrokeGradientMode::Across);
st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone())));
let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g);
assert!(!meshes.is_empty());
for m in &meshes {
assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len());
// A row at the start of each of the 100 runs, on every rib, is white.
let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count();
assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len());
}
// A strip of more ribs than one mesh has room for is split into meshes that share their end
// ribs, every rib in one of them.
use super::gradient::rib_pieces;
assert_eq!(rib_pieces(5, 3), [0..3, 2..5]);
assert_eq!(rib_pieces(4, 3), [0..3, 2..4]);
let one = rib_pieces(2, 1000);
assert_eq!((one.len(), one.first()), (1, Some(&(0..2))));
assert_eq!(rib_pieces(3, 0), [0..2, 1..3]);
}
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
/// 256 do.
+15 -8
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
expand
),
@@ -476,8 +476,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
} else {
// A gradient that repeats or reflects expands as its stops repeated over the box.
let (g, runs) = padded_on(g, bounds);
// A gradient that repeats or reflects expands as its stops repeated over the box (and
// stays a gradient when the box needs more runs than are written).
let (g, runs) = padded_on(g, bounds)?;
let g = &*g;
let frame = Frame::new(g, bounds);
match to {
@@ -1241,26 +1242,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32))
}
/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and
/// how many runs lie evenly from there to 1.
type Runs = Option<(f64, usize)>;
/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond
/// gradient that repeats or reflects with its stops repeated as many times as the box needs
/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the
/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated
/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from
/// there to 1.
fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) {
/// there to 1. `None` when the box needs more runs than are written
/// ([`vectorcraft_color::Padded::capped`]): the gradient stays.
fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> {
use std::borrow::Cow;
if !g.gradient.is_spread() {
return (Cow::Borrowed(g), None);
return Some((Cow::Borrowed(g), None));
}
let geom = g.resolve(bounds);
match g.gradient.padded_over(&geom, bounds) {
Some(p) if p.capped => None,
Some(p) => {
let runs = Some((p.first, p.runs));
(Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)
Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs))
}
None => {
let runs = Some((0.0, g.gradient.repeat_count() as usize));
(Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)
Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs))
}
}
}
+20
View File
@@ -508,6 +508,26 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() {
}
}
/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not
/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of
/// the object (its stroke) still expands.
#[test]
fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() {
for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] {
let mut s = session();
let gr = json!({"kind": "linear", "spread": "repeat", "repeats": 100, "start": [30, 100], "end": [50, 100],
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]});
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, true);
let before = render(&s);
run(&mut s, "object.expand", to.clone());
let after = render(&s);
assert!(mean_diff(&before, &after) < 2.0, "{to}: {}", mean_diff(&before, &after));
let mut gradients = 0;
s.doc().unwrap().doc.walk(|n| gradients += usize::from(matches!(n.appearance.fill_paint(), Paint::Gradient(_))));
assert_eq!(gradients, 1, "{to}");
}
}
#[test]
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
for gradient in [
+4 -1
View File
@@ -582,9 +582,12 @@ impl Rules<'_> {
}
_ => (unrolled, geom, ""),
};
// A repeating gradient's period is the span from its first stop to its last, repeated
// across the box: its positions get four decimals, so the period stays in place there.
let decimals = if repeating.is_empty() { 2 } else { 4 };
let stops = |pos: &dyn Fn(f32) -> f64| {
let stops: Vec<String> =
gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, 2))).collect();
gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect();
stops.join(", ")
};
match g.gradient.kind {
+7
View File
@@ -359,6 +359,13 @@ fn spread_gradients_give_repeating_css_gradients() {
css(GradientKind::Linear, GradientSpread::Reflect, across).as_deref(),
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 25%, #000000 25%, #ffffff 50%)")
);
// A period that is not a whole hundredth of the box keeps four decimals, so it does not drift
// across the box.
let short = GradientGeom { end: Point::new(10.3452, 30.0), ..across };
assert_eq!(
css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(),
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)")
);
let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
assert_eq!(
css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(),
+33
View File
@@ -759,6 +759,39 @@ mod gradient_chip_tests {
}
}
/// A gradient that repeats or reflects as many times as a chip has cells (8 on a 24-point chip)
/// still shows its runs: the grid gets more cells than runs, so its points do not all sample
/// the same place in a run.
#[test]
fn a_chip_repeated_once_a_cell_is_not_one_color() {
for spread in [vectorcraft_color::GradientSpread::Repeat, vectorcraft_color::GradientSpread::Reflect] {
let mut gp = paint(GradientKind::Linear, 0.0);
gp.gradient.spread = spread;
gp.gradient.repeats = 8;
let (cols, rows) = chips::chip_grid(&gp.gradient, 24.0, 24.0);
assert!(cols > 8 && rows > 8, "{spread:?}: {cols} x {rows}");
let chip = gradient_chip_colors(&gp, 24.0, 24.0, cols, rows);
let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect();
assert!(row.len() > 2, "{spread:?}: {row:?}");
}
// A gradient that pads keeps a cell about every 3 points.
assert_eq!(chips::chip_grid(&Gradient::default(), 24.0, 24.0), (8, 8));
// Many runs give a small chip at most a cell a point.
let mut many = paint(GradientKind::Linear, 0.0).gradient;
many.spread = vectorcraft_color::GradientSpread::Repeat;
many.repeats = 100;
assert_eq!(chips::chip_grid(&many, 24.0, 12.0), (24, 12));
assert_eq!(chips::chip_grid(&many, 400.0, 400.0), (128, 128));
// A count that matches a chip's points still shows its runs.
let mut gp = paint(GradientKind::Linear, 0.0);
gp.gradient.spread = vectorcraft_color::GradientSpread::Repeat;
gp.gradient.repeats = 23;
let (cols, rows) = chips::chip_grid(&gp.gradient, 23.0, 23.0);
let chip = gradient_chip_colors(&gp, 23.0, 23.0, cols, rows);
let row: std::collections::HashSet<Color32> = chip.iter().take(cols + 1).copied().collect();
assert!(row.len() > 2, "{cols}: {row:?}");
}
#[test]
fn radial_chip_runs_from_the_centre_out() {
let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4);
+33 -5
View File
@@ -33,11 +33,7 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient
if !(rect.width() > 0.0 && rect.height() > 0.0) {
return;
}
// Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates).
let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform;
let max = if freeform { 16 } else { 64 };
let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max);
let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max);
let (cols, rows) = chip_grid(&gradient.gradient, rect.width(), rect.height());
let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows);
// Stops below full opacity show a checkerboard through them.
if colors.iter().any(|c| c.a() < u8::MAX) {
@@ -59,6 +55,38 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient
ui.painter().add(egui::Shape::mesh(mesh));
}
/// Most grid cells across (and down) a chip of a gradient that repeats or reflects.
const SPREAD_CHIP_CELLS: usize = 128;
/// The grid (columns, rows) a `width` x `height` chip of gradient `g` is sampled on: a cell about
/// every 3 points (at most 64 a side, 16 for a freeform gradient, whose sampling is costly per
/// spot; the mesh interpolates between them), and for a gradient that repeats or reflects at
/// least 4 cells a run along each side (at most one a point, and [`SPREAD_CHIP_CELLS`]), so the
/// grid's points do not all fall at the same place in a run and paint the chip one color. For
/// such a gradient a side gets a cell or two more when its points would fall at fewer than three
/// places in a run.
pub(super) fn chip_grid(g: &vectorcraft_color::Gradient, width: f32, height: f32) -> (usize, usize) {
let freeform = g.kind == vectorcraft_color::GradientKind::Freeform;
let max = if freeform { 16 } else { 64 };
let runs = if g.is_spread() { g.repeat_count() as usize } else { 1 };
let least = if runs > 1 { (4 * runs).min(SPREAD_CHIP_CELLS) } else { 1 };
let gcd = |mut a: usize, mut b: usize| {
while b != 0 {
(a, b) = (b, a % b);
}
a
};
let side = |len: f32| {
let mut cells = ((len / 3.0).ceil() as usize).clamp(1, max).max(least.min(len.ceil() as usize));
// Points `i / cells` of the way along fall at `cells / gcd(cells, runs)` places in a run.
while runs > 1 && cells / gcd(cells, runs) < 3 {
cells += 1;
}
cells
};
(side(width), side(height))
}
/// A white and light gray checkerboard filling `rect`, with cells a quarter of its shorter side
/// (3 to 8 points).
fn checkerboard(ui: &Ui, rect: Rect) {
+10 -5
View File
@@ -1204,11 +1204,16 @@ is written with its stops repeated as many times as the shape needs: at most 256
legacy `.ai`), and for a radial or diamond gradient no farther from its center than 65,536 runs, or than a million
times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning.
The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with
a warning. PDF import finds a sharp step between two colors in an axial or radial shading, in any PDF, by halving
the spans between its samples, so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis, with at
most 16,384 samples a shading) instead of blurred across the samples around it. Object › Expand expands the
repeated stops, into the same whole number of `steps` objects for each run (at least one, so about `steps` in all),
each run starting on an object's edge:
a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that
change its colors least (its ends, sharp steps and swatch-linked stops stay); the same applies to a gradient along
or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. PDF
import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples,
so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples
around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay
blurred, and a color band narrower than 1/64 of the axis can be missed when no sample lands in it. Object › Expand
expands the repeated stops, into the same whole number of `steps` objects for each run (at least one, so about
`steps` in all), each run starting on an object's edge; a gradient that needs more than 256 runs across the box stays
a gradient:
```json
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"spread":"reflect","repeats":3}}}