Merge pull request #5385 from storytold/fix/sec-gradient-pixel-stop-search

Gradients drawn pixel by pixel find their stops by binary search (EMF export, Affinity overlays)
This commit is contained in:
BFlat
2026-11-03 22:41:24 +07:00
committed by GitHub
4 changed files with 52 additions and 18 deletions
+29 -17
View File
@@ -332,7 +332,7 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
};
let to_unit = match o.source {
Source::Gradient(g) => match invert(g.transform) {
Some(m) => Some((img.transform.then(m), g)),
Some(m) => Some((img.transform.then(m), g, stop_reach(g))),
None => return false,
},
Source::Solid(_) => None,
@@ -343,9 +343,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
}
let (s, sa) = match (solid, &to_unit) {
(Some(s), _) => s,
(None, Some((m, g))) => {
(None, Some((m, g, reach))) => {
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
let c = sample(g, q);
let c = sample(g, reach, q);
(rgb(c), c.alpha())
}
(None, None) => continue,
@@ -376,8 +376,22 @@ fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
Some(reader.decode().ok()?.into_rgba8().into_raw())
}
/// The gradient's colour at unit-space point `q`.
fn sample(g: &Gradient, q: Point) -> Color {
/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`:
/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and
/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not.
fn stop_reach(g: &Gradient) -> Vec<f64> {
g.stops
.iter()
.skip(1)
.scan(f64::NEG_INFINITY, |m, s| {
*m = m.max(s.offset);
Some(*m)
})
.collect()
}
/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`).
fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color {
let t = match g.kind {
GradientKind::Linear => q.x,
GradientKind::Radial => q.x.hypot(q.y),
@@ -388,18 +402,16 @@ fn sample(g: &Gradient, q: Point) -> Color {
if t <= first.offset {
return first.color;
}
for w in g.stops.windows(2) {
let [a, b] = [&w[0], &w[1]];
if t <= b.offset {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
let k = reach.partition_point(|r| *r < t);
if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
last.color
}
+6
View File
@@ -245,6 +245,12 @@ impl Gradient {
pub fn sample(&self, t: f32) -> (Color, f32) {
self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
}
/// [`Self::sample`] for many points in no order (a gradient drawn pixel by pixel): the stops
/// are prepared once, so each sample costs a binary search rather than a scan of the stops.
pub fn sampler(&self) -> impl Fn(f32) -> (Color, f32) + '_ {
let reach = StopReach::new(&self.stops);
move |t| reach.sample_with(&self.stops, t, &|a, b, v| self.method.mix(a, b, v))
}
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
/// gradient's as [`Self::sample`] mixes them.
+15
View File
@@ -23,6 +23,21 @@ fn increasing_samples_match_single_samples() {
assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one);
}
/// The prepared sampler gives what sampling each position alone does, out-of-order stops included.
#[test]
fn the_sampler_matches_single_samples() {
let stop = |offset: f32, hex: &str| GradientStop::new(offset, Color::from_hex(hex).unwrap());
let g = Gradient::new(
GradientKind::Linear,
vec![stop(0.1, "#ff0000"), stop(0.4, "#00ff00"), stop(0.4, "#0000ff"), stop(0.2, "#ffff00"), stop(0.9, "#000000")],
);
let sample = g.sampler();
for k in (0..=40).rev() {
let t = k as f32 / 40.0;
assert_eq!(sample(t), g.sample(t), "{t}");
}
}
/// Looking stops up by their reach finds what scanning them does, for any order of offsets,
/// repeated, out-of-order and NaN ones included, and for positions outside and between them.
#[test]
+2 -1
View File
@@ -412,6 +412,7 @@ impl<'a> Scene<'a> {
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
} else {
let geom = g.resolve(bounds);
let sample = g.gradient.sampler();
let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows));
(0..usize::from(rows) * usize::from(cols))
.flat_map(|i| {
@@ -419,7 +420,7 @@ impl<'a> Scene<'a> {
let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch);
let t = geom.param_at(g.gradient.kind, p);
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let (c, a) = g.gradient.sample(t as f32);
let (c, a) = sample(t as f32);
c.to_rgba8(a)
})
.collect()