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 GitHub
parent c75c6c9695
commit ceb2de51fb
7 changed files with 451 additions and 78 deletions

View File

@@ -1,4 +1,4 @@
use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, gradient_settings_at, 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::SRGBA8;
use core_types::list::List;
@@ -10,7 +10,7 @@ use graphic_types::vector_types::gradient::GradientForm;
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 {
@@ -95,7 +95,7 @@ impl RenderExt for List<Gradient> {
let Some(stops) = self.element(0) else { return 0 };
let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0);
let local_gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let settings = gradient_settings_at(self, 0);
let settings = GradientSettings::from_list_row_attributes(self, 0);
let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);

View File

@@ -14,8 +14,8 @@ use core_types::transform::Footprint;
use core_types::uuid::{NodeId, generate_uuid};
use core_types::{
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT,
ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING,
ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, ATTR_TRANSFORM,
ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN,
ATTR_TRANSFORM,
};
use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
@@ -496,23 +496,12 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend {
}
}
/// The whole-ramp settings attributes carried by the gradient at `index` of the list.
pub(crate) fn gradient_settings_at(list: &List<Gradient>, index: usize) -> GradientSettings {
GradientSettings {
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),
}
}
fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;
let gradient_form: GradientForm = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0);
let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let settings = gradient_settings_at(gradient_list, 0);
let settings = GradientSettings::from_list_row_attributes(gradient_list, 0);
let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
@@ -2195,7 +2184,7 @@ impl Render for List<Gradient> {
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index);
let settings = gradient_settings_at(self, index);
let settings = GradientSettings::from_list_row_attributes(self, index);
let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" };
render.leaf_tag(tag, |attributes| {
if let Some((min, size)) = thumbnail_rect {
@@ -2288,7 +2277,7 @@ impl Render for List<Gradient> {
let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let settings = gradient_settings_at(self, index);
let settings = GradientSettings::from_list_row_attributes(self, index);
let (samples, span) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
let stops = peniko_color_stops(&samples);

View File

@@ -849,6 +849,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,
@@ -1207,16 +1248,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)?;
@@ -1251,7 +1293,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()
@@ -1267,8 +1309,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| {
@@ -1286,7 +1335,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 {
@@ -1475,15 +1597,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);
@@ -1681,8 +1807,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),
@@ -1691,6 +1819,23 @@ impl From<&Item<Gradient>> for GradientSettings {
interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION),
}
}
/// 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 {
@@ -2457,25 +2602,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]
@@ -2750,4 +2896,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");
}
}