From 67ef83f1555d9de204d24d74c486f9371dcfdfb1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:39:15 -0500 Subject: [PATCH 1/3] Gradients: midpoints and freeform points find their stops by binary search A gradient whose stops are not strictly increasing (repeated offsets, or a file's unsorted stops) sampled each Classic off-centre midpoint, and each freeform point when recoloring, by scanning the stops from the start, so many stops and points cost their product. Such stops now get an index of the furthest offset reached so far, which never decreases, and each sample finds the pair of stops sample() would pick by binary search, giving the same colour for any order of offsets. --- crates/color/src/gradient.rs | 48 ++++++++++++++++++++++++++++-- crates/color/src/gradient/tests.rs | 38 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 402cf3ae2..8441ebcc5 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -175,6 +175,40 @@ fn between(a: &GradientStop, b: &GradientStop, t: f32, mix: &impl Fn(&Color, &Co (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v) } +/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset among stops `1..=k + 1`. +/// [`Gradient::sample_with`] picks the first pair whose end offset `t` doesn't pass; that is the +/// first pair whose reach `t` doesn't pass, and the reaches never decrease, so the pair is found by +/// binary search whether or not the stops are in order. +struct StopReach(Vec); + +impl StopReach { + fn new(stops: &[GradientStop]) -> Self { + // `f32::max` skips a NaN offset, which `t <= offset` never chooses either. + let reach = stops.iter().skip(1).scan(f32::NEG_INFINITY, |m, s| { + *m = m.max(s.offset); + Some(*m) + }); + Self(reach.collect()) + } + + /// What [`Gradient::sample_with`] gives at `t` for `stops`, the stops this was built from. + fn sample_with(&self, stops: &[GradientStop], t: f32, mix: &impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) { + let Some(first) = stops.first() else { return (Color::BLACK, 1.0) }; + if t <= first.offset { + return (first.color, first.opacity); + } + let last = || stops.last().map_or((Color::BLACK, 1.0), |l| (l.color, l.opacity)); + if t.is_nan() { + return last(); + } + let k = self.0.partition_point(|r| *r < t); + match (stops.get(k), stops.get(k + 1)) { + (Some(a), Some(b)) => between(a, b, t, mix), + _ => last(), + } + } +} + fn one() -> f32 { 1.0 } @@ -245,9 +279,11 @@ impl Gradient { /// the one before it stopped, so the work is the samples plus the stops, not their product. pub fn sample_increasing_with(&self, ts: impl IntoIterator, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> { let stops = &self.stops; - // The shared index assumes ordered stops; others (a file's, unsorted) sample one by one. + // The shared cursor assumes ordered stops; others (a file's, unsorted) look each sample up + // in a [`StopReach`], so they cost a binary search each rather than a scan of the stops. if stops.windows(2).any(|w| w[1].offset < w[0].offset) { - return ts.into_iter().map(|t| self.sample_with(t, &mix)).collect(); + let reach = StopReach::new(stops); + return ts.into_iter().map(|t| reach.sample_with(stops, t, &mix)).collect(); } let mut j = 0; ts.into_iter() @@ -284,6 +320,9 @@ impl Gradient { fn expanded_with_stops(&self) -> impl Iterator, Option)> + '_ { // Direct interpolation matches `sample` only for strictly increasing offsets. let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset); + // Otherwise (repeated or unsorted offsets) each midpoint is looked up as `sample` would + // find it, but by binary search: a scan of all the stops per midpoint cost their square. + let reach = if increasing || !self.method.is_classic() { None } else { Some(StopReach::new(&self.stops)) }; self.stops.iter().enumerate().flat_map(move |(i, s)| { let next = self.stops.get(i + 1); let off_centre = (s.midpoint - 0.5).abs() > 1e-3; @@ -293,7 +332,10 @@ impl Gradient { // Mixed from the two stops it lies between, as `sample` would: searching all the // stops again for each midpoint made many stops cost their square. let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v); - let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) }; + let (c, o) = match &reach { + Some(reach) => reach.sample_with(&self.stops, t, &mix), + None => between(s, n, t, &mix), + }; (t, c, o, Some(s.midpoint), None) }); // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 224803075..7084be18c 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -23,6 +23,44 @@ fn increasing_samples_match_single_samples() { assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); } +/// 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] +fn stop_reach_matches_scanning_the_stops() { + let colors = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#000000"]; + let offsets: [&[f32]; 5] = [ + &[0.1, 0.4, 0.4, 0.2, 0.9, 0.5], + &[0.5, 0.5, 0.5, 0.5], + &[0.9, 0.1, 0.8, 0.2, 0.7, 0.3], + &[0.2, f32::NAN, 0.6, 0.1, f32::NAN, 1.0], + &[f32::NAN, 0.3, 0.3, 0.8], + ]; + for offsets in offsets { + let stops: Vec = + offsets.iter().zip(colors.iter().cycle()).map(|(&o, hex)| GradientStop { midpoint: 0.3, ..GradientStop::new(o, Color::from_hex(hex).unwrap()) }).collect(); + let g = Gradient::new(GradientKind::Linear, stops); + let reach = StopReach::new(&g.stops); + for t in (-4..=44).map(|k| k as f32 / 40.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]) { + assert_eq!(reach.sample_with(&g.stops, t, &Color::lerp_keep_model), g.sample_with(t, Color::lerp_keep_model), "{offsets:?} at {t}"); + } + } +} + +/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as +/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one +/// offset used to scan them all for each midpoint). +#[test] +fn expanded_with_repeated_offsets_samples_midpoints() { + let stop = |offset: f32, hex: &str| GradientStop { midpoint: 0.25, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; + let g = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000"), stop(0.5, "#00ff00"), stop(0.5, "#0000ff"), stop(1.0, "#000000")]); + for (t, c, o, mid) in g.expanded().filter(|e| e.3.is_some()) { + assert_eq!(mid, Some(0.25)); + assert_eq!((c, o), g.sample(t), "midpoint at {t}"); + } + let many = Gradient::new(GradientKind::Linear, (0..200_000).map(|i| stop(if i == 0 { 0.0 } else { 0.5 }, "#808080")).collect()); + assert_eq!(many.expanded().count(), 2 * 200_000 - 1); +} + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); From 1f80c018c7c149ecba6f625fe6775d0777b8067a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:03:30 -0500 Subject: [PATCH 2/3] Gradients: compare NaN samples as printed in the stop lookup test --- crates/color/src/gradient/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 7084be18c..6c9a08a9d 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -41,7 +41,9 @@ fn stop_reach_matches_scanning_the_stops() { let g = Gradient::new(GradientKind::Linear, stops); let reach = StopReach::new(&g.stops); for t in (-4..=44).map(|k| k as f32 / 40.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]) { - assert_eq!(reach.sample_with(&g.stops, t, &Color::lerp_keep_model), g.sample_with(t, Color::lerp_keep_model), "{offsets:?} at {t}"); + // Compared as printed: a NaN offset mixes NaN colours, which are equal only so. + let (got, want) = (reach.sample_with(&g.stops, t, &Color::lerp_keep_model), g.sample_with(t, Color::lerp_keep_model)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "{offsets:?} at {t}"); } } } From fb67e28954ae13a2deaebc59957553c67d1505ca Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 04:31:54 +0000 Subject: [PATCH 3/3] Format with rustfmt --- crates/color/src/gradient/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 6c9a08a9d..da9a3f2cc 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -36,8 +36,11 @@ fn stop_reach_matches_scanning_the_stops() { &[f32::NAN, 0.3, 0.3, 0.8], ]; for offsets in offsets { - let stops: Vec = - offsets.iter().zip(colors.iter().cycle()).map(|(&o, hex)| GradientStop { midpoint: 0.3, ..GradientStop::new(o, Color::from_hex(hex).unwrap()) }).collect(); + let stops: Vec = offsets + .iter() + .zip(colors.iter().cycle()) + .map(|(&o, hex)| GradientStop { midpoint: 0.3, ..GradientStop::new(o, Color::from_hex(hex).unwrap()) }) + .collect(); let g = Gradient::new(GradientKind::Linear, stops); let reach = StopReach::new(&g.stops); for t in (-4..=44).map(|k| k as f32 / 40.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]) {