mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 19:08:05 +08:00
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:
committed by
Dennis Kobert
parent
344535d143
commit
2baf9a8192
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user