mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-29 18:08:12 +08:00
Ease the Smooth gradient interpolation into the ends of an open ramp (#4521)
This commit is contained in:
@@ -553,6 +553,14 @@ fn knot_channels<CS: color::ColorSpace>(knots: &[GradientStop], gradient_hue_dir
|
|||||||
channels
|
channels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tangent a [`MonotonicSpline`] takes at its two outermost knots: the end secant itself, or half of it as the mean of
|
||||||
|
/// that secant and the flat continuation beyond the end, so the curve settles into its ends instead of arriving at full slope.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum EndTangent {
|
||||||
|
Secant,
|
||||||
|
HalfSecant,
|
||||||
|
}
|
||||||
|
|
||||||
/// A Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) spline, preserving its samples' monotonicity:
|
/// A Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) spline, preserving its samples' monotonicity:
|
||||||
/// it passes through every sample and joins the pieces with matching slopes, while the Fritsch-Carlson
|
/// it passes through every sample and joins the pieces with matching slopes, while the Fritsch-Carlson
|
||||||
/// limiter keeps each piece bounded by its own two samples, so the curve rises and falls only where its
|
/// limiter keeps each piece bounded by its own two samples, so the curve rises and falls only where its
|
||||||
@@ -566,7 +574,7 @@ struct MonotonicSpline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MonotonicSpline {
|
impl MonotonicSpline {
|
||||||
fn new(position: Vec<f64>, value: Vec<f64>) -> Self {
|
fn new(position: Vec<f64>, value: Vec<f64>, end_tangent: EndTangent) -> Self {
|
||||||
let count = position.len();
|
let count = position.len();
|
||||||
if count < 2 {
|
if count < 2 {
|
||||||
let tangent = vec![0.; count];
|
let tangent = vec![0.; count];
|
||||||
@@ -580,15 +588,20 @@ impl MonotonicSpline {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let end_scale = match end_tangent {
|
||||||
|
EndTangent::Secant => 1.,
|
||||||
|
EndTangent::HalfSecant => 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
// A sign change or a flat run between neighboring secants pins that tangent to zero,
|
// A sign change or a flat run between neighboring secants pins that tangent to zero,
|
||||||
// which is what stops the curve from bulging past a local extreme
|
// which is what stops the curve from bulging past a local extreme
|
||||||
let mut tangent = Vec::with_capacity(count);
|
let mut tangent = Vec::with_capacity(count);
|
||||||
tangent.push(secant[0]);
|
tangent.push(secant[0] * end_scale);
|
||||||
for index in 1..count - 1 {
|
for index in 1..count - 1 {
|
||||||
let (before, after) = (secant[index - 1], secant[index]);
|
let (before, after) = (secant[index - 1], secant[index]);
|
||||||
tangent.push(if before * after <= 0. { 0. } else { (before + after) / 2. });
|
tangent.push(if before * after <= 0. { 0. } else { (before + after) / 2. });
|
||||||
}
|
}
|
||||||
tangent.push(secant[count - 2]);
|
tangent.push(secant[count - 2] * end_scale);
|
||||||
|
|
||||||
// Fritsch-Carlson: pull any tangent pair back inside the radius-3 circle around their shared secant
|
// Fritsch-Carlson: pull any tangent pair back inside the radius-3 circle around their shared secant
|
||||||
for index in 0..count - 1 {
|
for index in 0..count - 1 {
|
||||||
@@ -635,9 +648,10 @@ impl MonotonicSpline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Smooth path: a monoticity-preserving spline per color channel through every stop, traversed by a second such spline that
|
/// The Smooth path: a monotonicity-preserving spline per color channel through every stop, traversed by a second such spline that
|
||||||
/// maps ramp position to spline parameter. Fitting the stop and midpoint constraints into one global warp is what keeps the
|
/// maps ramp position to spline parameter. Fitting the stop and midpoint constraints into one global warp is what keeps the
|
||||||
/// traversal rate continuous across stops, where independent per-interval curves (what Linear uses) would kink at each one.
|
/// traversal rate continuous across stops, where independent per-interval curves (what Linear uses) would kink at each one.
|
||||||
|
/// The color splines take [`EndTangent::HalfSecant`] at a ramp's real ends while the warp keeps the full secant, so the color eases but the traversal stays even.
|
||||||
struct SmoothPath {
|
struct SmoothPath {
|
||||||
space: GradientSpace,
|
space: GradientSpace,
|
||||||
hue_index: Option<usize>,
|
hue_index: Option<usize>,
|
||||||
@@ -677,7 +691,9 @@ impl SmoothPath {
|
|||||||
|
|
||||||
let channels = with_space!(settings.space, knot_channels, &knots, settings.hue_direction);
|
let channels = with_space!(settings.space, knot_channels, &knots, settings.hue_direction);
|
||||||
let parameter: Vec<f64> = (0..knots.len()).map(|index| index as f64).collect();
|
let parameter: Vec<f64> = (0..knots.len()).map(|index| index as f64).collect();
|
||||||
let channel = std::array::from_fn(|component| MonotonicSpline::new(parameter.clone(), channels.iter().map(|values| values[component]).collect()));
|
// Wrapped copies give the end stops neighbors on both sides, so only a ramp with real ends eases into them
|
||||||
|
let end_tangent = if settings.cyclic && wrapped_interval { EndTangent::Secant } else { EndTangent::HalfSecant };
|
||||||
|
let channel = std::array::from_fn(|component| MonotonicSpline::new(parameter.clone(), channels.iter().map(|values| values[component]).collect(), end_tangent));
|
||||||
|
|
||||||
// Each stop pins its own knot parameter and each midpoint the half-parameter between two,
|
// Each stop pins its own knot parameter and each midpoint the half-parameter between two,
|
||||||
// so one monotonic curve satisfies every midpoint constraint at once
|
// so one monotonic curve satisfies every midpoint constraint at once
|
||||||
@@ -700,7 +716,7 @@ impl SmoothPath {
|
|||||||
space: settings.space,
|
space: settings.space,
|
||||||
hue_index: with_space!(settings.space, space_hue_index),
|
hue_index: with_space!(settings.space, space_hue_index),
|
||||||
channel,
|
channel,
|
||||||
warp: MonotonicSpline::new(warp_position, warp_value),
|
warp: MonotonicSpline::new(warp_position, warp_value, EndTangent::Secant),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1783,7 +1799,7 @@ pub enum GradientInterpolation {
|
|||||||
/// Transitions straight from each stop to the next, turning a corner at every stop.
|
/// Transitions straight from each stop to the next, turning a corner at every stop.
|
||||||
#[default]
|
#[default]
|
||||||
Linear,
|
Linear,
|
||||||
/// Transitions along a curve that flows through the stops without corners.
|
/// Transitions along a curve that flows through the stops without corners and settles gently into the two ends.
|
||||||
///
|
///
|
||||||
/// The rate of color change carries smoothly through each stop (C1 continuity) and never overshoots beyond the stop colors, properties of its spline: a Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) with Fritsch-Carlson tangent limiting.
|
/// The rate of color change carries smoothly through each stop (C1 continuity) and never overshoots beyond the stop colors, properties of its spline: a Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) with Fritsch-Carlson tangent limiting.
|
||||||
Smooth,
|
Smooth,
|
||||||
@@ -2216,6 +2232,24 @@ mod tests {
|
|||||||
assert_eq!((last.0, last.1), (1., Color::WHITE));
|
assert_eq!((last.0, last.1), (1., Color::WHITE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smooth_eases_into_the_ends_of_an_open_ramp() {
|
||||||
|
let smooth = GradientSettings {
|
||||||
|
space: GradientSpace::RgbLinear,
|
||||||
|
interpolation: GradientInterpolation::Smooth,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
|
||||||
|
|
||||||
|
// Half the end secant as the end tangent makes a two-stop ramp the cubic 0.5 t + 1.5 t^2 - t^3, which leaves
|
||||||
|
// and arrives at half speed and crosses the middle at the halfway color
|
||||||
|
for (t, expected) in [(0.25, 0.203125), (0.5, 0.5), (0.75, 0.796875)] {
|
||||||
|
let red = gradient.evaluate(t, smooth).r() as f64;
|
||||||
|
assert!((red - expected).abs() < 1e-4, "expected {expected} at {t}, got {red}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smooth_cyclic_stops_on_both_boundaries_keep_a_hard_seam() {
|
fn smooth_cyclic_stops_on_both_boundaries_keep_a_hard_seam() {
|
||||||
let open = GradientSettings {
|
let open = GradientSettings {
|
||||||
|
|||||||
Reference in New Issue
Block a user