New nodes: 'Gradient Reverse', 'Gradient Shift', and 'Gradient Stretch' nodes for rearranging a ramp's stops (#4424)

* New nodes: 'Gradient Reverse', 'Gradient Shift', and 'Gradient Stretch' nodes for rearranging a ramp's stops

* Clip a lone out-of-range stop into the bake, and correct the docs that still promised edge-held stops
This commit is contained in:
Keavon Chambers
2026-08-08 03:09:06 -07:00
committed by Dennis Kobert
parent 344535d143
commit 2baf9a8192
7 changed files with 460 additions and 86 deletions

View File

@@ -1,4 +1,4 @@
use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, lane_gradient_settings, spread_adjusted_samples, transform_is_invertible};
use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible};
use crate::{Render, RenderSvgSegmentList, SvgRender};
use core_types::Color;
use core_types::attribute::Transform;
@@ -8,13 +8,11 @@ use core_types::uuid::generate_uuid;
use glam::{DAffine2, DVec2};
use graphic_types::Graphic;
use graphic_types::vector_types::gradient::GradientForm;
use graphic_types::vector_types::markers::{
GradientCyclic as GradientCyclicAttr, GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr,
};
use graphic_types::vector_types::markers::GradientForm as GradientFormAttr;
use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use std::fmt::Write;
use vector_types::Gradient;
use vector_types::gradient::GradientSpread;
use vector_types::gradient::{GradientSettings, GradientSpread};
#[derive(Copy, Clone, PartialEq)]
pub enum PaintTarget {
@@ -112,7 +110,7 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>
let Some(stops) = source.element(0) else { return 0 };
let gradient_form: GradientForm = source.attr::<GradientFormAttr>(0);
let local_gradient_transform: DAffine2 = source.attr::<Transform>(0);
let settings = lane_gradient_settings(source, 0);
let settings = GradientSettings::from_lane_attributes(source, 0);
let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);

View File

@@ -27,10 +27,7 @@ use graphic_types::graphic::{PaintColumns, PaintOverlay, PaintReach, has_paint,
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientSettings};
use graphic_types::vector_types::markers::GradientInterpolation as GradientInterpolationAttr;
use graphic_types::vector_types::markers::{
GradientCyclic as GradientCyclicAttr, GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr,
};
use graphic_types::vector_types::markers::GradientForm as GradientFormAttr;
use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
@@ -505,23 +502,12 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend {
}
}
/// The gradient's whole-ramp settings from its lane attributes.
pub(crate) fn lane_gradient_settings<S: LaneSource<Element = Gradient>>(source: &S, index: usize) -> GradientSettings {
GradientSettings {
spread: source.attr::<GradientSpreadAttr>(index),
cyclic: source.attr::<GradientCyclicAttr>(index),
space: source.attr::<GradientSpaceAttr>(index),
hue_direction: source.attr::<GradientHueDirectionAttr>(index),
interpolation: source.attr::<GradientInterpolationAttr>(index),
}
}
fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;
let gradient_form: GradientForm = gradient_list.attr::<GradientFormAttr>(0);
let gradient_transform: DAffine2 = gradient_list.attr::<Transform>(0);
let settings = lane_gradient_settings(gradient_list, 0);
let settings = GradientSettings::from_lane_attributes(gradient_list, 0);
let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
@@ -2437,7 +2423,7 @@ fn render_gradient_svg<S: LaneSource<Element = Gradient>>(source: &S, render: &m
let blend_mode: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index);
let opacity_fill_attr: f64 = source.attr::<OpacityFill>(index);
let settings = lane_gradient_settings(source, index);
let settings = GradientSettings::from_lane_attributes(source, index);
let gradient_form: GradientForm = source.attr::<GradientFormAttr>(index);
let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" };
render.leaf_tag(tag, |attributes| {
@@ -2523,7 +2509,7 @@ fn render_gradient_vello<S: LaneSource<Element = Gradient>>(source: &S, scene: &
for index in 0..source.lane_count() {
let Some(gradient) = source.element(index) else { continue };
let settings = lane_gradient_settings(source, index);
let settings = GradientSettings::from_lane_attributes(source, index);
let gradient_form: GradientForm = source.attr::<GradientFormAttr>(index);
let transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
@@ -2560,7 +2546,6 @@ fn render_gradient_vello<S: LaneSource<Element = Gradient>>(source: &S, scene: &
kind,
stops,
extend,
// Straight alpha, keeping parity with the SVG renderer's stop interpolation
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied,
..Default::default()
});
@@ -3342,7 +3327,7 @@ mod group_walk_tests {
#[cfg(test)]
mod spread_tests {
use super::*;
use graphic_types::vector_types::gradient::{GradientHueDirection, GradientInterpolation, GradientSpace};
use graphic_types::vector_types::gradient::GradientSpace;
#[test]
fn spread_adjusted_samples_wraps_clear_in_transparent_guards() {
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);

View File

@@ -1,6 +1,10 @@
use crate::markers::{ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD};
use crate::markers::{
ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, GradientCyclic as GradientCyclicAttr,
GradientHueDirection as GradientHueDirectionAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr,
};
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::lane::LaneSource;
use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List};
use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny;
@@ -850,6 +854,47 @@ fn smooth_samples(stops: &[GradientStop], settings: GradientSettings) -> Vec<(f6
result
}
/// Clips a baked sample list to the unit range, replacing everything beyond an end with one sample interpolated at that boundary.
/// Renderers only accept offsets from 0 to 1, and the bake is dense enough that interpolating between neighbors in gamma reproduces the ramp's curve there.
fn clip_samples_to_unit_range(samples: &[(f64, Color, Option<f64>)]) -> Vec<(f64, Color, Option<f64>)> {
fn sample_at(samples: &[(f64, Color, Option<f64>)], boundary: f64) -> Color {
let index = samples.partition_point(|&(position, ..)| position < boundary);
// A sample sitting on the boundary already answers it, so only a straddling pair needs interpolating
if let Some(&(position, color, _)) = samples.get(index)
&& position == boundary
{
return color;
}
match (index.checked_sub(1).map(|before| samples[before]), samples.get(index).copied()) {
(Some((left, left_color, _)), Some((right, right_color, _))) => {
let span = right - left;
let fraction = if span > 0. { (boundary - left) / span } else { 0. };
left_color.lerp_gamma_srgb(&right_color, fraction as f32)
}
(Some((_, color, _)), None) | (None, Some((_, color, _))) => color,
(None, None) => Color::BLACK,
}
}
let leads_in = samples.first().is_some_and(|&(position, ..)| position < 0.);
let trails_out = samples.last().is_some_and(|&(position, ..)| position > 1.);
if !leads_in && !trails_out {
return samples.to_vec();
}
let mut result: Vec<(f64, Color, Option<f64>)> = samples.iter().copied().filter(|&(position, ..)| (0. ..=1.).contains(&position)).collect();
if leads_in && result.first().is_none_or(|&(position, ..)| position > 0.) {
result.insert(0, (0., sample_at(samples, 0.), None));
}
if trails_out && result.last().is_none_or(|&(position, ..)| position < 1.) {
result.push((1., sample_at(samples, 1.), None));
}
result
}
#[derive(Debug, Clone, Copy)]
pub struct GradientStop {
pub position: f64,
@@ -1208,16 +1253,17 @@ impl Gradient {
new_index
}
/// Gradient stops as evaluation and rendering should see them: positions clamped to the 0 to 1 range
/// (infinities landing at the ends, a NaN dropping its stop from sampling since it has no defined placement)
/// and sorted ascending, so the sampler and every renderer agree on how non-compliant authored data behaves.
/// Gradient stops as evaluation and rendering should see them, sorted ascending: a NaN drops its stop since it has no
/// defined placement, and an infinity lands on the nearest end. A cyclic ramp folds positions into the 0 to 1 range, the
/// whole of its circle, while a non-cyclic ramp keeps finite ones so a shifted ramp samples through stops that slid out.
fn normalized_stops(&self, gradient_cyclic: bool) -> Vec<GradientStop> {
let mut stops: Vec<GradientStop> = (0..self.len())
.filter_map(|index| {
let position = self.position(index, gradient_cyclic).clamp(0., 1.);
let position = self.position(index, gradient_cyclic);
if position.is_nan() {
return None;
}
let position = if gradient_cyclic || position.is_infinite() { position.clamp(0., 1.) } else { position };
let midpoint = self.midpoint(index);
let color = self.color(index)?;
@@ -1252,7 +1298,7 @@ impl Gradient {
pub fn reversed(&self, gradient_cyclic: bool) -> Self {
let count = self.len();
let mut list = self.reordered((0..count).rev());
let mut list = self.mirrored_rows(gradient_cyclic);
// Row reversal already reversed the position cells' order, each also flips across the range
if self.has_position_attribute()
@@ -1268,8 +1314,15 @@ impl Gradient {
}
}
// Midpoints belong to the interval to a stop's right, so they shift by one stop as well as flipping;
// a cyclic gradient's final midpoint is its wrap handle, which flips in place
Self(list)
}
/// Reverses the rows for a mirrored ramp, leaving positions to the caller since each mirroring has its own axis. Midpoints
/// govern the interval to their stop's right, so each shifts by one stop as well as flipping. The wrap handle flips in place.
fn mirrored_rows(&self, gradient_cyclic: bool) -> List<Color> {
let count = self.len();
let mut list = self.reordered((0..count).rev());
if self.has_midpoint_attribute() {
let midpoints: Vec<f64> = (0..count)
.map(|i| {
@@ -1287,7 +1340,80 @@ impl Gradient {
}
}
Self(list)
list
}
/// Shifts every stop along the ramp by `fraction` of its whole length, each midpoint riding along. A cyclic ramp rotates,
/// wrapping past 1 back to 0 and re-sorting. A non-cyclic one translates, its positions leaving the range to sample through.
pub fn shift_positions(&mut self, fraction: f64, gradient_cyclic: bool) {
if !fraction.is_finite() {
return;
}
// Only a rotation comes back around every whole turn, while a translation keeps going
let shift = if gradient_cyclic { fraction.rem_euclid(1.) } else { fraction };
if shift == 0. {
return;
}
self.materialize_default_positions(gradient_cyclic);
if let Some(positions) = self.0.iter_attribute_values_mut::<f64>(ATTR_POSITION) {
for position in positions {
// A non-finite position has no placement to shift, and wrapping one would land it on NaN
if !position.is_finite() {
continue;
}
*position = if gradient_cyclic {
// Wrapping into the range first lands stops at 0 and 1, the same circle point,
// on an identical value rather than rounding apart into an unstable order
(position.rem_euclid(1.) + shift).rem_euclid(1.)
} else {
*position + shift
};
}
}
// Rotation carries stops across the seam and reorders the list, but translation leaves the order alone
if gradient_cyclic {
self.sort(gradient_cyclic);
}
self.elide_default_attributes(gradient_cyclic);
}
/// Multiplies every stop's distance from `pivot` by `factor`, a negative one mirroring the ramp across it. A cyclic ramp wraps
/// the results back around the circle its positions live on. A non-cyclic ramp lets them leave the range and samples through.
pub fn stretch_positions(&mut self, factor: f64, pivot: f64, gradient_cyclic: bool) {
if !factor.is_finite() || !pivot.is_finite() || factor == 1. {
return;
}
self.materialize_default_positions(gradient_cyclic);
if let Some(positions) = self.0.iter_attribute_values_mut::<f64>(ATTR_POSITION) {
for position in positions {
if !position.is_finite() {
continue;
}
let stretched = pivot + (*position - pivot) * factor;
*position = if gradient_cyclic { stretched.rem_euclid(1.) } else { stretched };
}
}
// A mirrored ramp comes out in descending order, which the row reversal undoes
if factor < 0. {
self.0 = self.mirrored_rows(gradient_cyclic);
}
// Wrapping can carry stops across the seam and reorder them
if gradient_cyclic {
self.sort(gradient_cyclic);
}
self.elide_default_attributes(gradient_cyclic);
}
pub fn map_colors<F: Fn(&Color) -> Color>(&self, f: F) -> Self {
@@ -1476,15 +1602,19 @@ impl Gradient {
}
if count == 1 {
return vec![(stops[0].position, stops[0].color, Some(sanitized_midpoint(stops[0].midpoint)))];
let sample = vec![(stops[0].position, stops[0].color, Some(sanitized_midpoint(stops[0].midpoint)))];
return clip_samples_to_unit_range(&sample);
}
let mut result = match settings.interpolation {
let samples = match settings.interpolation {
GradientInterpolation::Stepped => stepped_samples(&stops, settings.cyclic),
GradientInterpolation::Linear => linear_samples(&stops, settings),
GradientInterpolation::Smooth => smooth_samples(&stops, settings),
};
// A shifted non-cyclic ramp can leave stops outside the range, which the curve still runs through
let mut result = clip_samples_to_unit_range(&samples);
// If every midpoint is 0.5 (or within epsilon), turn all midpoints to None
if result.iter().all(|(_, _, midpoint)| matches!(midpoint, Some(m) if (m - 0.5).abs() < 1e-6)) {
result.iter_mut().for_each(|(_, _, midpoint)| *midpoint = None);
@@ -1682,8 +1812,10 @@ pub struct GradientSettings {
pub interpolation: GradientInterpolation,
}
impl From<&Item<Gradient>> for GradientSettings {
fn from(item: &Item<Gradient>) -> Self {
impl GradientSettings {
/// The whole-ramp settings an item carries in its attributes beside a gradient element, defaulting each absent one.
/// Generic over the element type so callers holding an unresolved item can still read them.
pub fn from_item_attributes<T>(item: &Item<T>) -> Self {
Self {
spread: item.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD),
cyclic: item.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC),
@@ -1692,6 +1824,34 @@ impl From<&Item<Gradient>> for GradientSettings {
interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION),
}
}
/// The whole-ramp settings a lane source carries at `index` beside a gradient element, defaulting each absent one.
pub fn from_lane_attributes<S: LaneSource>(source: &S, index: usize) -> Self {
Self {
spread: source.attr::<GradientSpreadAttr>(index),
cyclic: source.attr::<GradientCyclicAttr>(index),
space: source.attr::<GradientSpaceAttr>(index),
hue_direction: source.attr::<GradientHueDirectionAttr>(index),
interpolation: source.attr::<GradientInterpolationAttr>(index),
}
}
/// The whole-ramp settings a list carries at `index` beside a gradient element, defaulting each absent one.
pub fn from_list_row_attributes<T>(list: &List<T>, index: usize) -> Self {
Self {
spread: list.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, index),
cyclic: list.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC, index),
space: list.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index),
hue_direction: list.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION, index),
interpolation: list.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION, index),
}
}
}
impl From<&Item<Gradient>> for GradientSettings {
fn from(item: &Item<Gradient>) -> Self {
Self::from_item_attributes(item)
}
}
impl<C> From<&GradientRamp<C>> for GradientSettings {
@@ -2458,25 +2618,26 @@ mod tests {
#[test]
fn non_compliant_positions_normalize_for_sampling_and_rendering() {
// Stored positions stay as authored, but consumers see them clamped to the 0 to 1 range and sorted
// Stored positions stay as authored and consumers sort them, with the bake clipped to the range the renderers accept
let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK, Color::RED]);
gradient.set_positions(&[1.5, 0.4, -0.5]);
assert_eq!(gradient.positions(false), vec![1.5, 0.4, -0.5]);
let sample_positions: Vec<f64> = gradient
.interpolated_samples(GradientSettings {
space: GradientSpace::RgbGamma,
..Default::default()
})
.iter()
.map(|(position, ..)| *position)
.collect();
let settings = GradientSettings {
space: GradientSpace::RgbGamma,
..Default::default()
};
let sample_positions: Vec<f64> = gradient.interpolated_samples(settings).iter().map(|(position, ..)| *position).collect();
assert!(sample_positions.windows(2).all(|pair| pair[0] <= pair[1]), "samples must ascend: {sample_positions:?}");
assert_eq!(sample_positions.first(), Some(&0.));
assert_eq!(sample_positions.last(), Some(&1.));
assert_eq!(gradient.evaluate(0., Default::default()), Color::RED);
assert_eq!(gradient.evaluate(1., Default::default()), Color::WHITE);
// The outermost stops lie beyond the range, so the ends interpolate toward them instead of adopting their colors
let start = gradient.evaluate(0., settings);
let end = gradient.evaluate(1., settings);
assert!(max_gamma_channel_deviation(start, Color::RED.lerp_gamma_srgb(&Color::BLACK, 0.5 / 0.9)) < 1e-6, "got {start:?}");
assert!(max_gamma_channel_deviation(end, Color::BLACK.lerp_gamma_srgb(&Color::WHITE, 0.6 / 1.1)) < 1e-6, "got {end:?}");
}
#[test]
@@ -2751,4 +2912,177 @@ mod tests {
"the wrap handle should flip in place while interval midpoints shift and flip"
);
}
#[test]
fn shift_rotates_a_cyclic_ramp_back_onto_the_even_distribution() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE, Color::WHITE]);
assert!(!gradient.has_position_attribute(), "an even distribution starts elided");
gradient.shift_positions(0.25, true);
let colors: Vec<Color> = (0..gradient.len()).filter_map(|index| gradient.color(index)).collect();
assert_eq!(colors, vec![Color::WHITE, Color::RED, Color::GREEN, Color::BLUE], "a quarter turn should rotate the stops by one");
assert!(!gradient.has_position_attribute(), "landing back on the even distribution should re-elide the positions");
}
#[test]
fn cyclic_shift_keeps_stops_at_both_ends_together_and_in_order() {
// Stops at 0 and 1 occupy one point on the circle, so every shift has to place them together and break the
// resulting tie the same way, or the render flickers as the fraction sweeps
let mut gradient = Gradient::from(vec![Color::RED, Color::BLUE]);
gradient.set_positions(&[0., 1.]);
// Decimal steps, as a slider produces them, since a dyadic fraction would wrap exactly either way
for step in 1..100 {
let mut shifted = gradient.clone();
shifted.shift_positions(step as f64 * 0.01, true);
let positions = shifted.positions(true);
assert_eq!(positions[0], positions[1], "both ends should land on an identical position, got {positions:?}");
assert_eq!(shifted.color(0), Some(Color::RED), "the tie should keep the original stop order");
}
}
#[test]
fn stops_shifted_out_of_range_sample_through_instead_of_piling_at_the_edge() {
let settings = GradientSettings {
space: GradientSpace::RgbGamma,
..Default::default()
};
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE]);
gradient.shift_positions(-0.6, false);
// The red stop slid to -0.6 and the green to -0.1, so the range's start lands a fifth of the way from green to blue
let expected = Color::GREEN.lerp_gamma_srgb(&Color::BLUE, 0.2);
let edge = gradient.evaluate(0., settings);
assert!(
max_gamma_channel_deviation(edge, expected) < 1e-6,
"the start should interpolate through the stops that slid out, got {edge:?}"
);
let samples = gradient.interpolated_samples(settings);
assert!(
samples.iter().all(|&(position, ..)| (0. ..=1.).contains(&position)),
"the bake should carry no offsets outside the range"
);
let &(first_position, first_color, _) = samples.first().expect("a three stop ramp bakes samples");
assert_eq!(first_position, 0.);
assert!(
max_gamma_channel_deviation(first_color, expected) < SAMPLE_THRESHOLD,
"the baked start should match the sampled start rather than the stop that slid out, got {first_color:?}"
);
}
#[test]
fn shift_translates_a_non_cyclic_ramp_and_stays_reversible() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE]);
gradient.shift_positions(0.25, false);
assert_eq!(gradient.positions(false), vec![0.25, 0.75, 1.25], "stops should slide past the end rather than wrapping to the front");
let colors: Vec<Color> = (0..gradient.len()).filter_map(|index| gradient.color(index)).collect();
assert_eq!(colors, vec![Color::RED, Color::GREEN, Color::BLUE], "a translation should leave the stop order alone");
gradient.shift_positions(-0.25, false);
assert_eq!(
gradient.positions(false),
vec![0., 0.5, 1.],
"the out-of-range position should survive so shifting back restores the ramp"
);
assert!(!gradient.has_position_attribute(), "landing back on the even distribution should re-elide the positions");
}
#[test]
fn shift_carries_midpoints_with_their_stops_and_ignores_whole_turns() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE, Color::WHITE]);
gradient.set_midpoints(&[0.8, 0.5, 0.5, 0.5]);
let untouched = gradient.clone();
gradient.shift_positions(1., true);
assert_eq!(gradient, untouched, "a whole turn should leave the ramp exactly as it was");
gradient.shift_positions(0.25, true);
assert_eq!(gradient.color(1), Some(Color::RED));
assert_eq!(gradient.midpoints(), vec![0.5, 0.8, 0.5, 0.5], "red's midpoint should follow it to its new index");
}
#[test]
fn stretch_spreads_stops_about_the_pivot_and_stays_reversible() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE]);
gradient.stretch_positions(2., 0.5, false);
assert_eq!(gradient.positions(false), vec![-0.5, 0.5, 1.5], "the stop on the pivot should stay put while the rest spread out");
let colors: Vec<Color> = (0..gradient.len()).filter_map(|index| gradient.color(index)).collect();
assert_eq!(colors, vec![Color::RED, Color::GREEN, Color::BLUE], "a positive factor should leave the stop order alone");
gradient.stretch_positions(0.5, 0.5, false);
assert_eq!(
gradient.positions(false),
vec![0., 0.5, 1.],
"the out-of-range positions should survive so the reciprocal restores the ramp"
);
assert!(!gradient.has_position_attribute(), "landing back on the even distribution should re-elide the positions");
}
#[test]
fn negative_stretch_matches_reversal_when_mirrored_about_the_middle() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE]);
gradient.set_midpoints(&[0.25, 0.125, 0.5]);
let mut mirrored = gradient.clone();
mirrored.stretch_positions(-1., 0.5, false);
assert_eq!(mirrored, gradient.reversed(false), "mirroring about the middle is exactly a reversal");
assert_eq!(
mirrored.midpoints(),
vec![0.875, 0.75, 0.5],
"each interval's midpoint should flip and move to the stop now preceding it"
);
}
#[test]
fn negative_stretch_mirrors_across_an_off_center_pivot() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE]);
gradient.stretch_positions(-1., 0.25, false);
let colors: Vec<Color> = (0..gradient.len()).filter_map(|index| gradient.color(index)).collect();
assert_eq!(colors, vec![Color::BLUE, Color::GREEN, Color::RED], "mirroring should reverse the stop order");
assert_eq!(
gradient.positions(false),
vec![-0.5, 0., 0.5],
"each stop should land as far from the pivot as it started, on the other side"
);
}
#[test]
fn cyclic_stretch_mirrors_around_the_circle_and_re_elides() {
let mut gradient = Gradient::from(vec![Color::RED, Color::GREEN, Color::BLUE, Color::WHITE]);
assert!(!gradient.has_position_attribute(), "an even distribution starts elided");
// Mirroring about the ramp's start sends every other stop the long way around the loop
gradient.stretch_positions(-1., 0., true);
let colors: Vec<Color> = (0..gradient.len()).filter_map(|index| gradient.color(index)).collect();
assert_eq!(
colors,
vec![Color::RED, Color::WHITE, Color::BLUE, Color::GREEN],
"the stop on the pivot should stay while the rest wrap past it"
);
assert!(!gradient.has_position_attribute(), "landing back on the even distribution should re-elide the positions");
}
#[test]
fn a_lone_stop_shifted_out_of_range_still_bakes_inside_it() {
let mut gradient = Gradient::from(vec![Color::RED]);
gradient.shift_positions(2., false);
let samples = gradient.interpolated_samples(Default::default());
let positions: Vec<f64> = samples.iter().map(|(position, ..)| *position).collect();
assert!(
positions.iter().all(|position| (0. ..=1.).contains(position)),
"renderers only accept offsets from 0 to 1, got {positions:?}"
);
assert_eq!(samples.first().map(|(_, color, _)| *color), Some(Color::RED), "the lone stop's color should still fill the ramp");
}
}

View File

@@ -1257,6 +1257,48 @@ fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: IList<f64>
gradient
}
/// Reverses the order of each gradient's stops, moving the color at the start of the ramp to the end and vice versa.
#[node_macro::node(category("Gradient"))]
fn gradient_reverse(_: impl Ctx, (gradient, cyclic): (Gradient, Attr<GradientCyclicAttr>)) -> Gradient {
gradient.reversed(cyclic.0)
}
/// Shifts every stop along each gradient's ramp, sliding the colors within the gradient without moving the gradient itself.
///
/// The fraction is measured against the whole ramp. A cyclic gradient spins, wrapping past the end back around to the start so 1 is a full turn that lands where it began. A gradient that isn't cyclic has no loop to spin around, so its stops slide off the end and keep going, leaving the visible ramp to blend between whichever colors still span it.
#[node_macro::node(category("Gradient"))]
fn gradient_shift(
_: impl Ctx,
(mut gradient, cyclic): (Gradient, Attr<GradientCyclicAttr>),
#[range]
#[soft(-1..1)]
fraction: f64,
) -> Gradient {
gradient.shift_positions(fraction, cyclic.0);
gradient
}
/// Stretches or squeezes the spacing of each gradient's stops around a pivot, spreading the colors within the gradient without moving the gradient itself.
///
/// The factor multiplies every stop's distance from the pivot, so 2 spreads the ramp over twice its span while 0.5 packs it into half. A negative factor mirrors the stops across the pivot, reversing the order of the colors.
///
/// The pivot is the one point that stays put, measured against the whole ramp from 0 at the start to 1 at the end.
#[node_macro::node(category("Gradient"))]
fn gradient_stretch(
_: impl Ctx,
(mut gradient, cyclic): (Gradient, Attr<GradientCyclicAttr>),
#[default(1.)]
#[unit("x")]
factor: f64,
#[default(0.5)]
#[range]
#[soft(0..1)]
pivot: f64,
) -> Gradient {
gradient.stretch_positions(factor, pivot, cyclic.0);
gradient
}
/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear. Colors between stops interpolate in the gradient's `gradient_space` color space.
#[node_macro::node(category("Color"))]
fn evaluate_gradient(