mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #6961 from dotCooCoo/fix/gradient-repeat-runs
Gradients: a repeated gradient with many stops keeps every run
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -194,10 +194,12 @@ impl Expandable {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
};
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
@@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or
|
||||
/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a
|
||||
/// path that live-effect plug-ins paint, whether a piece of the art they paint has one
|
||||
/// ([`expand_pieces`]).
|
||||
fn gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return false;
|
||||
}
|
||||
if !vectorcraft_render::effects::has_painted_effects(l) {
|
||||
return piece_gradient_expands(d, l);
|
||||
}
|
||||
let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false };
|
||||
let mut any = false;
|
||||
art.walk(&mut |c| {
|
||||
any = any
|
||||
|| (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& !c.is_ruler_guide()
|
||||
&& c.appearance.items.iter().any(|i| gradient_fill(i).is_some())
|
||||
&& piece_gradient_expands(d, c));
|
||||
});
|
||||
any
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
return l.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
}
|
||||
let reshaped = reshaped(d, l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let Some((path, _)) = node_path(l) else { return false };
|
||||
let Some(bounds) = path.bounds() else { return false };
|
||||
l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| {
|
||||
fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
@@ -476,8 +515,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 {
|
||||
@@ -1244,26 +1284,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +529,42 @@ 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. `object.expand.info` reports no gradient or fill to
|
||||
/// expand, and Expand with only Fill on reports nothing to expand; the same gradient in 70 runs
|
||||
/// (10 repeats) reports both.
|
||||
#[test]
|
||||
fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() {
|
||||
let gradient = |repeats: u32| {
|
||||
json!({"kind": "linear", "spread": "repeat", "repeats": repeats, "start": [30, 100], "end": [50, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]})
|
||||
};
|
||||
for (repeats, expands) in [(10, true), (100, false)] {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(repeats), true);
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(info["gradient"].clone(), info["fill"].clone(), info["stroke"].clone()),
|
||||
(json!(expands), json!(expands), json!(true)),
|
||||
"{repeats}"
|
||||
);
|
||||
let only_fill = s.execute("object.expand", &json!({"object": false, "stroke": false}));
|
||||
assert_eq!(only_fill.is_ok(), expands, "{repeats}: {only_fill:?}");
|
||||
}
|
||||
for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(100), 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 [
|
||||
|
||||
@@ -722,6 +722,27 @@ fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.expand.info` measures each piece a painted effect returns: a piece whose repeating
|
||||
/// gradient needs more runs across it than are written (1,000) reports no gradient to expand, as
|
||||
/// Expand leaves it a gradient, and the same gradient in 10 runs reports one.
|
||||
#[test]
|
||||
fn expand_info_measures_the_gradient_on_each_painted_piece() {
|
||||
for (end, expands) in [(120.0, true), (100.2, false)] {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let gradient = json!({"kind": "linear", "spread": "repeat", "repeats": 10, "start": [100, 50], "end": [end, 50]});
|
||||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": gradient}));
|
||||
let out = json!({"objects": [{"path": square_at(100.0)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandruns", true), &out));
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandruns"}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{end}");
|
||||
let only_fill = s.execute("object.expand", &json!({"object": false, "fill": true, "stroke": false}));
|
||||
assert_eq!(only_fill.is_ok(), expands, "{end}: {only_fill:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its
|
||||
/// tiles expanded, and a piece with a paint of its own keeps that paint.
|
||||
#[test]
|
||||
|
||||
+55
-9
@@ -206,6 +206,14 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
p
|
||||
}
|
||||
|
||||
/// The decimals, at least `least` and at most 12, for numbers that place a period of length
|
||||
/// `period` repeated out to `reach` (in the same units): each period then rounds by at most
|
||||
/// 10^-decimals, and over the `reach / period` periods that adds up to at most a hundredth of one.
|
||||
fn period_decimals(period: f64, reach: f64, least: u8) -> u8 {
|
||||
let d = (100.0 * reach / (period * period)).log10().ceil();
|
||||
if d.is_nan() || d == f64::INFINITY { 12 } else { d.clamp(f64::from(least), 12.0) as u8 }
|
||||
}
|
||||
|
||||
/// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`.
|
||||
fn color_css(c: &Color, opacity: f32) -> String {
|
||||
if opacity >= 1.0 {
|
||||
@@ -247,13 +255,22 @@ impl CssUnits {
|
||||
|
||||
/// `points` in these units (`0` without a unit).
|
||||
fn len(self, points: f64) -> String {
|
||||
let per_point = match self {
|
||||
self.len_to(points, 3)
|
||||
}
|
||||
|
||||
/// One point in these units.
|
||||
fn per_point(self) -> f64 {
|
||||
match self {
|
||||
CssUnits::Px | CssUnits::Pt => 1.0,
|
||||
CssUnits::Mm => 25.4 / 72.0,
|
||||
CssUnits::Cm => 2.54 / 72.0,
|
||||
CssUnits::In => 1.0 / 72.0,
|
||||
};
|
||||
match fmt_num(points * per_point, 3) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::len`] with `decimals` decimals.
|
||||
fn len_to(self, points: f64, decimals: u8) -> String {
|
||||
match fmt_num(points * self.per_point(), decimals) {
|
||||
zero if zero == "0" => zero,
|
||||
v => format!("{v}{}", self.name()),
|
||||
}
|
||||
@@ -582,9 +599,21 @@ impl Rules<'_> {
|
||||
}
|
||||
_ => (unrolled, geom, ""),
|
||||
};
|
||||
let stops = |pos: &dyn Fn(f32) -> f64| {
|
||||
// A repeating gradient's period is the span from its first stop to its last, repeated
|
||||
// across the box (from 0% to `reach`%): its positions get at least four decimals, and more
|
||||
// when the box holds so many periods that their rounding would add up to more than a
|
||||
// hundredth of one ([`period_decimals`]).
|
||||
let span = gradient.expanded().next().zip(gradient.expanded().last()).map(|(a, z)| (a.0, z.0));
|
||||
let decimals = |pos: &dyn Fn(f32) -> f64, reach: f64| match span {
|
||||
Some((a, z)) if !repeating.is_empty() => {
|
||||
let (p0, p1) = (pos(a) * 100.0, pos(z) * 100.0);
|
||||
period_decimals((p1 - p0).abs(), p0.abs().max((reach - p0).abs()), 4)
|
||||
}
|
||||
_ => 2,
|
||||
};
|
||||
let stops = |pos: &dyn Fn(f32) -> f64, decimals: u8| {
|
||||
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 {
|
||||
@@ -601,7 +630,7 @@ impl Rules<'_> {
|
||||
let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs();
|
||||
let from = (geom.start - b.center()).dot(dir);
|
||||
let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 };
|
||||
Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos)))
|
||||
Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos, decimals(&pos, 100.0))))
|
||||
}
|
||||
// CSS has no diamond gradient: the ellipse through the square's corners stands in.
|
||||
GradientKind::Radial | GradientKind::Diamond => {
|
||||
@@ -611,13 +640,30 @@ impl Rules<'_> {
|
||||
} else if geom.focal.is_some() {
|
||||
self.warn("radial gradients start at their centre (an off-centre focal point is left out)");
|
||||
}
|
||||
// The positions are a share of the radius, and the box reaches out to its farthest
|
||||
// corner. A repeating gradient's period scales with its radius, so the radius gets
|
||||
// the decimals that keep the periods in place out to that corner too.
|
||||
let units = self.opts.units;
|
||||
let small = r * geom.aspect.abs().min(1.0);
|
||||
let far = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| (Point::new(x, y) - geom.start).hypot())
|
||||
.fold(0.0, f64::max);
|
||||
let reach = if small > 1e-12 { 100.0 * far / small } else { 0.0 };
|
||||
let pos = |t: f32| t as f64;
|
||||
let len = match span {
|
||||
Some((a, z)) if !repeating.is_empty() => {
|
||||
period_decimals(small * f64::from(z - a).abs() * units.per_point(), far * units.per_point(), 3)
|
||||
}
|
||||
_ => 3,
|
||||
};
|
||||
let shape = if (geom.aspect - 1.0).abs() < 1e-9 {
|
||||
format!("circle {}", self.len(r))
|
||||
format!("circle {}", units.len_to(r, len))
|
||||
} else {
|
||||
format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect))
|
||||
format!("ellipse {} {}", units.len_to(r, len), units.len_to(r * geom.aspect, len))
|
||||
};
|
||||
let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0));
|
||||
Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64)))
|
||||
Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&pos, decimals(&pos, reach))))
|
||||
}
|
||||
GradientKind::Conic => {
|
||||
// CSS sweeps clockwise from the `from` angle, 0deg straight up
|
||||
|
||||
@@ -359,6 +359,28 @@ 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%)")
|
||||
);
|
||||
// About 1,000 periods across the box: six decimals, so their rounding adds up to at most a
|
||||
// hundredth of a period at the far side (with four, `0.1%` would be almost half a period out
|
||||
// there).
|
||||
let tiny = GradientGeom { end: Point::new(0.400196, 30.0), ..across };
|
||||
assert_eq!(
|
||||
css(GradientKind::Linear, GradientSpread::Repeat, tiny).as_deref(),
|
||||
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 0.100049%)")
|
||||
);
|
||||
// A radial period of 0.03085 px out to a corner 67 px away: its radius keeps the decimals that
|
||||
// hold the periods in place there.
|
||||
let dot = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.1234, 30.0), aspect: 1.0, focal: None };
|
||||
assert_eq!(
|
||||
css(GradientKind::Radial, GradientSpread::Repeat, dot).as_deref(),
|
||||
Some("repeating-radial-gradient(circle 0.03085px at 40px 30px, #ffffff 0%, #000000 100%)")
|
||||
);
|
||||
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(),
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
+13
-5
@@ -1270,11 +1270,19 @@ 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. Each run keeps its ends; sharp steps and swatch-linked stops are the last to be left out,
|
||||
but a run with more of them than its share of the limit loses some. 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. For CSS the 16,384
|
||||
counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual
|
||||
gradient stops along each span's curve, so the written gradient can hold more. 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}}}
|
||||
|
||||
Reference in New Issue
Block a user